diff --git a/.github/workflows/admin-api.yml b/.github/workflows/admin-api.yml new file mode 100644 index 000000000..6a29d1655 --- /dev/null +++ b/.github/workflows/admin-api.yml @@ -0,0 +1,30 @@ +name: Admin API document + +# Lints docs/api/admin-v1.openapi.json as OpenAPI 3.1 (admin-public-api §2.1). +# That the document and appinfo/routes.php agree is checked by PHPUnit +# (tests/Unit/Contract/AdminApiContractTest.php) in the code quality workflow. + +on: + push: + branches: [main, development] + paths: ["docs/api/**", ".github/workflows/admin-api.yml"] + pull_request: + branches: [main, development, beta] + paths: ["docs/api/**", ".github/workflows/admin-api.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npx --yes @redocly/cli@1 lint docs/api/admin-v1.openapi.json diff --git a/.github/workflows/browser-extension.yml b/.github/workflows/browser-extension.yml new file mode 100644 index 000000000..adf0f8a69 --- /dev/null +++ b/.github/workflows/browser-extension.yml @@ -0,0 +1,43 @@ +name: Browser extension + +# Builds the extension for Chromium and Firefox and loads each package in a +# headless browser to check its background starts and answers the popup +# (clients-browser-builds). Safari needs a macOS runner and stays an open task. + +on: + push: + branches: [main, development] + paths: ["browser-extension/**", "src/crypto/**", "src/totp/**", ".github/workflows/browser-extension.yml"] + pull_request: + branches: [main, development, beta] + paths: ["browser-extension/**", "src/crypto/**", "src/totp/**", ".github/workflows/browser-extension.yml"] + workflow_dispatch: + +jobs: + build-and-load: + runs-on: ubuntu-latest + # A build and two browser starts; minutes at most. + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci --ignore-scripts + - run: npm run build:extension + - name: Chromium loads the package + run: | + npx playwright install --with-deps chromium + node browser-extension/load-check/chromium.mjs + - name: Firefox loads the package + run: | + npm install --no-save selenium-webdriver@4 + firefox --version + geckodriver --version + node browser-extension/load-check/firefox.mjs + - uses: actions/upload-artifact@v4 + with: + name: browser-extension + path: browser-extension/dist/ + retention-days: 14 diff --git a/.github/workflows/cli-macos.yml b/.github/workflows/cli-macos.yml new file mode 100644 index 000000000..2e714e67b --- /dev/null +++ b/.github/workflows/cli-macos.yml @@ -0,0 +1,62 @@ +name: CLI on macOS + +# Checks `keepiq ssh-agent` on macOS, where nobody on the team has a Mac to +# try it by hand (keepiq#1042). With no secrets and no Nextcloud server it: +# +# - runs the CLI's Go tests, including the real-sshd integration test and +# the `eval` test, and fails if either of those is skipped +# - runs cli/scripts/ssh-agent-e2e.sh: the README's recipe in a real shell +# against a fake Keepiq (cli/internal/fakevault) and a throwaway sshd +# started as the runner user on a high port, never the system sshd +# - installs the README's launchd plist in the runner user's home and checks +# the agent launchd starts (same script, KEEPIQ_E2E_LAUNCHD=1) + +on: + pull_request: + branches: [main, development, beta] + paths: ["cli/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/cli-macos.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cli-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + ssh-agent: + runs-on: macos-latest + # The Go tests take a few minutes (RSA-4096 keys); the e2e script under two. + timeout-minutes: 25 + defaults: + run: + working-directory: cli + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache-dependency-path: cli/go.sum + - name: Versions + run: | + sw_vers + uname -m + go version + ssh -V + command -v sshd ssh-add git + id + - run: go vet ./... + - name: Go tests, with the ssh-agent tests required to run + run: | + set -o pipefail + go test -count=1 -v ./... 2>&1 | tee "$RUNNER_TEMP/go-test.log" | grep -E '^(--- |ok|FAIL|PASS)' + for t in TestRealSSHThroughTheAgent TestEvalReturnsAndTheAgentKeepsServing TestVaultUnlockerReadsOnlyCiphertext; do + grep -q -- "--- PASS: $t " "$RUNNER_TEMP/go-test.log" || { echo "::error::$t did not pass (skipped or missing)"; exit 1; } + done + - name: README recipe and launchd plist, end to end + # A hang in `eval "$(keepiq ssh-agent)"` (keepiq#1022) ends here, not at the job limit. + timeout-minutes: 10 + env: + KEEPIQ_E2E_LAUNCHD: "1" + run: bash scripts/ssh-agent-e2e.sh diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index ae1c70870..632df2ae2 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -1,23 +1,27 @@ name: CLI Release # Builds the keepiq-cli single static binary for every supported platform. -# On a tag push it also uploads the artifacts to the GitHub release. The CLI is -# stdlib-only Go, so the matrix is a plain cross-compile — no PHP/Node needed. +# On a tag push it also uploads the artifacts to the GitHub release, with a +# SHA256SUMS file that the GitHub Action and the GitLab template check every +# download against, and pushes the container image ghcr.io/conductionnl/keepiq-cli +# at the same version. The CLI is pure Go: the standard library, sdk/go in this +# repository, and the Go project's golang.org/x/crypto and golang.org/x/sys for +# the SSH agent. The matrix is a plain cross-compile with no PHP or Node needed. on: push: branches: [main, development] - paths: ["cli/**"] + paths: ["cli/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/cli-release.yml"] tags: ["cli-v*"] pull_request: branches: [main, development, beta] - paths: ["cli/**"] + paths: ["cli/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/cli-release.yml"] workflow_dispatch: jobs: test: runs-on: ubuntu-latest - # No observed runs yet; a Go vet+test of a stdlib-only CLI is minutes at most. + # No observed runs yet; vet, test and govulncheck of the CLI are minutes at most. timeout-minutes: 20 defaults: run: @@ -26,14 +30,20 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.25.x" - run: go vet ./... + # The SSH agent's integration test runs the real ssh client against a + # throwaway sshd on localhost (cli-ssh-agent). + - name: Install OpenSSH for the agent integration test + run: sudo apt-get update -qq && sudo apt-get install -y -qq openssh-server openssh-client && sudo mkdir -p /run/sshd - run: go test ./... + # The CLI now has dependencies: check them for known vulnerabilities. + - run: go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... build: needs: test runs-on: ubuntu-latest - # No observed runs yet; a stdlib-only Go cross-compile is minutes at most. + # No observed runs yet; a pure-Go cross-compile is minutes at most. timeout-minutes: 20 defaults: run: @@ -51,7 +61,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.25.x" - name: Build static binary env: GOOS: ${{ matrix.target.goos }} @@ -72,3 +82,79 @@ jobs: uses: softprops/action-gh-release@v2 with: files: ${{ env.ARTIFACT }} + + checksums: + # One SHA256SUMS over every binary, attached to the release. On a branch or + # pull request it is built and uploaded as an artifact only (the dry run). + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/download-artifact@v4 + with: + pattern: keepiq-* + merge-multiple: true + path: dist + - name: Write SHA256SUMS + working-directory: dist + run: | + sha256sum keepiq-* > SHA256SUMS + cat SHA256SUMS + test "$(wc -l < SHA256SUMS)" -eq 6 + - uses: actions/upload-artifact@v4 + with: + name: SHA256SUMS + path: dist/SHA256SUMS + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/cli-v') + uses: softprops/action-gh-release@v2 + with: + files: dist/SHA256SUMS + + image: + # ghcr.io/conductionnl/keepiq-cli: the linux binaries on a distroless static + # base, multi-arch, tagged with the release version. Pushed on a cli-v tag; + # built without pushing otherwise (the dry run). + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: keepiq-linux-* + merge-multiple: true + path: cli/dist + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Log in to ghcr.io + if: startsWith(github.ref, 'refs/tags/cli-v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Image tags + id: tags + run: | + version="${GITHUB_REF_NAME#cli-v}" + echo "tags=ghcr.io/conductionnl/keepiq-cli:${version},ghcr.io/conductionnl/keepiq-cli:latest" >> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v6 + with: + context: cli + file: cli/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ startsWith(github.ref, 'refs/tags/cli-v') }} + load: false + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.source=https://github.com/ConductionNL/keepiq + org.opencontainers.image.description=keepiq CLI, the zero-knowledge Keepiq command-line client + org.opencontainers.image.licenses=EUPL-1.2 + - name: Smoke test the amd64 image + run: | + docker buildx build --platform linux/amd64 --load -t keepiq-cli:smoke -f cli/Dockerfile cli + docker run --rm keepiq-cli:smoke --version diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index ace9a852f..fb2d083d1 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -110,9 +110,21 @@ permissions: jobs: quality: - # Skips the standing "development → beta" sync PR, which would otherwise - # re-run the whole pipeline on every merge into development. - if: (github.event_name != 'pull_request' || github.head_ref != 'development') + # THE PROMOTION PR RUNS (keepiq#882). A pull request whose head is + # `development` is skipped only when it targets something other than + # `beta`. The standing "development to beta" sync PR used to be skipped + # too, and the shared workflow keeps Playwright off ordinary `development` + # traffic (`e2e-promotion-only`, default true) and runs it on PRs into + # `beta` and `main` instead. The two rules together meant the promotion + # into `beta` ran nothing, so release.yml cut the beta artifact before + # Playwright had seen the promoted tree. + # + # The price is a full run each time a merge into `development` updates the + # sync PR. The concurrency group above cancels a superseded PR run, so only + # the newest one finishes. The fast tier on that run repeats what the push + # to `development` already checked; the shared workflow has no input to run + # only the heavy tier. + if: (github.event_name != 'pull_request' || github.head_ref != 'development' || github.base_ref == 'beta') uses: ConductionNL/.github/.github/workflows/quality.yml@main with: app-name: keepiq @@ -190,7 +202,10 @@ jobs: # 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. + # `tests/e2e/workflows/integrations-page.spec.ts` fails wherever + # Playwright runs: the promotion PRs into `beta` and `main`, the nightly + # dispatch, and a manual dispatch. Ordinary `development` pushes and PRs + # do not run Playwright (`e2e-promotion-only`). # `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"}]' diff --git a/.github/workflows/docs-media.yml b/.github/workflows/docs-media.yml new file mode 100644 index 000000000..58c758265 --- /dev/null +++ b/.github/workflows/docs-media.yml @@ -0,0 +1,85 @@ +name: Docs media + +# Captures the screenshots and videos of the browser extension for the user +# documentation (browser-extension/capture/). One Nextcloud with Keepiq and +# OpenRegister, seeded with the development vault; the capture fills it with +# demo items and visits local demo sites only. The media is uploaded as an +# artifact; committing it to docs/static/media/browser-extension/ stays a +# reviewed step. + +on: + push: + branches: [development] + paths: ["browser-extension/capture/**", ".github/workflows/docs-media.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + capture: + runs-on: ubuntu-latest + # Two builds, one Nextcloud install and two short browser runs. + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + path: keepiq + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ConductionNL/openregister + ref: development + path: openregister + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + - uses: actions/setup-node@v4 + with: + # npm 11, which both lockfiles need; Node 22 ships npm 10. + node-version: "24" + + - name: Build Keepiq and the extension + working-directory: keepiq + run: | + composer install --no-dev --prefer-dist --no-interaction + npm ci + npm run build + npm run build:extension + - name: Build OpenRegister + working-directory: openregister + run: | + composer install --no-dev --prefer-dist --no-interaction + npm ci + npm run build + + - name: Start the instance + run: docker compose -p kq-media -f keepiq/browser-extension/capture/compose.yaml up -d + - name: Provision it + run: bash keepiq/browser-extension/capture/setup.sh kq-media keepiq openregister + + - name: Chromium screenshots and videos + working-directory: keepiq + run: | + npx playwright install --with-deps chromium + node browser-extension/capture/chromium.mjs + - name: Firefox screenshots + working-directory: keepiq + run: | + npm install --no-save selenium-webdriver@4 + firefox --version + geckodriver --version + node browser-extension/capture/firefox.mjs + + - name: Server log + if: failure() + run: docker exec kq-media-nc-1 tail -n 200 /var/www/html/data/nextcloud.log || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: docs-media + path: keepiq/browser-extension/capture/out/media/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml new file mode 100644 index 000000000..71e6be158 --- /dev/null +++ b/.github/workflows/extension-release.yml @@ -0,0 +1,230 @@ +name: Extension release + +# Builds, tests and packs the browser extension per store target +# (extension-store-release). On pull requests and pushes it proves the +# packages build twice to the same bytes and lints the Firefox one; store +# credentials are not reachable there. On an `extension-v` tag a +# second job, bound to the protected `extension-stores` environment that a +# maintainer approves, submits the packages to the Chrome Web Store, Firefox +# Add-ons and Edge Add-ons and attaches them to a GitHub release. +# +# Firefox Add-ons signs the listed package only after its review, which takes +# hours to days. A third job, `attach-amo-signed`, runs daily and on demand: once +# a released version is approved, it downloads the file Firefox Add-ons signed, +# checks it holds exactly the files of the package the release job built, and +# attaches it to the same GitHub release for administrators who host it +# themselves. It reads only public Firefox Add-ons data, so it needs no store +# credential and no environment approval. +# +# Store credentials and who approves the environment: docs/browser-extension-release.md. + +on: + push: + branches: [main, development] + tags: ["extension-v*"] + paths: ["browser-extension/**", "src/crypto/**", "src/totp/**", "src/policy/**", "src/health/**", ".github/workflows/extension-release.yml"] + pull_request: + branches: [main, development, beta] + paths: ["browser-extension/**", "src/crypto/**", "src/totp/**", "src/policy/**", "src/health/**", ".github/workflows/extension-release.yml"] + schedule: + # Daily check for Firefox Add-ons approvals of released versions. + - cron: "23 5 * * *" + workflow_dispatch: + inputs: + amo_version: + description: "Attach the Firefox Add-ons signed file for this released version (for example 1.2.0); leave empty to build" + required: false + default: "" + +permissions: + contents: read + +jobs: + build: + if: github.event_name != 'schedule' && (github.event_name != 'workflow_dispatch' || inputs.amo_version == '') + runs-on: ubuntu-latest + # Tests, two builds per target, a lint and a zip; minutes at most. + timeout-minutes: 20 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - run: npm ci --ignore-scripts + - name: Extension tests + run: npx vitest run tests/extension + - name: Version from the tag + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/extension-v* ]]; then + v="${GITHUB_REF#refs/tags/extension-v}" + else + v="$(node -p "require('./browser-extension/manifest.json').version")" + fi + echo "version=$v" >> "$GITHUB_OUTPUT" + - name: Build each target twice and compare + env: + EXTENSION_VERSION: ${{ steps.version.outputs.version }} + run: | + for run in a b; do + for target in chrome firefox; do + node browser-extension/build.mjs --target "$target" --outdir "out/$run" + done + done + ( cd out/a && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum ) > a.sha + ( cd out/b && find . -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum ) > b.sha + diff -u a.sha b.sha + - name: Lint the Firefox package + run: npx --yes web-ext@8.3.0 lint --source-dir out/a/firefox --warnings-as-errors=false + - name: Pack + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p packages + for dir in chromium firefox; do + ( cd "out/a/$dir" \ + && find . -exec touch -h -d '1980-01-01T00:00:00Z' {} + \ + && find . -type f | LC_ALL=C sort | zip -X -q -@ "../../../packages/keepiq-$dir-$VERSION.zip" ) + done + # Firefox Add-ons reviews bundled code against its source. + git archive --format=zip -o "packages/keepiq-extension-source-$VERSION.zip" HEAD \ + browser-extension src/crypto src/totp src/policy src/health package.json package-lock.json + sha256sum packages/* | tee packages/SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: keepiq-extension-${{ steps.version.outputs.version }} + path: packages/ + retention-days: 30 + + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/extension-v') + runs-on: ubuntu-latest + # Uploads and store API calls; store review itself happens later. + timeout-minutes: 30 + environment: extension-stores + permissions: + contents: write + env: + VERSION: ${{ needs.build.outputs.version }} + steps: + - uses: actions/download-artifact@v4 + with: + name: keepiq-extension-${{ needs.build.outputs.version }} + path: packages + - name: Chrome Web Store + env: + EXTENSION_ID: ${{ vars.CHROME_EXTENSION_ID }} + CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }} + REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }} + run: | + npx --yes chrome-webstore-upload-cli@3 upload --auto-publish \ + --source "packages/keepiq-chromium-$VERSION.zip" \ + --extension-id "$EXTENSION_ID" --client-id "$CLIENT_ID" \ + --client-secret "$CLIENT_SECRET" --refresh-token "$REFRESH_TOKEN" + - name: Firefox Add-ons (listed) + env: + AMO_JWT_ISSUER: ${{ secrets.AMO_JWT_ISSUER }} + AMO_JWT_SECRET: ${{ secrets.AMO_JWT_SECRET }} + run: | + mkdir -p firefox && unzip -q "packages/keepiq-firefox-$VERSION.zip" -d firefox + npx --yes web-ext@8.3.0 sign --channel listed --source-dir firefox \ + --upload-source-code "packages/keepiq-extension-source-$VERSION.zip" \ + --api-key "$AMO_JWT_ISSUER" --api-secret "$AMO_JWT_SECRET" \ + --approval-timeout 0 --artifacts-dir signed + - name: Edge Add-ons + env: + PRODUCT_ID: ${{ vars.EDGE_PRODUCT_ID }} + CLIENT_ID: ${{ secrets.EDGE_CLIENT_ID }} + API_KEY: ${{ secrets.EDGE_API_KEY }} + run: | + api="https://api.addons.microsoftedge.microsoft.com/v1/products/$PRODUCT_ID/submissions" + auth=(-H "Authorization: ApiKey $API_KEY" -H "X-ClientID: $CLIENT_ID") + op=$(curl -fsS -D - -o /dev/null "${auth[@]}" -H "Content-Type: application/zip" \ + --data-binary "@packages/keepiq-chromium-$VERSION.zip" "$api/draft/package" \ + | awk 'tolower($1)=="location:"{print $2}' | tr -d '\r') + for i in $(seq 1 30); do + status=$(curl -fsS "${auth[@]}" "$api/draft/package/operations/$op" | jq -r .status) + [ "$status" = "Succeeded" ] && break + [ "$status" = "Failed" ] && { echo "Edge package upload failed"; exit 1; } + sleep 10 + done + curl -fsS "${auth[@]}" -X POST -H "Content-Type: application/json" \ + -d "{\"notes\":\"Keepiq $VERSION\"}" "$api" + - name: GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + packages/* + signed/* + + attach-amo-signed: + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.amo_version != '') + runs-on: ubuntu-latest + # A few small downloads per release; the scheduled run looks at the last five. + timeout-minutes: 15 + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + REQUESTED: ${{ inputs.amo_version }} + # The add-on id in the Firefox manifest (browser-extension/manifests/browsers.mjs). + GECKO_ID: keepiq@conduction.nl + steps: + - name: Attach the signed Firefox file to approved releases + run: | + set -euo pipefail + if [ -n "$REQUESTED" ]; then + versions="$REQUESTED" + else + versions=$(gh release list -R "$GITHUB_REPOSITORY" --limit 30 --json tagName \ + -q '[.[].tagName | select(startswith("extension-v")) | ltrimstr("extension-v")] | .[0:5][]') + fi + for v in $versions; do + tag="extension-v$v" + asset="keepiq-firefox-$v-amo-signed.xpi" + if gh release view "$tag" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[].name' | grep -qx "$asset"; then + echo "$tag already holds $asset" + continue + fi + # The public version detail answers only for a listed version that passed review. + code=$(curl -sS -o amo.json -w '%{http_code}' \ + "https://addons.mozilla.org/api/v5/addons/addon/$GECKO_ID/versions/v$v/") + status=$(jq -r '.file.status // empty' amo.json 2>/dev/null || true) + if [ "$code" != "200" ] || [ "$status" != "public" ]; then + echo "::notice::Firefox Add-ons has not approved $v yet (HTTP $code, file status '${status:-none}')" + if [ -n "$REQUESTED" ]; then exit 1; fi + continue + fi + url=$(jq -r '.file.url' amo.json) + expected=$(jq -r '.file.hash' amo.json) + case "$url" in + https://addons.mozilla.org/*) ;; + *) echo "::error::Unexpected download host in $url"; exit 1 ;; + esac + curl -fsSL -o "$asset" "$url" + actual="sha256:$(sha256sum "$asset" | cut -d' ' -f1)" + if [ "$actual" != "$expected" ]; then + echo "::error::$asset hash $actual does not match Firefox Add-ons ($expected)" + exit 1 + fi + # The signed file must be the package this release built, plus the signature. + gh release download "$tag" -R "$GITHUB_REPOSITORY" -p "keepiq-firefox-$v.zip" --clobber + rm -rf built signed && mkdir built signed + unzip -q "keepiq-firefox-$v.zip" -d built + unzip -q "$asset" -d signed + ls signed/META-INF/mozilla.rsa signed/META-INF/cose.sig > /dev/null + ( cd built && find . -type f | LC_ALL=C sort | xargs sha256sum ) > built.sha + ( cd signed && find . -type f ! -path './META-INF/*' | LC_ALL=C sort | xargs sha256sum ) > signed.sha + if ! diff -u built.sha signed.sha; then + echo "::error::$asset does not hold exactly the files of keepiq-firefox-$v.zip" + exit 1 + fi + sha256sum "$asset" > "$asset.sha256" + gh release upload "$tag" -R "$GITHUB_REPOSITORY" "$asset" "$asset.sha256" + echo "Attached $asset to $tag" + done diff --git a/.github/workflows/federation.yml b/.github/workflows/federation.yml new file mode 100644 index 000000000..64930c947 --- /dev/null +++ b/.github/workflows/federation.yml @@ -0,0 +1,99 @@ +name: Federation (two instances) + +# The two-instance test of federated sharing (sharing-federated-recipients +# task 5.1): two Nextcloud 35 containers on one network, each with Keepiq and +# OpenRegister, partners pinned on both sides, then a browser shares a secret +# from A to a user of B, B accepts and reads it, A updates it and revokes it. +# See tests/integration/federation/. + +on: + pull_request: + branches: [main, development, beta] + paths: + - "lib/**" + - "src/**" + - "appinfo/**" + - "tests/integration/federation/**" + - ".github/workflows/federation.yml" + push: + branches: [development] + paths: + - "lib/**" + - "src/**" + - "appinfo/**" + - "tests/integration/federation/**" + - ".github/workflows/federation.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + two-instances: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + path: keepiq + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ConductionNL/openregister + ref: development + path: openregister + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + - uses: actions/setup-node@v4 + with: + # npm 11, which both lockfiles need (engines: npm ^11); Node 22 + # ships npm 10, whose `npm ci` misreads them. + node-version: "24" + + - name: Build Keepiq + working-directory: keepiq + run: | + composer install --no-dev --prefer-dist --no-interaction + npm ci + npm run build + - name: Build OpenRegister + working-directory: openregister + run: | + composer install --no-dev --prefer-dist --no-interaction + npm ci + npm run build + + - name: Start the two instances + run: docker compose -p kq-fed -f keepiq/tests/integration/federation/compose.yaml up -d + - name: Provision both instances and pin the partners + run: bash keepiq/tests/integration/federation/setup.sh kq-fed keepiq openregister + + - name: Install the browser + working-directory: keepiq + run: | + npx playwright install --with-deps chromium + - name: Share, accept, update and revoke across the two instances + working-directory: keepiq + env: + FED_A_URL: http://localhost:8101 + FED_B_URL: http://localhost:8102 + run: npx playwright test --config tests/integration/federation/playwright.config.ts + + - name: Server logs + if: failure() + run: | + for side in a b; do + echo "== instance ${side}" + docker exec "kq-fed-nc-${side}-1" tail -n 200 /var/www/html/data/nextcloud.log || true + done + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: federation-playwright-report + path: | + keepiq/playwright-report-federation/ + keepiq/test-results-federation/ + if-no-files-found: ignore diff --git a/.github/workflows/integrations-kubernetes.yml b/.github/workflows/integrations-kubernetes.yml new file mode 100644 index 000000000..91a817f04 --- /dev/null +++ b/.github/workflows/integrations-kubernetes.yml @@ -0,0 +1,124 @@ +name: Kubernetes operator + +# Tests the operator in integrations/kubernetes on every pull request that +# touches it (unit and envtest, chart lint and snapshots, a kind cluster +# end to end), and releases it on a k8s-v* tag: a signed multi-arch image +# ghcr.io/conductionnl/keepiq-operator and the Helm chart as an OCI artifact in +# ghcr.io/conductionnl/charts. On a pull request the image is built, not pushed. + +on: + push: + branches: [main, development] + paths: ["integrations/kubernetes/**", "sdk/go/**", "sdk/testdata/**", "cli/**", ".github/workflows/integrations-kubernetes.yml"] + tags: ["k8s-v*"] + pull_request: + branches: [main, development, beta] + paths: ["integrations/kubernetes/**", "sdk/go/**", "sdk/testdata/**", "cli/**", ".github/workflows/integrations-kubernetes.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + # envtest starts a real kube-apiserver and etcd; a cold module cache is + # the slow part. + timeout-minutes: 20 + defaults: + run: + working-directory: integrations/kubernetes + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: integrations/kubernetes/go.sum + - run: go vet ./... + - name: envtest binaries + run: | + go run sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.19 use 1.30.0 --bin-dir "$RUNNER_TEMP/envtest" -p path > "$RUNNER_TEMP/assets" + echo "KUBEBUILDER_ASSETS=$(cat "$RUNNER_TEMP/assets")" >> "$GITHUB_ENV" + - run: go test ./... + + chart: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v4 + with: + version: v3.15.4 + - run: bash integrations/kubernetes/test/chart.sh + + kind: + needs: [test, chart] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - uses: azure/setup-helm@v4 + with: + version: v3.15.4 + - uses: helm/kind-action@v1 + with: + install_only: true + - name: Build images + run: | + docker build -f integrations/kubernetes/Dockerfile -t keepiq-operator:e2e . + mkdir -p cli/dist + (cd cli && CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=e2e" -o dist/keepiq-linux-amd64 .) + docker build -f cli/Dockerfile -t keepiq-cli:e2e cli + - run: bash integrations/kubernetes/test/kind.sh + + release: + if: startsWith(github.ref, 'refs/tags/k8s-v') + needs: kind + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Version + id: v + run: echo "version=${GITHUB_REF_NAME#k8s-v}" >> "$GITHUB_OUTPUT" + - name: Chart version matches the tag + run: | + grep -qx "version: ${{ steps.v.outputs.version }}" integrations/kubernetes/charts/keepiq-operator/Chart.yaml + grep -qx "appVersion: \"${{ steps.v.outputs.version }}\"" integrations/kubernetes/charts/keepiq-operator/Chart.yaml + - id: build + uses: docker/build-push-action@v6 + with: + context: . + file: integrations/kubernetes/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + build-args: VERSION=${{ steps.v.outputs.version }} + tags: ghcr.io/conductionnl/keepiq-operator:${{ steps.v.outputs.version }} + labels: | + org.opencontainers.image.source=https://github.com/ConductionNL/keepiq + org.opencontainers.image.licenses=EUPL-1.2 + - uses: sigstore/cosign-installer@v3 + - name: Sign the image (keyless) + run: cosign sign --yes "ghcr.io/conductionnl/keepiq-operator@${{ steps.build.outputs.digest }}" + - uses: azure/setup-helm@v4 + with: + version: v3.15.4 + - name: Push the chart + run: | + helm package integrations/kubernetes/charts/keepiq-operator -d "$RUNNER_TEMP/chart" + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin + helm push "$RUNNER_TEMP/chart/keepiq-operator-${{ steps.v.outputs.version }}.tgz" oci://ghcr.io/conductionnl/charts diff --git a/.github/workflows/integrations-runner.yml b/.github/workflows/integrations-runner.yml new file mode 100644 index 000000000..f644aa59e --- /dev/null +++ b/.github/workflows/integrations-runner.yml @@ -0,0 +1,115 @@ +name: Rotation and sync runner + +# Tests keepiq-runner (integrations/runner) on every pull request that touches +# it, including the PostgreSQL and MySQL connectors against real servers, and +# releases it on a runner-v* tag: static binaries with SHA256SUMS on the +# release, and a cosign-signed multi-arch image ghcr.io/conductionnl/keepiq-runner. +# On a pull request the binaries and the image are built, not published. + +on: + push: + branches: [main, development] + paths: ["integrations/runner/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations-runner.yml"] + tags: ["runner-v*"] + pull_request: + branches: [main, development, beta] + paths: ["integrations/runner/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations-runner.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16-alpine + env: {POSTGRES_PASSWORD: pg-admin-pass} + ports: ["5432:5432"] + options: --health-cmd "pg_isready -U postgres" --health-interval 5s --health-retries 20 + mysql: + image: mysql:8.4 + env: {MYSQL_ROOT_PASSWORD: my-admin-pass} + ports: ["3306:3306"] + options: --health-cmd "mysqladmin ping -uroot -pmy-admin-pass" --health-interval 5s --health-retries 30 + defaults: + run: + working-directory: integrations/runner + env: + KEEPIQ_TEST_POSTGRES: "127.0.0.1:5432 postgres pg-admin-pass" + KEEPIQ_TEST_MYSQL: "127.0.0.1:3306 root my-admin-pass" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: integrations/runner/go.sum + - run: go vet ./... + - run: go test ./... + + build: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Static binaries + working-directory: integrations/runner + run: | + mkdir -p dist + for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + os="${t%/*}"; arch="${t#*/}"; ext=""; [ "$os" = windows ] && ext=".exe" + CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build -trimpath -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" -o "dist/keepiq-runner-$os-$arch$ext" ./cmd/keepiq-runner + done + (cd dist && sha256sum keepiq-runner-* > SHA256SUMS && cat SHA256SUMS) + - uses: actions/upload-artifact@v4 + with: + name: keepiq-runner + path: integrations/runner/dist/* + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/runner-v') + uses: softprops/action-gh-release@v2 + with: + files: integrations/runner/dist/* + + image: + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - name: Log in to ghcr.io + if: startsWith(github.ref, 'refs/tags/runner-v') + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: build + uses: docker/build-push-action@v6 + with: + context: . + file: integrations/runner/Dockerfile + platforms: linux/amd64,linux/arm64 + push: ${{ startsWith(github.ref, 'refs/tags/runner-v') }} + build-args: VERSION=${{ github.ref_name }} + tags: ghcr.io/conductionnl/keepiq-runner:${{ startsWith(github.ref, 'refs/tags/runner-v') && github.ref_name || 'pr' }} + labels: | + org.opencontainers.image.source=https://github.com/ConductionNL/keepiq + org.opencontainers.image.licenses=EUPL-1.2 + - uses: sigstore/cosign-installer@v3 + if: startsWith(github.ref, 'refs/tags/runner-v') + - name: Sign the image (keyless) + if: startsWith(github.ref, 'refs/tags/runner-v') + run: cosign sign --yes "ghcr.io/conductionnl/keepiq-runner@${{ steps.build.outputs.digest }}" diff --git a/.github/workflows/integrations-terraform.yml b/.github/workflows/integrations-terraform.yml new file mode 100644 index 000000000..efd518668 --- /dev/null +++ b/.github/workflows/integrations-terraform.yml @@ -0,0 +1,81 @@ +name: Terraform provider + +# Tests the provider in integrations/terraform-provider-keepiq on pull +# requests (unit tests, and the end-to-end test that drives Terraform and +# fails when a plan or the state holds a value), checks docs/ is generated +# from the current schema, and on a tf-v* tag copies the module to the mirror +# repository ConductionNL/terraform-provider-keepiq, where GoReleaser signs and +# publishes the release for the Terraform and OpenTofu registries. + +on: + push: + branches: [main, development] + paths: ["integrations/terraform-provider-keepiq/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations-terraform.yml"] + tags: ["tf-v*"] + pull_request: + branches: [main, development, beta] + paths: ["integrations/terraform-provider-keepiq/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations-terraform.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: integrations/terraform-provider-keepiq + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache-dependency-path: integrations/terraform-provider-keepiq/go.sum + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.11.4" + terraform_wrapper: false + - run: go vet ./... + - run: KEEPIQ_TF_BIN="$(command -v terraform)" go test ./... + - name: docs/ is current + run: bash scripts/docs.sh "$(command -v terraform)" --check + + mirror: + if: startsWith(github.ref, 'refs/tags/tf-v') + needs: test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Build the mirror tree + run: | + version="${GITHUB_REF_NAME#tf-v}" + sdk="$(git tag --list 'sdk/go/v*' --sort=-v:refname | head -1)" + test -n "$sdk" || { echo "::error::no sdk/go/v* tag: release the Go library first"; exit 1; } + rm -rf "$RUNNER_TEMP/mirror" && cp -r integrations/terraform-provider-keepiq "$RUNNER_TEMP/mirror" + cd "$RUNNER_TEMP/mirror" + go mod edit -dropreplace github.com/ConductionNL/keepiq/sdk/go -require "github.com/ConductionNL/keepiq/sdk/go@${sdk#sdk/go/}" + go mod tidy + go build ./... + echo "VERSION=$version" >> "$GITHUB_ENV" + - name: Push to ConductionNL/terraform-provider-keepiq + env: + DEPLOY_KEY: ${{ secrets.TF_MIRROR_DEPLOY_KEY }} + run: | + test -n "$DEPLOY_KEY" || { echo "::error::TF_MIRROR_DEPLOY_KEY is not set (one-time organisation admin step)"; exit 1; } + mkdir -p ~/.ssh && printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/mirror && chmod 600 ~/.ssh/mirror + export GIT_SSH_COMMAND="ssh -i ~/.ssh/mirror -o StrictHostKeyChecking=accept-new" + git clone git@github.com:ConductionNL/terraform-provider-keepiq.git "$RUNNER_TEMP/out" + rsync -a --delete --exclude .git "$RUNNER_TEMP/mirror/" "$RUNNER_TEMP/out/" + cd "$RUNNER_TEMP/out" + git config user.name "keepiq release" && git config user.email "info@conduction.nl" + git add -A && git commit -m "Release v${VERSION} from ConductionNL/keepiq ${GITHUB_SHA}" || true + git tag "v${VERSION}" + git push origin HEAD "v${VERSION}" diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml new file mode 100644 index 000000000..c1f9673fa --- /dev/null +++ b/.github/workflows/integrations.yml @@ -0,0 +1,107 @@ +name: CI integrations + +# Runs the GitHub Action and the GitLab CI template against a stub Keepiq +# (sdk/testdata/stub_server.py) and a local release directory holding a CLI +# built from this commit plus its SHA256SUMS. integrations/test/run.sh covers +# both, including a tampered binary; the `action` job then uses the action the +# way a workflow does, so the masked export shows in this run's log. + +on: + push: + branches: [main, development] + paths: ["integrations/**", "cli/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations.yml"] + pull_request: + branches: [main, development, beta] + paths: ["integrations/**", "cli/**", "sdk/go/**", "sdk/testdata/**", ".github/workflows/integrations.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + scripts: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install cryptography pyyaml + - name: Build the CLI + working-directory: cli + run: CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=cli-vtest" -o "$RUNNER_TEMP/keepiq-linux-amd64" . + - run: bash integrations/test/run.sh "$RUNNER_TEMP/keepiq-linux-amd64" + - name: Lint the GitLab template + run: | + python - <<'PY' + import yaml + doc = yaml.safe_load(open("integrations/gitlab-ci/keepiq.gitlab-ci.yml")) + assert ".keepiq" in doc and doc[".keepiq"]["before_script"], "no .keepiq before_script" + assert "KEEPIQ_CLI_VERSION" in doc["variables"] + PY + + action: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install cryptography + - name: Local release and stub Keepiq + run: | + mkdir -p "$RUNNER_TEMP/release/cli-vtest" + (cd cli && CGO_ENABLED=0 go build -trimpath -ldflags "-X main.version=cli-vtest" -o "$RUNNER_TEMP/release/cli-vtest/keepiq-linux-amd64" .) + (cd "$RUNNER_TEMP/release/cli-vtest" && sha256sum keepiq-linux-amd64 > SHA256SUMS) + nohup python -m http.server 18088 --bind 127.0.0.1 --directory "$RUNNER_TEMP/release" > /dev/null 2>&1 & + # The password is random and lives only in the stub's memory and argv, + # so the disk check below cannot be fooled by a literal in this repo. + db_value="$(openssl rand -hex 24)" + nohup python sdk/testdata/stub_server.py --port 18089 --add "DB_PASSWORD=$db_value" --add API_TOKEN=api-token-from-keepiq > /dev/null 2>&1 & + echo "STUB_PID=$!" >> "$GITHUB_ENV" + echo "DB_HASH=$(printf %s "$db_value" | sha256sum | cut -c1-64)" >> "$GITHUB_ENV" + for _ in $(seq 50); do curl -fs http://127.0.0.1:18089/index.php/apps/keepiq/api/v1/app/.well-known/keepiq > /dev/null && break; sleep 0.2; done + { + echo 'KEEPIQ_TEST_KEY<> "$GITHUB_ENV" + - name: Run mode + uses: ./integrations/github-action + with: + url: http://127.0.0.1:18089/index.php + application-id: billing + private-key: ${{ env.KEEPIQ_TEST_KEY }} + secrets: DB_PASSWORD + run: test "$(printf %s "$KEEPIQ_DB_PASSWORD" | sha256sum | cut -c1-64)" = "$DB_HASH" && echo "deploy saw the password" + version: cli-vtest + download-base: http://127.0.0.1:18088 + - name: No file on the runner holds the value + run: | + value="$(tr '\0' '\n' < "/proc/$STUB_PID/cmdline" | sed -n 's/^DB_PASSWORD=//p')" + test -n "$value" + if grep -rqF "$value" "$RUNNER_TEMP" "$HOME" "$GITHUB_WORKSPACE" 2>/dev/null; then + echo "::error::a file holds the secret value"; exit 1 + fi + - name: Export mode + uses: ./integrations/github-action + with: + url: http://127.0.0.1:18089/index.php + application-id: billing + private-key: ${{ env.KEEPIQ_TEST_KEY }} + secrets: API_TOKEN + export-env: "true" + version: cli-vtest + download-base: http://127.0.0.1:18088 + - name: A later step sees the value, and the log shows it masked + run: | + test "$KEEPIQ_API_TOKEN" = api-token-from-keepiq + echo "value: $KEEPIQ_API_TOKEN" diff --git a/.github/workflows/mobile-e2e.yml b/.github/workflows/mobile-e2e.yml new file mode 100644 index 000000000..5b8d5fc3d --- /dev/null +++ b/.github/workflows/mobile-e2e.yml @@ -0,0 +1,216 @@ +name: Mobile e2e + +# End-to-end tests of the native apps (clients-mobile-apps), with +# screenshots and video of the flows as artifacts. +# +# Android: the Nextcloud with Keepiq and OpenRegister of +# browser-extension/capture (the same setup docs-media.yml uses), behind the +# https front of mobile/e2e/server.mjs, and an emulator that reaches it at +# https://10.0.2.2:8443. The e2e build type trusts that run's certificate; +# debug and release builds never do. +# +# iOS: the macOS runners have no Docker, so the simulator talks to +# mobile/e2e/server.mjs replay, which answers from mobile/e2e/fixtures, +# recorded from the same seeded test server (`server.mjs seed`, then +# `server.mjs record`). + +on: + pull_request: + branches: [main, development, beta] + paths: + - "mobile/**" + - "browser-extension/capture/compose.yaml" + - "browser-extension/capture/setup.sh" + - ".github/workflows/mobile-e2e.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mobile-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + android: + name: android (API ${{ matrix.api }}) + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + # 34 so later passkey work (Android 14+) fits; 28 is the minimum. + - api: 34 + artifact: mobile-media-android + - api: 28 + artifact: mobile-media-android-api28 + env: + PROJECT: kq-e2e + OUT: ${{ github.workspace }}/media + steps: + - uses: actions/checkout@v4 + with: + path: keepiq + persist-credentials: false + - uses: actions/checkout@v4 + with: + repository: ConductionNL/openregister + ref: development + path: openregister + persist-credentials: false + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + tools: composer:v2 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + + # The API is all the apps use, so neither front end is built. + - name: PHP dependencies of Keepiq and OpenRegister + run: | + composer install --no-dev --prefer-dist --no-interaction --working-dir=keepiq + composer install --no-dev --prefer-dist --no-interaction --working-dir=openregister + - name: Start the test server + run: docker compose -p "$PROJECT" -f keepiq/browser-extension/capture/compose.yaml up -d + - name: Provision it + run: bash keepiq/browser-extension/capture/setup.sh "$PROJECT" keepiq openregister + # The demo vault the vault tests read: example.com names, all fake. + - name: Seed the demo vault + run: node keepiq/mobile/e2e/server.mjs seed --upstream http://localhost:8188 + - name: The https front the emulator reaches at 10.0.2.2 + run: | + occ() { docker exec -u www-data "$PROJECT-nc-1" php occ "$@"; } + occ config:system:set overwriteprotocol --value=https + occ config:system:set trusted_domains 6 --value=10.0.2.2:8443 + occ config:system:set trusted_domains 7 --value=localhost:8443 + mkdir -p "$RUNNER_TEMP/e2e" + openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj "/CN=Keepiq e2e" \ + -addext "subjectAltName=IP:10.0.2.2,IP:127.0.0.1,DNS:localhost" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -keyout "$RUNNER_TEMP/e2e/key.pem" -out "$RUNNER_TEMP/e2e/cert.pem" + mkdir -p keepiq/mobile/android/app/src/e2e/res/raw + cp "$RUNNER_TEMP/e2e/cert.pem" keepiq/mobile/android/app/src/e2e/res/raw/keepiq_e2e_ca.pem + nohup node keepiq/mobile/e2e/server.mjs proxy --port 8443 \ + --cert "$RUNNER_TEMP/e2e/cert.pem" --key "$RUNNER_TEMP/e2e/key.pem" \ + --upstream http://localhost:8188 --container "$PROJECT-nc-1" > "$RUNNER_TEMP/e2e/proxy.log" 2>&1 & + for _ in $(seq 1 20); do curl -skf -o /dev/null https://localhost:8443/status.php && break; sleep 1; done + curl -skf https://localhost:8443/status.php + # A passkey's rpId is checked with Digital Asset Links at + # https:///.well-known/assetlinks.json, port 443. The emulator + # reaches this host's port 443 at 10.0.2.2: send it to the https front. + - name: Port 443 to the https front + run: | + sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443 + curl -skf https://127.0.0.1/status.php + - name: The shared core against the test server + working-directory: keepiq/mobile + run: | + keytool -importcert -noprompt -alias e2e -file "$RUNNER_TEMP/e2e/cert.pem" \ + -keystore "$RUNNER_TEMP/e2e/trust.p12" -storetype PKCS12 -storepass changeit + ./gradlew --no-daemon :shared:jvmTest --tests '*LiveServerTest*' \ + -Pkeepiq.liveServer=https://localhost:8443 -Pkeepiq.liveMasterPassword=Oj \ + -Pjavax.net.ssl.trustStore="$RUNNER_TEMP/e2e/trust.p12" -Pjavax.net.ssl.trustStorePassword=changeit + # A skipped test reads like a passed one; this one must have run. + report=shared/build/test-results/jvmTest/TEST-nl.conduction.keepiq.shared.LiveServerTest.xml + grep -q 'tests="1" skipped="0" failures="0" errors="0"' "$report" + - name: Build the e2e app and its tests + working-directory: keepiq/mobile + run: ./gradlew --no-daemon :android:app:assembleFdroidE2e :android:app:assembleFdroidE2eAndroidTest :android:otherapp:assembleDebug + # The release build is shrunk by R8, which the e2e build is not. On one + # API level, android-run.sh installs it after the tests and checks that + # it starts and stays up through an autofill request. Signed + # with a throwaway key made here. + - name: Build the R8 release app for the smoke check + if: matrix.api == 34 + working-directory: keepiq/mobile + run: | + pw=$(openssl rand -hex 24) + echo "::add-mask::$pw" + keytool -genkeypair -keystore "$RUNNER_TEMP/smoke.p12" -storetype PKCS12 \ + -storepass "$pw" -keypass "$pw" -alias smoke -keyalg RSA -keysize 2048 \ + -validity 1 -dname "CN=Keepiq smoke" >/dev/null + KEEPIQ_SIGNING_STORE_FILE="$RUNNER_TEMP/smoke.p12" KEEPIQ_SIGNING_STORE_PASSWORD="$pw" \ + KEEPIQ_SIGNING_KEY_ALIAS=smoke KEEPIQ_SIGNING_KEY_PASSWORD="$pw" \ + ./gradlew --no-daemon :android:app:assembleFdroidRelease + rm -f "$RUNNER_TEMP/smoke.p12" + # The emulator records webm; the docs and the artifact take mp4. + - name: ffmpeg for the videos + run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends ffmpeg + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Emulator tests, screenshots and video + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api }} + target: default + arch: x86_64 + profile: pixel_6 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: bash keepiq/mobile/e2e/android-run.sh "$OUT" "$PROJECT" keepiq/browser-extension/capture/out/app-password + - name: Server logs + if: failure() + run: | + cat "$RUNNER_TEMP/e2e/proxy.log" || true + docker exec "$PROJECT-nc-1" tail -n 100 /var/www/html/data/nextcloud.log || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: ${{ matrix.artifact }} + path: media/ + if-no-files-found: warn + retention-days: 14 + - name: Stop the test server + if: always() + run: docker compose -p "$PROJECT" -f keepiq/browser-extension/capture/compose.yaml down -v + + ios: + runs-on: macos-latest + timeout-minutes: 60 + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Shared framework for the simulator + run: ./gradlew --no-daemon :shared:assembleKeepiqSharedDebugXCFramework + - name: Xcode project + working-directory: mobile/ios + run: | + brew install xcodegen + xcodegen generate + - name: UI tests, screenshots and video + run: bash e2e/ios-run.sh "$GITHUB_WORKSPACE/media" + - name: Replay log + if: failure() + run: cat "$GITHUB_WORKSPACE/media/replay.log" || true + - uses: actions/upload-artifact@v4 + if: always() + with: + name: mobile-media-ios + path: | + media/shots/ + media/*.mp4 + media/*.xcresult/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/mobile-fdroid.yml b/.github/workflows/mobile-fdroid.yml new file mode 100644 index 000000000..2b7f8d98b --- /dev/null +++ b/.github/workflows/mobile-fdroid.yml @@ -0,0 +1,168 @@ +name: Mobile F-Droid + +# The fdroid flavour of the Android app (clients-mobile-apps, tasks 6.1 and +# 6.2, design D8). +# +# free-software: the runtime classpath of the fdroid release build may hold +# no Google Play Services, Firebase, Play Core or other non-free artifact. +# The play flavour is held to the same rule, because D8 allows no +# proprietary SDK in any flavour. A hit prints the artifact and fails. +# +# reproducible: two jobs build the unsigned fdroid release APK, each from its +# own clean checkout at a different path, and a third compares them byte for +# byte. On a mismatch it uploads the diffoscope report and fails. F-Droid +# publishes a build signed with the developer's key only when its own rebuild +# matches, so this is the property its build server relies on. + +on: + push: + branches: [main, development] + paths: + - "mobile/**" + - ".github/workflows/mobile-fdroid.yml" + pull_request: + branches: [main, development, beta] + paths: + - "mobile/**" + - ".github/workflows/mobile-fdroid.yml" + workflow_dispatch: + +permissions: + contents: read + +env: + # The JDK is pinned to the patch release, the Gradle wrapper pins Gradle + # with a checksum, and gradle/libs.versions.toml pins AGP, aapt2 and the + # build tools. + JAVA_VERSION: "21.0.8" + +jobs: + free-software: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + - uses: gradle/actions/setup-gradle@v4 + # Lenient dependency verification for this report only, so an artifact + # that is not in gradle/verification-metadata.xml still shows up here by + # name instead of as a checksum error. The builds below verify strictly. + - name: No non-free artifact in the runtime classpath + run: | + for flavour in fdroid play; do + ./gradlew --no-daemon -q --dependency-verification lenient \ + :android:app:dependencies --configuration "${flavour}ReleaseRuntimeClasspath" > "deps-$flavour.txt" + # The report must list the app's own dependencies; an empty or + # failed report would otherwise pass. + grep -q "androidx.credentials:credentials" "deps-$flavour.txt" + done + pattern='com\.google\.android\.gms|com\.google\.firebase|com\.google\.android\.play|com\.google\.mlkit|com\.google\.android\.datatransport|com\.google\.android\.ads|com\.android\.billingclient|com\.crashlytics|io\.fabric|com\.huawei\.hms|com\.amazon\.device|credentials-play-services-auth' + found=0 + for flavour in fdroid play; do + if hits=$(grep -E "$pattern" "deps-$flavour.txt"); then + echo "::error::Non-free artifact in the $flavour release runtime classpath:" + printf '%s\n' "$hits" | sed 's/^[-+|\\ ]*//' | sort -u | while read -r line; do + echo "::error:: $line" + done + found=1 + fi + done + if [ "$found" -ne 0 ]; then + echo "F-Droid builds only free software (design D8). Remove the artifact above." >&2 + exit 1 + fi + echo "No non-free artifact in the fdroid or play release runtime classpath." + + build: + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + # Two clean checkouts at different paths: a build path that leaks into + # the APK shows up as a difference. + copy: [first, second] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + path: ${{ matrix.copy == 'first' && 'keepiq' || 'elsewhere/keepiq-rebuild' }} + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + # No build cache: each build starts from nothing. + - uses: gradle/actions/setup-gradle@v4 + with: + cache-disabled: true + - name: Build the unsigned fdroid release APK + env: + DIR: ${{ matrix.copy == 'first' && 'keepiq' || 'elsewhere/keepiq-rebuild' }} + run: | + cd "$DIR/mobile" + java -version 2>&1 | head -1 + # Unsigned: the KEEPIQ_SIGNING_* variables are not set here. + ./gradlew --no-daemon :android:app:assembleFdroidRelease + apk=android/app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk + test -f "$apk" + mkdir -p "$GITHUB_WORKSPACE/out" + cp "$apk" "$GITHUB_WORKSPACE/out/keepiq-fdroid-${{ matrix.copy }}.apk" + sha256sum "$GITHUB_WORKSPACE/out/keepiq-fdroid-${{ matrix.copy }}.apk" + - uses: actions/upload-artifact@v4 + with: + name: fdroid-apk-${{ matrix.copy }} + path: out/*.apk + if-no-files-found: error + retention-days: 14 + + reproducible: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/download-artifact@v4 + with: + pattern: fdroid-apk-* + merge-multiple: true + path: apks + - name: Compare the two builds byte for byte + id: compare + run: | + sha256sum apks/*.apk | tee -a "$GITHUB_STEP_SUMMARY" + if cmp apks/keepiq-fdroid-first.apk apks/keepiq-fdroid-second.apk; then + echo "The two unsigned fdroid release APKs are identical." | tee -a "$GITHUB_STEP_SUMMARY" + else + echo "match=false" >> "$GITHUB_OUTPUT" + echo "::error::The two builds differ; see the diffoscope artifact." + fi + - name: diffoscope report + if: steps.compare.outputs.match == 'false' + run: | + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends diffoscope-minimal unzip || \ + sudo apt-get install -y -q diffoscope + mkdir -p report + diffoscope --text report/diffoscope.txt --html report/diffoscope.html \ + apks/keepiq-fdroid-first.apk apks/keepiq-fdroid-second.apk || true + head -c 60000 report/diffoscope.txt + - uses: actions/upload-artifact@v4 + if: steps.compare.outputs.match == 'false' + with: + name: diffoscope + path: report/ + retention-days: 14 + - name: Fail on a mismatch + if: steps.compare.outputs.match == 'false' + run: exit 1 diff --git a/.github/workflows/mobile-preview.yml b/.github/workflows/mobile-preview.yml new file mode 100644 index 000000000..5c134ef22 --- /dev/null +++ b/.github/workflows/mobile-preview.yml @@ -0,0 +1,238 @@ +name: Mobile preview + +# Preview builds of Keepiq for Android (clients-mobile-apps, task 6.4). +# +# A tag mobile-v-preview. builds the release APK, signs it +# with the PREVIEW key from GitHub secrets, checks the signature with +# apksigner, and publishes a GitHub pre-release with the APK and its SHA-256. +# The preview key is not the store release key: a later Play or F-Droid build +# is signed differently, so users reinstall then. +# +# workflow_dispatch does the same; with a tag it also publishes the +# pre-release at that tag on the chosen ref, without one it only uploads the +# signed APK as a workflow artifact. +# +# A pull request that touches the Android build runs the dry run: the same +# build, signed with a throwaway key made in the job, checked with apksigner, +# and no release. +# +# It builds the fdroid flavour (task 6.1): a preview is installed by hand, +# outside Google Play, and the fdroid flavour is the one without any store +# code. The asset names stay as they were. + +on: + push: + tags: ["mobile-v*-preview.*"] + pull_request: + branches: [main, development, beta] + paths: + - ".github/workflows/mobile-preview.yml" + - "mobile/android/**" + - "mobile/shared/**" + - "mobile/gradle/**" + - "mobile/*.gradle.kts" + - "mobile/gradle.properties" + workflow_dispatch: + inputs: + tag: + description: "Tag to publish, for example mobile-v0.1.0-preview.1. Leave empty to build without a release." + required: false + default: "" + +permissions: + contents: read + +concurrency: + group: mobile-preview-${{ github.ref }} + cancel-in-progress: false + +env: + # The SHA-256 of the preview signing certificate, as docs/mobile/using.md + # publishes it. A preview build signed with anything else fails. + PREVIEW_CERT_SHA256: 89313b106e7d43fa141ecb0b4d1c188d77d8b3e214dba9fb4e8dd3c2674bfc35 + +jobs: + android: + runs-on: ubuntu-latest + # The debug build in mobile.yml takes about ten minutes; a release build + # is of the same order. + timeout-minutes: 45 + permissions: + contents: write + defaults: + run: + # Explicit bash runs with pipefail, so a failing apksigner is not + # hidden behind tee. + shell: bash + working-directory: mobile + steps: + - uses: actions/checkout@v4 + + - name: Mode and version + id: mode + env: + EVENT: ${{ github.event_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + version_name=$(sed -n 's/^ *versionName = "\(.*\)"$/\1/p' android/app/build.gradle.kts) + if [ -z "$version_name" ]; then + echo "No versionName in mobile/android/app/build.gradle.kts." >&2 + exit 1 + fi + tag="" + case "$EVENT" in + push) tag="$GITHUB_REF_NAME" ;; + workflow_dispatch) tag="$INPUT_TAG" ;; + esac + if [ "$EVENT" = "pull_request" ]; then + mode=dry-run + version="${version_name}-dryrun" + else + mode=preview + version="${version_name}-preview" + fi + if [ -n "$tag" ]; then + if ! printf '%s' "$tag" | grep -Eq '^mobile-v[0-9]+\.[0-9]+\.[0-9]+-preview\.[0-9]+$'; then + echo "Tag $tag does not match mobile-v-preview.." >&2 + exit 1 + fi + version="${tag#mobile-v}" + if [ "${version%-preview.*}" != "$version_name" ]; then + echo "Tag $tag does not match versionName $version_name in the app." >&2 + exit 1 + fi + fi + echo "mode=$mode" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "apk=keepiq-android-${version}.apk" >> "$GITHUB_OUTPUT" + echo "Mode $mode, version $version, tag ${tag:-none}" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + + - name: Signing key (throwaway, dry run) + if: steps.mode.outputs.mode == 'dry-run' + run: | + pw=$(openssl rand -hex 24) + echo "::add-mask::$pw" + keytool -genkeypair -keystore "$RUNNER_TEMP/signing.p12" -storetype PKCS12 \ + -storepass "$pw" -keypass "$pw" -alias dryrun -keyalg RSA -keysize 2048 \ + -validity 1 -dname "CN=Keepiq dry run" >/dev/null + { + echo "KEEPIQ_SIGNING_STORE_FILE=$RUNNER_TEMP/signing.p12" + echo "KEEPIQ_SIGNING_STORE_PASSWORD=$pw" + echo "KEEPIQ_SIGNING_KEY_ALIAS=dryrun" + echo "KEEPIQ_SIGNING_KEY_PASSWORD=$pw" + } >> "$GITHUB_ENV" + + - name: Signing key (preview, from secrets) + if: steps.mode.outputs.mode == 'preview' + env: + KEYSTORE_B64: ${{ secrets.KEEPIQ_PREVIEW_KEYSTORE_B64 }} + STORE_PASSWORD: ${{ secrets.KEEPIQ_PREVIEW_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEEPIQ_PREVIEW_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEEPIQ_PREVIEW_KEY_PASSWORD }} + run: | + if [ -z "$KEYSTORE_B64" ] || [ -z "$STORE_PASSWORD" ] || [ -z "$KEY_ALIAS" ] || [ -z "$KEY_PASSWORD" ]; then + echo "A KEEPIQ_PREVIEW_* secret is missing; the preview cannot be signed." >&2 + exit 1 + fi + printf '%s' "$KEYSTORE_B64" | base64 -d > "$RUNNER_TEMP/signing.p12" + { + echo "KEEPIQ_SIGNING_STORE_FILE=$RUNNER_TEMP/signing.p12" + echo "KEEPIQ_SIGNING_STORE_PASSWORD=$STORE_PASSWORD" + echo "KEEPIQ_SIGNING_KEY_ALIAS=$KEY_ALIAS" + echo "KEEPIQ_SIGNING_KEY_PASSWORD=$KEY_PASSWORD" + } >> "$GITHUB_ENV" + + # R8-shrunk (build.gradle.kts), and split per processor type next to + # the universal APK. Nothing in the build writes a timestamp, so the + # same commit gives the same APKs before signing. + - name: Build the signed release APKs + run: ./gradlew --no-daemon -Pkeepiq.abiSplits=true :android:app:assembleFdroidRelease + + - name: Remove the keystore from the runner + if: always() + run: rm -f "$RUNNER_TEMP/signing.p12" + + # The universal APK keeps the name it always had; the others carry + # their processor type: keepiq-android--arm64-v8a.apk. + - name: Verify the signatures with apksigner + env: + MODE: ${{ steps.mode.outputs.mode }} + APK: ${{ steps.mode.outputs.apk }} + run: | + build_tools=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1) + out=android/app/build/outputs/apk/fdroid/release + mkdir -p dist + cp "$out/app-fdroid-universal-release.apk" "dist/$APK" + for abi in arm64-v8a armeabi-v7a x86 x86_64; do + cp "$out/app-fdroid-$abi-release.apk" "dist/${APK%.apk}-$abi.apk" + done + { + echo "| APK | Size |" + echo "| --- | ---: |" + } >> "$GITHUB_STEP_SUMMARY" + for file in dist/*.apk; do + name=$(basename "$file") + "$build_tools/apksigner" verify --verbose --print-certs "$file" | tee "dist/$name.apksigner.txt" + grep -q "Verified using v2 scheme (APK Signature Scheme v2): true" "dist/$name.apksigner.txt" + if [ "$MODE" = "preview" ]; then + if ! grep -qi "certificate SHA-256 digest: $PREVIEW_CERT_SHA256" "dist/$name.apksigner.txt"; then + echo "$name is not signed with the published preview key." >&2 + exit 1 + fi + fi + (cd dist && sha256sum "$name" > "$name.sha256" && cat "$name.sha256") + echo "| $name | $(stat -c %s "$file") bytes |" >> "$GITHUB_STEP_SUMMARY" + done + + - uses: actions/upload-artifact@v4 + with: + name: keepiq-android-${{ steps.mode.outputs.version }} + path: | + mobile/dist/*.apk + mobile/dist/*.sha256 + retention-days: 14 + + - name: Publish the pre-release + if: steps.mode.outputs.mode == 'preview' && steps.mode.outputs.tag != '' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.mode.outputs.tag }} + VERSION: ${{ steps.mode.outputs.version }} + APK: ${{ steps.mode.outputs.apk }} + run: | + fingerprint=$(printf '%s' "$PREVIEW_CERT_SHA256" | tr 'a-f' 'A-F' | sed 's/../&:/g; s/:$//') + sums=$(cd dist && for f in *.apk; do printf -- '- SHA-256 of `%s`: `%s`\n' "$f" "$(cut -d' ' -f1 "$f.sha256")"; done) + cat > dist/notes.md < deps.txt + if grep -E "com\.google\.android\.gms|com\.google\.firebase|credentials-play-services-auth" deps.txt; then + echo "A proprietary Google artifact is in the dependency tree." >&2 + exit 1 + fi + grep -q "kotlinx-serialization-json" deps.txt + # The passkey provider (task 5.1) is androidx.credentials alone. + grep -q "androidx.credentials:credentials:1" deps.txt + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: npm + - name: The web app opens what the core wrote in this run + working-directory: . + run: | + npm ci --ignore-scripts + KEEPIQ_KOTLIN_VECTORS=mobile/shared/build/vectors/kotlin-output.json \ + npx vitest run tests/vitest/crypto-vectors.spec.js tests/vitest/crypto-vectors-kotlin.spec.js \ + tests/vitest/generator-vectors.spec.js tests/vitest/autofill-vectors.spec.js + # What the core wrote in this run. When its format changes on purpose, + # this file replaces tests/vectors/crypto/kotlin-output.json. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: kotlin-vectors + path: mobile/shared/build/vectors/kotlin-output.json + if-no-files-found: ignore + retention-days: 14 + + ios: + runs-on: macos-latest + timeout-minutes: 60 + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@v4 + - name: Shared core tests on the iOS simulator + run: ./gradlew --no-daemon :shared:iosSimulatorArm64Test + - name: The iOS package links the shared framework + run: | + ./gradlew --no-daemon :shared:assembleKeepiqSharedReleaseXCFramework + cd ios/KeepiqApp + xcodebuild -scheme KeepiqApp -destination "generic/platform=iOS Simulator" build + # The SwiftUI app itself (mobile/ios/project.yml), so a Swift compile + # error shows here and not only in mobile-e2e.yml. + - name: The iOS app compiles against the shared framework + run: | + ./gradlew --no-daemon :shared:assembleKeepiqSharedDebugXCFramework + brew install xcodegen + cd ios + xcodegen generate + xcodebuild build -project Keepiq.xcodeproj -scheme Keepiq \ + -destination "generic/platform=iOS Simulator" CODE_SIGNING_ALLOWED=NO -quiet + # The Objective-C header Kotlin generates: the names Swift sees. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: keepiq-shared-header + path: mobile/shared/build/XCFrameworks/debug/KeepiqShared.xcframework/*/KeepiqShared.framework/Headers/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml new file mode 100644 index 000000000..1c1fcd813 --- /dev/null +++ b/.github/workflows/sdk.yml @@ -0,0 +1,142 @@ +name: Client libraries + +# Tests the Go, Python and TypeScript client libraries in sdk/ on every pull +# request that touches them, against the shared vectors in sdk/testdata, and +# releases each one on its own tag prefix: +# +# sdk/go/vX.Y.Z Go module github.com/ConductionNL/keepiq/sdk/go (the tag is +# the release; the Go proxy serves it) +# sdk-py-vX.Y.Z keepiq-sdk on PyPI, through trusted publishing +# sdk-js-vX.Y.Z @conduction/keepiq-sdk on npm, with provenance +# +# On a pull request the package jobs build the Python distribution and the npm +# tarball without publishing them: that is the dry run. + +on: + push: + branches: [main, development] + paths: ["sdk/**", ".github/workflows/sdk.yml"] + tags: ["sdk/go/v*", "sdk-py-v*", "sdk-js-v*"] + pull_request: + branches: [main, development, beta] + paths: ["sdk/**", ".github/workflows/sdk.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + # Stdlib-only Go; the RSA-4096 key generation in the tests is the slow part. + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - working-directory: sdk/go + run: | + go vet ./... + go test ./... + - name: Go release tag matches the module + if: startsWith(github.ref, 'refs/tags/sdk/go/v') + run: | + grep -qx 'module github.com/ConductionNL/keepiq/sdk/go' sdk/go/go.mod + echo "Released ${GITHUB_REF_NAME}; consumers fetch it with go get github.com/ConductionNL/keepiq/sdk/go@${GITHUB_REF_NAME#sdk/go/}" + + python: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + python: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - working-directory: sdk/python + run: | + python -m pip install --upgrade pip + python -m pip install cryptography pytest + python -m pytest -q + + js: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - working-directory: sdk/js + run: | + npm install --no-audit --no-fund + npm run typecheck + npm test + + python-package: + needs: python + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: ${{ startsWith(github.ref, 'refs/tags/sdk-py-v') && 'pypi' || '' }} + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build + working-directory: sdk/python + run: | + python -m pip install build + python -m build + ls -l dist + - name: Tag matches the package version + if: startsWith(github.ref, 'refs/tags/sdk-py-v') + working-directory: sdk/python + run: | + want="${GITHUB_REF_NAME#sdk-py-v}" + got="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + test "$want" = "$got" || { echo "::error::tag says $want, pyproject.toml says $got"; exit 1; } + - name: Publish to PyPI (trusted publishing) + if: startsWith(github.ref, 'refs/tags/sdk-py-v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: sdk/python/dist + + js-package: + needs: js + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + registry-url: https://registry.npmjs.org + - name: Build + working-directory: sdk/js + run: | + npm install --no-audit --no-fund + npm run build + npm pack --dry-run + - name: Tag matches the package version + if: startsWith(github.ref, 'refs/tags/sdk-js-v') + working-directory: sdk/js + run: | + want="${GITHUB_REF_NAME#sdk-js-v}" + got="$(node -p 'require("./package.json").version')" + test "$want" = "$got" || { echo "::error::tag says $want, package.json says $got"; exit 1; } + - name: Publish to npm with provenance + if: startsWith(github.ref, 'refs/tags/sdk-js-v') + working-directory: sdk/js + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --provenance --access public diff --git a/.gitignore b/.gitignore index 77a7f7525..edc3eab18 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,7 @@ screens/ .stale/ /.e2e-state/ .phpunit.result.cache + +# The two-instance federation test (tests/integration/federation). +/playwright-report-federation/ +/test-results-federation/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..d9bb0d4be --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,13 @@ +# Secret scanning configuration (gitleaks v8). +# +# sdk/testdata/ holds a throwaway RSA key and values encrypted to it. They are +# conformance vectors that every client library and the server decrypt in +# tests; the key protects nothing and is labelled TEST ONLY in each file. +title = "keepiq" + +[extend] +useDefault = true + +[allowlist] +description = "Test-only conformance vectors for the client libraries" +paths = ['''^sdk/testdata/'''] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..9d95bcbfd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to Keepiq are recorded in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Keepiq uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Each entry names the OpenSpec change it comes from. + +## [Unreleased] + +### Added + +- AI assistants can read vault metadata through three MCP tools: `listEntries`, `expiryReport` and `rotationStatus`. The tools return names, addresses, types, folders and dates, never a password, login or other secret value. They act only for the signed-in user and change nothing. Every call is written to the audit log with the tool name and the number of results. The tools appear only when OpenRegister is installed. (hermiq-ai-tooling) + +### Fixed + +- `keepiq ssh-agent` works as a systemd user service. Under systemd the agent moved to the background and left the unit, which systemd then stopped, so the agent never kept running. The new `--foreground` option keeps it in place, and the unit in `cli/README.md` now uses it. + +### Removed + +- The `vault_admin` group no longer counts as the People and offboarding admin area, and the General area no longer shows a notice about it. A member of that group who holds no delegation is now refused the admin handover and team offboarding. To keep those actions for them, delegate the People and offboarding area to their group on Nextcloud's administration privileges page before you upgrade. (admin-scoped-roles, #1043) + +### Security + +- Keepiq records its decision not to take part in OpenRegister integration leaves: secret material and vault structure stay inside the vault's own access rules. A test now fails when a register schema declares `linkedTypes` or `mailObjectTemplate`, so the boundary cannot be crossed by accident. (leaf-integrations) diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 000000000..137069b82 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSES/CC-BY-3.0-US.txt b/LICENSES/CC-BY-3.0-US.txt new file mode 100644 index 000000000..c35a2b1a1 --- /dev/null +++ b/LICENSES/CC-BY-3.0-US.txt @@ -0,0 +1,83 @@ +Creative Commons Attribution 3.0 United States + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM ITS USE. + +License + +THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. + +BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS. + +1. Definitions + + a. "Collective Work" means a work, such as a periodical issue, anthology or encyclopedia, in which the Work in its entirety in unmodified form, along with one or more other contributions, constituting separate and independent works in themselves, are assembled into a collective whole. A work that constitutes a Collective Work will not be considered a Derivative Work (as defined below) for the purposes of this License. + + b. "Derivative Work" means a work based upon the Work or upon the Work and other pre-existing works, such as a translation, musical arrangement, dramatization, fictionalization, motion picture version, sound recording, art reproduction, abridgment, condensation, or any other form in which the Work may be recast, transformed, or adapted, except that a work that constitutes a Collective Work will not be considered a Derivative Work for the purpose of this License. For the avoidance of doubt, where the Work is a musical composition or sound recording, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered a Derivative Work for the purpose of this License. + + c. "Licensor" means the individual, individuals, entity or entities that offers the Work under the terms of this License. + + d. "Original Author" means the individual, individuals, entity or entities who created the Work. + + e. "Work" means the copyrightable work of authorship offered under the terms of this License. + + f. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation. + +2. Fair Use Rights. Nothing in this license is intended to reduce, limit, or restrict any rights arising from fair use, first sale or other limitations on the exclusive rights of the copyright owner under copyright law or other applicable laws. + +3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below: + + a. to reproduce the Work, to incorporate the Work into one or more Collective Works, and to reproduce the Work as incorporated in the Collective Works; + + b. to create and reproduce Derivative Works provided that any such Derivative Work, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified.";; + + c. to distribute copies or phonorecords of, display publicly, perform publicly, and perform publicly by means of a digital audio transmission the Work including as incorporated in Collective Works; + + d. to distribute copies or phonorecords of, display publicly, perform publicly, and perform publicly by means of a digital audio transmission Derivative Works. + + e. For the avoidance of doubt, where the Work is a musical composition: + + i. Performance Royalties Under Blanket Licenses. Licensor waives the exclusive right to collect, whether individually or, in the event that Licensor is a member of a performance rights society (e.g. ASCAP, BMI, SESAC), via that society, royalties for the public performance or public digital performance (e.g. webcast) of the Work. + + ii. Mechanical Rights and Statutory Royalties. Licensor waives the exclusive right to collect, whether individually or via a music rights agency or designated agent (e.g. Harry Fox Agency), royalties for any phonorecord You create from the Work ("cover version") and distribute, subject to the compulsory license created by 17 USC Section 115 of the US Copyright Act (or the equivalent in other jurisdictions). + + f. Webcasting Rights and Statutory Royalties. For the avoidance of doubt, where the Work is a sound recording, Licensor waives the exclusive right to collect, whether individually or via a performance-rights society (e.g. SoundExchange), royalties for the public digital performance (e.g. webcast) of the Work, subject to the compulsory license created by 17 USC Section 114 of the US Copyright Act (or the equivalent in other jurisdictions). + +The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. All rights not expressly granted by Licensor are hereby reserved. + +4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions: + + a. You may distribute, publicly display, publicly perform, or publicly digitally perform the Work only under the terms of this License, and You must include a copy of, or the Uniform Resource Identifier for, this License with every copy or phonorecord of the Work You distribute, publicly display, publicly perform, or publicly digitally perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of a recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties. When You distribute, publicly display, publicly perform, or publicly digitally perform the Work, You may not impose any technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collective Work, but this does not require the Collective Work apart from the Work itself to be made subject to the terms of this License. If You create a Collective Work, upon notice from any Licensor You must, to the extent practicable, remove from the Collective Work any credit as required by Section 4(b), as requested. If You create a Derivative Work, upon notice from any Licensor You must, to the extent practicable, remove from the Derivative Work any credit as required by Section 4(b), as requested. + + b. If You distribute, publicly display, publicly perform, or publicly digitally perform the Work (as defined in Section 1 above) or any Derivative Works (as defined in Section 1 above) or Collective Works (as defined in Section 1 above), You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or (ii) if the Original Author and/or Licensor designate another party or parties (e.g. a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; the title of the Work if supplied; to the extent reasonably practicable, the Uniform Resource Identifier, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and, consistent with Section 3(b) in the case of a Derivative Work, a credit identifying the use of the Work in the Derivative Work (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4(b) may be implemented in any reasonable manner; provided, however, that in the case of a Derivative Work or Collective Work, at a minimum such credit will appear, if a credit for all contributing authors of the Derivative Work or Collective Work appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties. + +5. Representations, Warranties and Disclaimer + +UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND ONLY TO THE EXTENT OF ANY RIGHTS HELD IN THE LICENSED WORK BY THE LICENSOR. THE LICENSOR MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MARKETABILITY, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. + +6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. Termination + + a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Derivative Works (as defined in Section 1 above) or Collective Works (as defined in Section 1 above) from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License. + + b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above. + +8. Miscellaneous + + a. Each time You distribute or publicly digitally perform the Work (as defined in Section 1 above) or a Collective Work (as defined in Section 1 above), the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License. + + b. Each time You distribute or publicly digitally perform a Derivative Work, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License. + + c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. + + d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent. + + e. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. This License may not be modified without the mutual written agreement of the Licensor and You. + +Creative Commons Notice + +Creative Commons is not a party to this License, and makes no warranty whatsoever in connection with the Work. Creative Commons will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, if Creative Commons has expressly identified itself as the Licensor hereunder, it shall have all rights and obligations of Licensor. + +Except for the limited purpose of indicating to the public that the Work is licensed under the CCPL, Creative Commons does not authorize the use by either party of the trademark "Creative Commons" or any related trademark or logo of Creative Commons without the prior written consent of Creative Commons. Any permitted use will be in compliance with Creative Commons' then-current trademark usage guidelines, as may be published on its website or otherwise made available upon request from time to time. For the avoidance of doubt, this trademark restriction does not form part of the License. + +Creative Commons may be contacted at https://creativecommons.org/. diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 000000000..0e259d42c --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index d4817464e..75a4cbfac 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Securely store and share secrets (passwords, API keys, certificates) for Nextcloud users and applications, using end-to-end RSA/AES encryption backed by a private Certificate Authority. -> **Thick backend architecture** — Keepiq owns its own encrypted database tables. No OpenRegister dependency. All secrets are encrypted at rest with RSA-4096 public keys; private keys are AES-256 wrapped with a master password derived key. +> **Thick backend architecture** — Keepiq owns its own encrypted database tables and does not store secrets in OpenRegister. It does need OpenRegister installed and enabled for its app shell (page, settings and health routes). All secrets are encrypted at rest with RSA-4096 public keys; private keys are AES-256 wrapped with a master password derived key. ## Screenshots @@ -118,6 +118,9 @@ keepiq/ | Nextcloud | 28 – 33 | | PHP | 8.1+ | | Node.js | 20+ | +| OpenRegister | installed and enabled | + +Nextcloud cannot enforce one app depending on another, so nothing stops you enabling Keepiq without OpenRegister. Without it Keepiq no longer takes the rest of Nextcloud down, but Keepiq itself is not usable. Install and enable OpenRegister first. ## Installation diff --git a/REUSE.toml b/REUSE.toml index 9efe1e642..24b328767 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -34,3 +34,18 @@ path = "**" precedence = "closest" SPDX-FileCopyrightText = "2026 Conduction B.V. " SPDX-License-Identifier = "EUPL-1.2" + +[[annotations]] +path = "src/generator/eff-large-wordlist.js" +precedence = "override" +SPDX-FileCopyrightText = "2016 Electronic Frontier Foundation" +SPDX-License-Identifier = "CC-BY-3.0-US" + +# The reference Argon2 C code (P-H-C/phc-winner-argon2, tag 20190702), linked +# into the iOS build of the mobile core through cinterop (clients-mobile-apps +# design D1). Unchanged upstream files; the authors offer CC0 or Apache 2.0. +[[annotations]] +path = "mobile/shared/src/nativeInterop/argon2/**" +precedence = "override" +SPDX-FileCopyrightText = "2015 Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves" +SPDX-License-Identifier = "CC0-1.0 OR Apache-2.0" diff --git a/appinfo/info.xml b/appinfo/info.xml index aebbf2cb3..ba18cbbe1 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.4-unstable.20260912202807 + 0.3.4-unstable.20261004130000 EUPL-1.2 Conduction Keepiq @@ -114,14 +114,20 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\Keepiq\BackgroundJob\CheckRootCertificateExpiry OCA\Keepiq\BackgroundJob\PurgeAuditLogJob OCA\Keepiq\BackgroundJob\ExpireSecretRequestsJob + OCA\Keepiq\BackgroundJob\ExpireSharesJob + OCA\Keepiq\BackgroundJob\ExpireDeviceApprovalsJob + OCA\Keepiq\BackgroundJob\ExpireRecoveryRequestsJob OCA\Keepiq\BackgroundJob\ApproveElapsedEmergencyRequests OCA\Keepiq\BackgroundJob\PruneSecretVersionsJob + OCA\Keepiq\BackgroundJob\RetryFederatedNotificationsJob + OCA\Keepiq\BackgroundJob\PurgeTrashedSecretsJob OCA\Keepiq\BackgroundJob\ScanExpiringSecretsJob OCA\Keepiq\BackgroundJob\ExpireMachineLeasesJob OCA\Keepiq\BackgroundJob\EphemeralSendPurgeJob OCA\Keepiq\BackgroundJob\RefreshComplianceMetricsJob OCA\Keepiq\BackgroundJob\DeliverSiemEventsJob OCA\Keepiq\BackgroundJob\ScanCertificateExpiryJob + OCA\Keepiq\BackgroundJob\ScheduledVaultBackupJob + + OCA\Keepiq\Command\BackupCreate + OCA\Keepiq\Command\BackupList + OCA\Keepiq\Command\BackupVerify + OCA\Keepiq\Command\BackupRestore + + + OCA\Keepiq\Settings\AdminSettings + OCA\Keepiq\Settings\PolicyAdminSettings + OCA\Keepiq\Settings\ApplicationAdminSettings + OCA\Keepiq\Settings\PeopleAdminSettings + OCA\Keepiq\Settings\AuditAdminSettings OCA\Keepiq\Sections\SettingsSection diff --git a/appinfo/routes.php b/appinfo/routes.php index 2f5c4a08c..605bed268 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -12,29 +12,80 @@ * /api/metrics URLs are unchanged; their controllers are aliased to the * AppHost generic controllers by Bootstrap::register() in Application.php. * - * Every Keepiq domain route is appended via $extra below — it is inserted + * Every Keepiq domain route is appended via $extra below. It is inserted * before the SPA catch-all so it keeps priority over the /{path} fallback. - * This file references no OCA\OpenRegister symbol other than the pure array - * builder Routes::standard(), so it is safe to require even when OpenRegister - * is disabled. + * + * The AppHost builder is called behind a class_exists() guard. Nextcloud's + * router requires this file for every enabled app on every route-cache miss, + * so an unguarded call to a class from another app throws when OpenRegister + * is not installed, or installed but disabled (the autoloader prelude skips a + * disabled OpenRegister since #712). That throw is not confined to Keepiq: it + * answers HTTP 500 on every page of the instance, the login page and the apps + * page included (#857, #867). Without OpenRegister the fallback below routes + * the dashboard and settings controllers Keepiq ships itself, the domain + * routes and the SPA catch-all. The AppHost-only routes (preferences, health, + * metrics) are left out, because their controllers only exist as aliases to + * OpenRegister classes: a 404 there is honest, a 500 is not. */ -return \OCA\OpenRegister\AppHost\Routes::standard([ +$extra = [ // Dashboard summary (domain aggregator — DashboardController::summary()). ['name' => 'dashboard#summary', 'url' => '/api/dashboard/summary', 'verb' => 'GET'], - // Admin + user settings split (implement-dashboard-settings §2.4). - ['name' => 'settings#getAdminSettings', 'url' => '/api/settings/admin', 'verb' => 'GET'], - ['name' => 'settings#updateAdminSettings', 'url' => '/api/settings/admin', 'verb' => 'PUT'], + // Admin settings, one route pair per admin area, each guarded by its own + // area class (admin-scoped-roles D2). The People area owns no settings keys. + ['name' => 'adminAreaSettings#getGeneralSettings', 'url' => '/api/settings/admin/general', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#updateGeneralSettings', 'url' => '/api/settings/admin/general', 'verb' => 'PUT'], + ['name' => 'adminAreaSettings#getPolicySettings', 'url' => '/api/settings/admin/policies', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#updatePolicySettings', 'url' => '/api/settings/admin/policies', 'verb' => 'PUT'], + ['name' => 'adminAreaSettings#getApplicationSettings', 'url' => '/api/settings/admin/applications', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#updateApplicationSettings', 'url' => '/api/settings/admin/applications', 'verb' => 'PUT'], + ['name' => 'adminAreaSettings#getAuditSettings', 'url' => '/api/settings/admin/audit', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#updateAuditSettings', 'url' => '/api/settings/admin/audit', 'verb' => 'PUT'], + // Two-factor gap count for the vault policy section (admin-vault-policies §1.3). + ['name' => 'settings#twoFactorGaps', 'url' => '/api/settings/admin/two-factor-gaps', 'verb' => 'GET'], + // Vault backups (admin-scheduled-vault-backups §4.1): status, list and a + // run request. No route serves archive content (design D6). + ['name' => 'backupAdmin#index', 'url' => '/api/settings/admin/backups', 'verb' => 'GET'], + ['name' => 'backupAdmin#update', 'url' => '/api/settings/admin/backups', 'verb' => 'PUT'], + ['name' => 'backupAdmin#run', 'url' => '/api/settings/admin/backups/run', 'verb' => 'POST'], ['name' => 'settings#getUserSettings', 'url' => '/api/settings/user', 'verb' => 'GET'], // Read-only org password policy for write dialogs (org-password-policies §1.3). ['name' => 'settings#getPolicy', 'url' => '/api/settings/policy', 'verb' => 'GET'], ['name' => 'settings#updateUserSettings', 'url' => '/api/settings/user', 'verb' => 'PUT'], + // Admin API v1 (admin-public-api): a documented, versioned surface for + // scripts, each route guarded by one admin area (admin-scoped-roles). + // docs/api/admin-v1.openapi.json describes exactly these routes + // (AdminApiContractTest). The policies pair reuses the area settings + // methods; `postfix` keeps their route names distinct. + ['name' => 'adminIndex#index', 'url' => '/api/v1/admin', 'verb' => 'GET'], + ['name' => 'adminAreaSettings#getPolicySettings', 'url' => '/api/v1/admin/policies', 'verb' => 'GET', 'postfix' => 'AdminApi'], + ['name' => 'adminAreaSettings#updatePolicySettings', 'url' => '/api/v1/admin/policies', 'verb' => 'PUT', 'postfix' => 'AdminApi'], + ['name' => 'adminPeople#suites', 'url' => '/api/v1/admin/suites', 'verb' => 'GET'], + ['name' => 'adminPeople#offboard', 'url' => '/api/v1/admin/offboarding', 'verb' => 'POST'], + ['name' => 'adminApplication#index', 'url' => '/api/v1/admin/applications', 'verb' => 'GET'], + ['name' => 'adminApplication#create', 'url' => '/api/v1/admin/applications', 'verb' => 'POST'], + ['name' => 'adminApplication#approve', 'url' => '/api/v1/admin/applications/{id}/approve', 'verb' => 'POST'], + ['name' => 'adminApplication#reject', 'url' => '/api/v1/admin/applications/{id}/reject', 'verb' => 'POST'], + ['name' => 'adminApplication#getLeasePolicy', 'url' => '/api/v1/admin/applications/{id}/lease-policy', 'verb' => 'GET'], + ['name' => 'adminApplication#setLeasePolicy', 'url' => '/api/v1/admin/applications/{id}/lease-policy', 'verb' => 'PUT'], + ['name' => 'adminApplication#show', 'url' => '/api/v1/admin/applications/{id}', 'verb' => 'GET'], + ['name' => 'adminApplication#destroy', 'url' => '/api/v1/admin/applications/{id}', 'verb' => 'DELETE'], + ['name' => 'adminAudit#events', 'url' => '/api/v1/admin/audit', 'verb' => 'GET'], + ['name' => 'adminAudit#reports', 'url' => '/api/v1/admin/compliance/reports', 'verb' => 'GET'], + ['name' => 'adminAudit#generateReport', 'url' => '/api/v1/admin/compliance/reports', 'verb' => 'POST'], + ['name' => 'adminAudit#showReport', 'url' => '/api/v1/admin/compliance/reports/{id}', 'verb' => 'GET'], + ['name' => 'adminAudit#sinks', 'url' => '/api/v1/admin/siem/sinks', 'verb' => 'GET'], + ['name' => 'adminAudit#createSink', 'url' => '/api/v1/admin/siem/sinks', 'verb' => 'POST'], + ['name' => 'adminAudit#updateSink', 'url' => '/api/v1/admin/siem/sinks/{id}', 'verb' => 'PUT'], + ['name' => 'adminAudit#destroySink', 'url' => '/api/v1/admin/siem/sinks/{id}', 'verb' => 'DELETE'], + // EncryptionSuite CRUD. ['name' => 'encryptionSuite#index', 'url' => '/api/v1/suites', 'verb' => 'GET'], ['name' => 'encryptionSuite#show', 'url' => '/api/v1/suites/{id}', 'verb' => 'GET'], ['name' => 'encryptionSuite#create', 'url' => '/api/v1/suites', 'verb' => 'POST'], + ['name' => 'encryptionSuite#reenrol', 'url' => '/api/v1/suites/reenrol', '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'], @@ -85,6 +136,8 @@ // Secret CRUD. The nested link-shares route below is more specific and // is registered immediately after, so it still resolves correctly. + // Recently used widget (vault-recently-used); before the {id} wildcard. + ['name' => 'audit#recent', 'url' => '/api/v1/secrets/recent', 'verb' => 'GET'], ['name' => 'secret#index', 'url' => '/api/v1/secrets', 'verb' => 'GET'], ['name' => 'secret#create', 'url' => '/api/v1/secrets', 'verb' => 'POST'], // Batch import commit (secret-import D7). Accepts arrays of already @@ -94,8 +147,19 @@ // catch-all wildcard. ['name' => 'import#batchCreate', 'url' => '/api/v1/secrets/import-batch', 'verb' => 'POST'], ['name' => 'secret#show', 'url' => '/api/v1/secrets/{id}', 'verb' => 'GET'], - ['name' => 'secret#update', 'url' => '/api/v1/secrets/{id}', 'verb' => 'PUT'], - ['name' => 'secret#destroy', 'url' => '/api/v1/secrets/{id}', 'verb' => 'DELETE'], + ['name' => 'secretUpdate#update', 'url' => '/api/v1/secrets/{id}', 'verb' => 'PUT'], + // Trash and archive (vault-trash-and-archive): DELETE /{id} moves a secret to the trash. + ['name' => 'secretTrash#trash', 'url' => '/api/v1/secrets/{id}', 'verb' => 'DELETE'], + ['name' => 'secretTrash#restore', 'url' => '/api/v1/secrets/{id}/restore', 'verb' => 'POST'], + ['name' => 'secretTrash#purge', 'url' => '/api/v1/secrets/{id}/purge', 'verb' => 'DELETE'], + ['name' => 'secretTrash#archive', 'url' => '/api/v1/secrets/{id}/archive', 'verb' => 'POST'], + ['name' => 'secretTrash#unarchive', 'url' => '/api/v1/secrets/{id}/unarchive', 'verb' => 'POST'], + // Favourites, tags and last used (vault-favourites-tags-and-last-used), the caller's own rows only. + ['name' => 'secretOrganisation#favourite', 'url' => '/api/v1/secrets/{id}/favourite', 'verb' => 'PUT'], + ['name' => 'secretOrganisation#tags', 'url' => '/api/v1/secrets/{id}/tags', 'verb' => 'PUT'], + // A fill of a use-only copy, reported by the extension (sharing-use-only-and-expiring-shares §3.3). + ['name' => 'useOnly#used', 'url' => '/api/v1/secrets/{id}/used', 'verb' => 'POST'], + ['name' => 'secretOrganisation#tagIndex', 'url' => '/api/v1/tags', 'verb' => 'GET'], // Link sharing — authenticated CRUD (secret owner). ['name' => 'linkShare#index', 'url' => '/api/v1/secrets/{secretId}/link-shares', 'verb' => 'GET'], @@ -123,11 +187,14 @@ // Bulk direct-share registration + recipient-cert lookup (bulk-actions §6.1). ['name' => 'share#registerBatch', 'url' => '/api/v1/shares/register-batch', 'verb' => 'POST'], ['name' => 'share#recipientCertificate', 'url' => '/api/v1/shares/recipient-certificate', 'verb' => 'GET'], + ['name' => 'recipientStatus#status', 'url' => '/api/v1/shares/recipient-status', 'verb' => 'POST'], // POST, not GET: a candidate list does not belong in a query string, // and the sharee-search pages these ids come from can be long. ['name' => 'share#recipientCertificates', 'url' => '/api/v1/shares/recipient-certificates', 'verb' => 'POST'], ['name' => 'share#sync', 'url' => '/api/v1/secrets/{secretId}/sync', 'verb' => 'PUT'], ['name' => 'share#destroy', 'url' => '/api/v1/shares/{id}', 'verb' => 'DELETE'], + // Use-only flag and end date of a direct share (sharing-use-only-and-expiring-shares §2.1). + ['name' => 'share#update', 'url' => '/api/v1/shares/{id}', 'verb' => 'PATCH'], // Group sharing — implement-user-sharing §9.2. ['name' => 'groupShare#index', 'url' => '/api/v1/secrets/{secretId}/group-shares', 'verb' => 'GET'], @@ -227,6 +294,33 @@ // Offline cache (offline-readonly-cache §1.4) — owner-scoped // consolidated snapshot; 403 when the admin off switch is set. ['name' => 'offline#manifest', 'url' => '/api/v1/offline/manifest', 'verb' => 'GET'], + // Organisation account recovery (crypto-organisation-account-recovery). + ['name' => 'recoveryAdmin#show', 'url' => '/api/v1/recovery/admin', 'verb' => 'GET'], + ['name' => 'recoveryAdmin#update', 'url' => '/api/v1/recovery/admin', 'verb' => 'PUT'], + ['name' => 'recoveryAdmin#retireKey', 'url' => '/api/v1/recovery/admin/keys/{id}/retire', 'verb' => 'POST'], + ['name' => 'recoveryAdmin#enrolled', 'url' => '/api/v1/recovery/admin/enrolled', 'verb' => 'GET'], + ['name' => 'recoveryOfficer#overview', 'url' => '/api/v1/recovery/officer', 'verb' => 'GET'], + ['name' => 'recoveryOfficer#createKey', 'url' => '/api/v1/recovery/officer/keys', 'verb' => 'POST'], + ['name' => 'recoveryOfficer#ownCopy', 'url' => '/api/v1/recovery/officer/copy', 'verb' => 'GET'], + ['name' => 'recoveryOfficer#replaceOwnCopy', 'url' => '/api/v1/recovery/officer/keys/{keyId}/copy', 'verb' => 'PUT'], + ['name' => 'recoveryOfficer#approve', 'url' => '/api/v1/recovery/requests/{id}/approve', 'verb' => 'POST'], + ['name' => 'recoveryOfficer#decline', 'url' => '/api/v1/recovery/requests/{id}/decline', 'verb' => 'POST'], + ['name' => 'recoveryOfficer#handoff', 'url' => '/api/v1/recovery/requests/{id}/handoff', 'verb' => 'GET'], + ['name' => 'recoveryOfficer#postSealed', 'url' => '/api/v1/recovery/requests/{id}/sealed', 'verb' => 'POST'], + ['name' => 'recoveryUser#enrolment', 'url' => '/api/v1/recovery/enrolment', 'verb' => 'GET'], + ['name' => 'recoveryUser#enrol', 'url' => '/api/v1/recovery/enrolment', 'verb' => 'PUT'], + ['name' => 'recoveryUser#withdraw', 'url' => '/api/v1/recovery/enrolment', 'verb' => 'DELETE'], + ['name' => 'recoveryUser#myRequest', 'url' => '/api/v1/recovery/requests/mine', 'verb' => 'GET'], + ['name' => 'recoveryUser#createRequest', 'url' => '/api/v1/recovery/requests', 'verb' => 'POST'], + ['name' => 'recoveryUser#complete', 'url' => '/api/v1/recovery/requests/{id}/complete', 'verb' => 'POST'], + // New device approval (crypto-new-device-approval). The fixed paths come + // before the {id} ones. + ['name' => 'deviceApproval#status', 'url' => '/api/v1/device-approvals/status', 'verb' => 'GET'], + ['name' => 'deviceApproval#pending', 'url' => '/api/v1/device-approvals/pending', 'verb' => 'GET'], + ['name' => 'deviceApproval#create', 'url' => '/api/v1/device-approvals', 'verb' => 'POST'], + ['name' => 'deviceApproval#show', 'url' => '/api/v1/device-approvals/{id}', 'verb' => 'GET'], + ['name' => 'deviceApproval#approve', 'url' => '/api/v1/device-approvals/{id}/approve', 'verb' => 'POST'], + ['name' => 'deviceApproval#deny', 'url' => '/api/v1/device-approvals/{id}/deny', 'verb' => 'POST'], // Offline service worker (offline-readonly-cache §3) — served from the // app root with the correct JS MIME + app-root default scope. @@ -293,6 +387,27 @@ // Canonical discovery path. The pre-rename path below is still served // and is retired before the first stable release — see legacyDocument(). ['name' => 'discovery#document', 'url' => '/api/v1/app/.well-known/keepiq', 'verb' => 'GET'], + // Federated recipients (sharing-federated-recipients). The partner-facing + // endpoints are not routes: they live under /ocm/keepiq/... and are + // answered by FederationOcmRequestListener. + ['name' => 'federationPartner#index', 'url' => '/api/v1/federation/partners', 'verb' => 'GET'], + ['name' => 'federationPartner#preview', 'url' => '/api/v1/federation/partners/preview', 'verb' => 'POST'], + ['name' => 'federationPartner#create', 'url' => '/api/v1/federation/partners', 'verb' => 'POST'], + ['name' => 'federationPartner#update', 'url' => '/api/v1/federation/partners/{id}', 'verb' => 'PUT'], + ['name' => 'federationPartner#destroy', 'url' => '/api/v1/federation/partners/{id}', 'verb' => 'DELETE'], + ['name' => 'federation#recipientCertificate', 'url' => '/api/v1/federation/recipient-certificate', 'verb' => 'POST'], + ['name' => 'federation#status', 'url' => '/api/v1/federation/status', 'verb' => 'GET'], + // Federated shares (sharing-federated-recipients D4): the owner posts the + // ciphertext made for a partner's user; the recipient accepts or declines + // what arrived under "Incoming from other organisations". + ['name' => 'federatedShare#index', 'url' => '/api/v1/secrets/{secretId}/federated-shares', 'verb' => 'GET'], + ['name' => 'federatedShare#create', 'url' => '/api/v1/secrets/{secretId}/federated-shares', 'verb' => 'POST'], + ['name' => 'federatedShare#update', 'url' => '/api/v1/federated-shares/{id}', 'verb' => 'PUT'], + ['name' => 'federatedShare#destroy', 'url' => '/api/v1/federated-shares/{id}', 'verb' => 'DELETE'], + ['name' => 'federatedShare#suspend', 'url' => '/api/v1/federated-shares/{id}/suspend', 'verb' => 'POST'], + ['name' => 'federatedInbound#index', 'url' => '/api/v1/federation/incoming', 'verb' => 'GET'], + ['name' => 'federatedInbound#accept', 'url' => '/api/v1/federation/incoming/{id}/accept', 'verb' => 'POST'], + ['name' => 'federatedInbound#decline', 'url' => '/api/v1/federation/incoming/{id}/decline', 'verb' => 'POST'], ['name' => 'discovery#legacyDocument', 'url' => '/api/v1/app/.well-known/doriath', 'verb' => 'GET'], // JWT-Bearer token exchange (public; signature-verified). @@ -301,6 +416,10 @@ // Bearer-authenticated application secrets API (openconnector-secret-store-api). // JwtAuthMiddleware enforces the Authorization header before the controller runs. // The by-name route precedes {id} so its extra path segment resolves first. + // The calling application's own certificate and fingerprint + // (app-own-certificate), so a client can check envelopes without + // configuring the certificate. + ['name' => 'applicationCertificate#show', 'url' => '/api/v1/app/certificate', 'verb' => 'GET'], ['name' => 'applicationSecrets#index', 'url' => '/api/v1/app/secrets', 'verb' => 'GET'], ['name' => 'applicationSecrets#create', 'url' => '/api/v1/app/secrets', 'verb' => 'POST'], ['name' => 'applicationSecrets#byName', 'url' => '/api/v1/app/secrets/by-name/{name}', 'verb' => 'GET', @@ -317,10 +436,10 @@ ['name' => 'applicationSecretRequests#create', 'url' => '/api/v1/app/secret-requests', 'verb' => 'POST'], ['name' => 'machineLease#index', 'url' => '/api/v1/app/leases', 'verb' => 'GET'], - ['name' => 'machineLease#renew', 'url' => '/api/v1/app/leases/{id}/renew', 'verb' => 'POST'], ['name' => 'machineLease#revoke', 'url' => '/api/v1/app/leases/{id}/revoke', 'verb' => 'POST'], // Session-authenticated admin/owner lease management. ['name' => 'leaseAdmin#index', 'url' => '/api/v1/applications/{id}/leases', 'verb' => 'GET'], + ['name' => 'leaseAdmin#getPolicy', 'url' => '/api/v1/applications/{id}/lease-policy', 'verb' => 'GET'], ['name' => 'leaseAdmin#setPolicy', 'url' => '/api/v1/applications/{id}/lease-policy', 'verb' => 'PUT'], ['name' => 'leaseAdmin#revoke', 'url' => '/api/v1/leases/{leaseId}', 'verb' => 'DELETE'], @@ -361,6 +480,11 @@ ['name' => 'teamFolder#index', 'url' => '/api/v1/team-folders', 'verb' => 'GET'], ['name' => 'teamFolder#create', 'url' => '/api/v1/team-folders', 'verb' => 'POST'], ['name' => 'teamFolder#offboard', 'url' => '/api/v1/team-folders/offboard', 'verb' => 'POST'], + // Contributable team folders (admin-vault-policies §4.3): before any /{id} route. + ['name' => 'teamFolderContribution#contributable', 'url' => '/api/v1/team-folders/contributable', 'verb' => 'GET'], + ['name' => 'teamFolderContribution#ownershipFindings', 'url' => '/api/v1/team-folders/ownership-findings', 'verb' => 'GET'], + // admin-auto-confirm-members D4: before any /{id} route. + ['name' => 'teamFolder#pendingConfirmations', 'url' => '/api/v1/team-folders/pending-confirmations', 'verb' => 'GET'], ['name' => 'teamFolderMember#members', 'url' => '/api/v1/team-folders/{id}/members', 'verb' => 'GET'], ['name' => 'teamFolderMember#addMember', 'url' => '/api/v1/team-folders/{id}/members', 'verb' => 'POST'], ['name' => 'teamFolderMember#removeMember', 'url' => '/api/v1/team-folders/{id}/members/{memberId}', 'verb' => 'DELETE'], @@ -369,9 +493,17 @@ ['name' => 'share#writeContext', 'url' => '/api/v1/secrets/{id}/write-context', 'verb' => 'GET'], ['name' => 'teamFolder#reconcile', 'url' => '/api/v1/team-folders/{id}/reconcile', 'verb' => 'GET'], ['name' => 'teamFolder#registerShares', 'url' => '/api/v1/team-folders/{id}/shares', 'verb' => 'POST'], + // Write-grade member contribution (admin-vault-policies D5). + ['name' => 'teamFolderContribution#contribute', 'url' => '/api/v1/team-folders/{id}/secrets', 'verb' => 'POST'], + ['name' => 'teamFolderContribution#contributionContext', 'url' => '/api/v1/team-folders/{id}/contribution-context', 'verb' => 'GET'], ['name' => 'teamFolderMember#approveJoin', 'url' => '/api/v1/team-folders/{id}/approve-join', 'verb' => 'POST'], ['name' => 'teamFolder#destroy', 'url' => '/api/v1/team-folders/{id}', 'verb' => 'DELETE'], + // Admin member overview (admin-member-overview-and-offboarding D4): admin + // only, metadata only. Under /api/v1/admin/ so admin-public-api can + // document it without a rename. + ['name' => 'memberOverview#index', 'url' => '/api/v1/admin/members', 'verb' => 'GET'], + // Audit trail (add-secret-audit-trail §4.1). Specific /secret/{id} and // /me routes come before the admin instance-wide /audit collection. ['name' => 'audit#secret', 'url' => '/api/v1/audit/secret/{id}', 'verb' => 'GET'], @@ -380,7 +512,9 @@ // Password-health breach-check proxy (password-health §1.5). Prefix-only // k-anonymity forward to HIBP; double-gated (admin setting + user opt-in). - ['name' => 'breachProxy#range', 'url' => '/api/v1/breach-check/range/{prefix}', 'verb' => 'GET'], + // POST with the prefix in the body, never in the URI: Nextcloud stamps the + // request URI next to the user id on every log line (keepiq#866). + ['name' => 'breachProxy#range', 'url' => '/api/v1/breach-check/range', 'verb' => 'POST'], // GDPR data-subject endpoints (secret-export-gdpr D3/D4). All self-scoped // to the session user — no user selector. Master-password re-auth on the @@ -405,4 +539,40 @@ ['name' => 'extension#pair', 'url' => '/api/v1/extension/pair', 'verb' => 'POST'], ['name' => 'extension#unpair', 'url' => '/api/v1/extension/unpair', 'verb' => 'POST'], ['name' => 'extension#match', 'url' => '/api/v1/extension/match', 'verb' => 'GET'], -]); + // The idle lock maximum the extension clamps the user's choice to. + ['name' => 'extension#policy', 'url' => '/api/v1/extension/policy', 'verb' => 'GET'], + // A fill from the extension counts as a use (vault-favourites-tags-and-last-used); 404 for a row the caller does not hold. + ['name' => 'secretOrganisation#used', 'url' => '/api/v1/extension/used/{id}', 'verb' => 'POST'], +]; + +// Preferred path: OpenRegister's AppHost owns the canonical route table. +// class_exists() autoloads, and answers false rather than throwing when the +// class cannot be loaded. +if (class_exists('OCA\OpenRegister\AppHost\Routes') === true) { + return \OCA\OpenRegister\AppHost\Routes::standard($extra); +} + +// Fallback: OpenRegister is missing or disabled. Keep the routes whose +// controllers Keepiq ships itself, so the instance stays up and Keepiq +// degrades per endpoint instead of taking every app down with it. +return [ + 'routes' => array_merge( + [ + ['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'], + ['name' => 'settings#index', 'url' => '/api/settings', 'verb' => 'GET'], + ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], + ['name' => 'settings#update', 'url' => '/api/settings', 'verb' => 'PUT'], + ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + ], + $extra, + [ + [ + 'name' => 'dashboard#catchAll', + 'url' => '/{path}', + 'verb' => 'GET', + 'requirements' => ['path' => '(?!api/).+'], + 'defaults' => ['path' => ''], + ], + ] + ), +]; diff --git a/browser-extension/README.md b/browser-extension/README.md index a336114a4..400d34c47 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -1,7 +1,7 @@ # Keepiq browser extension A Manifest V3 WebExtension (Firefox / Chrome / Edge) that brings **autofill**, -**passkey provision**, and **TOTP** to the [Keepiq](../) secrets manager — +**passkey provision**, and **TOTP** to the [Keepiq](../) secrets manager, without weakening its zero-knowledge model. The extension is a **second end-to-end client**, exactly the shape ADR-003 @@ -12,41 +12,106 @@ and the server only ever ships **encrypted blobs**. The master password, the derived key, and plaintext never reach the server and are never written to `storage.local`/`storage.sync`. +## What it does + +For users, see `docs/browser-extension/using.md`. In short: + +- **Fill:** logins, one-time codes and passkeys, only into frames on the matched site; from the popup, the right-click menu or Ctrl+Shift+L. +- **Save:** a bar offers to save a new login or update a changed password, per tab, also after the login page redirects. +- **Popup:** This site, Vault (browse, detail, edit, folders), Generator, Send and Settings tabs; a pop-out window. +- **Unlock:** master password, a PIN for the browser session, fingerprint or face, or offline from the vault copy. +- **Offline:** an encrypted copy of the vault per account, synced every 15 minutes and after each change. +- **Clipboard:** every copy is cleared after the delay the user picks, by the worker. + +The specs: `browser-extension-autofill`, `extension-*`, `clients-*` and `item-name-limit` in `openspec/specs/`; the changes that built them are under `openspec/changes/archive/`. `openspec/references/keepiq-extension/mapping.md` maps the former keepiq-extension plans onto them. + ## Layout ``` browser-extension/ - manifest.json MV3 manifest + manifest.json MV3 manifest; manifests/browsers.mjs adds the per-browser parts + icons/ toolbar and store icons (scripts/render-icons.mjs makes them) + THIRD-PARTY-NOTICES.txt credits for the bundled word list and Argon2 src/ crypto/ the SAME recipe as the web app (re-exported from ../../src/crypto) - lib/ - api.js Keepiq API client (pair, match, list, get, create, update) - match.js registrable-domain / origin matching over unencrypted url/name - vault.js in-worker unlock/lock state + decrypt-on-demand background/ - service-worker.js holds the CryptoKey; unlock, lock timer, blob fetch, decrypt, - WebAuthn ceremony (passkey), TOTP compute - popup/ - popup.html / popup.js unlock, matched-credential list, save/update, TOTP code, lock - content/ - content-script.js field detection + fill (all_frames), submit-capture, OTP fill, - WebAuthn relay - inpage-shim.js page-context navigator.credentials shim (Firefox path) - passkey/webauthn.js create()/get() ceremony (client-side signing) - totp/ RFC 6238 (re-exported from ../../src/totp) - tests/ vitest unit + integration + service-worker.js entry: wires runtime messages, alarms and the OS lock to router.js + router.js accounts, unlock and lock, the PIN, fill to frames, the save + prompt per tab, the context menu and shortcut, settings + vault-handlers.js the Vault, Send and generator messages + vault-sync.js the vault copy per account and its sync + generator-handlers.js generator options, policy and history + clipboard-clear.js clears the clipboard after a copy (offscreen page in Chromium) + lib/ + api.js Keepiq API client: no cookies, https only, 401 signs out + server-url.js cleans a server address and refuses plain http + vault.js in-worker key per account, decrypt on demand + match.js host and registrable-domain matching, last use first + field-detect.js login fields, also in shadow roots and by label + pin-unlock.js the PIN-wrapped unlock key in session storage + extension-settings.js the browser-wide settings + ... item form, folder rules, send form, generator state, policy + popup/ the popup page and its views (vault, item detail, folders, + generator, send, clipboard) + content/ field detection and fill (all frames), submit capture and the + save bar, one-time code fill, password suggestions, WebAuthn relay + offscreen/ the hidden page that clears the clipboard in Chromium + passkey/ the WebAuthn create and get ceremony, signed in the extension + unlock/ the unlock window for fingerprint or face unlock + load-check/ headless smoke test of each package + capture/ screenshots and videos for docs/browser-extension/using.md +tests/extension/ vitest unit and integration tests (the real router and popup) ``` ## Zero-knowledge invariants (enforced by tests) - The `CryptoKey` is `extractable: false` and lives only in the service worker's - memory — never in `storage.*`, never in a request body. -- A paired-but-locked extension can list unencrypted names/URLs but cannot - decrypt any value. -- Autofill, WebAuthn signing, and TOTP computation all happen in the extension; + memory, never in `storage.*` and never in a request body. A PIN keeps only a + wrapped unlock key, in session storage, for the browser session. +- A passkey's private key never leaves the worker; the popup gets its site and + account only. +- A paired-but-locked extension can list unencrypted names and URLs but cannot + decrypt any value. The vault copy holds what the server stores: ciphertext and + plaintext metadata. +- Autofill, WebAuthn signing and TOTP computation all happen in the extension; the server sees only ciphertext. -- The extension auto-locks on idle timeout, browser/OS lock, worker termination, - and manual lock — clearing the key and all derived state. +- The extension locks on idle timeout, browser or OS lock, worker termination + and manual lock, clearing the key and what the popup shows. +- A web page may send only the messages a content script needs: + `capture-credential`, `capture-decision`, `capture-offer`, `frame-ready`, + `webauthn-create`, `webauthn-get`, `otp-field-detected`, + `generate-for-field` and `page-settings` (`PAGE_MESSAGES` in `router.js`). + None of them returns vault data. Everything that unlocks, lists, fills, saves + or changes settings needs one of the extension's own pages as sender, as the + top frame of its tab. + +## Accounts, lock delay and fingerprint unlock + +- Up to five accounts, on one or more servers. Each has its own key, lock + state, idle timer and settings. Matching, filling and saving use the active + account, picked in the popup header. A fill is refused unless the login came + from the active account's own match for the page that is open. +- Each account picks its idle lock delay (1, 5, 15, 30, 60 or 240 minutes, + 15 by default). The administrator sets the longest one in the Keepiq admin + settings; the extension reads it at every unlock and uses the shorter of the + two. When it cannot be read, the extension caps the delay at 15 minutes. +- Fingerprint or face unlock enrols a platform passkey with the WebAuthn PRF + extension, with the extension's own origin as relying party. The server + stores only the PRF-wrapped unlock key, next to the web app's passkeys, + where the owner sees it as "Browser extension" and can revoke it. The option + shows only where the browser exposes a user-verifying platform authenticator. + +Which browsers pass that check has not been tried by hand yet. Open item: +try Chrome, Edge and Firefox (current versions) on a machine with a +fingerprint reader or Windows Hello, and record the result here. + +## Install + +Once the store listings are live, install Keepiq from the Chrome Web Store, +Firefox Add-ons or Edge Add-ons. Each `extension-v` GitHub release +also carries the packages. Organisations can force-install it, see +`docs/browser-extension/rollout.md`. Releasing is described in +`docs/browser-extension/release.md`. ## Build @@ -56,3 +121,25 @@ The extension shares the web app's `src/crypto` and `src/totp` modules verbatim ```sh npm run build:extension # from the repo root ``` + +The build writes one package per browser, from `manifest.json` plus the +overlay in `manifests/browsers.mjs`: + +- `dist/chromium`: Chrome and Edge (load unpacked from `chrome://extensions`). +- `dist/firefox`: Firefox 115 or later (load from `about:debugging` as a + temporary add-on). +- Safari: made from `dist/chromium` on a Mac, see `safari/README.md`. Not + yet part of the pipeline. + +`--target chrome|firefox` builds one package, `--outdir ` writes it +elsewhere, and `EXTENSION_VERSION=1.2.0` sets the manifest version, as the +release workflow does from the tag. + +`node browser-extension/load-check/chromium.mjs` and `firefox.mjs` start each +package headless and check its background answers the popup; the +`Browser extension` workflow runs both. + +`browser-extension/capture/` records the screenshots and videos in +`docs/browser-extension/using.md`, against a local Nextcloud and local demo +sites. The `Docs media` workflow runs it and uploads the media; see the +header of `capture/chromium.mjs` to run it yourself. diff --git a/browser-extension/THIRD-PARTY-NOTICES.txt b/browser-extension/THIRD-PARTY-NOTICES.txt new file mode 100644 index 000000000..c4fd1971a --- /dev/null +++ b/browser-extension/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,21 @@ +Keepiq browser extension: third-party material + +EFF large word list + Used by the passphrase generator. + Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt + Copyright Electronic Frontier Foundation. + Licensed under the Creative Commons Attribution 3.0 United States + licence (CC BY 3.0 US): https://creativecommons.org/licenses/by/3.0/us/ + The dice numbers were removed; the words keep their order. + +argon2-browser (bundled, with its WebAssembly build of Argon2) + Used to protect password-protected sends. + Licensed under the MIT licence: + + Copyright © 2021 Antelle + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/browser-extension/build.mjs b/browser-extension/build.mjs index a4d123695..11642b2b2 100644 --- a/browser-extension/build.mjs +++ b/browser-extension/build.mjs @@ -1,45 +1,109 @@ /** - * Build the Keepiq MV3 extension with esbuild. Each entry is bundled to a - * single self-contained ESM/IIFE file (MV3 forbids remote code + runtime chunk - * loading), inlining the shared `src/crypto` and `src/totp` modules verbatim so - * the PHP↔JS↔extension crypto stays in lockstep (ADR-003). + * Build the Keepiq MV3 extension with esbuild, once per browser + * (clients-browser-builds): `dist/chromium` (Chrome, Edge; also the input of + * the Safari converter) and `dist/firefox`. Each entry is bundled to a single + * self-contained file (MV3 forbids remote code + runtime chunk loading), + * inlining the shared `src/crypto` and `src/totp` modules verbatim so the + * PHP↔JS↔extension crypto stays in lockstep (ADR-003). * * Usage: node browser-extension/build.mjs [--watch] + * [--target chrome|firefox | --browser chromium|firefox] [--outdir ] + * + * `--target chrome` builds the Chromium package (Chrome and Edge), `--target + * firefox` the Firefox one (extension-store-release D1). EXTENSION_VERSION + * (from the `extension-v` tag) becomes the manifest version. */ import { build, context } from 'esbuild' -import { cp, mkdir, rm } from 'node:fs/promises' +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { BROWSERS, manifestFor } from './manifests/browsers.mjs' const root = dirname(fileURLToPath(import.meta.url)) -const outdir = resolve(root, 'dist') +const argValue = (name) => + process.argv.includes(name) ? process.argv[process.argv.indexOf(name) + 1] : null +const dist = argValue('--outdir') + ? resolve(process.cwd(), argValue('--outdir')) + : resolve(root, 'dist') const watch = process.argv.includes('--watch') +// `--target chrome` is the store-facing name of the Chromium package. +const TARGETS = { chrome: 'chromium', chromium: 'chromium', firefox: 'firefox' } +const requested = argValue('--target') || argValue('--browser') +if (requested && !TARGETS[requested]) { + console.error('unknown target: ' + requested + ' (chrome or firefox)') + process.exit(2) +} +const only = requested ? TARGETS[requested] : null const common = { bundle: true, - format: 'esm', - target: ['chrome110', 'firefox110'], + target: ['chrome110', 'firefox115'], logLevel: 'info', sourcemap: false, legalComments: 'none', + // Argon2id's WebAssembly is bundled as bytes (password-protected sends). + loader: { '.wasm': 'binary' }, + // The emscripten glue of argon2-browser has Node-only branches; they never + // run in a browser, so their modules stay unresolved. + external: ['fs', 'path', 'crypto'], } -// The content script must be a classic (non-module) IIFE — content scripts do -// not support ES module imports. -const entries = [ - { in: resolve(root, 'src/background/service-worker.js'), out: 'service-worker', format: 'esm' }, - { in: resolve(root, 'src/popup/popup.js'), out: 'popup', format: 'esm' }, - { in: resolve(root, 'src/content/content-script.js'), out: 'content-script', format: 'iife' }, - { in: resolve(root, 'src/content/inpage-shim.js'), out: 'inpage-shim', format: 'iife' }, - { in: resolve(root, 'src/passkey/consent.js'), out: 'consent', format: 'esm' }, -] +/** + * The bundles for one browser. The content script is always a classic IIFE + * (content scripts cannot import); the worker is a module on Chromium and a + * classic background script on Firefox. + * + * @param {string} browser chromium or firefox. + * @return {Array<{in: string, out: string, format: string}>} The entries. + */ +function entriesFor(browser) { + return [ + { + in: resolve(root, 'src/background/service-worker.js'), + out: 'service-worker', + format: browser === 'firefox' ? 'iife' : 'esm', + }, + { in: resolve(root, 'src/popup/popup.js'), out: 'popup', format: 'esm' }, + { + in: resolve(root, 'src/content/content-script.js'), + out: 'content-script', + format: 'iife', + }, + { + in: resolve(root, 'src/content/inpage-shim.js'), + out: 'inpage-shim', + format: 'iife', + }, + { + in: resolve(root, 'src/passkey/consent.js'), + out: 'consent', + format: 'esm', + }, + { + in: resolve(root, 'src/unlock/unlock.js'), + out: 'unlock', + format: 'esm', + }, + { + in: resolve(root, 'src/offscreen/offscreen.js'), + out: 'offscreen', + format: 'esm', + }, + ] +} -async function run() { +async function buildBrowser(browser, base) { + const outdir = resolve(dist, browser) + // Clear only this browser's package, never the whole output directory. await rm(outdir, { recursive: true, force: true }) await mkdir(outdir, { recursive: true }) - - for (const e of entries) { - const opts = { ...common, entryPoints: [e.in], outfile: resolve(outdir, e.out + '.js'), format: e.format } + for (const e of entriesFor(browser)) { + const opts = { + ...common, + entryPoints: [e.in], + outfile: resolve(outdir, e.out + '.js'), + format: e.format, + } if (watch) { const ctx = await context(opts) await ctx.watch() @@ -47,12 +111,47 @@ async function run() { await build(opts) } } - - // Static assets: manifest + popup html/css (service-worker/popup ESM live in dist/). - await cp(resolve(root, 'manifest.json'), resolve(outdir, 'manifest.json')) + await writeFile( + resolve(outdir, 'manifest.json'), + JSON.stringify(manifestFor(base, browser), null, 2) + '\n', + ) await cp(resolve(root, 'src/popup/popup.html'), resolve(outdir, 'popup.html')) await cp(resolve(root, 'src/popup/popup.css'), resolve(outdir, 'popup.css')) - await cp(resolve(root, 'src/passkey/consent.html'), resolve(outdir, 'consent.html')) + await cp( + resolve(root, 'src/passkey/consent.html'), + resolve(outdir, 'consent.html'), + ) + await cp(resolve(root, 'src/unlock/unlock.html'), resolve(outdir, 'unlock.html')) + await cp( + resolve(root, 'src/offscreen/offscreen.html'), + resolve(outdir, 'offscreen.html'), + ) + // The toolbar and store icons, and the notices for bundled third-party + // material (clients-extension-gaps). + await cp(resolve(root, 'icons'), resolve(outdir, 'icons'), { recursive: true }) + await cp( + resolve(root, 'THIRD-PARTY-NOTICES.txt'), + resolve(outdir, 'THIRD-PARTY-NOTICES.txt'), + ) +} + +async function run() { + const base = JSON.parse(await readFile(resolve(root, 'manifest.json'), 'utf8')) + const version = process.env.EXTENSION_VERSION + if (version) { + // Store manifests take one to four dot-separated integers. + if (!/^\d+(\.\d+){0,3}$/.test(version)) { + throw new Error('EXTENSION_VERSION must look like 1.2.0, got ' + version) + } + base.version = version + } + for (const browser of BROWSERS) { + if (only && only !== browser) continue + await buildBrowser(browser, base) + } } -run().catch((e) => { console.error(e); process.exit(1) }) +run().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/browser-extension/capture/.gitignore b/browser-extension/capture/.gitignore new file mode 100644 index 000000000..89f9ac04a --- /dev/null +++ b/browser-extension/capture/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/browser-extension/capture/chromium.mjs b/browser-extension/capture/chromium.mjs new file mode 100644 index 000000000..d5d684330 --- /dev/null +++ b/browser-extension/capture/chromium.mjs @@ -0,0 +1,360 @@ +/** + * Capture the screenshots and videos of the Chromium package for the user + * documentation (docs/browser-extension/using.md). + * + * Needs the capture Nextcloud from compose.yaml + setup.sh, and the built + * package (npm run build:extension). Writes PNG screenshots and webm videos + * to browser-extension/capture/out/media/. + * + * node browser-extension/capture/chromium.mjs + * + * KEEPIQ_CAPTURE_SERVER is the Nextcloud (http://localhost:8188 by default). + * The extension pairs with it as https://cloud.example.com through the demo + * server in fixtures.mjs, and every website it visits is a local demo page. + * + * The popup runs as an extension page in a tab, pinned to the site tab with + * ?tabId=, as a popped-out popup is. That is the same page the toolbar opens, + * and Playwright can record it. The vault is filled with demo items through + * the extension itself, so every value is encrypted as a user's would be. + */ +import { chromium } from '@playwright/test' +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { CLOUD_HOST, DEMO_DOMAINS, PASSKEY_HOST, startFixtures } from './fixtures.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const pkg = resolve(process.argv[2] || join(here, '..', 'dist', 'chromium')) +const OUT = join(here, 'out', 'media') +const SERVER = process.env.KEEPIQ_CAPTURE_SERVER || 'http://localhost:8188' +const HTTPS_PORT = Number(process.env.KEEPIQ_CAPTURE_HTTPS_PORT || 8443) +const PROXY_PORT = Number(process.env.KEEPIQ_CAPTURE_PROXY_PORT || 8444) +const APP_PASSWORD = readFileSync(join(here, 'out', 'app-password'), 'utf8').trim() +const MASTER = process.env.KEEPIQ_CAPTURE_MASTER || 'Oj' +const POPUP = { width: 380, height: 600 } +const SITE = { width: 1024, height: 640 } + +/** The demo items, created through the extension after the first unlock. */ +const DEMO_ITEMS = [ + { name: 'Webmail (demo)', url: 'https://webmail.example.com', login: 'anna.demo@example.com', folder: 'Personal' }, + { name: 'Bank (demo)', url: 'https://bank.example.net', login: '40817265', folder: 'Personal' }, + { name: 'Intranet (demo)', url: 'https://intranet.example.org', login: 'anna.demo', folder: 'Work' }, + { name: 'Project board (demo)', url: 'https://board.example.org', login: 'anna.demo@example.com', folder: 'Work' }, + { name: 'Router admin (demo)', url: 'https://router.example', login: 'admin', folder: null }, +] + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const log = (...args) => console.log('[capture]', ...args) + +mkdirSync(OUT, { recursive: true }) +const profile = mkdtempSync(join(tmpdir(), 'keepiq-capture-')) +const videoTmp = mkdtempSync(join(tmpdir(), 'keepiq-capture-video-')) +const fixtures = await startFixtures({ httpsPort: HTTPS_PORT, proxyPort: PROXY_PORT, nextcloud: SERVER }) +// The browser that is running, closed on failure too. +let open = null + +/** + * Start Chromium with the package, recording video at `size`. The profile is + * kept between sessions, so the account stays paired; the vault locks when + * the browser closes, as it does for a user. + * + * @param {{width: number, height: number}} size The video and viewport size. + */ +async function launch(size) { + const context = await chromium.launchPersistentContext(profile, { + channel: 'chromium', + headless: true, + viewport: size, + ignoreHTTPSErrors: true, + recordVideo: { dir: videoTmp, size }, + args: [ + `--disable-extensions-except=${pkg}`, + `--load-extension=${pkg}`, + `--host-resolver-rules=${DEMO_DOMAINS.map((d) => `MAP *.${d} 127.0.0.1:${HTTPS_PORT}`).join(',')}`, + '--ignore-certificate-errors', + ], + }) + open = context + const worker = + context.serviceWorkers()[0] + || (await context.waitForEvent('serviceworker', { timeout: 15000 })) + return { context, worker, extId: new URL(worker.url()).host } +} + +/** The browser's id of the tab showing `url`. */ +async function tabIdOf(worker, url) { + const id = await worker.evaluate(async (u) => { + const tabs = await chrome.tabs.query({}) + return tabs.find((t) => t.url && t.url.startsWith(u))?.id + }, url) + if (id === undefined) throw new Error('no tab shows ' + url) + return id +} + +/** Open the popup page, pinned to the site tab `tabId`. */ +async function openPopup(session, tabId) { + const popup = await session.context.newPage() + await popup.setViewportSize(POPUP) + await popup.goto(`chrome-extension://${session.extId}/popup.html?tabId=${tabId}`) + return popup +} + +/** Close a page and keep its video as `name`, or drop it without a name. */ +async function closePage(page, name) { + const video = page.video() + await page.close() + if (video && name) { + await video.saveAs(join(OUT, name)) + log('video', name) + } + await video?.delete().catch(() => {}) +} + +async function shot(page, name, options = {}) { + await page.screenshot({ path: join(OUT, name), ...options }) + log('screenshot', name) +} + +/** Ask the worker from an extension page. */ +function ask(page, type, payload) { + return page.evaluate( + ([t, p]) => chrome.runtime.sendMessage({ type: t, payload: p }), + [type, payload], + ) +} + +async function unlock(popup, { slow = false } = {}) { + await popup.waitForSelector('#view-locked:not([hidden])', { timeout: 30000 }) + if (slow) await popup.type('#unlock-master', MASTER, { delay: 120 }) + else await popup.fill('#unlock-master', MASTER) + return async () => { + await popup.click('#unlock-submit') + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 60000 }) + } +} + +/** A random demo password. */ +function demoPassword() { + const chars = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!#%+=?' + const bytes = new Uint8Array(20) + globalThis.crypto.getRandomValues(bytes) + return [...bytes].map((b) => chars[b % chars.length]).join('') +} + +let failed = false +try { + // ── Session 1: the popup, recorded at popup size ───────────────────── + let session = await launch(POPUP) + const webmail = await session.context.newPage() + await webmail.goto('https://webmail.example.com/') + const webmailTab = await tabIdOf(session.worker, 'https://webmail.example.com/') + + // Pair and unlock. + let popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#view-pair:not([hidden])') + await sleep(600) + await popup.type('#pair-url', `https://${CLOUD_HOST}`, { delay: 40 }) + await popup.type('#pair-user', 'admin', { delay: 60 }) + await popup.fill('#pair-app-password', APP_PASSWORD) + await shot(popup, 'pair.png') + await popup.click('#pair-submit') + const submitUnlock = await unlock(popup, { slow: true }) + await shot(popup, 'unlock.png') + await submitUnlock() + await sleep(800) + await closePage(popup, 'pair-and-unlock.webm') + + // The demo items, through the extension's own save. + popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 30000 }) + const index = await ask(popup, 'vault-list') + if (index?.error) throw new Error('vault-list: ' + index.error) + // A second run starts from an empty vault again. + for (const item of index.items || []) { + const trashed = await ask(popup, 'vault-trash', { id: item.id }) + if (!trashed?.ok) throw new Error('trashing ' + item.id + ': ' + JSON.stringify(trashed)) + } + const sends = await ask(popup, 'send-list') + for (const send of sends?.sends || []) { + const ended = await ask(popup, 'send-revoke', { id: send.id }) + if (!ended?.ok) throw new Error('ending send ' + send.id + ': ' + JSON.stringify(ended)) + } + const loginType = index.types.find((t) => t.name === 'login') + if (!loginType) throw new Error('the server has no login type') + for (const item of DEMO_ITEMS) { + const folder = item.folder ? index.folders.find((f) => f.name === item.folder) : null + if (item.folder && !folder) throw new Error('no folder ' + item.folder) + const saved = await ask(popup, 'vault-save', { + typeId: loginType.id, + typeName: 'login', + changes: { + name: item.name, + url: item.url, + login: item.login, + key: demoPassword(), + folderId: folder ? folder.id : null, + }, + }) + if (!saved?.ok) throw new Error('saving ' + item.name + ': ' + JSON.stringify(saved)) + } + log('demo items', DEMO_ITEMS.length) + await closePage(popup) + + // This site: the logins for the open page. + popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#candidates .candidate-fill', { timeout: 30000 }) + await sleep(400) + await shot(popup, 'this-site.png') + await closePage(popup) + + // Vault: browse, search, open an item. + popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 30000 }) + await sleep(500) + await popup.click('#tab-vault') + await popup.waitForFunction( + () => document.querySelectorAll('#vault-list button:not([disabled])').length >= 5, + null, + { timeout: 30000 }, + ) + await sleep(800) + await shot(popup, 'vault.png') + await popup.type('#vault-search', 'bank', { delay: 120 }) + await sleep(800) + await popup.click('#vault-list button:not([disabled])') + await popup.waitForSelector('#vault-detail:not([hidden])', { timeout: 30000 }) + await sleep(600) + await popup.click('#detail-reveal').catch(() => {}) + await sleep(1200) + await shot(popup, 'vault-item.png') + await closePage(popup, 'vault.webm') + + // Generator: a password, then a passphrase. + popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 30000 }) + await sleep(500) + await popup.click('#tab-generator') + await popup.waitForFunction(() => document.getElementById('gen-output').textContent.length > 0) + await sleep(800) + await shot(popup, 'generator.png') + await popup.click('#gen-regenerate') + await sleep(900) + await popup.click('#gen-tab-passphrase') + await popup.waitForFunction(() => document.getElementById('gen-output').textContent.includes('-')) + await sleep(1200) + await shot(popup, 'generator-passphrase.png') + await closePage(popup, 'generator.webm') + + // Send: share a short text through a link that expires. + popup = await openPopup(session, webmailTab) + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 30000 }) + await sleep(500) + await popup.click('#tab-send') + await popup.waitForSelector('#send-text', { state: 'visible' }) + await popup.type('#send-text', 'The door code for the demo office is 4711.', { delay: 35 }) + await popup.selectOption('#send-expiry', '1h').catch(() => {}) + await sleep(400) + await shot(popup, 'send-form.png') + await popup.click('#send-create') + await popup.waitForSelector('#send-result:not([hidden])', { timeout: 30000 }) + await popup.$eval('#send-result', (el) => el.scrollIntoView({ block: 'start' })) + await sleep(1500) + await shot(popup, 'send-link.png') + await closePage(popup, 'send.webm') + + await closePage(webmail) + await session.context.close() + + // ── Session 2: the websites, recorded at page size ─────────────────── + session = await launch(SITE) + const unlockTab = await session.context.newPage() + await unlockTab.goto('https://webmail.example.com/') + popup = await openPopup(session, await tabIdOf(session.worker, 'https://webmail.example.com/')) + await (await unlock(popup))() + await closePage(popup) + await closePage(unlockTab) + + // Fill a login from the popup. + const login = await session.context.newPage() + await login.goto('https://webmail.example.com/') + await sleep(1200) + popup = await openPopup(session, await tabIdOf(session.worker, 'https://webmail.example.com/')) + await popup.waitForSelector('#candidates .candidate-fill', { timeout: 30000 }) + await shot(popup, 'fill-popup.png') + await popup.click('#candidates .candidate-fill') + await login.bringToFront() + await login.waitForFunction(() => document.getElementById('password').value.length > 0, null, { timeout: 15000 }) + await sleep(1500) + await shot(login, 'fill-filled.png') + if (!popup.isClosed()) await closePage(popup) + await closePage(login, 'fill.webm') + + // Sign in with a login Keepiq does not know: the save bar. + const forum = await session.context.newPage() + await forum.goto('https://forum.example/') + await sleep(1000) + await forum.click('#user') + await forum.keyboard.type('anna_demo', { delay: 90 }) + await forum.click('#password') + await forum.keyboard.type('Demo-forum-2026!', { delay: 90 }) + await sleep(500) + await forum.click('button[type="submit"]') + await forum.waitForURL('**/signed-in', { timeout: 15000 }) + await forum.waitForSelector('#keepiq-save-prompt', { state: 'attached', timeout: 15000 }) + await sleep(1200) + await shot(forum, 'save-prompt.png') + // The main button has the focus; Enter is a user's key press. + await forum.keyboard.press('Enter') + await sleep(2500) + await shot(forum, 'save-done.png') + await sleep(1000) + popup = await openPopup(session, await tabIdOf(session.worker, 'https://forum.example/')) + await popup.waitForSelector('#view-unlocked:not([hidden])', { timeout: 30000 }) + const saved = await ask(popup, 'vault-list') + await closePage(popup) + await closePage(forum, 'save-prompt.webm') + if (!saved.items?.some((i) => (i.url || '').includes('forum.example'))) { + throw new Error('the save bar did not save the forum login') + } + + // Create a passkey, then sign in with it. + const pk = await session.context.newPage() + await pk.goto(`https://${PASSKEY_HOST}/`) + await sleep(1000) + for (const [button, consentShot, resultShot, expect] of [ + ['#create', 'passkey-consent.png', 'passkey-created.png', 'Passkey created'], + ['#signin', 'passkey-signin-consent.png', 'passkey-signed-in.png', 'Signed in'], + ]) { + const consentOpens = session.context.waitForEvent('page', { + predicate: (p) => p.url().includes('consent.html'), + timeout: 20000, + }) + await pk.click(button) + const consent = await consentOpens + await consent.setViewportSize({ width: 380, height: 260 }) + await consent.waitForSelector('#allow') + await sleep(800) + await shot(consent, consentShot) + await consent.click('#allow') + await pk.bringToFront() + await pk.waitForFunction((t) => document.getElementById('result').textContent.includes(t), expect, { + timeout: 30000, + }) + await sleep(1500) + await shot(pk, resultShot) + if (!consent.isClosed()) await closePage(consent) + else await consent.video()?.delete().catch(() => {}) + } + await closePage(pk, 'passkey.webm') + await session.context.close() +} catch (e) { + failed = true + console.error('capture failed:', e.stack || e) +} finally { + await open?.close().catch(() => {}) + await fixtures.close() + rmSync(profile, { recursive: true, force: true }) + rmSync(videoTmp, { recursive: true, force: true }) +} +process.exitCode = failed ? 1 : 0 diff --git a/browser-extension/capture/compose.yaml b/browser-extension/capture/compose.yaml new file mode 100644 index 000000000..9e21531c7 --- /dev/null +++ b/browser-extension/capture/compose.yaml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# One Nextcloud with Keepiq and OpenRegister, for capturing the browser +# extension's screenshots and videos (browser-extension/capture/). PostgreSQL, +# because Keepiq's migrations use json columns that SQLite refuses. +# +# docker compose -p kq-media -f browser-extension/capture/compose.yaml up -d +# bash browser-extension/capture/setup.sh kq-media . +# +# KQ_MEDIA_PORT moves the port (8188 by default). KQ_MEDIA_NC_IMAGE and +# KQ_MEDIA_DB_IMAGE swap the images, for a machine that already has another +# Nextcloud 32 to 35 or PostgreSQL 16 image. +# Tear down with `down -v`. + +services: + db: + image: ${KQ_MEDIA_DB_IMAGE:-postgres:16-alpine} + environment: + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + volumes: [db:/var/lib/postgresql/data] + + nc: + image: ${KQ_MEDIA_NC_IMAGE:-nextcloud:35-apache} + depends_on: [db] + ports: ['${KQ_MEDIA_PORT:-8188}:80'] + environment: + POSTGRES_HOST: db + POSTGRES_DB: nextcloud + POSTGRES_USER: nextcloud + POSTGRES_PASSWORD: nextcloud + NEXTCLOUD_ADMIN_USER: admin + NEXTCLOUD_ADMIN_PASSWORD: admin + NEXTCLOUD_TRUSTED_DOMAINS: localhost localhost:${KQ_MEDIA_PORT:-8188} + volumes: + - nc:/var/www/html + +volumes: + db: + nc: diff --git a/browser-extension/capture/firefox.mjs b/browser-extension/capture/firefox.mjs new file mode 100644 index 000000000..450e2edf0 --- /dev/null +++ b/browser-extension/capture/firefox.mjs @@ -0,0 +1,109 @@ +/** + * Capture screenshots of the Firefox package: the popup and a fill. + * + * Run after chromium.mjs against the same instance: that script fills the + * vault with the demo items this one shows. Needs Firefox, geckodriver and + * `selenium-webdriver`, like load-check/firefox.mjs. The add-on UUID is + * pinned so the popup URL is known. Writes to browser-extension/capture/out/media/. + * + * node browser-extension/capture/firefox.mjs + */ +import { Builder, By, until } from 'selenium-webdriver' +import firefox from 'selenium-webdriver/firefox.js' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { GECKO_ID } from '../manifests/browsers.mjs' +import { CLOUD_HOST, DEMO_DOMAINS, startFixtures } from './fixtures.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const pkg = resolve(process.argv[2] || join(here, '..', 'dist', 'firefox')) +const OUT = join(here, 'out', 'media') +const SERVER = process.env.KEEPIQ_CAPTURE_SERVER || 'http://localhost:8188' +const HTTPS_PORT = Number(process.env.KEEPIQ_CAPTURE_HTTPS_PORT || 8443) +const PROXY_PORT = Number(process.env.KEEPIQ_CAPTURE_PROXY_PORT || 8444) +const APP_PASSWORD = readFileSync(join(here, 'out', 'app-password'), 'utf8').trim() +const MASTER = process.env.KEEPIQ_CAPTURE_MASTER || 'Oj' +const UUID = '7a1c7c52-5b0f-4a8e-9d53-000000000002' +const POPUP_URL = `moz-extension://${UUID}/popup.html` + +// Only the demo names go through the proxy; everything else stays direct. +const PAC = `function FindProxyForURL(url, host) { + var demo = ${JSON.stringify(DEMO_DOMAINS)}; + for (var i = 0; i < demo.length; i++) { + if (host === demo[i] || dnsDomainIs(host, '.' + demo[i])) return 'PROXY 127.0.0.1:${PROXY_PORT}'; + } + return 'DIRECT'; +}` + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +mkdirSync(OUT, { recursive: true }) +const fixtures = await startFixtures({ httpsPort: HTTPS_PORT, proxyPort: PROXY_PORT, nextcloud: SERVER }) + +const options = new firefox.Options() + .addArguments('-headless') + .setAcceptInsecureCerts(true) + .setPreference('extensions.webextensions.uuids', JSON.stringify({ [GECKO_ID]: UUID })) + .setPreference('network.proxy.type', 2) + .setPreference('network.proxy.autoconfig_url', 'data:text/javascript,' + encodeURIComponent(PAC)) + +async function shot(driver, name) { + writeFileSync(join(OUT, name), await driver.takeScreenshot(), 'base64') + console.log('[capture] screenshot', name) +} + +async function visible(driver, css) { + const el = await driver.wait(until.elementLocated(By.css(css)), 30000) + await driver.wait(until.elementIsVisible(el), 60000) + return el +} + +let driver +let failed = false +try { + driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build() + await driver.installAddon(pkg, true) + await driver.manage().window().setRect({ width: 1024, height: 640 }) + await driver.get('https://webmail.example.com/') + const siteWindow = await driver.getWindowHandle() + + await driver.switchTo().newWindow('tab') + await driver.get(POPUP_URL) + const tabId = await driver.executeAsyncScript(function (done) { + browser.tabs + .query({}) + .then((tabs) => done((tabs.find((t) => (t.url || '').startsWith('https://webmail.example.com/')) || {}).id)) + }) + if (tabId === undefined || tabId === null) throw new Error('no tab shows the webmail page') + await driver.manage().window().setRect({ width: 380, height: 600 }) + await driver.get(`${POPUP_URL}?tabId=${tabId}`) + + await (await visible(driver, '#pair-url')).sendKeys(`https://${CLOUD_HOST}`) + await driver.findElement(By.css('#pair-user')).sendKeys('admin') + await driver.findElement(By.css('#pair-app-password')).sendKeys(APP_PASSWORD) + await driver.findElement(By.css('#pair-submit')).click() + await (await visible(driver, '#unlock-master')).sendKeys(MASTER) + await driver.findElement(By.css('#unlock-submit')).click() + await visible(driver, '#candidates .candidate-fill') + await sleep(800) + await shot(driver, 'firefox-this-site.png') + + await driver.findElement(By.css('#candidates .candidate-fill')).click() + await sleep(1500) + await driver.switchTo().window(siteWindow) + await driver.manage().window().setRect({ width: 1024, height: 640 }) + await driver.wait( + async () => (await driver.findElement(By.css('#password')).getAttribute('value')).length > 0, + 15000, + ) + await sleep(500) + await shot(driver, 'firefox-fill.png') +} catch (e) { + failed = true + console.error('firefox capture failed:', e.stack || e) +} finally { + await driver?.quit() + await fixtures.close() +} +process.exitCode = failed ? 1 : 0 diff --git a/browser-extension/capture/fixtures.mjs b/browser-extension/capture/fixtures.mjs new file mode 100644 index 000000000..b6d4c9668 --- /dev/null +++ b/browser-extension/capture/fixtures.mjs @@ -0,0 +1,221 @@ +/** + * Local stand-ins for the websites the capture visits, so no third-party + * site is ever opened. + * + * One https server answers every demo name by its Host header: + * demo login pages, a WebAuthn demo page, and cloud.example.com, which + * forwards to the capture Nextcloud so the extension pairs with an https + * address as a real user would. The certificate is self-signed and made per + * run; the browsers are told to accept it. Chromium reaches the server through + * --host-resolver-rules, Firefox through a small CONNECT proxy. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createServer as createHttpServer, request as httpRequest } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import { connect } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +/** + * The demo sites, by host. They sit on different registrable domains + * (example.com, example.net, example.org and the .example name), because + * Keepiq offers every login of the same site under This site. + */ +export const SITES = { + 'webmail.example.com': { title: 'Webmail (demo)', field: 'Email address' }, + 'bank.example.net': { title: 'Bank (demo)', field: 'Customer number' }, + 'forum.example': { title: 'Community forum (demo)', field: 'Username' }, +} + +export const PASSKEY_HOST = 'passkeys.example.com' + +/** The names the demo hosts live under, all reserved for examples (RFC 2606). */ +export const DEMO_DOMAINS = ['example.com', 'example.net', 'example.org', 'example'] +export const CLOUD_HOST = 'cloud.example.com' + +const STYLE = ` + body { font: 16px/1.5 system-ui, sans-serif; margin: 0; background: #f3f5f7; color: #1d2733; } + header { background: #1d4f91; color: #fff; padding: 14px 32px; font-weight: 600; } + main { max-width: 380px; margin: 56px auto; background: #fff; padding: 28px 32px; + border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.12); } + h1 { font-size: 22px; margin: 0 0 18px; } + label { display: block; margin: 12px 0 4px; font-weight: 600; font-size: 14px; } + input { width: 100%; box-sizing: border-box; padding: 9px 10px; font: inherit; + border: 1px solid #8a96a3; border-radius: 6px; } + button { margin-top: 20px; width: 100%; padding: 10px; font: inherit; font-weight: 600; + color: #fff; background: #1d4f91; border: 0; border-radius: 6px; cursor: pointer; } + p.note { color: #4a5663; font-size: 14px; } + #result { margin-top: 16px; font-weight: 600; }` + +function page(title, body) { + return `${title} +
${title}
${body}
` +} + +function loginPage(site) { + return page( + site.title, + `

Sign in

+
+ + + + + +
+

A demo page for the Keepiq screenshots. Nothing is sent anywhere.

`, + ) +} + +function signedInPage(site) { + return page( + site.title, + `

Welcome back

You are signed in to the demo site.

`, + ) +} + +// The WebAuthn demo: the browser's own API, answered by the extension. +// Nothing is verified server-side; the page only shows what came back. +const PASSKEY_PAGE = page( + 'Passkey demo', + `

Passkeys

+

Create a passkey for this demo site, then sign in with it.

+ + +

+`, +) + +/** + * Make a self-signed certificate for the demo names. + * + * @param {string} dir Where to write it. + * @return {{key: Buffer, cert: Buffer}} + */ +function makeCertificate(dir) { + const key = join(dir, 'key.pem') + const cert = join(dir, 'cert.pem') + execFileSync('openssl', [ + 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '2', + '-subj', '/CN=Keepiq capture demo', + '-addext', `subjectAltName=${DEMO_DOMAINS.map((d) => `DNS:*.${d}`).join(',')}`, + '-keyout', key, '-out', cert, + ], { stdio: 'ignore' }) + return { key: readFileSync(key), cert: readFileSync(cert) } +} + +/** + * Start the demo sites. + * + * @param {object} options The ports and the Nextcloud to forward to. + * @param {number} options.httpsPort The https server's port. + * @param {number} options.proxyPort The CONNECT proxy's port (Firefox). + * @param {string} options.nextcloud The capture Nextcloud, http://localhost:8188. + * @return {Promise<{close: Function}>} + */ +export async function startFixtures({ httpsPort, proxyPort, nextcloud }) { + const dir = mkdtempSync(join(tmpdir(), 'keepiq-capture-cert-')) + const tls = makeCertificate(dir) + const upstream = new URL(nextcloud) + + const server = createHttpsServer(tls, (req, res) => { + const host = String(req.headers.host || '').split(':')[0] + if (host === CLOUD_HOST) { + // Forward to the capture Nextcloud, keeping the Host it trusts. + const forward = httpRequest( + { + hostname: upstream.hostname, + port: upstream.port, + method: req.method, + path: req.url, + headers: { ...req.headers, host: CLOUD_HOST }, + }, + (answer) => { + res.writeHead(answer.statusCode || 502, answer.headers) + answer.pipe(res) + }, + ) + forward.on('error', () => { + res.writeHead(502) + res.end() + }) + req.pipe(forward) + return + } + res.setHeader('content-type', 'text/html; charset=utf-8') + if (host === PASSKEY_HOST) { + res.end(PASSKEY_PAGE) + return + } + const site = SITES[host] + if (!site) { + res.writeHead(404) + res.end('not a demo site') + return + } + if (req.method === 'POST' || req.url.startsWith('/signed-in')) { + req.resume() + // A real sign-in takes a moment; the extension decides its save + // offer in that time, and the next page shows it. + setTimeout(() => res.end(signedInPage(site)), 1500) + return + } + res.end(loginPage(site)) + }) + await new Promise((resolve) => server.listen(httpsPort, '127.0.0.1', resolve)) + + // Firefox has no host resolver rules: it sends the demo names through this + // proxy, which tunnels every CONNECT to the https server above. + const proxy = createHttpServer((req, res) => { + res.writeHead(405) + res.end() + }) + proxy.on('connect', (req, socket, head) => { + const target = connect(httpsPort, '127.0.0.1', () => { + socket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + target.write(head) + target.pipe(socket) + socket.pipe(target) + }) + target.on('error', () => socket.destroy()) + socket.on('error', () => target.destroy()) + }) + await new Promise((resolve) => proxy.listen(proxyPort, '127.0.0.1', resolve)) + + return { + async close() { + server.closeAllConnections?.() + proxy.closeAllConnections?.() + await Promise.all([ + new Promise((resolve) => server.close(resolve)), + new Promise((resolve) => proxy.close(resolve)), + ]) + rmSync(dir, { recursive: true, force: true }) + }, + } +} diff --git a/browser-extension/capture/setup.sh b/browser-extension/capture/setup.sh new file mode 100644 index 000000000..a582fee2c --- /dev/null +++ b/browser-extension/capture/setup.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# Provision the capture instance brought up by compose.yaml in this directory: +# +# bash browser-extension/capture/setup.sh +# +# The same steps as tests/integration/federation/setup.sh, for one instance: +# copy a built OpenRegister and Keepiq in, enable them with debug on so admin +# gets the development vault (master password "Oj"), import Keepiq's register +# configuration and VERIFY the vault exists. Then the development secrets, +# which carry real company names, are purged: the capture script fills the +# vault with demo items through the extension itself. Last, an app password +# for the extension is written to browser-extension/capture/out/app-password. +set -euo pipefail + +PROJECT="${1:?compose project}" +APP_DIR="$(cd "${2:?app dir}" && pwd)" +OR_DIR="$(cd "${3:?openregister dir}" && pwd)" +PORT="${KQ_MEDIA_PORT:-8188}" +BASE="http://localhost:${PORT}" +CONTAINER="${PROJECT}-nc-1" +OUT="${APP_DIR}/browser-extension/capture/out" + +occ() { + docker exec -u www-data "$CONTAINER" php occ "$@" +} + +api() { + local method="$1" path="$2" + curl -sS -f -u admin:admin -X "$method" \ + -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + "${BASE}/index.php/apps/keepiq${path}" +} + +echo "[capture-setup] waiting for ${BASE}" +for _ in $(seq 1 120); do + if curl -sf "${BASE}/status.php" | grep -q '"installed":true'; then + break + fi + sleep 5 +done +curl -sf "${BASE}/status.php" | grep -q '"installed":true' \ + || { echo "::error::the instance did not finish installing"; exit 1; } + +occ config:system:set appstoreenabled --value=false --type=boolean +occ config:system:set debug --value=true --type=boolean +occ config:system:set overwrite.cli.url --value="${BASE}" +# The capture reaches this instance as https://cloud.example.com too, through +# the forwarding demo server in fixtures.mjs. +occ config:system:set trusted_domains 5 --value=cloud.example.com +# The welcome wizard would cover the app on the first browser visit. +occ app:disable firstrunwizard >/dev/null 2>&1 || true + +echo "[capture-setup] copying OpenRegister in" +docker exec "$CONTAINER" mkdir -p /var/www/html/custom_apps/openregister +tar -C "$OR_DIR" --exclude=./node_modules --exclude=./.git --exclude=./tests --exclude=./coverage \ + --exclude=./docs --exclude=./custom_apps --exclude=./openspec --exclude=./website -cf - . \ + | docker exec -i "$CONTAINER" tar -xf - -C /var/www/html/custom_apps/openregister +docker exec "$CONTAINER" chown -R www-data:www-data /var/www/html/custom_apps/openregister +occ app:enable openregister + +echo "[capture-setup] copying Keepiq in" +docker exec "$CONTAINER" mkdir -p /var/www/html/custom_apps/keepiq +tar -C "$APP_DIR" --exclude=.git --exclude=node_modules --exclude=.lane --exclude=tests \ + --exclude=./docs --exclude=./browser-extension --exclude=./openspec -cf - . \ + | docker exec -i "$CONTAINER" tar -xf - -C /var/www/html/custom_apps/keepiq +docker exec "$CONTAINER" chown -R www-data:www-data /var/www/html/custom_apps/keepiq +occ app:enable keepiq +occ app:list | sed -n '/Enabled:/,/Disabled:/p' | grep -q ' keepiq:' \ + || { echo "::error::keepiq is not enabled"; exit 1; } +for conf in "$APP_DIR"/lib/Settings/keepiq_register.json "$APP_DIR"/lib/Settings/register.d/*.json; do + [ -f "$conf" ] || continue + code="$(curl -sS -o /dev/null -w '%{http_code}' -u admin:admin -H 'OCS-APIRequest: true' \ + -F "file=@${conf}" -F force=true -F appId=keepiq \ + "${BASE}/index.php/apps/openregister/api/configurations/import")" + [ "$code" = "200" ] || { echo "::error::importing ${conf} answered ${code}"; exit 1; } +done + +# The development vault of admin, which the extension unlocks with "Oj". +suites="$(api GET /api/v1/suites)" +echo "$suites" | grep -q '"status":"active"' \ + || { echo "::error::admin has no active suite: ${suites:0:300}"; exit 1; } + +echo "[capture-setup] purging the development secrets" +ids="$(api GET '/api/v1/secrets?limit=200' | python3 -c ' +import json, sys +body = json.load(sys.stdin) +if isinstance(body, dict): + for key in ("results", "data", "items", "secrets"): + if isinstance(body.get(key), list): + body = body[key] + break +print(" ".join(item["id"] for item in body)) +')" +for id in $ids; do + api DELETE "/api/v1/secrets/${id}" >/dev/null + api DELETE "/api/v1/secrets/${id}/purge" >/dev/null +done +left="$(api GET '/api/v1/secrets?limit=200' | python3 -c ' +import json, sys +body = json.load(sys.stdin) +if isinstance(body, dict): + for key in ("results", "data", "items", "secrets"): + if isinstance(body.get(key), list): + body = body[key] + break +print(len(body)) +')" +[ "$left" = "0" ] || { echo "::error::${left} development secrets are left"; exit 1; } + +echo "[capture-setup] creating an app password for the extension" +mkdir -p "$OUT" +docker exec -u www-data -e NC_PASS=admin "$CONTAINER" \ + php occ user:auth-tokens:add --password-from-env --name 'Keepiq extension' admin \ + | tail -n 1 | tr -d '[:space:]' > "${OUT}/app-password" +[ -s "${OUT}/app-password" ] || { echo "::error::no app password was created"; exit 1; } + +echo "[capture-setup] ready: ${BASE} (admin, master password Oj)" diff --git a/browser-extension/icons/icon-128.png b/browser-extension/icons/icon-128.png new file mode 100644 index 000000000..f17c2d9f2 Binary files /dev/null and b/browser-extension/icons/icon-128.png differ diff --git a/browser-extension/icons/icon-16.png b/browser-extension/icons/icon-16.png new file mode 100644 index 000000000..256f7052f Binary files /dev/null and b/browser-extension/icons/icon-16.png differ diff --git a/browser-extension/icons/icon-32.png b/browser-extension/icons/icon-32.png new file mode 100644 index 000000000..a1f921f53 Binary files /dev/null and b/browser-extension/icons/icon-32.png differ diff --git a/browser-extension/icons/icon-48.png b/browser-extension/icons/icon-48.png new file mode 100644 index 000000000..da23a8b08 Binary files /dev/null and b/browser-extension/icons/icon-48.png differ diff --git a/browser-extension/load-check/chromium.mjs b/browser-extension/load-check/chromium.mjs new file mode 100644 index 000000000..dadd4a199 --- /dev/null +++ b/browser-extension/load-check/chromium.mjs @@ -0,0 +1,71 @@ +/** + * Load the Chromium package headless and check the service worker starts and + * answers the popup (clients-browser-builds, "Chromium is unchanged"). + * + * Usage: node browser-extension/load-check/chromium.mjs [dist/chromium] + * Exits 0 when the worker answered get-state, 1 otherwise. + * + * @spec openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md + */ +import { chromium } from '@playwright/test' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const pkg = resolve(process.argv[2] || join(here, '..', 'dist', 'chromium')) +const profile = await mkdtemp(join(tmpdir(), 'keepiq-chromium-')) + +let context +let cdp = null +try { + context = await chromium.launchPersistentContext(profile, { + channel: 'chromium', + headless: true, + args: [ + `--disable-extensions-except=${pkg}`, + `--load-extension=${pkg}`, + '--remote-debugging-port=0', + ], + }) + const worker = + context.serviceWorkers()[0] + || (await context.waitForEvent('serviceworker', { timeout: 15000 })) + // The worker answers its own pages only, never a tab (#921), so the check + // opens the REAL action popup over an ordinary page and reaches it over + // the DevTools protocol: popup.html opened as a tab is refused, rightly. + await (await context.newPage()).goto('about:blank') + await worker.evaluate(() => chrome.action.openPopup()) + const port = (await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split( + '\n', + )[0] + let popup = null + for (let i = 0; i < 20 && !popup; i++) { + await new Promise((r) => setTimeout(r, 250)) + cdp?.close().catch(() => {}) + cdp = await chromium.connectOverCDP(`http://127.0.0.1:${port}`) + popup = cdp + .contexts() + .flatMap((c) => c.pages()) + .find((p) => p.url().endsWith('/popup.html')) + } + if (!popup) throw new Error('the action popup did not open') + const state = await popup.evaluate(() => + chrome.runtime.sendMessage({ type: 'get-state' }), + ) + if (!state || state.paired !== false) { + throw new Error('unexpected state ' + JSON.stringify(state)) + } + console.log( + 'chromium: service worker started, get-state answered', + JSON.stringify(state), + ) +} catch (e) { + console.error('chromium load check failed:', e.message || e) + process.exitCode = 1 +} finally { + await cdp?.close().catch(() => {}) + await context?.close() + await rm(profile, { recursive: true, force: true }) +} diff --git a/browser-extension/load-check/firefox.mjs b/browser-extension/load-check/firefox.mjs new file mode 100644 index 000000000..2fac0485d --- /dev/null +++ b/browser-extension/load-check/firefox.mjs @@ -0,0 +1,61 @@ +/** + * Install the Firefox package as a temporary add-on in headless Firefox and + * check the background script starts and answers the popup + * (clients-browser-builds, "Firefox starts the background"). + * + * Needs Firefox, geckodriver and `selenium-webdriver` (the CI job installs + * the last one without saving it). The add-on UUID is pinned through the + * `extensions.webextensions.uuids` preference so the popup URL is known. + * + * Usage: node browser-extension/load-check/firefox.mjs [dist/firefox] + * Exits 0 when the background answered get-state, 1 otherwise. + * + * @spec openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md + */ +import { Builder } from 'selenium-webdriver' +import firefox from 'selenium-webdriver/firefox.js' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { GECKO_ID } from '../manifests/browsers.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const pkg = resolve(process.argv[2] || join(here, '..', 'dist', 'firefox')) +const UUID = '7a1c7c52-5b0f-4a8e-9d53-000000000001' + +const options = new firefox.Options() + .addArguments('-headless') + .setPreference( + 'extensions.webextensions.uuids', + JSON.stringify({ [GECKO_ID]: UUID }), + ) + +let driver +try { + driver = await new Builder() + .forBrowser('firefox') + .setFirefoxOptions(options) + .build() + await driver.installAddon(pkg, true) + await driver.get(`moz-extension://${UUID}/popup.html`) + let state = null + for (let i = 0; i < 10 && !state; i++) { + state = await driver.executeAsyncScript(function (done) { + browser.runtime + .sendMessage({ type: 'get-state' }) + .then(done, () => done(null)) + }) + if (!state) await new Promise((r) => setTimeout(r, 500)) + } + if (!state || state.paired !== false) { + throw new Error('unexpected state ' + JSON.stringify(state)) + } + console.log( + 'firefox: background started, get-state answered', + JSON.stringify(state), + ) +} catch (e) { + console.error('firefox load check failed:', e.message || e) + process.exitCode = 1 +} finally { + await driver?.quit() +} diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index eeb386ca6..b402a287e 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -2,30 +2,46 @@ "manifest_version": 3, "name": "Keepiq", "version": "0.1.0", - "description": "Zero-knowledge autofill for the Keepiq secrets manager. The vault is unlocked and every secret decrypted inside the extension — the server only ever ships encrypted blobs.", + "description": "Fill logins, one-time codes and passkeys from your Keepiq vault. It opens in the extension, so your server only sees encrypted data.", "permissions": [ "storage", "activeTab", "tabs", - "scripting", "clipboardWrite", "idle", - "windows" - ], - "optional_permissions": [ - "webAuthenticationProxy" + "alarms", + "windows", + "offscreen", + "contextMenus" ], "host_permissions": [ "http://*/*", "https://*/*" ], - "background": { - "service_worker": "service-worker.js", - "type": "module" + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "commands": { + "fill-login": { + "suggested_key": { + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" + }, + "description": "Fill the login for this site" + } }, "action": { "default_popup": "popup.html", - "default_title": "Keepiq" + "default_title": "Keepiq", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } }, "content_scripts": [ { @@ -42,6 +58,6 @@ } ], "content_security_policy": { - "extension_pages": "script-src 'self'; object-src 'self'" + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" } } diff --git a/browser-extension/manifests/browsers.mjs b/browser-extension/manifests/browsers.mjs new file mode 100644 index 000000000..195a4f62f --- /dev/null +++ b/browser-extension/manifests/browsers.mjs @@ -0,0 +1,70 @@ +/** + * Per-browser manifests (clients-browser-builds). One base manifest + * (`manifest.json`) plus a small overlay per browser, so the differences are + * few and reviewable: + * - Chromium (Chrome, Edge): a module service worker. + * - Firefox: background `scripts` (Firefox MV3 has no extension service + * worker) and a gecko id with a minimum version. + * - Safari: the Chromium package is the input of Apple's converter; see + * `safari/README.md`. + * + * @spec openspec/changes/clients-extension-firefox-and-safari-builds/specs/clients-browser-builds/spec.md + */ + +/** The add-on id Firefox signs and updates under. */ +export const GECKO_ID = 'keepiq@conduction.nl' + +/** + * Firefox's data collection categories for this extension. + * + * @spec openspec/specs/extension-release/spec.md#requirement-firefox-is-told-what-leaves-the-browser + */ +export const DATA_COLLECTION = Object.freeze([ + 'authenticationInfo', + 'browsingActivity', +]) + +/** The oldest Firefox with the MV3 features the extension uses. */ +export const GECKO_MIN_VERSION = '115.0' + +/** The browsers a package is built for. */ +export const BROWSERS = Object.freeze(['chromium', 'firefox']) + +/** + * The manifest for one browser. + * + * @param {object} base The base manifest. + * @param {string} browser chromium or firefox. + * @return {object} A new manifest object. + */ +export function manifestFor(base, browser) { + const manifest = structuredClone(base) + if (browser === 'chromium') { + manifest.background = { service_worker: 'service-worker.js', type: 'module' } + return manifest + } + if (browser === 'firefox') { + // The worker bundle is built as a classic script for Firefox. + manifest.background = { scripts: ['service-worker.js'] } + manifest.browser_specific_settings = { + gecko: { + id: GECKO_ID, + strict_min_version: GECKO_MIN_VERSION, + // What leaves the browser, for Firefox's consent screen (required + // for new add-ons since 3 November 2025, read from Firefox 140): + // the app password and encrypted logins, and the site the user is + // on to find its logins. Both go only to the user's own server. + data_collection_permissions: { + required: [...DATA_COLLECTION], + }, + }, + } + // Firefox has no `windows` permission (the API needs none) and warns on + // it, and no offscreen documents (its background page has a document). + manifest.permissions = (manifest.permissions || []).filter( + (p) => p !== 'windows' && p !== 'offscreen', + ) + return manifest + } + throw new Error('unknown browser: ' + browser) +} diff --git a/browser-extension/safari/README.md b/browser-extension/safari/README.md new file mode 100644 index 000000000..8a896dee5 --- /dev/null +++ b/browser-extension/safari/README.md @@ -0,0 +1,25 @@ +# Safari build (open task) + +Safari runs web extensions through an app wrapper that Apple's converter makes +from the Chromium package. This step needs macOS with Xcode, which the +project does not have yet, so it is an open task of +`clients-extension-firefox-and-safari-builds` (task 1.2). Signing and store +release belong to `clients-extension-store-release`. + +On a Mac with Xcode 15 or later: + +```sh +npm ci +npm run build:extension # makes browser-extension/dist/chromium +bash browser-extension/safari/convert.sh +``` + +`convert.sh` runs `xcrun safari-web-extension-converter` on +`dist/chromium` without opening Xcode, writes the project to +`browser-extension/dist/safari/` and keeps the converter's output in +`browser-extension/dist/safari/convert.log`. Keep that log with the build. + +What is known to differ in Safari, to check on the first run: + +- Safari has no `idle` permission; the idle lock falls back to the in-worker timer. +- Passkeys go through the page-context shim, as in Firefox. diff --git a/browser-extension/safari/convert.sh b/browser-extension/safari/convert.sh new file mode 100755 index 000000000..dffc6ccfb --- /dev/null +++ b/browser-extension/safari/convert.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Convert the Chromium package into a Safari web extension Xcode project. +# Needs macOS with Xcode. See README.md next to this script. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +pkg="$here/../dist/chromium" +out="$here/../dist/safari" +if [ "$(uname)" != "Darwin" ]; then + echo "The Safari conversion needs macOS with Xcode." >&2 + exit 2 +fi +[ -f "$pkg/manifest.json" ] || { echo "Build first: npm run build:extension" >&2; exit 1; } +rm -rf "$out" && mkdir -p "$out" +xcrun safari-web-extension-converter "$pkg" \ + --project-location "$out" \ + --app-name Keepiq \ + --bundle-identifier nl.conduction.keepiq \ + --no-open --no-prompt --force 2>&1 | tee "$out/convert.log" diff --git a/browser-extension/scripts/render-icons.mjs b/browser-extension/scripts/render-icons.mjs new file mode 100644 index 000000000..e7501c748 --- /dev/null +++ b/browser-extension/scripts/render-icons.mjs @@ -0,0 +1,41 @@ +/** + * Render the extension's icons from the app's own icon (img/pwa-icon.svg), + * so the toolbar and the store show the same mark as the web app. Run with + * `node browser-extension/scripts/render-icons.mjs` after the SVG changes; + * the PNGs are committed (clients-extension-gaps). + * + * @spec openspec/specs/extension-release/spec.md#requirement-the-extension-ships-its-own-icons + */ +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const here = dirname(fileURLToPath(import.meta.url)) +const svg = await readFile(resolve(here, '../../img/pwa-icon.svg'), 'utf8') + +/** The sizes the manifest names. */ +export const ICON_SIZES = [16, 32, 48, 128] + +const browser = await chromium.launch() +try { + for (const size of ICON_SIZES) { + const page = await browser.newPage({ + viewport: { width: size, height: size }, + deviceScaleFactor: 1, + }) + await page.setContent( + `${svg.replace( + /`, + ) + await page.screenshot({ + path: resolve(here, `../icons/icon-${size}.png`), + omitBackground: true, + }) + await page.close() + } +} finally { + await browser.close() +} diff --git a/browser-extension/src/background/clipboard-clear.js b/browser-extension/src/background/clipboard-clear.js new file mode 100644 index 000000000..cabb22620 --- /dev/null +++ b/browser-extension/src/background/clipboard-clear.js @@ -0,0 +1,150 @@ +/** + * Clearing the clipboard after a copy (clients-extension-gaps). The popup + * copies and tells the worker; the worker clears the clipboard after the + * delay the user chose, even when the popup has closed. A service worker + * has no clipboard, so Chromium clears through an offscreen document and + * Firefox through its background page, which has a document. + * + * The clipboard is cleared whatever it holds by then: reading it would + * need a permission that lets the extension read every copy. + * + * @spec openspec/specs/extension-clipboard/spec.md#requirement-every-copy-is-cleared-after-a-delay-the-user-sets + */ + +/** The delays a user can pick, in seconds; 0 means never. */ +export const CLEAR_CHOICES = Object.freeze([0, 10, 20, 30, 60, 120, 300]) + +/** The delay before the user picks one. */ +export const DEFAULT_CLEAR_SECONDS = 30 + +/** The alarm that clears the clipboard after a longer delay. */ +export const CLEAR_ALARM = 'keepiq-clipboard' + +const SETTING_KEY = 'clipboard-clear-seconds' + +// Alarms fire no sooner than 30 seconds; shorter delays use a timer. +const ALARM_FLOOR_SECONDS = 30 + +/** + * Build the clipboard clearer. + * + * @param {object} deps The collaborators. + * @param {object} deps.local The persistent storage area. + * @param {() => Promise} deps.clear Clear the clipboard now. + * @param {object} [deps.alarms] The alarms API. + * @param {() => number} [deps.now] The clock, in ms. + * @return {object} + */ +export function buildClipboardClear({ + local, + clear, + alarms = globalThis.chrome?.alarms, + now = () => Date.now(), +}) { + let timer = null + + /** + * The chosen delay in seconds. + * + * @return {Promise} + */ + async function seconds() { + const value = (await local.get(SETTING_KEY))[SETTING_KEY] + return CLEAR_CHOICES.includes(value) ? value : DEFAULT_CLEAR_SECONDS + } + + return { + seconds, + + /** + * Store a new delay. + * + * @param {number} value Seconds, one of CLEAR_CHOICES. + * @return {Promise} The stored delay. + */ + async setSeconds(value) { + const n = Number(value) + if (!CLEAR_CHOICES.includes(n)) throw new Error('unsupported delay') + await local.set({ [SETTING_KEY]: n }) + return n + }, + + /** + * A copy just happened: clear the clipboard after the delay. A newer + * copy restarts the wait. + * + * @return {Promise<{clearsInSeconds: number}>} + */ + async copied() { + const delay = await seconds() + clearTimeout(timer) + timer = null + await alarms?.clear?.(CLEAR_ALARM) + if (delay === 0) return { clearsInSeconds: 0 } + if (delay < ALARM_FLOOR_SECONDS || !alarms) { + timer = setTimeout(() => { + timer = null + clear().catch(() => {}) + }, delay * 1000) + } else { + alarms.create(CLEAR_ALARM, { when: now() + delay * 1000 }) + } + return { clearsInSeconds: delay } + }, + + /** + * An alarm fired. + * + * @param {{name: string}} alarm The alarm. + * @return {Promise} Whether it was this one. + */ + async onAlarm(alarm) { + if (alarm?.name !== CLEAR_ALARM) return false + await clear().catch(() => {}) + return true + }, + } +} + +/** + * Write an empty text to the clipboard from a page with a document. + * + * @param {Document} doc The document. + * @return {boolean} Whether the browser ran the copy. + */ +export function clearWithDocument(doc) { + const onCopy = (event) => { + event.clipboardData?.setData('text/plain', '') + event.preventDefault() + } + doc.addEventListener('copy', onCopy) + try { + return doc.execCommand('copy') === true + } finally { + doc.removeEventListener('copy', onCopy) + } +} + +/** + * Clear the clipboard now: through an offscreen document where the browser + * has them (Chromium), else from this page's own document (Firefox). + * + * @return {Promise} + */ +export async function clearClipboardNow() { + const offscreen = globalThis.chrome?.offscreen + if (offscreen) { + const url = 'offscreen.html' + const exists = (await offscreen.hasDocument?.()) === true + if (!exists) { + await offscreen.createDocument({ + url, + reasons: ['CLIPBOARD'], + justification: 'Clear a copied password from the clipboard', + }) + } + await chrome.runtime.sendMessage({ type: 'offscreen-clear-clipboard' }) + return + } + if (typeof document !== 'undefined') clearWithDocument(document) +} diff --git a/browser-extension/src/background/generator-handlers.js b/browser-extension/src/background/generator-handlers.js new file mode 100644 index 000000000..0f77c48eb --- /dev/null +++ b/browser-extension/src/background/generator-handlers.js @@ -0,0 +1,192 @@ +/** + * Worker handlers for the Generator tab: its context (policy, options, + * history, the active site and the account's email), saving options, and + * the history. None needs the vault key, so the tab works while locked and, + * with the cached policy, while the server is unreachable. + * + * Options live in `storage.local` per account and go when the account goes. + * History lives in `storage.session` (memory on browsers without it) and goes + * on lock, on account removal and when the browser or extension restarts. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-generator-history + */ + +import { addToHistory, sanitizeOptions } from '../lib/generator-state.js' + +const OPTIONS_KEY = (id) => 'generator-options:' + id +const POLICY_KEY = (id) => 'generator-policy:' + id +const EMAIL_KEY = (id) => 'generator-email:' + id +const HISTORY_KEY = (id) => 'generator-history:' + id + +/** + * A storage area with get/set/remove, falling back to memory. + * + * @param {object|null} area A chrome.storage area, or null. + * @return {{get: Function, set: Function, remove: Function}} + */ +export function areaOrMemory(area) { + if (area) return area + const memory = new Map() + return { + get: async (key) => (memory.has(key) ? { [key]: memory.get(key) } : {}), + set: async (items) => { + for (const [k, v] of Object.entries(items)) memory.set(k, v) + }, + remove: async (key) => { + memory.delete(key) + }, + } +} + +/** + * Build the handlers. + * + * @param {object} deps The collaborators. + * @param {object} deps.api The API client (fetchPolicy, fetchAccountEmail). + * @param {() => Promise} deps.activeAccount The active, paired account. + * @param {(payload?: {tabId?: number}) => Promise} deps.activeHost The target tab's host name, or ''. + * @param {object} deps.local The persistent storage area. + * @param {object} deps.session The session storage area. + * @return {{handlers: Record, forget: (accountId: string) => Promise, clearHistory: (accountId: string) => Promise}} + */ +export function buildGeneratorHandlers({ + api, + activeAccount, + activeHost, + local, + session, +}) { + /** + * The org policy: live when the server answers, else the last one seen. + * + * @param {object} account The account. + * @return {Promise} + */ + async function policyFor(account) { + try { + const policy = await api.fetchPolicy(account) + await local.set({ [POLICY_KEY(account.id)]: policy ?? null }) + return policy ?? null + } catch { + return ( + (await local.get(POLICY_KEY(account.id)))[POLICY_KEY(account.id)] + ?? null + ) + } + } + + /** + * The account's email, fetched once and kept. + * + * @param {object} account The account. + * @return {Promise} + */ + async function emailFor(account) { + const cached = (await local.get(EMAIL_KEY(account.id)))[ + EMAIL_KEY(account.id) + ] + if (typeof cached === 'string') return cached + try { + const email = await api.fetchAccountEmail(account) + await local.set({ [EMAIL_KEY(account.id)]: email }) + return email + } catch { + return '' + } + } + + /** @param {string} id The account id. @return {Promise>} */ + const historyOf = async (id) => + (await session.get(HISTORY_KEY(id)))[HISTORY_KEY(id)] || [] + + // The pending history write per account, so writes run in order. + const historyWrites = new Map() + + const clearHistory = async (accountId) => { + await session.remove(HISTORY_KEY(accountId)) + } + + return { + clearHistory, + async forget(accountId) { + await Promise.all([ + local.remove(OPTIONS_KEY(accountId)), + local.remove(POLICY_KEY(accountId)), + local.remove(EMAIL_KEY(accountId)), + clearHistory(accountId), + ]) + }, + handlers: { + /** + * Everything the Generator tab needs to open. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-works-while-locked-and-offline + */ + 'generator-context': async (payload = {}) => { + const account = await activeAccount() + const policy = await policyFor(account) + const stored = (await local.get(OPTIONS_KEY(account.id)))[ + OPTIONS_KEY(account.id) + ] + const options = sanitizeOptions(stored, policy) + if (!options.username.email) { + options.username.email = await emailFor(account) + } + return { + policy, + options, + history: await historyOf(account.id), + website: await activeHost(payload).catch(() => ''), + } + }, + + /** + * Keep the options for the next time. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-options-remembered-per-account + */ + 'generator-options-save': async (payload) => { + const account = await activeAccount() + const policy = + (await local.get(POLICY_KEY(account.id)))[POLICY_KEY(account.id)] + ?? null + const options = sanitizeOptions(payload.options, policy) + await local.set({ [OPTIONS_KEY(account.id)]: options }) + return { options } + }, + + /** + * Add a generated value to the history. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-generator-history + */ + 'generator-history-add': async (payload) => { + const account = await activeAccount() + // One write at a time per account: values generated in quick + // succession would otherwise read the same list and drop each + // other. + const previous = historyWrites.get(account.id) || Promise.resolve() + const next = previous.then(async () => { + const history = addToHistory( + await historyOf(account.id), + payload, + ) + await session.set({ [HISTORY_KEY(account.id)]: history }) + return history + }) + historyWrites.set( + account.id, + next.catch(() => {}), + ) + return { history: await next } + }, + + /** @spec openspec/specs/extension-generator/spec.md#requirement-generator-history */ + 'generator-history-clear': async () => { + const account = await activeAccount() + await clearHistory(account.id) + return { history: [] } + }, + }, + } +} diff --git a/browser-extension/src/background/router.js b/browser-extension/src/background/router.js new file mode 100644 index 000000000..2926c5501 --- /dev/null +++ b/browser-extension/src/background/router.js @@ -0,0 +1,1785 @@ +/** + * The worker's message handlers (browser-extension-autofill §"Extension + * architecture"). The service worker is the ONLY place vault keys live; the + * popup, the unlock window and the content scripts are UIs that message it. + * + * Trust: a content script runs inside a web page, so a compromised page can + * speak through it. Only the four messages a content script needs are accepted + * from a tab (`PAGE_MESSAGES`); everything that unlocks, lists, fills, saves or + * changes settings is accepted from the extension's own pages only. + * + * Accounts (extension-account-switching): up to five paired accounts, each with + * its own key, idle timer and match cache. Matching, filling, the one-time + * code and saving use the ACTIVE account, and a fill is refused unless the + * secret id came from the active account's own last match for this site. + */ + +import * as api from '../lib/api.js' +import * as vault from '../lib/vault.js' +import * as deviceApproval from '../lib/deviceApproval.js' +import { matchSecrets, hostOf, registrableDomain } from '../lib/match.js' +import { classifyCapture } from '../lib/capture.js' +import { policyRefusal } from '../lib/policy.js' +import { buildPasskeyOrchestrator } from '../passkey/orchestrator.js' +import { senderOrigin } from '../passkey/rp.js' +import { computeTotp } from '../lib/totp-service.js' +import { reportFill } from '../lib/usage.js' +import { + allowedOnHost, + blocksSavePrompt, + filterForHost, + isUseOnly, +} from '../lib/useOnly.js' +import { isServerSupported } from '../lib/version.js' +import { isSecureServerUrl, normalizeServerUrl } from '../lib/server-url.js' +import { buildPinUnlock } from '../lib/pin-unlock.js' +import { readSettings, writeSettings } from '../lib/extension-settings.js' +import { + decodeEnvelope, + decryptPrivateKeyWithRawKey, + deriveUnlockKeyRaw, +} from '../crypto/index.js' +import { buildVaultHandlers } from './vault-handlers.js' +import { areaOrMemory, buildGeneratorHandlers } from './generator-handlers.js' +import { buildVaultSync, isOffline, SYNC_INTERVAL_MINUTES } from './vault-sync.js' +import { + buildClipboardClear, + CLEAR_CHOICES, + clearClipboardNow, +} from './clipboard-clear.js' + +/** + * The messages a content script (a tab) may send. Everything else needs an + * extension page as sender. + */ +export const PAGE_MESSAGES = Object.freeze( + new Set([ + 'capture-credential', + 'capture-decision', + // A frame says it loaded; the worker records its host from the sender. + 'frame-ready', + // A new page asks whether a save offer is still waiting for its tab. + 'capture-offer', + 'webauthn-create', + 'webauthn-get', + 'otp-field-detected', + // A random password for a sign-up field; it carries no vault data. + 'generate-for-field', + // Whether to offer those suggestions at all; no vault data either. + 'page-settings', + ]), +) + +// chrome.storage.session key of a tab's frames, { frameId: host }, recorded +// from the browser's sender record when each frame's content script loads. +const FRAMES_KEY = (tabId) => 'frames:' + tabId + +/** + * Record a frame of a tab with the host the browser says it is on. + * + * @param {object} payload Unused: the page's claims are not read. + * @param {object} sender The runtime.MessageSender of the content script. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-a-fill-reaches-only-frames-on-the-matched-site + */ +async function doFrameReady(payload, sender) { + const tabId = sender?.tab?.id + const frameId = sender?.frameId + const host = hostOf(sender?.url || '') + const store = sessionStore() + if (!store || !Number.isInteger(tabId) || !Number.isInteger(frameId) || !host) { + return { ok: false } + } + const key = FRAMES_KEY(tabId) + const frames = (await store.get(key))[key] || {} + // Frames report in any order; a new page clears the record when it + // starts loading (below), never when its top frame reports. + await store.set({ [key]: { ...frames, [frameId]: host } }) + return { ok: true } +} + +// A tab starts loading a new page: its frames are gone. +chrome.tabs?.onUpdated?.addListener((tabId, info) => { + if (info?.status !== 'loading') return + sessionStore() + ?.remove(FRAMES_KEY(tabId)) + .catch(() => {}) +}) + +/** + * The frames of a tab that are on a host. Without a record (the worker + * restarted), only the top frame, whose page the caller already checked. + * + * @param {number} tabId The tab. + * @param {string} host The matched host. + * @return {Promise>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-a-fill-reaches-only-frames-on-the-matched-site + */ +async function framesOn(tabId, host) { + const store = sessionStore() + const key = FRAMES_KEY(tabId) + const frames = store ? (await store.get(key))[key] : null + if (!frames || Object.keys(frames).length === 0) return [0] + return Object.entries(frames) + .filter(([, h]) => h === host) + .map(([id]) => Number(id)) +} + +/** + * Send a message to the frames of a tab on a host, one by one, and report + * whether any of them handled it. + * + * @param {number} tabId The tab. + * @param {string} host The matched host. + * @param {object} message The message. + * @return {Promise<{filled: boolean}>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-a-fill-reaches-only-frames-on-the-matched-site + */ +async function sendToFramesOn(tabId, host, message) { + let filled = false + for (const frameId of await framesOn(tabId, host)) { + const res = await chrome.tabs + .sendMessage(tabId, message, { frameId }) + .catch(() => null) + if (res?.filled) filled = true + } + return { filled } +} + +/** How long after a login fill the code may fill on the next step. */ +export const OTP_INTENT_MS = 5 * 60 * 1000 + +// chrome.storage.session key of the pending code intents, by tab id. Session +// storage is held in memory by the browser and is not readable by content +// scripts. An intent holds no seed and no code (extension-totp-autofill). +const OTP_INTENTS_KEY = 'keepiq.otpIntents' + +function sessionStore() { + return chrome.storage && chrome.storage.session ? chrome.storage.session : null +} + +/** + * The page tab the popup acts on: the one a popped-out popup was opened over + * (by id), else the active tab of the current window. + * + * @param {number|undefined} tabId The pinned tab id, if any. + * @return {Promise} + */ +async function targetTab(tabId) { + if (Number.isInteger(tabId)) { + return chrome.tabs.get(tabId).catch(() => null) + } + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + return tab || null +} + +// The Generator tab's state (clients-extension-complete), built on first use +// so it binds to the storage areas the browser provides at that time. +let generatorState = null + +// Clearing the clipboard after a copy (clients-extension-gaps), built on first +// use like the generator state. +let clipboardState = null + +/** + * The clipboard clearer. + * + * @return {object} + * @spec openspec/specs/extension-clipboard/spec.md#requirement-every-copy-is-cleared-after-a-delay-the-user-sets + */ +function clipboardModule() { + if (!clipboardState) { + clipboardState = buildClipboardClear({ + local: areaOrMemory(chrome.storage?.local), + clear: clearClipboardNow, + }) + } + return clipboardState +} + +function generatorModule() { + if (!generatorState) { + generatorState = buildGeneratorHandlers({ + api, + activeAccount, + activeHost: async (payload) => { + const tab = await targetTab(payload?.tabId) + try { + const url = new URL(tab?.url || '') + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.hostname + : '' + } catch { + return '' + } + }, + local: chrome.storage.local, + session: areaOrMemory(sessionStore()), + }) + } + return generatorState +} + +// The vault snapshot and its sync (clients-extension-complete), built on +// first use, like the generator state. +let syncState = null + +function syncModule() { + if (!syncState) { + syncState = buildVaultSync({ + api, + local: chrome.storage.local, + activeSuiteId: (id) => vault.activeSuiteId(id), + activeSuiteEpoch: (id) => vault.activeSuiteEpoch(id), + lock: (id) => lockAccount(id), + }) + } + return syncState +} + +const SYNC_ALARM = (id) => 'keepiq-sync:' + id + +/** + * After an unlock: sync now, then every SYNC_INTERVAL_MINUTES while unlocked. + * + * @param {object} account The account. + * @return {void} + */ +function startSync(account) { + syncModule() + .sync(account, { force: true }) + .catch(() => {}) + chrome.alarms?.create(SYNC_ALARM(account.id), { + periodInMinutes: SYNC_INTERVAL_MINUTES, + }) +} + +/** + * A scheduled sync fired: sync that account if it is still unlocked. + * + * @param {{name: string}} alarm The alarm. + * @return {Promise} + */ +export async function onAlarm(alarm) { + if (await clipboardModule().onAlarm(alarm)) return + if (!alarm?.name?.startsWith('keepiq-sync:')) return + const id = alarm.name.slice('keepiq-sync:'.length) + const account = await api.loadAccount(id) + if (account && vault.isUnlocked(id)) { + await syncModule() + .sync(account) + .catch(() => {}) + } +} + +// No sync runs while locked: the alarm goes with the key. The popup's last +// tab is forgotten too, so a locked popup reopens on its first tab. +vault.onLock((accountId) => { + chrome.alarms?.clear(SYNC_ALARM(accountId)) + // Tell an open popup, so it drops what it shows of the vault at once. + try { + chrome.runtime + .sendMessage({ type: 'keepiq-locked', accountId }) + ?.catch?.(() => {}) + } catch { + // No page is listening. + } + sessionStore() + ?.remove('popup:lastTab') + .catch(() => {}) + // Where a browser without session storage keeps it. + chrome.storage?.local?.remove?.('popup:lastTab')?.catch?.(() => {}) +}) + +// Generator history goes whenever an account locks, for any reason. +vault.onLock((accountId) => { + generatorModule() + .clearHistory(accountId) + .catch(() => {}) +}) + +async function readIntents() { + const store = sessionStore() + if (!store) return {} + const data = await store.get(OTP_INTENTS_KEY) + return data[OTP_INTENTS_KEY] || {} +} + +async function writeIntents(intents) { + const store = sessionStore() + if (!store) return + if (Object.keys(intents).length === 0) { + await store.remove(OTP_INTENTS_KEY) + } else { + await store.set({ [OTP_INTENTS_KEY]: intents }) + } +} + +/** + * Drop every pending code intent (lock, OS lock, unpair). + * + * @return {Promise} + */ +export async function clearOtpIntents() { + await writeIntents({}) +} + +/** + * The idle cap used when the organisation's maximum could not be read at + * unlock: the default delay, so an unreachable policy never lengthens it. + */ +const FALLBACK_MAX_IDLE_MINUTES = api.DEFAULT_IDLE_MINUTES + +// accountId → the administrator maximum read at that account's last unlock. +const maxIdleByAccount = new Map() + +// accountId → { host, rows: Map(id → blob row) } from that account's last match. +// Cleared on lock. +const matchCache = new Map() + +// Submitted logins waiting for a save or update decision, one per tab. Each is +// bound to the account active at submit and expires after CAPTURE_TTL_MS +// (clients-extension-gaps). +const captures = new Map() + +// Sites the user never wants a save offer on (clients-extension-gaps). +const NEVER_KEY = 'capture-never' + +/** + * The sites with no save offer. + * + * @return {Promise>} + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-never-offer-to-save-on-a-site + */ +async function neverSites() { + const area = chrome.storage?.local + if (!area) return [] + const list = (await area.get(NEVER_KEY))[NEVER_KEY] + return Array.isArray(list) ? list : [] +} + +/** + * Add or remove a site from the no-save list. + * + * @param {string} host The site. + * @param {boolean} on Add (true) or remove (false). + * @return {Promise>} The list. + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-never-offer-to-save-on-a-site + */ +async function setNever(host, on) { + const list = (await neverSites()).filter((h) => h !== host) + if (on && host) list.push(host) + list.sort() + await chrome.storage.local.set({ [NEVER_KEY]: list }) + return list +} + +/** How long a submitted login waits for a decision. */ +export const CAPTURE_TTL_MS = 5 * 60 * 1000 + +/** + * The live capture of a tab, or null. An expired one is dropped. + * + * @param {number|undefined} tabId The tab. + * @return {object|null} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-the-save-prompt-trusts-the-browser-not-the-page + */ +function captureOf(tabId) { + const capture = captures.get(tabId) + if (!capture) return null + if (capture.expiresAt <= Date.now()) { + captures.delete(tabId) + return null + } + return capture +} + +chrome.tabs?.onRemoved?.addListener((tabId) => { + captures.delete(tabId) + sessionStore() + ?.remove(FRAMES_KEY(tabId)) + .catch(() => {}) +}) + +// Passkey provider: the orchestrator works on the active account. +const passkey = buildPasskeyOrchestrator({ + api, + vault: vault.boundTo(api.activeAccountId), + loadConfig: api.loadConfig, +}) + +/** + * The relying party id of the extension's own passkey: the host part of the + * extension origin (the extension id on Chromium, the install uuid on Firefox). + * + * @return {string} + */ +export function extensionRpId() { + return new URL(chrome.runtime.getURL('')).hostname +} + +/** + * Whether a message comes from one of the extension's own pages (popup, + * unlock window, popped-out popup), not from a content script in a tab. + * + * A page in its own window is a tab too. It counts only as that tab's top + * frame: the extension's pages are not web-accessible, so a web page can + * neither frame them nor navigate to them. + * + * @param {object|undefined} sender The runtime.MessageSender. + * @return {boolean} + */ +export function fromExtensionPage(sender) { + if (!sender || sender.id !== chrome.runtime.id) return false + const base = chrome.runtime.getURL('') + if (typeof sender.url !== 'string' || !sender.url.startsWith(base)) { + return false + } + return !sender.tab || sender.frameId === 0 +} + +/** + * The idle delay in force for an account: its own choice, capped by the + * organisation's maximum read at its last unlock. + * + * @param {object} account The stored account. + * @return {number} Minutes. + */ +export function effectiveIdleMinutes(account) { + const chosen = api.IDLE_CHOICES.includes(account?.idleMinutes) + ? account.idleMinutes + : api.DEFAULT_IDLE_MINUTES + const max = maxIdleByAccount.get(account?.id) ?? FALLBACK_MAX_IDLE_MINUTES + return Math.min(chosen, max) +} + +async function touchActivity(accountId) { + const account = await api.loadAccount(accountId) + if (!account) return + vault.armIdleLock(accountId, effectiveIdleMinutes(account) * 60 * 1000) +} + +function lockAccount(accountId) { + vault.lock(accountId) + matchCache.delete(accountId) + clearOtpIntents().catch(() => {}) + for (const [tabId, capture] of captures) { + if (capture.accountId === accountId) captures.delete(tabId) + } +} + +// PIN unlock (clients-extension-gaps), on session storage, built on first use. +let pinState = null + +/** + * The PIN store. + * + * @return {object} + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +function pinModule() { + if (!pinState) pinState = buildPinUnlock(areaOrMemory(sessionStore())) + return pinState +} + +/** + * The account's active suite: from the server, or from the vault snapshot + * when the server cannot be reached. + * + * @param {object} account The account. + * @return {Promise} + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +async function suiteFor(account) { + try { + return await api.fetchActiveSuite(account) + } catch (e) { + const suite = isOffline(e) + ? (await syncModule().snapshotOf(account.id))?.suite + : null + if (!suite) throw e + return suite + } +} + +/** + * Set a PIN for the unlocked account on screen. The master password proves + * it is the user and yields the unlock key the PIN then wraps. + * + * @param {{masterPassword: string, pin: string}} payload The master password and the new PIN. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +async function doPinSet(payload) { + const account = await activeAccount() + if (!vault.isUnlocked(account.id)) throw new Error('vault is locked') + const suite = await suiteFor(account) + const { salt } = decodeEnvelope(suite.privateKey) + const rawKey = await deriveUnlockKeyRaw( + String(payload.masterPassword ?? ''), + salt, + ) + try { + await decryptPrivateKeyWithRawKey(suite.privateKey, rawKey) + } catch { + rawKey.fill(0) + throw new Error('Invalid master password') + } + try { + await pinModule().set(account.id, rawKey, payload.pin) + } finally { + rawKey.fill(0) + } + return { ok: true } +} + +/** + * Unlock the account on screen with its PIN. + * + * @param {{pin: string}} payload The PIN. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +async function doPinUnlock(payload) { + const account = await activeAccount() + if (account.loggedOut) { + throw new Error( + 'This account is signed out. Sign in again with a new app password.', + ) + } + const rawKey = await pinModule().open(account.id, payload.pin) + try { + const suite = await suiteFor(account) + await vault.unlockWithRawKey(account.id, account, rawKey, { suite }) + } catch (e) { + if (e?.name === 'OperationError') { + // The master password changed: the wrapped key opens nothing now. + await pinModule().remove(account.id) + throw new Error( + 'Your master password changed. Unlock with it, then set the PIN again.', + ) + } + throw e + } finally { + rawKey.fill(0) + } + await refreshPolicy(account) + await touchActivity(account.id) + startSync(account) + return { ok: true } +} + +// Accounts being signed out right now, so parallel 401s do it once. +const signingOut = new Set() + +/** + * Sign an account out after the server refused its app password (401): the + * password was revoked or changed in Nextcloud. Lock it, forget the password, + * the vault snapshot and the generator state, and keep the account so the + * user can sign in again with a new app password. + * + * @param {string} accountId The account. + * @param {string} [reason] revoked (the server refused it) or logout (the user's choice). + * @return {Promise} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +export async function signOutAccount(accountId, reason = 'revoked') { + if (!accountId || signingOut.has(accountId)) return + signingOut.add(accountId) + try { + lockAccount(accountId) + await api + .updateAccount(accountId, { + appPassword: '', + loggedOut: true, + loggedOutReason: reason, + }) + .catch(() => {}) + await syncModule() + .forget(accountId) + .catch(() => {}) + await generatorModule() + .forget(accountId) + .catch(() => {}) + await pinModule() + .remove(accountId) + .catch(() => {}) + } finally { + signingOut.delete(accountId) + } +} + +api.onUnauthorized((config) => { + signOutAccount(config.id) +}) + +/** Lock every account and drop every cache (OS lock, Lock button). */ +export function lockEverything() { + vault.lockAll() + matchCache.clear() + clearOtpIntents().catch(() => {}) +} + +async function activeAccount() { + const config = await api.loadConfig() + if (!config) throw new Error('not paired') + return config +} + +function hostLabel(account) { + try { + return new URL(account.url).host + } catch { + return String(account.url || '') + } +} + +/** + * Read and store the server version of an account (the pair route reports + * it). A server that cannot be reached keeps the last known version. + * + * @param {object} account The account. + * @return {Promise} The version now stored. + */ +async function refreshServerVersion(account) { + try { + const res = await api.pair(account) + const version = res?.serverVersion ?? null + await api.updateAccount(account.id, { serverVersion: version }) + account.serverVersion = version + } catch { + // Offline or unreachable: keep what we had. + } + return account.serverVersion ?? null +} + +/** + * Current state for the popup to render the right view. + * + * @return {Promise} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-server-address-is-https-and-stored-clean + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-lock-and-log-out-per-account-or-all + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +async function getState() { + const accounts = await api.loadAccounts() + const activeId = await api.activeAccountId() + const active = accounts.find((a) => a.id === activeId) || null + const loggedOut = active?.loggedOut === true + const insecure = active ? !isSecureServerUrl(active.url) : false + // An account paired before the handshake has no version yet: ask once. + if (active && active.serverVersion === undefined && !loggedOut && !insecure) { + await refreshServerVersion(active) + } + return { + paired: accounts.length > 0, + maxAccounts: api.MAX_ACCOUNTS, + activeAccountId: activeId, + accounts: accounts.map((a) => ({ + id: a.id, + user: a.user, + host: hostLabel(a), + label: a.label || '', + unlocked: vault.isUnlocked(a.id), + idleMinutes: a.idleMinutes, + loggedOut: a.loggedOut === true, + })), + loggedOut, + loggedOutReason: loggedOut ? active.loggedOutReason || 'revoked' : null, + pinSet: active ? await pinModule().has(active.id) : false, + insecure, + unlocked: active ? vault.isUnlocked(active.id) : false, + user: active ? active.user : null, + url: active ? active.url : null, + idleMinutes: active ? active.idleMinutes : null, + maxIdleMinutes: active ? (maxIdleByAccount.get(active.id) ?? null) : null, + idleChoices: api.IDLE_CHOICES, + serverVersion: active ? (active.serverVersion ?? null) : null, + serverOutdated: active ? !isServerSupported(active.serverVersion) : false, + } +} + +/** + * Pair an account: clean the address, verify the app password, store it. + * + * @param {{url: string, user: string, appPassword: string}} payload The pairing form. + * @return {Promise<{ok: boolean, accountId: string}>} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-server-address-is-https-and-stored-clean + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-unlock-offline-and-say-what-went-wrong + */ +async function doPair(payload) { + if ((await api.loadAccounts()).length >= api.MAX_ACCOUNTS) { + throw new Error( + 'You can connect up to ' + + api.MAX_ACCOUNTS + + ' accounts. Disconnect one first.', + ) + } + const config = { + url: normalizeServerUrl(payload.url), + user: payload.user, + appPassword: payload.appPassword, + } + // Verify the credential actually pairs before persisting it. + const res = await api.pair(config).catch((e) => { + throw new Error(pairingProblem(e)) + }) + const account = await api.addAccount({ + ...config, + serverVersion: res?.serverVersion ?? null, + }) + return { ok: true, accountId: account.id } +} + +/** + * Disconnect an account and delete its app password in Nextcloud. + * + * @param {{accountId?: string}} payload The account, or the active one. + * @return {Promise<{ok: boolean, revoked: boolean}>} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +async function doUnpair(payload) { + const id = payload.accountId || (await api.activeAccountId()) + const account = id ? await api.loadAccount(id) : null + if (!account) return { ok: true } + try { + await api.unpair(account) + } catch { + // Best-effort acknowledgement. + } + let revoked = false + try { + // Delete the app password itself, so Disconnect really ends the + // pairing (#748). The local state is cleared either way. A signed-out + // account has no password left to delete. + revoked = account.loggedOut ? false : await api.revokeAppPassword(account) + } catch { + revoked = false + } + lockAccount(id) + deviceApproval.forget(id) + maxIdleByAccount.delete(id) + await generatorModule() + .forget(id) + .catch(() => {}) + await syncModule() + .forget(id) + .catch(() => {}) + await pinModule() + .remove(id) + .catch(() => {}) + await api.removeAccount(id) + return { ok: true, revoked } +} + +/** + * What went wrong when pairing, in words the user can act on. + * + * @param {{status?: number, insecure?: boolean, message?: string}} error The failure. + * @return {string} + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-unlock-offline-and-say-what-went-wrong + */ +export function pairingProblem(error) { + if (error?.insecure) return error.message + const status = error?.status + if (!status) return 'Cannot reach this server. Check the address.' + if (status === 401) { + return 'Nextcloud did not accept this user name and app password.' + } + if (status === 403) return 'This Nextcloud account may not use Keepiq.' + if (status === 404) { + return 'Keepiq is not installed on this server, or the address is wrong.' + } + if (status >= 500) return 'The server could not answer. Try again later.' + return 'Connecting failed (' + status + ').' +} + +/** + * Log out of accounts on purpose: delete each app password in Nextcloud and + * sign the account out here, keeping its address and user. + * + * @param {{accountId?: string, all?: boolean}} payload One account, or all. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-lock-and-log-out-per-account-or-all + */ +async function doLogout(payload) { + const ids = payload.all + ? (await api.loadAccounts()).map((a) => a.id) + : [payload.accountId || (await api.activeAccountId())] + for (const id of ids) { + const account = id ? await api.loadAccount(id) : null + if (!account || account.loggedOut) continue + try { + await api.revokeAppPassword(account) + } catch { + // Offline: the password is still forgotten here. + } + await signOutAccount(id, 'logout') + } + return { ok: true } +} + +/** + * Sign a signed-out account in again with a new app password. The address + * and user stay; the password is verified before it is stored. + * + * @param {{accountId: string, appPassword: string}} payload The account and its new app password. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +async function doRelogin(payload) { + const account = await api.loadAccount(payload.accountId) + if (!account) throw new Error('This account is no longer connected') + const appPassword = String(payload.appPassword ?? '').trim() + if (appPassword === '') throw new Error('Enter a new app password') + const config = { url: account.url, user: account.user, appPassword } + const res = await api.pair(config) + await api.updateAccount(account.id, { + appPassword, + loggedOut: false, + serverVersion: res?.serverVersion ?? account.serverVersion ?? null, + }) + return { ok: true } +} + +async function doSwitchAccount(payload) { + await api.setActiveAccount(payload.accountId) + return { ok: true } +} + +async function doSetIdle(payload) { + const id = payload.accountId || (await api.activeAccountId()) + const account = await api.updateAccount(id, { + idleMinutes: Number(payload.idleMinutes), + }) + if (vault.isUnlocked(id)) await touchActivity(id) + return { ok: true, effectiveIdleMinutes: effectiveIdleMinutes(account) } +} + +/** + * Read the organisation's idle maximum for an account at unlock. A policy that + * cannot be read caps the delay at the default instead of lifting it. + * + * @param {object} account The account. + * @return {Promise} + */ +async function refreshPolicy(account) { + let max = FALLBACK_MAX_IDLE_MINUTES + try { + const policy = await api.extensionPolicy(account) + const value = Number(policy?.maxIdleMinutes) + if (api.IDLE_CHOICES.includes(value)) max = value + } catch { + // Keep the fallback. + } + maxIdleByAccount.set(account.id, max) +} + +/** + * Unlock the active account with its master password. A signed-out account + * cannot unlock. + * + * @param {{masterPassword: string}} payload The master password. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-unlock-offline-and-say-what-went-wrong + */ +async function doUnlock(payload) { + const account = await activeAccount() + if (account.loggedOut) { + throw new Error( + 'This account is signed out. Sign in again with a new app password.', + ) + } + await refreshServerVersion(account) + let offline = false + try { + await vault.unlock(account.id, account, payload.masterPassword) + } catch (e) { + // No server: unlock with the suite in the vault snapshot, if any. + const suite = isOffline(e) + ? (await syncModule().snapshotOf(account.id))?.suite + : null + if (!suite) throw e + await vault.unlock(account.id, account, payload.masterPassword, { suite }) + offline = true + } + await refreshPolicy(account) + await touchActivity(account.id) + startSync(account) + return { ok: true, offline } +} + +/** + * Unlock one account with a raw unlock key the unlock window unwrapped from a + * passkey (extension-biometric-unlock). The key is used once and not kept. + * + * @param {{accountId: string, rawKey: number[]}} payload The account and key bytes. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +async function doUnlockRaw(payload) { + const account = await api.loadAccount(payload.accountId) + if (!account) throw new Error('unknown account') + if (account.loggedOut) { + throw new Error( + 'This account is signed out. Sign in again with a new app password.', + ) + } + const bytes = Array.isArray(payload.rawKey) ? payload.rawKey : [] + if (bytes.length !== 32) throw new Error('invalid unlock key') + const rawKey = Uint8Array.from(bytes) + try { + await vault.unlockWithRawKey(account.id, account, rawKey) + } finally { + rawKey.fill(0) + } + await refreshPolicy(account) + await touchActivity(account.id) + startSync(account) + return { ok: true } +} + +/** + * Whether device approval is on, and the active account's open request. + * + * @return {Promise<{enabled: boolean, request: object|null}>} + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ +async function doDeviceApprovalState() { + const account = await activeAccount() + return { + enabled: await api.deviceApprovalEnabled(account), + request: deviceApproval.current(account.id), + } +} + +/** + * Start "Approve from another device" for the active account. + * + * @return {Promise} The request: id, phrase, expiry and status. + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ +async function doDeviceApprovalStart() { + const account = await activeAccount() + const agent = globalThis.navigator?.userAgent ?? '' + return { request: await deviceApproval.start(account, agent) } +} + +/** + * Poll the active account's request once; an approval unlocks the account + * through the same raw-key unlock as a passkey. + * + * @return {Promise<{status: string}>} + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ +async function doDeviceApprovalPoll() { + const account = await activeAccount() + const status = await deviceApproval.poll(account, async (rawKey) => { + await vault.unlockWithRawKey(account.id, account, rawKey) + await refreshPolicy(account) + await touchActivity(account.id) + }) + return { status } +} + +/** + * Stop waiting for an approval. + * + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ +async function doDeviceApprovalCancel() { + await deviceApproval.cancel(await activeAccount()) + return { ok: true } +} + +/** + * Candidate list for a host — metadata only (id/name/url). No decryption + * happens here; a locked-but-paired extension can still list names/urls. + * A blocked row is never offered. + * + * @param {{host: string}} payload The site. + * @return {Promise>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-blocked-items-are-never-offered + */ +async function doMatch(payload) { + const account = await activeAccount() + if (!isServerSupported(account.serverVersion)) { + throw new Error( + 'Update Keepiq on your server to use this extension version.', + ) + } + const host = hostOf(payload.host) + let rows + try { + rows = await api.match(account, payload.host) + } catch (e) { + // Offline: offer logins from the vault snapshot instead. + const snapshot = isOffline(e) + ? await syncModule().snapshotOf(account.id) + : null + if (!snapshot) throw e + rows = snapshot.secrets.filter( + (r) => !r.blocked && !r.trashedAt && !r.archivedAt, + ) + } + // A blocked row (its key cannot be used) is never offered, online or not. + rows = rows.filter((r) => !r.blocked) + // A use-only copy is only ever offered on its own site (no "fill anyway"). + const ranked = filterForHost(matchSecrets(rows, payload.host), payload.host) + // Return only index fields; the blobs stay in this account's cache. + matchCache.set(account.id, { + host, + rows: new Map(ranked.map((r) => [r.id, r])), + }) + return ranked.map((r) => ({ + id: r.id, + name: r.name, + url: r.url, + typeId: r.typeId, + useOnly: isUseOnly(r), + accountId: account.id, + })) +} + +/** The context menu item and the keyboard command that fill a login. */ +export const FILL_MENU_ID = 'keepiq-fill' +export const FILL_COMMAND = 'fill-login' + +/** + * Open the popup, where the user can unlock or choose. Browsers allow it only + * right after a user action, which a menu click or a shortcut is. + * + * @return {Promise} + */ +async function openPopupForChoice() { + try { + await chrome.action?.openPopup?.() + } catch { + // Not allowed here: the toolbar button still opens it. + } +} + +/** + * Fill the login for a tab from the context menu or the shortcut: the only + * login for the site fills at once; several, a locked vault or an https + * login on an http page open the popup instead. + * + * @param {object} tab The tab. + * @return {Promise<{filled: boolean, opened?: boolean}>} + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-fill-from-the-context-menu-and-a-shortcut + */ +export async function fillFromShortcut(tab) { + if (!tab || !/^https?:/i.test(String(tab.url || ''))) return { filled: false } + const account = await api.loadConfig() + if (!account) return { filled: false } + if (account.loggedOut || !vault.isUnlocked(account.id)) { + await openPopupForChoice() + return { filled: false, opened: true } + } + const offered = await doMatch({ host: hostOf(tab.url) }) + if (offered.length !== 1) { + if (offered.length > 1) await openPopupForChoice() + return { filled: false, opened: offered.length > 1 } + } + const res = await doFill({ + id: offered[0].id, + accountId: account.id, + tabId: tab.id, + }) + if (res.confirm) { + await openPopupForChoice() + return { filled: false, opened: true } + } + return { filled: !!res.filled } +} + +chrome.runtime?.onInstalled?.addListener(() => { + chrome.contextMenus?.removeAll?.(() => { + chrome.contextMenus.create({ + id: FILL_MENU_ID, + title: 'Fill a login with Keepiq', + contexts: ['editable'], + }) + }) +}) + +chrome.contextMenus?.onClicked?.addListener((info, tab) => { + if (info?.menuItemId === FILL_MENU_ID) fillFromShortcut(tab).catch(() => {}) +}) + +chrome.commands?.onCommand?.addListener(async (command, tab) => { + if (command !== FILL_COMMAND) return + const target = + tab || (await chrome.tabs.query({ active: true, currentWindow: true }))[0] + fillFromShortcut(target).catch(() => {}) +}) + +/** + * Whether filling a row into a page would send an https login to a plain + * http page. + * + * @param {object} row The matched row. + * @param {string} pageUrl The page address. + * @return {boolean} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-ask-before-filling-an-https-login-into-an-http-page + */ +export function downgradesToHttp(row, pageUrl) { + try { + return ( + new URL(pageUrl).protocol === 'http:' + && /^https:\/\//i.test(String(row.url || '')) + ) + } catch { + return false + } +} + +/** + * Decrypt the chosen secret and fill it into the active tab. Refused unless + * the id came from the ACTIVE account's own last match, the message names + * that account, and the tab is still on the matched site. Only the tab's + * frames on the matched host receive the values, and an https login waits + * for the user's yes before it fills a plain http page. + * + * @param {{id: string, accountId: string, tabId?: number, allowHttp?: boolean}} payload The choice. + * @return {Promise} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-a-fill-reaches-only-frames-on-the-matched-site + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-ask-before-filling-an-https-login-into-an-http-page + */ +async function doFill(payload) { + const account = await activeAccount() + if (payload.accountId !== account.id) { + throw new Error('This login belongs to another account') + } + if (!vault.isUnlocked(account.id)) throw new Error('vault is locked') + const cache = matchCache.get(account.id) + const row = cache ? cache.rows.get(payload.id) : undefined + if (!row) throw new Error('This login was not offered for this site') + // A popped-out popup names the tab it was opened over; its own window + // has no page to fill. The host check below applies either way. + const tab = await targetTab(payload.tabId) + if (!tab) return { filled: false } + if (hostOf(tab.url) !== cache.host) { + throw new Error('The page changed. Open Keepiq again to fill.') + } + const useOnly = isUseOnly(row) + if (useOnly && !allowedOnHost(row, cache.host)) { + // Never fill a use-only copy on another site. + return { filled: false } + } + // An https login into a plain http page: the user decides, in the popup. + if (downgradesToHttp(row, tab.url) && payload.allowHttp !== true) { + return { filled: false, confirm: 'http-page' } + } + const { login, secret } = await vault.decryptSecret(account.id, row) + await touchActivity(account.id) + // Only frames on this host get the values; each frame checks again (#740). + const results = await sendToFramesOn(tab.id, cache.host, { + type: 'fill-credential', + payload: { login, secret, host: cache.host, useOnly }, + }) + // A fill counts as a use for the vault's Last used sort; a failed report + // never fails the fill (vault-favourites-tags-and-last-used). A use-only + // fill is also recorded for its owner (sharing-use-only-and-expiring-shares). + await reportFill(results, payload.id, async (id) => + useOnly ? api.reportUseOnlyFill(account, id) : api.markUsed(account, id), + ) + // Auto-copy a matched TOTP code so it is one paste away on the 2FA prompt + // (extension-totp-autofill §3). The popup performs the clipboard write + + // scheduled clear (a service worker has no clipboard access). + const totp = cache.host ? await totpForHost(cache.host) : null + const totpCode = totp ? totp.code : null + if (totp) { + // Best-effort: fill a detected OTP field on the page; the popup also + // copies the code as the fallback (extension-totp-autofill §4.1). + const otp = await sendToFramesOn(tab.id, cache.host, { + type: 'fill-otp', + payload: { code: totp.code, host: cache.host }, + }) + if (!otp?.filled) { + // The code field is on the next step: remember, for this tab, this + // site and five minutes, which secret to compute it from. No seed, + // no code. + const intents = await readIntents() + intents[tab.id] = { + tabId: tab.id, + site: registrableDomain(cache.host), + totpSecretId: totp.secretId, + expiresAt: Date.now() + OTP_INTENT_MS, + } + await writeIntents(intents) + } + } + return { filled: !!results?.filled, totpCode } +} + +/** + * Find a `totp`-typed secret matching the host and compute its current code + * (extension-totp-autofill §2.1), from the active account. The seed is + * decrypted only transiently. + * + * @param {object} payload { host } + * @return {Promise<{ valid: boolean, code?: string, secondsRemaining?: number }>} + */ +async function doTotpForHost(payload) { + const found = await findTotp(payload.host) + return found ? found.result : { valid: false, none: true } +} + +/** + * The active account's `totp` secret for a host and its current code. The + * seed is decrypted only transiently. + * + * @param {string} host The site. + * @return {Promise<{row: object, result: object}|null>} + */ +async function findTotp(host) { + const account = await activeAccount() + if (!vault.isUnlocked(account.id)) throw new Error('vault is locked') + const totpTypeId = await api.typeIdByName(account, 'totp') + if (!totpTypeId) return null + const rows = matchSecrets(await api.match(account, host), host) + const totp = rows.find((r) => r.typeId === totpTypeId) + if (!totp) return null + const seed = await vault.decryptField(account.id, totp.key) + await touchActivity(account.id) + return { row: totp, result: await computeTotp(seed) } +} + +async function totpForHost(host) { + try { + const found = await findTotp(host) + return found && found.result.valid + ? { code: found.result.code, secretId: found.row.id } + : null + } catch { + return null + } +} + +/** + * A code field showed up in a tab (extension-totp-autofill, next step). Fill + * it only for the tab, the site and the time a login fill named, while the + * vault is unlocked, and only once: the intent is deleted before the code is + * computed. + * + * @param {object} payload Ignored: the site comes from the browser's sender record. + * @param {object} sender The content script's sender. + * @return {Promise<{filled: boolean}>} + */ +async function doOtpFieldDetected(payload, sender) { + const tabId = sender?.tab?.id + if (tabId === undefined) return { filled: false } + const intents = await readIntents() + const intent = intents[tabId] + if (!intent) return { filled: false } + const host = hostOf(senderOrigin(sender)) + const site = registrableDomain(host) + if (site === '' || site !== intent.site) return { filled: false } + delete intents[tabId] + await writeIntents(intents) + if (Date.now() >= intent.expiresAt) return { filled: false } + const account = await api.loadConfig() + if (!account || !vault.isUnlocked(account.id)) return { filled: false } + const row = await api.getSecret(account, intent.totpSecretId).catch(() => null) + if (!row || !row.key) return { filled: false } + const result = await computeTotp(await vault.decryptField(account.id, row.key)) + if (!result.valid) return { filled: false } + const res = await chrome.tabs + .sendMessage( + tabId, + // The frame fills only for its own host (fillScope, #740), so + // the message names the host the field was reported from. + { type: 'fill-otp', payload: { code: result.code, host } }, + { frameId: sender.frameId ?? 0 }, + ) + .catch(() => ({ filled: false })) + return { filled: !!res?.filled } +} + +/** + * Why the org password policy refuses a captured password, or null + * (keepiq#746). Runs before anything is encrypted, as the web app does. + * + * @param {object} config The paired config. + * @param {string} value The captured password. + * @return {Promise} The refusal reason, or null. + */ +async function policyRefusalFor(config, value) { + const policy = await api.fetchPolicy(config) + return policyRefusal(policy, value, (prefix) => api.breachRange(config, prefix)) +} + +/** + * Save or update a held capture (encrypted here) to the account it belongs + * to. A password the org policy refuses is not saved; the reason comes back + * as the error. + * + * @param {object} capture The held capture. + * @param {number} tabId The tab it was held for. + * @param {string|null} [folderId] The folder a new login goes into. + * @return {Promise<{ok: boolean, saved: string}>} + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-save-a-new-login-into-a-folder + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-update-the-one-login-that-is-meant + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-a-save-that-confirms + */ +async function saveHeldCapture(capture, tabId, folderId = null) { + const accountId = capture.accountId + const config = await api.loadAccount(accountId) + if (!config) throw new Error('not paired') + if (!vault.isUnlocked(accountId)) throw new Error('vault is locked') + const refusal = await policyRefusalFor(config, capture.secret) + if (refusal !== null) { + captures.delete(tabId) + throw new Error(refusal) + } + const encryptedKey = await vault.encryptField(accountId, capture.secret) + const encryptedLogin = await vault.encryptField(accountId, capture.login || '') + const encryptionSuiteId = vault.activeSuiteId(accountId) + // An update re-reads the login first: it may be gone since the offer. + let update = !!capture.id + if (update) { + try { + await api.getSecret(config, capture.id) + } catch (e) { + if (e?.status !== 404) throw e + update = false + } + } + if (update) { + // The password only: name, address and username stay as they are. + await api.updateSecret(config, capture.id, { + key: encryptedKey, + encryptionSuiteId, + }) + } else { + const typeId = await api.typeIdByName(config, 'login').catch(() => null) + await api.createSecret(config, { + name: capture.name || capture.host, + url: capture.url || capture.host, + key: encryptedKey, + login: encryptedLogin, + encryptionSuiteId, + ...(typeId ? { typeId } : {}), + ...(folderId ? { folderId } : {}), + }) + } + captures.delete(tabId) + await touchActivity(accountId) + // The vault list and the snapshot show the change at once; a failed + // sync never fails the save. + try { + await syncModule().sync(config, { force: true }) + } catch { + // The next sync catches up. + } + return { ok: true, saved: update ? 'updated' : 'saved' } +} + +/** + * Save the capture held for the popup's tab. The popup names the tab, never + * the credential: what is saved is what the browser saw submitted there. + * + * @param {{tabId?: number}} payload The popup's pinned tab, if popped out. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-the-save-prompt-trusts-the-browser-not-the-page + */ +async function doSaveCapture(payload) { + const tab = await targetTab(payload.tabId) + const capture = tab ? captureOf(tab.id) : null + if (!capture) throw new Error('There is no login waiting to be saved here') + return saveHeldCapture(capture, tab.id, payload.folderId || null) +} + +/** + * Never offer to save on the site of the popup's held capture. + * + * @param {{tabId?: number}} payload The popup's pinned tab, if popped out. + * @return {Promise<{ok: boolean}>} + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-never-offer-to-save-on-a-site + */ +async function doCaptureNever(payload) { + const tab = await targetTab(payload.tabId) + const capture = tab ? captureOf(tab.id) : null + if (!capture) return { ok: false } + await setNever(capture.host, true) + captures.delete(tab.id) + return { ok: true } +} + +/** + * The capture held for the popup's tab, for its save prompt, with the account + * it will be saved to. No secret leaves the worker. + * + * @param {{tabId?: number}} payload The popup's pinned tab, if popped out. + * @return {Promise<{capture: object|null}>} + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-the-save-prompt-trusts-the-browser-not-the-page + */ +async function takePendingCapture(payload = {}) { + const tab = await targetTab(payload.tabId) + const capture = tab ? captureOf(tab.id) : null + if (!capture) return { capture: null } + const account = await api.loadAccount(capture.accountId) + return { + capture: { + host: capture.host, + login: capture.login, + update: !!capture.id, + account: account ? account.user + '@' + hostLabel(account) : '', + }, + } +} + +/** + * Hold a submitted login and decide the offer (clients-save-prompt): update + * when a saved login for the site has this username and another password, + * nothing when it has this password, else save. A locked vault cannot tell, + * so it offers nothing in the page and leaves the popup fallback. The capture + * belongs to the account active at submit time and to the tab it came from. + * Its site is the one the browser says sent it, never the page's claim. + * + * @param {object} capture The submitted login from the content script. + * @param {object} sender The runtime.MessageSender of the content script. + * @return {Promise<{action: string, name?: string}>} The offer, without ids or secrets. + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-the-save-prompt-trusts-the-browser-not-the-page + */ +export async function doCapture(capture, sender) { + const tabId = sender?.tab?.id + let origin + try { + origin = new URL(sender?.url || '') + } catch { + return { action: 'none' } + } + if (!Number.isInteger(tabId) || !/^https?:$/.test(origin.protocol)) { + return { action: 'none' } + } + const host = origin.hostname + // The user said never for this site. + if ((await neverSites()).includes(host)) return { action: 'none' } + const config = await api.loadConfig() + if (!config) return { action: 'none' } + captures.set(tabId, { + host, + url: origin.origin, + name: host, + login: String(capture?.login ?? ''), + secret: String(capture?.secret ?? ''), + accountId: config.id, + expiresAt: Date.now() + CAPTURE_TTL_MS, + }) + if (!vault.isUnlocked(config.id)) return { action: 'locked' } + let offer + try { + const rows = await api.match(config, host) + // A login that belongs to a use-only copy is never offered for save + // or update (sharing-use-only-and-expiring-shares D3). + if (blocksSavePrompt(rows, host)) { + captures.delete(tabId) + return { action: 'none' } + } + offer = await classifyCapture({ ...captures.get(tabId) }, rows, (row) => + vault.decryptSecret(config.id, row), + ) + } catch { + return { action: 'none' } + } + // The user may have switched the save or the update offer off. + const settings = await readSettings(chrome.storage?.local) + if ( + offer.action === 'none' + || (offer.action === 'save' && !settings.offerSave) + || (offer.action === 'update' && !settings.offerUpdate) + ) { + captures.delete(tabId) + return { action: 'none' } + } + // Say so in the page instead of offering a save the policy would refuse. + const refusal = await policyRefusalFor(config, captures.get(tabId).secret) + if (refusal !== null) { + captures.delete(tabId) + return { action: 'refused', reason: refusal } + } + captures.get(tabId).id = offer.id + // Kept, so the next page on the site can show the offer again. + captures.get(tabId).offer = { action: offer.action, name: offer.name } + return { action: offer.action, name: offer.name } +} + +/** + * The offer for a page that just loaded in a tab with a held capture: the + * login form often redirects, so the bar comes back on the next page of the + * same site. A page on another site drops the capture. + * + * @param {object} payload Unused. + * @param {object} sender The runtime.MessageSender of the content script. + * @return {Promise<{action: string, name?: string}>} + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-the-offer-follows-the-site-not-the-page + */ +async function doCaptureOffer(payload, sender) { + const tabId = sender?.tab?.id + const capture = captureOf(tabId) + if (!capture?.offer || sender?.frameId) return { action: 'none' } + const host = hostOf(sender?.url || '') + if (registrableDomain(host) !== registrableDomain(capture.host)) { + captures.delete(tabId) + return { action: 'none' } + } + return capture.offer +} + +/** + * Act on the choice made in the in-page offer, for the tab that made it. + * + * @param {{choice: string}} payload save, update or dismiss. + * @param {object} sender The runtime.MessageSender of the content script. + * @return {Promise} The save result, or ok. + * @spec openspec/specs/extension-fill-and-capture/spec.md#requirement-the-save-prompt-trusts-the-browser-not-the-page + */ +async function doCaptureDecision(payload, sender) { + const tabId = sender?.tab?.id + const capture = captureOf(tabId) + if (!capture) return { ok: false } + if (payload.choice === 'save' || payload.choice === 'update') { + return saveHeldCapture(capture, tabId) + } + if (payload.choice === 'never') await setNever(capture.host, true) + captures.delete(tabId) + return { ok: true } +} + +// --- extension passkey unlock (extension-biometric-unlock) --- + +async function unlockedAccount(accountId) { + const account = await api.loadAccount(accountId) + if (!account) throw new Error('unknown account') + if (!vault.isUnlocked(account.id)) throw new Error('vault is locked') + return account +} + +/** + * What the unlock window needs to enrol a passkey for an unlocked account: + * a challenge, the private-key envelope (already ciphertext; its salt feeds + * the unlock-key derivation) and the relying party id. + * + * @param {{accountId: string}} payload The account. + * @return {Promise} + */ +async function doBiometricEnrolContext(payload) { + const account = await unlockedAccount(payload.accountId) + const [{ challenge }, suite] = await Promise.all([ + api.passkeyChallenge(account), + api.fetchActiveSuite(account), + ]) + return { + challenge, + envelope: suite.privateKey, + rpId: extensionRpId(), + user: account.user, + } +} + +/** + * Store the enrolled extension passkey. Only the listed fields are sent: the + * credential id, the PRF salt, the wrapped unlock key, a label and the + * transports. Anything else the window sent is dropped. + * + * @param {{accountId: string, body: object}} payload The account and credential. + * @return {Promise} + */ +async function doBiometricEnrol(payload) { + const account = await unlockedAccount(payload.accountId) + const b = payload.body || {} + return api.enrolPasskey(account, { + credentialId: String(b.credentialId || ''), + wrappedUnlockKey: String(b.wrappedUnlockKey || ''), + prfSalt: String(b.prfSalt || ''), + label: String(b.label || 'Browser extension'), + transports: String(b.transports || ''), + rpId: extensionRpId(), + }) +} + +/** + * The extension's passkey login options for an account (its own relying + * party only). + * + * @param {{accountId: string}} payload The account. + * @return {Promise} + */ +async function doBiometricOptions(payload) { + const account = await api.loadAccount(payload.accountId) + if (!account) throw new Error('unknown account') + const rpId = extensionRpId() + const options = await api.passkeyLoginOptions(account, rpId) + return { ...options, rpId } +} + +async function doBiometricUsed(payload) { + const account = await api.loadAccount(payload.accountId) + if (account && payload.id) { + await api.markPasskeyUsed(account, payload.id).catch(() => {}) + } + return { ok: true } +} + +// --- message router --- + +const handlers = { + 'get-state': getState, + pair: doPair, + relogin: doRelogin, + logout: doLogout, + 'pin-set': doPinSet, + 'pin-unlock': doPinUnlock, + 'pin-remove': async () => { + await pinModule().remove((await activeAccount()).id) + return { ok: true } + }, + unpair: doUnpair, + 'switch-account': doSwitchAccount, + 'set-idle': doSetIdle, + unlock: doUnlock, + 'unlock-raw': doUnlockRaw, + 'device-approval-state': doDeviceApprovalState, + 'device-approval-start': doDeviceApprovalStart, + 'device-approval-poll': doDeviceApprovalPoll, + 'device-approval-cancel': doDeviceApprovalCancel, + lock: async (payload) => { + if (payload.accountId) lockAccount(payload.accountId) + else lockEverything() + return { ok: true } + }, + match: doMatch, + fill: doFill, + 'save-capture': doSaveCapture, + 'totp-for-host': doTotpForHost, + 'pending-capture': takePendingCapture, + // The extension's settings for this browser (clients-extension-gaps). + 'extension-settings': () => readSettings(chrome.storage?.local), + 'set-extension-settings': (payload) => + writeSettings(chrome.storage?.local, payload || {}), + 'page-settings': async () => ({ + suggestPasswords: (await readSettings(chrome.storage?.local)) + .suggestPasswords, + }), + 'capture-never': doCaptureNever, + 'capture-offer': doCaptureOffer, + 'never-sites': async () => ({ sites: await neverSites() }), + 'never-remove': async (payload) => ({ + sites: await setNever(String(payload.host || ''), false), + }), + // A copy in the popup; the worker clears the clipboard later. + 'clipboard-copied': () => clipboardModule().copied(), + 'clipboard-settings': async () => ({ + seconds: await clipboardModule().seconds(), + choices: CLEAR_CHOICES, + }), + 'set-clipboard-clear': async (payload) => ({ + seconds: await clipboardModule().setSeconds(payload.seconds), + }), + 'frame-ready': doFrameReady, + 'capture-decision': doCaptureDecision, + 'biometric-enrol-context': doBiometricEnrolContext, + 'biometric-enrol': doBiometricEnrol, + 'biometric-options': doBiometricOptions, + 'biometric-used': doBiometricUsed, + 'otp-field-detected': doOtpFieldDetected, + // Generator tab context, options and history (clients-extension-complete). + 'generator-context': (p) => generatorModule().handlers['generator-context'](p), + 'generator-options-save': (p) => + generatorModule().handlers['generator-options-save'](p), + 'generator-history-add': (p) => + generatorModule().handlers['generator-history-add'](p), + 'generator-history-clear': (p) => + generatorModule().handlers['generator-history-clear'](p), + // Vault, Generator and Send tabs (clients-extension-generator-vault-send). + ...buildVaultHandlers({ + api, + vault, + activeAccount, + policyRefusalFor: (config, value, typeName) => + api + .fetchPolicy(config) + .then((policy) => + policyRefusal( + policy, + value, + (prefix) => api.breachRange(config, prefix), + typeName, + ), + ), + touchActivity, + sync: syncModule, + suggestPasswords: async () => + (await readSettings(chrome.storage?.local)).suggestPasswords, + }), + // WebAuthn ceremonies relayed from the page-context shim. The origin is the + // sender's, as the browser reports it; the page's own claim in the payload + // is ignored (clients-passkey-origin). + 'webauthn-create': async (p, sender) => ({ + credential: await passkey.handleCreate(p.options, senderOrigin(sender)), + }), + 'webauthn-get': async (p, sender) => ({ + assertion: await passkey.handleGet(p.options, senderOrigin(sender)), + }), +} + +/** + * Whether this router answers a message type. + * + * @param {string} type The message type. + * @return {boolean} + */ +export function handles(type) { + return ( + type === 'capture-credential' + || Object.prototype.hasOwnProperty.call(handlers, type) + ) +} + +/** + * Handle one runtime message. Resolves to the response, or to null for a + * message this router does not own (another listener may take it, such as the + * passkey consent result). + * + * @param {object} msg The message ({ type, payload }). + * @param {object} sender The runtime.MessageSender. + * @return {Promise|null} + */ +export function handleMessage(msg, sender) { + const type = msg?.type + if (type === 'capture-credential') { + // Submit-capture arrives from a content script: hold it and answer + // with the offer the page shows at once. + return doCapture(msg.payload || {}, sender).catch(() => ({ + action: 'none', + })) + } + const handler = Object.prototype.hasOwnProperty.call(handlers, type) + ? handlers[type] + : null + if (!handler) return null + if (!PAGE_MESSAGES.has(type) && !fromExtensionPage(sender)) { + return Promise.resolve({ error: 'not allowed from a web page' }) + } + return handler(msg.payload || {}, sender).catch((e) => ({ + error: e.message || String(e), + })) +} + +/** + * The OS or browser reported an idle state; a locked screen locks every + * account. + * + * @param {string} state active, idle or locked. + * @return {void} + */ +export function onIdleState(state) { + if (state === 'locked') lockEverything() +} diff --git a/browser-extension/src/background/service-worker.js b/browser-extension/src/background/service-worker.js index 95bab958f..3ad3cdac3 100644 --- a/browser-extension/src/background/service-worker.js +++ b/browser-extension/src/background/service-worker.js @@ -1,261 +1,36 @@ /** * Background service worker — the extension's trust core (browser-extension- - * autofill §"Extension architecture"). It is the ONLY place the vault CryptoKey - * lives (in memory, never persisted); the popup and content scripts are - * untrusted UIs that message it. + * autofill §"Extension architecture"). It is the ONLY place vault CryptoKeys + * live (in memory, never persisted); the popup and content scripts are + * untrusted UIs that message it. The handlers live in `router.js`. * - * Responsibilities: pairing, in-worker unlock/lock, URL-matched candidate list - * (metadata only until the user selects), decrypt-on-demand + fill, submit - * capture → save/update, and auto-lock (idle timeout, browser lock, worker - * termination clears memory for free). + * Auto-lock: per-account idle timers (in the vault), every account on OS or + * browser lock, and worker termination clears memory for free. */ -import * as api from '../lib/api.js' -import * as vault from '../lib/vault.js' -import { matchSecrets, hostOf } from '../lib/match.js' -import { buildPasskeyOrchestrator } from '../passkey/orchestrator.js' -import { registerWebAuthnProxy } from '../passkey/registration.js' -import { computeTotp } from '../lib/totp-service.js' +import { migrateLegacyConfig } from '../lib/api.js' +import { handleMessage, handles, onAlarm, onIdleState } from './router.js' -// Passkey provider (extension-passkey-provider): bind the ceremony orchestrator -// to this worker's api + vault. Used by both the native WebAuthn proxy (Chrome/ -// Edge) and the page-context shim relay (Firefox/others). -const passkey = buildPasskeyOrchestrator({ api, vault, loadConfig: api.loadConfig }) -registerWebAuthnProxy(passkey) - -const DEFAULT_IDLE_MINUTES = 15 - -// A pending submit-capture, surfaced to the popup for save/update confirmation. -let pendingCapture = null - -async function idleMs() { - const config = await api.loadConfig() - const minutes = (config && config.idleMinutes) || DEFAULT_IDLE_MINUTES - return minutes * 60 * 1000 -} - -async function touchActivity() { - vault.armIdleLock(await idleMs()) -} - -/** Current state for the popup to render the right view. */ -async function getState() { - const config = await api.loadConfig() - return { - paired: !!config, - unlocked: vault.isUnlocked(), - user: config ? config.user : null, - url: config ? config.url : null, - } -} - -async function doPair(payload) { - const config = { - url: payload.url, - user: payload.user, - appPassword: payload.appPassword, - } - // Verify the credential actually pairs before persisting it. - await api.pair(config) - await api.saveConfig(config) - return { ok: true } -} - -async function doUnpair() { - const config = await api.loadConfig() - if (config) { - try { - await api.unpair(config) - } catch { - // Best-effort; unpairing is local + NC-side revocation. - } - } - vault.lock() - await api.clearConfig() - return { ok: true } -} - -async function doUnlock(payload) { - const config = await api.loadConfig() - if (!config) throw new Error('not paired') - await vault.unlock(config, payload.masterPassword) - await touchActivity() - return { ok: true } -} - -/** - * Candidate list for a host — metadata only (id/name/url). No decryption - * happens here; a locked-but-paired extension can still list names/urls. - * @param payload - */ -async function doMatch(payload) { - const config = await api.loadConfig() - if (!config) throw new Error('not paired') - const rows = await api.match(config, payload.host) - const ranked = matchSecrets(rows, payload.host) - // Return only index fields to the popup; the blobs stay in the worker cache. - blobCache = new Map(ranked.map((r) => [r.id, r])) - return ranked.map((r) => ({ - id: r.id, - name: r.name, - url: r.url, - typeId: r.typeId, - })) -} - -// Short-lived cache of the last match's blob rows (cleared on lock). -let blobCache = new Map() - -/** - * Decrypt the chosen secret and fill it into the active tab. - * @param payload - */ -async function doFill(payload) { - if (!vault.isUnlocked()) throw new Error('vault is locked') - const row = - blobCache.get(payload.id) - || (await api.getSecret(await api.loadConfig(), payload.id)) - const { login, secret } = await vault.decryptSecret(row) - await touchActivity() - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - if (!tab) return { filled: false } - const results = await chrome.tabs - .sendMessage(tab.id, { - type: 'fill-credential', - payload: { login, secret }, - }) - .catch(() => ({ filled: false })) - // Auto-copy a matched TOTP code so it is one paste away on the 2FA prompt - // (extension-totp-autofill §3). The popup performs the clipboard write + - // scheduled clear (a service worker has no clipboard access). - let host = '' - try { - host = tab.url ? new URL(tab.url).hostname : '' - } catch { - host = '' - } - const totpCode = host ? await totpCodeForHost(host) : null - if (totpCode) { - // Best-effort: fill a detected OTP field on the page; the popup also - // copies the code as the fallback (extension-totp-autofill §4.1). - chrome.tabs - .sendMessage(tab.id, { type: 'fill-otp', payload: { code: totpCode } }) - .catch(() => {}) - } - return { filled: !!results?.filled, totpCode } -} - -/** - * Find a `totp`-typed secret matching the host and compute its current code - * (extension-totp-autofill §2.1). The seed is decrypted only transiently. - * - * @param {object} payload { host } - * @return {Promise<{ valid: boolean, code?: string, secondsRemaining?: number }>} - */ -async function doTotpForHost(payload) { - if (!vault.isUnlocked()) throw new Error('vault is locked') - const config = await api.loadConfig() - const totpTypeId = await api.typeIdByName(config, 'totp') - if (!totpTypeId) return { valid: false, none: true } - const rows = matchSecrets(await api.match(config, payload.host), payload.host) - const totp = rows.find((r) => r.typeId === totpTypeId) - if (!totp) return { valid: false, none: true } - const seed = await vault.decryptField(totp.key) - await touchActivity() - return computeTotp(seed) -} - -/** - * Compute the TOTP code for a matched host, if any (auto-copy on fill). - * @param {string} host - * @return {Promise} - */ -async function totpCodeForHost(host) { - try { - const result = await doTotpForHost({ host }) - return result.valid ? result.code : null - } catch { - return null - } -} - -/** - * Save or update a captured credential (encrypted client-side). - * @param payload - */ -async function doSaveCapture(payload) { - if (!vault.isUnlocked()) throw new Error('vault is locked') - const config = await api.loadConfig() - const encryptedKey = await vault.encryptField(payload.secret) - const encryptedLogin = await vault.encryptField(payload.login || '') - const body = { - name: payload.name || hostOf(payload.host), - url: payload.url || payload.host, - key: encryptedKey, - login: encryptedLogin, - encryptionSuiteId: vault.activeSuiteId(), - } - if (payload.id) { - await api.updateSecret(config, payload.id, body) - } else { - await api.createSecret(config, body) - } - pendingCapture = null - await touchActivity() - return { ok: true } -} - -function takePendingCapture() { - return pendingCapture -} - -// --- message router --- - -const handlers = { - 'get-state': getState, - pair: doPair, - unpair: doUnpair, - unlock: doUnlock, - lock: async () => { - vault.lock() - blobCache = new Map() - return { ok: true } - }, - match: doMatch, - fill: doFill, - 'save-capture': doSaveCapture, - 'totp-for-host': doTotpForHost, - 'pending-capture': async () => ({ capture: takePendingCapture() }), - // WebAuthn ceremonies relayed from the page-context shim (Firefox path). - 'webauthn-create': async (p) => ({ - credential: await passkey.handleCreate(p.options, p.origin), - }), - 'webauthn-get': async (p) => ({ - assertion: await passkey.handleGet(p.options, p.origin), - }), -} +// An extension paired before several accounts keeps its pairing as the first +// account (extension-account-switching). Messages wait for it. +const ready = migrateLegacyConfig().catch(() => {}) chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - // Submit-capture arrives from a content script (has sender.tab); stash it. - if (msg?.type === 'capture-credential') { - pendingCapture = { ...msg.payload } - sendResponse({ ok: true }) - return true - } - const handler = handlers[msg?.type] - if (!handler) return false - handler(msg.payload || {}) + if (!handles(msg?.type)) return false + ready + .then(() => handleMessage(msg, sender)) .then((result) => sendResponse(result)) - .catch((e) => sendResponse({ error: e.message || String(e) })) return true // async response }) -// Auto-lock on OS/browser idle+locked. -if (chrome.idle && chrome.idle.onStateChanged) { - chrome.idle.onStateChanged.addListener((state) => { - if (state === 'locked') { - vault.lock() - blobCache = new Map() - } +// Auto-lock every account on OS/browser lock. +// Scheduled vault syncs while unlocked (clients-extension-complete). +if (chrome.alarms && chrome.alarms.onAlarm) { + chrome.alarms.onAlarm.addListener((alarm) => { + onAlarm(alarm).catch(() => {}) }) } + +if (chrome.idle && chrome.idle.onStateChanged) { + chrome.idle.onStateChanged.addListener(onIdleState) +} diff --git a/browser-extension/src/background/vault-handlers.js b/browser-extension/src/background/vault-handlers.js new file mode 100644 index 000000000..4a49c5470 --- /dev/null +++ b/browser-extension/src/background/vault-handlers.js @@ -0,0 +1,622 @@ +/** + * Worker handlers for the popup's Vault, Generator and Send tabs. + * + * Built with their collaborators injected, so the tests drive them without a + * browser or a server. The popup receives index fields for browsing and one + * item's decrypted values only when it opens that item; blobs and keys stay + * in the worker, as for autofill. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + */ + +import { generateKey } from '../../../src/generator/generator.js' +import { parsePasskey } from '../../../src/passkey/passkey.js' +import { + deriveAesKeyArgon2id, + isArgon2Supported, +} from '../../../src/crypto/argon2.js' +import { + aesEncrypt, + sealPayload, + sendLink, + toBase64, +} from '../../../src/send/sendCrypto.js' +import { installArgon2Wasm } from '../lib/argon2-wasm.js' +import { credentialPayload, expirySeconds, maxViewsFrom } from '../lib/send-form.js' +import { buildIndex } from '../lib/vault-index.js' +import { writeErrorMessage } from '../lib/item-form.js' +import { folderNameProblem } from '../lib/folder-rules.js' + +/** Longest secret name the server stores. */ +export const MAX_NAME_LENGTH = 255 + +/** + * Build the handlers. + * + * @param {object} deps The collaborators. + * @param {object} deps.api The API client (`lib/api.js`). + * @param {object} deps.vault The key holder (`lib/vault.js`). + * @param {() => Promise} deps.activeAccount The active, paired account. + * @param {(config: object, value: string, typeName: string) => Promise} deps.policyRefusalFor Why the org policy refuses a value, or null. + * @param {(accountId: string) => Promise} [deps.touchActivity] Re-arm the idle lock. + * @return {Record Promise>} + */ +/** + * What went wrong with a send, in words: offline, the server's own message, + * or the error itself. + * + * @param {{status?: number, body?: string, message?: string}} error The failure. + * @return {string} + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ +export function sendProblem(error) { + if (!error?.status) { + return error?.message && !/fetch/i.test(error.message) + ? error.message + : 'You are offline. A send needs a connection to Keepiq.' + } + try { + const body = JSON.parse(error.body || '') + const message = body?.message || body?.error + if (typeof message === 'string' && message.trim() !== '') return message + } catch { + // No JSON: fall through. + } + return error.message || 'The send failed.' +} + +export function buildVaultHandlers({ + api, + vault, + activeAccount, + policyRefusalFor, + touchActivity = async () => {}, + sync = null, + suggestPasswords = async () => true, +}) { + // Per account: the last listed rows (with blobs), keyed by id. + const rowCache = new Map() + + /** + * After a write: sync, so the list and the cache show it at once. + * + * @param {object} account The account. + * @return {Promise} + */ + async function afterWrite(account) { + rowCache.delete(account.id) + if (sync) await sync().sync(account, { force: true }) + } + + /** + * The active account, refused when its vault is locked. + * + * @return {Promise} + */ + async function unlockedAccount() { + const account = await activeAccount() + if (!vault.isUnlocked(account.id)) { + throw new Error('vault is locked') + } + return account + } + + // Per account: the secret types, id to name, fetched once. + const typeCache = new Map() + + /** + * The name of a secret type, or 'login'. + * + * @param {object} account The account. + * @param {string|null} typeId The type id. + * @return {Promise} + */ + async function typeNameOf(account, typeId) { + if (!typeCache.has(account.id)) { + const types = await api.listTypes(account).catch(() => []) + typeCache.set( + account.id, + new Map(types.map((t) => [t.id, t.name || t.slug])), + ) + } + return typeCache.get(account.id).get(typeId) || 'login' + } + + return { + /** + * The vault index, folders and types (no values). + * + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + */ + 'vault-list': async () => { + const account = await unlockedAccount() + let rows + let folders + let types + let status = null + const snapshot = sync + ? await (async () => { + // Popup open with an old snapshot: sync first (cheaply). + if (await sync().isStale(account.id)) + await sync().sync(account) + status = sync().statusOf(account.id) + return sync().snapshotOf(account.id) + })() + : null + if (snapshot) { + ;({ secrets: rows, folders, types } = snapshot) + } else { + ;[rows, folders, types] = await Promise.all([ + api.listSecrets(account), + api.listFolders(account), + api.listTypes(account), + ]) + } + rowCache.set(account.id, new Map(rows.map((r) => [r.id, r]))) + await touchActivity(account.id) + return { + accountId: account.id, + webAppUrl: api.publicBase(account).replace(/\/public$/, '/'), + items: buildIndex(rows, types, folders), + folders: folders.map((f) => ({ + id: f.id, + name: f.name, + parentId: f.parentId || null, + })), + types: types.map((t) => ({ id: t.id, name: t.name || t.slug })), + sync: status, + } + }, + + /** + * Sync now, whatever the snapshot's age. + * + * @spec openspec/specs/extension-vault-sync/spec.md#requirement-sync-when-it-matters-and-cheaply + */ + 'vault-sync-now': async () => { + const account = await unlockedAccount() + return sync ? sync().sync(account, { force: true }) : { syncedAt: null } + }, + + /** + * One item, fetched fresh and decrypted, for the detail view and the + * form: a stale list row never seeds an edit. A blocked item is not + * decrypted; its reason is returned instead. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-item-detail-with-copy-and-reveal + * @spec openspec/specs/extension-vault/spec.md#requirement-detail-sections-for-every-kind-of-item + * @spec openspec/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker + */ + 'vault-item': async (payload) => { + const account = await unlockedAccount() + let row + let fromCache = false + try { + row = await api.getSecret(account, payload.id) + } catch (e) { + // Offline: read the item from the snapshot instead. + const cached = rowCache.get(account.id)?.get(payload.id) + if (!cached || (e?.status && e.status < 500)) throw e + row = cached + fromCache = true + } + if (!row) { + throw new Error('This item no longer exists') + } + const meta = { + id: row.id, + name: row.name || '', + url: row.url || '', + folderId: row.folderId || null, + typeId: row.typeId || null, + typeName: await typeNameOf(account, row.typeId), + createdAt: row.createdAt || null, + updatedAt: row.updatedAt || null, + expiresAt: row.expiresAt || null, + } + if (row.blocked === true) { + return { + ...meta, + blocked: true, + blockedReason: + row.blockedReason || 'This item cannot be opened here.', + migrationError: row.migrationError || null, + } + } + const decrypted = await vault.decryptSecret(account.id, row) + const login = decrypted.login + let secret = decrypted.secret + // A passkey's private key stays in the worker: the popup gets only + // what it shows, so the key never reaches a page or its DOM. + let passkey + if (meta.typeName === 'passkey') { + const credential = parsePasskey(secret) + passkey = credential + ? { + rpId: credential.rpId, + rpName: credential.rpName, + userName: credential.userName, + userDisplayName: credential.userDisplayName, + createdAt: credential.createdAt, + } + : null + secret = '' + } + let additionalFields = null + let additionalFieldsError = false + if (row.additionalFields) { + const json = await vault.decryptField( + account.id, + row.additionalFields, + ) + try { + const parsed = JSON.parse(json) + if ( + parsed + && typeof parsed === 'object' + && !Array.isArray(parsed) + ) { + additionalFields = parsed + } else { + additionalFieldsError = true + } + } catch { + additionalFieldsError = true + } + } + await touchActivity(account.id) + return { + ...meta, + blocked: false, + fromCache, + login, + secret, + ...(passkey !== undefined ? { passkey } : {}), + additionalFields, + additionalFieldsError, + } + }, + + /** + * Create an item, or update only the parts that changed. Values are + * encrypted here; the popup never sends ciphertext or receives keys. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-add-edit-and-delete-items + * @spec openspec/specs/extension-vault/spec.md#requirement-edit-every-kind-of-item + * @spec openspec/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker + */ + 'vault-save': async (payload) => { + const account = await unlockedAccount() + const changes = payload.changes || {} + const creating = !payload.id + // A passkey is made and updated by the website that uses it; here + // only its name, address, folder and notes change. + if (payload.typeName === 'passkey' && (creating || 'key' in changes)) { + throw new Error( + 'A passkey is created by the website that uses it, and its key cannot be edited', + ) + } + if (creating || 'name' in changes) { + const name = String(changes.name ?? '').trim() + if (name === '') { + throw new Error('Give the item a name') + } + if (name.length > MAX_NAME_LENGTH) { + throw new Error( + `A name has at most ${MAX_NAME_LENGTH} characters`, + ) + } + } + if ('key' in changes) { + const refusal = await policyRefusalFor( + account, + String(changes.key ?? ''), + payload.typeName || 'login', + ) + if (refusal !== null) { + throw new Error(refusal) + } + } + const body = {} + for (const field of ['name', 'url', 'folderId']) { + if (field in changes) + body[field] = + field === 'name' + ? String(changes.name).trim() + : (changes[field] ?? null) + } + if ('key' in changes || creating) + body.key = await vault.encryptField( + account.id, + String(changes.key ?? ''), + ) + if ('login' in changes || creating) + body.login = await vault.encryptField( + account.id, + String(changes.login ?? ''), + ) + if ('additionalFields' in changes) { + body.additionalFields = changes.additionalFields + ? await vault.encryptField( + account.id, + JSON.stringify(changes.additionalFields), + ) + : null + } + if ( + body.key !== undefined + || body.login !== undefined + || body.additionalFields + ) { + body.encryptionSuiteId = vault.activeSuiteId(account.id) + } + let saved + try { + saved = creating + ? await api.createSecret(account, { + ...body, + ...(payload.typeId ? { typeId: payload.typeId } : {}), + }) + : await api.updateSecret(account, payload.id, body) + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + await afterWrite(account) + await touchActivity(account.id) + return { ok: true, id: saved?.id || payload.id || null } + }, + + /** + * Move an item to another folder: only its folder changes. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-clone-and-move + */ + 'vault-move': async (payload) => { + const account = await unlockedAccount() + try { + await api.updateSecret(account, payload.id, { + folderId: payload.folderId || null, + }) + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + await afterWrite(account) + return { ok: true } + }, + + /** + * Create a folder. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + 'folder-create': async (payload) => { + const account = await unlockedAccount() + const problem = folderNameProblem(payload.name) + if (problem) throw new Error(problem) + try { + const folder = await api.createFolder(account, { + name: String(payload.name).trim(), + parentId: payload.parentId || null, + }) + await afterWrite(account) + return { ok: true, id: folder?.id || null } + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + }, + + /** + * Rename a folder. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + 'folder-rename': async (payload) => { + const account = await unlockedAccount() + const problem = folderNameProblem(payload.name) + if (problem) throw new Error(problem) + try { + await api.renameFolder( + account, + payload.id, + String(payload.name).trim(), + ) + await afterWrite(account) + return { ok: true } + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + }, + + /** + * What a folder holds, to choose how to delete it. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + 'folder-children': async (payload) => { + const account = await unlockedAccount() + try { + return await api.folderChildren(account, payload.id) + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + }, + + /** + * Delete a folder with the user's choice for what it holds. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + 'folder-delete': async (payload) => { + const account = await unlockedAccount() + try { + await api.deleteFolder(account, payload.id, { + cascade: payload.cascade, + resolution: payload.resolution, + }) + await afterWrite(account) + return { ok: true } + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + }, + + /** + * Move an item to the trash. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-add-edit-and-delete-items + */ + 'vault-trash': async (payload) => { + const account = await unlockedAccount() + try { + await api.trashSecret(account, payload.id) + } catch (e) { + throw new Error(writeErrorMessage(e), { cause: e }) + } + await afterWrite(account) + return { ok: true } + }, + + /** + * A strong password for a sign-up field on a page, under the org policy. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-suggest-a-strong-password-in-a-sign-up-field + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-settings-for-autofill-new-items-and-appearance + */ + 'generate-for-field': async () => { + // Switched off in Settings: nothing to suggest. + if (!(await suggestPasswords())) return { value: null } + let policy = null + try { + policy = await api.fetchPolicy(await activeAccount()) + } catch { + // No account or no answer: the generator's own defaults apply. + } + return { value: generateKey({ length: 20 }, policy) } + }, + + /** + * Encrypt and create a send; the link carries the key in its fragment. + * + * @spec openspec/specs/extension-send/spec.md#requirement-create-a-send-from-the-popup + * @spec openspec/specs/extension-send/spec.md#requirement-password-protected-sends + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ + 'send-create': async (payload) => { + const account = await unlockedAccount() + const views = maxViewsFrom(payload.maxViews) + if (views.error) throw new Error(views.error) + const expiry = expirySeconds(payload.expiry, payload.customHours) + if (expiry.error) throw new Error(expiry.error) + const payloadType = + payload.payloadType === 'credential' ? 'credential' : 'text' + const plaintext = + payloadType === 'credential' + ? credentialPayload( + String(payload.username || ''), + String(payload.password || ''), + ) + : String(payload.text || '') + if (plaintext.trim() === '') { + throw new Error('There is nothing to send') + } + const { encryptedPayload, rawKey } = await sealPayload(plaintext) + const body = { + encryptedPayload, + payloadType, + maxViews: views.maxViews, + ttlSeconds: expiry.ttlSeconds, + hasPassword: false, + } + // With a password the content key is wrapped under an Argon2id key + // from it, as the web app does; the link then carries no key. + const sendPassword = String(payload.sendPassword || '') + if (sendPassword !== '') { + if (!isArgon2Supported()) { + throw new Error( + 'This browser cannot protect a send with a password.', + ) + } + installArgon2Wasm() + const salt = crypto.getRandomValues(new Uint8Array(16)) + const kek = await deriveAesKeyArgon2id(sendPassword, salt) + body.hasPassword = true + body.wrappedKey = await aesEncrypt(kek, rawKey) + body.argon2idSalt = toBase64(salt) + } + let send + try { + send = await api.createSend(account, body) + } catch (e) { + throw new Error(sendProblem(e)) + } + await touchActivity(account.id) + return { + id: send?.id || null, + link: sendLink( + api.publicBase(account), + send.token, + body.hasPassword ? null : rawKey, + ), + hasPassword: body.hasPassword, + } + }, + + /** + * The account's sends (metadata only). + * + * @spec openspec/specs/extension-send/spec.md#requirement-list-and-end-my-sends + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ + 'send-list': async () => { + const account = await unlockedAccount() + let sends + try { + sends = await api.listSends(account) + } catch (e) { + throw new Error(sendProblem(e)) + } + return { + sends: sends.map((s) => ({ + id: s.id, + payloadType: s.payloadType, + createdAt: s.createdAt, + expiresAt: s.expiresAt || null, + viewCount: s.viewCount ?? 0, + maxViews: s.maxViews ?? 1, + status: s.status || null, + hasPassword: s.hasPassword === true, + })), + } + }, + + /** + * Whether a send can be made now: not while the server is away. + * + * @spec openspec/specs/extension-send-details/spec.md#requirement-no-send-while-offline + */ + 'send-state': async () => { + const account = await unlockedAccount() + return { + offline: sync ? sync().statusOf(account.id).offline === true : false, + } + }, + + /** + * End a send. + * + * @spec openspec/specs/extension-send/spec.md#requirement-list-and-end-my-sends + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ + 'send-revoke': async (payload) => { + const account = await unlockedAccount() + try { + await api.revokeSend(account, payload.id) + } catch (e) { + // Already ended or expired: nothing left to end. + if (e?.status === 404) return { ok: true, gone: true } + throw new Error(sendProblem(e)) + } + return { ok: true } + }, + } +} diff --git a/browser-extension/src/background/vault-sync.js b/browser-extension/src/background/vault-sync.js new file mode 100644 index 000000000..6d8d66b10 --- /dev/null +++ b/browser-extension/src/background/vault-sync.js @@ -0,0 +1,266 @@ +/** + * Vault sync for the extension: a snapshot of the vault per account, kept in + * `storage.local` and replaced in one write, so the Vault tab and autofill + * keep working while the server is unreachable. The snapshot holds what the + * server already stores (ciphertext and plaintext metadata); nothing + * decrypted is ever written. + * + * One sync runs per account at a time; a trigger during a running sync gets + * that sync. A suite change discards the snapshot and locks the vault. + * + * @spec openspec/specs/extension-vault-sync/spec.md#requirement-keep-a-snapshot-of-the-vault + */ + +/** Minutes between scheduled syncs while unlocked. */ +export const SYNC_INTERVAL_MINUTES = 15 + +const SNAPSHOT_KEY = (id) => 'vault-snapshot:' + id + +/** + * Whether an error means the server could not be reached (or failed). + * + * @param {{status?: number}} error The failure. + * @return {boolean} + */ +export function isOffline(error) { + return !error?.status || error.status >= 500 +} + +/** + * Build the sync for the worker. + * + * @param {object} deps The collaborators. + * @param {object} deps.api The API client. + * @param {object} deps.local The persistent storage area. + * @param {(accountId: string) => string|null} deps.activeSuiteId The suite the vault is unlocked with. + * @param {(accountId: string) => void} deps.lock Lock an account. + * @param {() => number} [deps.now] The clock, in ms. + * @return {object} + */ +export function buildVaultSync({ + api, + local, + activeSuiteId, + activeSuiteEpoch = () => null, + lock, + now = () => Date.now(), +}) { + const inFlight = new Map() + const status = new Map() + + /** + * The sync status of an account. + * + * @param {string} id The account id. + * @return {{syncing: boolean, syncedAt: string|null, offline: boolean, lastError: string|null}} + */ + function statusOf(id) { + return { + syncing: false, + syncedAt: null, + offline: false, + lastError: null, + ...status.get(id), + } + } + + /** + * The stored snapshot, or null. + * + * @param {string} id The account id. + * @return {Promise} + */ + async function snapshotOf(id) { + return (await local.get(SNAPSHOT_KEY(id)))[SNAPSHOT_KEY(id)] ?? null + } + + /** + * Fetch the whole vault: the manifest, or page by page when the + * administrator switched offline caching off. + * + * @param {object} account The account. + * @return {Promise<{suite: object|null, secrets: Array, folders: Array, types: Array}>} + */ + async function fetchVault(account) { + try { + const manifest = await api.fetchOfflineManifest(account) + return { + suite: manifest.suite ?? null, + secrets: manifest.secrets ?? [], + folders: manifest.folders ?? [], + types: manifest.types ?? [], + } + } catch (e) { + // Offline caching off: 403 on older servers, 428 since keepiq#673. + if (e?.status !== 403 && e?.status !== 428 && e?.status !== 404) throw e + } + const [secrets, folders, types, suite] = await Promise.all([ + api.listSecrets(account), + api.listFolders(account), + api.listTypes(account), + api.fetchActiveSuite(account), + ]) + return { suite: suite ?? null, secrets, folders, types } + } + + /** + * Run one sync. + * + * @param {object} account The account. + * @param {boolean} force Skip the cheap check. + * @return {Promise} The status after the sync. + */ + async function run(account, force) { + const id = account.id + status.set(id, { ...statusOf(id), syncing: true }) + try { + const cached = await snapshotOf(id) + if (!force && cached) { + const latest = await api.latestSecret(account) + const top = latest?.items?.[0]?.updatedAt ?? null + const fresh = + now() - Date.parse(cached.syncedAt) + < SYNC_INTERVAL_MINUTES * 60_000 + if ( + top === cached.check?.top + && (latest?.total ?? 0) === cached.check?.total + && fresh + ) { + const syncedAt = new Date(now()).toISOString() + await local.set({ [SNAPSHOT_KEY(id)]: { ...cached, syncedAt } }) + status.set(id, { + syncing: false, + syncedAt, + offline: false, + lastError: null, + }) + return statusOf(id) + } + } + const vault = await fetchVault(account) + // A new suite: the cached ciphertext and the key in memory belong to + // the old one. Throw both away and ask for a fresh unlock. + const unlockedWith = activeSuiteId(id) + if (vault.suite?.id && unlockedWith && vault.suite.id !== unlockedWith) { + await local.remove(SNAPSHOT_KEY(id)) + lock(id) + status.set(id, { + syncing: false, + syncedAt: null, + offline: false, + lastError: 'suite-changed', + }) + return statusOf(id) + } + // The same suite with a new unlock-key epoch: the master password + // changed. The cached envelope is wrapped under the old one; drop + // it and ask for the new password. + const epochNow = vault.suite?.unlockKeyEpoch + const epochThen = activeSuiteEpoch(id) + if ( + Number.isInteger(epochNow) + && Number.isInteger(epochThen) + && epochNow !== epochThen + ) { + await local.remove(SNAPSHOT_KEY(id)) + lock(id) + status.set(id, { + syncing: false, + syncedAt: null, + offline: false, + lastError: 'master-password-changed', + }) + return statusOf(id) + } + const sorted = [...vault.secrets].sort((a, b) => + String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')), + ) + const syncedAt = new Date(now()).toISOString() + // One write: a reader never sees half a vault. + await local.set({ + [SNAPSHOT_KEY(id)]: { + suite: vault.suite, + secrets: vault.secrets, + folders: vault.folders, + types: vault.types, + syncedAt, + check: { + top: sorted[0]?.updatedAt ?? null, + total: vault.secrets.length, + }, + }, + }) + status.set(id, { + syncing: false, + syncedAt, + offline: false, + lastError: null, + }) + } catch (e) { + const prior = statusOf(id) + if (e?.status === 401) { + lock(id) + status.set(id, { ...prior, syncing: false, lastError: 'auth' }) + } else { + // Keep the snapshot; a later trigger tries again. + status.set(id, { + ...prior, + syncing: false, + offline: isOffline(e), + lastError: + e?.status === 423 + ? 'locked-for-migration' + : e?.message || 'sync failed', + }) + } + } + return statusOf(id) + } + + return { + /** + * Sync an account, reusing a sync that is already running. + * + * @param {object} account The account. + * @param {{force?: boolean}} [how] force: skip the cheap check. + * @return {Promise} The status. + */ + sync(account, { force = false } = {}) { + if (inFlight.has(account.id)) return inFlight.get(account.id) + const running = run(account, force).finally(() => + inFlight.delete(account.id), + ) + inFlight.set(account.id, running) + return running + }, + + /** + * Whether the snapshot is older than the interval (or missing). + * + * @param {string} id The account id. + * @return {Promise} + */ + async isStale(id) { + const cached = await snapshotOf(id) + return ( + !cached + || now() - Date.parse(cached.syncedAt) + >= SYNC_INTERVAL_MINUTES * 60_000 + ) + }, + + snapshotOf, + statusOf, + + /** + * Forget an account's snapshot and status (logout, removal). + * + * @param {string} id The account id. + * @return {Promise} + */ + async forget(id) { + status.delete(id) + await local.remove(SNAPSHOT_KEY(id)) + }, + } +} diff --git a/browser-extension/src/content/content-script.js b/browser-extension/src/content/content-script.js index dbda27467..68ecfde03 100644 --- a/browser-extension/src/content/content-script.js +++ b/browser-extension/src/content/content-script.js @@ -14,19 +14,13 @@ * No secret is ever stored here; the worker owns all key material. */ -const USERNAME_SELECTORS = [ - 'input[autocomplete="username"]', - 'input[autocomplete="email"]', - 'input[type="email"]', - 'input[name*="user" i]', - 'input[name*="email" i]', - 'input[id*="user" i]', - 'input[id*="email" i]', -] -const PASSWORD_SELECTORS = [ - 'input[type="password"]', - 'input[autocomplete="current-password"]', -] +import { frameMayFill } from '../lib/fillScope.js' +import { watchForOtpField } from './otp-watch.js' +import { attachPasswordSuggestions } from './password-suggest.js' +import { showSavePrompt, showSaveResult } from './save-prompt.js' +import { useOnlyPasswordTarget } from '../lib/useOnly.js' +import { findLoginFields, firstUsable } from '../lib/field-detect.js' + const OTP_SELECTORS = [ 'input[autocomplete="one-time-code"]', 'input[name*="otp" i]', @@ -35,43 +29,13 @@ const OTP_SELECTORS = [ 'input[inputmode="numeric"][maxlength="6"]', ] -function visible(el) { - if (!el || el.disabled || el.readOnly) return false - const rect = el.getBoundingClientRect() - if (rect.width === 0 && rect.height === 0) return false - const style = getComputedStyle(el) - return style.visibility !== 'hidden' && style.display !== 'none' -} - function firstVisible(selectors) { - for (const sel of selectors) { - for (const el of document.querySelectorAll(sel)) { - if (visible(el)) return el - } - } - return null + return firstUsable(document, selectors) } /** Detect the login field pair in this frame. */ function detectLoginFields() { - const password = firstVisible(PASSWORD_SELECTORS) - let username = firstVisible(USERNAME_SELECTORS) - // If no explicit username field, take a preceding visible text input. - if (!username && password) { - const inputs = Array.from(document.querySelectorAll('input')) - const pwIndex = inputs.indexOf(password) - for (let i = pwIndex - 1; i >= 0; i--) { - const t = (inputs[i].type || 'text').toLowerCase() - if ( - (t === 'text' || t === 'email' || t === 'tel') - && visible(inputs[i]) - ) { - username = inputs[i] - break - } - } - } - return { username, password } + return findLoginFields(document) } /** @@ -92,8 +56,16 @@ function setValue(el, value) { el.dispatchEvent(new Event('change', { bubbles: true })) } -function fillCredential({ login, secret }) { - const { username, password } = detectLoginFields() +function fillCredential({ login, secret, useOnly }) { + const detected = detectLoginFields() + const username = detected.username + // A use-only value goes only into a real password field. + const password = useOnly + ? useOnlyPasswordTarget(detected.password) + : detected.password + if (useOnly && !password) { + return false + } let filled = false if (username && login) { username.focus() @@ -147,22 +119,81 @@ function onSubmit() { captureCurrent() } -function captureCurrent() { +// One capture per submit: Enter and submit both fire for the same form. +let lastCaptured = '' + +/** + * Send the submitted login to the worker and show its offer in the page at + * once (clients-save-prompt). The worker decides save, update or nothing; the + * popup keeps the same offer as a fallback. + * + * @return {Promise} + */ +async function captureCurrent() { const { username, password } = detectLoginFields() if (!password || !password.value) return + const login = username ? username.value : '' + const stamp = login + '\u0000' + password.value + if (stamp === lastCaptured) return + lastCaptured = stamp + let offer try { - chrome.runtime.sendMessage({ + offer = await chrome.runtime.sendMessage({ type: 'capture-credential', payload: { host: location.hostname, url: location.origin, - login: username ? username.value : '', + login, secret: password.value, }, }) } catch { // The worker may be asleep; the capture is best-effort. + return } + if (window.top !== window) return // one bar, in the top frame's view only + if (!offer) return + if (offer.action === 'refused') { + // Nothing to decide: the policy refused the password, so only explain. + await showSavePrompt(offer, location.hostname) + return + } + await offerInPage(offer) +} + +/** + * Show a save or update offer in the bar, pass on the choice, and say how + * the save went. + * + * @param {{action: string, name?: string}} offer The worker's offer. + * @return {Promise} + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-a-save-that-confirms + */ +async function offerInPage(offer) { + if (offer?.action !== 'save' && offer?.action !== 'update') return + const choice = await showSavePrompt(offer, location.hostname) + let result + try { + result = await chrome.runtime.sendMessage({ + type: 'capture-decision', + payload: { choice }, + }) + } catch { + // The popup still offers the capture. + return + } + if (choice === 'save' || choice === 'update') showSaveResult(result || {}) +} + +// After a login redirects, the next page of the same site shows the offer +// that is still waiting for this tab. +if (window.top === window) { + Promise.resolve( + chrome.runtime.sendMessage({ type: 'capture-offer', payload: {} }), + ) + .catch(() => null) + .then((offer) => offerInPage(offer)) + .catch(() => {}) } // --- message handling from the popup / background worker --- @@ -173,9 +204,17 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse(reportHasLoginForm()) return true case 'fill-credential': + // A frame of another site (an embedded widget, an advert) stays + // silent, so the answer comes from a frame that may fill (#740). + if (!frameMayFill(location.hostname, msg.payload?.host)) { + return false + } sendResponse({ filled: fillCredential(msg.payload) }) return true case 'fill-otp': + if (!frameMayFill(location.hostname, msg.payload?.host)) { + return false + } sendResponse({ filled: fillOtp(msg.payload?.code) }) return true default: @@ -185,6 +224,34 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { attachSubmitCapture() +// Tell the worker this frame is here; it records the frame's site from the +// browser's sender record, so a fill reaches only frames on the matched site. +chrome.runtime.sendMessage({ type: 'frame-ready', payload: {} })?.catch?.(() => {}) + +// A strong password for a sign-up or change-password field, generated by the +// worker under the org policy (clients-extension-generator-vault-send). +// Only when the user has not switched suggestions off in Settings. +Promise.resolve(chrome.runtime.sendMessage({ type: 'page-settings', payload: {} })) + .catch(() => null) + .then((settings) => { + if (settings?.suggestPasswords === false) return + attachPasswordSuggestions(document, () => + chrome.runtime.sendMessage({ type: 'generate-for-field', payload: {} }), + ) + }) + +// A code field on the step after the login: tell the worker, which fills it +// only when a login fill on this site in this tab asked for it. +watchForOtpField({ + doc: document, + find: () => firstVisible(OTP_SELECTORS), + report: () => { + chrome.runtime + .sendMessage({ type: 'otp-field-detected', payload: {} }) + .catch(() => {}) + }, +}) + // --- WebAuthn relay (extension-passkey-provider, page-context shim path) --- // Inject the page-context shim so it can override navigator.credentials in the @@ -196,7 +263,7 @@ function injectShim() { s.onload = () => s.remove() ;(document.head || document.documentElement).appendChild(s) } catch { - // CSP may block injection; the native proxy path covers Chrome/Edge. + // CSP may block injection; the page then keeps the browser's own authenticator. } } @@ -209,7 +276,9 @@ window.addEventListener('message', async (event) => { try { const res = await chrome.runtime.sendMessage({ type, - payload: { options: data.options, origin: data.origin }, + // The worker takes the origin from the browser's sender record; this + // is only informative and never the page's own claim. + payload: { options: data.options, origin: location.origin }, }) window.postMessage( { diff --git a/browser-extension/src/content/otp-watch.js b/browser-extension/src/content/otp-watch.js new file mode 100644 index 000000000..f285442ed --- /dev/null +++ b/browser-extension/src/content/otp-watch.js @@ -0,0 +1,54 @@ +/** + * Watch a page for a one-time-code field that shows up after the login step + * (extension-totp-autofill, next step): on load and, throttled, whenever the + * DOM changes. Each field is reported once; the worker decides whether a + * pending intent allows a fill. + * + * @spec openspec/specs/extension-totp-autofill/spec.md#requirement-one-time-code-fill-on-the-step-after-the-login + */ + +/** + * Start watching. + * + * @param {object} deps The dependencies. + * @param {Document} deps.doc The document to watch. + * @param {function(): (Element|null)} deps.find Returns the visible code field, if any. + * @param {function(): void} deps.report Tells the worker a new field is there. + * @param {number} [deps.throttleMs] The shortest time between two checks. + * @return {function(): void} Stops watching. + */ +export function watchForOtpField({ doc, find, report, throttleMs = 250 }) { + const reported = new WeakSet() + let timer = null + + function check() { + timer = null + // A throttled check can fire after the page is gone (navigation, or a + // test environment torn down); a detached document has nothing to watch. + if (!doc.defaultView || typeof document === 'undefined') { + return + } + const field = find() + if (field && !reported.has(field)) { + reported.add(field) + report() + } + } + + const view = doc.defaultView || globalThis + const observer = new view.MutationObserver(() => { + if (timer === null) timer = view.setTimeout(check, throttleMs) + }) + observer.observe(doc.documentElement || doc, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['style', 'class', 'hidden'], + }) + check() + + return () => { + observer.disconnect() + if (timer !== null) view.clearTimeout(timer) + } +} diff --git a/browser-extension/src/content/password-suggest.js b/browser-extension/src/content/password-suggest.js new file mode 100644 index 000000000..31af6c10d --- /dev/null +++ b/browser-extension/src/content/password-suggest.js @@ -0,0 +1,201 @@ +/** + * Suggest a strong password in a sign-up or change-password field + * (clients-extension-generator-vault-send). + * + * When a field for a NEW password gets focus, a small offer appears next to + * it. On the user's own click the worker generates a password under the org + * policy and it is filled into that field and its confirmation field. The + * existing save prompt then offers to save the login after the form is sent. + * The offer lives in a closed shadow root, so page script can neither read + * nor click it. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-suggest-a-strong-password-in-a-sign-up-field + */ + +const HOST_ID = 'keepiq-password-suggest' + +/** Words that mark a field for a new password, in its name, id or label. */ +const NEW_PASSWORD_HINT = + /new|confirm|repeat|again|register|signup|sign-up|create|choose/i + +/** + * Whether an input is meant for a new password, not for signing in. + * + * @param {HTMLInputElement} input The input. + * @return {boolean} + */ +export function isNewPasswordField(input) { + if (!input || (input.type || '').toLowerCase() !== 'password') { + return false + } + const autocomplete = (input.getAttribute('autocomplete') || '').toLowerCase() + if (autocomplete.includes('current-password')) { + return false + } + if (autocomplete.includes('new-password')) { + return true + } + const scope = input.form || input.ownerDocument + const passwords = scope.querySelectorAll('input[type="password"]') + if (passwords.length >= 2) { + // A password and its confirmation: a sign-up or change form. + return true + } + const words = [ + input.name, + input.id, + input.getAttribute('aria-label'), + input.placeholder, + ] + .filter(Boolean) + .join(' ') + return NEW_PASSWORD_HINT.test(words) +} + +/** + * The fields to fill for one new password: every password field of the same + * form that is not for the current password. + * + * @param {HTMLInputElement} input The focused field. + * @return {HTMLInputElement[]} + */ +export function fieldsToFill(input) { + const scope = input.form || input.ownerDocument + return Array.from(scope.querySelectorAll('input[type="password"]')).filter( + (field) => + !(field.getAttribute('autocomplete') || '') + .toLowerCase() + .includes('current-password'), + ) +} + +/** + * Set a value so frameworks (React, Vue) see the change. + * + * @param {HTMLInputElement} el The input. + * @param {string} value The value. + */ +function setValue(el, value) { + const setter = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(el), + 'value', + )?.set + if (setter) { + setter.call(el, value) + } else { + el.value = value + } + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) +} + +/** + * Show the offer next to a field. Resolves with true when the user took the + * password and it was filled, false when the offer closed without it. + * + * @param {HTMLInputElement} input The new-password field. + * @param {() => Promise<{value?: string, error?: string}>} request Asks the worker for a password. + * @param {object} [options] Test seams; production uses the defaults. + * @param {string} [options.mode] The shadow root mode, closed by default. + * @param {(event: Event) => boolean} [options.isUserEvent] Whether a click came from the user. + * @return {Promise} + */ +export function showSuggestion( + input, + request, + { mode = 'closed', isUserEvent = (event) => event.isTrusted } = {}, +) { + const doc = input.ownerDocument + doc.getElementById(HOST_ID)?.remove() + const holder = doc.createElement('div') + holder.id = HOST_ID + const root = holder.attachShadow({ mode }) + + const rect = input.getBoundingClientRect() + const style = doc.createElement('style') + style.textContent = ` + .offer { position: fixed; z-index: 2147483647; max-width: 320px; + padding: 8px 12px; border-radius: 8px; background: #fff; color: #222; + box-shadow: 0 2px 12px rgba(0,0,0,.25); font: 14px/1.4 system-ui, sans-serif; + display: flex; gap: 8px; align-items: center; } + button { font: inherit; padding: 4px 10px; border-radius: 6px; cursor: pointer; + border: 1px solid #00679e; background: #00679e; color: #fff; } + button.close { background: transparent; color: inherit; border-color: #767676; } + @media (prefers-color-scheme: dark) { + .offer { background: #1e1e1e; color: #eee; } + }` + const offer = doc.createElement('div') + offer.className = 'offer' + offer.setAttribute('role', 'dialog') + offer.setAttribute('aria-label', 'Keepiq') + offer.style.top = `${Math.round(rect.bottom + 6)}px` + offer.style.left = `${Math.round(rect.left)}px` + const use = doc.createElement('button') + use.type = 'button' + use.textContent = 'Use a strong password' + const close = doc.createElement('button') + close.type = 'button' + close.className = 'close' + close.textContent = 'No thanks' + close.setAttribute('aria-label', 'Close the Keepiq offer') + offer.append(use, close) + root.append(style, offer) + ;(doc.body || doc.documentElement).appendChild(holder) + + return new Promise((resolve) => { + let done = false + const finish = (filled) => { + if (done) return + done = true + holder.remove() + resolve(filled) + } + use.addEventListener('click', async (event) => { + // A script-dispatched click is not the user's choice. + if (!isUserEvent(event)) return + const answer = await request().catch(() => ({})) + if (!answer?.value) { + finish(false) + return + } + for (const field of fieldsToFill(input)) { + setValue(field, answer.value) + } + finish(true) + }) + close.addEventListener('click', (event) => { + if (isUserEvent(event)) finish(false) + }) + input.addEventListener('blur', () => setTimeout(() => finish(false), 300), { + once: true, + }) + }) +} + +/** + * Offer a strong password whenever a new-password field gets focus, at most + * once per field. + * + * @param {Document} doc The document. + * @param {() => Promise<{value?: string}>} request Asks the worker for a password. + * @return {void} + */ +export function attachPasswordSuggestions(doc, request) { + const offered = new WeakSet() + doc.addEventListener( + 'focusin', + (event) => { + const input = event.target + if (!(input instanceof doc.defaultView.HTMLInputElement)) return + if ( + offered.has(input) + || !isNewPasswordField(input) + || input.value !== '' + ) + return + offered.add(input) + showSuggestion(input, request) + }, + true, + ) +} diff --git a/browser-extension/src/content/save-prompt.js b/browser-extension/src/content/save-prompt.js new file mode 100644 index 000000000..e5a3f2c39 --- /dev/null +++ b/browser-extension/src/content/save-prompt.js @@ -0,0 +1,168 @@ +/** + * The in-page save or update offer after a login form is submitted + * (clients-save-prompt). It lives in a closed shadow root, so page script can + * neither read nor click it, and it acts only on trusted (user) clicks. + * + * @spec openspec/specs/clients-save-prompt/spec.md + */ + +const HOST_ID = 'keepiq-save-prompt' + +/** How long the offer stays before it closes on its own (ms). */ +export const PROMPT_TTL_MS = 30000 + +/** + * The words of the offer. + * + * @param {{action: string, name?: string}} offer The offer from the worker. + * @param {string} host The site host. + * @return {{text: string, primary: string|null}} The message and the main button (null: none). + */ +export function promptCopy(offer, host) { + if (offer.action === 'refused') { + // The org password policy refuses this password (keepiq#746): explain, + // and offer no save button. + return { + text: `Keepiq did not save this login for ${host}. ${offer.reason || ''}`.trim(), + primary: null, + } + } + if (offer.action === 'update') { + return { + text: `Update the password of ${offer.name || host} in Keepiq?`, + primary: 'Update', + } + } + return { text: `Save this login for ${host} in Keepiq?`, primary: 'Save' } +} + +/** + * Show the offer. Resolves with the user's choice: 'save', 'update' or + * 'dismiss' (Not now, or the offer timed out). + * + * @param {{action: string, name?: string}} offer The offer from the worker. + * @param {string} host The site host. + * @param {Document} doc The document to show it in. + * @param {object} [options] Test seams; production uses the defaults. + * @param {string} [options.mode] The shadow root mode, closed by default. + * @param {Function} [options.isUserEvent] Whether a click came from the user. + * @return {Promise} The choice. + */ +export function showSavePrompt( + offer, + host, + doc = document, + { mode = 'closed', isUserEvent = (event) => event.isTrusted } = {}, +) { + doc.getElementById(HOST_ID)?.remove() + const holder = doc.createElement('div') + holder.id = HOST_ID + const root = holder.attachShadow({ mode }) + const { text, primary } = promptCopy(offer, host) + + const style = doc.createElement('style') + style.textContent = ` + .bar { position: fixed; top: 12px; right: 12px; z-index: 2147483647; + max-width: 360px; padding: 12px 16px; border-radius: 8px; + background: #fff; color: #222; box-shadow: 0 2px 12px rgba(0,0,0,.25); + font: 14px/1.4 system-ui, sans-serif; } + .actions { display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end; } + button { font: inherit; padding: 6px 12px; border-radius: 6px; cursor: pointer; + border: 1px solid #767676; background: #fff; color: #222; } + button.primary { background: #00679e; border-color: #00679e; color: #fff; } + @media (prefers-color-scheme: dark) { + .bar { background: #1e1e1e; color: #eee; } + button { background: #1e1e1e; color: #eee; border-color: #8c8c8c; } + }` + const bar = doc.createElement('div') + bar.className = 'bar' + bar.setAttribute('role', 'dialog') + bar.setAttribute('aria-label', 'Keepiq') + const message = doc.createElement('p') + message.textContent = text + message.style.margin = '0' + const actions = doc.createElement('div') + actions.className = 'actions' + const later = doc.createElement('button') + later.type = 'button' + later.textContent = primary === null ? 'Close' : 'Not now' + const main = doc.createElement('button') + main.type = 'button' + main.className = 'primary' + main.textContent = primary + // Only a new login can be declined for the site for good. + const never = doc.createElement('button') + never.type = 'button' + never.textContent = 'Never for this site' + if (primary === null) { + actions.append(later) + } else if (offer.action === 'save') { + actions.append(never, later, main) + } else { + actions.append(later, main) + } + bar.append(message, actions) + root.append(style, bar) + ;(doc.body || doc.documentElement).appendChild(holder) + + return new Promise((resolve) => { + let done = false + const finish = (choice) => { + if (done) return + done = true + holder.remove() + resolve(choice) + } + const timer = setTimeout(() => finish('dismiss'), PROMPT_TTL_MS) + const onClick = (choice) => (event) => { + // A script-dispatched click is not the user's choice. + if (!isUserEvent(event)) return + clearTimeout(timer) + finish(choice) + } + main.addEventListener('click', onClick(offer.action)) + later.addEventListener('click', onClick('dismiss')) + never.addEventListener('click', onClick('never')) + ;(primary === null ? later : main).focus?.() + }) +} + +/** How long the result of a save stays on screen. */ +export const RESULT_TTL_MS = 4000 + +/** + * Say how a save from the bar went, for a few seconds. + * + * @param {{ok?: boolean, saved?: string, error?: string}} result The worker's answer. + * @param {Document} doc The document to show it in. + * @param {object} [options] Test seams; production uses the defaults. + * @param {string} [options.mode] The shadow root mode, closed by default. + * @return {string} The text shown. + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-a-save-that-confirms + */ +export function showSaveResult(result, doc = document, { mode = 'closed' } = {}) { + doc.getElementById(HOST_ID)?.remove() + const text = result?.error + ? `Keepiq could not save this login: ${result.error}` + : result?.saved === 'updated' + ? 'Password updated in Keepiq.' + : 'Login saved to Keepiq.' + const holder = doc.createElement('div') + holder.id = HOST_ID + const root = holder.attachShadow({ mode }) + const style = doc.createElement('style') + style.textContent = ` + .bar { position: fixed; top: 12px; right: 12px; z-index: 2147483647; + max-width: 360px; padding: 12px 16px; border-radius: 8px; + background: #fff; color: #222; box-shadow: 0 2px 12px rgba(0,0,0,.25); + font: 14px/1.4 system-ui, sans-serif; } + @media (prefers-color-scheme: dark) { .bar { background: #1e1e1e; color: #eee; } }` + const bar = doc.createElement('p') + bar.className = 'bar' + bar.setAttribute('role', 'status') + bar.textContent = text + root.append(style, bar) + ;(doc.body || doc.documentElement).appendChild(holder) + setTimeout(() => holder.remove(), RESULT_TTL_MS) + return text +} diff --git a/browser-extension/src/crypto/index.js b/browser-extension/src/crypto/index.js index 8d10c707a..586aad33b 100644 --- a/browser-extension/src/crypto/index.js +++ b/browser-extension/src/crypto/index.js @@ -25,3 +25,32 @@ export { } from '../../../src/crypto/rsa.js' export { encodeEnvelope, decodeEnvelope } from '../../../src/crypto/envelope.js' + +// Passkey (PRF) unlock in the extension (extension-biometric-unlock): the web +// app's recipe, re-exported, not re-implemented. +export { + deriveUnlockKeyRaw, + decryptPrivateKeyWithRawKey, +} from '../../../src/crypto/aes.js' + +export { + deriveKekFromPrf, + wrapUnlockKey, + unwrapUnlockKey, + toBase64Url, + fromBase64Url, +} from '../../../src/crypto/passkey.js' + +// New device approval (crypto-new-device-approval D1, D2, D4): the one-time +// X25519 key, the shared verification phrase and the opening of the sealed +// unlock key, re-exported from the web app. +export { generateRecipientKeyPair } from '../../../src/crypto/hpke.js' +export { + openUnlockKey, + sealUnlockKey, + toBase64, +} from '../../../src/crypto/deviceApproval.js' +export { + verificationPhrase, + verificationPhraseFromBase64, +} from '../../../src/crypto/verificationPhrase.js' diff --git a/browser-extension/src/lib/api.js b/browser-extension/src/lib/api.js index 88c5eeaeb..5db85a176 100644 --- a/browser-extension/src/lib/api.js +++ b/browser-extension/src/lib/api.js @@ -5,30 +5,211 @@ * encrypted blob or plaintext index field; the server never returns a decrypted * value. * - * Config ({ url, user, appPassword }) lives in `storage.local`; the app-password - * is a device-scoped, NC-revocable credential. No key material is stored here — + * Accounts ({ id, url, user, appPassword, label, idleMinutes }, up to five) + * live in `storage.local`; each app-password is a device-scoped, NC-revocable + * credential (extension-account-switching). No key material is stored here — * the master password and the derived CryptoKey never touch storage. */ -const CONFIG_KEY = 'keepiq.config' +import { isSecureServerUrl } from './server-url.js' -/** Load the paired config from storage.local (or null if unpaired). */ +// The single pairing before several accounts (read once, then removed). +const LEGACY_CONFIG_KEY = 'keepiq.config' +const ACCOUNTS_KEY = 'keepiq.accounts' +const ACTIVE_KEY = 'keepiq.activeAccountId' + +/** The most accounts one extension holds (extension-account-switching). */ +export const MAX_ACCOUNTS = 5 + +/** The idle lock delays a user can pick, in minutes. */ +export const IDLE_CHOICES = Object.freeze([1, 5, 15, 30, 60, 240]) + +/** The idle lock delay of a new account, in minutes. */ +export const DEFAULT_IDLE_MINUTES = 15 + +function newAccountId() { + return crypto.randomUUID() +} + +/** + * Move a pairing stored under the old single key into the account list, once. + * An extension paired before several accounts keeps its pairing as the first + * account and the old key is removed. + * + * @return {Promise} + */ +export async function migrateLegacyConfig() { + const data = await chrome.storage.local.get([LEGACY_CONFIG_KEY, ACCOUNTS_KEY]) + const legacy = data[LEGACY_CONFIG_KEY] + if (!legacy) return + const accounts = Array.isArray(data[ACCOUNTS_KEY]) ? data[ACCOUNTS_KEY] : [] + if (accounts.length === 0) { + const account = { + id: newAccountId(), + url: legacy.url, + user: legacy.user, + appPassword: legacy.appPassword, + label: '', + idleMinutes: IDLE_CHOICES.includes(legacy.idleMinutes) + ? legacy.idleMinutes + : DEFAULT_IDLE_MINUTES, + } + await chrome.storage.local.set({ + [ACCOUNTS_KEY]: [account], + [ACTIVE_KEY]: account.id, + }) + } + await chrome.storage.local.remove(LEGACY_CONFIG_KEY) +} + +/** + * Every paired account, in pairing order. + * + * @return {Promise>} The accounts. + */ +export async function loadAccounts() { + const data = await chrome.storage.local.get(ACCOUNTS_KEY) + return Array.isArray(data[ACCOUNTS_KEY]) ? data[ACCOUNTS_KEY] : [] +} + +async function saveAccounts(accounts) { + await chrome.storage.local.set({ [ACCOUNTS_KEY]: accounts }) +} + +/** + * The id of the active account (the first one when none is set). + * + * @return {Promise} The id, or null when nothing is paired. + */ +export async function activeAccountId() { + const accounts = await loadAccounts() + const data = await chrome.storage.local.get(ACTIVE_KEY) + const id = data[ACTIVE_KEY] + if (id && accounts.some((a) => a.id === id)) return id + return accounts.length ? accounts[0].id : null +} + +/** + * One account by id. + * + * @param {string} id The account id. + * @return {Promise} The account, or null. + */ +export async function loadAccount(id) { + return (await loadAccounts()).find((a) => a.id === id) || null +} + +/** + * The active account's config, or null when nothing is paired. Every API call + * takes one account's config; matching and filling use the active one. + * + * @return {Promise} The active account. + */ export async function loadConfig() { - const data = await chrome.storage.local.get(CONFIG_KEY) - return data[CONFIG_KEY] || null + const id = await activeAccountId() + return id ? loadAccount(id) : null } /** - * Persist the paired config (non-sensitive: url, user, app-password). - * @param config + * Add a paired account and make it active. Refuses a sixth account, and the + * same user on the same server twice. + * + * @param {{url: string, user: string, appPassword: string, label?: string}} config The pairing. + * @return {Promise} The stored account. */ -export async function saveConfig(config) { - await chrome.storage.local.set({ [CONFIG_KEY]: config }) +export async function addAccount(config) { + const accounts = await loadAccounts() + if (accounts.length >= MAX_ACCOUNTS) { + throw new Error( + 'You can connect up to ' + + MAX_ACCOUNTS + + ' accounts. Disconnect one first.', + ) + } + const sameServer = (a) => + String(a.url).replace(/\/+$/, '') === String(config.url).replace(/\/+$/, '') + && a.user === config.user + if (accounts.some(sameServer)) { + throw new Error('This account is already connected.') + } + const account = { + id: newAccountId(), + url: config.url, + user: config.user, + appPassword: config.appPassword, + label: config.label || '', + idleMinutes: DEFAULT_IDLE_MINUTES, + serverVersion: config.serverVersion ?? null, + } + await saveAccounts([...accounts, account]) + await setActiveAccount(account.id) + return account +} + +/** + * Change stored settings of one account: label, idle delay, server version, + * and the app password and signed-out flag when a revoked password signs it + * out or the user signs in again. + * + * @param {string} id The account id. + * @param {{label?: string, idleMinutes?: number, serverVersion?: string|null, appPassword?: string, loggedOut?: boolean}} patch The changes. + * @return {Promise} The updated account. + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +export async function updateAccount(id, patch) { + const accounts = await loadAccounts() + const account = accounts.find((a) => a.id === id) + if (!account) throw new Error('unknown account') + if (patch.idleMinutes !== undefined) { + if (!IDLE_CHOICES.includes(patch.idleMinutes)) { + throw new Error('unsupported idle delay') + } + account.idleMinutes = patch.idleMinutes + } + if (patch.label !== undefined) account.label = String(patch.label) + if (patch.serverVersion !== undefined) { + account.serverVersion = patch.serverVersion ?? null + } + // Signing out after a revoked app password, and signing in again. + if (patch.appPassword !== undefined) { + account.appPassword = String(patch.appPassword) + } + if (patch.loggedOut !== undefined) account.loggedOut = patch.loggedOut === true + if (patch.loggedOutReason !== undefined) { + account.loggedOutReason = String(patch.loggedOutReason) + } + await saveAccounts(accounts) + return account +} + +/** + * Remove one account. The next remaining account becomes active. + * + * @param {string} id The account id. + * @return {Promise} + */ +export async function removeAccount(id) { + const accounts = (await loadAccounts()).filter((a) => a.id !== id) + await saveAccounts(accounts) + const data = await chrome.storage.local.get(ACTIVE_KEY) + if (data[ACTIVE_KEY] === id) { + if (accounts.length) { + await chrome.storage.local.set({ [ACTIVE_KEY]: accounts[0].id }) + } else { + await chrome.storage.local.remove(ACTIVE_KEY) + } + } } -/** Clear the pairing. */ -export async function clearConfig() { - await chrome.storage.local.remove(CONFIG_KEY) +/** + * Make an account the active one. + * + * @param {string} id The account id. + * @return {Promise} + */ +export async function setActiveAccount(id) { + if (!(await loadAccount(id))) throw new Error('unknown account') + await chrome.storage.local.set({ [ACTIVE_KEY]: id }) } function authHeader(config) { @@ -39,26 +220,114 @@ function base(config) { return String(config.url).replace(/\/+$/, '') } -async function request(config, method, path, body) { - const res = await fetch(base(config) + '/index.php/apps/keepiq' + path, { - method, - headers: { - Authorization: authHeader(config), - 'Content-Type': 'application/json', - 'OCS-APIRequest': 'true', - Accept: 'application/json', +// Called with the account when the server answers 401 to its app password. +const unauthorizedListeners = [] + +/** + * Listen for a server refusing an account's app password (HTTP 401), which + * means it was revoked or changed. + * + * @param {(config: object) => void} listener Called with the account. + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +export function onUnauthorized(listener) { + unauthorizedListeners.push(listener) +} + +/** + * Refuse to send an app password to a server address that is not https + * (or http on a local host), such as one paired before that rule. + * + * @param {object} config The account. + * @throws {Error} When the address is not secure. + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-server-address-is-https-and-stored-clean + */ +function assertSecure(config) { + if (!isSecureServerUrl(base(config))) { + const err = new Error( + 'Keepiq needs an https address. Disconnect this account and connect it again over https.', + ) + err.status = 0 + err.insecure = true + throw err + } +} + +/** + * Fetch from the server with the account's app password and no cookies, so + * a Nextcloud browser session never rides along. + * + * @param {object} config The account. + * @param {string} url The full address. + * @param {object} init The fetch options. + * @return {Promise} + * @spec openspec/specs/extension-pairing/spec.md#requirement-requests-carry-the-app-password-and-no-cookies + */ +function serverFetch(config, url, init = {}) { + assertSecure(config) + return fetch(url, { ...init, credentials: 'omit' }) +} + +async function request(config, method, path, body, extraHeaders = {}) { + const res = await serverFetch( + config, + base(config) + '/index.php/apps/keepiq' + path, + { + method, + headers: { + ...extraHeaders, + Authorization: authHeader(config), + 'Content-Type': 'application/json', + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, }, - body: body ? JSON.stringify(body) : undefined, - }) + ) if (!res.ok) { const text = await res.text().catch(() => '') const err = new Error(`Keepiq ${method} ${path} failed (${res.status})`) err.status = res.status err.body = text + err.code = refusalCodeOf(text) + // A stored account whose app password stopped working. A pairing + // attempt has no id yet; its 401 is just a wrong password. + if (res.status === 401 && config.id) { + for (const listener of unauthorizedListeners) listener(config) + } throw err } if (res.status === 204) return null - return res.json() + const data = await res.json() + // Nextcloud answers a refusal it raises itself on an OCS controller as an + // HTTP 200 envelope with the real status inside (keepiq#673). Keepiq's own + // refusals are 428, but this one must not read as a success either. + const meta = data?.ocs?.meta + if (meta && Number(meta.statuscode) >= 400) { + const err = new Error(`Keepiq ${method} ${path} failed (${meta.statuscode})`) + err.status = Number(meta.statuscode) + err.body = JSON.stringify({ message: meta.message ?? '' }) + err.code = null + throw err + } + return data +} + +/** + * The machine-readable code of a refusal body: its `error`, else its policy + * `code`. Keepiq's OCS routes refuse with 428 and an `error` (keepiq#673). + * + * @param {string} text The response body. + * @return {string|null} + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ +function refusalCodeOf(text) { + try { + const body = JSON.parse(text || '{}') + return body?.error ?? body?.code ?? null + } catch { + return null + } } /** @@ -70,22 +339,87 @@ export function pair(config) { } /** - * Acknowledge unpairing (revocation is the NC app-password). + * Acknowledge unpairing to Keepiq. * @param config */ export function unpair(config) { return request(config, 'POST', '/api/v1/extension/unpair') } +/** + * Delete the app password this extension signs in with, through Nextcloud's + * own endpoint for it (#748). Clearing local settings alone left the password + * valid, so a copy of it kept working after Disconnect. + * + * Nextcloud refuses (403) when the credential is not an app password, which + * leaves nothing to revoke. + * + * @param {object} config The paired config. + * @return {Promise} True when Nextcloud deleted the app password. + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-pairing-against-the-nextcloud-session + */ +/** + * The Nextcloud email address of the account's user, or '' when it has none + * or the server does not answer. Seeds the plus-addressed username. + * + * @param {object} config The account. + * @return {Promise} + * @spec openspec/specs/extension-generator/spec.md#requirement-username-generator + */ +export async function fetchAccountEmail(config) { + const res = await serverFetch( + config, + base(config) + '/ocs/v2.php/cloud/user?format=json', + { + headers: { + Authorization: authHeader(config), + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + }, + ) + if (!res.ok) return '' + const data = await res.json().catch(() => null) + return typeof data?.ocs?.data?.email === 'string' ? data.ocs.data.email : '' +} + +export async function revokeAppPassword(config) { + const res = await serverFetch( + config, + base(config) + '/ocs/v2.php/core/apppassword', + { + method: 'DELETE', + headers: { + Authorization: authHeader(config), + 'OCS-APIRequest': 'true', + Accept: 'application/json', + }, + }, + ) + return res.ok +} + /** * Fetch the caller's active EncryptionSuite (private-key envelope + certificate). * @param config + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login */ export async function fetchActiveSuite(config) { const suites = await request(config, 'GET', '/api/v1/suites') const list = Array.isArray(suites) ? suites : suites.items || [] const active = list.find((s) => s.status === 'active') if (!active) throw new Error('no active encryption suite') + // The two-factor vault policy withholds the wrapped key + // (admin-vault-policies D3): name the reason, never a decryption error. + if (active.unlockBlocked) { + const err = new Error( + active.unlockBlocked === 'two_factor_required' + ? 'two_factor_required: your organisation requires two-factor login in Nextcloud before you can open your vault' + : `vault unlock blocked: ${active.unlockBlocked}`, + ) + err.code = active.unlockBlocked + throw err + } return active } @@ -112,6 +446,36 @@ export function getSecret(config, id) { return request(config, 'GET', '/api/v1/secrets/' + encodeURIComponent(id)) } +/** + * Tell the server this secret was just filled, so the vault list can sort + * by last used (vault-favourites-tags-and-last-used). Sends only the id. + * @param config + * @param id + */ +export function markUsed(config, id) { + return request( + config, + 'POST', + '/api/v1/extension/used/' + encodeURIComponent(id), + ) +} + +/** + * Record a fill of a use-only copy for its owner's activity + * (sharing-use-only-and-expiring-shares §3.3). Sends only the id. + * + * @param {object} config The paired config. + * @param {string} id The copy that was filled. + * @return {Promise} + */ +export function reportUseOnlyFill(config, id) { + return request( + config, + 'POST', + '/api/v1/secrets/' + encodeURIComponent(id) + '/used', + ) +} + /** * Create a secret from an already-encrypted body (blobs only). * @param config @@ -131,11 +495,248 @@ export function updateSecret(config, id, body) { return request(config, 'PUT', '/api/v1/secrets/' + encodeURIComponent(id), body) } +/** + * The offline manifest: the active suite, every secret row (ciphertext and + * plaintext metadata), the folders and the types, in one response. + * + * @param {object} config The account. + * @return {Promise} + * @spec openspec/specs/extension-vault-sync/spec.md#requirement-keep-a-snapshot-of-the-vault + */ +export function fetchOfflineManifest(config) { + return request(config, 'GET', '/api/v1/offline/manifest') +} + +/** + * The most recently updated secret and the total, for the cheap "did + * anything change" check. + * + * @param {object} config The account. + * @return {Promise<{items: Array, total: number}>} + * @spec openspec/specs/extension-vault-sync/spec.md#requirement-sync-when-it-matters-and-cheaply + */ +export function latestSecret(config) { + return request( + config, + 'GET', + '/api/v1/secrets?sort=updated_at&direction=desc&limit=1', + ) +} + +/** The largest page the secrets list serves (SecretService::MAX_LIMIT). */ +export const SECRETS_PAGE_SIZE = 100 + +/** The most pages the page-by-page fallback reads before it gives up loudly. */ +export const MAX_SECRET_PAGES = 1000 + +/** + * Every secret the account can open, page by page (index fields and blobs). + * + * @param {object} config The account. + * @return {Promise>} + * @throws {Error} When the vault has more pages than the fallback reads. + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + * @spec openspec/specs/extension-clipboard/spec.md#requirement-a-large-vault-is-never-cut-off-in-silence + */ +export async function listSecrets(config) { + const items = [] + for (let page = 1; page <= MAX_SECRET_PAGES; page++) { + const data = await request( + config, + 'GET', + `/api/v1/secrets?page=${page}&limit=${SECRETS_PAGE_SIZE}`, + ) + const batch = data?.items || [] + items.push(...batch) + if (batch.length < SECRETS_PAGE_SIZE || items.length >= (data?.total ?? 0)) { + return items + } + } + // Never hand back part of a vault as if it were all of it. + const err = new Error( + `The vault has more than ${MAX_SECRET_PAGES * SECRETS_PAGE_SIZE} items, more than the extension can read page by page. Ask your administrator to switch on offline caching.`, + ) + err.status = 413 + throw err +} + +/** + * The account's folders (names are plaintext on the server). + * + * @param {object} config The account. + * @return {Promise>} + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + */ +export async function listFolders(config) { + const data = await request(config, 'GET', '/api/v1/folders') + return Array.isArray(data) ? data : data?.items || [] +} + +/** + * Create a folder. + * + * @param {object} config The account. + * @param {{name: string, parentId?: string|null}} body The folder. + * @return {Promise} The folder. + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ +export function createFolder(config, body) { + return request(config, 'POST', '/api/v1/folders', body) +} + +/** + * Rename a folder: only its name is sent. + * + * @param {object} config The account. + * @param {string} id The folder id. + * @param {string} name The new name. + * @return {Promise} The folder. + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ +export function renameFolder(config, id, name) { + return request(config, 'PUT', '/api/v1/folders/' + encodeURIComponent(id), { + name, + }) +} + +/** + * A folder's direct item count and direct subfolders with their counts. + * + * @param {object} config The account. + * @param {string} id The folder id. + * @return {Promise<{directSecretCount: number, subfolders: Array}>} + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ +export function folderChildren(config, id) { + return request( + config, + 'GET', + '/api/v1/folders/' + encodeURIComponent(id) + '/children', + ) +} + +/** + * Delete a folder: plain when empty, with a cascade for a leaf with items, + * with a resolution plan when it has subfolders. + * + * @param {object} config The account. + * @param {string} id The folder id. + * @param {{cascade?: string, resolution?: object}} [how] From deleteRequest. + * @return {Promise} + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ +export function deleteFolder(config, id, { cascade, resolution } = {}) { + const query = cascade ? '?cascade=' + encodeURIComponent(cascade) : '' + return request( + config, + 'DELETE', + '/api/v1/folders/' + encodeURIComponent(id) + query, + resolution, + ) +} + +/** + * Move a secret to the trash (it can be restored from the web app). + * + * @param {object} config The account. + * @param {string} id The secret id. + * @return {Promise} + * @spec openspec/specs/extension-vault/spec.md#requirement-add-edit-and-delete-items + */ +export function trashSecret(config, id) { + return request(config, 'DELETE', '/api/v1/secrets/' + encodeURIComponent(id)) +} + +/** + * Create an ephemeral send from an already-encrypted body. + * + * @param {object} config The account. + * @param {object} body encryptedPayload, payloadType, maxViews, ttlSeconds, hasPassword. + * @return {Promise} The send, with its token. + * @spec openspec/specs/extension-send/spec.md#requirement-create-a-send-from-the-popup + */ +export function createSend(config, body) { + return request(config, 'POST', '/api/v1/sends', body) +} + +/** + * The account's own sends (metadata only). + * + * @param {object} config The account. + * @return {Promise>} + * @spec openspec/specs/extension-send/spec.md#requirement-list-and-end-my-sends + */ +export async function listSends(config) { + const data = await request(config, 'GET', '/api/v1/sends') + return Array.isArray(data) ? data : [] +} + +/** + * End one of the account's sends. + * + * @param {object} config The account. + * @param {string} id The send id. + * @return {Promise} + * @spec openspec/specs/extension-send/spec.md#requirement-list-and-end-my-sends + */ +export function revokeSend(config, id) { + return request(config, 'DELETE', '/api/v1/sends/' + encodeURIComponent(id)) +} + +/** + * The public base for recipient links on this account's server. + * + * @param {object} config The account. + * @return {string} + */ +export function publicBase(config) { + return base(config) + '/index.php/apps/keepiq/public' +} + +/** + * Read the org password policy, the same endpoint the web app reads + * (keepiq#746). Resolves to null when it cannot be read: an unavailable + * policy never blocks a save. + * @param config + */ +export async function fetchPolicy(config) { + try { + return await request(config, 'GET', '/api/settings/policy') + } catch { + return null + } +} + +/** + * Fetch the breach suffix list for a 5-character SHA-1 prefix through the + * Keepiq proxy. Only the prefix leaves the browser, and it goes in the body, + * never in the URL, which the server logs next to the user (keepiq#866). + * @param config + * @param prefix + */ +export async function breachRange(config, prefix) { + const data = await request(config, 'POST', '/api/v1/breach-check/range', { + prefix, + }) + return data?.suffixes ?? '' +} + /** * Fetch the secret-type catalogue and return the id of a type by name/slug. * @param config * @param name */ +/** + * The secret types the account's server knows ({id, name}). + * + * @param {object} config The account. + * @return {Promise>} + */ +export async function listTypes(config) { + const types = await request(config, 'GET', '/api/v1/secret-types') + return Array.isArray(types) ? types : types?.items || [] +} + export async function typeIdByName(config, name) { const types = await request(config, 'GET', '/api/v1/secret-types') const list = Array.isArray(types) ? types : types.items || [] @@ -150,3 +751,131 @@ export async function typeIdByName(config, name) { export function passkeyTypeId(config) { return typeIdByName(config, 'passkey') } + +/** + * The organisation's extension policy: `maxIdleMinutes`, the longest idle + * lock delay a user may pick. + * @param config + */ +export function extensionPolicy(config) { + return request(config, 'GET', '/api/v1/extension/policy') +} + +/** + * The extension's own passkey unlock options (client `extension`, bound to + * the extension's relying party id): PRF salts and wrapped unlock keys. + * @param config + * @param rpId + */ +export function passkeyLoginOptions(config, rpId) { + return request( + config, + 'GET', + '/api/v1/passkeys/login-options?client=extension&rpId=' + + encodeURIComponent(rpId), + ) +} + +/** + * A fresh WebAuthn challenge for an enrolment. + * @param config + */ +export function passkeyChallenge(config) { + return request(config, 'GET', '/api/v1/passkeys/challenge') +} + +/** + * Store an extension passkey: credential metadata, the PRF salt and the + * PRF-wrapped unlock key, never the raw key or the PRF output. + * @param config + * @param body + */ +export function enrolPasskey(config, body) { + return request(config, 'POST', '/api/v1/passkeys', { + ...body, + clientKind: 'extension', + }) +} + +/** + * Stamp a passkey as just used. + * @param config + * @param id + */ +export function markPasskeyUsed(config, id) { + return request( + config, + 'POST', + '/api/v1/passkeys/' + encodeURIComponent(id) + '/used', + ) +} + +/** The header that carries a device approval request's one-time secret. */ +export const REQUEST_SECRET_HEADER = 'X-Keepiq-Request-Secret' + +/** + * Whether an administrator left device approval on. + * + * @param {object} config The paired config. + * @return {Promise} + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ +export async function deviceApprovalEnabled(config) { + try { + const res = await request(config, 'GET', '/api/v1/device-approvals/status') + return res?.enabled === true + } catch { + return false + } +} + +/** + * Ask to unlock this extension from another device. + * + * @param {object} config The paired config. + * @param {{publicKey: string, deviceLabel: string}} body The one-time public key (base64) and a label. + * @return {Promise<{id: string, requestSecret: string, expiresAt: string}>} + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ +export function createDeviceApproval(config, body) { + return request(config, 'POST', '/api/v1/device-approvals', { + publicKey: body.publicKey, + clientKind: 'extension', + deviceLabel: body.deviceLabel, + }) +} + +/** + * Pick up a device approval request with its one-time secret. + * + * @param {object} config The paired config. + * @param {string} id The request id. + * @param {string} secret The request secret the create call returned. + * @return {Promise<{status: string, sealedUnlockKey?: string}>} + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ +export function pickupDeviceApproval(config, id, secret) { + return request( + config, + 'GET', + '/api/v1/device-approvals/' + encodeURIComponent(id), + undefined, + { [REQUEST_SECRET_HEADER]: secret }, + ) +} + +/** + * End a device approval request this extension no longer waits for. + * + * @param {object} config The paired config. + * @param {string} id The request id. + * @return {Promise} + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ +export function endDeviceApproval(config, id) { + return request( + config, + 'POST', + '/api/v1/device-approvals/' + encodeURIComponent(id) + '/deny', + ) +} diff --git a/browser-extension/src/lib/argon2-wasm.js b/browser-extension/src/lib/argon2-wasm.js new file mode 100644 index 000000000..6f3660840 --- /dev/null +++ b/browser-extension/src/lib/argon2-wasm.js @@ -0,0 +1,20 @@ +/** + * Argon2id for the extension: the web app's KDF (`src/crypto/argon2.js`) + * with its WebAssembly bundled in (esbuild's binary loader), so the worker + * never fetches code. Password-protected sends need it. + * + * @spec openspec/specs/extension-send/spec.md#requirement-password-protected-sends + */ + +import wasmBinary from 'argon2-browser/dist/argon2.wasm' + +/** + * Point argon2-browser at the bundled WebAssembly, once. + * + * @return {void} + */ +export function installArgon2Wasm() { + if (!globalThis.loadArgon2WasmBinary) { + globalThis.loadArgon2WasmBinary = async () => wasmBinary + } +} diff --git a/browser-extension/src/lib/capture.js b/browser-extension/src/lib/capture.js new file mode 100644 index 000000000..ddb7d2a7f --- /dev/null +++ b/browser-extension/src/lib/capture.js @@ -0,0 +1,51 @@ +/** + * Decide what to offer after a login form is submitted (clients-save-prompt): + * save a new login, update the saved one whose password changed, or nothing + * when the saved one already has this password. Runs in the service worker, + * which holds the vault; the content script only learns which of the three. + * + * @spec openspec/specs/clients-save-prompt/spec.md + */ + +import { hostOf, matchSecrets } from './match.js' + +/** + * Classify a capture against the saved logins for its site. + * + * Only exact-host and same-site rows are considered (a name match is too + * loose to update a password on). A row whose login decrypts to the captured + * username is the match. + * + * @param {{host: string, login: string, secret: string}} capture The submitted login. + * @param {Array} rows Candidate rows from the match endpoint (ciphertext). + * @param {Function} decrypt Resolves a row to {login, secret} in plain text. + * @return {Promise<{action: 'save'|'update'|'none', id?: string, name?: string}>} The offer. + * @spec openspec/specs/extension-save-prompt-details/spec.md#requirement-update-the-one-login-that-is-meant + */ +export async function classifyCapture(capture, rows, decrypt) { + const host = hostOf(capture.host) + const candidates = matchSecrets(rows, host).filter((row) => row._score >= 80) + const sameLogin = [] + for (const row of candidates) { + let plain + try { + plain = await decrypt(row) + } catch { + continue + } + if ((plain.login || '') !== (capture.login || '')) { + continue + } + if ((plain.secret || '') === capture.secret) { + return { action: 'none', id: row.id, name: row.name } + } + sameLogin.push(row) + } + // One saved login with this username: update it. Several: which one is + // meant cannot be told, so offer nothing rather than guess. + if (sameLogin.length === 1) { + return { action: 'update', id: sameLogin[0].id, name: sameLogin[0].name } + } + if (sameLogin.length > 1) return { action: 'none' } + return { action: 'save' } +} diff --git a/browser-extension/src/lib/deviceApproval.js b/browser-extension/src/lib/deviceApproval.js new file mode 100644 index 000000000..4b08b1e05 --- /dev/null +++ b/browser-extension/src/lib/deviceApproval.js @@ -0,0 +1,208 @@ +/** + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * "Approve from another device" for the extension (crypto-new-device-approval + * task 3.1). The worker, not the popup, holds the request: a popup closes as + * soon as it loses focus, and the user approves on another screen. The + * one-time X25519 private key and the request secret live only in this + * module's memory. They are never written to extension storage, so a stopped + * worker loses the request and the user starts again (design, risks). + * + * The approving device seals the raw unlock key to the one-time key; this + * module opens it and hands it to the worker's raw-key unlock, then drops it. + */ + +import { + generateRecipientKeyPair, + openUnlockKey, + toBase64, + verificationPhrase, +} from '../crypto/index.js' +import * as api from './api.js' + +/** One open request per account: accountId => request with its secrets. */ +const requests = new Map() + +/** Statuses after which a request is over and its key is dropped. */ +const FINAL = new Set(['denied', 'expired', 'consumed']) + +/** + * A label for this device, from the user agent: which browser, which system. + * + * @param {string} agent The user agent string. + * @return {string} For example "Keepiq extension in Firefox on Linux". + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ +export function deviceLabel(agent = '') { + const browser = + ['Firefox', 'Edg', 'Chrome'].find((name) => agent.includes(name)) ?? '' + const system = + ['Windows', 'Mac OS', 'Linux', 'CrOS'].find((name) => agent.includes(name)) + ?? '' + const names = { Edg: 'Edge', 'Mac OS': 'macOS', CrOS: 'ChromeOS' } + let label = 'Keepiq extension' + if (browser) label += ' in ' + (names[browser] ?? browser) + if (system) label += ' on ' + (names[system] ?? system) + return label +} + +/** + * What the popup may see of a request: never the key or the secret. + * + * @param {object} request The stored request. + * @return {{id: string, phrase: string, expiresAt: string, status: string}} + */ +function publicView(request) { + return { + id: request.id, + phrase: request.phrase, + expiresAt: request.expiresAt, + status: request.status, + } +} + +/** + * The open request of an account, as the popup may see it. + * + * @param {string} accountId The account. + * @return {object|null} + * @spec openspec/specs/new-device-approval/spec.md#requirement-both-devices-show-the-same-verification-phrase + */ +export function current(accountId) { + const request = requests.get(accountId) + return request ? publicView(request) : null +} + +/** + * Start a request for an account, or return the one already open, so a + * reopened popup does not spend another of the three requests an hour. + * + * @param {object} account The paired account. + * @param {string} agent The user agent, for the device label. + * @return {Promise} The request as the popup shows it. + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ +export async function start(account, agent = '') { + const open = requests.get(account.id) + if (open && !lapsed(open)) return publicView(open) + requests.delete(account.id) + + if (!(await api.deviceApprovalEnabled(account))) { + throw new Error( + 'Your organisation has turned off approval from another device.', + ) + } + const pair = await generateRecipientKeyPair() + const created = await api.createDeviceApproval(account, { + publicKey: toBase64(pair.publicKeyRaw), + deviceLabel: deviceLabel(agent), + }) + const request = { + id: String(created.id), + expiresAt: String(created.expiresAt ?? ''), + secret: String(created.requestSecret ?? ''), + privateKey: pair.privateKey, + publicKeyRaw: pair.publicKeyRaw, + phrase: await verificationPhrase(pair.publicKeyRaw), + status: 'pending', + } + requests.set(account.id, request) + return publicView(request) +} + +/** + * Whether a request is past its expiry on this device's clock. + * + * @param {object} request The stored request. + * @return {boolean} + */ +function lapsed(request) { + const end = Date.parse(request.expiresAt) + return Number.isFinite(end) && end <= Date.now() +} + +/** + * Ask the server once. On approval, open the sealed unlock key with the + * one-time key, unlock through `unlock(rawKey)` and drop the request. The raw + * key is zeroed after use, whatever the unlock did. + * + * @param {object} account The paired account. + * @param {function(Uint8Array): Promise} unlock The worker's raw-key unlock. + * @return {Promise} `none`, `pending`, `unlocked`, `denied`, `expired` or `consumed`. + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ +export async function poll(account, unlock) { + const request = requests.get(account.id) + if (!request) return 'none' + if (lapsed(request)) { + requests.delete(account.id) + return 'expired' + } + + const answer = await api.pickupDeviceApproval( + account, + request.id, + request.secret, + ) + const status = String(answer?.status ?? 'pending') + if (status === 'approved' && answer?.sealedUnlockKey) { + // One pickup only: the server has already cleared the sealed key. + requests.delete(account.id) + let raw + try { + raw = await openUnlockKey( + answer.sealedUnlockKey, + request.privateKey, + request.publicKeyRaw, + request.id, + ) + } catch { + throw new Error( + 'The approval could not be opened on this device. Start again.', + ) + } + try { + await unlock(raw) + } finally { + raw.fill(0) + } + return 'unlocked' + } + if (FINAL.has(status)) { + requests.delete(account.id) + } else { + request.status = status + } + return status +} + +/** + * Stop waiting: drop the key and end the request on the server, so it no + * longer shows as pending on the user's other devices. + * + * @param {object} account The paired account. + * @return {Promise} + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ +export async function cancel(account) { + const request = requests.get(account.id) + requests.delete(account.id) + if (!request) return + try { + await api.endDeviceApproval(account, request.id) + } catch { + // The request expires on its own. + } +} + +/** + * Drop an account's request without a server call (unpair). + * + * @param {string} accountId The account. + * @return {void} + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ +export function forget(accountId) { + requests.delete(accountId) +} diff --git a/browser-extension/src/lib/extension-settings.js b/browser-extension/src/lib/extension-settings.js new file mode 100644 index 000000000..17d26ca60 --- /dev/null +++ b/browser-extension/src/lib/extension-settings.js @@ -0,0 +1,74 @@ +/** + * The extension's own settings for this browser (clients-extension-gaps): + * the save and update offers, password suggestions in sign-up fields, the + * type of a new item and the colour theme. One record for every account. + * + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-settings-for-autofill-new-items-and-appearance + */ + +const KEY = 'extension-settings' + +/** The themes a user can pick. */ +export const THEMES = Object.freeze(['system', 'light', 'dark']) + +/** The settings before the user changes any. */ +export const DEFAULT_SETTINGS = Object.freeze({ + offerSave: true, + offerUpdate: true, + suggestPasswords: true, + defaultType: 'login', + theme: 'system', +}) + +/** + * Settings with unknown keys dropped and bad values replaced by defaults. + * + * @param {object} value The stored or proposed settings. + * @return {object} + */ +export function cleanSettings(value) { + const v = value && typeof value === 'object' ? value : {} + return { + offerSave: v.offerSave !== false, + offerUpdate: v.offerUpdate !== false, + suggestPasswords: v.suggestPasswords !== false, + defaultType: + typeof v.defaultType === 'string' && v.defaultType !== '' + ? v.defaultType.slice(0, 64) + : DEFAULT_SETTINGS.defaultType, + theme: THEMES.includes(v.theme) ? v.theme : DEFAULT_SETTINGS.theme, + } +} + +/** + * Read the settings. + * + * @param {object|undefined} area The storage area. + * @return {Promise} + */ +export async function readSettings(area) { + if (!area) return { ...DEFAULT_SETTINGS } + return cleanSettings((await area.get(KEY))[KEY]) +} + +/** + * Change some settings. + * + * @param {object|undefined} area The storage area. + * @param {object} patch The changes. + * @return {Promise} The settings after the change. + */ +export function writeSettings(area, patch) { + // One write at a time: two quick changes must not read the same old + // record and undo each other. + const run = writes.then(async () => { + const next = cleanSettings({ ...(await readSettings(area)), ...patch }) + await area?.set({ [KEY]: next }) + return next + }) + writes = run.catch(() => {}) + return run +} + +// The pending writes, in order. +let writes = Promise.resolve() diff --git a/browser-extension/src/lib/field-detect.js b/browser-extension/src/lib/field-detect.js new file mode 100644 index 000000000..d8052ec9e --- /dev/null +++ b/browser-extension/src/lib/field-detect.js @@ -0,0 +1,128 @@ +/** + * Finding login fields in a page (clients-extension-gaps): the username and + * password inputs, also inside open shadow roots, never a hidden field or + * one under `aria-hidden`, with the username recognised by its type and + * name or, failing those, by its label, placeholder or accessible name. + * + * Pure apart from reading the document it is given. + * + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-find-fields-in-shadow-roots-and-by-their-label + */ + +export const USERNAME_SELECTORS = [ + 'input[autocomplete="username"]', + 'input[autocomplete="email"]', + 'input[type="email"]', + 'input[name*="user" i]', + 'input[name*="email" i]', + 'input[name*="login" i]', + 'input[id*="user" i]', + 'input[id*="email" i]', + 'input[id*="login" i]', +] + +export const PASSWORD_SELECTORS = [ + 'input[type="password"]', + 'input[autocomplete="current-password"]', +] + +// Words that mark a username field in a label, placeholder or name. A +// leading boundary only, so a compound such as "E-mailadres" counts too. +const USERNAME_WORDS = /\b(user ?name|user|e-?mail|login|account|gebruiker)/i + +/** + * Every element matching a selector in a root and its open shadow roots. + * + * @param {Document|ShadowRoot|Element} root Where to look. + * @param {string} selector The selector. + * @return {Array} + */ +export function deepQueryAll(root, selector) { + const found = [...root.querySelectorAll(selector)] + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) found.push(...deepQueryAll(el.shadowRoot, selector)) + } + return found +} + +/** + * Whether a field can be seen and used. + * + * @param {Element} el The field. + * @return {boolean} + */ +export function usable(el) { + if (!el || el.disabled || el.readOnly) return false + if (String(el.type || '').toLowerCase() === 'hidden') return false + if (el.closest?.('[aria-hidden="true"]')) return false + const rect = el.getBoundingClientRect() + if (rect.width === 0 && rect.height === 0) return false + const style = el.ownerDocument.defaultView.getComputedStyle(el) + return style.visibility !== 'hidden' && style.display !== 'none' +} + +/** + * The first usable element for a list of selectors, in order. + * + * @param {Document} doc The document. + * @param {Array} selectors The selectors. + * @return {Element|null} + */ +export function firstUsable(doc, selectors) { + for (const selector of selectors) { + for (const el of deepQueryAll(doc, selector)) { + if (usable(el)) return el + } + } + return null +} + +/** + * The text a field is known by: its labels, placeholder and accessible name. + * + * @param {HTMLInputElement} el The field. + * @return {string} + */ +function describedAs(el) { + const labels = [...(el.labels || [])].map((l) => l.textContent) + const labelledBy = (el.getAttribute('aria-labelledby') || '') + .split(/\s+/) + .filter(Boolean) + .map((id) => el.getRootNode().getElementById?.(id)?.textContent || '') + return [ + ...labels, + ...labelledBy, + el.getAttribute('aria-label') || '', + el.getAttribute('placeholder') || '', + ].join(' ') +} + +/** + * The login fields of a document. + * + * @param {Document} doc The document. + * @return {{username: HTMLInputElement|null, password: HTMLInputElement|null}} + */ +export function findLoginFields(doc) { + const password = firstUsable(doc, PASSWORD_SELECTORS) + let username = firstUsable(doc, USERNAME_SELECTORS) + const textInputs = deepQueryAll(doc, 'input').filter((el) => { + const type = String(el.type || 'text').toLowerCase() + return (type === 'text' || type === 'email' || type === 'tel') && usable(el) + }) + if (!username) { + username = + textInputs.find((el) => USERNAME_WORDS.test(describedAs(el))) || null + } + // Else the text field right before the password field. + if (!username && password) { + const all = deepQueryAll(doc, 'input') + const at = all.indexOf(password) + username = + all + .slice(0, at) + .reverse() + .find((el) => textInputs.includes(el)) || null + } + return { username, password } +} diff --git a/browser-extension/src/lib/fillScope.js b/browser-extension/src/lib/fillScope.js new file mode 100644 index 000000000..86a3e923d --- /dev/null +++ b/browser-extension/src/lib/fillScope.js @@ -0,0 +1,24 @@ +/** + * Which frames may receive a fill (#740). + * + * The content script runs in every frame, and a fill message to a tab reaches + * all of them. A credential matched for the site in the address bar must not + * land in a third party's iframe (an embedded widget, an advert), so each frame + * fills only when its own host is the host the fill was matched for. The match + * is exact: a sibling subdomain or a parent domain is a different site here. + * + * @spec openspec/specs/browser-extension-autofill/spec.md + */ + +/** + * Whether the frame at `frameHost` may fill a credential matched for `matchedHost`. + * + * @param {string} frameHost The frame's own `location.hostname`. + * @param {string} matchedHost The host of the tab the fill was matched for. + * @return {boolean} True only for the same, non-empty host. + */ +export function frameMayFill(frameHost, matchedHost) { + const frame = String(frameHost || '').toLowerCase() + const matched = String(matchedHost || '').toLowerCase() + return frame !== '' && frame === matched +} diff --git a/browser-extension/src/lib/folder-rules.js b/browser-extension/src/lib/folder-rules.js new file mode 100644 index 000000000..ce89b6973 --- /dev/null +++ b/browser-extension/src/lib/folder-rules.js @@ -0,0 +1,106 @@ +/** + * Folder rules for the folder manager: name checks, the tree, and the + * delete plan the server's deletion protocol expects. Pure: no DOM, no + * network. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + +/** + * Why a folder name cannot be used, or null. + * + * @param {string} name The name. + * @return {string|null} + */ +export function folderNameProblem(name) { + const clean = String(name ?? '').trim() + if (clean === '') return 'Name is required' + if (clean.includes('/')) return 'Folder names cannot contain slashes' + if (clean.length > 255) return 'At most 255 characters' + return null +} + +/** + * The folders as a depth-first tree, siblings sorted by name. + * + * @param {Array<{id: string, name: string, parentId?: string|null}>} folders The folders. + * @return {Array<{id: string, name: string, parentId: string|null, depth: number}>} + */ +export function folderTree(folders) { + const ids = new Set(folders.map((f) => f.id)) + const children = new Map() + for (const folder of folders) { + const parent = + folder.parentId && ids.has(folder.parentId) ? folder.parentId : null + if (!children.has(parent)) children.set(parent, []) + children.get(parent).push(folder) + } + const out = [] + const seen = new Set() + const walk = (parent, depth) => { + const list = (children.get(parent) || []) + .slice() + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }), + ) + for (const folder of list) { + if (seen.has(folder.id)) continue + seen.add(folder.id) + out.push({ + id: folder.id, + name: folder.name, + parentId: folder.parentId || null, + depth, + }) + walk(folder.id, depth + 1) + } + } + walk(null, 0) + return out +} + +/** + * Which delete dialog a folder needs, from its children summary. + * + * @param {{directSecretCount: number, subfolders: Array}} children From `GET /folders/{id}/children`. + * @return {'empty'|'items'|'subfolders'} + */ +export function deleteKind(children) { + if ((children?.subfolders || []).length > 0) return 'subfolders' + if ((children?.directSecretCount || 0) > 0) return 'items' + return 'empty' +} + +/** + * The request for a delete: a cascade for a leaf, a plan for a folder with + * subfolders, nothing for an empty one. Every direct subfolder must have an + * action, as the server requires. + * + * @param {'empty'|'items'|'subfolders'} kind From deleteKind. + * @param {object} choice The user's choice. + * @param {'delete'|'move'} [choice.items] What happens to the folder's own items. + * @param {Record} [choice.subfolders] Action per direct subfolder. + * @param {Array<{id: string}>} [subfolders] The direct subfolders. + * @return {{cascade?: string, resolution?: object}} + */ +export function deleteRequest(kind, choice = {}, subfolders = []) { + if (kind === 'items') { + return { cascade: choice.items === 'delete' ? 'delete' : 'move' } + } + if (kind === 'subfolders') { + const plan = {} + for (const sub of subfolders) { + const action = choice.subfolders?.[sub.id] + plan[sub.id] = ['delete', 'move', 'keep'].includes(action) + ? action + : 'keep' + } + return { + resolution: { + directSecrets: choice.items === 'delete' ? 'delete' : 'move', + subfolders: plan, + }, + } + } + return {} +} diff --git a/browser-extension/src/lib/generator-state.js b/browser-extension/src/lib/generator-state.js new file mode 100644 index 000000000..446c58523 --- /dev/null +++ b/browser-extension/src/lib/generator-state.js @@ -0,0 +1,197 @@ +/** + * The Generator tab's options and history: defaults, sanitising against the + * supported ranges and the org policy, and the history ring. Pure: no DOM, + * no storage. + * + * Ranges follow Keepiq's own generator, which is stricter than Bitwarden's + * where they differ: a password has at least 8 characters, a passphrase 4 to + * 12 words. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-options-remembered-per-account + */ + +import { + generatorPolicy, + MAX_LENGTH, + MAX_WORDS, + MIN_LENGTH, + MIN_WORDS, +} from '../../../src/generator/generator.js' + +/** How many generated values the history keeps. */ +export const MAX_HISTORY = 50 + +/** The sub-tabs of the Generator tab. */ +export const GENERATOR_TABS = Object.freeze(['password', 'passphrase', 'username']) + +/** Options for a first use, close to Bitwarden's defaults. */ +export const DEFAULT_OPTIONS = Object.freeze({ + tab: 'password', + password: Object.freeze({ + length: 14, + includeUppercase: true, + includeLowercase: true, + includeDigits: true, + includeSpecialCharacters: false, + minDigits: 1, + minSpecial: 1, + avoidAmbiguous: true, + }), + passphrase: Object.freeze({ + words: 5, + separator: '-', + capitalise: false, + includeNumber: false, + }), + username: Object.freeze({ + type: 'word', + capitalize: true, + includeNumber: true, + email: '', + emailMode: 'random', + domain: '', + domainMode: 'random', + }), +}) + +/** + * A whole number within a range, or the fallback. + * + * @param {*} value The value. + * @param {number} min Lowest allowed. + * @param {number} max Highest allowed. + * @param {number} fallback Used when value is not a number. + * @return {number} + */ +function clampInt(value, min, max, fallback) { + const n = Math.round(Number(value)) + if (!Number.isFinite(n)) { + return fallback + } + return Math.min(max, Math.max(min, n)) +} + +/** + * A boolean, or the fallback. + * + * @param {*} value The value. + * @param {boolean} fallback Used when value is not a boolean. + * @return {boolean} + */ +function bool(value, fallback) { + return typeof value === 'boolean' ? value : fallback +} + +/** + * Stored options made safe: unknown keys dropped, numbers clamped to the + * supported ranges and to the org policy, classes the policy requires on. + * + * @param {object|null|undefined} raw The stored options. + * @param {object|null} [rawPolicy] The org policy as the server returns it. + * @return {object} + */ +export function sanitizeOptions(raw, rawPolicy = null) { + const d = DEFAULT_OPTIONS + const p = raw?.password || {} + const w = raw?.passphrase || {} + const u = raw?.username || {} + const policy = generatorPolicy(rawPolicy) + const password = { + length: clampInt(p.length, MIN_LENGTH, MAX_LENGTH, d.password.length), + includeUppercase: bool(p.includeUppercase, d.password.includeUppercase), + includeLowercase: bool(p.includeLowercase, d.password.includeLowercase), + includeDigits: bool(p.includeDigits, d.password.includeDigits), + includeSpecialCharacters: bool( + p.includeSpecialCharacters, + d.password.includeSpecialCharacters, + ), + minDigits: clampInt(p.minDigits, 0, 9, d.password.minDigits), + minSpecial: clampInt(p.minSpecial, 0, 9, d.password.minSpecial), + avoidAmbiguous: bool(p.avoidAmbiguous, d.password.avoidAmbiguous), + } + if (policy !== null) { + password.length = Math.max(password.length, policy.minLength) + password.includeUppercase ||= policy.requireUpper + password.includeLowercase ||= policy.requireLower + password.includeDigits ||= policy.requireDigit + password.includeSpecialCharacters ||= policy.requireSymbol + // A required class needs at least one character of it. + if (policy.requireDigit) password.minDigits = Math.max(password.minDigits, 1) + if (policy.requireSymbol) { + password.minSpecial = Math.max(password.minSpecial, 1) + } + } + if ( + !password.includeUppercase + && !password.includeLowercase + && !password.includeDigits + && !password.includeSpecialCharacters + ) { + password.includeLowercase = true + } + const tab = GENERATOR_TABS.includes(raw?.tab) ? raw.tab : d.tab + return { + tab: + tab === 'passphrase' && policy !== null && !policy.allowPassphrase + ? 'password' + : tab, + password, + passphrase: { + words: clampInt(w.words, MIN_WORDS, MAX_WORDS, d.passphrase.words), + separator: + typeof w.separator === 'string' + ? w.separator.slice(0, 3) + : d.passphrase.separator, + capitalise: bool(w.capitalise, d.passphrase.capitalise), + includeNumber: bool(w.includeNumber, d.passphrase.includeNumber), + }, + username: { + type: ['word', 'plus', 'catchall'].includes(u.type) + ? u.type + : d.username.type, + capitalize: bool(u.capitalize, d.username.capitalize), + includeNumber: bool(u.includeNumber, d.username.includeNumber), + email: typeof u.email === 'string' ? u.email.slice(0, 254) : '', + emailMode: u.emailMode === 'website' ? 'website' : 'random', + domain: typeof u.domain === 'string' ? u.domain.slice(0, 253) : '', + domainMode: u.domainMode === 'website' ? 'website' : 'random', + }, + } +} + +/** + * The history with a new value first, at most MAX_HISTORY long. + * + * @param {Array} history The current history, newest first. + * @param {{value: string, kind: string}} entry The new value and its kind. + * @param {number} [now] The time, in ms. + * @return {Array} + */ +export function addToHistory(history, entry, now = Date.now()) { + const list = Array.isArray(history) ? history : [] + return [ + { + value: String(entry.value), + kind: String(entry.kind || 'password'), + at: now, + }, + ...list, + ].slice(0, MAX_HISTORY) +} + +/** + * How long ago, in words. + * + * @param {number} at The time of the entry, in ms. + * @param {number} [now] The current time, in ms. + * @return {string} + */ +export function relativeTime(at, now = Date.now()) { + const seconds = Math.max(0, Math.round((now - at) / 1000)) + if (seconds < 60) return 'just now' + const minutes = Math.round(seconds / 60) + if (minutes < 60) + return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago` + const hours = Math.round(minutes / 60) + return hours === 1 ? '1 hour ago' : `${hours} hours ago` +} diff --git a/browser-extension/src/lib/item-form.js b/browser-extension/src/lib/item-form.js new file mode 100644 index 000000000..699dc5338 --- /dev/null +++ b/browser-extension/src/lib/item-form.js @@ -0,0 +1,307 @@ +/** + * The item form's rules: which fields a type has, how a form maps to the + * secret's key, login and additional fields, what changed, and the limits. + * Pure: no DOM, no network. Card, identity and passkey payloads use the web + * app's own modules. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-edit-every-kind-of-item + */ + +import { + CARD_FIELDS, + IDENTITY_FIELDS, + parsePayload, + serializeCard, + serializeIdentity, +} from '../../../src/cardIdentity/cardIdentity.js' +import { parseOtpauth } from '../../../src/totp/totp.js' + +/** Longest address or field value, in characters (the import limits). */ +export const MAX_FIELD_CHARS = 4096 + +/** Longest name, in characters: the width of the server's name column. */ +export const MAX_NAME_CHARS = 255 + +/** Largest key or additional-fields payload, in UTF-8 bytes. */ +export const MAX_PAYLOAD_BYTES = 65536 + +/** Additional-field names that would collide with the secret's own fields. */ +export const RESERVED_FIELD_NAMES = Object.freeze(['key', 'login', 'url']) + +/** The additional field a non-note item keeps its notes in. */ +export const NOTES_FIELD = 'notes' + +/** + * How the form treats a type. + * + * @param {string} typeName The secret type's name. + * @return {'login'|'note'|'totp'|'card'|'identity'|'passkey'|'generic'} + */ +export function formKind(typeName) { + if ( + ['login', 'note', 'totp', 'card', 'identity', 'passkey'].includes(typeName) + ) { + return typeName + } + return 'generic' +} + +/** Labels of the card and identity fields, in form order. */ +export const COMPOSITE_LABELS = Object.freeze({ + number: 'Card number', + expiry: 'Expiry (MM/YY)', + cvv: 'CVV', + pin: 'PIN', + cardholder: 'Cardholder', + firstName: 'First name', + lastName: 'Last name', + address: 'Address', + phone: 'Phone', + email: 'Email', + bsn: 'BSN', +}) + +/** The card or identity fields shown masked. */ +export const MASKED_COMPOSITE = Object.freeze(['number', 'cvv', 'pin', 'bsn']) + +/** + * The composite field names of a kind, or []. + * + * @param {string} kind The form kind. + * @return {string[]} + */ +export function compositeFields(kind) { + if (kind === 'card') return CARD_FIELDS + if (kind === 'identity') return IDENTITY_FIELDS + return [] +} + +/** + * The draft the form edits, from a decrypted item (or an empty one). + * + * @param {object|null} item The decrypted item from the worker. + * @param {string} typeName The item's type name. + * @return {object} + */ +export function draftFromItem(item, typeName) { + const kind = formKind(typeName) + const fields = + item?.additionalFields + && typeof item.additionalFields === 'object' + && !Array.isArray(item.additionalFields) + ? { ...item.additionalFields } + : {} + const notesKey = Object.keys(fields).find((k) => k.toLowerCase() === NOTES_FIELD) + const notes = + kind === 'note' + ? item?.secret || '' + : notesKey + ? String(fields[notesKey]) + : '' + if (notesKey) delete fields[notesKey] + const composite = {} + if (kind === 'card' || kind === 'identity') { + Object.assign( + composite, + Object.fromEntries(compositeFields(kind).map((f) => [f, ''])), + ) + const parsed = parsePayload(item?.secret || '') + if (parsed && typeof parsed === 'object') { + for (const f of compositeFields(kind)) { + if (parsed[f] !== undefined) composite[f] = String(parsed[f]) + } + } + } + return { + kind, + name: item?.name || '', + url: item?.url || '', + folderId: item?.folderId || null, + login: item?.login || '', + secret: + kind === 'note' || kind === 'card' || kind === 'identity' + ? '' + : item?.secret || '', + notes, + composite, + fields: Object.entries(fields).map(([name, value]) => ({ + name, + value: String(value), + })), + } +} + +/** + * The secret's plaintext parts from a draft. + * + * @param {object} draft The form's draft. + * @return {{name: string, url: string, folderId: string|null, login: string, key: string, additionalFields: object|null}} + */ +export function partsFromDraft(draft) { + let key = draft.secret + if (draft.kind === 'note') key = draft.notes + if (draft.kind === 'card') key = serializeCard(draft.composite) + if (draft.kind === 'identity') key = serializeIdentity(draft.composite) + const fields = {} + for (const { name, value } of draft.fields) { + fields[name.trim()] = value + } + if (draft.kind !== 'note' && draft.notes.trim() !== '') { + fields[NOTES_FIELD] = draft.notes + } + return { + name: draft.name.trim(), + url: draft.url.trim(), + folderId: draft.folderId || null, + login: draft.kind === 'login' || draft.kind === 'generic' ? draft.login : '', + key, + additionalFields: Object.keys(fields).length > 0 ? fields : null, + } +} + +/** + * What changed between the original and the edited parts, as a sparse + * update: only changed fields are sent, so a field the user did not touch is + * never rewritten from a stale form. + * + * @param {object} before The parts the form opened with. + * @param {object} after The parts on Save. + * @return {object} The changed parts only. + */ +export function changedParts(before, after) { + const changes = {} + for (const field of ['name', 'url', 'folderId', 'login', 'key']) { + if ((before[field] ?? '') !== (after[field] ?? '')) + changes[field] = after[field] + } + if ( + JSON.stringify(before.additionalFields ?? null) + !== JSON.stringify(after.additionalFields ?? null) + ) { + changes.additionalFields = after.additionalFields + } + return changes +} + +/** + * UTF-8 byte length. + * + * @param {string} text The text. + * @return {number} + */ +function bytes(text) { + return new TextEncoder().encode(text).length +} + +/** + * Why the draft cannot be saved, by field, or {} when it can. + * + * @param {object} draft The form's draft. + * @return {Record} + * @spec openspec/specs/extension-small-items/spec.md#requirement-a-form-that-starts-and-checks-sensibly + */ +export function validateDraft(draft) { + const errors = {} + if (draft.name.trim() === '') errors.name = 'Give the item a name' + if (draft.name.length > MAX_NAME_CHARS) + errors.name = `At most ${MAX_NAME_CHARS} characters` + if (draft.url.length > MAX_FIELD_CHARS) + errors.url = `At most ${MAX_FIELD_CHARS} characters` + if (draft.login.length > MAX_FIELD_CHARS) + errors.login = `At most ${MAX_FIELD_CHARS} characters` + const seen = new Set() + draft.fields.forEach(({ name, value }, i) => { + const clean = name.trim().toLowerCase() + if (clean === '') errors['field-' + i] = 'Give the field a name' + else if (RESERVED_FIELD_NAMES.includes(clean) || clean === NOTES_FIELD) + errors['field-' + i] = 'This name is reserved' + else if (seen.has(clean)) + errors['field-' + i] = 'Another field has this name' + else if (value.length > MAX_FIELD_CHARS) + errors['field-' + i] = `At most ${MAX_FIELD_CHARS} characters` + seen.add(clean) + }) + // An authenticator secret must be one the code generator can read. + if (draft.kind === 'totp' && draft.secret.trim() !== '') { + try { + parseOtpauth(draft.secret) + } catch { + errors.secret = 'This is not a valid authenticator secret' + } + } + const parts = partsFromDraft(draft) + if (bytes(parts.key) > MAX_PAYLOAD_BYTES) + errors.secret = 'This value is too long to save' + if ( + parts.additionalFields + && bytes(JSON.stringify(parts.additionalFields)) > MAX_PAYLOAD_BYTES + ) { + errors.fields = 'The additional fields are too long to save' + } + return errors +} + +/** + * A folder's path, "Work / Clients", or "No folder". + * + * @param {Array<{id: string, name: string, parentId?: string|null}>} folders The folders. + * @param {string|null} folderId The folder. + * @return {string} + */ +export function folderPath(folders, folderId) { + const byId = new Map(folders.map((f) => [f.id, f])) + const names = [] + const seen = new Set() + let current = folderId ? byId.get(folderId) : null + while (current && !seen.has(current.id)) { + seen.add(current.id) + names.unshift(current.name) + current = current.parentId ? byId.get(current.parentId) : null + } + return names.length > 0 ? names.join(' / ') : 'No folder' +} + +/** + * A response body as an object, or an empty one when it is not JSON. + * + * @param {string|undefined} text The body. + * @return {object} + */ +function parseBody(text) { + try { + const body = JSON.parse(text || '{}') + return body && typeof body === 'object' ? body : {} + } catch { + return {} + } +} + +/** + * A server write failure in words the user can act on. + * + * @param {{status?: number, body?: string, message?: string}} error The failure. + * @return {string} + */ +export function writeErrorMessage(error) { + if (error?.status === 423) + return 'Vault is temporarily locked for a key migration, try again later' + // Keepiq's OCS routes refuse with 428 and an error code (keepiq#673); a + // plain controller still says 403. A policy refusal carries its own + // reason; a plain one, as before, means the suite is blocked. + if (error?.status === 403 || error?.status === 428) { + const body = parseBody(error.body) + if (body.code || (body.error && body.error !== 'forbidden')) { + return body.message + ? String(body.message) + : 'The server refused this change' + } + return 'Your encryption suite is blocked, open Keepiq to resolve it' + } + if (error?.status === 400 || error?.status === 409) { + const message = parseBody(error.body).message + if (message) return String(message) + return 'The server refused this change' + } + if (!error?.status) return 'Could not reach the server' + return error.message || 'Saving failed' +} diff --git a/browser-extension/src/lib/match.js b/browser-extension/src/lib/match.js index ef97b56e3..3539413e6 100644 --- a/browser-extension/src/lib/match.js +++ b/browser-extension/src/lib/match.js @@ -49,6 +49,18 @@ export function hostOf(input) { } } +/** + * Whether a host is a public suffix under the same approximation: a single + * label (`org`, `nl`) or one of the multi-label suffixes above. + * + * @param {string} host A hostname. + * @return {boolean} True for a public suffix. + */ +export function isPublicSuffix(host) { + const h = hostOf(host) + return h !== '' && (h.indexOf('.') === -1 || MULTI_LABEL_SUFFIXES.has(h)) +} + /** * The registrable domain (eTLD+1 approximation) of a hostname. * @param host @@ -100,10 +112,18 @@ export function matchScore(secret, targetHost) { * @param {Array<{ url?: string, name?: string }>} secrets * @param {string} targetHost * @return {Array} matching secrets, best-first, each with `_score` + * @spec openspec/specs/extension-small-items/spec.md#requirement-suggestions-by-last-use */ export function matchSecrets(secrets, targetHost) { return (secrets || []) .map((s) => ({ ...s, _score: matchScore(s, targetHost) })) .filter((s) => s._score > 0) - .sort((a, b) => b._score - a._score) + .sort( + (a, b) => + b._score - a._score + // Equally good: the one used last comes first. + || String(b.lastUsedAt || '').localeCompare( + String(a.lastUsedAt || ''), + ), + ) } diff --git a/browser-extension/src/lib/pin-unlock.js b/browser-extension/src/lib/pin-unlock.js new file mode 100644 index 000000000..b2bf2bba8 --- /dev/null +++ b/browser-extension/src/lib/pin-unlock.js @@ -0,0 +1,140 @@ +/** + * Unlock with a PIN (clients-extension-gaps). After a master-password unlock, + * the user may set a PIN: the account's unlock key (the AES key that opens + * the private-key envelope) is wrapped under a key derived from the PIN + * with Argon2id. The wrapped key lives in session storage only, so the PIN + * works until the browser closes; after five wrong PINs it is forgotten. + * + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ + +import { + aesDecrypt, + aesEncrypt, + fromBase64, + toBase64, +} from '../../../src/send/sendCrypto.js' +import { deriveAesKeyArgon2id } from '../../../src/crypto/argon2.js' +import { installArgon2Wasm } from './argon2-wasm.js' + +/** Wrong PINs before the PIN is forgotten. */ +export const PIN_MAX_ATTEMPTS = 5 + +/** The shortest PIN accepted. */ +export const PIN_MIN_LENGTH = 6 + +const KEY = (accountId) => 'pin:' + accountId + +/** + * What is wrong with a PIN, or null. + * + * @param {string} pin The PIN. + * @return {string|null} + */ +export function pinProblem(pin) { + const value = String(pin ?? '') + if (value.length < PIN_MIN_LENGTH) { + return `A PIN has at least ${PIN_MIN_LENGTH} characters` + } + if (value.length > 64) return 'A PIN has at most 64 characters' + return null +} + +/** + * Build the PIN store on a session storage area. + * + * @param {object} session The session storage area. + * @return {object} + */ +export function buildPinUnlock(session) { + /** + * The stored record of an account, or null. + * + * @param {string} accountId The account. + * @return {Promise} + */ + async function recordOf(accountId) { + return (await session.get(KEY(accountId)))[KEY(accountId)] ?? null + } + + return { + /** + * Whether an account has a PIN. + * + * @param {string} accountId The account. + * @return {Promise} + */ + async has(accountId) { + return (await recordOf(accountId)) !== null + }, + + /** + * Wrap an unlock key under a PIN. + * + * @param {string} accountId The account. + * @param {Uint8Array} rawKey The unlock key. + * @param {string} pin The PIN. + * @return {Promise} + */ + async set(accountId, rawKey, pin) { + const problem = pinProblem(pin) + if (problem) throw new Error(problem) + installArgon2Wasm() + const salt = crypto.getRandomValues(new Uint8Array(16)) + const key = await deriveAesKeyArgon2id(String(pin), salt) + await session.set({ + [KEY(accountId)]: { + salt: toBase64(salt), + wrapped: await aesEncrypt(key, rawKey), + attempts: 0, + }, + }) + }, + + /** + * Open the unlock key with a PIN. A wrong PIN counts; the fifth + * forgets the PIN. + * + * @param {string} accountId The account. + * @param {string} pin The PIN. + * @return {Promise} The unlock key. + * @throws {Error} On a wrong PIN or no PIN. + */ + async open(accountId, pin) { + const record = await recordOf(accountId) + if (!record) + throw new Error('No PIN is set. Unlock with your master password.') + installArgon2Wasm() + const key = await deriveAesKeyArgon2id( + String(pin ?? ''), + fromBase64(record.salt), + ) + try { + return await aesDecrypt(key, record.wrapped) + } catch { + const attempts = record.attempts + 1 + if (attempts >= PIN_MAX_ATTEMPTS) { + await session.remove(KEY(accountId)) + throw new Error( + 'Too many wrong PINs. Unlock with your master password.', + ) + } + await session.set({ [KEY(accountId)]: { ...record, attempts } }) + const left = PIN_MAX_ATTEMPTS - attempts + throw new Error( + `Wrong PIN. ${left} ${left === 1 ? 'try' : 'tries'} left.`, + ) + } + }, + + /** + * Forget an account's PIN. + * + * @param {string} accountId The account. + * @return {Promise} + */ + async remove(accountId) { + await session.remove(KEY(accountId)) + }, + } +} diff --git a/browser-extension/src/lib/policy.js b/browser-extension/src/lib/policy.js new file mode 100644 index 000000000..cc9b9e74a --- /dev/null +++ b/browser-extension/src/lib/policy.js @@ -0,0 +1,48 @@ +/** + * The org password policy on the extension's save path (keepiq#746). + * + * The web app refuses a manual value below the policy's strength floor, or + * one found in known breaches when the policy blocks those. A login saved + * from the extension skipped both. This module applies the SAME rules + * (`src/policy/rules.js`, bundled from the web app's source) to the same + * policy (`GET /api/settings/policy`), before the value is encrypted. + * + * Honest-client model, as in the web app: the server cannot see the value. + * A policy or breach service that does not answer never blocks a save. + * + * @spec openspec/specs/org-password-policies/spec.md#requirement-client-side-save-enforcement + */ + +import { checkValueWith } from '../../../src/health/hibpMatch.js' +import { hibpBlockApplies, scoreShortfall } from '../../../src/policy/rules.js' + +/** The type a login saved from the extension gets (the server default). */ +export const EXTENSION_SAVE_TYPE = 'login' + +/** + * Why the policy refuses this value, or null when it may be saved. + * + * @param {object|null} policy The policy from the server, or null when unavailable. + * @param {string} value The captured password (stays in the worker). + * @param {Function} fetchRange Resolves a 5-char SHA-1 prefix to the suffix list. + * @param {string} [typeName] The secret type the value is saved as. + * @return {Promise} The refusal reason, or null. + */ +export async function policyRefusal( + policy, + value, + fetchRange, + typeName = EXTENSION_SAVE_TYPE, +) { + const shortfall = scoreShortfall(policy, typeName, value) + if (shortfall !== null) { + return `Password strength ${shortfall.score} is below your organisation's minimum of ${shortfall.floor}.` + } + if (hibpBlockApplies(policy, typeName, value)) { + const result = await checkValueWith(value, fetchRange) + if (result.status === 'breached') { + return `This password appears in known breaches ${result.count} times. Choose another.` + } + } + return null +} diff --git a/browser-extension/src/lib/send-form.js b/browser-extension/src/lib/send-form.js new file mode 100644 index 000000000..c9ae42522 --- /dev/null +++ b/browser-extension/src/lib/send-form.js @@ -0,0 +1,115 @@ +/** + * Send form rules for the popup: expiry presets, view bounds, the credential + * body and the row label. Pure: no DOM, no network. + * + * @spec openspec/specs/extension-send/spec.md#requirement-create-a-send-from-the-popup + */ + +/** Most views a send may allow (EphemeralSendService::MAX_VIEWS_CAP). */ +export const MAX_VIEWS_CAP = 100 + +/** Longest custom expiry, in hours (30 days). */ +export const MAX_CUSTOM_HOURS = 720 + +/** The expiry presets, in the order the form shows them. */ +export const EXPIRY_PRESETS = Object.freeze([ + { id: '1h', label: '1 hour', seconds: 3600 }, + { id: '1d', label: '1 day', seconds: 86400 }, + { id: '2d', label: '2 days', seconds: 2 * 86400 }, + { id: '3d', label: '3 days', seconds: 3 * 86400 }, + { id: '7d', label: '7 days', seconds: 7 * 86400 }, + { id: '30d', label: '30 days', seconds: 30 * 86400 }, + { id: 'custom', label: 'Custom', seconds: null }, +]) + +/** + * The expiry in seconds, or an error to show under the field. + * + * @param {string} presetId One of EXPIRY_PRESETS' ids. + * @param {string|number} [customHours] Hours for the Custom preset. + * @return {{ttlSeconds: number}|{error: string}} + */ +export function expirySeconds(presetId, customHours) { + const preset = EXPIRY_PRESETS.find((p) => p.id === presetId) + if (!preset) { + return { error: 'Choose when the send expires' } + } + if (preset.seconds !== null) { + return { ttlSeconds: preset.seconds } + } + const hours = Number(customHours) + if (!Number.isInteger(hours) || hours < 1) { + return { error: 'Enter a whole number of hours, at least 1' } + } + if (hours > MAX_CUSTOM_HOURS) { + return { error: 'At most 720 hours (30 days)' } + } + return { ttlSeconds: hours * 3600 } +} + +/** + * The view limit, or an error to show under the field. + * + * @param {string|number} value The entered limit. + * @return {{maxViews: number}|{error: string}} + */ +export function maxViewsFrom(value) { + const views = Number(value) + if (!Number.isInteger(views) || views < 1 || views > MAX_VIEWS_CAP) { + return { error: `Between 1 and ${MAX_VIEWS_CAP} views` } + } + return { maxViews: views } +} + +/** + * The plaintext of a credential send: exactly two lines, as the recipient + * page shows the payload as plain text. + * + * @param {string} username The username. + * @param {string} password The password. + * @return {string} + */ +export function credentialPayload(username, password) { + return `Username: ${username}\nPassword: ${password}` +} + +/** + * The label of a send in the list: its kind and when it was made, since + * sends have no name. + * + * @param {{payloadType: string, createdAt: string}} send The send row. + * @param {string} [locale] The display locale (default: the browser's). + * @return {string} + */ +/** + * When a send expires, in words. + * + * @param {string|null} expiresAt The expiry time. + * @param {number} [now] The clock, in ms. + * @return {string} Like "expires in 3 hours", or '' without a time. + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ +export function expiresIn(expiresAt, now = Date.now()) { + const at = Date.parse(expiresAt || '') + if (Number.isNaN(at)) return '' + const minutes = Math.round((at - now) / 60000) + if (minutes <= 0) return 'expired' + if (minutes < 60) + return `expires in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` + const hours = Math.round(minutes / 60) + if (hours < 48) return `expires in ${hours} ${hours === 1 ? 'hour' : 'hours'}` + return `expires in ${Math.round(hours / 24)} days` +} + +export function sendRowLabel(send, locale) { + const kind = send.payloadType === 'credential' ? 'Credential send' : 'Text send' + const created = new Date(send.createdAt) + if (Number.isNaN(created.getTime())) { + return kind + } + const when = created.toLocaleString(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }) + return `${kind}, ${when}` +} diff --git a/browser-extension/src/lib/server-url.js b/browser-extension/src/lib/server-url.js new file mode 100644 index 000000000..8a7a85677 --- /dev/null +++ b/browser-extension/src/lib/server-url.js @@ -0,0 +1,83 @@ +/** + * The address of a Keepiq server, as the extension stores it: scheme, host, + * port and any Nextcloud subfolder, without a page path, query or fragment. + * Plain http is refused except for a local development host, so an app + * password never travels in clear. + * + * Pure: no DOM, no network. + * + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-server-address-is-https-and-stored-clean + */ + +// Where a pasted Nextcloud page address stops being the server address. +const PAGE_PATH = /\/(index\.php|apps|login|ocs|remote\.php|settings|s)(\/|$)/ + +/** + * Whether a host is a local development host, where http is allowed. + * + * @param {string} hostname The host name. + * @return {boolean} + */ +export function isLocalHost(hostname) { + const host = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return ( + host === 'localhost' + || host === '127.0.0.1' + || host === '::1' + || host.endsWith('.localhost') + || host.endsWith('.test') + || host.endsWith('.local') + ) +} + +/** + * Whether a stored server address may be used: https, or http on a local host. + * + * @param {string} url The stored address. + * @return {boolean} + */ +export function isSecureServerUrl(url) { + let parsed + try { + parsed = new URL(url) + } catch { + return false + } + if (parsed.protocol === 'https:') return true + return parsed.protocol === 'http:' && isLocalHost(parsed.hostname) +} + +/** + * Clean up a server address as typed or pasted. + * + * @param {string} raw What the user entered. + * @return {string} The address to store, without a trailing slash. + * @throws {Error} When it is not a usable https address. + */ +export function normalizeServerUrl(raw) { + let text = String(raw ?? '').trim() + if (text === '') throw new Error('Enter the address of your Nextcloud') + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = 'https://' + text + let parsed + try { + parsed = new URL(text) + } catch { + throw new Error('This is not a valid address') + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('This is not a valid address') + } + if (parsed.username || parsed.password) { + throw new Error('Leave the user name and password out of the address') + } + if (parsed.protocol === 'http:' && !isLocalHost(parsed.hostname)) { + throw new Error( + 'Keepiq needs an https address, so your app password is never sent in clear', + ) + } + const cut = parsed.pathname.search(PAGE_PATH) + const folder = ( + cut === -1 ? parsed.pathname : parsed.pathname.slice(0, cut) + ).replace(/\/+$/, '') + return parsed.origin + folder +} diff --git a/browser-extension/src/lib/usage.js b/browser-extension/src/lib/usage.js new file mode 100644 index 000000000..429528a70 --- /dev/null +++ b/browser-extension/src/lib/usage.js @@ -0,0 +1,26 @@ +/** + * Report a fill to the server as a use of the secret + * (vault-favourites-tags-and-last-used). Only a fill that happened is + * reported, once, and a failed report is swallowed: the sort order is not + * worth breaking a fill for. + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + +/** + * Post the secret id after a successful fill. + * + * @param {{filled?: boolean}|undefined} results What the page answered to the fill. + * @param {string} id The filled secret's id. + * @param {function(string): Promise} post Sends the report. + * @return {Promise} Whether a report was sent and accepted. + */ +export async function reportFill(results, id, post) { + if (!results?.filled || !id) return false + try { + await post(id) + return true + } catch { + return false + } +} diff --git a/browser-extension/src/lib/useOnly.js b/browser-extension/src/lib/useOnly.js new file mode 100644 index 000000000..3643c1b20 --- /dev/null +++ b/browser-extension/src/lib/useOnly.js @@ -0,0 +1,78 @@ +/** + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * The extension's rules for a use-only copy (sharing-use-only-and-expiring- + * shares D3): fill it only on a site whose registrable domain matches the + * copy's URL, with no "fill anyway"; fill only into a real password field; + * never offer to save or update it; report each fill. The popup never shows + * or copies a value, use-only or not. + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-keepiqs-clients-never-reveal-a-use-only-value + */ + +import { hostOf, registrableDomain } from './match.js' + +/** + * Whether a secret row is a use-only copy. + * + * @param {object} row A secret row from the match API. + * @return {boolean} + */ +export function isUseOnly(row) { + return row?.useOnly === true +} + +/** + * Whether a use-only row may be filled on a host: its URL's registrable + * domain must equal the host's. A row without a URL never matches. A row + * that is not use-only is always allowed (the normal rules apply). + * + * @param {object} row A secret row. + * @param {string} host The page host. + * @return {boolean} + */ +export function allowedOnHost(row, host) { + if (!isUseOnly(row)) { + return true + } + const own = registrableDomain(hostOf(row?.url ?? '')) + return own !== '' && own === registrableDomain(host) +} + +/** + * Drop the use-only rows a host may not fill. + * + * @param {Array} rows Ranked candidate rows. + * @param {string} host The page host. + * @return {Array} + */ +export function filterForHost(rows, host) { + return (rows ?? []).filter((row) => allowedOnHost(row, host)) +} + +/** + * Whether a submitted login on this host belongs to a use-only copy, so no + * save or update may be offered for it. + * + * @param {Array} rows The candidate rows for the host. + * @param {string} host The page host. + * @return {boolean} + */ +export function blocksSavePrompt(rows, host) { + return (rows ?? []).some((row) => isUseOnly(row) && allowedOnHost(row, host)) +} + +/** + * The field a use-only value may go into: only an input whose type is + * `password`, so the value never lands in a visible text field. + * + * @param {HTMLInputElement|null} field The detected password field. + * @return {HTMLInputElement|null} + */ +export function useOnlyPasswordTarget(field) { + if (!field || String(field.type || '').toLowerCase() !== 'password') { + return null + } + return field +} diff --git a/browser-extension/src/lib/vault-index.js b/browser-extension/src/lib/vault-index.js new file mode 100644 index 000000000..9752bcefd --- /dev/null +++ b/browser-extension/src/lib/vault-index.js @@ -0,0 +1,138 @@ +/** + * The vault index the popup browses: names, addresses, types and folders, + * never a decrypted value. Pure: no DOM, no network. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + */ + +/** + * One list entry from a server row, without its blobs. + * + * @param {object} row A secret as `GET /api/v1/secrets` returns it. + * @param {Map} typeNames Type id to type name. + * @param {Map} folderNames Folder id to folder name. + * @return {object} + */ +export function toIndexEntry(row, typeNames, folderNames) { + return { + id: row.id, + name: row.name || '', + url: row.url || '', + typeName: typeNames.get(row.typeId) || 'login', + folderId: row.folderId || null, + folderName: row.folderId ? folderNames.get(row.folderId) || '' : '', + blocked: row.blocked === true, + favourite: row.favourite === true, + } +} + +/** + * Build the index from the server's rows, leaving out trashed and archived + * secrets, sorted alphabetically by name. + * + * @param {Array} rows The secrets. + * @param {Array} types The secret types ({id, name}). + * @param {Array} folders The folders ({id, name}). + * @return {Array} + */ +export function buildIndex(rows, types, folders) { + const typeNames = new Map(types.map((t) => [t.id, t.name || t.slug])) + const folderNames = new Map(folders.map((f) => [f.id, f.name])) + return rows + .filter((r) => !r.trashedAt && !r.archivedAt) + .map((r) => toIndexEntry(r, typeNames, folderNames)) + .sort(byName) +} + +/** + * Alphabetical, case- and accent-insensitive. + * + * @param {{name: string}} a First entry. + * @param {{name: string}} b Second entry. + * @return {number} + */ +export function byName(a, b) { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + // Same name: a fixed order, so the list does not shuffle. + || String(a.id).localeCompare(String(b.id)) + ) +} + +/** The folder filter value for items in no folder. */ +export const NO_FOLDER = '__none__' + +/** + * What the list should say: loading, an empty vault, nothing matching, every + * item blocked, or the items. + * + * @param {Array|null} index The whole index, or null while loading. + * @param {Array} shown The entries after filtering. + * @return {'loading'|'empty'|'no-match'|'all-blocked'|'items'} + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-a-list-that-says-what-it-shows + */ +export function listState(index, shown) { + if (index === null) return 'loading' + if (index.length === 0) return 'empty' + if (shown.length === 0) return 'no-match' + if (index.every((e) => e.blocked)) return 'all-blocked' + return 'items' +} + +/** + * The entries that match the search, folder and type filters. + * + * @param {Array} entries The index. + * @param {object} [filters] The filters. + * @param {string} [filters.query] Matched against name and address, case-insensitive. + * @param {string|null} [filters.folderId] Only this folder ('' or null: all). + * @param {string|null} [filters.typeName] Only this type ('' or null: all). + * @return {Array} + */ +export function filterIndex( + entries, + { query = '', folderId = null, typeName = null } = {}, +) { + const needle = query.trim().toLowerCase() + return entries.filter( + (e) => + (!folderId + || (folderId === NO_FOLDER ? !e.folderId : e.folderId === folderId)) + && (!typeName || e.typeName === typeName) + && (needle === '' + || e.name.toLowerCase().includes(needle) + || e.url.toLowerCase().includes(needle)), + ) +} + +/** + * The type names present in the index, for the filter chips. + * + * @param {Array} entries The index. + * @return {string[]} + */ +export function presentTypes(entries) { + return [...new Set(entries.map((e) => e.typeName))].sort() +} + +/** + * Folder choices as indented paths ("Work / Clients"), sorted by path. + * + * @param {Array<{id: string, name: string, parentId?: string|null}>} folders The folders. + * @return {Array<{id: string, label: string}>} + */ +export function folderChoices(folders) { + const byId = new Map(folders.map((f) => [f.id, f])) + const pathOf = (folder, seen = new Set()) => { + if (!folder.parentId || seen.has(folder.id) || !byId.has(folder.parentId)) { + return folder.name + } + seen.add(folder.id) + return `${pathOf(byId.get(folder.parentId), seen)} / ${folder.name}` + } + return folders + .map((f) => ({ id: f.id, label: pathOf(f) })) + .sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }), + ) +} diff --git a/browser-extension/src/lib/vault.js b/browser-extension/src/lib/vault.js index 7a1fa58b6..f10006b56 100644 --- a/browser-extension/src/lib/vault.js +++ b/browser-extension/src/lib/vault.js @@ -6,16 +6,20 @@ * fetch active suite → decryptPrivateKey(envelope, masterPassword) * → importPrivateKey (NON-EXTRACTABLE) → hold the CryptoKey in memory. * - * The master password, the derived AES key, and the RSA CryptoKey NEVER touch - * `storage.*` and never leave the worker. Field values are RSA-OAEP decrypted on - * demand; new/updated secrets are encrypted to the suite certificate. + * or, for a passkey unlock, the raw unlock key unwrapped in the unlock window + * → decryptPrivateKeyWithRawKey(envelope, rawKey) → the same import. * - * This module holds module-scoped state (the service worker is a singleton). All - * of it is cleared on lock. + * State is kept PER ACCOUNT (extension-account-switching): each paired account + * has its own key, suite and idle timer, and locks on its own. An OS lock or a + * worker restart clears every account. + * + * The master password, the raw unlock key, the derived AES key, and the RSA + * CryptoKey NEVER touch `storage.*` and never leave the worker. */ import { decryptPrivateKey, + decryptPrivateKeyWithRawKey, importPrivateKey, importPublicKey, rsaDecrypt, @@ -23,57 +27,166 @@ import { } from '../crypto/index.js' import { fetchActiveSuite } from './api.js' -// Locked state: no key material present. -let cryptoKey = null // non-extractable RSA private key -let publicKey = null // suite certificate public key (for encrypting saves) -let suiteId = null -let idleTimer = null +// accountId → { cryptoKey, publicKey, suiteId, idleTimer }. An account that is +// not in the map is locked: no key material is present for it. +const accounts = new Map() + +/** + * Whether an account is unlocked (a CryptoKey is held for it). + * + * @param {string} accountId The account id. + * @return {boolean} + */ +export function isUnlocked(accountId) { + return !!accountId && accounts.has(accountId) +} + +/** + * The ids of every unlocked account. + * + * @return {string[]} + */ +export function unlockedAccounts() { + return [...accounts.keys()] +} + +/** + * The suite an account's saves are encrypted to. + * + * @param {string} accountId The account id. + * @return {string|null} + */ +export function activeSuiteId(accountId) { + return accounts.get(accountId)?.suiteId ?? null +} -/** Whether the vault is unlocked (a CryptoKey is held). */ -export function isUnlocked() { - return cryptoKey !== null +/** + * The unlock-key epoch of the suite an account was unlocked with, or null. + * + * @param {string} accountId The account id. + * @return {number|null} + * @spec openspec/specs/extension-lock/spec.md#requirement-a-changed-master-password-locks-the-extension + */ +export function activeSuiteEpoch(accountId) { + return accounts.get(accountId)?.suiteEpoch ?? null } -export function activeSuiteId() { - return suiteId +async function hold(accountId, suite, pem) { + lock(accountId) + accounts.set(accountId, { + cryptoKey: await importPrivateKey(pem), // extractable: false + publicKey: await importPublicKey(suite.certificate), + suiteId: suite.id, + // Rises when the master password changes (the key is re-wrapped). + suiteEpoch: Number.isInteger(suite.unlockKeyEpoch) + ? suite.unlockKeyEpoch + : null, + idleTimer: null, + }) } /** - * Unlock: fetch the active suite, decrypt its private key with the master - * password, and import a non-extractable CryptoKey. Returns nothing sensitive. + * Unlock one account: fetch its active suite, decrypt the private key with the + * master password, and import a non-extractable CryptoKey. Returns nothing + * sensitive. * - * @param {object} config The paired API config + * @param {string} accountId The account id + * @param {object} config The account's API config * @param {string} masterPassword The master password (used only here) * @return {Promise} + * @param {{suite?: object}} [options] A suite to use instead of fetching one. + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-unlock-offline-and-say-what-went-wrong + */ +export async function unlock(accountId, config, masterPassword, options = {}) { + // A suite from the vault snapshot unlocks while the server is away. + const suite = options.suite || (await fetchActiveSuite(config)) + let pem + try { + pem = await decryptPrivateKey(suite.privateKey, masterPassword) + } catch (e) { + // AES-GCM refuses a key derived from the wrong password. + if (e?.name === 'OperationError') { + throw new Error('Invalid master password') + } + throw e + } + await hold(accountId, suite, pem) +} + +/** + * Unlock one account with the raw unlock key a passkey unwrapped + * (extension-biometric-unlock). The raw key is used here and dropped. + * + * @param {string} accountId The account id + * @param {object} config The account's API config + * @param {Uint8Array} rawKey The raw 32-byte unlock key + * @return {Promise} + * @param {{suite?: object}} [options] A suite to use instead of fetching one. + * @spec openspec/specs/extension-pin-unlock/spec.md#requirement-unlock-with-a-pin-until-the-browser-closes + */ +export async function unlockWithRawKey(accountId, config, rawKey, options = {}) { + // A suite from the vault snapshot unlocks while the server is away. + const suite = options.suite || (await fetchActiveSuite(config)) + const pem = await decryptPrivateKeyWithRawKey(suite.privateKey, rawKey) + await hold(accountId, suite, pem) +} + +/** + * Lock one account: clear its key material and timer. + * + * @param {string} accountId The account id. + * @return {void} + */ +// Called with an account id whenever that account locks, for whatever +// reason (button, idle timer, OS lock, disconnect). +const lockListeners = new Set() + +/** + * Be told when an account locks. + * + * @param {(accountId: string) => void} listener Called with the account id. + * @return {() => void} Stops the listener. */ -export async function unlock(config, masterPassword) { - const suite = await fetchActiveSuite(config) - const pem = await decryptPrivateKey(suite.privateKey, masterPassword) - cryptoKey = await importPrivateKey(pem) // extractable: false - publicKey = await importPublicKey(suite.certificate) - suiteId = suite.id -} - -/** Lock: clear ALL key material and derived state. */ -export function lock() { - cryptoKey = null - publicKey = null - suiteId = null - if (idleTimer) { - clearTimeout(idleTimer) - idleTimer = null +export function onLock(listener) { + lockListeners.add(listener) + return () => lockListeners.delete(listener) +} + +export function lock(accountId) { + const state = accounts.get(accountId) + if (!state) return + if (state.idleTimer) clearTimeout(state.idleTimer) + accounts.delete(accountId) + for (const listener of lockListeners) { + try { + listener(accountId) + } catch { + // A listener never stops the lock. + } } } +/** Lock every account (OS lock, manual lock-all, worker restart). */ +export function lockAll() { + for (const id of [...accounts.keys()]) lock(id) +} + +function keyOf(accountId) { + const state = accounts.get(accountId) + if (!state) throw new Error('vault is locked') + return state +} + /** - * Decrypt one ciphertext field (RSA-OAEP chunked) with the in-memory key. - * Throws if locked. + * Decrypt one ciphertext field (RSA-OAEP chunked) with an account's key. + * Throws if that account is locked. * + * @param {string} accountId The account id * @param {string} ciphertext base64 chunked ciphertext (or '' → '') * @return {Promise} */ -export async function decryptField(ciphertext) { - if (!cryptoKey) throw new Error('vault is locked') +export async function decryptField(accountId, ciphertext) { + const { cryptoKey } = keyOf(accountId) if (!ciphertext) return '' return rsaDecrypt(ciphertext, cryptoKey) } @@ -81,38 +194,62 @@ export async function decryptField(ciphertext) { /** * Decrypt the autofill-relevant fields of a secret row. * + * @param {string} accountId The account id * @param {object} secret A blob row ({ key, login, ... }) * @return {Promise<{ login: string, secret: string }>} */ -export async function decryptSecret(secret) { +export async function decryptSecret(accountId, secret) { const [login, value] = await Promise.all([ - decryptField(secret.login || ''), - decryptField(secret.key || ''), + decryptField(accountId, secret.login || ''), + decryptField(accountId, secret.key || ''), ]) return { login, secret: value } } /** - * Encrypt a plaintext value to the suite certificate (for save/update capture). - * Throws if locked. + * Encrypt a plaintext value to an account's suite certificate. + * Throws if that account is locked. * + * @param {string} accountId The account id * @param {string} plaintext * @return {Promise} base64 chunked ciphertext */ -export async function encryptField(plaintext) { - if (!publicKey) throw new Error('vault is locked') +export async function encryptField(accountId, plaintext) { + const { publicKey } = keyOf(accountId) if (!plaintext) return '' return rsaEncrypt(plaintext, publicKey) } /** - * (Re)arm the idle auto-lock timer. Any activity resets it; expiry locks. + * (Re)arm one account's idle auto-lock timer. Any activity resets it; expiry + * locks that account only. * + * @param {string} accountId The account id * @param {number} idleMs Idle timeout in ms * @return {void} */ -export function armIdleLock(idleMs) { - if (idleTimer) clearTimeout(idleTimer) +export function armIdleLock(accountId, idleMs) { + const state = accounts.get(accountId) + if (!state) return + if (state.idleTimer) clearTimeout(state.idleTimer) + state.idleTimer = null if (!idleMs || idleMs <= 0) return - idleTimer = setTimeout(() => lock(), idleMs) + state.idleTimer = setTimeout(() => lock(accountId), idleMs) +} + +/** + * A view of one account with the single-account interface the passkey + * orchestrator was written against (isUnlocked, decryptField, encryptField, + * activeSuiteId), bound to whichever account `idOf` names at call time. + * + * @param {function(): Promise} idOf Resolves the account id. + * @return {object} The bound vault. + */ +export function boundTo(idOf) { + return { + isUnlocked: async () => isUnlocked(await idOf()), + activeSuiteId: async () => activeSuiteId(await idOf()), + decryptField: async (ciphertext) => decryptField(await idOf(), ciphertext), + encryptField: async (plaintext) => encryptField(await idOf(), plaintext), + } } diff --git a/browser-extension/src/lib/version.js b/browser-extension/src/lib/version.js new file mode 100644 index 000000000..fdb92abbd --- /dev/null +++ b/browser-extension/src/lib/version.js @@ -0,0 +1,39 @@ +/** + * The version handshake with the Keepiq server (extension-store-release D5). + * A store update can reach users before their organisation updates the + * server; the extension then says so instead of failing on a missing route. + * + * @spec openspec/specs/extension-store-release/spec.md#requirement-the-extension-checks-the-server-version-on-pairing + */ + +/** + * The oldest Keepiq server this extension works with: the first one that + * reports its version on pairing and serves the extension policy route. + */ +export const MIN_SERVER_VERSION = '0.3.4' + +function parts(version) { + const core = String(version || '').split(/[-+]/)[0] + if (!/^\d+(\.\d+)*$/.test(core)) return null + return core.split('.').map(Number) +} + +/** + * Whether a server version is at least the minimum. A missing or unreadable + * version (an older server that does not report one) is not. + * + * @param {string|null|undefined} version The server's app version, as paired. + * @param {string} minimum The minimum (defaults to MIN_SERVER_VERSION). + * @return {boolean} + */ +export function isServerSupported(version, minimum = MIN_SERVER_VERSION) { + const have = parts(version) + const need = parts(minimum) + if (!have || !need) return false + for (let i = 0; i < Math.max(have.length, need.length); i++) { + const a = have[i] ?? 0 + const b = need[i] ?? 0 + if (a !== b) return a > b + } + return true +} diff --git a/browser-extension/src/offscreen/offscreen.html b/browser-extension/src/offscreen/offscreen.html new file mode 100644 index 000000000..67523688c --- /dev/null +++ b/browser-extension/src/offscreen/offscreen.html @@ -0,0 +1,10 @@ + + + + + Keepiq + + + + + diff --git a/browser-extension/src/offscreen/offscreen.js b/browser-extension/src/offscreen/offscreen.js new file mode 100644 index 000000000..61be6be91 --- /dev/null +++ b/browser-extension/src/offscreen/offscreen.js @@ -0,0 +1,15 @@ +/** + * The offscreen document that clears the clipboard for the worker in + * Chromium, where a service worker has no clipboard (clients-extension-gaps). + * + * @spec openspec/specs/extension-clipboard/spec.md#requirement-every-copy-is-cleared-after-a-delay-the-user-sets + */ +import { clearWithDocument } from '../background/clipboard-clear.js' + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg?.type !== 'offscreen-clear-clipboard') return false + // Only the extension itself, never a page's content script. + if (sender?.id !== chrome.runtime.id || sender?.tab) return false + sendResponse({ cleared: clearWithDocument(document) }) + return false +}) diff --git a/browser-extension/src/passkey/orchestrator.js b/browser-extension/src/passkey/orchestrator.js index a67554f44..55d18abd6 100644 --- a/browser-extension/src/passkey/orchestrator.js +++ b/browser-extension/src/passkey/orchestrator.js @@ -11,6 +11,7 @@ */ import { createCredential, getAssertion } from './webauthn.js' +import { rpIdAllowed } from './rp.js' import { serializePasskey, parsePasskey } from './vault-passkey.js' /** @@ -72,8 +73,10 @@ function requestConsent(rpId, op) { */ export function buildPasskeyOrchestrator({ api, vault, loadConfig }) { async function handleCreate(options, origin) { - if (!vault.isUnlocked()) throw new Error('locked') - const rpId = (options.rp && options.rp.id) || new URL(origin).hostname + if (!(await vault.isUnlocked())) throw new Error('locked') + const rpId = (options.rp && options.rp.id) || hostnameOf(origin) + // The rpId must belong to the requesting origin (clients-passkey-origin). + if (!rpIdAllowed(rpId, origin)) throw new Error('rp-origin-mismatch') if (!(await requestConsent(rpId, 'create'))) throw new Error('declined') const { record, credential } = await createCredential(options, origin) // throws unsupported-algorithm → fall-through @@ -85,14 +88,16 @@ export function buildPasskeyOrchestrator({ api, vault, loadConfig }) { url: rpId, typeId, key: encryptedKey, - encryptionSuiteId: vault.activeSuiteId(), + encryptionSuiteId: await vault.activeSuiteId(), }) return credential } async function handleGet(options, origin) { - if (!vault.isUnlocked()) throw new Error('locked') - const rpId = options.rpId || new URL(origin).hostname + if (!(await vault.isUnlocked())) throw new Error('locked') + const rpId = options.rpId || hostnameOf(origin) + // Checked before any vault read: a foreign rpId learns nothing. + if (!rpIdAllowed(rpId, origin)) throw new Error('rp-origin-mismatch') const config = await loadConfig() // Candidate passkeys for this RP (matched on the plaintext url index). @@ -133,7 +138,7 @@ export function buildPasskeyOrchestrator({ api, vault, loadConfig }) { url: chosen.row.url, typeId: chosen.row.typeId, key: encryptedKey, - encryptionSuiteId: vault.activeSuiteId(), + encryptionSuiteId: await vault.activeSuiteId(), }) } return assertion @@ -142,6 +147,20 @@ export function buildPasskeyOrchestrator({ api, vault, loadConfig }) { return { handleCreate, handleGet } } +/** + * The hostname of an origin, or '' when it does not parse. + * + * @param {string} origin The origin. + * @return {string} The hostname. + */ +function hostnameOf(origin) { + try { + return new URL(origin).hostname + } catch { + return '' + } +} + // allowCredentials ids arrive as base64url strings or byte arrays; normalise to // the base64url form the stored record uses. function encodeAllowId(id) { diff --git a/browser-extension/src/passkey/registration.js b/browser-extension/src/passkey/registration.js deleted file mode 100644 index 13971f0f3..000000000 --- a/browser-extension/src/passkey/registration.js +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Browser-adaptive WebAuthn provider registration (extension-passkey-provider - * §1). Where the browser exposes the native proxy API (`chrome.webAuthentication - * Proxy`, Chrome/Edge) we register through it; otherwise the page-context shim - * (`inpage-shim.js`) + content-script relay drives the same orchestrator - * (Firefox and others). This module wires ONLY the native proxy; it is a no-op - * when the API is absent. - * - * On any orchestrator error (declined / no-credential / unsupported / locked) - * the request is completed with a DOM error so the browser falls through to the - * platform authenticator rather than hanging the page ceremony. - */ - -function fallThroughError(message) { - // NotAllowedError makes the RP fall back to another authenticator. - return { name: 'NotAllowedError', message: 'Keepiq: ' + message } -} - -/** - * @param {{ handleCreate: Function, handleGet: Function }} orchestrator - * @return {void} - */ -export function registerWebAuthnProxy(orchestrator) { - const proxy = - typeof chrome !== 'undefined' ? chrome.webAuthenticationProxy : undefined - if (!proxy || !proxy.onCreateRequest) { - return // no native proxy — the page-context shim path handles it - } - - try { - proxy.attach(() => {}) - } catch { - // attach may reject if another provider is active; the shim still works. - } - - proxy.onCreateRequest.addListener(async (details) => { - try { - const options = - JSON.parse(details.requestDetailsJson).publicKey - || JSON.parse(details.requestDetailsJson) - const credential = await orchestrator.handleCreate( - options, - originOf(details), - ) - proxy.completeCreateRequest({ - requestId: details.requestId, - responseJson: JSON.stringify(credential), - }) - } catch (e) { - proxy.completeCreateRequest({ - requestId: details.requestId, - error: fallThroughError(e.message || String(e)), - }) - } - }) - - proxy.onGetRequest.addListener(async (details) => { - try { - const options = - JSON.parse(details.requestDetailsJson).publicKey - || JSON.parse(details.requestDetailsJson) - const assertion = await orchestrator.handleGet( - options, - originOf(details), - ) - proxy.completeGetRequest({ - requestId: details.requestId, - responseJson: JSON.stringify(assertion), - }) - } catch (e) { - proxy.completeGetRequest({ - requestId: details.requestId, - error: fallThroughError(e.message || String(e)), - }) - } - }) -} - -function originOf(details) { - // The proxy provides the requesting frame's origin on newer builds; fall back - // to the rp id embedded in the request when absent. - if (details.origin) return details.origin - try { - const rp = JSON.parse(details.requestDetailsJson) - const rpId = - rp.publicKey?.rp?.id || rp.publicKey?.rpId || rp.rp?.id || rp.rpId - return rpId ? 'https://' + rpId : '' - } catch { - return '' - } -} diff --git a/browser-extension/src/passkey/rp.js b/browser-extension/src/passkey/rp.js new file mode 100644 index 000000000..7de58b137 --- /dev/null +++ b/browser-extension/src/passkey/rp.js @@ -0,0 +1,60 @@ +/** + * The relying-party check for passkey requests (clients-passkey-origin). + * + * In the page-context shim path the extension IS the authenticator, so the + * WebAuthn origin rule the browser would apply is ours to apply: the rpId must + * equal the requesting host or be a parent domain of it that is not a public + * suffix, and the origin must be secure. The origin itself comes from the + * browser (the message sender), never from the page. + * + * @spec openspec/specs/clients-passkey-origin/spec.md + */ + +import { isPublicSuffix } from '../lib/match.js' + +/** + * Whether a page at `origin` may use a passkey for `rpId`. + * + * @param {string} rpId The relying party id the request names. + * @param {string} origin The requesting frame's origin, as the browser reports it. + * @return {boolean} True when the request may proceed. + */ +export function rpIdAllowed(rpId, origin) { + let url + try { + url = new URL(origin) + } catch { + return false + } + const host = url.hostname.toLowerCase() + const secure = url.protocol === 'https:' || host === 'localhost' + if (!secure) { + return false + } + const rp = String(rpId || '').toLowerCase() + if (rp === '') { + return false + } + if (rp === host) { + return true + } + return host.endsWith('.' + rp) && !isPublicSuffix(rp) +} + +/** + * The origin of the frame that sent a runtime message, from the browser's own + * sender record. Chromium sets `sender.origin`; Firefox sets `sender.url`. + * + * @param {object|undefined} sender The runtime.MessageSender. + * @return {string} The origin, or '' when the sender has none. + */ +export function senderOrigin(sender) { + if (sender && typeof sender.origin === 'string' && sender.origin !== 'null') { + return sender.origin + } + try { + return new URL(sender?.url || '').origin + } catch { + return '' + } +} diff --git a/browser-extension/src/popup/clipboard.js b/browser-extension/src/popup/clipboard.js new file mode 100644 index 000000000..cb01b40b7 --- /dev/null +++ b/browser-extension/src/popup/clipboard.js @@ -0,0 +1,32 @@ +/** + * Copying from the popup (clients-extension-gaps): write the text, then tell + * the worker, which clears the clipboard after the user's delay even when + * the popup has closed by then. + * + * @spec openspec/specs/extension-clipboard/spec.md#requirement-every-copy-is-cleared-after-a-delay-the-user-sets + */ + +/** + * Copy text and have the worker clear it later. Quietly does nothing where + * the clipboard is unavailable: the value stays visible instead. + * + * @param {string} text The text. + * @return {Promise} Whether the text was copied. + */ +export async function copyText(text) { + try { + await navigator.clipboard.writeText(text) + } catch { + // No clipboard (no focus, or not allowed). + return false + } + try { + chrome.runtime.sendMessage( + { type: 'clipboard-copied', payload: {} }, + () => {}, + ) + } catch { + // The worker clears nothing; the copy itself stands. + } + return true +} diff --git a/browser-extension/src/popup/folder-view.js b/browser-extension/src/popup/folder-view.js new file mode 100644 index 000000000..2316e1d8d --- /dev/null +++ b/browser-extension/src/popup/folder-view.js @@ -0,0 +1,237 @@ +/** + * The folder manager in the Vault tab: add, rename and delete folders. A + * folder that holds items or subfolders is deleted only with the user's + * choice for what it holds, as the server's deletion protocol requires. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-manage-folders + */ + +import { + deleteKind, + deleteRequest, + folderNameProblem, + folderTree, +} from '../lib/folder-rules.js' +import { folderChoices } from '../lib/vault-index.js' + +const NOTICE_KEY = 'folders-notice-seen' + +/** + * Wire the folder manager. + * + * @param {object} ctx The popup context. + * @param {(id: string) => HTMLElement} ctx.$ Element by id. + * @param {(type: string, payload?: object) => Promise} ctx.send Message the worker. + * @param {(id: string, message: string) => void} ctx.showError Show or clear an error. + * @param {() => Array} ctx.getFolders The current folders. + * @param {() => Promise} ctx.reload Reload the vault after a change. + * @param {Document} [ctx.doc] The popup document. + * @return {{render: () => Promise}} + */ +export function initFolders({ + $, + send, + showError, + getFolders, + reload, + doc = document, +}) { + // The folder being deleted and what the server said it holds. + let pending = null + + /** Fill the parent picker of the add form. */ + function fillParents() { + const select = $('folder-add-parent') + select.replaceChildren(select.options[0]) + for (const { id, label } of folderChoices(getFolders())) { + const option = doc.createElement('option') + option.value = id + option.textContent = label + select.appendChild(option) + } + } + + /** + * A button. + * + * @param {string} text The text. + * @param {string} label The accessible name. + * @param {Function} onClick The action. + * @return {HTMLButtonElement} + */ + function button(text, label, onClick) { + const el = doc.createElement('button') + el.type = 'button' + el.className = 'link' + el.textContent = text + el.setAttribute('aria-label', label) + el.addEventListener('click', onClick) + return el + } + + /** + * Turn a row into a rename field. + * + * @param {HTMLElement} li The row. + * @param {{id: string, name: string}} folder The folder. + */ + function startRename(li, folder) { + const input = doc.createElement('input') + input.value = folder.name + input.maxLength = 255 + input.setAttribute('aria-label', `New name for ${folder.name}`) + const save = button('Save', `Save the name of ${folder.name}`, async () => { + showError('folders-error', '') + const res = await send('folder-rename', { + id: folder.id, + name: input.value, + }) + // On an error the typed name stays. + if (res.error) return showError('folders-error', res.error) + await reload() + await render() + }) + const cancel = button('Cancel', 'Cancel renaming', () => render()) + li.replaceChildren(input, save, cancel) + input.focus() + } + + /** + * Ask what the folder holds and show the matching delete dialog. + * + * @param {{id: string, name: string, parentId: string|null}} folder The folder. + */ + async function startDelete(folder) { + showError('folders-error', '') + const children = await send('folder-children', { id: folder.id }) + if (children.error) return showError('folders-error', children.error) + const kind = deleteKind(children) + const parent = getFolders().find((f) => f.id === folder.parentId) + const parentName = parent ? parent.name : 'No folder' + pending = { folder, kind, children } + $('folder-delete-items').options[0].textContent = `Move to ${parentName}` + $('folder-delete-items').value = 'move' + $('folder-delete-items-label').hidden = + kind === 'empty' + || (kind === 'subfolders' && children.directSecretCount === 0) + const list = $('folder-delete-subfolders') + list.replaceChildren() + if (kind === 'empty') { + $('folder-delete-text').textContent = + `Delete ${folder.name}? This cannot be undone.` + } else if (kind === 'items') { + $('folder-delete-text').textContent = + `${folder.name} holds ${children.directSecretCount} items. Move them to ${parentName}, or delete them too?` + } else { + $('folder-delete-text').textContent = + `${folder.name} holds subfolders. Choose what happens to each.` + for (const sub of children.subfolders) { + const label = doc.createElement('label') + label.textContent = `${sub.name} (${sub.secretCount} items, ${sub.subfolderCount} subfolders)` + const select = doc.createElement('select') + select.dataset.subfolder = sub.id + for (const [value, text] of [ + ['keep', `Keep it, inside ${parentName}`], + ['move', `Move its items to ${parentName}`], + ['delete', 'Delete it and its items'], + ]) { + const option = doc.createElement('option') + option.value = value + option.textContent = text + select.appendChild(option) + } + label.appendChild(select) + list.appendChild(label) + } + } + $('folder-delete').hidden = false + } + + /** Render the notice, the add form and the tree. */ + async function render() { + $('folder-delete').hidden = true + pending = null + let seen = false + try { + seen = (await chrome.storage.local.get(NOTICE_KEY))[NOTICE_KEY] === true + } catch { + seen = false + } + $('folders-notice').hidden = seen + fillParents() + // Folder changes need the server: off while it cannot be reached. + const offline = $('vault-offline').hidden === false + $('folder-add-save').disabled = offline + showError( + 'folders-error', + offline ? 'You are offline. Changes need a connection to Keepiq.' : '', + ) + const tree = $('folder-tree') + tree.replaceChildren() + for (const folder of folderTree(getFolders())) { + const li = doc.createElement('li') + li.className = 'candidate row' + li.style.paddingLeft = `${folder.depth * 16}px` + const name = doc.createElement('span') + name.className = 'folder-name' + name.textContent = folder.name + li.append( + name, + button('Rename', `Rename ${folder.name}`, () => + startRename(li, folder), + ), + button('Delete', `Delete ${folder.name}`, () => startDelete(folder)), + ) + for (const action of li.querySelectorAll('button')) { + action.disabled = offline + } + tree.appendChild(li) + } + } + + $('folders-notice-ok').addEventListener('click', async () => { + await chrome.storage.local.set({ [NOTICE_KEY]: true }).catch(() => {}) + $('folders-notice').hidden = true + }) + $('folder-add').addEventListener('submit', async (event) => { + event.preventDefault() + showError('folders-error', '') + const problem = folderNameProblem($('folder-add-name').value) + if (problem) return showError('folders-error', problem) + const res = await send('folder-create', { + name: $('folder-add-name').value, + parentId: $('folder-add-parent').value || null, + }) + if (res.error) return showError('folders-error', res.error) + $('folder-add-name').value = '' + await reload() + await render() + }) + $('folder-delete-cancel').addEventListener('click', () => { + $('folder-delete').hidden = true + pending = null + }) + $('folder-delete-confirm').addEventListener('click', async () => { + if (!pending) return + const subfolders = {} + for (const select of $('folder-delete-subfolders').querySelectorAll( + 'select', + )) { + subfolders[select.dataset.subfolder] = select.value + } + const request = deleteRequest( + pending.kind, + { items: $('folder-delete-items').value, subfolders }, + pending.children.subfolders, + ) + const res = await send('folder-delete', { + id: pending.folder.id, + ...request, + }) + if (res.error) return showError('folders-error', res.error) + await reload() + await render() + }) + + return { render } +} diff --git a/browser-extension/src/popup/generator-view.js b/browser-extension/src/popup/generator-view.js new file mode 100644 index 000000000..494688cc5 --- /dev/null +++ b/browser-extension/src/popup/generator-view.js @@ -0,0 +1,365 @@ +/** + * The popup's Generator tab: Password, Passphrase and Username sub-tabs, + * colour-coded output, history, options kept per account, and a pick mode + * that hands a value back to the item form. Values are made here, in the + * popup, with the web app's generator; nothing needs the vault key, so the + * tab also works while locked. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-generator-sub-tabs + */ + +import { + generateKey, + generatePassphrase, + generatorPolicy, + passphraseAllowed, +} from '../../../src/generator/generator.js' +import { generateUsername } from '../../../src/generator/username.js' +import { relativeTime, sanitizeOptions } from '../lib/generator-state.js' +import { copyText } from './clipboard.js' + +/** + * Generate a value for a sub-tab, or return why it cannot. + * + * @param {string} kind password, passphrase or username. + * @param {object} options The sanitised options. + * @param {object|null} policy The org policy, raw. + * @param {string} website The active site's host name, or ''. + * @return {{value: string}|{error: string}} + * @spec openspec/specs/extension-generator/spec.md#requirement-generator-sub-tabs + */ +export function generateFor(kind, options, policy, website) { + try { + if (kind === 'passphrase' && passphraseAllowed(policy)) { + return { value: generatePassphrase(options.passphrase, policy) } + } + if (kind === 'username') { + const u = options.username + return { + value: generateUsername({ + ...u, + mode: u.type === 'plus' ? u.emailMode : u.domainMode, + website, + }), + } + } + return { value: generateKey(options.password, policy) } + } catch (e) { + return { error: e.message } + } +} + +/** + * Render a value with digits and special characters in their own colours. + * + * @param {HTMLElement} target The element to fill. + * @param {string} value The value. + * @param {Document} doc The popup document. + */ +export function renderColoured(target, value, doc) { + target.replaceChildren() + for (const char of value) { + const span = doc.createElement('span') + span.textContent = char + if (/[0-9]/.test(char)) span.className = 'gen-digit' + else if (/[^A-Za-z0-9]/.test(char)) span.className = 'gen-special' + target.appendChild(span) + } + target.dataset.value = value +} + +/** + * The line telling the user the policy shapes the result, or ''. + * + * @param {object|null} policy The org policy, raw. + * @return {string} + */ +export function policyHint(policy) { + const normalised = generatorPolicy(policy) + return normalised === null + ? '' + : `Set by your organisation: at least ${normalised.minLength} characters.` +} + +/** + * Wire the Generator tab. + * + * @param {object} ctx The popup context. + * @param {(id: string) => HTMLElement} ctx.$ Element by id. + * @param {(type: string, payload?: object) => Promise} ctx.send Message the worker. + * @param {(id: string, message: string) => void} ctx.showError Show or clear an error. + * @param {Document} [ctx.doc] The popup document. + * @return {{open: (pick?: {kind: string, onPick: (value: string) => void}) => Promise, current: () => string}} + */ +export function initGenerator({ $, send, showError, doc = document }) { + let policy = null + let options = sanitizeOptions(null) + let website = '' + let pick = null + let saveTimer = null + + /** Read the form into the options. */ + function readForm() { + options = sanitizeOptions( + { + tab: options.tab, + password: { + length: $('gen-length').value, + includeUppercase: $('gen-upper').checked, + includeLowercase: $('gen-lower').checked, + includeDigits: $('gen-digits').checked, + includeSpecialCharacters: $('gen-symbols').checked, + minDigits: $('gen-min-digits').value, + minSpecial: $('gen-min-special').value, + avoidAmbiguous: $('gen-ambiguous').checked, + }, + passphrase: { + words: $('gen-words').value, + separator: $('gen-separator').value, + capitalise: $('gen-capitalise').checked, + includeNumber: $('gen-number').checked, + }, + username: { + type: $('gen-username-type').value, + capitalize: $('gen-username-capitalize').checked, + includeNumber: $('gen-username-number').checked, + email: $('gen-email').value, + emailMode: doc.querySelector( + 'input[name="gen-email-mode"]:checked', + )?.value, + domain: $('gen-domain').value, + domainMode: doc.querySelector( + 'input[name="gen-domain-mode"]:checked', + )?.value, + }, + }, + policy, + ) + } + + /** + * Lock the controls the organisation's policy decides: a required kind + * stays on, its minimum starts at one, and the length cannot go below + * the floor. Each locked control says why. + * + * @spec openspec/specs/extension-generator-policy/spec.md#requirement-controls-the-policy-decides-are-shown-as-such + */ + function lockToPolicy() { + const rule = generatorPolicy(policy) + const required = { + 'gen-upper': rule?.requireUpper, + 'gen-lower': rule?.requireLower, + 'gen-digits': rule?.requireDigit, + 'gen-symbols': rule?.requireSymbol, + } + for (const [id, on] of Object.entries(required)) { + const box = $(id) + box.disabled = !!on + const label = box.closest('label') + let note = label.querySelector('.gen-required') + if (on && !note) { + note = doc.createElement('span') + note.className = 'gen-required hint' + note.textContent = ' (required by your organisation)' + label.appendChild(note) + } + if (!on && note) note.remove() + } + $('gen-min-digits').min = rule?.requireDigit ? '1' : '0' + $('gen-min-special').min = rule?.requireSymbol ? '1' : '0' + const floor = String(rule ? rule.minLength : 8) + $('gen-length').min = floor + $('gen-length-range').min = floor + } + + /** + * Put the options into the form, and show the right fields. + * + * @spec openspec/specs/extension-generator/spec.md#requirement-password-options + */ + function writeForm() { + const p = options.password + $('gen-length').value = p.length + $('gen-length-range').value = p.length + $('gen-length-value').textContent = String(p.length) + $('gen-upper').checked = p.includeUppercase + $('gen-lower').checked = p.includeLowercase + $('gen-digits').checked = p.includeDigits + $('gen-symbols').checked = p.includeSpecialCharacters + $('gen-min-digits').value = p.minDigits + $('gen-min-special').value = p.minSpecial + // A minimum counts only while its kind is on; the generator ignores + // it otherwise, so the form does not show it either. + $('gen-min-digits').closest('label').hidden = !p.includeDigits + $('gen-min-special').closest('label').hidden = !p.includeSpecialCharacters + $('gen-ambiguous').checked = p.avoidAmbiguous + lockToPolicy() + const w = options.passphrase + $('gen-words').value = w.words + $('gen-separator').value = w.separator + $('gen-capitalise').checked = w.capitalise + $('gen-number').checked = w.includeNumber + const u = options.username + $('gen-username-type').value = u.type + $('gen-username-capitalize').checked = u.capitalize + $('gen-username-number').checked = u.includeNumber + $('gen-email').value = u.email + $('gen-domain').value = u.domain + for (const [name, mode] of [ + ['gen-email-mode', u.emailMode], + ['gen-domain-mode', u.domainMode], + ]) { + const radio = doc.querySelector( + `input[name="${name}"][value="${website ? mode : 'random'}"]`, + ) + if (radio) radio.checked = true + const site = doc.querySelector(`input[name="${name}"][value="website"]`) + if (site) site.disabled = website === '' + } + $('gen-website-hint').hidden = website !== '' + $('gen-username-word').hidden = u.type !== 'word' + $('gen-username-plus').hidden = u.type !== 'plus' + $('gen-username-catchall').hidden = u.type !== 'catchall' + + const allowPassphrase = passphraseAllowed(policy) + $('gen-tab-passphrase').hidden = !allowPassphrase + for (const kind of ['password', 'passphrase', 'username']) { + $('gen-tab-' + kind).setAttribute( + 'aria-selected', + options.tab === kind ? 'true' : 'false', + ) + $(`gen-${kind}-options`).hidden = options.tab !== kind + } + const hint = policyHint(policy) + $('gen-policy').textContent = hint + $('gen-policy').hidden = hint === '' + } + + /** Generate a value with the current options, show it, keep it in the history. */ + async function generate() { + const result = generateFor(options.tab, options, policy, website) + if (result.error) { + showError('gen-error', result.error) + renderColoured($('gen-output'), '', doc) + return + } + showError('gen-error', '') + renderColoured($('gen-output'), result.value, doc) + await send('generator-history-add', { + value: result.value, + kind: options.tab, + }) + } + + /** Save the options a moment after the last change. */ + function saveSoon() { + clearTimeout(saveTimer) + saveTimer = setTimeout( + () => send('generator-options-save', { options }), + 200, + ) + } + + /** An option changed: read, write back (clamped), regenerate, save. */ + async function changed() { + readForm() + writeForm() + saveSoon() + await generate() + } + + /** Render the history list. */ + async function showHistory() { + const { history = [] } = await send('generator-context') + const list = $('gen-history-list') + list.replaceChildren() + $('gen-history-empty').hidden = history.length > 0 + for (const entry of history) { + const li = doc.createElement('li') + li.className = 'candidate row' + const value = doc.createElement('output') + value.className = 'gen-output small' + renderColoured(value, entry.value, doc) + const when = doc.createElement('span') + when.className = 'hint' + when.textContent = relativeTime(entry.at) + const copy = doc.createElement('button') + copy.className = 'link' + copy.textContent = 'Copy' + copy.setAttribute( + 'aria-label', + `Copy the value from ${relativeTime(entry.at)}`, + ) + copy.addEventListener('click', () => copyText(entry.value)) + li.append(value, when, copy) + list.appendChild(li) + } + $('gen-main').hidden = true + $('gen-history').hidden = false + } + + for (const kind of ['password', 'passphrase', 'username']) { + $('gen-tab-' + kind).addEventListener('click', async () => { + options.tab = kind + writeForm() + saveSoon() + await generate() + }) + } + $('gen-length-range').addEventListener('input', () => { + $('gen-length').value = $('gen-length-range').value + changed() + }) + for (const el of doc.querySelectorAll( + '#gen-password-options input:not([type=range]), #gen-passphrase-options input, #gen-username-options input, #gen-username-options select', + )) { + el.addEventListener('change', changed) + } + $('gen-regenerate').addEventListener('click', generate) + $('gen-copy').addEventListener('click', () => + copyText($('gen-output').dataset.value || ''), + ) + $('gen-use').addEventListener('click', () => { + const value = $('gen-output').dataset.value || '' + if (pick && value) pick.onPick(value) + }) + $('gen-history-open').addEventListener('click', showHistory) + $('gen-history-back').addEventListener('click', () => { + $('gen-history').hidden = true + $('gen-main').hidden = false + }) + $('gen-history-clear').addEventListener('click', async () => { + await send('generator-history-clear') + await showHistory() + }) + + return { + /** + * Open the tab, optionally in pick mode for the item form. + * + * @param {{kind: string, onPick: (value: string) => void}} [pickMode] The field asking. + * @return {Promise} + */ + async open(pickMode) { + pick = pickMode || null + const context = await send('generator-context') + policy = context.policy ?? null + website = context.website || '' + options = sanitizeOptions(context.options, policy) + if (pick) { + options.tab = + pick.kind === 'username' + ? 'username' + : options.tab === 'username' + ? 'password' + : options.tab + } + $('gen-use').hidden = !pick + $('gen-history').hidden = true + $('gen-main').hidden = false + writeForm() + await generate() + }, + current: () => $('gen-output').dataset.value || '', + } +} diff --git a/browser-extension/src/popup/item-detail.js b/browser-extension/src/popup/item-detail.js new file mode 100644 index 000000000..d14dcb42c --- /dev/null +++ b/browser-extension/src/popup/item-detail.js @@ -0,0 +1,340 @@ +/** + * The item detail view: sections for every kind of item. Values arrive + * decrypted from the worker when the item opens and live in this view only; + * `clearDetail` drops them when the view closes. A passkey's private key + * never reaches this view: the worker sends only the site and account. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-detail-sections-for-every-kind-of-item + * @spec openspec/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker + */ + +import { + cardBrand, + cardLast4, + parsePayload, +} from '../../../src/cardIdentity/cardIdentity.js' +import { + generateTotp, + parseOtpauth, + secondsRemaining, +} from '../../../src/totp/totp.js' +import { + COMPOSITE_LABELS, + compositeFields, + folderPath, + formKind, + MASKED_COMPOSITE, + NOTES_FIELD, +} from '../lib/item-form.js' +import { copyText } from './clipboard.js' + +const MASK = '••••••••' +let totpTimer = null + +/** + * A titled section. + * + * @param {Document} doc The popup document. + * @param {string} title The heading. + * @return {HTMLElement} + */ +function section(doc, title) { + const el = doc.createElement('section') + el.className = 'detail-section' + const h = doc.createElement('h3') + h.textContent = title + el.appendChild(h) + return el +} + +/** + * One labelled value, optionally masked with Show, and with Copy. + * + * @param {Document} doc The popup document. + * @param {string} label The label. + * @param {string} value The value. + * @param {object} [options] The options. + * @param {boolean} [options.masked] Hidden until Show. + * @param {boolean} [options.copy] Offer Copy (default true). + * @param {string} [options.id] Id prefix for the value and its buttons. + * @return {HTMLElement} + */ +function fieldRow(doc, label, value, { masked = false, copy = true, id = '' } = {}) { + const row = doc.createElement('div') + row.className = 'field-row' + const name = doc.createElement('span') + name.className = 'field-label' + name.textContent = label + const shown = doc.createElement('span') + shown.className = 'field-value' + if (id) shown.id = id + shown.textContent = masked ? MASK : value || '—' + row.append(name, shown) + if (masked) { + const reveal = doc.createElement('button') + reveal.className = 'link' + reveal.textContent = 'Show' + reveal.setAttribute('aria-pressed', 'false') + reveal.setAttribute('aria-label', `Show ${label}`) + if (id) + reveal.id = id + .replace(/^detail-/, 'detail-reveal-') + .replace('detail-reveal-secret', 'detail-reveal') + reveal.addEventListener('click', () => { + const open = reveal.getAttribute('aria-pressed') === 'true' + shown.textContent = open ? MASK : value + reveal.textContent = open ? 'Show' : 'Hide' + reveal.setAttribute('aria-pressed', open ? 'false' : 'true') + }) + row.appendChild(reveal) + } + if (copy && value) { + const button = doc.createElement('button') + button.className = 'link' + button.textContent = 'Copy' + button.setAttribute('aria-label', `Copy ${label}`) + if (id) button.id = id.replace(/^detail-/, 'detail-copy-') + button.addEventListener('click', () => copyText(value)) + row.appendChild(button) + } + return row +} + +/** + * The TOTP section: the current code, grouped, with a countdown. + * + * @param {Document} doc The popup document. + * @param {string} seed The decrypted otpauth URI or base32 secret. + * @return {HTMLElement} + */ +function totpSection(doc, seed) { + const el = section(doc, 'Authenticator code') + let params + try { + params = parseOtpauth(seed) + } catch { + params = null + } + if (!params) { + const p = doc.createElement('p') + p.className = 'error' + p.textContent = 'This is not a valid authenticator secret.' + el.appendChild(p) + return el + } + const row = doc.createElement('div') + row.className = 'field-row' + const code = doc.createElement('span') + code.className = 'totp-code' + code.id = 'detail-totp-code' + const count = doc.createElement('span') + count.className = 'totp-count' + count.id = 'detail-totp-count' + const copy = doc.createElement('button') + copy.className = 'link' + copy.textContent = 'Copy' + copy.setAttribute('aria-label', 'Copy the code') + row.append(code, count, copy) + el.appendChild(row) + let current = '' + const tick = async () => { + current = await generateTotp(params) + const half = Math.ceil(current.length / 2) + code.textContent = `${current.slice(0, half)} ${current.slice(half)}` + count.textContent = `${secondsRemaining(params.period)}s` + } + copy.addEventListener('click', () => copyText(current)) + tick() + clearInterval(totpTimer) + totpTimer = setInterval(tick, 1000) + return el +} + +/** + * Render an item into the detail view. + * + * @param {object} ctx The view context. + * @param {(id: string) => HTMLElement} ctx.$ Element by id. + * @param {Document} ctx.doc The popup document. + * @param {object} item The item from the worker. + * @param {Array} folders The folders, for the path. + * @return {void} + */ +export function renderDetail({ $, doc }, item, folders) { + clearDetail({ $ }) + $('detail-name').textContent = item.name + $('detail-meta').textContent = + `${item.typeName} · ${folderPath(folders, item.folderId)}` + const blocked = item.blocked === true + $('detail-blocked').hidden = !blocked + for (const id of ['detail-edit', 'detail-clone', 'detail-move', 'detail-send']) { + $(id).dataset.blocked = blocked ? 'true' : 'false' + $(id).disabled = blocked || $('vault-offline').hidden === false + } + // A clone would copy a passkey's key and a Send would carry it. + const isPasskey = formKind(item.typeName) === 'passkey' + $('detail-clone').hidden = isPasskey + // A send carries a username and password: logins only. + $('detail-send').hidden = formKind(item.typeName) !== 'login' + if (blocked) { + $('detail-blocked-reason').textContent = item.blockedReason + $('detail-migration').textContent = item.migrationError || '' + $('detail-migration').hidden = !item.migrationError + return + } + const sections = $('detail-sections') + const kind = formKind(item.typeName) + const fields = item.additionalFields || {} + const notesKey = Object.keys(fields).find((k) => k.toLowerCase() === NOTES_FIELD) + + if (kind === 'login' || kind === 'generic') { + const el = section(doc, 'Login credentials') + if (item.login) + el.appendChild( + fieldRow(doc, 'Username', item.login, { id: 'detail-login' }), + ) + el.appendChild( + fieldRow(doc, kind === 'login' ? 'Password' : 'Value', item.secret, { + masked: true, + id: 'detail-secret', + }), + ) + sections.appendChild(el) + } + if (kind === 'totp') sections.appendChild(totpSection(doc, item.secret)) + if (kind === 'card' || kind === 'identity') { + const data = parsePayload(item.secret) + const el = section(doc, kind === 'card' ? 'Card' : 'Identity') + if (!data) { + const p = doc.createElement('p') + p.className = 'error' + p.textContent = 'Could not read this item' + el.appendChild(p) + } else { + if (kind === 'card' && data.number) { + const p = doc.createElement('p') + p.className = 'hint' + p.textContent = `${cardBrand(data.number) || 'Card'} ending in ${cardLast4(data.number)}` + el.appendChild(p) + } + for (const field of compositeFields(kind)) { + if (data[field]) { + el.appendChild( + fieldRow(doc, COMPOSITE_LABELS[field], String(data[field]), { + masked: MASKED_COMPOSITE.includes(field), + }), + ) + } + } + } + sections.appendChild(el) + } + if (kind === 'passkey') { + const credential = item.passkey + const el = section(doc, 'Passkey') + if (!credential) { + const p = doc.createElement('p') + p.className = 'error' + p.textContent = 'Could not read this passkey' + el.appendChild(p) + } else { + const site = credential.rpName + ? `${credential.rpName} (${credential.rpId})` + : credential.rpId + el.appendChild(fieldRow(doc, 'Site', site, { copy: false })) + el.appendChild( + fieldRow( + doc, + 'Account', + credential.userName || credential.userDisplayName || '', + { copy: false }, + ), + ) + if (credential.createdAt) { + el.appendChild( + fieldRow( + doc, + 'Created', + new Date(credential.createdAt).toLocaleString(), + { copy: false }, + ), + ) + } + } + sections.appendChild(el) + } + if (item.url) { + const el = section(doc, 'Website') + const row = fieldRow(doc, 'Address', item.url) + const launch = doc.createElement('button') + launch.className = 'link' + launch.textContent = 'Open' + launch.setAttribute('aria-label', `Open ${item.url}`) + launch.addEventListener('click', () => { + try { + const url = new URL( + /^https?:\/\//i.test(item.url) + ? item.url + : 'https://' + item.url, + ) + chrome.tabs.create({ url: url.href }) + } catch { + // Not an address a tab can open. + } + }) + row.appendChild(launch) + el.appendChild(row) + sections.appendChild(el) + } + const extra = Object.entries(fields).filter(([name]) => name !== notesKey) + if (item.additionalFieldsError || extra.length > 0) { + const el = section(doc, 'Additional fields') + if (item.additionalFieldsError) { + const p = doc.createElement('p') + p.className = 'error' + p.textContent = 'Could not read additional fields' + el.appendChild(p) + } + for (const [name, value] of extra) { + el.appendChild(fieldRow(doc, name, String(value), { masked: true })) + } + sections.appendChild(el) + } + const notes = + kind === 'note' ? item.secret : notesKey ? String(fields[notesKey]) : '' + if (notes) { + const el = section(doc, 'Notes') + const pre = doc.createElement('p') + pre.className = 'detail-notes' + pre.id = 'detail-notes' + pre.textContent = notes + el.appendChild(pre) + sections.appendChild(el) + } + const meta = section(doc, 'About this item') + for (const [label, value] of [ + ['Created', item.createdAt], + ['Updated', item.updatedAt], + ['Expires', item.expiresAt], + ]) { + if (value) + meta.appendChild( + fieldRow(doc, label, new Date(value).toLocaleString(), { + copy: false, + }), + ) + } + sections.appendChild(meta) +} + +/** + * Drop every decrypted value and timer of the detail view. + * + * @param {{$: (id: string) => HTMLElement}} ctx The view context. + * @return {void} + */ +export function clearDetail({ $ }) { + clearInterval(totpTimer) + totpTimer = null + $('detail-sections').replaceChildren() +} diff --git a/browser-extension/src/popup/popup.css b/browser-extension/src/popup/popup.css index 0f7d51ceb..d3951ef47 100644 --- a/browser-extension/src/popup/popup.css +++ b/browser-extension/src/popup/popup.css @@ -39,6 +39,12 @@ label { margin-bottom: 10px; } +/* The hidden attribute wins over any display rule above, so a view's + `el.hidden = true` really hides it (a label is display: block). */ +[hidden] { + display: none !important; +} + input { display: block; width: 100%; @@ -70,6 +76,24 @@ button.link { padding: 6px 0; } +button.link:focus-visible { + outline: 2px solid var(--cobalt); + outline-offset: 2px; + border-radius: 4px; +} + +/* Copy and Open next to a vault item (and Copy in the generator history): + each its own target, apart from the item and from each other. */ +.candidate.row { + gap: 4px; +} + +.candidate.row > button.link { + flex: none; + min-height: 32px; + padding: 6px 8px; +} + .hint { font-size: 12px; color: var(--muted); @@ -139,17 +163,398 @@ button.link { background: #f9fafb; } +/* Dark surfaces: the system preference unless data-theme says light, or + data-theme dark on any system. The tokens are set with the shell below. */ @media (prefers-color-scheme: dark) { - :root { - --fg: #e5e7eb; - --bg: #1f2937; - --border: #374151; - --muted: #9ca3af; - } - .candidate-fill { + :root:not([data-theme='light']) .candidate-fill, + :root:not([data-theme='light']) .save-prompt { background: #111827; } - .save-prompt { - background: #111827; +} + +:root[data-theme='dark'] .candidate-fill, +:root[data-theme='dark'] .save-prompt { + background: #111827; +} + +.account-bar { + gap: 8px; + margin-bottom: 12px; +} + +.account-select-label { + flex: 1; + margin: 0; +} + +.account-select-label select { + display: block; + width: 100%; + margin-top: 4px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; +} + +.idle-fieldset { + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; +} + +.idle-choice { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0; +} + +.idle-choice input { + display: inline; + width: auto; + margin: 0; +} + +/* Tabs: This site, Vault, Generator, Send (clients-extension-generator-vault-send) */ +.tabs { + display: flex; + gap: 4px; + margin: 8px 0 12px; + border-bottom: 1px solid var(--border); +} + +.tabs button { + flex: 1; + padding: 6px 4px; + border: 0; + border-bottom: 2px solid transparent; + background: none; + color: var(--muted); + font-size: 13px; + cursor: pointer; +} + +.tabs button[aria-selected='true'] { + color: var(--cobalt); + border-bottom-color: var(--cobalt); + font-weight: 600; +} + +.filters { + gap: 8px; +} + +.filters label { + flex: 1; +} + +select, +textarea { + display: block; + width: 100%; + margin-top: 4px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + font-size: 14px; +} + +label.inline { + display: flex; + align-items: center; + gap: 6px; +} + +label.inline input { + display: inline; + width: auto; + margin: 0; +} + +fieldset.mode { + border: 0; + padding: 0; + margin: 0 0 8px; + display: flex; + gap: 12px; +} + +h2 { + font-size: 15px; + margin: 8px 0 4px; +} + +.field-row { + display: flex; + align-items: center; + gap: 6px; + margin: 6px 0; + font-size: 13px; +} + +.field-label { + width: 72px; + color: var(--muted); +} + +.field-value { + flex: 1; + overflow-wrap: anywhere; + font-family: ui-monospace, monospace; +} + +.actions { + gap: 8px; + margin-top: 8px; +} + +.gen-output { + display: block; + min-height: 40px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + font-family: ui-monospace, monospace; + overflow-wrap: anywhere; +} + +button.danger { + color: var(--error); +} + +/* Generator sub-tabs and colour-coded output (clients-extension-complete) */ +.subtabs { + display: flex; + gap: 4px; + margin-bottom: 8px; +} + +.subtabs button { + flex: 1; + padding: 4px; + border: 1px solid var(--border); + border-radius: 6px; + background: none; + color: var(--fg); + cursor: pointer; +} + +.subtabs button[aria-selected='true'] { + border-color: var(--cobalt); + color: var(--cobalt); + font-weight: 600; +} + +.gen-digit { + color: #0b6e4f; +} + +.gen-special { + color: #b54708; +} + +.gen-output.small { + min-height: 0; + flex: 1; + font-size: 12px; +} + +input[type='range'] { + padding: 0; + border: 0; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) .gen-digit { + color: #6ee7b7; + } + + :root:not([data-theme='light']) .gen-special { + color: #fdba74; + } +} + +:root[data-theme='dark'] .gen-digit { + color: #6ee7b7; +} + +:root[data-theme='dark'] .gen-special { + color: #fdba74; +} + +/* Item detail sections and the item form (clients-extension-complete) */ +.detail-section { + margin: 10px 0; +} + +.detail-section h3 { + margin: 0 0 4px; + font-size: 13px; + color: var(--muted); +} + +.detail-notes { + white-space: pre-wrap; + margin: 0; +} + +.field-edit input { + display: inline-block; + width: 40%; + margin: 2px 4px 2px 0; +} + +fieldset.fields { + border: 1px solid var(--border); + border-radius: 6px; + margin: 8px 0; + padding: 6px; +} + +/* Folder manager (clients-extension-complete) */ +.folder-name { + flex: 1; +} + +#folder-delete { + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + margin: 8px 0; +} + +/* Popup shell (clients-extension-complete): theme tokens, fixed header and + tab bar, size. Colours come from the tokens above; dark values follow the + system, and data-theme on the root overrides it either way. */ +:root { + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --cobalt: #8fb0ea; + --fg: #e5e7eb; + --muted: #9ca3af; + --border: #374151; + --error: #f87171; + --bg: #1f2937; } } + +:root[data-theme='dark'] { + --cobalt: #8fb0ea; + --fg: #e5e7eb; + --muted: #9ca3af; + --border: #374151; + --error: #f87171; + --bg: #1f2937; +} + +body { + width: 380px; + max-height: 600px; + overflow-y: auto; + background: var(--bg); +} + +body.popped-out { + width: auto; + max-height: none; +} + +input, +select, +textarea { + background: var(--bg); + color: var(--fg); +} + +#view-unlocked > header { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg); + padding-bottom: 4px; +} + +#view-unlocked > .tabs { + position: sticky; + bottom: 0; + z-index: 1; + margin: 12px -16px -16px; + padding: 0 8px; + border-top: 1px solid var(--border); + border-bottom: 0; + background: var(--bg); +} + +/* The verification phrase: the words the user compares on both devices. */ +.phrase { + font-size: 15px; + font-weight: 600; + margin: 12px 0; +} + +/* Unlock and accounts (clients-extension-gaps) */ +.grow { + flex: 1; +} + +.account-initials { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex: none; + border-radius: 50%; + background: var(--cobalt); + color: var(--bg); + font-size: 12px; + font-weight: 600; +} + +.account-initials:empty { + display: none; +} + +/* Vault list cards (clients-extension-gaps) */ +.vault-card .candidate-fill { + display: flex; + flex-direction: column; + align-items: flex-start; + flex: 1; + min-width: 0; +} + +.vault-card-name { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-card-meta { + color: var(--muted); + font-size: 12px; +} + +/* Settings groups (clients-extension-gaps) */ +.settings-group { + margin: 12px 0; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.settings-group h2 { + margin: 0 0 6px; + font-size: 14px; +} + +label.check { + display: flex; + align-items: center; + gap: 6px; +} diff --git a/browser-extension/src/popup/popup.html b/browser-extension/src/popup/popup.html index 08998f07a..959e1dd8d 100644 --- a/browser-extension/src/popup/popup.html +++ b/browser-extension/src/popup/popup.html @@ -8,43 +8,387 @@
+ + + + + + + + + + + + + + + + + +
diff --git a/browser-extension/src/popup/popup.js b/browser-extension/src/popup/popup.js index d7cc67d3e..853869464 100644 --- a/browser-extension/src/popup/popup.js +++ b/browser-extension/src/popup/popup.js @@ -1,12 +1,50 @@ /** * Popup UI — an untrusted view over the background worker. It never holds key * material; it renders state and relays user intent (pair / unlock / fill / - * save / lock) as messages. All decryption happens in the worker. + * save / lock / switch account / settings) as messages. All decryption happens + * in the worker. */ +import { platformAuthenticatorAvailable } from '../unlock/ceremony.js' +import { initGenerator } from './generator-view.js' +import { initSend } from './send-view.js' +import { initVault } from './vault-view.js' +import { copyText } from './clipboard.js' +import { folderChoices } from '../lib/vault-index.js' +import { + canAddAccount, + DEVICE_STATUS_TEXT, + renderAccountSwitcher, + renderIdleChoices, +} from './views.js' + +// The last state the worker reported (accounts, active account, settings). +let state = {} +// The pairing form is open to add another account. +let adding = false + +// A popped-out popup (its own window) acts on the tab it was opened over. +const params = new URLSearchParams(location.search) +const POPPED_OUT = params.get('popout') === '1' +const PINNED_TAB = Number.parseInt(params.get('tabId') || '', 10) +const PINNED = Number.isInteger(PINNED_TAB) ? PINNED_TAB : undefined +// The messages that act on the page tab carry the pinned tab. +const TAB_MESSAGES = new Set([ + 'fill', + 'generator-context', + 'pending-capture', + 'save-capture', +]) + function send(type, payload) { + const body = + PINNED !== undefined && TAB_MESSAGES.has(type) + ? { ...(payload || {}), tabId: PINNED } + : payload return new Promise((resolve) => { - chrome.runtime.sendMessage({ type, payload }, (res) => resolve(res || {})) + chrome.runtime.sendMessage({ type, payload: body }, (res) => + resolve(res || {}), + ) }) } @@ -15,7 +53,16 @@ function $(id) { } function show(view) { - for (const id of ['view-pair', 'view-locked', 'view-unlocked']) { + for (const id of [ + 'view-pair', + 'view-locked', + 'view-unlocked', + 'view-settings', + 'view-update', + 'view-signed-out', + 'view-device-approval', + 'view-locked-generator', + ]) { $(id).hidden = id !== view } } @@ -30,18 +77,63 @@ function showError(id, message) { el.hidden = false } -async function activeHost() { +/** + * The page tab the popup is about: the pinned one when popped out, else the + * active tab. + * + * @return {Promise} + */ +async function activeTab() { + if (PINNED !== undefined) { + return chrome.tabs.get(PINNED).catch(() => null) + } const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + return tab || null +} + +/** + * The host of the page tab, for http and https pages only. + * + * @return {Promise} + */ +async function activeHost() { + const tab = await activeTab() try { - return tab ? new URL(tab.url).hostname : '' + const url = new URL(tab?.url || '') + return url.protocol === 'http:' || url.protocol === 'https:' + ? url.hostname + : '' } catch { return '' } } +/** + * Fill the save prompt's folder picker from the vault. + * + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-save-a-new-login-into-a-folder + */ +async function fillSaveFolders() { + const select = $('save-folder') + select.replaceChildren(select.options[0] || new Option('No folder', '')) + const { folders = [] } = await send('vault-list') + for (const { id, label } of folderChoices(folders)) { + select.appendChild(new Option(label, id)) + } +} + async function renderUnlocked() { const host = await activeHost() $('active-host').textContent = host + if (!host) { + // No website in this tab: nothing to suggest or fill. + $('candidates').replaceChildren() + $('no-candidates').textContent = 'Open a website to see its logins.' + $('no-candidates').hidden = false + $('totp-block').hidden = true + return + } + $('no-candidates').textContent = 'No matching secrets for this site.' const candidates = await send('match', { host }) const list = $('candidates') list.innerHTML = '' @@ -56,7 +148,19 @@ async function renderUnlocked() { btn.className = 'candidate-fill' btn.textContent = c.name + (c.url ? ' — ' + c.url : '') btn.addEventListener('click', async () => { - const res = await send('fill', { id: c.id }) + let res = await send('fill', { id: c.id, accountId: c.accountId }) + if (res.confirm === 'http-page') { + // The login was saved for https; this page is plain http. + const yes = window.confirm( + `${c.name} was saved for a secure (https) site, but this page is not secure. Anyone on the network could read what is filled in. Fill it anyway?`, + ) + if (!yes) return + res = await send('fill', { + id: c.id, + accountId: c.accountId, + allowHttp: true, + }) + } if (res.error) { showError('unlock-error', res.error) return @@ -64,7 +168,15 @@ async function renderUnlocked() { // Auto-copy a matched TOTP code so it is one paste away, then // clear it after a short delay (extension-totp-autofill §3). if (res.totpCode) { - await copyWithAutoClear(res.totpCode) + await copyText(res.totpCode) + } + if (!res.filled) { + // Say so instead of closing as if it worked. + showError( + 'unlock-error', + 'Keepiq found no login form on this page to fill.', + ) + return } window.close() }) @@ -79,20 +191,31 @@ async function renderUnlocked() { const { capture } = await send('pending-capture') if (capture) { $('save-prompt').hidden = false - $('save-text').textContent = `Save login for ${capture.host}?` + $('save-text').textContent = capture.account + ? `Save login for ${capture.host} to ${capture.account}?` + : `Save login for ${capture.host}?` + // A new login can go into a folder; an update stays where it is. + $('save-folder-label').hidden = !!capture.update + $('save-never').hidden = !!capture.update + if (!capture.update) await fillSaveFolders() $('save-yes').onclick = async () => { - const res = await send('save-capture', capture) + // The worker saves what it holds for this tab; nothing is sent back. + const res = await send('save-capture', { + folderId: $('save-folder').value || null, + }) if (res.error) showError('unlock-error', res.error) $('save-prompt').hidden = true } $('save-no').onclick = () => { $('save-prompt').hidden = true } + $('save-never').onclick = async () => { + await send('capture-never', {}) + $('save-prompt').hidden = true + } } } -// Clipboard TTL for a copied TOTP code (ms). -const TOTP_CLIPBOARD_TTL = 30000 let totpTimer = null /** @@ -138,36 +261,459 @@ async function renderTotp(host) { } /** - * Copy a code to the clipboard and clear it after the TTL (no later than the - * code window would expire). + * Open the unlock window for the active account (the OS prompt would close + * the popup), in unlock or enrol mode. + * + * @param {string} mode unlock or enrol. + * @return {void} + */ +function openUnlockWindow(mode) { + const url = chrome.runtime.getURL( + 'unlock.html?mode=' + + mode + + '&account=' + + encodeURIComponent(state.activeAccountId || ''), + ) + chrome.windows.create({ url, type: 'popup', width: 380, height: 360 }) + window.close() +} + +/** + * Show the fingerprint or face unlock button when this browser has a platform + * authenticator and the account has an extension passkey enrolled. * - * @param {string} code * @return {Promise} */ -async function copyWithAutoClear(code) { +async function renderBiometricUnlock() { + $('unlock-biometric').hidden = true + if (!(await platformAuthenticatorAvailable(window))) return + const options = await send('biometric-options', { + accountId: state.activeAccountId, + }) + $('unlock-biometric').hidden = !(options.credentials || []).length +} + +// Device approval: poll every three seconds while the popup is open. +const DEVICE_POLL_MS = 3000 +let devicePoll = null + +function stopDevicePoll() { + if (devicePoll) { + clearInterval(devicePoll) + devicePoll = null + } +} + +/** + * Show the waiting view for a request and poll until it ends. The worker + * holds the one-time key; the popup only shows the phrase and the status. + * + * @param {{phrase: string}} request The request as the worker reports it. + * @return {void} + */ +function showDeviceApproval(request) { + show('view-device-approval') + showError('device-error', '') + $('device-phrase').textContent = request.phrase + $('device-status').textContent = DEVICE_STATUS_TEXT.pending + stopDevicePoll() + devicePoll = setInterval(async () => { + const res = await send('device-approval-poll') + if (res.error) { + stopDevicePoll() + showError('device-error', res.error) + return + } + if (res.status === 'unlocked') { + stopDevicePoll() + await refresh() + return + } + $('device-status').textContent = + DEVICE_STATUS_TEXT[res.status] ?? DEVICE_STATUS_TEXT.pending + if (res.status !== 'pending') stopDevicePoll() + }, DEVICE_POLL_MS) +} + +/** + * Offer "Approve from another device" on the locked view when the + * organisation allows it, or go straight back to an open request. + * + * @return {Promise} True when an open request took over the view. + */ +async function renderDeviceApprovalOption() { + $('unlock-device').hidden = true + const res = await send('device-approval-state') + if (res.request) { + showDeviceApproval(res.request) + return true + } + $('unlock-device').hidden = !res.enabled + return false +} + +async function renderSettings() { + show('view-settings') + showError('settings-error', '') + renderIdleChoices($('idle-choices'), state, async (minutes) => { + const res = await send('set-idle', { + accountId: state.activeAccountId, + idleMinutes: minutes, + }) + if (res.error) showError('settings-error', res.error) + }) + $('biometric-enrol').hidden = !(await platformAuthenticatorAvailable(window)) + $('pin-set-form').hidden = !!state.pinSet + $('pin-remove').hidden = !state.pinSet + await renderClipboardSetting() + await renderNeverSites() + await renderShortcut() + await renderExtensionSettings() +} + +/** + * Show a theme: the system's, or light or dark whatever the system says. + * + * @param {string} theme system, light or dark. + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-settings-for-autofill-new-items-and-appearance + */ +function applyTheme(theme) { + if (theme === 'light' || theme === 'dark') { + document.documentElement.dataset.theme = theme + } else { + delete document.documentElement.dataset.theme + } +} + +/** + * The browser-wide settings: autofill offers, the type of a new item, the + * theme, the web app and the About text. + * + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-settings-for-autofill-new-items-and-appearance + */ +async function renderExtensionSettings() { + const settings = await send('extension-settings') + const list = state.unlocked ? await send('vault-list') : {} + $('setting-offer-save').checked = settings.offerSave !== false + $('setting-offer-update').checked = settings.offerUpdate !== false + $('setting-suggest').checked = settings.suggestPasswords !== false + const typeSelect = $('setting-default-type') + typeSelect.replaceChildren() + const names = (list.types || []) + .map((t) => t.name) + .filter((n) => n && n !== 'passkey') + for (const name of names.length ? names : ['login']) { + typeSelect.appendChild( + new Option(name, name, false, name === settings.defaultType), + ) + } + $('setting-theme').value = settings.theme || 'system' + const save = async (patch) => { + const res = await send('set-extension-settings', patch) + if (res.error) showError('settings-error', res.error) + return res + } + $('setting-offer-save').onchange = () => + save({ offerSave: $('setting-offer-save').checked }) + $('setting-offer-update').onchange = () => + save({ offerUpdate: $('setting-offer-update').checked }) + $('setting-suggest').onchange = () => + save({ suggestPasswords: $('setting-suggest').checked }) + typeSelect.onchange = () => save({ defaultType: typeSelect.value }) + $('setting-theme').onchange = async () => { + const res = await save({ theme: $('setting-theme').value }) + applyTheme(res.theme) + } + $('settings-open-web').hidden = !list.webAppUrl + $('settings-open-web').onclick = () => + chrome.tabs.create({ url: list.webAppUrl }) + $('settings-notices').onclick = () => + chrome.tabs.create({ url: chrome.runtime.getURL('THIRD-PARTY-NOTICES.txt') }) + const version = chrome.runtime.getManifest?.().version || '' + $('about-text').textContent = + `Keepiq extension ${version}` + + (state.serverVersion + ? `, Keepiq ${state.serverVersion} on your server` + : '') + + '.' +} + +/** + * The sites with no save offer, each with Remove. + * + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-never-offer-to-save-on-a-site + */ +async function renderNeverSites() { + const { sites = [] } = await send('never-sites') + const list = $('never-list') + list.replaceChildren() + for (const host of sites) { + const li = document.createElement('li') + li.className = 'candidate row' + const name = document.createElement('span') + name.textContent = host + const remove = document.createElement('button') + remove.type = 'button' + remove.className = 'link' + remove.textContent = 'Remove' + remove.setAttribute('aria-label', `Offer to save on ${host} again`) + remove.addEventListener('click', async () => { + await send('never-remove', { host }) + await renderNeverSites() + }) + li.append(name, remove) + list.appendChild(li) + } + $('never-empty').hidden = sites.length > 0 +} + +/** + * The keyboard shortcut that fills a login, as the browser set it. + * + * @spec openspec/specs/extension-autofill-extras/spec.md#requirement-fill-from-the-context-menu-and-a-shortcut + */ +async function renderShortcut() { + let shortcut = '' try { - await navigator.clipboard.writeText(code) - setTimeout(() => { - navigator.clipboard.writeText('').catch(() => {}) - }, TOTP_CLIPBOARD_TTL) + const commands = (await chrome.commands?.getAll?.()) || [] + shortcut = commands.find((c) => c.name === 'fill-login')?.shortcut || '' } catch { - // Clipboard may be unavailable (no focus); the code is still shown. + shortcut = '' + } + $('shortcut-text').textContent = shortcut + ? `Press ${shortcut} on a login page to fill its login. Change the shortcut in your browser's extension settings.` + : "Set a keyboard shortcut for filling a login in your browser's extension settings." +} + +/** + * The clipboard delay picker: how long a copy stays on the clipboard. It + * applies to every account in this browser. + * + * @spec openspec/specs/extension-clipboard/spec.md#requirement-every-copy-is-cleared-after-a-delay-the-user-sets + */ +async function renderClipboardSetting() { + const { seconds, choices = [] } = await send('clipboard-settings') + const select = $('clipboard-clear') + select.replaceChildren() + for (const value of choices) { + const option = document.createElement('option') + option.value = String(value) + option.textContent = + value === 0 + ? 'Never' + : value < 60 + ? `${value} seconds` + : value === 60 + ? '1 minute' + : `${value / 60} minutes` + option.selected = value === seconds + select.appendChild(option) + } + select.onchange = async () => { + const res = await send('set-clipboard-clear', { + seconds: Number(select.value), + }) + if (res.error) showError('settings-error', res.error) } } async function refresh() { - const state = await send('get-state') - if (!state.paired) { + state = await send('get-state') + const paired = !!state.paired + $('account-bar').hidden = !paired || adding + if (paired) { + renderAccountSwitcher($('account-select'), state) + $('account-initials').textContent = initialsOf( + (state.accounts || []).find((a) => a.id === state.activeAccountId), + ) + $('account-add').hidden = !canAddAccount(state) + } + $('pair-cancel').hidden = !paired + if (!paired || adding) { show('view-pair') + } else if (state.serverOutdated) { + // Nothing else works against an older server: say so, ask nothing. + show('view-update') + } else if (state.loggedOut || state.insecure) { + renderSignedOut() } else if (!state.unlocked) { show('view-locked') + $('pin-block').hidden = !state.pinSet + ;(state.pinSet ? $('unlock-pin') : $('unlock-master')).focus() + if (!(await renderDeviceApprovalOption())) await renderBiometricUnlock() } else { show('view-unlocked') - await renderUnlocked() + await selectTab(await lastTab()) + } +} + +/** + * Up to two initials for an account, from its label or user name. + * + * @param {object|undefined} account The account. + * @return {string} + * @spec openspec/specs/extension-unlock-and-accounts/spec.md#requirement-lock-and-log-out-per-account-or-all + */ +export function initialsOf(account) { + const name = String(account?.label || account?.user || '').trim() + const parts = name.split(/[\s._@-]+/).filter(Boolean) + const letters = parts.length > 1 ? parts[0][0] + parts[1][0] : name.slice(0, 2) + return letters.toUpperCase() +} + +/** + * The signed-out view: the server refused the account's app password, or the + * account was paired over http and cannot be used. + * + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +function renderSignedOut() { + show('view-signed-out') + showError('relogin-error', '') + $('relogin-app-password').value = '' + $('relogin-form').hidden = !state.loggedOut || state.insecure + $('signed-out-text').textContent = state.insecure + ? 'This account was connected over http. Keepiq now needs https, so your app password is never sent in clear. Disconnect it and connect again over https.' + : state.loggedOutReason === 'logout' + ? 'You logged out of this account. Create a new app password in Nextcloud and enter it here to sign in again.' + : 'Keepiq refused the app password of this account. It was revoked or changed in Nextcloud. Create a new app password in Nextcloud and enter it here.' +} + +// --- tabs: This site, Vault, Generator, Send --- + +const TABS = ['site', 'vault', 'generator', 'send'] +const LAST_TAB_KEY = 'popup:lastTab' + +/** + * The tab the popup was last on in this browser session, or This site. + * + * @return {Promise} + */ +async function lastTab() { + try { + const area = tabArea() + const saved = (await area.get(LAST_TAB_KEY))[LAST_TAB_KEY] + return TABS.includes(saved) ? saved : 'site' + } catch { + return 'site' + } +} + +/** + * Where the last tab is kept: session storage, or local storage in a + * browser without it (the worker clears it on lock either way). + * + * @return {object} + * @spec openspec/specs/extension-small-items/spec.md#requirement-the-popup-keeps-its-place + */ +function tabArea() { + return chrome.storage.session || chrome.storage.local +} + +/** + * Remember the tab for this browser session only. + * + * @param {string} name The tab. + */ +function rememberTab(name) { + try { + tabArea() + .set({ [LAST_TAB_KEY]: name }) + .catch(() => {}) + } catch { + // No storage at all: the popup opens on This site. + } +} +let generatorView = null +let vaultView = null +let sendView = null + +/** + * Show one tab and, unless returning to it, open its view. + * + * @param {string} name One of TABS. + * @param {object} [arg] Passed to the view's open (Send prefill, Generator pick mode). + * @param {{reopen?: boolean}} [how] reopen false: switch without reloading the view. + * @return {Promise} + */ +async function selectTab(name, arg, { reopen = true } = {}) { + for (const tab of TABS) { + const selected = tab === name + $('tab-' + tab).setAttribute('aria-selected', selected ? 'true' : 'false') + $('panel-' + tab).hidden = !selected } + rememberTab(name) + if (!reopen) return + if (name === 'site') await renderUnlocked() + if (name === 'vault') await vaultView.open() + if (name === 'generator') await generatorView.open(arg) + if (name === 'send') await sendView.open(arg) +} + +/** + * Open the Generator in pick mode for the item form, and come back to the + * form (with the user's other input intact) when a value is picked. + * + * @param {string} kind password or username. + * @param {(value: string) => void} onPick Puts the value in the form. + * @return {Promise} + */ +function pickGenerated(kind, onPick) { + return selectTab('generator', { + kind, + onPick: (value) => { + onPick(value) + selectTab('vault', undefined, { reopen: false }) + }, + }) +} + +// The Generator while locked: its panel moves into the locked view and back. +function openLockedGenerator() { + $('locked-generator-slot').appendChild($('panel-generator')) + $('panel-generator').hidden = false + show('view-locked-generator') + return generatorView.open() +} + +function closeLockedGenerator() { + $('panel-send').before($('panel-generator')) + $('panel-generator').hidden = true + return refresh() +} + +function wireTabs() { + const ctx = { $, send, showError } + generatorView = initGenerator(ctx) + sendView = initSend(ctx) + vaultView = initVault({ + ...ctx, + currentSite: async () => { + const tab = await activeTab() + try { + const url = new URL(tab?.url || '') + return /^https?:$/.test(url.protocol) ? url.origin : '' + } catch { + return '' + } + }, + pickGenerated, + sendItem: (item) => selectTab('send', item), + }) + for (const tab of TABS) { + $('tab-' + tab).addEventListener('click', () => { + // Leaving an item form with changes asks first. + const onVault = $('tab-vault').getAttribute('aria-selected') === 'true' + if (onVault && tab !== 'vault' && !vaultView.canLeave()) return + selectTab(tab) + }) + } + $('locked-generate').addEventListener('click', openLockedGenerator) + $('locked-generator-back').addEventListener('click', closeLockedGenerator) } function wire() { + wireTabs() $('pair-submit').addEventListener('click', async () => { showError('pair-error', '') const res = await send('pair', { @@ -175,8 +721,75 @@ function wire() { user: $('pair-user').value.trim(), appPassword: $('pair-app-password').value, }) - if (res.error) showError('pair-error', res.error) - else await refresh() + if (res.error) { + showError('pair-error', res.error) + return + } + adding = false + for (const id of ['pair-url', 'pair-user', 'pair-app-password']) { + $(id).value = '' + } + await refresh() + }) + + $('pair-cancel').addEventListener('click', async () => { + adding = false + showError('pair-error', '') + await refresh() + }) + + $('account-add').addEventListener('click', async () => { + adding = true + await refresh() + }) + + $('account-select').addEventListener('change', async (event) => { + await send('switch-account', { accountId: event.target.value }) + await refresh() + }) + + $('unlock-biometric').addEventListener('click', () => openUnlockWindow('unlock')) + $('biometric-enrol').addEventListener('click', () => openUnlockWindow('enrol')) + $('settings-btn').addEventListener('click', () => renderSettings()) + $('tab-settings').addEventListener('click', () => renderSettings()) + $('popout-btn').hidden = POPPED_OUT + if (POPPED_OUT) document.body.classList.add('popped-out') + $('popout-btn').addEventListener('click', async () => { + const tab = await activeTab() + const query = 'popout=1' + (tab?.id !== undefined ? '&tabId=' + tab.id : '') + chrome.windows.create({ + url: chrome.runtime.getURL('popup.html?' + query), + type: 'popup', + width: 380, + height: 630, + }) + window.close() + }) + $('settings-back').addEventListener('click', () => refresh()) + $('relogin-form').addEventListener('submit', async (event) => { + event.preventDefault() + showError('relogin-error', '') + const res = await send('relogin', { + accountId: state.activeAccountId, + appPassword: $('relogin-app-password').value, + }) + $('relogin-app-password').value = '' + if (res.error) { + showError('relogin-error', res.error) + return + } + await refresh() + }) + $('signed-out-disconnect').addEventListener('click', async () => { + if (!confirmDisconnect()) return + await send('unpair', { accountId: state.activeAccountId }) + await refresh() + }) + + $('settings-unpair').addEventListener('click', async () => { + if (!confirmDisconnect()) return + await send('unpair', { accountId: state.activeAccountId }) + await refresh() }) $('unlock-submit').addEventListener('click', async () => { @@ -184,21 +797,148 @@ function wire() { const res = await send('unlock', { masterPassword: $('unlock-master').value, }) + $('unlock-master').type = 'password' + $('unlock-show').textContent = 'Show' + $('unlock-show').setAttribute('aria-pressed', 'false') $('unlock-master').value = '' if (res.error) showError('unlock-error', res.error) else await refresh() }) + $('unlock-device').addEventListener('click', async () => { + showError('unlock-error', '') + const res = await send('device-approval-start') + if (res.error) { + showError('unlock-error', res.error) + return + } + showDeviceApproval(res.request) + }) + + $('device-cancel').addEventListener('click', async () => { + stopDevicePoll() + await send('device-approval-cancel') + await refresh() + }) + $('unlock-unpair').addEventListener('click', async () => { - await send('unpair') + if (!confirmDisconnect()) return + await send('unpair', { accountId: state.activeAccountId }) + await refresh() + }) + + // Show or hide the master password while typing it. + $('unlock-show').addEventListener('click', () => { + const shown = $('unlock-master').type === 'text' + $('unlock-master').type = shown ? 'password' : 'text' + $('unlock-show').textContent = shown ? 'Show' : 'Hide' + $('unlock-show').setAttribute('aria-pressed', shown ? 'false' : 'true') + $('unlock-master').focus() + }) + $('unlock-master').addEventListener('keydown', (event) => { + if (event.key === 'Enter') $('unlock-submit').click() + }) + $('settings-logout').addEventListener('click', async () => { + if ( + !window.confirm( + 'Log out of this account? Its app password is deleted in Nextcloud, and you need a new one to sign in again.', + ) + ) + return + await send('logout', { accountId: state.activeAccountId }) + await refresh() + }) + $('settings-logout-all').addEventListener('click', async () => { + if ( + !window.confirm( + 'Log out of all accounts? Their app passwords are deleted in Nextcloud, and you need new ones to sign in again.', + ) + ) + return + await send('logout', { all: true }) + await refresh() + }) + $('unlock-pin-submit').addEventListener('click', async () => { + showError('unlock-error', '') + const res = await send('pin-unlock', { pin: $('unlock-pin').value }) + $('unlock-pin').value = '' + if (res.error) showError('unlock-error', res.error) + await refresh() + }) + $('unlock-pin').addEventListener('keydown', (event) => { + if (event.key === 'Enter') $('unlock-pin-submit').click() + }) + $('pin-set').addEventListener('click', async () => { + showError('settings-error', '') + const res = await send('pin-set', { + masterPassword: $('pin-master').value, + pin: $('pin-new').value, + }) + $('pin-master').value = '' + $('pin-new').value = '' + if (res.error) { + showError('settings-error', res.error) + return + } + state = await send('get-state') + await renderSettings() + }) + $('pin-remove').addEventListener('click', async () => { + await send('pin-remove') + state = await send('get-state') + await renderSettings() + }) + $('settings-lock-all').addEventListener('click', async () => { + await send('lock', {}) await refresh() }) $('lock-btn').addEventListener('click', async () => { - await send('lock') + // The account on screen; the others stay as they are. + await send('lock', { accountId: state.activeAccountId }) await refresh() }) } +/** + * The worker locked an account. When it is the one on screen, drop what the + * popup shows of the vault at once and show the lock screen. + * + * @param {object} msg The worker's message. + * @spec openspec/specs/extension-lock/spec.md#requirement-the-popup-forgets-the-vault-when-it-locks + */ +function onWorkerMessage(msg) { + if (msg?.type !== 'keepiq-locked') return + if (msg.accountId && msg.accountId !== state.activeAccountId) return + vaultView?.forget() + $('candidates').replaceChildren() + $('totp-code').textContent = '' + $('totp-block').hidden = true + $('gen-output').textContent = '' + $('view-unlocked').hidden = true + refresh() +} + +/** + * Ask before disconnecting: it deletes the account's app password in + * Nextcloud and its data in this browser. + * + * @return {boolean} Whether the user confirmed. + * @spec openspec/specs/extension-lock/spec.md#requirement-lock-locks-the-account-on-screen-and-disconnect-asks-first + */ +function confirmDisconnect() { + const account = (state.accounts || []).find( + (a) => a.id === state.activeAccountId, + ) + const name = account + ? account.label || account.user + '@' + account.host + : 'this account' + return window.confirm( + `Disconnect ${name}? Its app password is deleted in Nextcloud and its data is removed from this browser.`, + ) +} + +chrome.runtime.onMessage?.addListener(onWorkerMessage) +send('extension-settings').then((settings) => applyTheme(settings?.theme)) wire() refresh() diff --git a/browser-extension/src/popup/send-view.js b/browser-extension/src/popup/send-view.js new file mode 100644 index 000000000..e00fcdd6a --- /dev/null +++ b/browser-extension/src/popup/send-view.js @@ -0,0 +1,178 @@ +/** + * The popup's Send tab: create a send from text or a username and password, + * copy its link once, and see or end your sends. + * + * @spec openspec/specs/extension-send/spec.md#requirement-create-a-send-from-the-popup + */ + +import { expiresIn, EXPIRY_PRESETS, sendRowLabel } from '../lib/send-form.js' +import { copyText } from './clipboard.js' + +/** + * Wire the Send tab. + * + * @param {object} ctx The popup context. + * @param {(id: string) => HTMLElement} ctx.$ Element by id. + * @param {(type: string, payload?: object) => Promise} ctx.send Message the worker. + * @param {(id: string, message: string) => void} ctx.showError Show or clear an error. + * @param {Document} [ctx.doc] The popup document. + * @return {{open: (prefill?: {login?: string, secret?: string}) => Promise}} + */ +export function initSend({ $, send, showError, doc = document }) { + for (const preset of EXPIRY_PRESETS) { + const option = doc.createElement('option') + option.value = preset.id + option.textContent = preset.label + $('send-expiry').appendChild(option) + } + $('send-expiry').value = '1d' + + // Links of sends made while this popup is open, by send id. A link holds + // the key, so it lives only here and only until the popup closes. + const sessionLinks = new Map() + // The server cannot be reached: no send can be made. + let offline = false + + /** @return {string} The chosen kind of send. */ + const kind = () => + doc.querySelector('input[name="send-type"]:checked')?.value || 'text' + + /** Show the fields for the chosen kind. */ + function syncKind() { + const credential = kind() === 'credential' + $('send-credential').hidden = !credential + $('send-text-label').hidden = credential + } + + /** + * Load and render the account's sends: what each is, when it expires, + * whether it has a password, and how often it was opened. + * + * @spec openspec/specs/extension-send-details/spec.md#requirement-say-what-a-send-is-and-what-went-wrong + */ + async function loadSends() { + const { sends = [], error } = await send('send-list') + const list = $('send-list') + list.replaceChildren() + $('send-empty').hidden = sends.length > 0 || !!error + $('send-retry').hidden = !error + if (error) { + showError('send-error', error) + return + } + for (const row of sends) { + const li = doc.createElement('li') + li.className = 'candidate row' + const label = doc.createElement('span') + const details = [ + `${row.viewCount} of ${row.maxViews} opened`, + expiresIn(row.expiresAt), + row.hasPassword ? 'password' : '', + ].filter(Boolean) + label.textContent = `${sendRowLabel(row)} (${details.join(', ')})` + const end = doc.createElement('button') + end.className = 'link danger' + end.textContent = 'End' + end.setAttribute('aria-label', `End ${sendRowLabel(row)}`) + end.addEventListener('click', async () => { + if ( + !window.confirm( + `End ${sendRowLabel(row)}? Its link stops working.`, + ) + ) + return + const res = await send('send-revoke', { id: row.id }) + if (res.error) showError('send-error', res.error) + await loadSends() + }) + li.append(label) + if (sessionLinks.has(row.id)) { + const copy = doc.createElement('button') + copy.className = 'link' + copy.textContent = 'Copy link' + copy.setAttribute( + 'aria-label', + `Copy the link of ${sendRowLabel(row)}`, + ) + copy.addEventListener('click', () => + copyText(sessionLinks.get(row.id)), + ) + li.append(copy) + } + li.append(end) + list.appendChild(li) + } + } + + for (const radio of doc.querySelectorAll('input[name="send-type"]')) { + radio.addEventListener('change', syncKind) + } + $('send-expiry').addEventListener('change', () => { + $('send-custom-label').hidden = $('send-expiry').value !== 'custom' + }) + $('send-retry').addEventListener('click', () => { + showError('send-error', '') + loadSends() + }) + $('send-form').addEventListener('submit', async (event) => { + event.preventDefault() + showError('send-error', '') + // Argon2id takes a moment: say so, and allow one press. + const protecting = $('send-protect').value !== '' + $('send-progress').hidden = !protecting + $('send-create').disabled = true + const res = await send('send-create', { + payloadType: kind(), + text: $('send-text').value, + username: $('send-username').value, + password: $('send-password').value, + maxViews: $('send-views').value, + expiry: $('send-expiry').value, + customHours: $('send-custom').value, + sendPassword: $('send-protect').value, + }) + $('send-progress').hidden = true + $('send-create').disabled = offline + if (res.error) { + showError('send-error', res.error) + return + } + // The link exists only here: the key is in its fragment, never stored. + $('send-link').value = res.link + if (res.id) sessionLinks.set(res.id, res.link) + $('send-result').hidden = false + for (const id of [ + 'send-text', + 'send-username', + 'send-password', + 'send-protect', + ]) { + $(id).value = '' + } + await loadSends() + }) + $('send-copy').addEventListener('click', async () => { + await copyText($('send-link').value) + $('send-copy').textContent = 'Copied' + }) + + return { + async open(prefill) { + $('send-result').hidden = true + $('send-link').value = '' + $('send-copy').textContent = 'Copy link' + if (prefill) { + doc.querySelector( + 'input[name="send-type"][value="credential"]', + ).checked = true + $('send-username').value = prefill.login || '' + $('send-password').value = prefill.secret || '' + } + syncKind() + offline = (await send('send-state')).offline === true + $('send-offline').hidden = !offline + $('send-create').disabled = offline + await loadSends() + }, + } +} diff --git a/browser-extension/src/popup/vault-view.js b/browser-extension/src/popup/vault-view.js new file mode 100644 index 000000000..93f02f589 --- /dev/null +++ b/browser-extension/src/popup/vault-view.js @@ -0,0 +1,676 @@ +/** + * The popup's Vault tab: search and browse the vault, open an item, and add, + * edit, clone, move or delete items. The worker decrypts and encrypts; this + * view holds the open item's values only while it is open. + * + * @spec openspec/specs/extension-vault/spec.md#requirement-browse-and-search-the-vault + * @spec openspec/specs/extension-vault/spec.md#requirement-edit-every-kind-of-item + * @spec openspec/specs/extension-vault/spec.md#requirement-a-passkeys-private-key-stays-in-the-worker + */ + +import { + changedParts, + COMPOSITE_LABELS, + compositeFields, + draftFromItem, + formKind, + MASKED_COMPOSITE, + partsFromDraft, + validateDraft, +} from '../lib/item-form.js' +import { + filterIndex, + folderChoices, + listState, + NO_FOLDER, + presentTypes, +} from '../lib/vault-index.js' +import { copyText } from './clipboard.js' +import { relativeTime } from '../lib/generator-state.js' +import { initFolders } from './folder-view.js' +import { clearDetail, renderDetail } from './item-detail.js' + +const UNSAVED = 'You have unsaved changes. Discard them?' +const NEW_FOLDER = '__new__' + +/** + * Replace a select's options after its first option. + * + * @param {HTMLSelectElement} select The select. + * @param {Array<{value: string, label: string}>} options The options. + * @param {Document} doc The popup document. + */ +function fillSelect(select, options, doc) { + const keep = select.options[0] + select.replaceChildren(...(keep ? [keep] : [])) + for (const { value, label } of options) { + const option = doc.createElement('option') + option.value = value + option.textContent = label + select.appendChild(option) + } +} + +/** + * Wire the Vault tab. + * + * @param {object} ctx The popup context. + * @param {(id: string) => HTMLElement} ctx.$ Element by id. + * @param {(type: string, payload?: object) => Promise} ctx.send Message the worker. + * @param {(id: string, message: string) => void} ctx.showError Show or clear an error. + * @param {(kind: string, onPick: (value: string) => void) => Promise} ctx.pickGenerated Open the Generator to pick a value for the form. + * @param {(item: object) => void} ctx.sendItem Open the Send tab for an item. + * @param {Document} [ctx.doc] The popup document. + * @param {() => Promise} [ctx.currentSite] The address of the site the popup is on. + * @spec openspec/specs/extension-small-items/spec.md#requirement-a-form-that-starts-and-checks-sensibly + * @return {{open: () => Promise, canLeave: () => boolean}} + */ +export function initVault({ + $, + send, + showError, + pickGenerated, + sendItem, + doc = document, + currentSite = null, +}) { + let index = [] + // Whether the first list has arrived. + let loaded = false + // The type of a new item (Settings). + let preferredType = 'login' + // The address of the site the popup is on, for a new item. + let siteOrigin = '' + // Where the list was scrolled when an item opened. + let listScroll = 0 + let folders = [] + let types = [] + let webAppUrl = '' + // Whether the server could not be reached on the last sync. + let offline = false + + /** The buttons that change the vault, disabled while offline. */ + const WRITE_CONTROLS = [ + 'vault-new', + 'detail-edit', + 'detail-clone', + 'detail-move', + 'detail-delete', + 'edit-save', + 'folder-add-save', + 'folder-delete-confirm', + ] + + /** + * Show the sync status, and allow or block writes. + * + * @param {object|null} status The sync status from the worker. + */ + function renderSync(status) { + offline = status?.offline === true + $('vault-sync').textContent = status?.syncedAt + ? `Last synced ${relativeTime(Date.parse(status.syncedAt))}${offline ? ' (offline)' : ''}` + : offline + ? 'Offline' + : '' + $('vault-offline').hidden = !offline + for (const id of WRITE_CONTROLS) { + const el = $(id) + if (el) el.disabled = offline || el.dataset.blocked === 'true' + } + } + // The open item, decrypted; dropped when it closes. + let current = null + // The form: its type and the parts it opened with, to detect changes. + let form = null + + /** + * Show one of the three Vault views; leaving the detail drops its values. + * + * @param {'vault-browse'|'vault-detail'|'vault-edit'} view The view id. + */ + function showView(view) { + for (const id of [ + 'vault-browse', + 'vault-detail', + 'vault-edit', + 'vault-folders', + ]) { + $(id).hidden = id !== view + } + if (view !== 'vault-edit') form = null + if (view === 'vault-browse') { + current = null + clearDetail({ $ }) + } + } + + /** @return {Array<{value: string, label: string}>} Folder choices. */ + const folderOptions = () => [ + { value: NO_FOLDER, label: 'No folder' }, + ...folderChoices(folders).map((f) => ({ value: f.id, label: f.label })), + ] + + /** Render the filtered list. */ + function renderList() { + const entries = filterIndex(index, { + query: $('vault-search').value, + folderId: $('vault-folder').value || null, + typeName: $('vault-type').value || null, + }) + const list = $('vault-list') + list.replaceChildren() + const state = listState(loaded ? index : null, entries) + $('vault-status').textContent = { + loading: 'Loading your vault…', + empty: 'Your vault is empty. Add an item with New.', + 'no-match': 'Nothing matches.', + 'all-blocked': + 'Every item is blocked here: its key cannot be used in this browser.', + items: `${entries.length} items`, + }[state] + $('vault-clear').hidden = state !== 'no-match' + for (const entry of entries) list.appendChild(card(entry)) + } + + /** + * One item in the list: its name, type and site, Copy and Open. + * + * @param {object} entry The index entry. + * @return {HTMLLIElement} + * @spec openspec/specs/extension-list-and-settings/spec.md#requirement-a-list-that-says-what-it-shows + */ + function card(entry) { + const li = doc.createElement('li') + li.className = 'candidate row vault-card' + const button = doc.createElement('button') + button.className = 'candidate-fill' + const name = doc.createElement('span') + name.className = 'vault-card-name' + name.textContent = entry.name + const meta = doc.createElement('span') + meta.className = 'vault-card-meta' + let host = '' + try { + host = entry.url ? new URL(entry.url).host : '' + } catch { + host = entry.url + } + meta.textContent = [entry.typeName, host, entry.blocked ? 'blocked' : ''] + .filter(Boolean) + .join(' · ') + button.append(name, meta) + button.addEventListener('click', () => openItem(entry.id)) + li.appendChild(button) + const kind = formKind(entry.typeName) + if (!entry.blocked && (kind === 'login' || kind === 'generic')) { + const copy = doc.createElement('button') + copy.type = 'button' + copy.className = 'link' + copy.textContent = 'Copy' + copy.setAttribute('aria-label', `Copy the password of ${entry.name}`) + copy.addEventListener('click', async () => { + const item = await send('vault-item', { id: entry.id }) + if (item.error) return showError('vault-status', item.error) + if (item.secret) await copyText(item.secret) + }) + li.appendChild(copy) + } + if (host && /^https?:\/\//i.test(entry.url)) { + const open = doc.createElement('button') + open.type = 'button' + open.className = 'link' + open.textContent = 'Open' + open.setAttribute('aria-label', `Open ${host}`) + open.addEventListener('click', () => + chrome.tabs.create({ url: entry.url }), + ) + li.appendChild(open) + } + return li + } + + /** + * Open an item's detail view, fetched fresh. + * + * @param {string} id The item id. + */ + async function openItem(id) { + listScroll = doc.body.scrollTop + showError('detail-error', '') + const item = await send('vault-item', { id }) + if (item.error) { + $('vault-status').textContent = item.error + return + } + current = item + $('detail-move-picker').hidden = true + renderDetail({ $, doc }, item, folders) + showView('vault-detail') + } + + /** + * The current draft, read from the form. + * + * @return {object} + */ + function readDraft() { + const kind = form.kind + const composite = {} + for (const field of compositeFields(kind)) { + composite[field] = $('edit-composite-' + field).value + } + const fields = [...$('edit-fields').querySelectorAll('.field-edit')].map( + (row) => ({ + name: row.querySelector('.field-name').value, + value: row.querySelector('.field-value').value, + }), + ) + return { + kind, + name: $('edit-name').value, + url: $('edit-url').value, + folderId: $('edit-folder').value || null, + login: $('edit-login').value, + secret: $('edit-secret').value, + notes: $('edit-notes').value, + composite, + fields, + } + } + + /** @return {boolean} Whether the form differs from how it opened. */ + const dirty = () => + form !== null + && Object.keys(changedParts(form.initialParts, partsFromDraft(readDraft()))) + .length > 0 + + /** + * Whether the user may leave the form: no changes, or they discard them. + * + * @return {boolean} + */ + function canLeave() { + return !dirty() || window.confirm(UNSAVED) + } + + /** + * Add one additional-field row. + * + * @param {string} name The field name. + * @param {string} value The field value. + */ + function addFieldRow(name = '', value = '') { + const row = doc.createElement('div') + row.className = 'field-edit row' + const nameInput = doc.createElement('input') + nameInput.className = 'field-name' + nameInput.placeholder = 'Field name' + nameInput.setAttribute('aria-label', 'Field name') + nameInput.maxLength = 4096 + nameInput.value = name + const valueInput = doc.createElement('input') + valueInput.className = 'field-value' + valueInput.type = 'password' + valueInput.setAttribute('aria-label', 'Field value') + valueInput.value = value + const show = doc.createElement('button') + show.type = 'button' + show.className = 'link' + show.textContent = 'Show' + show.setAttribute('aria-pressed', 'false') + show.addEventListener('click', () => { + const open = valueInput.type === 'text' + valueInput.type = open ? 'password' : 'text' + show.textContent = open ? 'Show' : 'Hide' + show.setAttribute('aria-pressed', open ? 'false' : 'true') + }) + const remove = doc.createElement('button') + remove.type = 'button' + remove.className = 'link danger' + remove.textContent = 'Remove' + remove.setAttribute('aria-label', 'Remove this field') + remove.addEventListener('click', () => row.remove()) + row.append(nameInput, valueInput, show, remove) + $('edit-fields').appendChild(row) + } + + /** + * Show the inputs for a form kind. + * + * @param {string} kind The form kind. + */ + function layoutFor(kind) { + $('edit-login-block').hidden = !(kind === 'login' || kind === 'generic') + $('edit-secret-block').hidden = !( + kind === 'login' + || kind === 'generic' + || kind === 'totp' + ) + $('edit-generate').hidden = kind !== 'login' && kind !== 'generic' + $('edit-secret-label').firstChild.textContent = + kind === 'totp' + ? 'Secret key or otpauth:// address' + : kind === 'login' + ? 'Password' + : 'Value' + const composite = $('edit-composite') + composite.replaceChildren() + for (const field of compositeFields(kind)) { + const label = doc.createElement('label') + label.textContent = COMPOSITE_LABELS[field] + const input = doc.createElement('input') + input.id = 'edit-composite-' + field + input.type = MASKED_COMPOSITE.includes(field) ? 'password' : 'text' + input.autocomplete = 'off' + input.maxLength = 4096 + label.appendChild(input) + composite.appendChild(label) + } + } + + /** + * Open the form for a new item, an edit or a clone. + * + * @param {object|null} item The decrypted item, or null for a new one. + * @param {{clone?: boolean}} [how] clone: prefill from item, save as new. + */ + function openEdit(item, { clone = false } = {}) { + showError('edit-error', '') + const creating = !item || clone + // A new item starts with the type picked in Settings. + const typeId = + item?.typeId + || types.find((t) => t.name === preferredType)?.id + || types.find((t) => t.name === 'login')?.id + || types[0]?.id + || '' + $('edit-title').textContent = clone + ? 'Clone item' + : item + ? 'Edit item' + : 'New item' + $('edit-type-label').hidden = !creating || clone + fillSelect( + $('edit-type'), + // Passkeys are created by the website that uses them. + types + .filter((t) => t.name !== 'passkey') + .map((t) => ({ value: t.id, label: t.name })), + doc, + ) + $('edit-type').value = typeId + const typeName = + types.find((t) => t.id === typeId)?.name || item?.typeName || 'login' + const draft = draftFromItem(item, typeName) + if (clone) draft.name += ' - Clone' + // A new item starts with the address of the site the popup is on. + const fresh = creating && !clone + if (fresh && !draft.url) draft.url = siteOrigin + fillEditFolders() + layoutFor(draft.kind) + $('edit-name').value = draft.name + $('edit-url').value = draft.url + $('edit-login').value = draft.login + $('edit-secret').value = draft.secret + $('edit-secret').type = 'password' + $('edit-notes').value = draft.notes + $('edit-folder').value = + draft.folderId + || (creating && !clone ? $('vault-folder').value : '') + || '' + for (const field of compositeFields(draft.kind)) { + $('edit-composite-' + field).value = draft.composite[field] || '' + } + $('edit-fields').replaceChildren() + for (const { name, value } of draft.fields) addFieldRow(name, value) + form = { + kind: draft.kind, + typeId, + typeName, + id: creating ? null : item.id, + // A clone or new item has nothing on the server yet: every part is new. + initialParts: creating + ? partsFromDraft({ + ...draftFromItem(null, typeName), + url: fresh ? siteOrigin : '', + }) + : partsFromDraft(draftFromItem(item, typeName)), + } + showView('vault-edit') + $('edit-name').focus() + } + + /** Fill the form's folder picker, with "New folder…" at the end. */ + function fillEditFolders() { + fillSelect( + $('edit-folder'), + [...folderOptions(), { value: NEW_FOLDER, label: 'New folder…' }], + doc, + ) + } + + /** Reload the folders only, keeping the form as it is. */ + async function reloadFolders() { + const result = await send('vault-list') + if (result.error) return + folders = result.folders || [] + fillEditFolders() + } + + /** Load the index, folders and types from the worker. */ + async function load() { + $('vault-status').textContent = 'Loading your vault…' + const result = await send('vault-list') + if (result.error) { + $('vault-status').textContent = result.error + return + } + index = result.items || [] + loaded = true + preferredType = (await send('extension-settings')).defaultType || 'login' + siteOrigin = (await currentSite?.()) || '' + folders = result.folders || [] + types = result.types || [] + webAppUrl = result.webAppUrl || '' + renderSync(result.sync) + fillSelect($('vault-folder'), folderOptions(), doc) + fillSelect( + $('vault-type'), + presentTypes(index).map((t) => ({ value: t, label: t })), + doc, + ) + renderList() + } + + for (const id of ['vault-search', 'vault-folder', 'vault-type']) { + $(id).addEventListener('input', renderList) + } + $('vault-clear').addEventListener('click', () => { + $('vault-search').value = '' + $('vault-folder').value = '' + $('vault-type').value = '' + renderList() + }) + const folderView = initFolders({ + $, + send, + showError, + getFolders: () => folders, + reload: load, + doc, + }) + $('vault-sync-now').addEventListener('click', async () => { + $('vault-sync').textContent = 'Syncing…' + await send('vault-sync-now') + await load() + }) + $('vault-folders-open').addEventListener('click', async () => { + showView('vault-folders') + await folderView.render() + }) + $('folders-back').addEventListener('click', () => showView('vault-browse')) + // The form's folder picker can make a folder on the spot. + $('edit-folder').addEventListener('change', async () => { + if ($('edit-folder').value !== NEW_FOLDER) return + const name = window.prompt('Name of the new folder') + if (!name) { + $('edit-folder').value = '' + return + } + const res = await send('folder-create', { name, parentId: null }) + if (res.error) { + $('edit-folder').value = '' + return showError('edit-error', res.error) + } + const keep = readDraft() + await reloadFolders() + $('edit-folder').value = res.id || '' + $('edit-name').value = keep.name + }) + $('vault-new').addEventListener('click', () => openEdit(null)) + $('detail-back').addEventListener('click', () => { + showView('vault-browse') + // Back where the user was in the list. + doc.body.scrollTop = listScroll + }) + $('detail-open-web').addEventListener('click', () => { + if (webAppUrl) chrome.tabs.create({ url: webAppUrl }) + }) + $('detail-edit').addEventListener('click', async () => { + if (!current) return + // Edit starts from the server's current value, not the open view. + const fresh = await send('vault-item', { id: current.id }) + if (fresh.error) return showError('detail-error', fresh.error) + current = fresh + openEdit(fresh) + }) + $('detail-clone').addEventListener( + 'click', + () => current && openEdit(current, { clone: true }), + ) + $('detail-send').addEventListener('click', () => current && sendItem(current)) + $('detail-move').addEventListener('click', () => { + fillSelect($('detail-move-folder'), folderOptions(), doc) + $('detail-move-folder').value = current?.folderId || '' + $('detail-move-picker').hidden = false + }) + $('detail-move-cancel').addEventListener('click', () => { + $('detail-move-picker').hidden = true + }) + $('detail-move-save').addEventListener('click', async () => { + const res = await send('vault-move', { + id: current.id, + folderId: $('detail-move-folder').value || null, + }) + if (res.error) return showError('detail-error', res.error) + const id = current.id + await load() + await openItem(id) + }) + $('detail-delete').addEventListener('click', async () => { + if (!current) return + // The browser's own confirm: a page cannot click it. + if ( + !window.confirm( + `Move "${current.name}" to the trash? You can restore it in Keepiq in Nextcloud.`, + ) + ) { + return + } + const res = await send('vault-trash', { id: current.id }) + if (res.error) return showError('detail-error', res.error) + showView('vault-browse') + await load() + }) + $('edit-type').addEventListener('change', () => { + const typeName = + types.find((t) => t.id === $('edit-type').value)?.name || 'login' + form.kind = formKind(typeName) + form.typeId = $('edit-type').value + form.typeName = typeName + form.initialParts = partsFromDraft(draftFromItem(null, typeName)) + layoutFor(form.kind) + }) + $('edit-field-add').addEventListener('click', () => addFieldRow()) + $('edit-generate').addEventListener('click', () => + pickGenerated('password', (value) => { + $('edit-secret').value = value + $('edit-secret').type = 'text' + }), + ) + $('edit-generate-username').addEventListener('click', () => + pickGenerated('username', (value) => { + $('edit-login').value = value + }), + ) + $('edit-cancel').addEventListener('click', () => { + if (!canLeave()) return + form = null + if (current) { + renderDetail({ $, doc }, current, folders) + showView('vault-detail') + } else { + showView('vault-browse') + } + }) + $('vault-edit').addEventListener('submit', async (event) => { + event.preventDefault() + showError('edit-error', '') + const draft = readDraft() + const errors = validateDraft(draft) + if (Object.keys(errors).length > 0) { + showError('edit-error', Object.values(errors)[0]) + return + } + const parts = partsFromDraft(draft) + const changes = form.id ? changedParts(form.initialParts, parts) : parts + if (form.id && Object.keys(changes).length === 0) { + form = null + renderDetail({ $, doc }, current, folders) + return showView('vault-detail') + } + const res = await send('vault-save', { + id: form.id || undefined, + typeId: form.typeId, + typeName: form.typeName, + changes, + }) + // On an error the form keeps everything the user typed. + if (res.error) return showError('edit-error', res.error) + $('edit-secret').type = 'password' + form = null + showView('vault-browse') + await load() + const saved = res.id + if (saved) await openItem(saved) + }) + + return { + async open() { + showView('vault-browse') + await load() + }, + canLeave, + + /** + * Drop everything this view holds of the vault: the open item, the + * form, the list. Called when the worker locks. + * + * @spec openspec/specs/extension-lock/spec.md#requirement-the-popup-forgets-the-vault-when-it-locks + */ + forget() { + current = null + form = null + index = [] + loaded = false + clearDetail({ $ }) + for (const el of $('vault-edit').querySelectorAll('input, textarea')) { + if (el.type !== 'checkbox' && el.type !== 'radio') el.value = '' + } + $('edit-fields').replaceChildren() + $('vault-list').replaceChildren() + showView('vault-browse') + }, + } +} diff --git a/browser-extension/src/popup/views.js b/browser-extension/src/popup/views.js new file mode 100644 index 000000000..176bbc1e8 --- /dev/null +++ b/browser-extension/src/popup/views.js @@ -0,0 +1,115 @@ +/** + * Pure render helpers for the popup (no worker calls, no globals), so the + * account switcher and the idle settings render the same in the popup and in + * tests. + */ + +/** + * Fill the account switcher: one option per account with its lock state, the + * active one selected. + * + * @param {HTMLSelectElement} select The switcher. + * @param {object} state The worker's get-state answer. + * @return {void} + * @spec openspec/specs/extension-pairing/spec.md#requirement-a-revoked-app-password-signs-the-account-out + */ +export function renderAccountSwitcher(select, state) { + const doc = select.ownerDocument + select.textContent = '' + for (const account of state.accounts || []) { + const option = doc.createElement('option') + option.value = account.id + const name = account.label || account.user + '@' + account.host + option.textContent = + name + + (account.loggedOut + ? ' (signed out)' + : account.unlocked + ? '' + : ' (locked)') + option.selected = account.id === state.activeAccountId + select.appendChild(option) + } +} + +/** + * Whether another account can still be added. + * + * @param {object} state The worker's get-state answer. + * @return {boolean} + */ +export function canAddAccount(state) { + return (state.accounts || []).length < (state.maxAccounts || 5) +} + +/** + * A human label for an idle delay. + * + * @param {number} minutes The delay. + * @return {string} + */ +export function idleLabel(minutes) { + if (minutes === 1) return '1 minute' + if (minutes < 60) return minutes + ' minutes' + if (minutes === 60) return '1 hour' + return minutes / 60 + ' hours' +} + +/** + * Render the idle delay choices as radio buttons. A delay above the + * organisation's maximum is shown, disabled, with the reason. + * + * @param {HTMLElement} container Where the choices go. + * @param {{idleChoices: number[], idleMinutes: number, maxIdleMinutes: number|null}} state The active account's settings. + * @param {function(number): void} onPick Called with the picked delay. + * @return {void} + */ +export function renderIdleChoices(container, state, onPick) { + const doc = container.ownerDocument + container.textContent = '' + const max = state.maxIdleMinutes + for (const minutes of state.idleChoices || []) { + const label = doc.createElement('label') + label.className = 'idle-choice' + const input = doc.createElement('input') + input.type = 'radio' + input.name = 'idle-minutes' + input.value = String(minutes) + input.checked = minutes === state.idleMinutes + const tooLong = typeof max === 'number' && minutes > max + input.disabled = tooLong + input.addEventListener('change', () => { + if (input.checked) onPick(minutes) + }) + label.appendChild(input) + label.appendChild(doc.createTextNode(' ' + idleLabel(minutes))) + if (tooLong) { + const note = doc.createElement('span') + note.className = 'hint' + note.textContent = " (above your organisation's maximum)" + label.appendChild(note) + } + container.appendChild(label) + } + if (typeof max === 'number' && state.idleMinutes > max) { + const note = doc.createElement('p') + note.className = 'hint' + note.textContent = + 'Your organisation locks the extension after ' + + idleLabel(max) + + ' at most.' + container.appendChild(note) + } +} + +/** + * What the popup says while it waits for approval from another device, per + * request status. + */ +export const DEVICE_STATUS_TEXT = Object.freeze({ + pending: 'Waiting for approval…', + denied: 'The request was denied on your other device.', + expired: 'The request expired. Start again.', + consumed: 'This request was already used. Start again.', + none: 'The request is gone, for example after the browser restarted. Start again.', +}) diff --git a/browser-extension/src/unlock/ceremony.js b/browser-extension/src/unlock/ceremony.js new file mode 100644 index 000000000..caad3edb4 --- /dev/null +++ b/browser-extension/src/unlock/ceremony.js @@ -0,0 +1,229 @@ +/** + * The extension's own passkey ceremonies (extension-biometric-unlock), run in + * the unlock window, an extension page: the OS fingerprint or face prompt takes + * focus and would close the popup. + * + * The recipe is the web app's (`src/store/modules/passkey.js`), on the same + * crypto modules: the raw unlock key is derived from the master password and + * the suite envelope's salt, wrapped under a key derived from the WebAuthn PRF + * output, and only the wrapped key, the PRF salt and the credential metadata go + * to the server. The relying party is the extension's own origin and user + * verification is required. + * + * Every dependency is passed in (`credentials` is `navigator.credentials`, + * `send` messages the worker), so the ceremonies run against a fake + * authenticator in tests. + */ + +import { + decodeEnvelope, + decryptPrivateKeyWithRawKey, + deriveKekFromPrf, + deriveUnlockKeyRaw, + fromBase64Url, + toBase64Url, + unwrapUnlockKey, + wrapUnlockKey, +} from '../crypto/index.js' + +/** Raised when the browser or authenticator cannot do a PRF passkey. */ +export class BiometricUnavailable extends Error {} + +/** + * Whether this page can offer fingerprint or face unlock at all: WebAuthn is + * exposed and a user-verifying platform authenticator is present. PRF support + * is only known after an enrolment (D5). + * + * @param {object} win The window (globalThis in a page). + * @return {Promise} + */ +export async function platformAuthenticatorAvailable(win = globalThis) { + const pkc = win.PublicKeyCredential + if ( + !pkc + || typeof pkc.isUserVerifyingPlatformAuthenticatorAvailable !== 'function' + ) { + return false + } + try { + return (await pkc.isUserVerifyingPlatformAuthenticatorAvailable()) === true + } catch { + return false + } +} + +function challengeBytes(challenge) { + return fromBase64Url(String(challenge).replace(/\+/g, '-').replace(/\//g, '_')) +} + +function toBase64(bytes) { + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s) +} + +/** + * Worker answers carry `error` instead of throwing; turn one into a throw. + * + * @param {object} res The worker answer. + * @return {object} The answer. + */ +function unwrap(res) { + if (!res || res.error) throw new Error(res?.error || 'no answer from Keepiq') + return res +} + +/** + * Enrol a platform passkey for one (unlocked) account. + * + * @param {object} deps The dependencies. + * @param {object} deps.credentials navigator.credentials. + * @param {function(string, object): Promise} deps.send Messages the worker. + * @param {string} deps.accountId The account. + * @param {string} deps.masterPassword The master password (used only here). + * @param {string} deps.label A name for the passkey. + * @return {Promise} The stored credential. + */ +export async function enrolBiometric({ + credentials, + send, + accountId, + masterPassword, + label, +}) { + const ctx = unwrap(await send('biometric-enrol-context', { accountId })) + + // The raw unlock key, checked against the envelope before any prompt. + const { salt } = decodeEnvelope(ctx.envelope) + const rawUnlockKey = await deriveUnlockKeyRaw(masterPassword, salt) + try { + await decryptPrivateKeyWithRawKey(ctx.envelope, rawUnlockKey) + } catch { + rawUnlockKey.fill(0) + throw new Error('That master password is not correct.') + } + + try { + const challenge = challengeBytes(ctx.challenge) + const created = await credentials.create({ + publicKey: { + rp: { id: ctx.rpId, name: 'Keepiq' }, + user: { + id: new TextEncoder().encode(ctx.user || 'keepiq-user'), + name: ctx.user || 'Keepiq', + displayName: ctx.user || 'Keepiq', + }, + challenge, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + residentKey: 'preferred', + userVerification: 'required', + }, + extensions: { prf: {} }, + }, + }) + if (!created?.getClientExtensionResults?.()?.prf?.enabled) { + throw new BiometricUnavailable( + 'This device cannot unlock Keepiq with a fingerprint or face.', + ) + } + + const prfSalt = crypto.getRandomValues(new Uint8Array(32)) + const assertion = await credentials.get({ + publicKey: { + challenge, + rpId: ctx.rpId, + allowCredentials: [{ type: 'public-key', id: created.rawId }], + userVerification: 'required', + extensions: { prf: { eval: { first: prfSalt } } }, + }, + }) + const prfOutput = + assertion?.getClientExtensionResults?.()?.prf?.results?.first + if (!prfOutput) { + throw new BiometricUnavailable( + 'This device cannot unlock Keepiq with a fingerprint or face.', + ) + } + + const credentialId = toBase64Url(created.rawId) + const kek = await deriveKekFromPrf(prfOutput, credentialId) + const wrappedUnlockKey = await wrapUnlockKey(kek, rawUnlockKey) + return unwrap( + await send('biometric-enrol', { + accountId, + body: { + credentialId, + wrappedUnlockKey, + prfSalt: toBase64(prfSalt), + label: label || 'Browser extension', + transports: (created.response?.getTransports?.() || []).join( + ',', + ), + }, + }), + ) + } finally { + rawUnlockKey.fill(0) + } +} + +/** + * Unlock one account with its enrolled passkey: the PRF output unwraps the + * raw unlock key here, which goes to the worker over extension messaging. + * + * @param {object} deps The dependencies. + * @param {object} deps.credentials navigator.credentials. + * @param {function(string, object): Promise} deps.send Messages the worker. + * @param {string} deps.accountId The account. + * @return {Promise} True when unlocked. + */ +export async function unlockWithBiometric({ credentials, send, accountId }) { + const options = unwrap(await send('biometric-options', { accountId })) + if (!options.credentials?.length) { + throw new BiometricUnavailable('No fingerprint or face unlock is set up.') + } + const salts = {} + for (const c of options.credentials) { + salts[toBase64Url(fromBase64Url(c.credentialId))] = { + first: Uint8Array.from(atob(c.prfSalt), (ch) => ch.charCodeAt(0)), + } + } + const assertion = await credentials.get({ + publicKey: { + challenge: challengeBytes(options.challenge), + rpId: options.rpId, + allowCredentials: options.credentials.map((c) => ({ + type: 'public-key', + id: fromBase64Url(c.credentialId), + })), + userVerification: 'required', + extensions: { prf: { evalByCredential: salts } }, + }, + }) + const usedId = toBase64Url(assertion.rawId) + const cred = options.credentials.find( + (c) => toBase64Url(fromBase64Url(c.credentialId)) === usedId, + ) + const prfOutput = assertion.getClientExtensionResults?.()?.prf?.results?.first + if (!cred || !prfOutput) throw new Error('The passkey did not unlock Keepiq.') + + const kek = await deriveKekFromPrf(prfOutput, cred.credentialId) + const rawUnlockKey = await unwrapUnlockKey(kek, cred.wrappedUnlockKey) + try { + unwrap( + await send('unlock-raw', { + accountId, + rawKey: Array.from(rawUnlockKey), + }), + ) + } finally { + rawUnlockKey.fill(0) + } + await send('biometric-used', { accountId, id: cred.id }) + return true +} diff --git a/browser-extension/src/unlock/unlock.html b/browser-extension/src/unlock/unlock.html new file mode 100644 index 000000000..53bea8ba7 --- /dev/null +++ b/browser-extension/src/unlock/unlock.html @@ -0,0 +1,27 @@ + + + + + Keepiq unlock + + + +
+ + + + +
+ + + diff --git a/browser-extension/src/unlock/unlock.js b/browser-extension/src/unlock/unlock.js new file mode 100644 index 000000000..ee5315e2a --- /dev/null +++ b/browser-extension/src/unlock/unlock.js @@ -0,0 +1,67 @@ +/** + * The unlock window (extension-biometric-unlock §D3): an extension page that + * runs the passkey ceremony for one account, because the OS prompt takes focus + * and would close the popup. `?mode=unlock` unlocks with an enrolled passkey, + * `?mode=enrol` sets one up. The window closes after a successful handoff. + */ + +import { enrolBiometric, unlockWithBiometric } from './ceremony.js' + +const params = new URLSearchParams(location.search) +const mode = params.get('mode') === 'enrol' ? 'enrol' : 'unlock' +const accountId = params.get('account') || '' + +function $(id) { + return document.getElementById(id) +} + +function send(type, payload) { + return new Promise((resolve) => { + chrome.runtime.sendMessage({ type, payload }, (res) => resolve(res || {})) + }) +} + +function showError(message) { + $('unlock-error').textContent = message + $('unlock-error').hidden = !message +} + +async function runUnlock() { + $('view-unlock').hidden = false + try { + await unlockWithBiometric({ + credentials: navigator.credentials, + send, + accountId, + }) + window.close() + } catch (e) { + showError(e.message || String(e)) + } +} + +function wireEnrol() { + $('view-enrol').hidden = false + $('enrol-submit').addEventListener('click', async () => { + showError('') + const masterPassword = $('enrol-master').value + $('enrol-master').value = '' + try { + await enrolBiometric({ + credentials: navigator.credentials, + send, + accountId, + masterPassword, + label: $('enrol-label').value.trim(), + }) + window.close() + } catch (e) { + showError(e.message || String(e)) + } + }) +} + +$('unlock-close').addEventListener('click', () => window.close()) + +if (mode === 'enrol') wireEnrol() +else runUnlock() diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/cli/Dockerfile b/cli/Dockerfile new file mode 100644 index 000000000..0138b97bf --- /dev/null +++ b/cli/Dockerfile @@ -0,0 +1,8 @@ +# ghcr.io/conductionnl/keepiq-cli: the static keepiq binary on a distroless base. +# Built by .github/workflows/cli-release.yml from the release binaries in +# cli/dist/ (keepiq-linux-amd64, keepiq-linux-arm64), so the image runs exactly +# the bytes listed in the release's SHA256SUMS. +FROM gcr.io/distroless/static-debian12:nonroot +ARG TARGETARCH +COPY --chmod=0755 dist/keepiq-linux-${TARGETARCH} /usr/local/bin/keepiq +ENTRYPOINT ["/usr/local/bin/keepiq"] diff --git a/cli/README.md b/cli/README.md index 2c3f34b9e..ab5ebecb3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,6 @@ # keepiq-cli -A single static binary, stdlib-only Go client for [Keepiq](../), the +A single static binary, pure Go client for [Keepiq](../), the zero-knowledge Nextcloud secrets manager. It talks to the **same** server surfaces the browser app and the openconnector machine consumer already use — nothing new server-side — and does **all** decryption client-side, so no @@ -66,6 +66,110 @@ is touched. disk and never sent in any request — human mode holds them in memory only, for the duration of a single command. +## SSH agent + +`keepiq ssh-agent` serves the SSH keys in your vault to `ssh`, `git` and +`scp`. It works on Linux and macOS. On Windows, run it in WSL for now. + +The agent keeps every secret of the type **SSH Key** whose private key opens +without a passphrase. It skips a key that needs a passphrase and names it on +standard error. Each key shows up under its secret name. + +Start it and point your shell at it: + +```sh +eval "$(keepiq ssh-agent)" +ssh-add -l # lists your vault keys +git clone git@github.com:example/repo.git +``` + +The agent asks for your master password once and decrypts the keys in its own +memory. Nothing decrypted is written to disk, core dumps are off, and the +server sees the same reads `keepiq show` makes. + +Under `eval` the agent moves to the background and your shell gets its prompt +back. It also exports `SSH_AGENT_PID`, so `kill $SSH_AGENT_PID` stops it. Run +`keepiq ssh-agent` straight in a terminal and it stays in the foreground until +you press Ctrl+C. + +Options: + +- `--socket `: where to listen. The default is + `$XDG_RUNTIME_DIR/keepiq/agent.sock` on Linux and + `$TMPDIR/keepiq-/agent.sock` on macOS. The folder must be yours with + mode 0700; the agent refuses anything else. +- `--folder `: offer only the keys in that folder. +- `--idle `: drop every key after this long without a signature + (60 by default, 0 turns it off). +- `--confirm`: ask before each signature through the program in + `SSH_ASKPASS`. Without `SSH_ASKPASS` the agent does not start. +- `--foreground`: never move to the background, even when the output is not a + terminal. A service manager needs this: systemd sends the output to its + journal, and without `--foreground` the agent would leave the unit, which + systemd then stops. +- `--locked`: start without keys, for a service manager. Unlock it with + `ssh-add -X` and your master password. Lock it again with `ssh-add -x`: + it asks for a lock password, which the agent ignores, because unlocking + always takes your master password. + +The vault is the only place keys come from: `ssh-add some_key` and +`ssh-add -d` are refused. RSA keys sign with SHA-2 only; a client that asks +for an old `ssh-rsa` (SHA-1) signature is refused. + +A connection from another user on the same machine is closed before it can +ask anything. + +### Run it as a service + +systemd user unit, `~/.config/systemd/user/keepiq-agent.service`: + +```ini +[Unit] +Description=Keepiq SSH agent + +[Service] +ExecStart=%h/.local/bin/keepiq ssh-agent --locked --foreground --socket %t/keepiq/agent.sock +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +```sh +systemctl --user enable --now keepiq-agent +export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/keepiq/agent.sock" +ssh-add -X # enter your master password +``` + +launchd agent, `~/Library/LaunchAgents/nl.conduction.keepiq-agent.plist`: + +```xml + + + + + Labelnl.conduction.keepiq-agent + ProgramArguments + + /usr/local/bin/keepiq + ssh-agent + --locked + --socket + /Users/YOU/.keepiq/agent.sock + + RunAtLoad + KeepAlive + + +``` + +```sh +mkdir -m 700 ~/.keepiq +launchctl load ~/Library/LaunchAgents/nl.conduction.keepiq-agent.plist +export SSH_AUTH_SOCK=~/.keepiq/agent.sock +ssh-add -X +``` + ## CI mode Non-interactive machine use. An RFC 7523 consumer that holds the **application @@ -86,6 +190,12 @@ keepiq ci run DB_PASSWORD,API_TOKEN -- ./migrate.sh # injected into the ch — nothing is written to disk and the parent environment is untouched. The `--output env` form prints an `export` line and warns on stderr that exporting a secret into the shell environment exposes it to sibling child processes. +The wrapped command does not get `KEEPIQ_APP_KEY`: it receives its secrets, not +the key that reads every secret of the application. + +`keepiq install ` copies the binary to a path. The container image +`ghcr.io/conductionnl/keepiq-cli` has no shell, so an init container uses this +to put `keepiq` into a shared volume (see `integrations/kubernetes/`). ### Leases @@ -96,18 +206,18 @@ omits lease reporting. ## Crypto parity -The `internal/crypto` package reimplements the browser recipe **byte-for-byte**: +The `sdk/go/crypto` package (shared with the client libraries, see `../sdk/`) reimplements the browser recipe **byte-for-byte**: - **Private-key blob** (human unlock): base64 of `[4B version][16B salt][12B - IV][ciphertext+16B GCM tag]`. The unlock key is +IV][ciphertext+16B GCM tag]`. The unlock key is `PBKDF2-HMAC-SHA256(masterPassword, salt, 600000)` → AES-256-GCM. - **Secret fields** (`rsa-oaep-sha256-chunked-v1`): base64 of `[4B chunk count - BE][512B RSA-OAEP-SHA256 blocks…]`, each block decrypted with the suite's +BE][512B RSA-OAEP-SHA256 blocks…]`, each block decrypted with the suite's RSA-4096 private key and concatenated. PBKDF2 is implemented in-house over `crypto/hmac` (RFC 8018) so the CLI has zero external dependencies. Byte-parity is pinned by the RFC 6070 test vectors in -`internal/crypto/crypto_test.go`. +`../sdk/go/crypto/crypto_test.go`. ```sh go test ./... diff --git a/cli/ci.go b/cli/ci.go index 2e7b3b2db..e11157f33 100644 --- a/cli/ci.go +++ b/cli/ci.go @@ -5,24 +5,21 @@ import ( "fmt" "os" "strings" - "time" - "crypto/rsa" - "github.com/ConductionNL/keepiq/cli/internal/client" - - dcrypto "github.com/ConductionNL/keepiq/cli/internal/crypto" + keepiq "github.com/ConductionNL/keepiq/sdk/go" ) // ciSetup loads the CI-mode inputs: the instance URL (KEEPIQ_URL), the // application id (KEEPIQ_APP_ID), and the application private key — supplied by // env (KEEPIQ_APP_KEY, a PEM) or file (KEEPIQ_APP_KEY_FILE), the operator's own -// credential Keepiq never stores (§4.1). It self-configures from discovery and -// exchanges an RFC 7523 assertion for a bearer token. -func ciSetup() (c *client.Client, key *rsa.PrivateKey, disc *client.Discovery, bearer, appID string, err error) { +// credential Keepiq never stores (§4.1). The returned client is the Go library +// (sdk/go): it self-configures from discovery and exchanges an RFC 7523 +// assertion for a bearer token on first use. +func ciSetup() (*keepiq.Client, error) { url := os.Getenv("KEEPIQ_URL") - appID = os.Getenv("KEEPIQ_APP_ID") + appID := os.Getenv("KEEPIQ_APP_ID") if url == "" || appID == "" { - return nil, nil, nil, "", "", fmt.Errorf("set KEEPIQ_URL and KEEPIQ_APP_ID") + return nil, fmt.Errorf("set KEEPIQ_URL and KEEPIQ_APP_ID") } pemStr := os.Getenv("KEEPIQ_APP_KEY") @@ -30,43 +27,22 @@ func ciSetup() (c *client.Client, key *rsa.PrivateKey, disc *client.Discovery, b if f := os.Getenv("KEEPIQ_APP_KEY_FILE"); f != "" { data, rerr := os.ReadFile(f) if rerr != nil { - return nil, nil, nil, "", "", rerr + return nil, rerr } pemStr = string(data) } } if pemStr == "" { - return nil, nil, nil, "", "", fmt.Errorf("set KEEPIQ_APP_KEY (PEM) or KEEPIQ_APP_KEY_FILE") - } - pk, perr := dcrypto.ParsePrivateKey(pemStr) - if perr != nil { - return nil, nil, nil, "", "", perr - } - - c = client.New(url) - disc, err = c.Discover() - if err != nil { - return nil, nil, nil, "", "", err + return nil, fmt.Errorf("set KEEPIQ_APP_KEY (PEM) or KEEPIQ_APP_KEY_FILE") } - bearer, err = c.MachineToken(appID, pk, disc, time.Now().Unix()) - if err != nil { - return nil, nil, nil, "", "", err - } - return c, pk, disc, bearer, appID, nil + return keepiq.New(url, appID, pemStr) } // fetchDecrypt fetches an application secret by name and decrypts its envelope -// 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.Encryption.Scheme != "rsa-oaep-sha256-chunked-v1" { - return "", fmt.Errorf("unexpected envelope scheme %q", env.Encryption.Scheme) - } - return dcrypto.DecryptField(env.Ciphertext.Key, key) +// with the application private key (§4.2), in this process. The library refuses +// any scheme other than rsa-oaep-sha256-chunked-v1 before decrypting. +func fetchDecrypt(c *keepiq.Client, name string) (*keepiq.Secret, error) { + return c.GetByName(name, "") } func cmdCIFetch(args []string) error { @@ -74,16 +50,17 @@ func cmdCIFetch(args []string) error { if len(args) < 1 { return fmt.Errorf("usage: keepiq ci fetch [--output env|json]") } - c, key, _, bearer, _, err := ciSetup() + c, err := ciSetup() if err != nil { return err } - value, err := fetchDecrypt(c, key, args[0], bearer) + secret, err := fetchDecrypt(c, args[0]) if err != nil { return err } - if lease := c.LeaseID(); lease != "" { - fmt.Fprintf(os.Stderr, "lease %s expires %s\n", lease, c.LeaseExpires()) + value := secret.Key + if secret.Lease != nil { + fmt.Fprintf(os.Stderr, "lease %s expires %s\n", secret.Lease.ID, secret.Lease.Expires) } switch output { case "json": @@ -110,17 +87,17 @@ func cmdCIRun(args []string) error { names := strings.Split(args[0], ",") cmd := args[sep+1:] - c, key, _, bearer, _, err := ciSetup() + c, err := ciSetup() if err != nil { return err } var env []string for _, name := range names { - value, ferr := fetchDecrypt(c, key, strings.TrimSpace(name), bearer) + secret, ferr := fetchDecrypt(c, strings.TrimSpace(name)) if ferr != nil { return ferr } - env = append(env, envName(name)+"="+value) + env = append(env, envName(name)+"="+secret.Key) } // Inject into the child environment ONLY — no plaintext to disk (§4.3). return runChild(env, cmd) diff --git a/cli/ci_test.go b/cli/ci_test.go index d0a75d5cf..7cdc2f613 100644 --- a/cli/ci_test.go +++ b/cli/ci_test.go @@ -10,11 +10,10 @@ import ( "strings" "testing" - "github.com/ConductionNL/keepiq/cli/internal/client" - dcrypto "github.com/ConductionNL/keepiq/cli/internal/crypto" + keepiq "github.com/ConductionNL/keepiq/sdk/go" ) -// machineFixture is testdata/machine_envelope.json: an envelope written by the +// machineFixture is sdk/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() @@ -27,7 +26,7 @@ type machineFixture struct { func loadMachineFixture(t *testing.T) machineFixture { t.Helper() - raw, err := os.ReadFile("testdata/machine_envelope.json") + raw, err := os.ReadFile("../sdk/testdata/machine_envelope.json") if err != nil { t.Fatal(err) } @@ -36,7 +35,7 @@ func loadMachineFixture(t *testing.T) machineFixture { 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") + t.Fatal("sdk/testdata/machine_envelope.json is missing envelope, privateKeyPem or plaintext.key") } return f } @@ -46,8 +45,8 @@ func loadMachineFixture(t *testing.T) machineFixture { 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/.well-known/keepiq", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"apiVersion":1,"tokenEndpoint":"/apps/keepiq/api/v1/app/token","assertion":{"alg":"RS256","audience":"keepiq"},"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"}`)) @@ -72,21 +71,19 @@ func stubKeepiq(t *testing.T, envelope []byte) *httptest.Server { func TestFetchDecryptRealServerEnvelope(t *testing.T) { f := loadMachineFixture(t) srv := stubKeepiq(t, f.Envelope) - key, err := dcrypto.ParsePrivateKey(f.PrivateKeyPem) + c, err := keepiq.New(srv.URL, "app-cli-fixture", f.PrivateKeyPem) if err != nil { t.Fatal(err) } - - c := client.New(srv.URL) - got, err := fetchDecrypt(c, key, "ci-fixture-db-password", "tok") + got, err := fetchDecrypt(c, "ci-fixture-db-password") if err != nil { t.Fatalf("fetchDecrypt: %v", err) } - if got != f.Plaintext["key"] { - t.Fatalf("value = %q, want %q", got, f.Plaintext["key"]) + if got.Key != f.Plaintext["key"] { + t.Fatalf("value = %q, want %q", got.Key, f.Plaintext["key"]) } - if c.LeaseID() != "lease-7" { - t.Fatalf("lease id = %q, want lease-7", c.LeaseID()) + if got.Lease == nil || got.Lease.ID != "lease-7" { + t.Fatalf("lease = %+v, want lease-7", got.Lease) } } @@ -101,13 +98,12 @@ func TestFetchDecryptRefusesAnUnknownScheme(t *testing.T) { env["encryption"].(map[string]any)["scheme"] = "rsa-oaep-sha1-v0" body, _ := json.Marshal(env) srv := stubKeepiq(t, body) - key, err := dcrypto.ParsePrivateKey(f.PrivateKeyPem) + c, err := keepiq.New(srv.URL, "app-cli-fixture", 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"`) { + _, err = fetchDecrypt(c, "ci-fixture-db-password") + if err == nil || !strings.Contains(err.Error(), `unsupported encryption scheme "rsa-oaep-sha1-v0"`) { t.Fatalf("want an unexpected scheme error, got %v", err) } } @@ -168,3 +164,13 @@ func captureOutput(t *testing.T, fn func() error) (string, string, error) { <-done return outBuf.String(), errBuf.String(), runErr } + +// TestChildEnvironDropsTheApplicationKey: `keepiq ci run` hands the wrapped +// command its secrets, not the application private key. +func TestChildEnvironDropsTheApplicationKey(t *testing.T) { + got := childEnviron([]string{"PATH=/bin", "KEEPIQ_APP_KEY=-----BEGIN PRIVATE KEY-----", "KEEPIQ_URL=https://x", "KEEPIQ_APP_KEY_FILE=/run/k.pem"}) + want := []string{"PATH=/bin", "KEEPIQ_URL=https://x", "KEEPIQ_APP_KEY_FILE=/run/k.pem"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("child env = %v", got) + } +} diff --git a/cli/clipboard.go b/cli/clipboard.go index 27f87ad1e..1ea632cbb 100644 --- a/cli/clipboard.go +++ b/cli/clipboard.go @@ -55,6 +55,9 @@ func cmdCopy(args []string) error { if err != nil { return err } + if err := refuseUseOnly(s, args[1]); err != nil { + return err + } fields := decryptSecret(s, session) v, ok := fields[args[1]] if !ok { diff --git a/cli/go.mod b/cli/go.mod index 67b95763d..383a7a965 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -1,3 +1,15 @@ module github.com/ConductionNL/keepiq/cli -go 1.22 +go 1.25.0 + +require ( + github.com/ConductionNL/keepiq/sdk/go v0.0.0 + golang.org/x/crypto v0.52.0 + golang.org/x/sys v0.45.0 +) + +// The CLI builds against the library in this repository, so a change to +// sdk/go is tested by the CLI in the same pull request. sdk/go stays stdlib +// only; the CLI adds golang.org/x/crypto and golang.org/x/sys for its SSH +// agent (cli-ssh-agent). +replace github.com/ConductionNL/keepiq/sdk/go => ../sdk/go diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 000000000..3f17939a3 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= diff --git a/cli/install.go b/cli/install.go new file mode 100644 index 000000000..2ac5429b9 --- /dev/null +++ b/cli/install.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// cmdInstall copies this binary to a path, so an init container from the +// distroless CLI image (which has no shell and no cp) can put keepiq into a +// shared volume for the app container's `keepiq ci run` wrapper. +func cmdInstall(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: keepiq install ") + } + self, err := os.Executable() + if err != nil { + return err + } + return copyExecutable(self, args[0]) +} + +func copyExecutable(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + tmp := dst + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, dst) +} diff --git a/cli/install_test.go b/cli/install_test.go new file mode 100644 index 000000000..b8dc6514f --- /dev/null +++ b/cli/install_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCopyExecutable: `keepiq install ` writes an identical, executable +// copy, creating the directory. +func TestCopyExecutable(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + if err := os.WriteFile(src, []byte("binary bytes"), 0o700); err != nil { + t.Fatal(err) + } + dst := filepath.Join(dir, "shared", "keepiq") + if err := copyExecutable(src, dst); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != "binary bytes" { + t.Fatalf("copy = %q, %v", got, err) + } + if fi, _ := os.Stat(dst); fi.Mode().Perm()&0o111 == 0 { + t.Fatalf("copy is not executable: %v", fi.Mode()) + } + if err := cmdInstall(nil); err == nil { + t.Fatal("install without a path must fail") + } +} diff --git a/cli/internal/client/client_test.go b/cli/internal/client/client_test.go deleted file mode 100644 index 11744e113..000000000 --- a/cli/internal/client/client_test.go +++ /dev/null @@ -1,82 +0,0 @@ -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 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")) - } - if r.Header.Get("If-None-Match") == etag { - w.Header().Set("ETag", etag) - w.WriteHeader(http.StatusNotModified) - return - } - w.Header().Set("ETag", etag) - 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(body) - })) - defer srv.Close() - - c := New(srv.URL) - - env, err := c.FetchByName("DB_PASSWORD", "tok") - if err != nil { - t.Fatalf("first fetch: %v", err) - } - 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()) - } - if c.LastETag() != etag { - t.Fatalf("etag = %q", c.LastETag()) - } - - // Unchanged re-fetch → 304 → ErrNotModified. - if _, err := c.FetchByName("DB_PASSWORD", "tok"); !errors.Is(err, ErrNotModified) { - t.Fatalf("second fetch: want ErrNotModified, got %v", err) - } -} diff --git a/cli/internal/fakevault/main.go b/cli/internal/fakevault/main.go new file mode 100644 index 000000000..2601bdaf5 --- /dev/null +++ b/cli/internal/fakevault/main.go @@ -0,0 +1,181 @@ +// Command fakevault is a throwaway Keepiq that serves one user's vault over +// the human session API, so `keepiq login` and `keepiq ssh-agent` can be run +// end to end without a Nextcloud server. TEST ONLY: it is not part of the +// released CLI (the release builds the cli/ package alone). +// +// It generates a fresh RSA-4096 encryption suite, wraps the suite's private +// key with the master password exactly as the browser does (PBKDF2-SHA256, +// AES-256-GCM, envelope version 1), and files the given OpenSSH private key +// as an `ssh_key` secret encrypted to the suite key, next to one login secret +// the agent must ignore. It answers only with ciphertext, checks the +// app-password on every request, and logs each request on standard error. +// +// Used by cli/scripts/ssh-agent-e2e.sh (keepiq#1042). +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "flag" + "log" + "math/big" + "net" + "net/http" + "os" + "strconv" + "time" + + dcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" +) + +const apiBase = "/apps/keepiq/api/v1" + +type secret struct { + ID string `json:"id"` + Name string `json:"name"` + TypeID string `json:"typeId"` + FolderID string `json:"folderId"` + Key string `json:"key"` +} + +func main() { + listen := flag.String("listen", "127.0.0.1:0", "address to listen on") + user := flag.String("user", "alice", "Nextcloud user") + appPassword := flag.String("app-password", "", "app-password the client must send") + master := flag.String("master", "", "master password that unlocks the suite") + sshKeyFile := flag.String("ssh-key", "", "OpenSSH private key (no passphrase) to file as an ssh_key secret") + name := flag.String("name", "e2e deploy key", "name of the ssh_key secret") + urlFile := flag.String("url-file", "", "write the base URL here once listening") + flag.Parse() + log.SetFlags(0) + if *appPassword == "" || *master == "" || *sshKeyFile == "" || *urlFile == "" { + log.Fatal("fakevault: -app-password, -master, -ssh-key and -url-file are required") + } + + suiteKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + log.Fatal(err) + } + certificate := selfSigned(suiteKey) + wrapped := wrapPrivateKey(suiteKey, *master) + + sshPEM, err := os.ReadFile(*sshKeyFile) + if err != nil { + log.Fatal(err) + } + encrypt := func(v string) string { + ct, err := dcrypto.EncryptField(v, &suiteKey.PublicKey) + if err != nil { + log.Fatal(err) + } + return ct + } + secrets := []secret{ + {ID: "s-ssh", Name: *name, TypeID: "t-ssh", Key: encrypt(string(sshPEM))}, + {ID: "s-login", Name: "Mail", TypeID: "t-login", Key: encrypt("not an ssh key")}, + } + + mux := http.NewServeMux() + mux.HandleFunc(apiBase+"/suites", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, []map[string]any{{"id": "suite-1", "certificate": certificate, "privateKey": wrapped, "status": "active"}}) + }) + mux.HandleFunc(apiBase+"/secret-types", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, []map[string]any{{"id": "t-login", "name": "login"}, {"id": "t-ssh", "name": "ssh_key"}}) + }) + mux.HandleFunc(apiBase+"/folders", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, []any{}) + }) + mux.HandleFunc(apiBase+"/secrets", func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + // Nextcloud 35 refuses a limit above 500 (keepiq#786). + if limit < 1 || limit > 500 || page < 1 { + http.Error(w, "", http.StatusBadRequest) + return + } + from := (page - 1) * limit + items := []secret{} + if from < len(secrets) { + items = secrets[from:min(from+limit, len(secrets))] + } + writeJSON(w, map[string]any{"items": items, "total": len(secrets)}) + }) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("fakevault: %s %s", r.Method, r.URL.RequestURI()) + u, p, ok := r.BasicAuth() + if !ok || u != *user || p != *appPassword { + http.Error(w, `{"message":"unauthorised"}`, http.StatusUnauthorized) + return + } + if r.Method != http.MethodGet { + http.Error(w, `{"message":"read only"}`, http.StatusMethodNotAllowed) + return + } + mux.ServeHTTP(w, r) + }) + + l, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatal(err) + } + base := "http://" + l.Addr().String() + if err := os.WriteFile(*urlFile, []byte(base+"\n"), 0o600); err != nil { + log.Fatal(err) + } + log.Printf("fakevault: serving %s for %s", base, *user) + log.Fatal(http.Serve(l, handler)) +} + +// wrapPrivateKey is the browser's private-key envelope: +// [uint32 version 1][16-byte salt][12-byte IV][AES-256-GCM(PKCS#8 PEM)]. +func wrapPrivateKey(key *rsa.PrivateKey, master string) string { + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + log.Fatal(err) + } + plain := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + salt := make([]byte, 16) + iv := make([]byte, 12) + _, _ = rand.Read(salt) + _, _ = rand.Read(iv) + block, err := aes.NewCipher(dcrypto.DeriveUnlockKey(master, salt)) + if err != nil { + log.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + log.Fatal(err) + } + out := make([]byte, 4, 4+len(salt)+len(iv)) + binary.BigEndian.PutUint32(out, 1) + out = append(append(append(out, salt...), iv...), gcm.Seal(nil, iv, plain, nil)...) + return base64.StdEncoding.EncodeToString(out) +} + +func selfSigned(key *rsa.PrivateKey) string { + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "keepiq fakevault"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + log.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} diff --git a/cli/main.go b/cli/main.go index 00b838586..cde3a535b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -12,7 +12,9 @@ // requires re-wrapping the value under every recipient's public key (the share // fan-out), which is a separate, larger surface deferred to a follow-up. // -// Single static binary, stdlib only — cross-compile with GOOS/GOARCH. +// Single static binary, pure Go — cross-compile with GOOS/GOARCH. The only +// dependencies are the Go project's own golang.org/x/crypto and +// golang.org/x/sys, for the SSH agent (cli-ssh-agent). package main import ( @@ -23,8 +25,8 @@ import ( "os/exec" "strings" - "github.com/ConductionNL/keepiq/cli/internal/client" - dcrypto "github.com/ConductionNL/keepiq/cli/internal/crypto" + "github.com/ConductionNL/keepiq/sdk/go/client" + dcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" ) // version is stamped at build time via -ldflags "-X main.version=…". @@ -55,6 +57,10 @@ func main() { err = cmdCI(args) case "completion": err = cmdCompletion(args) + case "ssh-agent": + err = cmdSSHAgent(args) + case "install": + err = cmdInstall(args) case "help", "--help", "-h": usage() default: @@ -82,6 +88,13 @@ CI mode (RFC 7523 machine consumer): keepiq ci fetch [--output env|json] fetch+decrypt an application secret keepiq ci run [,...] -- run with the secret(s) in its env + install copy this binary to (init containers) + +SSH agent (Linux and macOS): + keepiq ssh-agent [--socket ] [--confirm] [--idle ] [--folder ] [--locked] + serve your vault SSH keys to ssh and git; + eval its output to set SSH_AUTH_SOCK + version | completion | help v1 is READ-ONLY: no create/edit/update/delete (share fan-out is a follow-up). @@ -89,7 +102,7 @@ The master password is prompted per session and never leaves this process. `) } -// --- flag helpers (stdlib only, minimal) --- +// --- flag helpers (minimal) --- func popFlag(args []string, name string) (string, []string) { out := make([]string, 0, len(args)) @@ -172,11 +185,40 @@ func cmdList(args []string) error { } fmt.Printf("%-38s %s\n", "ID", "NAME") for _, s := range secrets { - fmt.Printf("%-38s %s\n", s.ID, s.Name) + fmt.Println(listLine(s)) } return nil } +// useOnlyRefusal is what the CLI says instead of printing or copying the +// value of a use-only copy (sharing-use-only-and-expiring-shares D3). +const useOnlyRefusal = "This secret is use-only. Sign in through the Keepiq browser extension." + +// visibleFields are the fields of a use-only copy the CLI may still print or +// copy: its plaintext metadata and login name, never its value. +var visibleFields = map[string]bool{"id": true, "name": true, "url": true, "login": true} + +// listLine is one row of `keepiq list`, with a marker on a use-only copy. +func listLine(s client.Secret) string { + name := s.Name + if s.UseOnly { + name += " [use only]" + } + return fmt.Sprintf("%-38s %s", s.ID, name) +} + +// refuseUseOnly refuses a command that would print or copy a value of a +// use-only copy. An empty field means the whole secret (`show`). +func refuseUseOnly(s *client.Secret, field string) error { + if s == nil || !s.UseOnly { + return nil + } + if field != "" && visibleFields[field] { + return nil + } + return fmt.Errorf("%s", useOnlyRefusal) +} + func cmdShow(args []string) error { output, args := popFlag(args, "--output") if len(args) < 1 { @@ -190,6 +232,9 @@ func cmdShow(args []string) error { if err != nil { return err } + if err := refuseUseOnly(s, ""); err != nil { + return err + } fields := decryptSecret(s, session) if output == "json" { return emitJSON(fields) @@ -212,6 +257,9 @@ func cmdGet(args []string) error { if err != nil { return err } + if err := refuseUseOnly(s, args[1]); err != nil { + return err + } fields := decryptSecret(s, session) v, ok := fields[args[1]] if !ok { @@ -267,23 +315,37 @@ func cmdCompletion(args []string) error { // A minimal, valid completion script per shell (§1.3). switch shell { case "bash": - fmt.Print("complete -W 'login list show get copy ci version completion help' keepiq\n") + fmt.Print("complete -W 'login list show get copy ci ssh-agent version completion help' keepiq\n") case "zsh": - fmt.Print("#compdef keepiq\ncompadd login list show get copy ci version completion help\n") + fmt.Print("#compdef keepiq\ncompadd login list show get copy ci ssh-agent version completion help\n") case "fish": - fmt.Print("complete -c keepiq -a 'login list show get copy ci version completion help'\n") + fmt.Print("complete -c keepiq -a 'login list show get copy ci ssh-agent version completion help'\n") default: return fmt.Errorf("unsupported shell %q (bash|zsh|fish)", shell) } return nil } +// childEnviron is the parent environment minus the application private key: +// the wrapped command gets the secrets it asked for, never the key that can +// read every other secret of the application. +func childEnviron(parent []string) []string { + out := make([]string, 0, len(parent)) + for _, kv := range parent { + if strings.HasPrefix(kv, "KEEPIQ_APP_KEY=") { + continue + } + out = append(out, kv) + } + return out +} + func runChild(env []string, cmd []string) error { if len(cmd) == 0 { return fmt.Errorf("no command after --") } child := exec.Command(cmd[0], cmd[1:]...) - child.Env = append(os.Environ(), env...) + child.Env = append(childEnviron(os.Environ()), env...) child.Stdin, child.Stdout, child.Stderr = os.Stdin, os.Stdout, os.Stderr return child.Run() } diff --git a/cli/scripts/ssh-agent-e2e.sh b/cli/scripts/ssh-agent-e2e.sh new file mode 100755 index 000000000..3ccdeb5cd --- /dev/null +++ b/cli/scripts/ssh-agent-e2e.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# Runs the README's `keepiq ssh-agent` recipe end to end, the way a person +# would in a shell, against cli/internal/fakevault (a throwaway Keepiq) and a +# throwaway sshd on localhost (keepiq#1042): +# +# 1. eval "$(keepiq ssh-agent)" returns, and the agent keeps serving +# 2. ssh-add -l lists the vault's SSH key, and only that one +# 3. ssh and git clone over SSH log in with it +# 4. ssh-add -x locks the agent: no keys, the login is refused +# 5. ssh-add -X with the master password unlocks it: the login works again +# 6. ssh-add and ssh-add -d are refused +# 7. kill $SSH_AGENT_PID stops the agent and removes its socket +# +# With KEEPIQ_E2E_LAUNCHD=1 on macOS it then installs the README's launchd +# plist in the current user's home and checks the agent it starts. That step +# writes to ~/Library/LaunchAgents and to keepiq's config in the real home, so +# it is meant for a throwaway CI runner only. +# +# Needs go, ssh, ssh-add, ssh-keygen, sshd and git. Runs as any user: sshd is +# started as that user on a high port with its own host key and +# authorized_keys, and never touches the system sshd. +# shellcheck disable=SC2016 # the $(...) and $VAR in step titles are literal on purpose +set -euo pipefail + +cli="$(cd "$(dirname "$0")/.." && pwd)" +me="$(id -un)" +real_home="$HOME" +work="$(mktemp -d /tmp/kq-e2e.XXXXXX)" +master='correct horse battery staple' +app_password='e2e-app-password' +key_name='e2e deploy key' +pids=() + +step() { printf '\n== %s\n' "$*"; } +fail() { + printf '::error::%s\n' "$*" + exit 1 +} + +cleanup() { + set +e + if [ -n "${SSH_AGENT_PID:-}" ]; then kill "$SSH_AGENT_PID" 2>/dev/null; fi + for p in "${pids[@]+"${pids[@]}"}"; do kill "$p" 2>/dev/null; done + if [ -n "${plist:-}" ] && [ -f "$plist" ]; then + launchctl unload "$plist" 2>/dev/null + launchctl bootout "gui/$(id -u)" "$plist" 2>/dev/null + rm -f "$plist" + fi + # Only what the launchd step created in the real home. + if [ -n "${launchd_owned:-}" ]; then rm -rf "$real_config" "$real_home/.keepiq"; fi + cd / && rm -rf "$work" +} +trap cleanup EXIT + +# Builds use the real home's Go caches; everything after runs with a throwaway +# home, so `keepiq login` cannot overwrite the user's own keepiq config. +step "build keepiq and the fake vault" +(cd "$cli" && go build -o "$work/keepiq" . && go build -o "$work/fakevault" ./internal/fakevault) +kq="$work/keepiq" +export HOME="$work/home" +mkdir -p "$HOME" +unset SSH_AUTH_SOCK SSH_AGENT_PID XDG_RUNTIME_DIR + +step "keys: the vault's SSH key, a host key, and a key that is not in the vault" +ssh-keygen -q -t ed25519 -N '' -C "$key_name" -f "$work/vault_key" +ssh-keygen -q -t ed25519 -N '' -C host -f "$work/host_key" +ssh-keygen -q -t ed25519 -N '' -C other -f "$work/other_key" +cp "$work/vault_key.pub" "$work/authorized_keys" +chmod 600 "$work/authorized_keys" + +step "throwaway sshd as $me" +sshd_bin="$(command -v sshd || true)" +[ -n "$sshd_bin" ] || sshd_bin=/usr/sbin/sshd +[ -x "$sshd_bin" ] || fail "no sshd at $sshd_bin" +if [ "$(id -u)" = 0 ]; then mkdir -p /run/sshd; fi +port=$((20000 + RANDOM % 20000)) +cat >"$work/sshd_config" <"$work/sshd.log" & +pids+=($!) +for _ in $(seq 1 50); do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then break; fi + sleep 0.2 +done +(exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null || { + cat "$work/sshd.log" + fail "sshd did not start on port $port" +} +echo "sshd listens on 127.0.0.1:$port" + +step "fake Keepiq with the vault key filed as an ssh_key secret" +"$work/fakevault" -app-password "$app_password" -master "$master" -ssh-key "$work/vault_key" \ + -name "$key_name" -url-file "$work/url" 2>"$work/fakevault.log" & +pids+=($!) +for _ in $(seq 1 300); do + [ -s "$work/url" ] && break + sleep 0.2 +done +[ -s "$work/url" ] || { + cat "$work/fakevault.log" + fail "the fake vault did not start" +} +url="$(cat "$work/url")" +# The private key now lives only in the vault: a login can only come from the agent. +rm -f "$work/vault_key" +echo "fake vault at $url" + +printf '%s\n' "$app_password" | "$kq" login --url "$url" --user alice + +ssh_opts=(-F /dev/null -o IdentityFile=none -o StrictHostKeyChecking=no + -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o BatchMode=yes -o ConnectTimeout=10 -p "$port") +login() { ssh "${ssh_opts[@]}" "$me@127.0.0.1" echo keepiq-agent-ok 2>&1; } +askpass="$work/askpass" +printf '#!/bin/sh\nprintf "%%s\\n" "$KQ_ASKPASS_ANSWER"\n' >"$askpass" +chmod 700 "$askpass" +with_askpass() { SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force DISPLAY=:0 KQ_ASKPASS_ANSWER="$1" "${@:2}"; } + +step '1. eval "$(keepiq ssh-agent)" returns to the shell' +started=$(date +%s) +eval "$(printf '%s\n' "$master" | "$kq" ssh-agent)" +took=$(($(date +%s) - started)) +echo "returned after ${took}s: SSH_AUTH_SOCK=$SSH_AUTH_SOCK SSH_AGENT_PID=$SSH_AGENT_PID" +[ -n "${SSH_AUTH_SOCK:-}" ] && [ -n "${SSH_AGENT_PID:-}" ] || fail "no SSH_AUTH_SOCK or SSH_AGENT_PID exported" +kill -0 "$SSH_AGENT_PID" || fail "the agent is not running after eval returned" +[ -S "$SSH_AUTH_SOCK" ] || fail "$SSH_AUTH_SOCK is not a socket" +mode() { perl -e 'printf "%o\n", (stat shift)[2] & 07777' "$1"; } +[ "$(mode "$SSH_AUTH_SOCK")" = 600 ] || fail "socket mode $(mode "$SSH_AUTH_SOCK"), want 600" +[ "$(mode "$(dirname "$SSH_AUTH_SOCK")")" = 700 ] || fail "socket folder mode $(mode "$(dirname "$SSH_AUTH_SOCK")"), want 700" +if [ "$(uname -s)" = Darwin ]; then + want="${TMPDIR%/}/keepiq-$(id -u)/agent.sock" + [ "$SSH_AUTH_SOCK" = "$want" ] || fail "default socket on macOS is $SSH_AUTH_SOCK, the README says $want" +fi +echo "socket $SSH_AUTH_SOCK is 0600 in a 0700 folder" + +step "2. ssh-add -l lists the vault key" +listed="$(ssh-add -l)" || fail "ssh-add -l failed: $listed" +echo "$listed" +[ "$(printf '%s\n' "$listed" | wc -l | tr -d ' ')" = 1 ] || fail "want exactly one key (the login secret must be skipped)" +case "$listed" in *"$key_name"*ED25519*) ;; *) fail "ssh-add -l does not show '$key_name (ED25519)'" ;; esac +[ "$(ssh-keygen -lf "$work/vault_key.pub" | awk '{print $2}')" = "$(printf '%s\n' "$listed" | awk '{print $2}')" ] || + fail "the agent offers a different key than the one in the vault" + +step "3. ssh and git clone over SSH with the vault key" +out="$(login)" || fail "ssh login failed: $out" +case "$out" in *keepiq-agent-ok*) echo "ssh: $out" ;; *) fail "unexpected ssh output: $out" ;; esac +git init -q --bare "$work/repo.git" +git -C "$work" init -q seed +git -C "$work/seed" -c user.name=e2e -c user.email=e2e@example.invalid commit -q --allow-empty -m "seed" +git -C "$work/seed" push -q "$work/repo.git" HEAD:refs/heads/main +GIT_SSH_COMMAND="ssh ${ssh_opts[*]}" git clone -q -b main --upload-pack "$(command -v git) upload-pack" \ + "ssh://$me@127.0.0.1:$port$work/repo.git" "$work/clone" || fail "git clone over SSH failed" +[ "$(git -C "$work/clone" log -1 --format=%s)" = seed ] || fail "the clone is not the repository" +echo "git clone over SSH works" + +step "4. ssh-add -x locks the agent" +with_askpass lock-pw ssh-add -x || fail "ssh-add -x failed" +if listed="$(ssh-add -l 2>&1)"; then fail "a locked agent still lists keys: $listed"; fi +echo "ssh-add -l: $listed" +if out="$(login)"; then fail "a locked agent still logged in: $out"; fi +case "$out" in *"Permission denied"*) echo "ssh: $out" ;; *) fail "the locked login failed for another reason: $out" ;; esac +if with_askpass not-the-master ssh-add -X 2>/dev/null; then fail "ssh-add -X unlocked with a wrong password"; fi +echo "a wrong password does not unlock" + +step "5. ssh-add -X with the master password unlocks it" +with_askpass "$master" ssh-add -X || fail "ssh-add -X with the master password failed" +listed="$(ssh-add -l)" || fail "no keys after unlock" +echo "$listed" +out="$(login)" || fail "ssh login after unlock failed: $out" +echo "ssh: $out" + +step "6. keys from anywhere but the vault are refused" +if out="$(ssh-add "$work/other_key" 2>&1)"; then fail "ssh-add was accepted: $out"; fi +echo "ssh-add : $out" +if out="$(ssh-add -d "$work/vault_key.pub" 2>&1)"; then fail "ssh-add -d was accepted: $out"; fi +echo "ssh-add -d: $out" +if out="$(ssh-add -D 2>&1)"; then fail "ssh-add -D was accepted: $out"; fi +echo "ssh-add -D: $out" +[ "$(ssh-add -l | wc -l | tr -d ' ')" = 1 ] || fail "the agent's keys changed" +echo "still exactly the vault key" + +step '7. kill $SSH_AGENT_PID stops the agent' +sock="$SSH_AUTH_SOCK" +kill "$SSH_AGENT_PID" +for _ in $(seq 1 50); do + [ -e "$sock" ] || break + sleep 0.1 +done +[ ! -e "$sock" ] || fail "the socket is still there after kill" +unset SSH_AGENT_PID +echo "agent stopped, socket removed" + +if [ "${KEEPIQ_E2E_LAUNCHD:-}" != 1 ] || [ "$(uname -s)" != Darwin ]; then + echo + echo "ssh-agent e2e: all checks passed" + exit 0 +fi + +# --- launchd (macOS, KEEPIQ_E2E_LAUNCHD=1) --------------------------------- +step "8. the README's launchd plist" +awk '/^```xml/{f=1; next} /^```/{if (f) exit} f' "$cli/README.md" >"$work/readme.plist" +grep -q 'Labelnl.conduction.keepiq-agent' "$work/readme.plist" || + fail "could not find the launchd plist in cli/README.md" +plutil -lint "$work/readme.plist" +args="$(plutil -extract ProgramArguments json -o - "$work/readme.plist")" +echo "ProgramArguments: $args" +[ "$args" = '["\/usr\/local\/bin\/keepiq","ssh-agent","--locked","--socket","\/Users\/YOU\/.keepiq\/agent.sock"]' ] || + [ "$args" = '["/usr/local/bin/keepiq","ssh-agent","--locked","--socket","/Users/YOU/.keepiq/agent.sock"]' ] || + fail "the README plist's ProgramArguments changed; update this check with it" + +# The README's own steps, with the binary path and YOU filled in. They run in +# the real home, as launchd starts the agent there. +export HOME="$real_home" +label=nl.conduction.keepiq-agent +plist="$HOME/Library/LaunchAgents/$label.plist" +sock="$HOME/.keepiq/agent.sock" +real_config="$HOME/Library/Application Support/keepiq/config.json" +for f in "$plist" "$real_config" "$HOME/.keepiq"; do + [ ! -e "$f" ] || fail "$f already exists; not overwriting it" +done +launchd_owned=1 +mkdir -p "$HOME/Library/LaunchAgents" +sed -e "s#/usr/local/bin/keepiq#$kq#" -e "s#/Users/YOU#$HOME#" "$work/readme.plist" >"$plist" +plutil -lint "$plist" +printf '%s\n' "$app_password" | "$kq" login --url "$url" --user alice +mkdir -m 700 "$HOME/.keepiq" + +loaded="" +if out="$(launchctl load "$plist" 2>&1)" && [ -z "$out" ]; then + loaded="launchctl load" +else + echo "launchctl load said: ${out:-(nothing, exit non-zero)}" + if out="$(launchctl bootstrap "gui/$(id -u)" "$plist" 2>&1)"; then + loaded="launchctl bootstrap gui/$(id -u)" + else + echo "launchctl bootstrap gui/$(id -u) said: $out" + fi +fi +if [ -z "$loaded" ]; then + echo "::warning::this runner cannot load a LaunchAgent; only plutil and ProgramArguments were checked" + exit 0 +fi +echo "loaded with: $loaded" +for _ in $(seq 1 100); do + [ -S "$sock" ] && break + sleep 0.1 +done +launchctl print "gui/$(id -u)/$label" 2>&1 | grep -E '^\s*(state|pid|last exit code)' || true +[ -S "$sock" ] || fail "launchd started no agent on $sock" +export SSH_AUTH_SOCK="$sock" +if listed="$(ssh-add -l 2>&1)"; then fail "the --locked agent listed keys before unlock: $listed"; fi +echo "locked at load: $listed" +with_askpass "$master" ssh-add -X || fail "ssh-add -X on the launchd agent failed" +ssh-add -l +out="$(login)" || fail "ssh login through the launchd agent failed: $out" +echo "ssh: $out" + +# KeepAlive: launchd starts the agent again when it stops. +first="$(launchctl list | awk -v l="$label" '$3 == l {print $1}')" +echo "launchd agent pid $first" +[ -n "$first" ] && [ "$first" != - ] || fail "launchctl list shows no pid for $label" +kill "$first" +second="" +for _ in $(seq 1 100); do + second="$(launchctl list | awk -v l="$label" '$3 == l {print $1}')" + if [ -n "$second" ] && [ "$second" != - ] && [ "$second" != "$first" ] && [ -S "$sock" ]; then break; fi + sleep 0.2 +done +[ -n "$second" ] && [ "$second" != - ] && [ "$second" != "$first" ] || fail "launchd did not restart the agent (KeepAlive)" +listed="$(ssh-add -l 2>&1)" && fail "a restarted agent must start locked: $listed" +echo "restarted as pid $second, locked again: $listed" + +launchctl unload "$plist" 2>/dev/null || launchctl bootout "gui/$(id -u)" "$plist" +rm -f "$plist" +echo +echo "ssh-agent e2e and launchd: all checks passed" diff --git a/cli/sshagent/agent.go b/cli/sshagent/agent.go new file mode 100644 index 000000000..abe49aa1e --- /dev/null +++ b/cli/sshagent/agent.go @@ -0,0 +1,235 @@ +package sshagent + +import ( + "bytes" + "crypto/rand" + "errors" + "os" + "os/exec" + "sync" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// Unlocker opens the vault with the master password and returns the keys. +type Unlocker func(masterPassword string) ([]Identity, error) + +// Confirmer asks the user to allow one signature with the named key. +type Confirmer func(keyName string) bool + +// Errors the agent answers with. The protocol carries only "failure"; these +// are for logs and tests. +var ( + ErrLocked = errors.New("the agent is locked") + ErrVaultOnly = errors.New("keys come from the Keepiq vault; add or remove them there") + ErrUnknownKey = errors.New("no such key in the agent") + ErrSHA1Refused = errors.New("ssh-rsa (SHA-1) signatures are refused; use rsa-sha2-256 or rsa-sha2-512") + ErrNotConfirmed = errors.New("the signature was not confirmed") + ErrNoAskpass = errors.New("--confirm needs SSH_ASKPASS to point at a confirmation program") + errNotSupported = errors.New("not supported") +) + +// Agent serves vault keys. It implements agent.ExtendedAgent. +type Agent struct { + mu sync.Mutex + keys []Identity + locked bool + unlock Unlocker + confirm Confirmer + idle time.Duration + now func() time.Time + lastUsed time.Time +} + +// Options configure an Agent. +type Options struct { + // Unlock opens the vault; required. + Unlock Unlocker + // Confirm, when set, is asked before every signature. + Confirm Confirmer + // Idle drops every key after this long without a sign request; 0 disables. + Idle time.Duration + // Now is the clock (tests inject one). + Now func() time.Time +} + +// New returns a locked agent. +func New(o Options) *Agent { + now := o.Now + if now == nil { + now = time.Now + } + return &Agent{locked: true, unlock: o.Unlock, confirm: o.Confirm, idle: o.Idle, now: now} +} + +// UnlockWith opens the vault with the master password (terminal start or +// `ssh-add -X`). +func (a *Agent) UnlockWith(masterPassword string) error { + keys, err := a.unlock(masterPassword) + if err != nil { + return err + } + a.mu.Lock() + defer a.mu.Unlock() + a.keys = keys + a.locked = false + a.lastUsed = a.now() + return nil +} + +// dropLocked clears every decrypted key. Callers hold mu. +func (a *Agent) dropLocked() { + a.keys = nil + a.locked = true +} + +// ExpireIfIdle locks the agent when the idle period has passed since the last +// signature. The serve loop calls it on a timer and before every request. +func (a *Agent) ExpireIfIdle() { + a.mu.Lock() + defer a.mu.Unlock() + a.expireLocked() +} + +func (a *Agent) expireLocked() { + if a.locked || a.idle <= 0 { + return + } + if a.now().Sub(a.lastUsed) >= a.idle { + a.dropLocked() + } +} + +// Locked reports whether the agent holds no keys. +func (a *Agent) Locked() bool { + a.mu.Lock() + defer a.mu.Unlock() + a.expireLocked() + return a.locked +} + +// List answers `ssh-add -l`: the vault keys, or nothing while locked. +func (a *Agent) List() ([]*agent.Key, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.expireLocked() + if a.locked { + return nil, nil + } + out := make([]*agent.Key, 0, len(a.keys)) + for _, k := range a.keys { + pub := k.Signer.PublicKey() + out = append(out, &agent.Key{Format: pub.Type(), Blob: pub.Marshal(), Comment: k.Name}) + } + return out, nil +} + +// Sign signs with default flags. +func (a *Agent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.SignWithFlags(key, data, 0) +} + +// SignWithFlags signs with the named key. RSA needs a SHA-2 flag. +func (a *Agent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + a.mu.Lock() + a.expireLocked() + if a.locked { + a.mu.Unlock() + return nil, ErrLocked + } + var id *Identity + want := key.Marshal() + for i := range a.keys { + if bytes.Equal(a.keys[i].Signer.PublicKey().Marshal(), want) { + id = &a.keys[i] + break + } + } + confirm := a.confirm + a.mu.Unlock() + if id == nil { + return nil, ErrUnknownKey + } + + algorithm := "" + if id.Signer.PublicKey().Type() == ssh.KeyAlgoRSA { + switch { + case flags&agent.SignatureFlagRsaSha512 != 0: + algorithm = ssh.KeyAlgoRSASHA512 + case flags&agent.SignatureFlagRsaSha256 != 0: + algorithm = ssh.KeyAlgoRSASHA256 + default: + return nil, ErrSHA1Refused + } + } + + if confirm != nil && !confirm(id.Name) { + return nil, ErrNotConfirmed + } + + var sig *ssh.Signature + var err error + if algorithm != "" { + as, ok := id.Signer.(ssh.AlgorithmSigner) + if !ok { + return nil, errNotSupported + } + sig, err = as.SignWithAlgorithm(rand.Reader, data, algorithm) + } else { + sig, err = id.Signer.Sign(rand.Reader, data) + } + if err != nil { + return nil, err + } + a.mu.Lock() + a.lastUsed = a.now() + a.mu.Unlock() + return sig, nil +} + +// Add is refused: the vault is the only source. +func (a *Agent) Add(agent.AddedKey) error { return ErrVaultOnly } + +// Remove is refused: the vault is the only source. +func (a *Agent) Remove(ssh.PublicKey) error { return ErrVaultOnly } + +// RemoveAll is refused: the vault is the only source. +func (a *Agent) RemoveAll() error { return ErrVaultOnly } + +// Lock answers `ssh-add -x`: every decrypted key is dropped. +func (a *Agent) Lock([]byte) error { + a.mu.Lock() + defer a.mu.Unlock() + a.dropLocked() + return nil +} + +// Unlock answers `ssh-add -X`: the passphrase is the master password. +func (a *Agent) Unlock(passphrase []byte) error { + return a.UnlockWith(string(passphrase)) +} + +// Signers is not offered over the protocol. +func (a *Agent) Signers() ([]ssh.Signer, error) { return nil, errNotSupported } + +// Extension: none supported. +func (a *Agent) Extension(string, []byte) ([]byte, error) { + return nil, agent.ErrExtensionUnsupported +} + +// AskpassConfirmer asks through the SSH_ASKPASS program, as OpenSSH's own +// agent does for `ssh-add -c` keys: exit status 0 allows the signature. +func AskpassConfirmer(program string) (Confirmer, error) { + if program == "" { + return nil, ErrNoAskpass + } + return func(keyName string) bool { + cmd := exec.Command(program, "Allow use of the Keepiq key \""+keyName+"\"?") + cmd.Env = append(os.Environ(), "SSH_ASKPASS_PROMPT=confirm") + return cmd.Run() == nil + }, nil +} + +var _ agent.ExtendedAgent = (*Agent)(nil) diff --git a/cli/sshagent/agent_test.go b/cli/sshagent/agent_test.go new file mode 100644 index 000000000..0eb4bcc17 --- /dev/null +++ b/cli/sshagent/agent_test.go @@ -0,0 +1,280 @@ +package sshagent + +import ( + "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/pem" + "errors" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + + "github.com/ConductionNL/keepiq/sdk/go/client" +) + +const sshType = "t-ssh" + +func openSSHPEM(t *testing.T, key any, passphrase string) string { + t.Helper() + var block *pem.Block + var err error + if passphrase != "" { + block, err = ssh.MarshalPrivateKeyWithPassphrase(key, "", []byte(passphrase)) + } else { + block, err = ssh.MarshalPrivateKey(key, "") + } + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(block)) +} + +// vault returns throwaway secrets: four usable keys of every supported kind, +// a passphrase-protected one, a broken one, a key in another folder and a +// login secret. The "ciphertext" is the plaintext; decrypt is the identity. +func vault(t *testing.T) []client.Secret { + t.Helper() + _, ed, _ := ed25519.GenerateKey(rand.Reader) + ec256, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + ec384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + ec521, _ := ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + rk, _ := rsa.GenerateKey(rand.Reader, 2048) + _, locked, _ := ed25519.GenerateKey(rand.Reader) + _, other, _ := ed25519.GenerateKey(rand.Reader) + return []client.Secret{ + {ID: "1", Name: "GitHub deploy", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, ed, "")}, + {ID: "2", Name: "P-256", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, ec256, "")}, + {ID: "3", Name: "P-384", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, ec384, "")}, + {ID: "4", Name: "P-521", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, ec521, "")}, + {ID: "5", Name: "RSA", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, rk, "")}, + {ID: "6", Name: "With passphrase", TypeID: sshType, FolderID: "f1", Key: openSSHPEM(t, locked, "pw")}, + {ID: "7", Name: "Truncated", TypeID: sshType, FolderID: "f1", Key: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n"}, + {ID: "8", Name: "Elsewhere", TypeID: sshType, FolderID: "f2", Key: openSSHPEM(t, other, "")}, + {ID: "9", Name: "A login", TypeID: "t-login", Key: "hunter2"}, + } +} + +func identity(s string) (string, error) { return s, nil } + +func TestBuildKeyringKeepsUsableKeysAndNamesTheSkippedOnes(t *testing.T) { + var warn bytes.Buffer + keys := BuildKeyring(vault(t), sshType, "f1", identity, &warn) + + var names []string + for _, k := range keys { + names = append(names, k.Name) + } + if got := strings.Join(names, ","); got != "GitHub deploy,P-256,P-384,P-521,RSA" { + t.Fatalf("keys = %s", got) + } + if !strings.Contains(warn.String(), `"With passphrase": it is protected by a passphrase`) { + t.Errorf("passphrase key not named: %q", warn.String()) + } + if !strings.Contains(warn.String(), `"Truncated"`) { + t.Errorf("broken key not named: %q", warn.String()) + } + if strings.Contains(warn.String(), "A login") || strings.Contains(warn.String(), "Elsewhere") { + t.Errorf("other types and folders must be ignored silently: %q", warn.String()) + } + + all := BuildKeyring(vault(t), sshType, "", identity, &bytes.Buffer{}) + if len(all) != 6 { + t.Fatalf("without a folder filter: %d keys, want 6", len(all)) + } +} + +// newAgent returns an unlocked agent and a protocol client talking to it over +// a pipe, the way ssh and ssh-add do. +func newAgent(t *testing.T, o Options) (*Agent, agent.ExtendedAgent) { + t.Helper() + secrets := vault(t) + if o.Unlock == nil { + o.Unlock = func(pw string) ([]Identity, error) { + if pw != "master" { + return nil, errors.New("wrong master password") + } + return BuildKeyring(secrets, sshType, "f1", identity, &bytes.Buffer{}), nil + } + } + a := New(o) + if err := a.UnlockWith("master"); err != nil { + t.Fatal(err) + } + server, conn := net.Pipe() + go func() { _ = agent.ServeAgent(a, server) }() + t.Cleanup(func() { conn.Close(); server.Close() }) + return a, agent.NewClient(conn) +} + +func TestListAndSignWithEveryKeyType(t *testing.T) { + _, c := newAgent(t, Options{}) + keys, err := c.List() + if err != nil || len(keys) != 5 { + t.Fatalf("List = %d keys, %v", len(keys), err) + } + data := []byte("session data") + for _, k := range keys { + flags := agent.SignatureFlags(0) + if k.Format == ssh.KeyAlgoRSA { + flags = agent.SignatureFlagRsaSha256 + } + sig, err := c.SignWithFlags(k, data, flags) + if err != nil { + t.Fatalf("%s: %v", k.Comment, err) + } + if err := k.Verify(data, sig); err != nil { + t.Fatalf("%s: signature does not verify: %v", k.Comment, err) + } + if k.Format == ssh.KeyAlgoRSA && sig.Format != ssh.KeyAlgoRSASHA256 { + t.Fatalf("RSA signed with %s", sig.Format) + } + } +} + +func TestRSAWithoutSHA2FlagIsRefused(t *testing.T) { + _, c := newAgent(t, Options{}) + keys, _ := c.List() + for _, k := range keys { + if k.Format != ssh.KeyAlgoRSA { + continue + } + if _, err := c.Sign(k, []byte("x")); err == nil { + t.Fatal("an ssh-rsa (SHA-1) signature must be refused") + } + sig, err := c.SignWithFlags(k, []byte("x"), agent.SignatureFlagRsaSha512) + if err != nil || sig.Format != ssh.KeyAlgoRSASHA512 { + t.Fatalf("rsa-sha2-512: %v %v", sig, err) + } + return + } + t.Fatal("no RSA key listed") +} + +func TestAddAndRemoveAreRefused(t *testing.T) { + _, c := newAgent(t, Options{}) + _, local, _ := ed25519.GenerateKey(rand.Reader) + if err := c.Add(agent.AddedKey{PrivateKey: local}); err == nil { + t.Fatal("ssh-add of a local key must be refused") + } + keys, _ := c.List() + if len(keys) != 5 { + t.Fatalf("the refused key was listed: %d keys", len(keys)) + } + if err := c.Remove(keys[0]); err == nil { + t.Fatal("remove must be refused") + } + if err := c.RemoveAll(); err == nil { + t.Fatal("remove-all must be refused") + } +} + +func TestLockDropsKeysAndUnlockNeedsTheMasterPassword(t *testing.T) { + a, c := newAgent(t, Options{}) + keys, _ := c.List() + if err := c.Lock([]byte("anything")); err != nil { + t.Fatal(err) + } + if listed, _ := c.List(); len(listed) != 0 { + t.Fatalf("locked agent listed %d keys", len(listed)) + } + if _, err := c.SignWithFlags(keys[0], []byte("x"), 0); err == nil { + t.Fatal("a locked agent must refuse to sign") + } + if a.keys != nil { + t.Fatal("locking must drop the decrypted keys") + } + if err := c.Unlock([]byte("wrong")); err == nil { + t.Fatal("a wrong master password must not unlock") + } + if err := c.Unlock([]byte("master")); err != nil { + t.Fatal(err) + } + if listed, _ := c.List(); len(listed) != 5 { + t.Fatalf("after unlock: %d keys", len(listed)) + } +} + +func TestStartedLockedHoldsNoKeysUntilUnlocked(t *testing.T) { + a := New(Options{Unlock: func(string) ([]Identity, error) { return nil, nil }}) + if !a.Locked() { + t.Fatal("a new agent must start locked") + } + if keys, _ := a.List(); len(keys) != 0 { + t.Fatal("a locked agent lists nothing") + } +} + +func TestIdleLockDropsKeys(t *testing.T) { + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + clock := func() time.Time { return now } + a, c := newAgent(t, Options{Idle: 30 * time.Minute, Now: clock}) + keys, _ := c.List() + + now = now.Add(29 * time.Minute) + if _, err := c.SignWithFlags(keys[0], []byte("x"), 0); err != nil { + t.Fatalf("before the idle period: %v", err) + } + now = now.Add(30 * time.Minute) + a.ExpireIfIdle() + if !a.Locked() || a.keys != nil { + t.Fatal("the idle lock must drop every key") + } + if _, err := c.SignWithFlags(keys[0], []byte("x"), 0); err == nil { + t.Fatal("after the idle period the agent must refuse as locked") + } +} + +func askpass(t *testing.T, exit int) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell script askpass") + } + dir := t.TempDir() + path := filepath.Join(dir, "askpass") + log := filepath.Join(dir, "log") + script := "#!/bin/sh\necho \"$SSH_ASKPASS_PROMPT|$1\" > " + log + "\nexit " + map[int]string{0: "0", 1: "1"}[exit] + "\n" + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + return path +} + +func TestConfirmAllowsOnlyOnExitZero(t *testing.T) { + for _, tc := range []struct { + exit int + ok bool + }{{0, true}, {1, false}} { + program := askpass(t, tc.exit) + confirm, err := AskpassConfirmer(program) + if err != nil { + t.Fatal(err) + } + _, c := newAgent(t, Options{Confirm: confirm}) + keys, _ := c.List() + _, err = c.SignWithFlags(keys[0], []byte("x"), 0) + if (err == nil) != tc.ok { + t.Fatalf("askpass exit %d: sign error %v", tc.exit, err) + } + logged, _ := os.ReadFile(filepath.Join(filepath.Dir(program), "log")) + if !strings.HasPrefix(string(logged), "confirm|") || !strings.Contains(string(logged), "GitHub deploy") { + t.Fatalf("askpass was not asked to confirm the named key: %q", logged) + } + } +} + +func TestConfirmNeedsAskpass(t *testing.T) { + if _, err := AskpassConfirmer(""); !errors.Is(err, ErrNoAskpass) { + t.Fatalf("want ErrNoAskpass, got %v", err) + } +} diff --git a/cli/sshagent/integration_test.go b/cli/sshagent/integration_test.go new file mode 100644 index 000000000..388e6ec97 --- /dev/null +++ b/cli/sshagent/integration_test.go @@ -0,0 +1,147 @@ +//go:build linux || darwin + +package sshagent + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "net" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/ConductionNL/keepiq/sdk/go/client" +) + +// TestRealSSHThroughTheAgent starts a throwaway sshd on localhost that trusts +// one generated key, serves that key from the agent as a vault key, and runs +// the real ssh client against it with IdentityAgent pointing at the agent. +// It skips where sshd or ssh is not installed. +func TestRealSSHThroughTheAgent(t *testing.T) { + sshd := findBinary("sshd", "/usr/sbin/sshd", "/usr/local/sbin/sshd") + sshBin := findBinary("ssh", "/usr/bin/ssh") + if sshd == "" || sshBin == "" { + t.Skip("sshd or ssh not installed") + } + me, err := user.Current() + if err != nil { + t.Fatal(err) + } + dir := shortTempDir(t) + + // The user's vault key, and the host key of the throwaway server. + _, userKey, _ := ed25519.GenerateKey(rand.Reader) + _, hostKey, _ := ed25519.GenerateKey(rand.Reader) + hostBlock, _ := ssh.MarshalPrivateKey(hostKey, "") + hostKeyPath := filepath.Join(dir, "host_ed25519") + must(t, os.WriteFile(hostKeyPath, pem.EncodeToMemory(hostBlock), 0o600)) + signer, _ := ssh.NewSignerFromKey(userKey) + authorized := filepath.Join(dir, "authorized_keys") + must(t, os.WriteFile(authorized, ssh.MarshalAuthorizedKey(signer.PublicKey()), 0o600)) + + port := freePort(t) + config := filepath.Join(dir, "sshd_config") + must(t, os.WriteFile(config, []byte(fmt.Sprintf(`Port %d +ListenAddress 127.0.0.1 +HostKey %s +AuthorizedKeysFile %s +PidFile %s +StrictModes no +UsePAM no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +PermitRootLogin yes +`, port, hostKeyPath, authorized, filepath.Join(dir, "sshd.pid"))), 0o600)) + daemon := exec.Command(sshd, "-D", "-e", "-f", config) + var daemonLog bytes.Buffer + daemon.Stderr = &daemonLog + must(t, daemon.Start()) + t.Cleanup(func() { _ = daemon.Process.Kill(); _ = daemon.Wait() }) + waitForPort(t, port) + + // The agent, serving that key as the vault's only ssh_key secret. + block, _ := ssh.MarshalPrivateKey(userKey, "") + vaultKey := string(pem.EncodeToMemory(block)) + a := New(Options{Unlock: func(string) ([]Identity, error) { + return BuildKeyring( + []client.Secret{{ID: "s1", Name: "GitHub deploy", TypeID: sshType, Key: vaultKey}}, + sshType, "", identity, &bytes.Buffer{}, + ), nil + }}) + must(t, a.UnlockWith("master")) + sock := filepath.Join(dir, "agent", "agent.sock") + l, err := Listen(sock) + must(t, err) + t.Cleanup(func() { l.Close() }) + go func() { _ = Serve(l, a, PeerUID, nil) }() + + out, err := exec.Command(sshBin, + "-F", "/dev/null", + "-o", "IdentityAgent="+sock, + "-o", "IdentityFile=none", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "BatchMode=yes", + "-p", fmt.Sprint(port), + me.Username+"@127.0.0.1", "echo keepiq-agent-ok", + ).CombinedOutput() + if err != nil || !strings.Contains(string(out), "keepiq-agent-ok") { + t.Fatalf("ssh through the agent failed: %v\n%s\nsshd: %s", err, out, daemonLog.String()) + } + + // Locked, the same login fails. + must(t, a.Lock(nil)) + if out, err := exec.Command(sshBin, "-F", "/dev/null", "-o", "IdentityAgent="+sock, "-o", "IdentityFile=none", + "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "BatchMode=yes", + "-p", fmt.Sprint(port), me.Username+"@127.0.0.1", "true").CombinedOutput(); err == nil { + t.Fatalf("a locked agent still logged in: %s", out) + } +} + +func findBinary(name string, candidates ...string) string { + if p, err := exec.LookPath(name); err == nil { + return p + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +func freePort(t *testing.T) int { + l, err := net.Listen("tcp", "127.0.0.1:0") + must(t, err) + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func waitForPort(t *testing.T, port int) { + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil { + c.Close() + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("sshd did not start on port %d", port) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/cli/sshagent/keyring.go b/cli/sshagent/keyring.go new file mode 100644 index 000000000..07e102a56 --- /dev/null +++ b/cli/sshagent/keyring.go @@ -0,0 +1,61 @@ +// Package sshagent is the SSH agent of the Keepiq CLI (cli-ssh-agent): it +// serves the user's vault `ssh_key` secrets over the OpenSSH agent protocol. +// Keys are decrypted in this process only; the server sees the same ciphertext +// reads `keepiq show` makes, and nothing decrypted is written to disk. +package sshagent + +import ( + "errors" + "fmt" + "io" + + "golang.org/x/crypto/ssh" + + "github.com/ConductionNL/keepiq/sdk/go/client" +) + +// Identity is one usable vault key: the secret's name, its signer and the +// public key derived from the private key. +type Identity struct { + SecretID string + Name string + Signer ssh.Signer +} + +// BuildKeyring turns the user's secrets into identities: it keeps the secrets +// of the ssh_key type (and of one folder when folderID is set), decrypts each +// `key` field with decrypt, and parses it. A key that cannot be decrypted or +// parsed, or that needs a passphrase, is skipped and named on warn. +func BuildKeyring(secrets []client.Secret, typeID, folderID string, decrypt func(string) (string, error), warn io.Writer) []Identity { + var out []Identity + for _, s := range secrets { + if s.TypeID != typeID { + continue + } + if folderID != "" && s.FolderID != folderID { + continue + } + pem, err := decrypt(s.Key) + if err != nil { + fmt.Fprintf(warn, "keepiq ssh-agent: skipped %q: cannot decrypt it\n", s.Name) + continue + } + raw, err := ssh.ParseRawPrivateKey([]byte(pem)) + if err != nil { + var missing *ssh.PassphraseMissingError + if errors.As(err, &missing) { + fmt.Fprintf(warn, "keepiq ssh-agent: skipped %q: it is protected by a passphrase\n", s.Name) + } else { + fmt.Fprintf(warn, "keepiq ssh-agent: skipped %q: not an OpenSSH or PEM private key\n", s.Name) + } + continue + } + signer, err := ssh.NewSignerFromKey(raw) + if err != nil { + fmt.Fprintf(warn, "keepiq ssh-agent: skipped %q: unsupported key type\n", s.Name) + continue + } + out = append(out, Identity{SecretID: s.ID, Name: s.Name, Signer: signer}) + } + return out +} diff --git a/cli/sshagent/peer_darwin.go b/cli/sshagent/peer_darwin.go new file mode 100644 index 000000000..b57482f6e --- /dev/null +++ b/cli/sshagent/peer_darwin.go @@ -0,0 +1,40 @@ +//go:build darwin + +package sshagent + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerUID reads the peer's uid with LOCAL_PEERCRED. +func PeerUID(conn net.Conn) (int, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return -1, fmt.Errorf("not a unix socket") + } + raw, err := uc.SyscallConn() + if err != nil { + return -1, err + } + uid := -1 + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, err := unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED) + if err != nil { + credErr = err + return + } + uid = int(cred.Uid) + }); err != nil { + return -1, err + } + return uid, credErr +} + +// HardenProcess disables core dumps so decrypted keys cannot land on disk. +func HardenProcess() error { + return unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{Cur: 0, Max: 0}) +} diff --git a/cli/sshagent/peer_linux.go b/cli/sshagent/peer_linux.go new file mode 100644 index 000000000..fdfdcb930 --- /dev/null +++ b/cli/sshagent/peer_linux.go @@ -0,0 +1,43 @@ +//go:build linux + +package sshagent + +import ( + "fmt" + "net" + + "golang.org/x/sys/unix" +) + +// PeerUID reads the peer's uid with SO_PEERCRED. +func PeerUID(conn net.Conn) (int, error) { + uc, ok := conn.(*net.UnixConn) + if !ok { + return -1, fmt.Errorf("not a unix socket") + } + raw, err := uc.SyscallConn() + if err != nil { + return -1, err + } + uid := -1 + var credErr error + if err := raw.Control(func(fd uintptr) { + cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + if err != nil { + credErr = err + return + } + uid = int(cred.Uid) + }); err != nil { + return -1, err + } + return uid, credErr +} + +// HardenProcess disables core dumps so decrypted keys cannot land on disk. +func HardenProcess() error { + if err := unix.Setrlimit(unix.RLIMIT_CORE, &unix.Rlimit{Cur: 0, Max: 0}); err != nil { + return err + } + return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) +} diff --git a/cli/sshagent/peer_other.go b/cli/sshagent/peer_other.go new file mode 100644 index 000000000..768efba87 --- /dev/null +++ b/cli/sshagent/peer_other.go @@ -0,0 +1,18 @@ +//go:build !linux && !darwin && !windows + +package sshagent + +import ( + "errors" + "net" +) + +// PeerUID cannot identify the peer here, so every connection is refused. +func PeerUID(net.Conn) (int, error) { + return -1, errors.New("peer credentials are not supported on this system") +} + +// HardenProcess is not available on this system. +func HardenProcess() error { + return errors.New("cannot disable core dumps on this system") +} diff --git a/cli/sshagent/socket.go b/cli/sshagent/socket.go new file mode 100644 index 000000000..bb7176dff --- /dev/null +++ b/cli/sshagent/socket.go @@ -0,0 +1,94 @@ +//go:build !windows + +package sshagent + +import ( + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "syscall" + + "golang.org/x/crypto/ssh/agent" +) + +// PrepareSocketDir creates the socket directory with mode 0700, or checks an +// existing one: it must belong to this user and be closed to everyone else. +func PrepareSocketDir(dir string) error { + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return os.MkdirAll(dir, 0o700) + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || int(st.Uid) != os.Getuid() { + return fmt.Errorf("%s belongs to another user; refusing to put the agent socket there", dir) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("%s is open to other users (mode %o); it must be 0700", dir, info.Mode().Perm()) + } + return nil +} + +// Listen prepares the directory, replaces a stale socket and listens with +// mode 0600. +func Listen(path string) (net.Listener, error) { + if err := PrepareSocketDir(filepath.Dir(path)); err != nil { + return nil, err + } + if info, err := os.Lstat(path); err == nil { + if info.Mode()&os.ModeSocket == 0 { + return nil, fmt.Errorf("%s exists and is not a socket", path) + } + _ = os.Remove(path) + } + old := syscall.Umask(0o177) + l, err := net.Listen("unix", path) + syscall.Umask(old) + if err != nil { + return nil, err + } + if err := os.Chmod(path, 0o600); err != nil { + l.Close() + return nil, err + } + return l, nil +} + +// PeerUIDFunc returns the uid of the process at the other end of a connection. +type PeerUIDFunc func(net.Conn) (int, error) + +// Serve accepts connections until the listener closes. A connection whose +// peer is another user, or whose peer cannot be identified, is closed before +// any request is read. +func Serve(l net.Listener, a *Agent, peerUID PeerUIDFunc, logf func(string, ...any)) error { + self := os.Getuid() + for { + conn, err := l.Accept() + if err != nil { + return err + } + uid, err := peerUID(conn) + if err != nil || uid != self { + if logf != nil { + logf("keepiq ssh-agent: refused a connection from another user\n") + } + conn.Close() + continue + } + go func(c net.Conn) { + defer c.Close() + a.ExpireIfIdle() + if err := agent.ServeAgent(a, c); err != nil && !errors.Is(err, io.EOF) && logf != nil { + logf("keepiq ssh-agent: %v\n", err) + } + }(conn) + } +} diff --git a/cli/sshagent/socket_test.go b/cli/sshagent/socket_test.go new file mode 100644 index 000000000..856b097dc --- /dev/null +++ b/cli/sshagent/socket_test.go @@ -0,0 +1,93 @@ +//go:build !windows + +package sshagent + +import ( + "net" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "golang.org/x/crypto/ssh/agent" +) + +func shortTempDir(t *testing.T) string { + t.Helper() + // Unix socket paths are short (104 bytes on macOS); t.TempDir can be long. + dir, err := os.MkdirTemp("/tmp", "kq") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func TestSocketDirectoryMustBePrivate(t *testing.T) { + base := shortTempDir(t) + open := filepath.Join(base, "open") + if err := os.Mkdir(open, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(open, 0o755); err != nil { + t.Fatal(err) + } + if _, err := Listen(filepath.Join(open, "agent.sock")); err == nil { + t.Fatal("a directory open to other users must be refused") + } + + fresh := filepath.Join(base, "fresh") + l, err := Listen(filepath.Join(fresh, "agent.sock")) + if err != nil { + t.Fatal(err) + } + defer l.Close() + dirInfo, _ := os.Stat(fresh) + sockInfo, _ := os.Stat(filepath.Join(fresh, "agent.sock")) + if dirInfo.Mode().Perm() != 0o700 || sockInfo.Mode().Perm() != 0o600 { + t.Fatalf("modes: dir %o, socket %o", dirInfo.Mode().Perm(), sockInfo.Mode().Perm()) + } +} + +func serveWith(t *testing.T, peer PeerUIDFunc) string { + t.Helper() + path := filepath.Join(shortTempDir(t), "s", "agent.sock") + l, err := Listen(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { l.Close() }) + a, _ := newAgent(t, Options{}) + go func() { _ = Serve(l, a, peer, nil) }() + return path +} + +func TestAForeignPeerIsClosedWithoutAnAnswer(t *testing.T) { + path := serveWith(t, func(net.Conn) (int, error) { return os.Getuid() + 1, nil }) + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + if keys, err := agent.NewClient(conn).List(); err == nil { + t.Fatalf("a foreign peer got an answer: %d keys", len(keys)) + } +} + +func TestTheOwnUserIsServed(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("peer credentials only on Linux and macOS") + } + path := serveWith(t, PeerUID) + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + keys, err := agent.NewClient(conn).List() + if err != nil || len(keys) != 5 { + t.Fatalf("own user: %d keys, %v", len(keys), err) + } +} diff --git a/cli/sshagent_cmd.go b/cli/sshagent_cmd.go new file mode 100644 index 000000000..ba5c54ebd --- /dev/null +++ b/cli/sshagent_cmd.go @@ -0,0 +1,184 @@ +//go:build !windows + +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/ConductionNL/keepiq/cli/sshagent" + "github.com/ConductionNL/keepiq/sdk/go/client" + dcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" +) + +// vaultUnlocker opens the vault in this process and decrypts the user's +// ssh_key secrets. The suite key is used here and not kept; only the parsed +// SSH keys stay, in the agent's memory. +func vaultUnlocker(folder string, warn io.Writer, open func(string) (*client.Client, *dcrypto.UnlockedSuite, error)) sshagent.Unlocker { + return func(masterPassword string) ([]sshagent.Identity, error) { + c, suite, err := open(masterPassword) + if err != nil { + return nil, err + } + typeID, err := c.SSHKeyTypeID() + if err != nil { + return nil, err + } + folderID := "" + if folder != "" { + if folderID, err = c.FolderIDByName(folder); err != nil { + return nil, err + } + } + secrets, err := c.ListSecrets() + if err != nil { + return nil, err + } + decrypt := func(ct string) (string, error) { return dcrypto.DecryptField(ct, suite.Key) } + return sshagent.BuildKeyring(secrets, typeID, folderID, decrypt, warn), nil + } +} + +// detachedEnv marks the background copy that `keepiq ssh-agent` starts when +// its output goes to `eval "$(...)"` rather than to a terminal. +const detachedEnv = "KEEPIQ_SSH_AGENT_DETACHED" + +func cmdSSHAgent(args []string) error { + flags, err := parseAgentFlags(args) + if err != nil { + return err + } + detached := os.Getenv(detachedEnv) == "1" + if shouldDetach(flags, detached, isTerminal(os.Stdout)) { + return startDetached(args, flags) + } + var confirm sshagent.Confirmer + if flags.Confirm { + if confirm, err = sshagent.AskpassConfirmer(os.Getenv("SSH_ASKPASS")); err != nil { + return err + } + } + if err := sshagent.HardenProcess(); err != nil { + return fmt.Errorf("cannot disable core dumps: %w", err) + } + a := sshagent.New(sshagent.Options{ + Unlock: vaultUnlocker(flags.Folder, os.Stderr, openHumanSession), + Confirm: confirm, + Idle: time.Duration(flags.Idle) * time.Minute, + }) + if !flags.Locked { + masterPassword := "" + if detached { + // The starting process asked for it and hands it over on stdin. + line, _ := bufio.NewReader(os.Stdin).ReadString('\n') + masterPassword = strings.TrimRight(line, "\r\n") + } else { + masterPassword = promptSecret("Master password: ") + } + if err := a.UnlockWith(masterPassword); err != nil { + return err + } + } + + path := flags.Socket + if path == "" { + path = defaultAgentSocket() + } + l, err := sshagent.Listen(path) + if err != nil { + return err + } + defer os.Remove(path) + fmt.Printf("SSH_AUTH_SOCK=%s; export SSH_AUTH_SOCK;\n", path) + if detached { + // Closing stdout tells the starting process the socket is listening. + os.Stdout.Close() + } + if flags.Locked { + fmt.Fprintln(os.Stderr, "keepiq ssh-agent: locked; run `ssh-add -X` and enter your master password to unlock") + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-stop + l.Close() + }() + go func() { + for range time.Tick(30 * time.Second) { + a.ExpireIfIdle() + } + }() + + logf := func(format string, v ...any) { fmt.Fprintf(os.Stderr, format, v...) } + if err := sshagent.Serve(l, a, sshagent.PeerUID, logf); err != nil && !isClosed(err) { + return err + } + return nil +} + +// startDetached serves `eval "$(keepiq ssh-agent)"`: a command substitution +// waits for its command to end, so the agent itself runs in a background copy +// of this program, in its own session. This process asks for the master +// password on the terminal, passes it to that copy on a pipe, prints the +// exports once the socket listens and returns to the shell. +func startDetached(args []string, flags agentFlags) error { + exe, err := os.Executable() + if err != nil { + return err + } + stdin := "" + if !flags.Locked { + stdin = promptSecret("Master password: ") + "\n" + } + cmd := exec.Command(exe, append([]string{"ssh-agent"}, args...)...) + cmd.Env = append(os.Environ(), detachedEnv+"=1") + cmd.Stdin = strings.NewReader(stdin) + cmd.Stderr = os.Stderr + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + out, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + exports, _ := io.ReadAll(out) + if !strings.HasPrefix(string(exports), "SSH_AUTH_SOCK=") { + if werr := cmd.Wait(); werr != nil { + return fmt.Errorf("the agent did not start: %w", werr) + } + return errors.New("the agent did not start") + } + fmt.Print(string(exports)) + fmt.Printf("SSH_AGENT_PID=%d; export SSH_AGENT_PID;\n", cmd.Process.Pid) + return cmd.Process.Release() +} + +// shouldDetach is true only for `eval "$(keepiq ssh-agent)"`: stdout is a +// pipe, this is not already the background copy, and --foreground is not set. +// A service manager also gives a stdout that is no terminal (systemd's +// journal), and there the agent must stay the unit's main process. +func shouldDetach(flags agentFlags, detached bool, stdoutIsTerminal bool) bool { + return !flags.Foreground && !detached && !stdoutIsTerminal +} + +// isTerminal reports whether f is a character device, so a terminal and not a +// pipe such as the one `eval "$(...)"` reads. +func isTerminal(f *os.File) bool { + info, err := f.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +func isClosed(err error) bool { + return errors.Is(err, net.ErrClosed) +} diff --git a/cli/sshagent_cmd_test.go b/cli/sshagent_cmd_test.go new file mode 100644 index 000000000..7a84cfe1b --- /dev/null +++ b/cli/sshagent_cmd_test.go @@ -0,0 +1,166 @@ +//go:build !windows + +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/ssh" + + "github.com/ConductionNL/keepiq/sdk/go/client" + dcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" +) + +func TestParseAgentFlags(t *testing.T) { + f, err := parseAgentFlags(nil) + if err != nil || f.Idle != 60 || f.Confirm || f.Locked || f.Socket != "" { + t.Fatalf("defaults: %+v %v", f, err) + } + f, err = parseAgentFlags([]string{"--socket", "/run/x.sock", "--confirm", "--idle", "0", "--folder", "Deploy", "--locked"}) + if err != nil || f.Socket != "/run/x.sock" || !f.Confirm || f.Idle != 0 || f.Folder != "Deploy" || !f.Locked { + t.Fatalf("all flags: %+v %v", f, err) + } + for _, bad := range [][]string{{"--idle", "-1"}, {"--idle", "soon"}, {"--socket"}, {"--add"}} { + if _, err := parseAgentFlags(bad); err == nil { + t.Errorf("%v: expected an error", bad) + } + } +} + +// encryptField mirrors the browser's rsaEncrypt for an RSA-4096 key: +// [4-byte chunk count][512-byte RSA-OAEP-SHA256 blocks]. +func encryptField(t *testing.T, plaintext []byte, pub *rsa.PublicKey) string { + t.Helper() + const chunk = 446 + var blocks [][]byte + for len(plaintext) > 0 { + n := chunk + if len(plaintext) < n { + n = len(plaintext) + } + b, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pub, plaintext[:n], nil) + if err != nil { + t.Fatal(err) + } + blocks = append(blocks, b) + plaintext = plaintext[n:] + } + out := make([]byte, 4) + binary.BigEndian.PutUint32(out, uint32(len(blocks))) + for _, b := range blocks { + out = append(out, b...) + } + return base64.StdEncoding.EncodeToString(out) +} + +// The agent's unlock reads only ciphertext from the server, sends no body and +// no master password, and decrypts the SSH key in this process. +func TestVaultUnlockerReadsOnlyCiphertext(t *testing.T) { + suiteKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + t.Fatal(err) + } + _, sshKey, _ := ed25519.GenerateKey(rand.Reader) + block, _ := ssh.MarshalPrivateKey(sshKey, "") + privatePEM := pem.EncodeToMemory(block) + secrets := []client.Secret{ + {ID: "s1", Name: "GitHub deploy", TypeID: "t-ssh", Key: encryptField(t, privatePEM, &suiteKey.PublicKey)}, + {ID: "s2", Name: "Mail", TypeID: "t-login", Key: encryptField(t, []byte("hunter2"), &suiteKey.PublicKey)}, + } + + type seen struct{ method, path, body, auth string } + var mu sync.Mutex + var requests []seen + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + requests = append(requests, seen{r.Method, r.URL.Path, string(body), r.Header.Get("Authorization")}) + mu.Unlock() + switch r.URL.Path { + case "/apps/keepiq/api/v1/secret-types": + _, _ = w.Write([]byte(`[{"id":"t-login","name":"login"},{"id":"t-ssh","name":"ssh_key"}]`)) + case "/apps/keepiq/api/v1/secrets": + _ = json.NewEncoder(w).Encode(map[string]any{"items": secrets}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + const master = "correct horse battery staple" + open := func(pw string) (*client.Client, *dcrypto.UnlockedSuite, error) { + if pw != master { + t.Fatalf("the unlocker passed on %q", pw) + } + c := client.New(srv.URL) + c.WithAppPassword("alice", "app-password") + return c, &dcrypto.UnlockedSuite{Key: suiteKey}, nil + } + + var warn bytes.Buffer + keys, err := vaultUnlocker("", &warn, open)(master) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Name != "GitHub deploy" { + t.Fatalf("keys: %+v", keys) + } + want, _ := ssh.NewSignerFromKey(sshKey) + if !bytes.Equal(keys[0].Signer.PublicKey().Marshal(), want.PublicKey().Marshal()) { + t.Fatal("the decrypted key is not the vault key") + } + + for _, r := range requests { + if r.method != http.MethodGet || r.body != "" { + t.Errorf("%s %s sent a body or was not a read", r.method, r.path) + } + if strings.Contains(r.path+r.body+r.auth, master) || strings.Contains(r.body, "PRIVATE KEY") { + t.Errorf("%s leaked key material or the master password", r.path) + } + } + if len(requests) != 2 { + t.Fatalf("requests: %+v", requests) + } +} + +// A service manager such as systemd gives the agent a stdout that is not a +// terminal. Without --foreground the agent would detach, the unit's main +// process would exit, and systemd would stop the unit and kill the agent. +func TestTheAgentDetachesOnlyForEvalNotUnderAServiceManager(t *testing.T) { + cases := []struct { + name string + flags agentFlags + isDetached bool + terminal bool + want bool + }{ + {"eval pipe, plain", agentFlags{}, false, false, true}, + {"terminal", agentFlags{}, false, true, false}, + {"already the background copy", agentFlags{}, true, false, false}, + {"systemd: journal stdout with --foreground", agentFlags{Foreground: true}, false, false, false}, + {"systemd: --locked --foreground", agentFlags{Locked: true, Foreground: true}, false, false, false}, + } + for _, c := range cases { + if got := shouldDetach(c.flags, c.isDetached, c.terminal); got != c.want { + t.Errorf("%s: shouldDetach = %v, want %v", c.name, got, c.want) + } + } + f, err := parseAgentFlags([]string{"--locked", "--foreground"}) + if err != nil || !f.Foreground || !f.Locked { + t.Fatalf("parseAgentFlags(--locked --foreground) = %+v, %v", f, err) + } +} diff --git a/cli/sshagent_cmd_windows.go b/cli/sshagent_cmd_windows.go new file mode 100644 index 000000000..fa6b498c3 --- /dev/null +++ b/cli/sshagent_cmd_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package main + +import "fmt" + +func cmdSSHAgent([]string) error { + return fmt.Errorf("keepiq ssh-agent is not supported on Windows yet; run it in WSL") +} diff --git a/cli/sshagent_eval_test.go b/cli/sshagent_eval_test.go new file mode 100644 index 000000000..b63883d45 --- /dev/null +++ b/cli/sshagent_eval_test.go @@ -0,0 +1,80 @@ +//go:build !windows + +package main + +import ( + "context" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "golang.org/x/crypto/ssh/agent" +) + +// TestMain lets a test run this binary as the keepiq command itself: with +// KEEPIQ_TEST_RUN_MAIN set, the process is `keepiq `. +func TestMain(m *testing.M) { + if os.Getenv("KEEPIQ_TEST_RUN_MAIN") == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +// `eval "$(keepiq ssh-agent)"` must return to the shell with the agent still +// serving, as the README and the spec scenario promise. Found running the +// agent by hand on Linux (keepiq#786): the agent served in the foreground, so +// the command substitution never returned and the shell hung. +func TestEvalReturnsAndTheAgentKeepsServing(t *testing.T) { + dir, err := os.MkdirTemp("", "kq-agent-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + sock := filepath.Join(dir, "keepiq", "agent.sock") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "ssh-agent", "--locked", "--socket", sock) + cmd.Env = append(os.Environ(), "KEEPIQ_TEST_RUN_MAIN=1") + // A file, not a buffer: a buffer would make Output wait for every holder + // of the pipe, the background agent included. + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if ctx.Err() != nil { + t.Fatal("keepiq ssh-agent did not return; `eval \"$(keepiq ssh-agent)\"` would hang the shell") + } + if err != nil { + t.Fatalf("keepiq ssh-agent failed: %v", err) + } + + m := regexp.MustCompile(`SSH_AGENT_PID=(\d+); export SSH_AGENT_PID;`).FindStringSubmatch(string(out)) + if m == nil { + t.Fatalf("no SSH_AGENT_PID export in %q", out) + } + pid, _ := strconv.Atoi(m[1]) + defer syscall.Kill(pid, syscall.SIGTERM) + if !strings.Contains(string(out), "SSH_AUTH_SOCK="+sock+"; export SSH_AUTH_SOCK;") { + t.Fatalf("no SSH_AUTH_SOCK export for %s in %q", sock, out) + } + + conn, err := net.Dial("unix", sock) + if err != nil { + t.Fatalf("the agent is not serving after eval returned: %v", err) + } + defer conn.Close() + keys, err := agent.NewClient(conn).List() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(keys) != 0 { + t.Fatalf("a locked agent offered %d keys", len(keys)) + } +} diff --git a/cli/sshagent_flags.go b/cli/sshagent_flags.go new file mode 100644 index 000000000..7d43c5b00 --- /dev/null +++ b/cli/sshagent_flags.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" +) + +// agentFlags are the `keepiq ssh-agent` options (cli-ssh-agent D1). +type agentFlags struct { + Socket string + Confirm bool + Idle int // minutes; 0 disables + Folder string + Locked bool + // Foreground keeps the agent in this process even when stdout is not a + // terminal, for a service manager such as systemd. + Foreground bool +} + +// parseAgentFlags reads the ssh-agent flags. Idle defaults to 60 minutes. +func parseAgentFlags(args []string) (agentFlags, error) { + f := agentFlags{Idle: 60} + for i := 0; i < len(args); i++ { + a := args[i] + value := func() (string, error) { + if i+1 >= len(args) { + return "", fmt.Errorf("%s needs a value", a) + } + i++ + return args[i], nil + } + var err error + switch a { + case "--confirm": + f.Confirm = true + case "--locked": + f.Locked = true + case "--foreground": + f.Foreground = true + case "--socket": + f.Socket, err = value() + case "--folder": + f.Folder, err = value() + case "--idle": + var v string + if v, err = value(); err == nil { + f.Idle, err = strconv.Atoi(v) + if err != nil || f.Idle < 0 { + err = fmt.Errorf("--idle takes a number of minutes (0 turns the idle lock off)") + } + } + default: + return f, fmt.Errorf("unknown ssh-agent option %q", a) + } + if err != nil { + return f, err + } + } + return f, nil +} + +// defaultAgentSocket is $XDG_RUNTIME_DIR/keepiq/agent.sock on Linux and +// $TMPDIR/keepiq-/agent.sock elsewhere (macOS). +func defaultAgentSocket() string { + if runtime.GOOS == "linux" { + if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { + return filepath.Join(dir, "keepiq", "agent.sock") + } + } + return filepath.Join(os.TempDir(), "keepiq-"+strconv.Itoa(os.Getuid()), "agent.sock") +} diff --git a/cli/term.go b/cli/term.go index c03d7e1c6..63e72f364 100644 --- a/cli/term.go +++ b/cli/term.go @@ -9,7 +9,7 @@ import ( // readPasswordNoEcho reads a line from stdin with terminal echo disabled, so the // master password / app-password never appears on screen. It toggles echo via -// `stty` (present on any POSIX shell) to keep the CLI stdlib-only — no cgo, no +// `stty` (present on any POSIX shell) with no cgo and no // external Go module, single static binary. Returns ok=false when stdin is not // an interactive TTY (piped input), so the caller falls back to a plain read. func readPasswordNoEcho() (string, bool) { diff --git a/cli/useonly_test.go b/cli/useonly_test.go new file mode 100644 index 000000000..4550992a7 --- /dev/null +++ b/cli/useonly_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ConductionNL/keepiq/sdk/go/client" +) + +// A use-only copy is never printed or copied by the CLI +// (sharing-use-only-and-expiring-shares task 4.3). +func TestUseOnlyCopyRefusesShowGetAndCopyOfTheValue(t *testing.T) { + s := &client.Secret{ID: "copy", Name: "Supplier portal", UseOnly: true} + + for _, field := range []string{"", "key", "additionalFields"} { + err := refuseUseOnly(s, field) + if err == nil || err.Error() != useOnlyRefusal { + t.Fatalf("field %q: want the use-only refusal, got %v", field, err) + } + } + + for _, field := range []string{"name", "url", "login", "id"} { + if err := refuseUseOnly(s, field); err != nil { + t.Fatalf("field %q is plain metadata and must stay readable: %v", field, err) + } + } +} + +func TestNormalSecretIsNotRefused(t *testing.T) { + if err := refuseUseOnly(&client.Secret{ID: "mine"}, "key"); err != nil { + t.Fatalf("a normal secret must not be refused: %v", err) + } +} + +func TestListMarksAUseOnlyCopy(t *testing.T) { + line := listLine(client.Secret{ID: "copy", Name: "Supplier portal", UseOnly: true}) + if !strings.HasSuffix(line, "Supplier portal [use only]") { + t.Fatalf("want a use-only marker, got %q", line) + } + if strings.Contains(listLine(client.Secret{ID: "mine", Name: "Mine"}), "use only") { + t.Fatal("a normal secret must carry no marker") + } +} diff --git a/composer.json b/composer.json index 8269621b3..09dd934a6 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require": { "php": "^8.3", - "phpseclib/phpseclib": "^3.0", + "phpseclib/phpseclib": "^4.0", "ramsey/uuid": "^4.9", "web-token/jwt-library": "^4.0" }, diff --git a/composer.lock b/composer.lock index e44cb03b9..83acb6b07 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "390fc6d7ee21a2332cff53180c9bfc98", + "content-hash": "b4146cfd9c6014b1697b7f94432f2746", "packages": [ { "name": "brick/math", @@ -135,83 +135,37 @@ }, "time": "2025-09-24T15:06:41+00:00" }, - { - "name": "paragonie/random_compat", - "version": "v9.99.100", - "source": { - "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", - "shasum": "" - }, - "require": { - "php": ">= 7" - }, - "require-dev": { - "phpunit/phpunit": "4.*|5.*", - "vimeo/psalm": "^1" - }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." - }, - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" - } - ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", - "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" - }, - "time": "2020-10-15T08:29:30+00:00" - }, { "name": "phpseclib/phpseclib", - "version": "3.0.56", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", - "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/bb7b959c8159957edae6f5084ebbac765d310e16", + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16", "shasum": "" }, "require": { - "paragonie/constant_time_encoding": "^1|^2|^3", - "paragonie/random_compat": "^1.4|^2.0|^9.99.99", - "php": ">=5.6.1" + "paragonie/constant_time_encoding": "^2|^3", + "php": ">=8.1", + "symfony/polyfill-php82": "^1.26" }, "require-dev": { - "phpunit/phpunit": "*" + "brianium/paratest": "^7.22", + "ext-xml": "*", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpunit/phpunit": "^13", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "*" }, "suggest": { "ext-dom": "Install the DOM extension to load XML formatted public keys.", "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", - "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." }, "type": "library", @@ -220,7 +174,7 @@ "phpseclib/bootstrap.php" ], "psr-4": { - "phpseclib3\\": "phpseclib/" + "phpseclib4\\": "phpseclib/" } }, "notification-url": "https://packagist.org/downloads/", @@ -252,10 +206,16 @@ "name": "Graham Campbell", "email": "graham@alt-three.com", "role": "Developer" + }, + { + "name": "Jack Worman", + "email": "jack.worman@gmail.com", + "homepage": "https://jackworman.com", + "role": "Developer" } ], "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", - "homepage": "http://phpseclib.sourceforge.net", + "homepage": "https://phpseclib.com/", "keywords": [ "BigInteger", "aes", @@ -277,7 +237,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" + "source": "https://github.com/phpseclib/phpseclib/tree/4.0.1" }, "funding": [ { @@ -293,7 +253,7 @@ "type": "tidelift" } ], - "time": "2026-08-03T04:36:50+00:00" + "time": "2026-08-26T12:15:13+00:00" }, { "name": "psr/clock", @@ -678,6 +638,86 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/polyfill-php82", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/843bbe8b2a7934307b749751eede0f111f5d1cfc", + "reference": "843bbe8b2a7934307b749751eede0f111f5d1cfc", + "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\\Php82\\": "" + }, + "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.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/tree/v1.43.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-09-25T15:50:05+00:00" + }, { "name": "web-token/jwt-library", "version": "4.2.3", @@ -2637,16 +2677,16 @@ }, { "name": "nextcloud/ocp", - "version": "v35.0.0", + "version": "v35.0.1", "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf" + "reference": "088e38c04842e027ff255d90b689817b74a54e60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf", - "reference": "94d85a0ba6b3b3911c25b2eddb0ca0fe5691acbf", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/088e38c04842e027ff255d90b689817b74a54e60", + "reference": "088e38c04842e027ff255d90b689817b74a54e60", "shasum": "" }, "require": { @@ -2684,9 +2724,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/v35.0.0" + "source": "https://github.com/nextcloud-deps/ocp/tree/v35.0.1" }, - "time": "2026-09-04T01:52:36+00:00" + "time": "2026-09-23T02:18:13+00:00" }, { "name": "nikic/php-parser", @@ -3936,11 +3976,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.14", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", - "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -3996,7 +4036,7 @@ "type": "github" } ], - "time": "2026-09-12T21:39:33+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", @@ -4692,12 +4732,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1" + "reference": "69ddaead6caa68f4f6190e2ee732f103326e78ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/3c9ad688ad8826203588ec49363f73f4deb590c1", - "reference": "3c9ad688ad8826203588ec49363f73f4deb590c1", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/69ddaead6caa68f4f6190e2ee732f103326e78ba", + "reference": "69ddaead6caa68f4f6190e2ee732f103326e78ba", "shasum": "" }, "conflict": { @@ -4736,9 +4776,10 @@ "andreapollastri/cipi": "<=3.1.15", "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", "aoe/restler": "<1.7.1", - "apache-solr-for-typo3/solr": "<2.8.3", + "apache-solr-for-typo3/solr": "<11.2.8|>=11.5,<11.6.6|>=12,<12.1.4|>=13,<13.1.4", + "apache/thrift": "<0.24", "apereo/phpcas": "<1.6", - "api-platform/core": "<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", + "api-platform/core": "<4.1.30|>=4.2,<4.2.26|>=4.3,<4.3.12", "api-platform/graphql": "<3.4.17|>=4,<4.0.22|>=4.1,<4.1.5", "api-platform/hal": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", "api-platform/json-api": ">=4,<4.1.29|>=4.2,<4.2.25|>=4.3,<4.3.8", @@ -4764,7 +4805,7 @@ "azuracast/azuracast": "<=0.23.5", "b13/seo_basics": "<0.8.2", "backdrop/backdrop": "<=1.32", - "backpack/crud": "<4.0.63|>=4.1,<4.1.69|>=5,<5.0.13", + "backpack/crud": "<6.8.15|>=7,<7.0.47", "backpack/filemanager": "<2.0.2|>=3,<3.0.9", "bacula-web/bacula-web": "<9.7.1", "badaso/core": "<=2.9.11", @@ -4796,15 +4837,17 @@ "brotkrueml/codehighlight": "<2.7", "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", "brotkrueml/typo3-matomo-integration": "<1.3.2", - "buddypress/buddypress": "<7.2.1", + "buddypress/buddypress": "<14.5", "bugsnag/bugsnag-laravel": ">=2,<2.0.2", "bvbmedia/multishop": "<2.0.39", "bytefury/crater": "<6.0.2", "cachethq/cachet": "<2.5.1", "cadmium-org/cadmium-cms": "<=0.4.9", - "cakephp/authentication": "<3.3.6|>=4,<4.1.1", - "cakephp/cakephp": "<4.5.11|>=4.6,<4.6.4|>=5,<5.1.7|>=5.2,<5.2.13|>=5.3,<5.3.6", - "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", + "cakephp/authentication": "<2.11.1|>=3,<3.3.6|>=4,<4.1.1", + "cakephp/cakephp": "<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", + "cakephp/database": ">=3,<4.5.12|>=4.6,<4.6.5|>=5,<5.1.10|>=5.2,<5.2.15|>=5.3,<5.3.7", + "cakephp/debug_kit": "<4.10.3|>=5,<5.2.4", + "cakephp/queue": ">=0.1.10,<2.3.1", "cardgate/magento2": "<2.0.33", "cardgate/woocommerce": "<=3.1.15", "cart2quote/module-quotation": ">=4.1.6,<4.4.6|>=5,<5.4.4", @@ -4816,6 +4859,7 @@ "centreon/centreon": "<22.10.15", "cesargb/laravel-magiclink": ">=2,<2.25.1", "cesnet/simplesamlphp-module-proxystatistics": "<3.1", + "chamilo/chamilo-lms": "<=2", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "chrome-php/chrome": "<1.14", "ci4-cms-erp/ci4ms": "<=0.31.8", @@ -4824,26 +4868,28 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.3", + "code16/sharp": "<9.22.5", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", - "codeigniter4/framework": "<4.7.2", + "codeigniter4/framework": "<4.7.4", "codeigniter4/shield": "<1.0.0.0-beta8", "codiad/codiad": "<=2.8.4", "codingms/additional-tca": ">=1.7,<1.15.17|>=1.16,<1.16.9", - "codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5", + "codingms/modules": "<7.10.4|>=8,<8.1.4", "commerceteam/commerce": ">=0.9.6,<0.9.9", "components/jquery": ">=1.0.3,<3.5", - "composer/composer": "<2.2.29|>=2.3,<2.10.2", + "composer/composer": "<2.2.30|>=2.3,<2.10.3", "concrete5/concrete5": "<9.5.2", "concrete5/core": "<8.5.8|>=9,<9.1", + "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", - "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", - "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<5.3.48|>=5.4,<5.7.9", + "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.48|>=5.4,<5.7.9", + "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", + "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", "coreshop/core-shop": "<4.1.9|==5", "corveda/phpsandbox": "<1.3.5", "cosenary/instagram": "<=2.3", @@ -4852,7 +4898,7 @@ "cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2", "craftcms/aws-s3": ">=2.0.2,<=2.2.4", "craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1", - "craftcms/cms": "<4.18|>=5,<5.10", + "craftcms/cms": "<4.18.3|>=5,<5.10.8", "craftcms/commerce": ">=4,<=4.11.1|>=5,<=5.6.4", "craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1", "craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21", @@ -4873,7 +4919,7 @@ "dcat/laravel-admin": "<=2.1.3|==2.2.0.0-beta|==2.2.2.0-beta", "dedoc/scramble": ">=0.13.2,<0.13.22", "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", - "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", + "derhansen/sf_event_mgt": "<5.9.3|>=6,<6.7.2|>=7,<7.9.3|>=8,<8.6.2|>=9,<9.0.3", "desperado/xml-bundle": "<=0.1.7", "dev-lancer/minecraft-motd-parser": "<=1.0.5", "devcode-it/openstamanager": "<=2.10.1", @@ -4935,7 +4981,7 @@ "drupal/umami_analytics": "<1.0.1", "duncanmcclean/guest-entries": "<3.1.2", "dweeves/magmi": "<=0.7.24", - "easycorp/easyadmin-bundle": ">=4,<4.29.10|>=5,<5.0.13", + "easycorp/easyadmin-bundle": ">=4,<4.29.16|>=5,<5.5.1", "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1", "ecodev/newsletter": "<=4", "ectouch/ectouch": "<=2.7.2", @@ -4980,7 +5026,7 @@ "feehi/feehicms": "<=2.1.1", "fenom/fenom": "<=2.12.1", "filament/actions": ">=3.2,<3.2.123|>=4,<=4.11.3|>=5,<=5.6.3", - "filament/filament": ">=3,<=3.3.51|>=4,<4.11.5|>=5,<5.6.5", + "filament/filament": ">=3,<=3.3.51|>=4,<4.12.6|>=5,<5.7.6", "filament/forms": ">=3,<=3.3.52", "filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4", "filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4", @@ -5002,6 +5048,7 @@ "floriangaerber/magnesium": "<0.3.1", "fluidtypo3/vhs": "<5.1.1", "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", + "fof/oauth": "<1.7.4|>=2.0.0.0-beta1,<2.0.0.0-beta4", "fof/pretty-mail": "<=1.1.2", "fof/upload": "<1.2.3", "foodcoopshop/foodcoopshop": ">=3.2,<3.6.1", @@ -5009,7 +5056,7 @@ "forkcms/forkcms": "<5.11.1", "fossar/tcpdf-parser": "<6.2.22", "francoisjacquet/rosariosis": "<=11.5.1", - "frappant/frp-form-answers": "<3.1.2|>=4,<4.0.2", + "frappant/frp-form-answers": "<5.0.5|>=6,<6.1.3|>=7,<7.1.1", "friendsofsymfony/oauth2-php": "<1.3", "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", "friendsofsymfony/user-bundle": ">=1,<1.3.5", @@ -5020,7 +5067,7 @@ "friendsoftypo3/tt-address": "<8.1.2|>=9,<9.1.1|>=10,<10.0.1", "froala/wysiwyg-editor": "<=4.3", "frosh/adminer-platform": "<2.2.1", - "froxlor/froxlor": "<2.3.7", + "froxlor/froxlor": "<2.3.8", "frozennode/administrator": "<=5.0.12", "fuel/core": "<1.8.1", "funadmin/funadmin": "<=7.1.0.0-RC6", @@ -5029,10 +5076,10 @@ "georgringer/news": "<10.0.4|>=11,<11.4.4|>=12,<12.3.2|>=13,<13.0.2|>=14,<14.0.3", "geshi/geshi": "<=1.0.9.1", "getformwork/formwork": "<=2.3.3", - "getgrav/grav": "<=2.0.0.0-RC8", + "getgrav/grav": "<=2.0.19", "getgrav/grav-plugin-api": "<1.0.0.0-beta15", "getgrav/grav-plugin-form": "<9.1", - "getkirby/cms": "<=4.9.3|>=5,<=5.4.3", + "getkirby/cms": "<4.9.5|>=5,<5.5.2", "getkirby/kirby": "<3.9.8.3-dev|>=3.10,<3.10.1.2-dev|>=4,<4.7.1", "getkirby/panel": "<2.5.14", "getkirby/starterkit": "<=3.7.0.2", @@ -5049,7 +5096,7 @@ "gregwar/rst": "<1.0.3", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", - "guzzlehttp/guzzle": "<7.15.1", + "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", "guzzlehttp/guzzle-services": "<1.5.4", "guzzlehttp/oauth-subscriber": "<0.8.1", "guzzlehttp/psr7": "<2.12.3", @@ -5086,10 +5133,10 @@ "imdbphp/imdbphp": "<=5.1.1", "impresscms/impresscms": "<=1.4.5", "impresspages/impresspages": "<1.0.13", - "in2code/femanager": "<6.4.2|>=7,<7.5.3|>=8,<8.3.1", + "in2code/femanager": "<6.4.5|>=7,<7.5.5|>=8,<8.4.2|>=13,<13.3.5", "in2code/ipandlanguageredirect": "<5.1.2", "in2code/lux": "<17.6.1|>=18,<24.0.2", - "in2code/powermail": "<7.5.1|>=8,<8.5.1|>=9,<10.9.1|>=11,<12.5.3|==13", + "in2code/powermail": "<10.9.3|>=11,<12.6.1|>=13,<13.2.1", "innologi/typo3-appointments": "<2.0.6", "intelliants/subrion": "<4.2.2", "inter-mediator/inter-mediator": "==5.5", @@ -5105,6 +5152,7 @@ "james-heinrich/getid3": "<1.9.21", "james-heinrich/phpthumb": "<=1.7.23", "jasig/phpcas": "<1.3.3", + "jayanta/laravel-threat-detection": "<1.9", "jbartels/wec-map": "<3.0.3", "jcbrand/converse.js": "<3.3.3", "jleehr/canto-saas-api": "<=2", @@ -5126,8 +5174,12 @@ "jsdecena/laracom": "<2.0.9", "jsmitty12/phpwhois": "<5.1", "juzaweb/cms": "<=3.4.2", - "jweiland/events2": "<8.3.8|>=9,<9.0.6", + "jweiland/clubdirectory": "<6.0.2|>=7,<7.0.2|>=8,<8.1.3", + "jweiland/events2": "<8.6.3|>=9,<9.4.2|>=10,<10.2.12", "jweiland/kk-downloader": "<1.2.2", + "jweiland/pforum": "<4.0.4|>=5,<5.0.1|>=6,<6.2.4", + "jweiland/telephonedirectory": "<4.1.1|>=5,<5.0.1|>=6,<6.2", + "jweiland/yellowpages2": "<6.1.6|>=7,<7.0.3|>=8,<8.1.2", "kantorge/yaffa": "<=2", "kazist/phpwhois": "<=4.2.6", "kelvinmo/simplejwt": "<=1.1", @@ -5139,7 +5191,7 @@ "klaviyo/magento2-extension": ">=1,<3", "knplabs/knp-snappy": "<=1.7", "kohana/core": "<3.3.3", - "koillection/koillection": "<1.6.12", + "koillection/koillection": "<1.8.4", "krayin/laravel-crm": "<=2.2", "kreait/firebase-php": ">=3.2,<3.8.1", "kumbiaphp/kumbiapp": "<=1.1.1", @@ -5151,7 +5203,7 @@ "lara-zeus/artemis": ">=1,<=1.0.6", "lara-zeus/dynamic-dashboard": ">=3,<=3.0.1", "laravel/fortify": "<1.11.1", - "laravel/framework": "<12.61.1|>=13,<13.12", + "laravel/framework": "<12.69|>=13,<13.30", "laravel/laravel": ">=5.4,<5.4.22", "laravel/passport": ">=13,<13.7.1", "laravel/pulse": "<1.3.1", @@ -5161,25 +5213,27 @@ "lavalite/cms": "<=10.1", "lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2", "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", - "league/commonmark": "<=2.8.1", - "league/flysystem": "<1.1.4|>=2,<2.1.1", + "league/commonmark": "<=2.10.1", + "league/flysystem": "<=3.35.2", "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", "leantime/leantime": "<3.3", "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", "libreform/libreform": ">=2,<=2.0.8", - "librenms/librenms": "<26.3", + "librenms/librenms": "<26.7", "liftkit/database": "<2.13.2", "lightsaml/lightsaml": "<1.3.5", "limesurvey/limesurvey": "<=7.0.0.0-beta1", "livehelperchat/livehelperchat": "<=3.91", "livewire-filemanager/filemanager": "<=1.0.4", - "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4", + "livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<=3.8.2|>=4.0.0.0-beta1,<=4.3.3", "livewire/volt": "<1.7", "lms/routes": "<2.1.1", "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", + "lochmueller/html5videoplayer-powermail": "<=0.2.1", "lomkit/laravel-rest-api": "<2.13", "luracast/restler": "<3.1", "luyadev/yii-helpers": "<1.2.1", + "maatwebsite/excel": ">=3.1.8,<3.1.70", "macropay-solutions/laravel-crud-wizard-free": "<3.4.17", "maestroerror/php-heic-to-jpg": "<1.0.5", "magento/community-edition": "<2.4.6.0-patch13|>=2.4.7.0-beta1,<2.4.7.0-patch8|>=2.4.8.0-beta1,<2.4.8.0-patch3|>=2.4.9.0-alpha1,<2.4.9.0-alpha3|==2.4.9", @@ -5197,6 +5251,7 @@ "marcwillmann/turn": "<0.3.3", "markhuot/craftql": "<=1.3.7", "marshmallow/nova-tiptap": "<5.7", + "mask/mask": "<8.3.12|>=9,<9.0.11", "matomo/matomo": "<1.11", "matyhtf/framework": "<3.0.6", "mautic/core": "<5.2.11|>=6,<6.0.9|>=7,<7.1.2", @@ -5204,6 +5259,7 @@ "mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7", "maximebf/debugbar": "<1.19", "mckenziearts/livewire-markdown-editor": "<1.3", + "mcp/sdk": ">=0.5,<0.7.1", "mdanter/ecc": "<2", "mediawiki/abuse-filter": "<1.39.9|>=1.40,<1.41.3|>=1.42,<1.42.2", "mediawiki/cargo": "<3.8.3", @@ -5211,7 +5267,7 @@ "mediawiki/data-transfer": ">=1.39,<1.39.11|>=1.41,<1.41.3|>=1.42,<1.42.2", "mediawiki/maps": "<12.1.3", "mediawiki/matomo": "<2.4.3", - "mediawiki/semantic-media-wiki": "<4.0.2", + "mediawiki/semantic-media-wiki": "<=7.2.1", "mehrwert/phpmyadmin": "<3.2", "melisplatform/melis-asset-manager": "<5.0.1", "melisplatform/melis-cms": "<5.3.4", @@ -5223,9 +5279,9 @@ "microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1", "microsoft/microsoft-graph-beta": "<2.0.1", "microsoft/microsoft-graph-core": "<2.0.2", - "microweber/microweber": "<2.0.20", + "microweber/microweber": "<=2.0.20", "mikehaertl/php-shellcommand": "<1.6.1", - "mineadmin/mineadmin": "<=3.0.9", + "mineadmin/mineadmin": "<3.2.0.0-alpha2", "miniorange/miniorange-saml": "<1.4.3", "miraheze/ts-portal": "<=33", "mittwald/typo3_forum": "<1.2.1", @@ -5234,7 +5290,7 @@ "mobiledetect/mobiledetectlib": "<2.8.32", "modx/revolution": "<=3.1", "mojo42/jirafeau": "<4.4", - "mongodb/mongodb": ">=1,<1.9.2", + "mongodb/mongodb": "<1.21.4|>=2,<2.4.1", "mongodb/mongodb-extension": "<1.21.2", "monolog/monolog": ">=1.8,<1.12", "moodle/moodle": "<4.5.9|>=5.0.0.0-beta,<5.0.5|>=5.1.0.0-beta,<5.1.2", @@ -5282,9 +5338,9 @@ "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": "<1.1.2", "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", - "october/october": "<3.7.14|>=4,<4.1.10", + "october/october": "<3.7.14|>=4,<4.1.10|>=4.3,<4.3.4", "october/rain": "<=3.7.13|>=4,<=4.1.9", - "october/system": "<3.7.16|>=4,<4.1.16", + "october/system": "<3.7.17|>=4,<4.2.23", "oliverklee/phpunit": "<3.5.15", "omeka/omeka-s": "<4.0.3", "onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1", @@ -5313,9 +5369,9 @@ "pagekit/pagekit": "<=1.0.18", "paragonie/ecc": "<2.0.1", "paragonie/random_compat": "<2", - "paragonie/sodium_compat": "<1.24|>=2,<2.5", + "paragonie/sodium_compat": "<1.24.1|>=2,<2.5.1", "passbolt/passbolt_api": "<4.6.2", - "paymenter/paymenter": "<=1.5.4", + "paymenter/paymenter": "<=1.5.6", "paypal/adaptivepayments-sdk-php": "<=3.9.2", "paypal/invoice-sdk-php": "<=3.9", "paypal/merchant-sdk-php": "<3.12", @@ -5328,6 +5384,7 @@ "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", "personnummer/personnummer": "<3.0.2", "ph7software/ph7builder": "<=17.9.1", + "phalcon/cphalcon": "<=5.15", "phanan/koel": "<=9.7", "pheditor/pheditor": "<2.0.8", "phenx/php-svg-lib": "<0.5.2", @@ -5336,20 +5393,21 @@ "php-standard-library/h2": ">=6.1,<6.1.2|>=6.2,<6.2.1", "php-standard-library/php-standard-library": ">=6.1,<6.1.2|>=6.2,<6.2.1", "phpbb/phpbb": "<3.3.16|==4.0.0.0-alpha1", + "phpcsstandards/phpcsutils": ">=1.0.0.0-alpha1,<1.2.3", "phpems/phpems": ">=6,<=6.1.3", "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", "phpmailer/phpmailer": "<6.5", "phpmussel/phpmussel": ">=1,<1.6", "phpmyadmin/phpmyadmin": "<5.2.2", - "phpmyfaq/phpmyfaq": "<4.1.4", + "phpmyfaq/phpmyfaq": "<4.2.0.0-alpha", "phpoffice/common": "<0.2.9", "phpoffice/math": "<=0.2", "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.5|>=2,<=2.1.17|>=2.2,<=2.4.6|>=3,<=3.10.6|>=4,<=5.8", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<=2.0.54|>=3,<=3.0.53", + "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", "phpservermon/phpservermon": "<3.6", - "phpsysinfo/phpsysinfo": "<3.4.3", + "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6", "phpwhois/phpwhois": "<=4.2.5", "phpxmlrpc/extras": "<0.6.1", @@ -5363,16 +5421,19 @@ "pimcore/demo": "<10.3", "pimcore/ecommerce-framework-bundle": "<1.0.10", "pimcore/perspective-editor": "<1.5.1", - "pimcore/pimcore": "<=12.3.8|>=2026.1,<2026.1.3", + "pimcore/pimcore": "<=12.3.9|>=2026.1,<=2026.1.5", + "pimcore/studio-backend-bundle": "<2025.4.6|>=2026.1,<2026.1.6", "pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1", "piwik/piwik": "<1.11", "pixelfed/pixelfed": "<0.12.5", + "plank/laravel-mediable": "<7", "plotly/plotly.js": "<2.25.2", "pocketmine/bedrock-protocol": "<8.0.2", "pocketmine/pocketmine-mp": "<5.42.1", "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", "pontedilana/php-weasyprint": "<=2.5.1", "poweradmin/poweradmin": "<4.2.5|>=4.3,<4.3.4", + "predis/predis": ">=3.0.0.0-RC1-dev,<3.3", "pressbooks/pressbooks": "<5.18", "prestashop/autoupgrade": ">=4,<4.10.1", "prestashop/blockreassurance": "<=5.1.3", @@ -5386,7 +5447,7 @@ "prestashop/ps_emailsubscription": "<2.6.1", "prestashop/ps_facetedsearch": "<4.0.4", "prestashop/ps_linklist": "<3.1", - "privatebin/privatebin": "<1.4|>=1.5,<1.7.4|>=1.7.7,<2.0.3", + "privatebin/privatebin": "<=2.0.4", "processwire/processwire": "<=3.0.255", "propel/propel": ">=2.0.0.0-alpha1,<2.0.0.0-alpha8", "propel/propel1": ">=1,<1.7.2", @@ -5409,9 +5470,9 @@ "ralffreit/mfa-email": "<1.0.7|==2", "rankmath/seo-by-rank-math": "<=1.0.95", "rap2hpoutre/laravel-log-viewer": "<0.13", - "react/http": ">=0.7,<1.9", + "react/http": ">=0.6,<1.11.1", "really-simple-plugins/complianz-gdpr": "<6.4.2", - "redaxo/source": "<5.21.1", + "redaxo/source": "<=5.21.1", "remdex/livehelperchat": "<4.29", "renolit/reint-downloadmanager": "<4.0.2|>=5,<5.0.1", "reportico-web/reportico": "<=8.1", @@ -5437,8 +5498,9 @@ "setasign/fpdi": "<2.6.7", "sfroemken/url_redirect": "<=1.2.1", "sheng/yiicms": "<1.2.1", + "shlinkio/shlink": "<=5.0.1", "shopper/cart": "<2.8", - "shopper/framework": "<2.8", + "shopper/framework": "<2.9.2", "shopware/core": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", "shopware/platform": "<6.6.10.18-dev|>=6.7,<6.7.10.1-dev", "shopware/production": "<=6.3.5.2", @@ -5468,8 +5530,8 @@ "silverstripe/versioned-admin": ">=1,<1.11.1", "simogeo/filemanager": "<=2.5", "simple-updates/phpwhois": "<=1", - "simplesamlphp/saml2": "<=4.20.2|>=5,<5.0.6|>=6,<6.2.1", - "simplesamlphp/saml2-legacy": "<=4.20.2", + "simplesamlphp/saml2": "<4.19.3|>=4.20,<=4.20.2|>=5,<5.0.6|>=6,<6.2.1", + "simplesamlphp/saml2-legacy": "<4.19.3|>=4.20,<=4.20.2", "simplesamlphp/simplesamlphp": "<=2.4.6|>=2.5,<=2.5.1", "simplesamlphp/simplesamlphp-module-casserver": "<=7.0.2", "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", @@ -5485,8 +5547,8 @@ "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", "slim/slim": "<2.6|>=4.4,<=4.15.1", "slub/slub-events": "<3.0.3", - "smarty/smarty": "<4.5.3|>=5,<5.1.1", - "snipe/snipe-it": "<=8.6.1", + "smarty/smarty": "<4.5.7|>=5,<5.8.4", + "snipe/snipe-it": "<8.7", "socalnick/scn-social-auth": "<1.15.2", "socialiteproviders/steam": "<1.1", "solidinvoice/solidinvoice": "<=2.3.15", @@ -5502,20 +5564,20 @@ "spomky-labs/otphp": "<11.4.3", "spoon/library": "<1.4.1", "spoonity/tcpdf": "<6.2.22", - "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "squizlabs/php_codesniffer": "<3.13.6|>=4,<4.0.2", "ssddanbrown/bookstack": "<24.05.1", "starcitizentools/citizen-skin": ">=1.9.4,<3.9", "starcitizentools/short-description": ">=4,<4.0.1", "starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1", "starcitizenwiki/embedvideo": "<=4", - "statamic/cms": "<5.74|>=6,<6.20.3", + "statamic/cms": "<5.74.3|>=6,<=6.30", "stormpath/sdk": "<9.9.99", - "studio-42/elfinder": "<=2.1.67", + "studio-42/elfinder": "<2.1.70", "studiomitte/friendlycaptcha": "<0.1.4", "subhh/libconnect": "<7.0.8|>=8,<8.1", "sukohi/surpass": "<1", "sulu/form-bundle": ">=2,<2.5.3", - "sulu/sulu": "<=2.6.22|>=3,<=3.0.5", + "sulu/sulu": "<2.6.25|>=3,<3.0.8", "sumocoders/framework-user-bundle": "<1.4", "superbig/craft-audit": "<3.0.2", "svewap/a21glossary": "<=0.4.10", @@ -5585,6 +5647,7 @@ "symfony/webhook": ">=6.3,<6.3.8", "symfony/yaml": "<5.4.52|>=6,<6.4.40|>=7,<7.4.12|>=8,<8.0.12", "symphonycms/symphony-2": "<2.6.4", + "syssy/syssy-typo3-extension": "<3.0.6", "t3/dce": "<0.11.5|>=2.2,<2.6.2", "t3g/svg-sanitizer": "<1.0.3", "t3s/content-consent": "<1.0.3|>=2,<2.0.2", @@ -5597,7 +5660,7 @@ "thelia/thelia": ">=2.0.0.0-beta1,<2.1.3", "theonedemon/phpwhois": "<=4.2.5", "thinkcmf/thinkcmf": "<6.0.8", - "thorsten/phpmyfaq": "<4.1.4", + "thorsten/phpmyfaq": "<4.2.0.0-alpha", "tikiwiki/tiki-manager": "<=17.1", "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", "tinymce/tinymce": "<7.9.3|>=8,<8.5.1", @@ -5621,10 +5684,10 @@ "twig/twig": "<3.27", "typicms/core": "<12.0.5|>=13,<13.0.9|>=14,<14.0.27|>=15,<15.0.29|>=16,<16.1.7", "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", - "typo3/cms-backend": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-backend": "<10.4.60|>=11,<11.5.54|>=12,<12.4.49|>=13,<13.4.35|>=14,<14.3.7", "typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", "typo3/cms-beuser": ">=9,<9.5.55|>=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", - "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", + "typo3/cms-core": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.34|>=14,<14.3.6", "typo3/cms-dashboard": ">=10,<10.4.54|>=11,<11.5.48|>=12,<12.4.37|>=13,<13.4.18", "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", "typo3/cms-extensionmanager": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2", @@ -5635,7 +5698,7 @@ "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", "typo3/cms-indexed-search": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8|==13.4.2", - "typo3/cms-lowlevel": ">=11,<=11.5.41", + "typo3/cms-lowlevel": ">=11,<=11.5.41|>=14.2,<14.3.7", "typo3/cms-recordlist": ">=11,<11.5.48", "typo3/cms-recycler": "<10.4.57|>=11,<11.5.51|>=12,<12.4.46|>=13,<13.4.31|>=14,<14.3.3", "typo3/cms-redirects": ">=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1", @@ -5661,7 +5724,7 @@ "uvdesk/core-framework": "<=1.1.1", "vanilla/safecurl": "<0.9.2", "verbb/comments": "<1.5.5", - "verbb/formie": "<3.1.28", + "verbb/formie": "<3.1.31", "verbb/image-resizer": "<2.0.9", "verbb/knock-knock": "<1.2.8", "verot/class.upload.php": "<=2.1.6", @@ -5681,7 +5744,7 @@ "web-feet/coastercms": "==5.5", "web-token/jwt-bundle": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-token/jwt-experimental": "<4.1.7", - "web-token/jwt-framework": "<4.1.7", + "web-token/jwt-framework": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-token/jwt-library": "<3.4.10|>=4,<4.0.7|>=4.1,<4.1.7", "web-tp3/wec_map": "<3.0.3", "webbuilders-group/silverstripe-kapost-bridge": "<0.4", @@ -5694,17 +5757,17 @@ "wikibase/wikibase": "<=1.39.3", "wikimedia/parsoid": "<0.12.2", "willdurand/js-translation-bundle": "<2.1.1", - "winter/wn-backend-module": "<1.2.12", - "winter/wn-cms-module": "<=1.2.9", + "winter/wn-backend-module": "<1.2.14", + "winter/wn-cms-module": "<=1.2.12", "winter/wn-dusk-plugin": "<2.1", - "winter/wn-system-module": "<1.2.4", + "winter/wn-system-module": "<1.2.13", "wintercms/winter": "<=1.2.3", "wireui/wireui": "<1.19.3|>=2,<2.1.3", "wnx/laravel-backup-restore": "<=1.9.3", "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", "wp-cli/wp-cli": ">=0.12,<2.5", "wp-coding-standards/wpcs": ">=0.14.1,<3.4.1", - "wp-graphql/wp-graphql": "<=2.6", + "wp-graphql/wp-graphql": "<2.22.2", "wp-premium/gravityforms": "<2.4.21", "wpanel/wpanel4-cms": "<=4.3.1", "wpcloud/wp-stateless": "<3.2", @@ -5731,10 +5794,11 @@ "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", "yoast-seo-for-typo3/yoast_seo": "<7.2.3", "yoast/duplicate-post": "<=4.5", - "yourls/yourls": "<=1.10.2", + "yourls/yourls": "<=1.10.3", "yuan1994/tpadmin": "<=1.3.12", "yungifez/skuul": "<=2.6.5", "z-push/z-push-dev": "<2.7.6", + "zbateson/mail-mime-parser": "<3.0.6|>=4,<4.0.2", "zencart/zencart": "<=1.5.7.0-beta", "zendesk/zendesk_api_client_php": "<2.2.11", "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", @@ -5810,7 +5874,7 @@ "type": "tidelift" } ], - "time": "2026-08-01T00:01:24+00:00" + "time": "2026-09-30T15:40:52+00:00" }, { "name": "sebastian/cli-parser", @@ -8588,16 +8652,16 @@ }, { "name": "twig/twig", - "version": "v3.29.0", + "version": "v3.30.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d" + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", - "reference": "45a3c6e9224c3377a39c7b150bb29d5d97d2c75d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/8c737079b726af72ff8ef3c595be9f6a810ea1ef", + "reference": "8c737079b726af72ff8ef3c595be9f6a810ea1ef", "shasum": "" }, "require": { @@ -8652,7 +8716,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.29.0" + "source": "https://github.com/twigphp/Twig/tree/v3.30.0" }, "funding": [ { @@ -8664,7 +8728,7 @@ "type": "tidelift" } ], - "time": "2026-09-18T09:10:14+00:00" + "time": "2026-09-25T13:20:01+00:00" }, { "name": "vimeo/psalm", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8232e68c..3f588c6ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -418,7 +418,7 @@ choice. | Feature | OCP Interface | What to Reuse | How | |---------|--------------|---------------|-----| | **Users** | `OCP\IUserManager` | Authentication identity, vault ownership | Reference by Nextcloud user UID | -| **Groups** | `OCP\IGroupManager` | Group sharing, vault_admin role | Query group membership for group shares | +| **Groups** | `OCP\IGroupManager` | Group sharing, instance admin check | Query group membership for group shares | | **Session** | `OCP\ISession` | Store AES-derived key during vault session | `ISession::set('keepiq_aes_key', $derivedKey)` | | **Notifications** | `OCP\Notification\IManager` | Share received, request fulfilled, CA expiry, app approval | Implement `INotifier` for rendering | | **Search** | `OCP\Search\IProvider` | Unified search (Ctrl+F) for secrets by name/URL | Query name + url without AES key; deep-link to secret | @@ -435,7 +435,7 @@ choice. | **EncryptionSuites & CA** | Core security model — no Nextcloud equivalent | | **Secret storage (encrypted)** | Field-level encryption with per-user keys — cannot use generic storage | | **Master password session** | Custom session management with configurable timeout and tab-close detection | -| **Key generator** | Server-side cryptographic randomness with configurable rules | +| **Key generator** | Browser-side randomness (`crypto.getRandomValues`) in `src/generator/generator.js`, shared by the web app and the extension, with configurable rules, passphrases and the org policy clamp; the server never sees a generated value | | **Sharing (user/link/request)** | Encryption-aware sharing — each share is a re-encrypted copy | | **Suite migration** | Compromise recovery with re-encryption — domain-specific | | **Application management** | CSR processing, approval queue — domain-specific PKI | @@ -604,7 +604,6 @@ documented intent rather than silently drop a limit during refactoring. | `ApplicationSecretsController::show` | 30 / 60s | Same rationale as `index`. | | `ApplicationController::create` | 10 / 60s | Anonymous application self-registration — only reachable when an admin opts in via `anonymous_application_registration_enabled`. Admins enabling anonymous registration inherit this rate limit. | | `MachineLeaseController::index` | 30 / 60s | Bearer-authenticated lease list (machine-secret-leases §4.1); `#[PublicPage]` pre-auth surface, same polling profile as the secrets endpoints. | -| `MachineLeaseController::renew` | 30 / 60s | Lease renewal — legitimate connectors renew at most once per default-TTL window. | | `MachineLeaseController::revoke` | 30 / 60s | Lease self-revocation — rare in legitimate use; the limit caps abuse of the rotation-flag side effect. | | `EphemeralSendAccessController::peek` | 15 / 60s | Anonymous ephemeral-send metadata (ephemeral-send §4.2); the ≥256-bit token is the real access control, the limit caps enumeration attempts. | | `EphemeralSendAccessController::access` | 15 / 60s | Ciphertext fetch phase of the two-phase protocol; a view is only consumed on confirm. | @@ -637,9 +636,20 @@ Enforced declaratively by `#[VaultKeyProofRequired(binds, subject, 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` | +| `EncryptionSuiteController::revoke` | `routeParam:id` | `reason`, `acceptEmergencyLoss` | `revoke-suite` | | `MigrationController::complete` | `migrationOldSuite` | `id`, `hasErrors`, `acceptUnrecoverable` | `complete-migration` | +| `MigrationController::abort` | `migrationNewSuite` | `id` | `abort-migration` | +| `MigrationController::reEnvelopeEmergencyContact` | `migrationNewSuite` | `id`, `contactId`, `recoveryEnvelope`, `granteeSuiteId` | `emergency-access-re-envelope` | +| `EmergencyAccessController::create` (designate) | `active` | `granteeUserId`, `waitPeriodDays`, `recoveryEnvelope` | `emergency-access-designate` | | `EmergencyAccessController::destroy` | `active` | `id` | `emergency-access-destroy` | +| `GdprController::deleteAccountData` | `active` | `confirmation` | `delete-account-data` | + +This table is generated from the attributes themselves +(`grep -rn "VaultKeyProofRequired(" lib/Controller`) and +`VaultKeyProofAttributesTest` pins every row. `updatePrivateKey` is also +refused while the suite is part of an open migration or not `active` +(keepiq#869), because during a compromise recovery the old password may be the +leaked one. Load-bearing design points — change these only deliberately: @@ -658,27 +668,45 @@ Load-bearing design points — change these only deliberately: 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. + once a proof verifies, a hash of its nonce is inserted into + `keepiq_used_proofs`, unique on that hash, and a second use hits the index + and is refused (keepiq#868). The database holds on every install: without a + memcache, with APCu alone, and across cluster nodes. A cache did not. If the + insert fails for any other reason the proof is refused. Expired rows are + swept on each use. A nonce is never consumed before its proof verifies. +- **Every refusal is recorded.** A refused proof is logged and written to the + audit trail as `key_proof.refused` (route, purpose, reason, never the proof), + because a session thief probing guarded routes produces exactly that. - **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. +- **Abort proves the NEW key** (`migrationNewSuite`, keepiq#859). A stolen + session could otherwise abort the owner's recovery every time it started, and + the old password may be the leaked one. Only whoever holds the key the + rotation moves to can call it off. A rotation started by someone else with a + leaked old password is contained by the administrator's compromise + force-revoke, which ends the migration. Every abort is audited as + `suite.recovery_aborted`. **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`). +carries a documented exclusion list: + +- `EncryptionSuiteController::proofChallenge`: issuing a challenge grants + nothing on its own, and guarding it would be circular. +- `EncryptionSuiteController::forceRevoke`: an administrator holds no vault key + (zero-knowledge), so a proof cannot be produced. It is guarded by the admin + check plus Nextcloud's password confirmation (sudo) instead. + +The master password is the only key to an owner's own revoke. An owner who has +lost it cannot sign the proof, so the locked-out owner's revoke path is an +administrator's force-revoke. ## 5. Open Research Questions diff --git a/docs/DESIGN-REFERENCES.md b/docs/DESIGN-REFERENCES.md index 59e0cd982..6eda42658 100644 --- a/docs/DESIGN-REFERENCES.md +++ b/docs/DESIGN-REFERENCES.md @@ -646,7 +646,7 @@ CnSettingsSection (name="Applications") └─────────────────────────────────────────────┘ ``` -Must use `NcAppSettingsDialog` (NOT `NcDialog`) with `NcAppSettingsSection` for each group. See `openspec/specs/nextcloud-app/spec.md` for the full pattern. +Must use `NcAppSettingsDialog` (NOT `NcDialog`) with `NcAppSettingsSection` for each group. See ADR-004 (frontend) and ADR-079 (settings surface placement) in hydra's `openspec/architecture/` for the full pattern. ### 3.12 Master Password Change diff --git a/docs/FEATURES.md b/docs/FEATURES.md index bd4254530..fc79fa207 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -71,13 +71,13 @@ There is **no production-ready Nextcloud-native encrypted vault with application | Bulk secret operations (delete, move folder) | **V1** | Efficiency for large vaults | | Secret import (CSV, Bitwarden JSON/CSV, KeePass 2.x XML, Nextcloud Passwords backup) | **V1** ✅ Built | Migration from other tools — client-side parse + encrypt, field-mapping preview, folder/collection mapping, duplicate detection, chunked encrypted commit, malformed-row rejection (see `docs/importing.md`) | | Secret export (encrypted backup, CSV) | **V1** ✅ Built | Data portability — client-side Argon2id+AES-256-GCM `.doriath-backup` + warning/re-auth-gated plaintext CSV (see `docs/gdpr.md`) | -| Favorite/pinned secrets | **V1** | Quick access to frequently used secrets | +| Favorite/pinned secrets | **V1** ✅ Built | Quick access to frequently used secrets: a star per holder and a Favourites filter, plus a Last used sort (`vault-favourites-tags-and-last-used`) | | Recently accessed secrets | **V1** | Convenience pattern from all major vaults | | Password health scoring per secret | **V1** ✅ | Flag weak, reused, or old passwords (Bitwarden Reports, 1Password Watchtower) — implemented in `password-health` (client-side vault health report) | | Secret strength indicator in list view | **V1** ✅ | Color-coded strength badge next to each secret (Passbolt, Bitwarden) — implemented in `password-health` (in-session zxcvbn badge) | | Vault search with keyboard shortcut (Ctrl+K) | **V1** | Power-user quick access (1Password pattern) | | Dark mode support | **V1** | User preference; Nextcloud supports dark mode natively | -| Secret tags (in addition to folders) | **Enterprise** | Cross-cutting categorization | +| Secret tags (in addition to folders) | **Enterprise** ✅ Built | Cross-cutting categorization: plain-text tags per holder, a tag filter and bulk add or remove (`vault-favourites-tags-and-last-used`) | | Custom fields per secret type (admin-defined) | **Enterprise** | Organization-specific field requirements | | Breach detection (HaveIBeenPwned) for secret values | **V1** ✅ | Opt-in k-anonymity breach check (5-char prefix proxy) — implemented in `password-health`, double-gated (admin + per-user), default off | | Password age indicator | **V1** ✅ | Show how old each secret is; flag stale credentials — implemented in `password-health` via server-maintained `key_updated_at` | @@ -234,7 +234,7 @@ There is **no production-ready Nextcloud-native encrypted vault with application |-------|-------------|-----------------|-----------------|------| | Secret shared with user | `secret_shared` | `notify_shares` | Notify recipient | **MVP** | | Secret request fulfilled | `request_fulfilled` | `notify_requests` | Notify requester | **MVP** | -| Application pending approval | `app_pending` | — (always notify admins) | All vault_admins | **MVP** | +| Application pending approval | `app_pending` | — (always notify admins) | Members of the Nextcloud `admin` group | **MVP** | | Group share: new member needs approval | `group_member_added` | `notify_group_shares` | Notify secret owner | **MVP** | | Share request from recipient | `share_request` | `notify_shares` | Notify secret owner | **MVP** | | Share request approved/denied | `share_request_result` | `notify_shares` | Notify requester | **MVP** | @@ -258,6 +258,8 @@ There is **no production-ready Nextcloud-native encrypted vault with application | GDPR data export (all user secrets + metadata) | **V1** ✅ Built | Right of access (Art. 15) — browser-assembled package = server metadata + client-decrypted vault (see `docs/gdpr.md`) | | GDPR data deletion (user + all shares) | **V1** ✅ Built | Right to erasure (Art. 17) — in-app + `UserDeletedEvent` cascade with defined shared-secret semantics (see `docs/gdpr.md`) | | Audit trail on all secret operations | **V1** ✅ Built | Accountability | +| No OpenRegister integration leaves (files, calendar, deck, activity) | **V1** ✅ Decided | Secret material and vault-structure metadata never leave the vault's own access control; expiry and rotation stay in Keepiq's scans, notifications and dashboard (see `openspec/specs/integration-boundary/`) | +| AI/MCP: metadata-only read tools (`listEntries`, `expiryReport`, `rotationStatus`) | **V1** ✅ Built | An assistant can answer "what expires this month?"; secret values are never agent-reachable and no tool writes (see `openspec/specs/mcp-metadata-surface/`) | | Field-level encryption audit (verify encrypted fields) | **Enterprise** | Compliance verification | | Data retention policies | **Enterprise** | Automated cleanup | @@ -269,7 +271,8 @@ There is **no production-ready Nextcloud-native encrypted vault with application | Nextcloud notifications (shares, requests, CA) | **MVP** | Platform integration | | REST API for all operations | **V1** | Programmatic access | | OpenConnector secret store integration | **V1** | Sister app integration | -| Browser extension (Bitwarden-compatible API subset) | **Enterprise** | Auto-fill in browser | +| Browser extension (own end-to-end client: autofill, passkeys, vault, generator, Send, offline) | **Enterprise** | Auto-fill in browser | +| Mobile apps for Android and iOS (own end-to-end client). Android: vault, one-time codes, generator, Send, offline copy, autofill in apps and browsers, passkeys on Android 14 and later; a signed preview APK on GitHub, with `fdroid` and `play` builds ready for the stores. iOS: vault, generator and Send; autofill, passkeys and offline come with the signed build through TestFlight. See `docs/mobile/using.md` and `docs/mobile/privacy.md` | **Enterprise** | Passwords on the phone | | CLI tool for secret management | **Enterprise** | DevOps workflow | | Nextcloud Flows automation triggers | **Enterprise** | Low-code integration | @@ -355,7 +358,7 @@ Three pillars: |------|----------|------------| | Feature gap vs. Bitwarden (browser extension, mobile, FIDO2) | High | Focus on what Bitwarden can't do: Nextcloud integration, write-without-read, application secrets. Browser extension is Enterprise tier. | | Passwords app incumbency on Nextcloud | High | Differentiate on encryption architecture (PKI vs. SSE), application secrets, and enterprise features. Consider migration tooling. | -| No mobile app | Medium | Nextcloud's mobile apps provide the session; Keepiq is web-first. Mobile vault is a future consideration. | +| Mobile apps not in the stores yet | Medium | Native Android and iOS apps are built (`clients-mobile-apps`). Android ships as a signed preview APK on GitHub. Its F-Droid build is free software only and reproducible, both checked in CI, and the store texts are drafted; the store accounts do not exist yet. iOS follows through TestFlight. | | Complexity of PKI for end users | Medium | Zero-friction onboarding: EncryptionSuite auto-created on first login. Users only interact with master password, never with certificates. | | Master password lost = data lost | High | This is by design (zero-knowledge). Document clearly. Consider emergency access (V1) or admin recovery mechanisms (Enterprise). | | Small team | High | Own-DB architecture means more backend code than thin-client apps. Prioritize MVP ruthlessly. | @@ -467,7 +470,7 @@ Large organizations, multi-instance deployments, and compliance-driven environme 78. Breach detection for secret URLs (HaveIBeenPwned) 79. Password age indicator 80. Export to PDF (single secret) -81. Browser extension (Bitwarden-compatible API subset) +81. Browser extension (own end-to-end client) 82. CLI tool for secret management 83. Multiple encryption suites per user (key rotation) 84. Custom CA chain upload diff --git a/docs/api/admin-v1.openapi.json b/docs/api/admin-v1.openapi.json new file mode 100644 index 000000000..0c1202dde --- /dev/null +++ b/docs/api/admin-v1.openapi.json @@ -0,0 +1,1164 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Keepiq admin API", + "version": "1", + "description": "The versioned admin API of Keepiq. Authenticate as a Nextcloud user: a browser session, or an app password over HTTP Basic with the header OCS-APIRequest: true. Each endpoint needs one Keepiq admin area, delegated on Nextcloud's administration privileges page. Version 1 only grows; a breaking change ships as version 2 next to it. Suite force revocation and reinstatement are not offered, because both need a fresh password confirmation.", + "license": { + "name": "EUPL-1.2", + "identifier": "EUPL-1.2" + } + }, + "servers": [ + { + "url": "{base}/index.php/apps/keepiq", + "variables": { + "base": { + "default": "https://cloud.example.com" + } + } + } + ], + "security": [ + { + "basicAuth": [] + } + ], + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic", + "description": "A Nextcloud app password. Send OCS-APIRequest: true with every request." + } + }, + "parameters": { + "OcsApiRequest": { + "name": "OCS-APIRequest", + "in": "header", + "required": true, + "schema": { + "type": "string", + "const": "true" + } + }, + "Id": { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "responses": { + "Error": { + "description": "Refused or invalid", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "paths": { + "/api/v1/admin": { + "get": { + "operationId": "indexIndex", + "summary": "The admin API index", + "description": "Returns the API version, the served versions, the areas you hold and every v1 path. Needs at least one Keepiq admin area. Admin area: any Keepiq admin area.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "any" + } + }, + "/api/v1/admin/policies": { + "get": { + "operationId": "getPolicySettings", + "summary": "Read the policies", + "description": "The settings of the Policies area: master password, organisation password, vault policies, rotation and expiry, session timeout, version and trash retention, extension idle limit. Admin area: Policies.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "policies" + }, + "put": { + "operationId": "updatePolicySettings", + "summary": "Update the policies", + "description": "Writes only Policies keys. A key of another area answers 400 and nothing is written. Admin area: Policies.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "policies", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "/api/v1/admin/members": { + "get": { + "operationId": "memberOverviewIndex", + "summary": "List members and their vault status", + "description": "One page of Nextcloud users with their vault status, secret count, team folder memberships and whether they have an emergency contact. Metadata only. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "", + "none", + "active", + "revoked", + "compromised" + ], + "default": "" + }, + "description": "Vault status filter; empty for every status." + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string", + "default": "" + }, + "description": "Search on user id or display name." + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "results", + "limit", + "offset", + "hasMore" + ], + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "vaultStatus": { + "type": "string", + "enum": [ + "none", + "active", + "revoked", + "compromised" + ] + }, + "activeSuiteId": { + "type": [ + "string", + "null" + ] + }, + "suiteCreatedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "secretCount": { + "type": "integer" + }, + "teamFolderMemberships": { + "type": "integer" + }, + "hasEmergencyContact": { + "type": "boolean" + } + } + } + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "hasMore": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people" + } + }, + "/api/v1/admin/suites": { + "get": { + "operationId": "peopleSuites", + "summary": "List active encryption suites", + "description": "One page of active suites, metadata only. Never a private key or a certificate. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + } + }, + { + "name": "offset", + "in": "query", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people" + } + }, + "/api/v1/admin/offboarding": { + "post": { + "operationId": "peopleOffboard", + "summary": "Offboard a leaver", + "description": "Revokes the leaver's team-folder access, removes their direct memberships and transfers the team secrets they own to the successor. Admin area: People and offboarding.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "people", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "leavingUserId", + "successorUserId" + ], + "properties": { + "leavingUserId": { + "type": "string" + }, + "successorUserId": { + "type": "string" + } + } + } + } + } + } + } + }, + "/api/v1/admin/applications": { + "get": { + "operationId": "applicationIndex", + "summary": "List applications", + "description": "Every application. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "post": { + "operationId": "applicationCreate", + "summary": "Register an application", + "description": "Registers an application. Your registration is active at once. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "internal", + "external" + ] + }, + "csr": { + "type": "string", + "description": "PKCS#10 CSR in PEM" + } + } + } + } + } + } + } + }, + "/api/v1/admin/applications/{id}": { + "get": { + "operationId": "applicationShow", + "summary": "Read an application", + "description": "The application, with its public certificate (PEM) in `certificate` when it is active. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "delete": { + "operationId": "applicationDestroy", + "summary": "Delete an application", + "description": "Deletes the application and its vault. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/approve": { + "post": { + "operationId": "applicationApprove", + "summary": "Approve a pending application", + "description": "You are recorded as approver. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/reject": { + "post": { + "operationId": "applicationReject", + "summary": "Reject a pending application", + "description": "Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + } + }, + "/api/v1/admin/applications/{id}/lease-policy": { + "get": { + "operationId": "applicationGetLeasePolicy", + "summary": "Read the lease policy", + "description": "The application's override and the effective values. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications" + }, + "put": { + "operationId": "applicationSetLeasePolicy", + "summary": "Set the lease policy", + "description": "A null value inherits the instance setting. Admin area: Applications and machine access.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "applications", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "defaultTtl": { + "type": [ + "integer", + "null" + ], + "minimum": 60 + }, + "maxTtl": { + "type": [ + "integer", + "null" + ], + "minimum": 60 + }, + "renewable": { + "type": [ + "boolean", + "null" + ] + } + } + } + } + } + } + } + }, + "/api/v1/admin/audit": { + "get": { + "operationId": "auditEvents", + "summary": "Read audit events", + "description": "Filtered and paged like the admin screen. Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "name": "eventType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "actor", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "objectType", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "objectId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/compliance/reports": { + "get": { + "operationId": "auditReports", + "summary": "List compliance reports", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + }, + "post": { + "operationId": "auditGenerateReport", + "summary": "Generate a compliance report", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/compliance/reports/{id}": { + "get": { + "operationId": "auditShowReport", + "summary": "Read a compliance report", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + }, + "/api/v1/admin/siem/sinks": { + "get": { + "operationId": "auditSinks", + "summary": "List SIEM sinks", + "description": "A sink's HMAC secret and connector credential never appear. Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + }, + "post": { + "operationId": "auditCreateSink", + "summary": "Create a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + } + ], + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "/api/v1/admin/siem/sinks/{id}": { + "put": { + "operationId": "auditUpdateSink", + "summary": "Update a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "delete": { + "operationId": "auditDestroySink", + "summary": "Delete a SIEM sink", + "description": "Admin area: Audit and compliance.", + "parameters": [ + { + "$ref": "#/components/parameters/OcsApiRequest" + }, + { + "$ref": "#/components/parameters/Id" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": [ + "object", + "array" + ] + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "403": { + "$ref": "#/components/responses/Error" + }, + "404": { + "$ref": "#/components/responses/Error" + } + }, + "x-keepiq-area": "audit" + } + } + } +} diff --git a/docs/audit-trail.md b/docs/audit-trail.md index e46f39506..2ab71cfa8 100644 --- a/docs/audit-trail.md +++ b/docs/audit-trail.md @@ -89,7 +89,21 @@ operators and works councils know it is the default. `link_share.access_failed`, `link_share.revoked`, `link_share.auto_deleted`, `request.created`, `request.fulfilled`, `request.re_requested`, `request.revoked`, `suite.revoked`, `suite.reinstated`, -`suite.recovery_started`, `suite.recovery_completed`, `application.registered`, +`suite.recovery_started`, `suite.recovery_completed`, `suite.recovery_aborted`, +`suite.migration_terminated`, `suite.revoke_refused`, `key_proof.refused`, +`application.registered`, `application.approved`, `application.rejected`, `application.deleted`, `application.token_issued`, `application.secret_retrieved`, `vault.exported`, `vault.gdpr_exported`, `vault.account_deleted`. + +Refusals on the containment paths are recorded too, so an attack shows up in +the trail and the SIEM export and not only in `nextcloud.log`: + +- `key_proof.refused`: a request to a route that needs a master password proof + was refused. Metadata: the route, the purpose and the reason. The proof + itself is never recorded. +- `suite.revoke_refused`: an administrator force-revoke was refused. Metadata: + a fixed reason code and whether a compromise revoke was asked for. +- `suite.recovery_aborted`: the owner called off a compromise recovery before + any secret moved. `suite.migration_terminated`: a compromise force-revoke + ended a recovery that was still running. diff --git a/docs/browser-extension/permissions.md b/docs/browser-extension/permissions.md new file mode 100644 index 000000000..425c95db4 --- /dev/null +++ b/docs/browser-extension/permissions.md @@ -0,0 +1,18 @@ +# Browser extension permissions + +Each permission the Keepiq extension asks for, and why. The store listings use these lines as the justification for review. + +| Permission | Why the extension needs it | +|---|---| +| `storage` | Keeps the connected accounts (server, user, app password, label, idle delay) and, for at most five minutes, which one-time code to fill on the next login step. | +| `activeTab` | Fills the login you pick into the tab you are on. | +| `tabs` | Reads the address of the current tab to show matching logins, and sends the fill to that tab only. | +| `clipboardWrite` | Copies a password, username, one-time code or Send link so you can paste it. | +| `offscreen` | Clears the clipboard again after the delay you picked (30 seconds unless you change it), also when the popup has closed. A Chromium service worker has no clipboard, so it clears through a hidden page. Firefox needs no permission for this. | +| `alarms` | Syncs the vault every 15 minutes while it is unlocked, and clears the clipboard after a longer delay. | +| `contextMenus` | Adds Fill a login with Keepiq to the right-click menu of a form field. | +| `idle` | Locks the vault when your computer locks or after the idle delay you picked. | +| `windows` | Opens the small window that asks before a passkey is used, and the window for fingerprint or face unlock. Firefox needs no permission for this. | +| Access to all `http` and `https` sites | Finds login, one-time code and passkey fields on the sites you use. The extension fills only after you pick a login in its popup, or a one-time code on the step right after a login fill on the same site. | + +A test checks that every permission in `browser-extension/manifest.json` has a call site in the extension code (`tests/extension/storeRelease.spec.js`), so an unused permission fails the build. diff --git a/docs/browser-extension/privacy.md b/docs/browser-extension/privacy.md new file mode 100644 index 000000000..a59848bca --- /dev/null +++ b/docs/browser-extension/privacy.md @@ -0,0 +1,47 @@ +# Browser extension privacy policy + +This page is the privacy policy for the Keepiq browser extension in the Chrome Web Store, Firefox Add-ons and Microsoft Edge Add-ons. + +## What the extension does + +The Keepiq extension fills logins, one-time codes and passkeys from your Keepiq vault. Your vault lives on your organisation's own Nextcloud server. The extension talks only to that server, the one you connect it to. + +## What leaves your device + +The extension opens your vault inside the extension, with your master password. Your master password never leaves the extension. The server receives: + +- your Nextcloud user name and app password, with every request, to sign in; +- the address of the site you are on, when you open the extension there, so the server can find the logins for it; +- encrypted secrets, when you save or update a login; +- the name and web address of a secret, which Keepiq keeps unencrypted so it can match a site; +- the first five characters of a password's SHA-1 hash, when your organisation checks new passwords against known breaches. The full password and its full hash stay on your device. + +The extension sends nothing to Conduction or to anyone other than your own server. + +## What the extension stores on your device + +In the browser's extension storage, per connected account: the server address, your Nextcloud user name, a Nextcloud app password, a label and the idle lock delay you picked. You revoke the app password in your Nextcloud security settings to cut the extension off. + +Also per account, a copy of your vault as the server stores it, so the extension works while the server cannot be reached: encrypted secrets, and the unencrypted names, web addresses and folders. It is replaced at every sync and removed when you disconnect the account, or when its app password is revoked. + +Once for the browser: how long a copied value stays on the clipboard, whether to offer to save and update logins and to suggest passwords, the type of a new item, the colour theme, and the sites you said never to save logins on. + +In the browser's session storage, for at most five minutes after a login fill: the tab, the site and which one-time code secret to use on the next step. It holds no code and no secret. Also, per open tab, which frames are on which site, so a fill reaches only the site you picked. And, when you set a PIN, your vault key encrypted with a key made from that PIN. It is gone when you close the browser, log out, disconnect, or enter a wrong PIN five times. The browser keeps session storage in memory only. + +A login you submit on a site waits in memory for at most five minutes, for you to save it. It is gone when you save or dismiss it, when its tab closes, or when the vault locks. + +Your master password, your decrypted secrets and your vault key are never stored. They stay in memory while the vault is unlocked and are gone when it locks. + +## What the extension does not do + +- It does not keep a history of the sites you visit. It sends the site you are on to your own server only when you open the extension there. +- It does not collect analytics or crash reports. +- It does not sell, share or transfer data to third parties. + +## Firefox data collection declaration + +Firefox asks extensions to declare what they send outside the browser. The extension declares `authenticationInfo` (your app password and your encrypted logins) and `browsingActivity` (the site you are on, to find its logins). Both go only to your own server. + +## Contact + +Questions go to your organisation's Keepiq administrator, or to Conduction at info@conduction.nl. diff --git a/docs/browser-extension/release.md b/docs/browser-extension/release.md new file mode 100644 index 000000000..e40bf4955 --- /dev/null +++ b/docs/browser-extension/release.md @@ -0,0 +1,46 @@ +# Releasing the browser extension + +The workflow `.github/workflows/extension-release.yml` builds and publishes the extension. + +## On every pull request + +It runs the extension tests, builds the Chrome and Firefox packages twice and fails when the two builds differ, lints the Firefox package with `web-ext lint`, and keeps the zips and a source archive as workflow artefacts. A pull request cannot read any store credential. + +## Releasing a version + +1. Push a tag `extension-v`, for example `extension-v1.2.0`. The version becomes the manifest version; it must be one to four numbers separated by dots. +2. A maintainer approves the `extension-stores` environment for the run. +3. The job submits the Chrome package to the Chrome Web Store, the Firefox package with its source archive to Firefox Add-ons, and the Chrome package to Edge Add-ons, and attaches the packages to the GitHub release. + +Store review then takes from hours to days per store. + +## The signed Firefox file for self-hosting + +Firefox Add-ons signs the listed package once its review passes. The job `attach-amo-signed` in the same workflow then adds that signed file to the GitHub release as `keepiq-firefox--amo-signed.xpi`, with a `.sha256` file next to it. One version is one file everywhere: the self-hosted copy is the exact file Firefox Add-ons serves. + +- It runs every day at 05:23 UTC and looks at the five most recent `extension-v*` releases. A release that already holds the file is skipped. A version still in review is skipped with a notice. +- To attach a file at once, run the workflow by hand (Actions, Extension release, Run workflow) and fill in `amo_version`, for example `1.2.0`. A manual run fails when that version is not approved yet. +- Before it uploads, it checks the download against the hash Firefox Add-ons publishes, checks that the file carries a Mozilla signature, and checks that every other file in it is byte-identical to `keepiq-firefox-.zip` from the same release. +- It reads only public Firefox Add-ons data. It needs no store credential and does not wait for the `extension-stores` approval. + +## Store credentials + +All of these are settings of the GitHub environment `extension-stores`. Environment secrets are readable only by jobs that name the environment, and the environment requires a maintainer's approval. Two maintainers can approve, rubenvdlinde and rjzondervan, and one approval is enough (decided 2 October 2026, keepiq#783). + +| Name | Kind | What it holds | Where it comes from | +|---|---|---|---| +| `CHROME_EXTENSION_ID` | variable | The Chrome Web Store item id, for example `YOUR_CHROME_EXTENSION_ID` | Chrome Web Store developer dashboard | +| `CHROME_CLIENT_ID` | secret | OAuth client id with access to the Chrome Web Store API, `YOUR_CHROME_CLIENT_ID` | Google Cloud console | +| `CHROME_CLIENT_SECRET` | secret | The matching client secret, `YOUR_CHROME_CLIENT_SECRET` | Google Cloud console | +| `CHROME_REFRESH_TOKEN` | secret | A refresh token for the publishing Google account, `YOUR_CHROME_REFRESH_TOKEN` | OAuth consent flow | +| `AMO_JWT_ISSUER` | secret | Firefox Add-ons API key (JWT issuer), `YOUR_AMO_JWT_ISSUER` | addons.mozilla.org developer hub, API keys | +| `AMO_JWT_SECRET` | secret | Firefox Add-ons API secret, `YOUR_AMO_JWT_SECRET` | addons.mozilla.org developer hub, API keys | +| `EDGE_PRODUCT_ID` | variable | The Edge Add-ons product id, `YOUR_EDGE_PRODUCT_ID` | Microsoft Partner Center | +| `EDGE_CLIENT_ID` | secret | Edge Add-ons API client id, `YOUR_EDGE_CLIENT_ID` | Partner Center, Publish API | +| `EDGE_API_KEY` | secret | Edge Add-ons API key, `YOUR_EDGE_API_KEY` | Partner Center, Publish API | + +## Still to do before the first release + +- Create the publisher accounts on the three stores and the first listings, with the privacy policy (`privacy.md`) and the permission lines (`permissions.md`). +- Add the settings above to the `extension-stores` environment. The environment and its required reviewers exist. +- Run a first release on a test tag and check each store by hand. diff --git a/docs/browser-extension/rollout.md b/docs/browser-extension/rollout.md new file mode 100644 index 000000000..b94f09634 --- /dev/null +++ b/docs/browser-extension/rollout.md @@ -0,0 +1,47 @@ +# Rolling out the browser extension + +How to install the Keepiq extension for a whole organisation. + +## Chrome and Edge + +Add the store id to the `ExtensionInstallForcelist` policy. Chrome and Edge then install the extension on every managed profile and keep it updated from the store. + +```text +Chrome: ;https://clients2.google.com/service/update2/crx +Edge: ;https://edge.microsoft.com/extensionwebstorebase/v1/crx +``` + +On Windows set it through Group Policy or Intune, on macOS through a configuration profile, and on Linux in `/etc/opt/chrome/policies/managed/keepiq.json`: + +```json +{ + "ExtensionInstallForcelist": [ + ";https://clients2.google.com/service/update2/crx" + ] +} +``` + +The store ids are filled in here once the listings are live. + +## Firefox + +Firefox installs add-ons by policy from Firefox Add-ons or from a signed package. Add this to `policies.json` (or the matching Group Policy): + +```json +{ + "policies": { + "ExtensionSettings": { + "keepiq@conduction.nl": { + "installation_mode": "force_installed", + "install_url": "https://addons.mozilla.org/firefox/downloads/latest/keepiq/latest.xpi" + } + } + } +} +``` + +To host the package yourself, download `keepiq-firefox--amo-signed.xpi` from the GitHub release `extension-v` and point `install_url` at your copy. It is the file Firefox Add-ons signed after review, so it appears on the release a few hours to days after the version is tagged. Check it against the `.sha256` file next to it. + +## After installing + +Each user connects the extension to Keepiq once: server address, Nextcloud user and a Nextcloud app password. The extension needs a Keepiq server at least as new as its minimum version, and says so when the server is older. diff --git a/docs/browser-extension/using.md b/docs/browser-extension/using.md new file mode 100644 index 000000000..91afeb080 --- /dev/null +++ b/docs/browser-extension/using.md @@ -0,0 +1,147 @@ +# Using the browser extension + +The Keepiq extension fills your logins, one-time codes and passkeys from your Keepiq vault, in Chrome, Edge and Firefox. It opens your vault inside the extension with your master password. The server only ever sees encrypted values. + +## Getting the extension + +The store listings for Chrome, Edge and Firefox are not live yet. Until then, download the packages from the [Keepiq releases on GitHub](https://github.com/ConductionNL/keepiq/releases?q=extension-v&expanded=true). Each `extension-v` release carries: + +- `keepiq-chromium-.zip` for Chrome and Edge. Unzip it, open `chrome://extensions`, switch on developer mode and choose **Load unpacked**. +- `keepiq-firefox--amo-signed.xpi` for Firefox, once Firefox Add-ons has signed that version. Open the file in Firefox to install it. +- `keepiq-firefox-.zip`, the unsigned Firefox package. Firefox only loads it as a temporary add-on, from `about:debugging`. + +Your organisation can also install the extension for you. See [rolling it out](rollout.md). + +## Connecting your account + +1. In Nextcloud, open **Settings → Security** and create an app password for the extension. +2. Click the Keepiq button in the browser toolbar. +3. Enter the address of your Nextcloud, your user name and the app password, and choose **Connect**. + +![The Keepiq popup asks for the server address, the Nextcloud user and an app password](/media/browser-extension/pair.png) + +You can paste any Nextcloud address, for example one you copied from a Keepiq page: the extension keeps only the server part. It needs an https address, so your app password is never sent in clear. Only a server on your own computer may use http. + +You can connect up to five accounts, on one or more servers. Pick the account you want in the bar at the top of the popup. + +## Unlocking + +Enter your master password and press Enter. **Show** lets you check what you typed. Your master password never leaves the extension. + +![The lock screen with the master password field and the Unlock button](/media/browser-extension/unlock.png) + + + +Other ways to unlock: + +- **A PIN.** In **Settings → This account**, enter your master password and a PIN of at least six characters. After that, the lock screen offers the PIN first. The PIN works until you close the browser, and after five wrong PINs Keepiq asks for your master password again. +- **Fingerprint or face.** Where your computer supports it, **Settings** offers fingerprint or face unlock. +- **Without a connection.** When your server cannot be reached, your master password still unlocks the copy of your vault the extension keeps. + +The vault locks again after the idle time you choose in **Settings** (1 minute to 4 hours), when your computer locks, or when you press the lock button. When it locks, the popup clears everything it showed. + +## The tabs + +The tabs at the bottom of the popup: + +- **This site** lists the logins for the site you are on. Pick one to fill it in. +- **Vault** lists all your items. Search, filter by folder or type, and open an item to see, copy, edit, clone, move or send it. Each item shows its type and site, with **Copy** for a password and **Open** for a web address. +- **Generator** makes passwords, passphrases and usernames. +- **Send** shares text or a username and password through a link that expires. +- **Settings** holds your account, autofill and appearance settings. + +The pop-out button opens the popup in its own window, which stays with the site you opened it from. + +![This site shows the one login saved for webmail.example.com](/media/browser-extension/this-site.png) + +### The vault + +Search the vault, or filter it by folder or type. Open an item to see its details. **Show** reveals the password, **Copy** copies it. + +![The Vault tab lists five demo logins, each with Copy and Open](/media/browser-extension/vault.png) + +![The details of the demo bank login, with its username, password and web address](/media/browser-extension/vault-item.png) + + + +### The generator + +Pick a password, a passphrase or a username. Set the length and the characters you want. **Regenerate** makes a new one, **Copy** copies it. + +![The generator shows a 14 character password and its options](/media/browser-extension/generator.png) + +![The generator shows a passphrase of words joined by dashes](/media/browser-extension/generator-passphrase.png) + + + +## Filling logins + +Open the popup on a login page and pick a login under **This site**. Keepiq fills it only in the parts of the page that belong to that site, not in an advert or a frame from somewhere else. + +You can also: + +- right-click a form field and choose **Fill a login with Keepiq**; +- press **Ctrl+Shift+L** (**Command+Shift+L** on a Mac). You can change the shortcut in your browser's extension settings. + +When the site has one login, it fills at once. When it has several, or the vault is locked, the popup opens so you can choose. When a login was saved for a secure (https) site and the page is not secure, Keepiq asks before it fills. + +After a login, Keepiq fills the one-time code on the next step when it can, or copies it for you to paste. + +![A demo webmail sign-in page with the email address and password filled in by Keepiq](/media/browser-extension/fill-filled.png) + + + +## Saving logins + +When you sign in with a login Keepiq does not know, a bar asks whether to save it. You can save it, choose **Not now**, or choose **Never for this site**. When you change a password, Keepiq offers to update the saved login. The bar comes back on the next page of the same site if the login page moves you on. + +![After signing in to a demo forum, a bar asks whether to save the login in Keepiq](/media/browser-extension/save-prompt.png) + +![The bar confirms the login was saved to Keepiq](/media/browser-extension/save-done.png) + + + +From the popup you can also pick the folder a new login goes into. **Settings → Autofill** switches the save and update offers off, and lists the sites you said never to. + +## Passkeys + +When a website offers to create a passkey, Keepiq asks whether to store it in your vault. Choose **Allow**. The next time you sign in on that site, Keepiq asks again and signs you in with the passkey. The passkey's private key stays in the extension: the popup never shows it. + +![Keepiq asks whether to create and store a passkey for passkeys.example.com](/media/browser-extension/passkey-consent.png) + +![The demo page says you are signed in with your passkey](/media/browser-extension/passkey-signed-in.png) + + + +## Copying + +Everything you copy from the extension is cleared from the clipboard after 30 seconds, also when the popup has closed. Change the delay in **Settings → Autofill**, or switch it off. + +## Sends + +A send shares text, or a username and password, through a link. Choose how often it may be opened and when it expires. With an optional password, share the password another way: the link alone then cannot open it. **My sends** shows each send with how often it was opened, when it expires and whether it has a password. **End** stops a link working. You need a connection to make a send. + +![The Send tab with a short text, opened once and expiring after one hour](/media/browser-extension/send-form.png) + +![The new link, with Copy link, and the send listed under My sends](/media/browser-extension/send-link.png) + + + +## Without a connection + +The extension keeps an encrypted copy of your vault, updated every 15 minutes while it is unlocked and after every change you make. When your server cannot be reached, you can still unlock, browse and fill from that copy. Changes, folders and sends need the connection back. + +## Signed out + +When Keepiq refuses your app password, because it was revoked or changed in Nextcloud, the extension signs the account out and deletes what it kept of it. Create a new app password in Nextcloud and enter it in the popup to sign in again. **Settings → This account** can also log out on purpose, which deletes the app password in Nextcloud. **Disconnect** removes the account from the extension. + +## Settings + +- **This account:** the idle lock time, a PIN, fingerprint or face unlock, log out, lock all accounts, log out of all accounts, disconnect. +- **Autofill:** the save and update offers, password suggestions in sign-up fields, the sites never to save on, the clipboard delay, and the keyboard shortcut. +- **New items:** the type a new item starts with. +- **Appearance:** light, dark, or the same as your system. +- **Keepiq on the web:** import and export, notifications and your master password are managed in Keepiq on your Nextcloud. +- **About:** the version of the extension and of Keepiq on your server, and the third-party notices. + +See also [the permissions the extension asks for](permissions.md) and [the privacy policy](privacy.md). diff --git a/docs/ci-integrations.md b/docs/ci-integrations.md new file mode 100644 index 000000000..245571225 --- /dev/null +++ b/docs/ci-integrations.md @@ -0,0 +1,90 @@ + + +# CI integrations + +Give a pipeline step its Keepiq secrets in one step, without a secret value in your pipeline configuration. +The pipeline holds only your application's private key. Every value is decrypted on the runner. + +Both integrations install the `keepiq` command-line client from a release. +They check the download against the release's `SHA256SUMS` file and refuse a binary that does not match. + +## GitHub Actions + +Store the application's private key as the repository secret `KEEPIQ_APP_KEY`. Then: + +```yaml +- uses: ConductionNL/keepiq/integrations/github-action@cli-v0.3.0 + with: + url: https://cloud.example.org + application-id: deploy-bot + private-key: ${{ secrets.KEEPIQ_APP_KEY }} + secrets: DB_PASSWORD + run: ./deploy.sh +``` + +`./deploy.sh` finds the value in `KEEPIQ_DB_PASSWORD`. Nothing is written to disk. + +List one secret name per line. Write `DB_PASSWORD=PGPASSWORD` to choose the variable name yourself. + +### Exporting to later steps + +Set `export-env: "true"` instead of `run` when later steps need the values: + +```yaml +- uses: ConductionNL/keepiq/integrations/github-action@cli-v0.3.0 + with: + url: https://cloud.example.org + application-id: deploy-bot + private-key: ${{ secrets.KEEPIQ_APP_KEY }} + secrets: API_TOKEN + export-env: "true" +- run: ./publish.sh # sees KEEPIQ_API_TOKEN +``` + +Every line of every value is masked in the log first. +Export does write the values to the runner's environment file, so use `run` when one step is enough. + +A step with neither `run` nor `export-env` fails and tells you to set one of them. + +When you use the action at a branch instead of a `cli-v` tag, also set `version: cli-v0.3.0`. + +## GitLab CI + +Include the template at a release tag and extend `.keepiq`: + +```yaml +include: + - remote: https://raw.githubusercontent.com/ConductionNL/keepiq/cli-v0.3.0/integrations/gitlab-ci/keepiq.gitlab-ci.yml + +migrate: + extends: .keepiq + variables: + KEEPIQ_CLI_VERSION: cli-v0.3.0 + script: + - keepiq ci run DB_PASSWORD -- ./migrate.sh +``` + +Set `KEEPIQ_URL`, `KEEPIQ_APP_ID` and `KEEPIQ_APP_KEY` as protected, masked CI/CD variables. +A variable of type File works too: name it `KEEPIQ_APP_KEY_FILE`. + +`./migrate.sh` finds the value in `KEEPIQ_DB_PASSWORD`. +GitLab cannot mask a value fetched during the job, so the template only offers this wrapped form. + +The job image needs a shell, `curl` or `wget`, and `sha256sum`. +When the job has its own `before_script`, start it with `- !reference [.keepiq, before_script]`. + +## The container image + +Each CLI release is also an image, `ghcr.io/conductionnl/keepiq-cli`, with the same version: + +```sh +docker run --rm -e KEEPIQ_URL -e KEEPIQ_APP_ID -e KEEPIQ_APP_KEY ghcr.io/conductionnl/keepiq-cli:0.3.0 ci fetch DB_PASSWORD --output json +``` + +## Next step + +Register the application your pipeline will use, then add its key to your CI secrets. +See [OpenConnector integration](./integration-openconnector.md) for how registration and approval work. diff --git a/docs/client-libraries.md b/docs/client-libraries.md new file mode 100644 index 000000000..dc3dea733 --- /dev/null +++ b/docs/client-libraries.md @@ -0,0 +1,95 @@ + + +# Client libraries + +Read and write your application's secrets from Go, Python or TypeScript in a few lines. +Each library decrypts in your own process. Only ciphertext and a signed assertion cross the network. + +You need an approved Keepiq application and its private key. +See [OpenConnector integration](./integration-openconnector.md) for how an application is registered and approved. + +## Python + +Install `keepiq-sdk` (it needs only `cryptography`): + +```python +from keepiq_sdk import Client + +key = open("/run/secrets/keepiq.pem").read() +client = Client("https://cloud.example.org", "billing", key) +secret = client.get_by_name("stripe-key") +print(secret.key) +``` + +## TypeScript and JavaScript + +Install `@conduction/keepiq-sdk`. It runs on Node 20 and later and in browsers, with no runtime dependency. + +```ts +import { Client } from '@conduction/keepiq-sdk' + +const client = new Client('https://cloud.example.org', 'billing', process.env.KEEPIQ_APP_KEY!) +const secret = await client.getByName('stripe-key') +await client.update(secret.id, { key: 'the new value' }) +``` + +## Go + +```go +import keepiq "github.com/ConductionNL/keepiq/sdk/go" + +c, err := keepiq.New("https://cloud.example.org", "billing", pemString) +s, err := c.GetByName("stripe-key", "") +fmt.Println(s.Key) +``` + +The Go library uses only the standard library. The `keepiq` command-line client is built on it. + +## What every library does + +The three libraries offer the same calls: + +| Call | What it does | +|---|---| +| get by name | Reads the secret with exactly this name. Pass a folder path to narrow it. | +| get by id | Reads one secret by its id. | +| list | Reads every secret, or only the ones changed after a given moment. | +| create | Files a new secret. `name` and `key` are required. | +| update | Replaces the fields you name and leaves the others alone. | + +Values go in `key`, `login` and `additionalFields`. The library encrypts them with your own key before sending. +`name`, `url` and `typeId` travel as they are, so keep secrets out of them. + +Each library finds the instance through the discovery document and signs the token request with your key. +It keeps the token until it expires and asks for a new one when the server refuses the old one. + +A second read of an unchanged secret answers "not modified", so a polling job does no work. +When your instance hands out leases, every read carries the lease id and its expiry. + +Two secrets with the same name raise an "ambiguous name" error. +It lists both ids and folder paths, so you can pick one with a folder path or rename one. + +Pass your application's certificate as well, and the library checks it against your key at start. +After that it refuses any secret encrypted to another certificate, before it decrypts anything. + +Don't have the certificate at hand? Your application can read its own at `GET /api/v1/app/certificate` with its access token. +The answer holds the certificate in PEM and its `certificateFingerprint`, the same value every envelope carries. +Pass that PEM as the certificate option. The library still checks it against your key, so a wrong answer stops it at start. + +## Base URL + +Use the address you open Nextcloud on. When your instance has no pretty URLs, add `/index.php`. + +## Proof that they agree + +All three libraries, the command-line client and the server share one set of test files in `sdk/testdata/`. +Every library decrypts what the server and the browser wrote. +The server decrypts what every library wrote. +When one implementation drifts, its tests fail. + +## Next step + +Bring secrets into a pipeline with the [CI integrations](./ci-integrations.md). diff --git a/docs/gdpr.md b/docs/gdpr.md index e6cab5c2b..d6c9268c4 100644 --- a/docs/gdpr.md +++ b/docs/gdpr.md @@ -53,7 +53,8 @@ browser from two halves: encryption-suite records (certificate, status, audit fields — the encrypted private-key blob is **excluded**, with the exclusion documented inside the package), shares given/received, delegations, link-share metadata (no - snapshots), secret requests, and user settings. + snapshots), secret requests, user settings, and per secret the user's + favourite star, tags and last-used time (`organisation`). - **Client vault**: the decrypted secrets + folder structure. If the vault is locked, the package is still produced with the server half only, diff --git a/docs/generator.md b/docs/generator.md new file mode 100644 index 000000000..f4e300fad --- /dev/null +++ b/docs/generator.md @@ -0,0 +1,38 @@ +# Generating passwords and passphrases + +Keepiq makes strong passwords and passphrases for you. They are made in your browser, so the server never sees them. The server only ever stores the encrypted value. + +## Generating a password + +Create or edit a secret and choose the dice button next to the value. The generator opens on **Password**. + +- **Length** sets the number of characters, from 8 to 128. +- **Include special characters** adds symbols such as `!`, `#` and `%`. +- **Exclude characters** leaves out characters you list, for example `0Ol1I` for characters that look alike. + +Choose **Generate**, then **Use** to put the value in the secret. Use the copy button to copy it first. + +Under **Advanced** you can give a pattern instead, such as `[A-Z0-9]{20}`. The pattern needs a length, like `{20}` or `{12,16}`. It also needs a set of characters in square brackets. + +## Generating a passphrase + +Choose **Passphrase** in the generator. A passphrase is a row of random words, such as `velvet-hamper-oxidize-trophy-crispy`. It is easier to type and to remember than a password of the same strength. + +- **Number of words** sets 4 to 12 words. Five is the default. +- **Separator** goes between the words. A hyphen is the default; a space works too. +- **Capitalise each word** starts every word with a capital. +- **Include a number** adds one digit to one word. + +The words come from a list of 7,776 words. The Electronic Frontier Foundation (EFF) published it for passphrases. Each word adds about 13 bits of randomness, so five words give about 64 bits. + +## When your organisation sets a password policy + +Your administrator can set a minimum length and the kinds of characters a generated value must contain. The generator follows the policy by itself: + +- A password gets at least the minimum length and every required kind of character. +- A passphrase gets extra words until it is long enough. It gets capitals, a digit or a symbol between the words when the policy asks for them. +- A pattern that cannot meet the policy is refused, with the reason. + +## For administrators + +Set the policy under **Administration settings, Keepiq, Org password policy**. Turn off **Allow passphrases made of words** to offer only passwords. The generator then shows no Passphrase choice. diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 000000000..742007e29 --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,77 @@ + + +# Kubernetes + +Get a Keepiq application secret into a pod with one resource and no scripting. +The Keepiq operator decrypts inside your cluster, with an application key your cluster holds. +The Keepiq server only ever sends ciphertext. + +## Install the operator + +```sh +helm install keepiq-operator oci://ghcr.io/conductionnl/charts/keepiq-operator --version 0.1.0 --namespace shop +``` + +The operator watches the namespace it runs in. Give each team or namespace its own Keepiq application, so it reads only its own vault. + +## Sync a secret into a Kubernetes Secret + +Put the application's private key in a Kubernetes Secret, then create two resources: + +```yaml +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqConnection +metadata: + name: shop +spec: + url: https://cloud.example.org + applicationId: shop-prod + privateKeySecretRef: {name: keepiq-app-key, key: key.pem} +--- +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqSecret +metadata: + name: shop-db +spec: + connectionRef: {name: shop} + target: {name: shop-db} + restartTargets: + - {kind: Deployment, name: shop-api} + items: + - {name: db-password, field: key, targetKey: DB_PASSWORD} +``` + +Secret `shop-db` now holds `DB_PASSWORD`. +Rotate `db-password` in Keepiq and `shop-db` follows within a minute. Deployment `shop-api` restarts with the new value. + +A `field` is `key`, `login` or `additionalFields.`. Set `refreshInterval` to poll more or less often; the minimum is 10 seconds. + +## When something is wrong + +`kubectl get keepiqsecrets` shows `Ready` and a reason. The target Secret stays as it was. + +| Reason | What to do | +|---|---| +| `SecretNotFound` | Check the name, or file the secret in the application's vault. | +| `AmbiguousName` | Two secrets share the name. The event lists both; add `folder:` to the item or rename one. | +| `TokenRefused` | Check the application id, its approval, and the key. | +| `FingerprintMismatch` | The key does not belong to the certificate. Put the right key in the Secret. | + +`FingerprintMismatch` needs the certificate: set `certificateSecretRef` on the `KeepiqConnection`. +To get the certificate, call `GET /api/v1/app/certificate` with the application's access token. The answer holds the PEM and its fingerprint. + +No value ever lands in the resource, an event or the operator log. + +## Keep the value out of Kubernetes Secrets + +A Kubernetes Secret is readable by anyone with Secret read rights in the namespace. +For a workload that must not keep its value in etcd, let the pod fetch it when it starts: +an init container copies the `keepiq` CLI into the pod, and the container starts through `keepiq ci run`. +The chart README holds the full pod template. + +## Next step + +Register a Keepiq application for your namespace and install the operator with the command above. diff --git a/docs/mobile/privacy.md b/docs/mobile/privacy.md new file mode 100644 index 000000000..7108be9e9 --- /dev/null +++ b/docs/mobile/privacy.md @@ -0,0 +1,54 @@ +# Mobile app privacy policy + +This page is the privacy policy for Keepiq for Android and Keepiq for iOS. It covers the preview on GitHub and the coming builds on Google Play, F-Droid and the App Store. + +## What the app does + +Keepiq opens your Keepiq vault on your phone. Your vault lives on your organisation's own Nextcloud server. The app opens it with your master password, on the phone. + +## Where the app connects + +The app talks to three kinds of server, and to nobody else. + +**Your own Nextcloud.** This is where your vault lives. The app sends it: + +- your Nextcloud user name and app password, with every request, to sign in; +- encrypted secrets, when you add or change an item; +- the name and web address of an item, which Keepiq keeps unencrypted so it can match a site or an app; +- the content of a new Send, encrypted, and its expiry and view limit. + +Your master password never leaves the phone. Neither do your decrypted secrets. + +**The server in a Send link you open.** When you open a Send link in Keepiq, the app fetches that Send from the server in the link. That can be another organisation's Nextcloud. It sends nothing about you or your vault. + +**A website's public app list.** Another app on your phone may ask Keepiq for a login or a passkey. Keepiq then checks whether that app really belongs to the website. It reads the website's public file `/.well-known/assetlinks.json` for that. The request carries nothing about you, and Keepiq remembers the answer for a day. A browser asking for a login does not need this check. + +The app sends nothing to Conduction. It has no analytics, no crash reports and no advertising. + +## What the app stores on your phone + +Per connected account: the server address, your Nextcloud user name and an app password. Android keeps these encrypted under a key that never leaves the phone. iOS keeps them in the keychain. You revoke the app password in your Nextcloud security settings to cut the phone off. + +Keepiq for Android also keeps a copy of your vault as the server stores it, so you can open it offline. That copy is encrypted as a whole as well. It holds your encrypted secrets and the names, web addresses and folders of your items. It is removed when you disconnect the account. + +When you turn on a PIN or fingerprint or face unlock, the phone stores your vault key, locked by that PIN or by the phone's secure hardware. Five wrong PINs delete it. + +The app also stores your settings: the lock delay, how long a copy stays on the clipboard, the sites and apps where you said never to save a login, and the signing certificate of each browser that asked for a login. + +Your master password, your decrypted secrets and your unlocked vault key are never stored. They stay in memory while the vault is unlocked and are gone when it locks. + +Keepiq is left out of phone backups, so your vault copy does not end up in a cloud backup. + +## What the app reads on your phone + +To fill in a login in another app, Keepiq looks up that app's name and signing certificate on the phone. This is how it makes sure a login only goes to the app it belongs to. The list of your apps stays on the phone. + +## What the app does not do + +- It does not keep a history of the apps or sites you fill logins in. +- It does not collect analytics or crash reports. +- It does not sell, share or transfer data to third parties. + +## Contact + +Questions go to your organisation's Keepiq administrator, or to Conduction at info@conduction.nl. diff --git a/docs/mobile/using.md b/docs/mobile/using.md new file mode 100644 index 000000000..bf90eb27d --- /dev/null +++ b/docs/mobile/using.md @@ -0,0 +1,208 @@ +# Using the mobile apps + +Keepiq for Android and Keepiq for iOS open your Keepiq vault on your phone. You search, copy, add and edit logins there. You also make passwords and share secrets with a Send link. The app opens your vault with your master password. Your Nextcloud only ever sees encrypted values. + +Keepiq for Android is available as a preview. Keepiq for iOS is not available yet. The iOS screenshots on this page show where it is heading. + +## Getting the app + +### Android + +Keepiq for Android needs Android 9 or later. + +1. Open the [Keepiq mobile releases on GitHub](https://github.com/ConductionNL/keepiq/releases?q=mobile-v&expanded=true) on your phone. +2. Download the `keepiq-android-.apk` file from the newest release. It runs on every phone. The smaller `keepiq-android--arm64-v8a.apk` holds only what most current phones need; take it if you know your phone has that processor type. +3. Open the file. Android asks whether your browser may install apps. Allow it for this install. +4. Choose **Install**. + +Each `mobile-v-preview.` release is a preview. It is signed with a preview key, not with the key of the coming store builds. When Keepiq arrives on Google Play or F-Droid, you uninstall the preview first. Your vault stays on your Nextcloud, so you only connect the phone again. + +#### Checking the download + +Each release lists the SHA-256 of every APK, and carries it as a `.sha256` file next to it. On a computer, compare it with: + +``` +sha256sum keepiq-android-.apk +``` + +You can also check who signed the app. Run `apksigner verify --print-certs` from the Android SDK on the APK. The preview signing certificate has this SHA-256 fingerprint: + +``` +89:31:3B:10:6E:7D:43:FA:14:1E:CB:0B:4D:1C:18:8D:77:D8:B3:E2:14:DB:A9:FB:4E:8D:D3:C2:67:4B:FC:35 +``` + +apksigner prints it in lower case and without colons. Do not install an APK with another fingerprint. + +### iOS + +Keepiq for iOS is not available yet. It comes later, first through TestFlight and then in the App Store. It will need iOS 17 or later. + +## Connecting to your Nextcloud + +1. Open Keepiq and enter the address of your Nextcloud. +2. Choose **Sign in with your browser**. Your phone's browser opens your Nextcloud login page. +3. Sign in and grant access. Keepiq picks up the connection by itself. + +
+ Connect to your Nextcloud, with the server address and the browser sign-in button +
+ +Can't use the browser sign-in? Choose **Use an app password instead**. Create an app password in Nextcloud under **Settings**, then **Security**. Then enter your user name and that app password in Keepiq. + +Keepiq only connects over https, so your app password never travels in clear. Your Nextcloud security settings list the phone as "Keepiq for Android" or "Keepiq for iOS". Revoke it there to cut the phone off. + +Does your organisation require two-factor authentication? Then Keepiq asks you to set it up in Nextcloud first. Do that and choose **Check again**. + +
+ Unlock Keepiq says the organisation requires two-factor authentication, with a Check again button +
+ +Use **Connect another account** to add a second account, on the same server or another one. + +## Unlocking + +Enter your master password and choose **Unlock**. Your master password never leaves the phone. + +
+ Android: Unlock Keepiq with the account name and the master password field + iOS: Unlock Keepiq with the account name and the master password field +
+ +### PIN and biometrics + +Typing your master password each time is slow. Open **Unlock and account** from the settings button to make it faster: + +- **Fingerprint or face** unlocks with your fingerprint, or with Face ID on an iPhone. Set up a fingerprint or face in the phone settings first. A new fingerprint on the phone switches it off. Then you unlock with your master password once. +- **PIN** unlocks with a short PIN. Five wrong PINs delete it, and then you need your master password. + +
+ Unlock and account settings with the fingerprint or face switch, the PIN, the lock delay and the account + Unlock Keepiq asks for the PIN, with a link to use the master password instead +
+ +### Auto-lock + +Under **Lock after** you choose how long Keepiq stays open while you do not use it. Pick 1, 5, 15, 30, 60 or 240 minutes. Your organisation can set a maximum, and the screen tells you what it is. Keepiq for Android also locks when you turn the screen off. + +The lock button at the top of the vault locks it at once. Locking forgets your vault key. Your logins stay on the phone in encrypted form only. + +The same settings screen has **Disconnect this account**. It signs the phone out and revokes its app password. It also removes the vault copy from the phone. + +## The vault + +The vault lists your folders and your items. Type in the search field to find an item by name or address. + +
+ Android: the vault with the folders Personal and Work and five demo items + iOS: the vault with the folders Personal and Work and the demo items + Android: searching for bank finds the Bank demo item + iOS: searching for bank finds the Bank demo item +
+ +
+ + +
+ +### Opening an item + +Tap an item to see its fields. **Show** reveals the password and **Hide** covers it again. + +**Copy** puts a value on the clipboard. Keepiq clears it again after 60 seconds. Android 13 and later hide the copied value in the clipboard preview. On iOS the copy stays on the phone. It does not go to your other Apple devices. + +
+ Android: the Webmail demo login with user name, password and address, each with Copy + iOS: the Webmail demo login, with the note Copied. Cleared in 60 seconds. +
+ +### One-time codes + +An item with an authenticator shows its current code. The circle counts down to the next one. Tap **Copy** to copy the code. + +
+ Android: the Authenticator demo item shows a six digit code and its countdown + iOS: the Authenticator demo item shows a six digit code and its countdown +
+ +### Adding and editing + +Tap **+** to add an item. Pick its type and fill in the fields. In an item, **Edit** changes it and **Move** puts it in another folder. **Move to trash** removes it. You can restore it from the trash in the web app. + +
+ Android: the new Shop demo login with a generated password + iOS: the new Shop demo login with a generated password +
+ +### Generator + +The **Generator** tab makes a password or a passphrase. Set the length and the kinds of characters you want. **Avoid look-alike characters** leaves out characters such as 0 and O. Your organisation's password policy sets the limits. Choose **Use this** to put it in the item you are editing. + +
+ Android: the generator with a 14 character password and its options + iOS: the generator with a 14 character password and its options +
+ +### Send + +A Send shares a secret through a link that expires. Open an item and choose **New Send**, or start one from the **Send** tab. When the link is ready, choose **Share** or **Copy link**. Anyone with the link can open the Send, so pass it on with care. + +Open a Send link on an Android phone and Keepiq shows its content. Opening it uses one of its views. + +
+ Android: the new Send is ready, with its link and the Share and Copy link buttons + iOS: the new Send is ready, with its link and the Share and Copy link buttons + Android: an opened Send shows the demo door code and says this was the last view +
+ +On iOS you cannot make a Send with a password yet. A Send link there opens in the browser. + +## Fill in logins in other apps + +Keepiq for Android can fill in your logins in other apps and in your browser. + +1. In Keepiq, open **Unlock and account** from the settings button. The **Autofill** part says whether Keepiq fills in your logins. +2. Tap **Choose Keepiq for autofill**. Android asks whether you trust Keepiq. Confirm. + +You can also do it from Android: open your phone's **Settings**, search for **Autofill service** and choose **Keepiq**. Where it sits differs per phone. + +Tap a login field in an app or on a website. Keepiq offers the matching logins. When the vault is locked, you see **Unlock Keepiq** first, without any account names. After you unlock, Keepiq fills in the login you choose. + +Keepiq only offers an app's login to the app it belongs to. A copy of that app from another publisher gets nothing. + +When you sign up or change a password, Keepiq offers to save it. Choose **Never** to stop the offer for that site or app. Android 9 and 10 only show **Not now**. To see or undo your **Never** choices, tap **Show where Keepiq never saves** under **Autofill** in Keepiq's settings, or the gear next to Keepiq in Android's autofill screen. + +A one-time code field gets the current code of the matching login. + +On iOS this comes later. It needs a signed build from the App Store or TestFlight. + +## Passkeys + +Keepiq for Android saves your passkeys and signs you in with them. This needs Android 14 or later. On older versions Keepiq still fills in your passwords and codes. + +1. Open your phone's **Settings** and go to **Passwords, passkeys and accounts**. The name differs a little per phone. +2. Turn on **Keepiq**. You can also tap **Choose Keepiq for passkeys** in Keepiq's autofill settings. + +When an app or a website asks to create a passkey, choose Keepiq. The passkey goes into your vault, encrypted, next to your logins. The web app and the browser extension use the same passkey. + +When an app or a website asks you to sign in with a passkey, Keepiq shows the passkeys you saved for it. When the vault is locked, you see **Unlock Keepiq** first, without any account names. + +Keepiq only signs in to the site the passkey belongs to. An app gets a site's passkey only when that site names the app in its Digital Asset Links file. Keepiq only makes ES256 passkeys. When a site asks for another kind, your phone offers its other password managers. + +On iOS this comes with the signed build from the App Store or TestFlight. It needs iOS 17 or later. + +## Offline + +Keepiq for Android keeps an encrypted copy of your vault on the phone. Without a connection you can still open, search and copy. The vault shows when it last synced. Adding, editing and deleting need a connection, and Keepiq tells you so. + +Keepiq for iOS does not keep an offline copy yet. It needs a connection. + +## Privacy + +Keepiq talks to your own Nextcloud and sends nothing to Conduction. It has no analytics and no crash reports. The [mobile app privacy policy](privacy.md) lists what the app sends where and what it keeps on your phone. + +## Coming next + +- **Autofill on iOS.** Fill in logins in other apps and in Safari. +- **Passkeys on iOS.** Save and use passkeys in Safari and in apps, with iOS 17 or later. +- **Google Play and F-Droid.** Install and update Keepiq for Android from a store. +- **The App Store.** Keepiq for iOS, first through TestFlight. diff --git a/docs/package-lock.json b/docs/package-lock.json index 30288527b..b175f270e 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -25,47 +25,62 @@ "node": ">=18.0" } }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" + } + }, "node_modules/@algolia/abtesting": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.1.tgz", - "integrity": "sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.25.0.tgz", + "integrity": "sha512-rSTin9Uta23uaewYVQEp8XI9T3iA/zrg0/1G2vhf8oFFDxFL5vybnZ5IQwsVAg4JpKxPX4/WYNKdcfWrZymk7w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.8.tgz", - "integrity": "sha512-3YEorYg44niXcm7gkft3nXYItHd44e8tmh4D33CTszPgP0QWkaLEaFywiNyJBo7UL/mqObA/G9RYuU7R8tN1IA==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.12.tgz", + "integrity": "sha512-nJU03L3Q0LlfnFsQTE1YppSrNcbP0lBg29mCaGCeYPog841HP9WWk5KtrTvNH2LPX6p5eELPFetSeSO8I1MFPg==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.8", - "@algolia/autocomplete-shared": "1.19.8" + "@algolia/autocomplete-plugin-algolia-insights": "1.19.12", + "@algolia/autocomplete-shared": "1.19.12" } }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.8.tgz", - "integrity": "sha512-ZvJWO8ZZJDpc1LNM2TTBdmQsZBLMR4rU5iNR2OYvEeFBiaf/0ESnRSSLQbryarJY4SVxtoz6A2ZtDMNM+iQEAA==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.12.tgz", + "integrity": "sha512-L3Fvu4Ajb7c9X1DMTHFFD8d1uK898b9bg9ENrjLpkSP8BAzO4vS5/MIsU0ks0FqpOihZ4VDAVYRaU6bhm/CljQ==", "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.19.8" + "@algolia/autocomplete-shared": "1.19.12" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.19.8", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.8.tgz", - "integrity": "sha512-h5hf2t8ejF6vlOgvLaZzQbWs5SyH2z4PAWygNAvvD/2RI29hdQ54ldUGwqVuj9Srs+n8XUKTPUqb7fvhBhQrnQ==", + "version": "1.19.12", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.12.tgz", + "integrity": "sha512-goe55oftoEduuOHhTg4viHr58/ZsZvYEtfxkKFy6LdJxZ7+jOdVwp2bZz7qjip9CdFqkIB+34d4esftcaErCzA==", "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -73,99 +88,99 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.1.tgz", - "integrity": "sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.59.0.tgz", + "integrity": "sha512-bm2XN0hCSMYwStSsCBT0/PUB2BDxoyR1Lnub3c392HMEy9bi8PUSW8vR6zltVEKWG2t4PQFWMD5C07bmJxGgPg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.1.tgz", - "integrity": "sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.59.0.tgz", + "integrity": "sha512-XOFPOTa69WuqHR6c5tMgnUUwwqQgNSzMpxmhrgA9KmxRf8WIqEa0cokHJvohk5CYb7CZx0xeSL6Bk2IUJ7Lv7Q==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.1.tgz", - "integrity": "sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.59.0.tgz", + "integrity": "sha512-PC8ipLOYFKRTfIUY1J3FJxS6ryzWziaXmIX9/sNMoUR8L+XhF7hX2QAeUI87YVl5zujvlYTSOb+HAIqKXDjyHQ==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.1.tgz", - "integrity": "sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.59.0.tgz", + "integrity": "sha512-yFNcCMM5fHiyoR0HuxMrzy+VjDcmhFUZTm2IJ2DHwGsVH3B5SEob4zTmeEZ3j/AZqNnmrJOCKl/tJnBg7+84bA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.1.tgz", - "integrity": "sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.59.0.tgz", + "integrity": "sha512-GYja6HkDt2VrQhWmB2cLx3Z5fDwI9no7q+xwCWcFrTPm5CLH9QZvK0XLO9co1FcNIDxxkMaP3AXM39/XcecEOg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.1.tgz", - "integrity": "sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.59.0.tgz", + "integrity": "sha512-Wofg7bMpWh8N5qDDZs0wy6whc+KMmdNsxgrIGp9Ug2s1Bka0uq7AjyckdxReKPHLA0Q1qH8X/SNh0wn2t2X2Lw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.1.tgz", - "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.59.0.tgz", + "integrity": "sha512-fHnALZfbEnODczGk14Y/1YBRApp6UEpZUTexGcMUPzY7RDc7q4HN2Y6jh0KUG8Jo9B+q+wOoQEc3ziR0ErbuFg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" @@ -178,81 +193,81 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.52.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.1.tgz", - "integrity": "sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.59.0.tgz", + "integrity": "sha512-Fa38s1mHgoaLCT117sfJ6P78rtxUt93CYxBYpq1VOIcslaF+cH+1h5uikAPsKfxLnsuEDZHiBiaI6eNPTsQMRA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.52.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.1.tgz", - "integrity": "sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.59.0.tgz", + "integrity": "sha512-NyNsRSqM2tF1MX7ZGw/j4rduoEJiQ5wfvbSo/CFVdEzYCjyxbGFRPOZyS/GETJG9rk1TdHOq8NZqKqk/u1fm4g==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.1.tgz", - "integrity": "sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.59.0.tgz", + "integrity": "sha512-nXBK2uygWtvbCOffMWqqfjjcEtp9enDKY5/2Pw/Hhw8VVaOyK2ThbGK04bNX/Le+qoK1VHYkodWGVTKfw0Otkw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "@algolia/client-common": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.1.tgz", - "integrity": "sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.59.0.tgz", + "integrity": "sha512-yb+4afX/zja8QwX0KmV4/ae2kyYkRknsE79CRusYS2U5D8qwn2wq0cn1x12f3tm3F3+5FgmDdbuGdQTuCLSKMQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.1.tgz", - "integrity": "sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.59.0.tgz", + "integrity": "sha512-Lp52TmpA1QtNmdHzs505Xwf4tgII7RAapkDSF9AAtWIBETcGaTIaXci4Rd9nS6SdaWK1BWTSqAPp5wzh6UN3Ag==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.1.tgz", - "integrity": "sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.59.0.tgz", + "integrity": "sha512-YYHLEs5rC6oRTFwT7bJaKBR9NSFzikeVHGAT1ffATmUQzR8XqyhHXjoieAUoR7fBU6I5cfy1FVeh5GoRgriuzA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.52.1" + "@algolia/client-common": "5.59.0" }, "engines": { "node": ">= 14.0.0" @@ -272,12 +287,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -286,29 +301,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -334,13 +349,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -350,25 +365,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -387,17 +402,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -417,12 +432,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, @@ -459,49 +474,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -511,35 +526,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -549,14 +564,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -566,79 +581,79 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -648,13 +663,13 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", - "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -664,12 +679,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -679,12 +694,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -694,13 +709,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", - "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -710,14 +725,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -727,13 +742,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -767,12 +782,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -782,12 +797,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -797,12 +812,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -812,12 +827,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -843,12 +858,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -858,14 +873,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -875,14 +890,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -892,12 +907,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -907,12 +922,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -922,13 +937,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -938,13 +953,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -954,17 +969,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -974,13 +989,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -990,13 +1005,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1006,13 +1021,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1022,12 +1037,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1037,13 +1052,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1053,12 +1068,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1068,13 +1083,13 @@ } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1084,12 +1099,12 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1099,12 +1114,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1114,13 +1129,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1130,14 +1145,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1147,12 +1162,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1162,12 +1177,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1177,12 +1192,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1192,12 +1207,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1207,13 +1222,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1223,13 +1238,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1239,15 +1254,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", - "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1257,13 +1272,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1273,13 +1288,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1289,12 +1304,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1304,12 +1319,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1319,12 +1334,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1334,16 +1349,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1353,13 +1368,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1369,12 +1384,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1384,13 +1399,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1400,12 +1415,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1415,13 +1430,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1431,14 +1446,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1448,12 +1463,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1463,12 +1478,12 @@ } }, "node_modules/@babel/plugin-transform-react-constant-elements": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz", - "integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.29.7.tgz", + "integrity": "sha512-J0wGhKan+rIiE2OhfhRptySLrJ6SjQYM6b6N1FMlhyhCcw1Mig8vQjWchyB+bgHGDvaWo6Diu6CLRMra2uMtmg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1478,12 +1493,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1493,16 +1508,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1512,12 +1527,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1527,13 +1542,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1543,12 +1558,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1558,13 +1573,13 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1574,12 +1589,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1589,13 +1604,13 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", "babel-plugin-polyfill-corejs2": "^0.4.14", "babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-regenerator": "^0.6.5", @@ -1618,12 +1633,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1633,13 +1648,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1649,12 +1664,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1664,12 +1679,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1679,12 +1694,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1694,16 +1709,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1713,12 +1728,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1728,13 +1743,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1744,13 +1759,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1760,13 +1775,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1776,76 +1791,76 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.29.5", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz", - "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==", - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.4", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", @@ -1897,17 +1912,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.29.7.tgz", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1917,16 +1932,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1945,31 +1960,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -1977,13 +1992,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2237,6 +2252,19 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-color-function": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-4.0.12.tgz", @@ -2623,6 +2651,19 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-light-dark-function": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.11.tgz", @@ -3057,6 +3098,19 @@ "postcss": "^8.4" } }, + "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@csstools/postcss-sign-functions": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz", @@ -3313,9 +3367,9 @@ } }, "node_modules/@docsearch/core": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.3.tgz", - "integrity": "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.7.0.tgz", + "integrity": "sha512-p/9xVKmPDj3FPvMfPf5naVO3Ej8SCbcUugGvx1+8GgkuBNbqxqN2Irx3WLBv8VY0jH7XpRwKWdlmjXLZsmTLsg==", "license": "MIT", "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3335,20 +3389,20 @@ } }, "node_modules/@docsearch/css": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz", - "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.0.tgz", + "integrity": "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==", "license": "MIT" }, "node_modules/@docsearch/react": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.3.tgz", - "integrity": "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.7.0.tgz", + "integrity": "sha512-x6oedjJ8O8/pIDBsMo5Orca3/6cQCz616/CwthVe68l43mqnj2lrJ9kFQITBqy8hMsS3nWeBWFoVO5dJ1DCFKA==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "4.6.3", - "@docsearch/css": "4.6.3" + "@docsearch/core": "4.7.0", + "@docsearch/css": "4.7.0" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -3404,9 +3458,9 @@ } }, "node_modules/@docusaurus/babel": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz", - "integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.2.tgz", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", @@ -3418,8 +3472,8 @@ "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -3429,17 +3483,17 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz", - "integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.2.tgz", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.10.1", - "@docusaurus/cssnano-preset": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", @@ -3472,18 +3526,18 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", - "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", - "license": "MIT", - "dependencies": { - "@docusaurus/babel": "3.10.1", - "@docusaurus/bundler": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.2.tgz", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -3491,7 +3545,7 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "detect-port": "^1.5.1", + "detect-port": "^2.1.0", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", @@ -3539,9 +3593,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz", - "integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.2.tgz", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", "license": "MIT", "dependencies": { "cssnano-preset-advanced": "^6.1.2", @@ -3554,9 +3608,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz", - "integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.2.tgz", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", "license": "MIT", "dependencies": { "chalk": "^4.1.2", @@ -3567,14 +3621,14 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz", - "integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.2.tgz", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -3606,12 +3660,12 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz", - "integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.2.tgz", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -3625,19 +3679,19 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.1.tgz", - "integrity": "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "cheerio": "1.0.0-rc.12", "combine-promises": "^1.1.0", "feed": "^4.2.2", @@ -3660,20 +3714,20 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", - "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -3693,16 +3747,16 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.1.tgz", - "integrity": "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.2.tgz", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -3716,15 +3770,15 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.1.tgz", - "integrity": "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.2.tgz", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3732,14 +3786,14 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.1.tgz", - "integrity": "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.2.tgz", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -3753,14 +3807,14 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.1.tgz", - "integrity": "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.2.tgz", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3772,15 +3826,14 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.1.tgz", - "integrity": "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.2.tgz", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", - "@types/gtag.js": "^0.0.20", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3792,14 +3845,14 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.1.tgz", - "integrity": "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.2.tgz", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -3811,17 +3864,17 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.1.tgz", - "integrity": "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.2.tgz", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -3835,15 +3888,15 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.1.tgz", - "integrity": "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.2.tgz", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -3858,26 +3911,26 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.1.tgz", - "integrity": "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/plugin-css-cascade-layers": "3.10.1", - "@docusaurus/plugin-debug": "3.10.1", - "@docusaurus/plugin-google-analytics": "3.10.1", - "@docusaurus/plugin-google-gtag": "3.10.1", - "@docusaurus/plugin-google-tag-manager": "3.10.1", - "@docusaurus/plugin-sitemap": "3.10.1", - "@docusaurus/plugin-svgr": "3.10.1", - "@docusaurus/theme-classic": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-search-algolia": "3.10.1", - "@docusaurus/types": "3.10.1" + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.2.tgz", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" }, "engines": { "node": ">=20.0" @@ -3888,24 +3941,24 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.1.tgz", - "integrity": "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.2.tgz", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", @@ -3951,15 +4004,15 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", - "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.2.tgz", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -4001,16 +4054,16 @@ } }, "node_modules/@docusaurus/theme-mermaid": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.1.tgz", - "integrity": "sha512-2gxpmln8Pc4EN1oWzshQEx2HTs67jk14v7MmgqGs8ZU7Nm8oihg+fTouof2u4vN8DtB3Fln4cDJu4UprSX1S3Q==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.2.tgz", + "integrity": "sha512-Stssh5MYQJ+EdYugUXf+ZcpeJFQPKXf0KCd/SWp10o3CmXNaOoh5IEgVjVqY1e1XhQf3on4+Y4BnrMiD95E2SQ==", "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "mermaid": ">=11.6.0", "tslib": "^2.6.0" }, @@ -4029,20 +4082,20 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.1.tgz", - "integrity": "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "^1.19.2", "@docsearch/react": "^3.9.0 || ^4.3.2", - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", @@ -4070,9 +4123,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.1.tgz", - "integrity": "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.2.tgz", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", "license": "MIT", "dependencies": { "fs-extra": "^11.1.1", @@ -4083,9 +4136,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz", - "integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.2.tgz", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.0.0", @@ -4119,21 +4172,21 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz", - "integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.2.tgz", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "escape-string-regexp": "^4.0.0", "execa": "^5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", "globby": "^11.1.0", - "gray-matter": "^4.0.3", "jiti": "^1.20.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -4151,12 +4204,12 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz", - "integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.2.tgz", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4164,14 +4217,14 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz", - "integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.2.tgz", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -5049,9 +5102,9 @@ "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, "node_modules/@sindresorhus/is": { @@ -5711,12 +5764,6 @@ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "license": "MIT" }, - "node_modules/@types/gtag.js": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.20.tgz", - "integrity": "sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==", - "license": "MIT" - }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -6247,12 +6294,12 @@ } }, "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.0.0" } }, "node_modules/aggregate-error": { @@ -6314,34 +6361,34 @@ } }, "node_modules/algoliasearch": { - "version": "5.52.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.1.tgz", - "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.18.1", - "@algolia/client-abtesting": "5.52.1", - "@algolia/client-analytics": "5.52.1", - "@algolia/client-common": "5.52.1", - "@algolia/client-insights": "5.52.1", - "@algolia/client-personalization": "5.52.1", - "@algolia/client-query-suggestions": "5.52.1", - "@algolia/client-search": "5.52.1", - "@algolia/ingestion": "1.52.1", - "@algolia/monitoring": "1.52.1", - "@algolia/recommend": "5.52.1", - "@algolia/requester-browser-xhr": "5.52.1", - "@algolia/requester-fetch": "5.52.1", - "@algolia/requester-node-http": "5.52.1" + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.59.0.tgz", + "integrity": "sha512-wUXzaeI7B526W4y1gFg3lcxgDZ67XSgRJIiellYWOas/pLpO7rOtxm9Gr2E/C8aiSDXIgx1q5TdSsvK67Uakqw==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.25.0", + "@algolia/client-abtesting": "5.59.0", + "@algolia/client-analytics": "5.59.0", + "@algolia/client-common": "5.59.0", + "@algolia/client-insights": "5.59.0", + "@algolia/client-personalization": "5.59.0", + "@algolia/client-query-suggestions": "5.59.0", + "@algolia/client-search": "5.59.0", + "@algolia/ingestion": "1.59.0", + "@algolia/monitoring": "1.59.0", + "@algolia/recommend": "5.59.0", + "@algolia/requester-browser-xhr": "5.59.0", + "@algolia/requester-fetch": "5.59.0", + "@algolia/requester-node-http": "5.59.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/algoliasearch-helper": { - "version": "3.29.1", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.29.1.tgz", - "integrity": "sha512-6ck2YFudF2Pje7szQoPBiRFTGfd+1I+0I/WfLPGn0bj1kvrFoOQmNyedNiDxTk3/r4IfSLDYk+RA4G7u8H6+yA==", + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.30.0.tgz", + "integrity": "sha512-leiyAC/Giqxk9OW2YCUZDtqKZInSwMgdhd4PNqeUC8eNBZgtIB9BlUR0RkAbHeS9r5GxI1GiJUu31SO8RjWfaA==", "license": "MIT", "dependencies": { "@algolia/events": "^4.0.1" @@ -6488,9 +6535,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "funding": [ { "type": "opencollective", @@ -6507,8 +6554,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -6614,9 +6661,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6653,9 +6700,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -6666,7 +6713,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -6751,9 +6798,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6773,9 +6820,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "funding": [ { "type": "opencollective", @@ -6792,11 +6839,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -6962,9 +7009,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "funding": [ { "type": "opencollective", @@ -7271,9 +7318,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz", + "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==", "license": "MIT" }, "node_modules/colorette": { @@ -7568,12 +7615,15 @@ } }, "node_modules/core-js-compat": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" }, "funding": { "type": "opencollective", @@ -7687,6 +7737,19 @@ "postcss": "^8.4" } }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.4.0.tgz", @@ -7726,6 +7789,19 @@ "postcss": "^8.4" } }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -7869,9 +7945,9 @@ } }, "node_modules/cssdb": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.8.1.tgz", - "integrity": "sha512-PdLTDamqN1muXEmfQggrogLmD+ZjfOhlZsFFs28tYSTqnlk6gEwg5wQCt6wLl2HstegUYgof6GrYyXXODFDC5g==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-8.12.0.tgz", + "integrity": "sha512-A8/XPAtGymaiKrVU++Xxmu+277gNdjKH0876QFiEFufAyKPX7V/+jVynbfiN9b45Hz07iKO4b7oJuECFTl6YQQ==", "funding": [ { "type": "opencollective", @@ -8751,20 +8827,19 @@ "license": "MIT" }, "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", "license": "MIT", "dependencies": { - "address": "^1.0.1", - "debug": "4" + "address": "^2.0.1" }, "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" }, "engines": { - "node": ">= 4.0.0" + "node": ">= 16.0.0" } }, "node_modules/devlop": { @@ -8855,9 +8930,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.14", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", - "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -8944,9 +9019,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.354", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", - "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "version": "1.5.444", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", + "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9153,19 +9228,6 @@ "node": ">=8.0.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -9380,9 +9442,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -9404,9 +9466,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -9514,9 +9576,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -9530,9 +9592,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -9799,9 +9861,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -10029,43 +10091,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -10139,9 +10164,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10532,9 +10557,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "license": "BSD-2-Clause" }, "node_modules/http-deceiver": { @@ -10584,9 +10609,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", @@ -10684,15 +10709,15 @@ } }, "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz", + "integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==", "license": "MIT", "bin": { "image-size": "bin/image-size.js" }, "engines": { - "node": ">=16.x" + "node": ">=18" } }, "node_modules/import-fresh": { @@ -10860,12 +10885,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -11198,9 +11223,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.8", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.8.tgz", + "integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -11217,9 +11242,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -11575,9 +11600,9 @@ } }, "node_modules/mdast-util-directive": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", - "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.1.tgz", + "integrity": "sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -11586,6 +11611,7 @@ "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", + "micromark-util-character": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" @@ -11595,10 +11621,46 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-directive/node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/mdast-util-directive/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/mdast-util-find-and-replace": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", - "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -11783,9 +11845,9 @@ } }, "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -12227,9 +12289,9 @@ } }, "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -12427,9 +12489,9 @@ } }, "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -12517,9 +12579,9 @@ "license": "MIT" }, "node_modules/micromark-extension-gfm-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", - "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", "license": "MIT", "dependencies": { "devlop": "^1.0.0", @@ -12534,9 +12596,9 @@ } }, "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -12620,9 +12682,9 @@ } }, "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", - "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", "funding": [ { "type": "GitHub Sponsors", @@ -14009,9 +14071,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -14067,10 +14129,13 @@ } }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", - "license": "MIT" + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -14690,9 +14755,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -14709,7 +14774,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -14742,6 +14807,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-calc": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-9.0.1.tgz", @@ -14758,19 +14836,6 @@ "postcss": "^8.2.2" } }, - "node_modules/postcss-calc/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-clamp": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", @@ -14986,6 +15051,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-dir-pseudo-class": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz", @@ -15011,6 +15089,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-discard-comments": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz", @@ -15074,19 +15165,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-discard-unused/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-double-position-gradients": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.4.tgz", @@ -15139,6 +15217,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-focus-within": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz", @@ -15164,6 +15255,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-font-variant": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", @@ -15347,19 +15451,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-merge-rules/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-minify-font-values": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-6.1.0.tgz", @@ -15424,19 +15515,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-minify-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", @@ -15466,6 +15544,19 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-modules-scope": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", @@ -15481,6 +15572,19 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-modules-values": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", @@ -15523,6 +15627,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-normalize-charset": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz", @@ -15873,6 +15990,19 @@ "postcss": "^8.4" } }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-reduce-idents": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz", @@ -15953,10 +16083,23 @@ "postcss": "^8.4" } }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -16012,19 +16155,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-unique-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -16191,9 +16321,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -16238,15 +16368,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -16575,9 +16696,9 @@ "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -16587,13 +16708,13 @@ } }, "node_modules/regexpu-core": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.2.tgz", + "integrity": "sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==", "license": "MIT", "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", + "regenerate-unicode-properties": "^10.3.0", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", @@ -16637,9 +16758,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -17118,9 +17239,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -17160,13 +17281,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "license": "MIT", - "peer": true - }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -17275,12 +17389,12 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/serve-handler": { @@ -17651,13 +17765,16 @@ } }, "node_modules/sockjs/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/sort-css-media-queries": { @@ -17679,9 +17796,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -17746,12 +17863,6 @@ "wbuf": "^1.7.3" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -17936,19 +18047,6 @@ "postcss": "^8.4.31" } }, - "node_modules/stylehacks/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/stylis": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", @@ -17980,15 +18078,18 @@ } }, "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.1.0.tgz", + "integrity": "sha512-bwLf38YmY+TDYHJw1Ex0Co8c4yeXuJAo8YnXGZrscxrvYoVVIvLeniEkV1Ks/54VteMnL4FtyN1+P+TZJdUPmQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } }, "node_modules/svgo": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz", - "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -18540,9 +18641,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "funding": [ { "type": "opencollective", diff --git a/docs/package.json b/docs/package.json index 92abe6375..4e2390bf6 100644 --- a/docs/package.json +++ b/docs/package.json @@ -44,5 +44,11 @@ }, "engines": { "node": ">=18.0" + }, + "overrides": { + "serialize-javascript": "^7.0.5", + "sockjs": { + "uuid": "^11.1.1" + } } } diff --git a/docs/rotation-and-sync.md b/docs/rotation-and-sync.md new file mode 100644 index 000000000..eb193dc25 --- /dev/null +++ b/docs/rotation-and-sync.md @@ -0,0 +1,79 @@ + + +# Rotation and sync runner + +Let a database password change itself every week, and keep your cloud secret stores in step with Keepiq. +The Keepiq server cannot do either, because it never sees a value. +`keepiq-runner` can: it acts as one Keepiq application and decrypts with that application's own key, on your machine. + +## Set it up + +1. Register a Keepiq application for the runner, for example `ops-runner`, and keep its private key on the runner host. +2. File the secrets it rotates or syncs in that application's vault, together with the credentials it needs: the database admin login, the AWS keys, the GitHub token. +3. Write `runner.yaml`. Start from `integrations/runner/runner.example.yaml`; it names secrets, never values. +4. Check it with `keepiq-runner check --config runner.yaml`, then start `keepiq-runner run`. + +Give each runner its own application, holding only the secrets that runner works on. +Whoever holds the runner's key can read every secret in that vault. + +Run it as a service, or once from cron or a Kubernetes CronJob with `keepiq-runner run --once`. +The image is `ghcr.io/conductionnl/keepiq-runner`. + +## Rotation + +A rotation never stores a password that does not work. For each rotation the runner: + +1. reads the current password and its version from Keepiq; +2. generates a new one; +3. notes both in its journal, encrypted to the application's key; +4. sets the new password at the database as the admin; +5. logs in with the new password; +6. stores it in Keepiq, but only if nobody changed the secret in the meantime. + +If the login fails, the runner sets the old password back and Keepiq keeps the old value. +If someone changed the secret in Keepiq during the rotation, the runner sets the old password back and logs a conflict. +If the runner stops between steps 4 and 6, it finishes the rotation from the journal at its next start. + +A rotation runs on its cron `schedule`, and with `followExpiry` also when the secret's expiry date is within `leadTime` (default 7 days). +The rotation does not move the expiry date; set a new one in Keepiq when your policy needs it. + +| Connector | What it changes | Target options | +|---|---|---| +| `postgres` | `ALTER ROLE … PASSWORD` | `host`, `database`, `sslmode`, `user` | +| `mysql` | `ALTER USER … IDENTIFIED BY` | `host`, `database`, `userHost`, `tls`, `user` | +| `exec` | anything your command does | `command` | + +The rotated secret's `login` is the account to rotate, unless `target.user` names another one. +The `adminSecret` holds the admin account in `login` and its password in `key`. + +The `exec` command gets one JSON object on stdin, `{"action":"set","user":…,"current":…,"new":…}` or `{"action":"login","user":…,"password":…}`, and answers with its exit code. +The runner never reads its output, so a hook cannot leak a value into the log. + +## Sync + +Every `interval` (default 60 seconds) the runner asks Keepiq what changed and pushes changed secrets of each sync set. +An unchanged secret is never pushed again. A failed push is retried later and never holds up the others. + +| Destination | Options | Credentials secret | +|---|---|---| +| `aws-secrets-manager` | `region`, `prefix` | `login` access key id, `key` secret key; or leave it out for the default AWS chain | +| `azure-key-vault` | `vaultUrl`, `prefix` | `login` client id, `key` client secret, `additionalFields.tenantId`; or leave it out for the managed identity | +| `github-actions` | `repository` (with `environment`) or `organization` | `key` a token that may write Actions secrets | +| `exec` | `command` | none; the command gets `{"name":…,"value":…}` on stdin | + +GitHub gets every value sealed with the repository's public key, as GitHub requires. +A destination keeps its own access rules; the runner copies only the secrets you name in a sync set. +When you delete a secret in Keepiq, the runner does not delete it at the destination. Remove it there yourself. + +## What stays secret + +The runner sends Keepiq ciphertext only, and the private key never leaves the host. +Its log and its state directory hold names, ids and versions, never a value. +Every rotation shows in the Keepiq audit trail as an update by the application, and version history keeps the previous value. + +## Next step + +Register the `ops-runner` application, copy `runner.example.yaml`, and run `keepiq-runner check`. diff --git a/docs/siem-connectors.md b/docs/siem-connectors.md new file mode 100644 index 000000000..dcb3fd778 --- /dev/null +++ b/docs/siem-connectors.md @@ -0,0 +1,56 @@ + + +# SIEM connectors + +Send Keepiq's audit events straight into Splunk, Microsoft Sentinel, QRadar or ArcSight, without building a collector or a parser. +Each event carries the same sanitized metadata as the generic export: who did what to which object, never a secret value, login or ciphertext. + +Add a sink in the Keepiq admin settings, under SIEM audit export, and pick a connector. + +## Splunk HTTP Event Collector + +1. Create an index, for example `keepiq`. +2. Create a HEC token that may write to that index only. +3. Install `integrations/siem/splunk/props.conf` for the `keepiq:audit` sourcetype. +4. Pick **Splunk HTTP Event Collector**, enter `https://:8088/services/collector/event`, the token and the index. + +Keepiq accepts a delivery only when Splunk answers 200 with code 0. +Anything else is retried with backoff and, after the retry ceiling, dead-lettered with an admin notification. + +## Microsoft Sentinel + +1. Deploy `integrations/siem/sentinel/keepiq-dcr.json`. It creates the `KeepiqAudit_CL` table and a data collection rule. +2. Register an application in Microsoft Entra ID with a client secret. +3. Give it only the Monitoring Metrics Publisher role, on that one data collection rule. +4. Pick **Microsoft Sentinel**, enter the logs ingestion URL of your data collection endpoint, the tenant id, the client id, the rule's immutable id and the client secret. + +Keepiq asks Entra ID for a token once per delivery run and keeps it in memory for that run only. +The setup steps are in `integrations/siem/sentinel/README.md`. + +## CEF over syslog + +Pick **CEF over syslog** for QRadar, ArcSight or Sentinel's CEF connector. +Keepiq sends `CEF:0|Conduction|Keepiq|||||…` in the same RFC 5424 frame as the JSON syslog sink. + +Severity follows the event category: + +| Category | Severity | +|---|---| +| honey | 10 | +| suite | 8 | +| emergency | 7 | +| share | 5 | +| any other | 3 | + +## Credentials + +The HEC token and the client secret are encrypted at rest and never shown again. +Leave the field blank when you edit a sink to keep the stored one. +They never appear in an API answer, a log line, an audit entry or a delivered event. + +## Next step + +Create the least-privilege credential for your SIEM, add the sink, and press **Test**. diff --git a/docs/static/media/browser-extension/fill-filled.png b/docs/static/media/browser-extension/fill-filled.png new file mode 100644 index 000000000..4d5a3bd9d Binary files /dev/null and b/docs/static/media/browser-extension/fill-filled.png differ diff --git a/docs/static/media/browser-extension/fill.webm b/docs/static/media/browser-extension/fill.webm new file mode 100644 index 000000000..3fb2f8f82 Binary files /dev/null and b/docs/static/media/browser-extension/fill.webm differ diff --git a/docs/static/media/browser-extension/generator-passphrase.png b/docs/static/media/browser-extension/generator-passphrase.png new file mode 100644 index 000000000..6619c1f92 Binary files /dev/null and b/docs/static/media/browser-extension/generator-passphrase.png differ diff --git a/docs/static/media/browser-extension/generator.png b/docs/static/media/browser-extension/generator.png new file mode 100644 index 000000000..320c1fadf Binary files /dev/null and b/docs/static/media/browser-extension/generator.png differ diff --git a/docs/static/media/browser-extension/generator.webm b/docs/static/media/browser-extension/generator.webm new file mode 100644 index 000000000..d95c05fb6 Binary files /dev/null and b/docs/static/media/browser-extension/generator.webm differ diff --git a/docs/static/media/browser-extension/pair-and-unlock.webm b/docs/static/media/browser-extension/pair-and-unlock.webm new file mode 100644 index 000000000..1b94cf5cb Binary files /dev/null and b/docs/static/media/browser-extension/pair-and-unlock.webm differ diff --git a/docs/static/media/browser-extension/pair.png b/docs/static/media/browser-extension/pair.png new file mode 100644 index 000000000..7041c3279 Binary files /dev/null and b/docs/static/media/browser-extension/pair.png differ diff --git a/docs/static/media/browser-extension/passkey-consent.png b/docs/static/media/browser-extension/passkey-consent.png new file mode 100644 index 000000000..e46817f5f Binary files /dev/null and b/docs/static/media/browser-extension/passkey-consent.png differ diff --git a/docs/static/media/browser-extension/passkey-signed-in.png b/docs/static/media/browser-extension/passkey-signed-in.png new file mode 100644 index 000000000..4463ccf62 Binary files /dev/null and b/docs/static/media/browser-extension/passkey-signed-in.png differ diff --git a/docs/static/media/browser-extension/passkey.webm b/docs/static/media/browser-extension/passkey.webm new file mode 100644 index 000000000..bb1a653a9 Binary files /dev/null and b/docs/static/media/browser-extension/passkey.webm differ diff --git a/docs/static/media/browser-extension/save-done.png b/docs/static/media/browser-extension/save-done.png new file mode 100644 index 000000000..ad625a7a8 Binary files /dev/null and b/docs/static/media/browser-extension/save-done.png differ diff --git a/docs/static/media/browser-extension/save-prompt.png b/docs/static/media/browser-extension/save-prompt.png new file mode 100644 index 000000000..76cce5b4d Binary files /dev/null and b/docs/static/media/browser-extension/save-prompt.png differ diff --git a/docs/static/media/browser-extension/save-prompt.webm b/docs/static/media/browser-extension/save-prompt.webm new file mode 100644 index 000000000..5ea97f0fd Binary files /dev/null and b/docs/static/media/browser-extension/save-prompt.webm differ diff --git a/docs/static/media/browser-extension/send-form.png b/docs/static/media/browser-extension/send-form.png new file mode 100644 index 000000000..2c5b34656 Binary files /dev/null and b/docs/static/media/browser-extension/send-form.png differ diff --git a/docs/static/media/browser-extension/send-link.png b/docs/static/media/browser-extension/send-link.png new file mode 100644 index 000000000..dc1335b5f Binary files /dev/null and b/docs/static/media/browser-extension/send-link.png differ diff --git a/docs/static/media/browser-extension/send.webm b/docs/static/media/browser-extension/send.webm new file mode 100644 index 000000000..29e0efc39 Binary files /dev/null and b/docs/static/media/browser-extension/send.webm differ diff --git a/docs/static/media/browser-extension/this-site.png b/docs/static/media/browser-extension/this-site.png new file mode 100644 index 000000000..79806b08c Binary files /dev/null and b/docs/static/media/browser-extension/this-site.png differ diff --git a/docs/static/media/browser-extension/unlock.png b/docs/static/media/browser-extension/unlock.png new file mode 100644 index 000000000..1e3042ccf Binary files /dev/null and b/docs/static/media/browser-extension/unlock.png differ diff --git a/docs/static/media/browser-extension/vault-item.png b/docs/static/media/browser-extension/vault-item.png new file mode 100644 index 000000000..31ce746a3 Binary files /dev/null and b/docs/static/media/browser-extension/vault-item.png differ diff --git a/docs/static/media/browser-extension/vault.png b/docs/static/media/browser-extension/vault.png new file mode 100644 index 000000000..3ba21fc48 Binary files /dev/null and b/docs/static/media/browser-extension/vault.png differ diff --git a/docs/static/media/browser-extension/vault.webm b/docs/static/media/browser-extension/vault.webm new file mode 100644 index 000000000..50686e66c Binary files /dev/null and b/docs/static/media/browser-extension/vault.webm differ diff --git a/docs/static/media/mobile/android-01-connect.png b/docs/static/media/mobile/android-01-connect.png new file mode 100644 index 000000000..25b3fe0d5 Binary files /dev/null and b/docs/static/media/mobile/android-01-connect.png differ diff --git a/docs/static/media/mobile/android-03-unlock.png b/docs/static/media/mobile/android-03-unlock.png new file mode 100644 index 000000000..3094b8a03 Binary files /dev/null and b/docs/static/media/mobile/android-03-unlock.png differ diff --git a/docs/static/media/mobile/android-06-settings-pin-set.png b/docs/static/media/mobile/android-06-settings-pin-set.png new file mode 100644 index 000000000..42e358edf Binary files /dev/null and b/docs/static/media/mobile/android-06-settings-pin-set.png differ diff --git a/docs/static/media/mobile/android-07-locked-pin.png b/docs/static/media/mobile/android-07-locked-pin.png new file mode 100644 index 000000000..5b2fb55cd Binary files /dev/null and b/docs/static/media/mobile/android-07-locked-pin.png differ diff --git a/docs/static/media/mobile/android-21-two-factor-required.png b/docs/static/media/mobile/android-21-two-factor-required.png new file mode 100644 index 000000000..3a9226f4a Binary files /dev/null and b/docs/static/media/mobile/android-21-two-factor-required.png differ diff --git a/docs/static/media/mobile/android-authenticator.png b/docs/static/media/mobile/android-authenticator.png new file mode 100644 index 000000000..b04b0fbd1 Binary files /dev/null and b/docs/static/media/mobile/android-authenticator.png differ diff --git a/docs/static/media/mobile/android-generate.png b/docs/static/media/mobile/android-generate.png new file mode 100644 index 000000000..b0210a88e Binary files /dev/null and b/docs/static/media/mobile/android-generate.png differ diff --git a/docs/static/media/mobile/android-item.png b/docs/static/media/mobile/android-item.png new file mode 100644 index 000000000..7b5798ab6 Binary files /dev/null and b/docs/static/media/mobile/android-item.png differ diff --git a/docs/static/media/mobile/android-locked.png b/docs/static/media/mobile/android-locked.png new file mode 100644 index 000000000..2eee33a1e Binary files /dev/null and b/docs/static/media/mobile/android-locked.png differ diff --git a/docs/static/media/mobile/android-new-login.png b/docs/static/media/mobile/android-new-login.png new file mode 100644 index 000000000..45308808b Binary files /dev/null and b/docs/static/media/mobile/android-new-login.png differ diff --git a/docs/static/media/mobile/android-search.png b/docs/static/media/mobile/android-search.png new file mode 100644 index 000000000..6f5b8c335 Binary files /dev/null and b/docs/static/media/mobile/android-search.png differ diff --git a/docs/static/media/mobile/android-send-link.png b/docs/static/media/mobile/android-send-link.png new file mode 100644 index 000000000..3662639ce Binary files /dev/null and b/docs/static/media/mobile/android-send-link.png differ diff --git a/docs/static/media/mobile/android-send-opened.png b/docs/static/media/mobile/android-send-opened.png new file mode 100644 index 000000000..cb73cd656 Binary files /dev/null and b/docs/static/media/mobile/android-send-opened.png differ diff --git a/docs/static/media/mobile/android-vault.mp4 b/docs/static/media/mobile/android-vault.mp4 new file mode 100644 index 000000000..925b06097 Binary files /dev/null and b/docs/static/media/mobile/android-vault.mp4 differ diff --git a/docs/static/media/mobile/android-vault.png b/docs/static/media/mobile/android-vault.png new file mode 100644 index 000000000..235c9e06d Binary files /dev/null and b/docs/static/media/mobile/android-vault.png differ diff --git a/docs/static/media/mobile/ios-authenticator.png b/docs/static/media/mobile/ios-authenticator.png new file mode 100644 index 000000000..2e84d7e9a Binary files /dev/null and b/docs/static/media/mobile/ios-authenticator.png differ diff --git a/docs/static/media/mobile/ios-generate.png b/docs/static/media/mobile/ios-generate.png new file mode 100644 index 000000000..7a63ebe58 Binary files /dev/null and b/docs/static/media/mobile/ios-generate.png differ diff --git a/docs/static/media/mobile/ios-item.png b/docs/static/media/mobile/ios-item.png new file mode 100644 index 000000000..8c53b2c70 Binary files /dev/null and b/docs/static/media/mobile/ios-item.png differ diff --git a/docs/static/media/mobile/ios-new-login.png b/docs/static/media/mobile/ios-new-login.png new file mode 100644 index 000000000..2ae899245 Binary files /dev/null and b/docs/static/media/mobile/ios-new-login.png differ diff --git a/docs/static/media/mobile/ios-search.png b/docs/static/media/mobile/ios-search.png new file mode 100644 index 000000000..e99980155 Binary files /dev/null and b/docs/static/media/mobile/ios-search.png differ diff --git a/docs/static/media/mobile/ios-send-link.png b/docs/static/media/mobile/ios-send-link.png new file mode 100644 index 000000000..0442c0247 Binary files /dev/null and b/docs/static/media/mobile/ios-send-link.png differ diff --git a/docs/static/media/mobile/ios-unlock.png b/docs/static/media/mobile/ios-unlock.png new file mode 100644 index 000000000..8dd53bec6 Binary files /dev/null and b/docs/static/media/mobile/ios-unlock.png differ diff --git a/docs/static/media/mobile/ios-vault.mp4 b/docs/static/media/mobile/ios-vault.mp4 new file mode 100644 index 000000000..7939f4460 Binary files /dev/null and b/docs/static/media/mobile/ios-vault.mp4 differ diff --git a/docs/static/media/mobile/ios-vault.png b/docs/static/media/mobile/ios-vault.png new file mode 100644 index 000000000..f8ce1113a Binary files /dev/null and b/docs/static/media/mobile/ios-vault.png differ diff --git a/docs/terraform.md b/docs/terraform.md new file mode 100644 index 000000000..65de597cb --- /dev/null +++ b/docs/terraform.md @@ -0,0 +1,78 @@ + + +# Terraform and OpenTofu + +Declare your application's Keepiq secrets next to the infrastructure that uses them. +The `keepiq` provider decrypts and encrypts in its own process, and Terraform never stores a value in plan or state. +You need Terraform 1.11 or later, or an OpenTofu release with write-only arguments. + +## Configure the provider + +```hcl +terraform { + required_providers { + keepiq = { source = "conductionnl/keepiq" } + } +} + +provider "keepiq" {} +``` + +The provider reads `KEEPIQ_URL`, `KEEPIQ_APP_ID` and `KEEPIQ_APP_KEY` (or `KEEPIQ_APP_KEY_FILE`) from the environment, so no key sits in your configuration. +Set `KEEPIQ_APP_CERT_FILE` as well, and the provider refuses any secret encrypted to another certificate. + +## Use a value without storing it + +```hcl +ephemeral "keepiq_secret" "db" { + name = "db-password" +} + +provider "postgresql" { + password = ephemeral.keepiq_secret.db.value +} +``` + +An ephemeral value exists for one run. Pass it to a provider block or a write-only argument. + +## Manage a secret + +```hcl +resource "keepiq_secret" "api_token" { + name = "api-token" + value_wo = var.api_token + value_wo_version = 1 +} +``` + +`value_wo`, `login_wo` and `additional_fields_wo` are write-only: Terraform sends them once and keeps nothing. +Because Terraform cannot compare a value it never stored, bump `value_wo_version` to write a new value. +A value rotated outside Terraform, by the rotation runner for example, shows no diff. + +`terraform destroy` leaves the secret in Keepiq and warns with its name. An administrator deletes it in Keepiq. +`terraform import keepiq_secret.api_token ` adopts an existing secret; the next apply writes `value_wo`. + +## Read metadata + +`data "keepiq_secret_metadata"` gives the id, timestamps, `expires_at` and certificate fingerprint of a secret, never its value. + +## Manage applications + +`keepiq_application` registers an application through Keepiq's admin API and approves it. +Give it a CSR in `csr_pem`; Keepiq signs it and the certificate appears in `certificate_pem`. +The private key stays with whoever made the CSR. If you generate it with `tls_private_key`, it sits in state. + +The application resources log in as a Nextcloud user, not as an application. +Set `admin_user` and `admin_password` (an app password), or `KEEPIQ_ADMIN_USER` and `KEEPIQ_ADMIN_PASSWORD`. +Give that user only the "Applications and machine access" admin area, on Nextcloud's administration privileges page. + +Deleting an application deletes its vault. So `terraform destroy` refuses until you set `allow_vault_deletion = true` and apply. + +`keepiq_application_lease_policy` sets the lease TTL override of one application. Leave an argument out to inherit the instance setting; destroy removes the override. + +## Next step + +Put the application key in `KEEPIQ_APP_KEY`, add the provider block, and run `terraform plan`. To register applications too, create the admin user's app password and set `KEEPIQ_ADMIN_USER` and `KEEPIQ_ADMIN_PASSWORD`. diff --git a/docs/trash-and-archive.md b/docs/trash-and-archive.md new file mode 100644 index 000000000..f32d8362f --- /dev/null +++ b/docs/trash-and-archive.md @@ -0,0 +1,28 @@ +# Trash and archive + +Deleting a secret in Keepiq no longer removes it at once. It goes to the trash, where you can bring it back until the retention period ends. Archiving puts a secret aside without deleting it. + +## Deleting a secret + +Choose **Delete secret** in the secret's detail panel, or select several secrets and choose **Delete**. The secrets move to the trash and every share ends at that moment: link shares, shares with people and groups, delegations and open requests. Nobody but you can read a secret in the trash. + +The secret keeps its value, attachments and version history while it is in the trash. + +## Restoring or deleting for good + +Open **Trash** in the navigation. Select one or more secrets and choose: + +- **Restore** to put them back in your vault. Their old shares do not come back; share them again where needed. +- **Delete for good** to remove them with their attachments and version history. This cannot be undone. + +A secret that stays in the trash longer than the retention period is deleted for good by a daily job. The retention is 30 days unless your administrator changed it. + +## Archiving a secret + +Choose **Archive** in the secret's detail panel, or select secrets and choose **Archive**. An archived secret leaves the vault list, search, Nextcloud search, the browser extension's suggestions and the password health report. It keeps its shares, and people you shared it with see no change. + +Open **Archive** in the navigation to find archived secrets. Choose **Unarchive** in the detail panel or on a selection to bring them back. An export includes archived secrets but never secrets in the trash. + +## For administrators + +The retention is set under **Administration settings, Keepiq, Attachments and version history**: days a deleted secret stays in the trash, from 1 to 365, 30 by default. diff --git a/docs/tutorials/admin/02-admin-api.md b/docs/tutorials/admin/02-admin-api.md new file mode 100644 index 000000000..3496813e8 --- /dev/null +++ b/docs/tutorials/admin/02-admin-api.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 2 +title: Script Keepiq administration with the admin API +description: Use the versioned admin API with a Nextcloud app password, scoped to the admin areas a script needs. +--- + +# Script Keepiq administration with the admin API + +Keepiq has a documented, versioned admin API under `/api/v1/admin`. Use it to approve applications, offboard a leaver or export audit events from a script. The full description is the OpenAPI 3.1 document [`docs/api/admin-v1.openapi.json`](https://github.com/ConductionNL/keepiq/blob/development/docs/api/admin-v1.openapi.json). + +## Goal + +By the end of this guide a script calls the admin API as a service account. That account can do exactly the admin jobs you gave it, and nothing else. + +## Prerequisites + +- A Nextcloud admin account on an instance where Keepiq is installed. +- A Nextcloud user for the script, for example `svc-keepiq-audit`. + +## 1. Give the service account only the areas it needs + +Keepiq administration has five areas: General, Policies, Applications and machine access, People and offboarding, and Audit and compliance. + +1. Create a group, for example `keepiq-audit-scripts`, and add the service account to it. +2. Open **Administration settings > Administration privileges**. +3. Find **Keepiq - Audit and compliance** and add the group. + +The account now holds the Audit area. Every other admin endpoint refuses it. + +## 2. Create an app password + +Log in as the service account and open **Personal settings > Security**. Create an app password named after the integration. Store it in a secret store, ideally Keepiq's own machine API, not in the script. + +To cut the integration off, revoke that app password. + +## 3. Call the API + +Send the app password over HTTP Basic, with the header `OCS-APIRequest: true`: + +```bash +curl -u svc-keepiq-audit:APP_PASSWORD \ + -H 'OCS-APIRequest: true' -H 'Accept: application/json' \ + https://cloud.example.com/index.php/apps/keepiq/api/v1/admin +``` + +The index returns `apiVersion`, the versions the server offers, the areas you hold and every path. Then read audit events: + +```bash +curl -u svc-keepiq-audit:APP_PASSWORD -H 'OCS-APIRequest: true' \ + 'https://cloud.example.com/index.php/apps/keepiq/api/v1/admin/audit?eventType=share.granted&limit=50' +``` + +## What v1 offers + +| Area | Endpoints | +|---|---| +| Any area | `GET /api/v1/admin` | +| Policies | `GET`, `PUT /policies` | +| People and offboarding | `GET /members`, `GET /suites`, `POST /offboarding` | +| Applications and machine access | `GET`, `POST /applications`; `GET`, `DELETE /applications/{id}`; `POST /applications/{id}/approve` and `/reject`; `GET`, `PUT /applications/{id}/lease-policy` | +| Audit and compliance | `GET /audit`; `GET`, `POST /compliance/reports`; `GET /compliance/reports/{id}`; `GET`, `POST /siem/sinks`; `PUT`, `DELETE /siem/sinks/{id}` | + +Every response is metadata: ids, statuses, counts, dates and settings. No response carries a private key, a secret value, ciphertext or a SIEM credential. + +Two jobs stay in the web interface. Force revocation and reinstatement of an encryption suite ask you to confirm your own password at that moment, and a stored app password cannot do that. + +## Versioning + +Version 1 only grows. New endpoints and new fields can appear in it. A removed or renamed field, or a changed status code, ships as `/api/v2/admin` next to v1, and v1 stays for at least one more minor release. + +## Next step + +Give the Terraform provider an app password with the Applications area, and manage your applications as code. diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a2b3e4c3e..951c04629 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -32,11 +32,6 @@ "count": 1 } }, - "src/components/dashboard/CaHealthCard.vue": { - "no-console": { - "count": 1 - } - }, "src/components/dashboard/RecentSecretsWidget.vue": { "no-console": { "count": 1 @@ -266,11 +261,6 @@ "count": 1 } }, - "src/views/DashboardSettingsView.vue": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/views/HealthReportView.vue": { "no-console": { "count": 5 diff --git a/img/pwa-icon-192.png b/img/pwa-icon-192.png new file mode 100644 index 000000000..ba5b30b1f Binary files /dev/null and b/img/pwa-icon-192.png differ diff --git a/img/pwa-icon-512.png b/img/pwa-icon-512.png new file mode 100644 index 000000000..70febd320 Binary files /dev/null and b/img/pwa-icon-512.png differ diff --git a/img/pwa-icon-maskable-192.png b/img/pwa-icon-maskable-192.png new file mode 100644 index 000000000..b891a512e Binary files /dev/null and b/img/pwa-icon-maskable-192.png differ diff --git a/img/pwa-icon-maskable-512.png b/img/pwa-icon-maskable-512.png new file mode 100644 index 000000000..df3962006 Binary files /dev/null and b/img/pwa-icon-maskable-512.png differ diff --git a/integrations/github-action/action.yml b/integrations/github-action/action.yml new file mode 100644 index 000000000..8b62c94fa --- /dev/null +++ b/integrations/github-action/action.yml @@ -0,0 +1,50 @@ +name: Keepiq secrets +description: Run a step with Keepiq application secrets in its environment, decrypted on the runner. +author: Conduction B.V. + +inputs: + url: + description: The Keepiq (Nextcloud) address, for example https://cloud.example.org. Include /index.php when the instance has no pretty URLs. + required: true + application-id: + description: The id of the approved Keepiq application. + required: true + private-key: + description: The application's private key (PEM). Pass it from a GitHub secret, for example secrets.KEEPIQ_APP_KEY. It is never sent anywhere; only a signed assertion is. + required: true + secrets: + description: Secret names, one per line. A value lands in KEEPIQ_; write NAME=ENV_VAR to choose the variable name. + required: true + run: + description: Command to run with the secrets in its environment, through keepiq ci run. Nothing is written to disk. + required: false + default: "" + export-env: + description: Set to true to export the values to later steps. This writes every value to the runner's environment file ($GITHUB_ENV); each value is masked in the log first. + required: false + default: "false" + version: + description: The CLI release to install, for example cli-v0.3.0. Defaults to the tag this action is used at, when that is a cli-v tag. + required: false + default: "" + download-base: + description: Where the CLI releases are downloaded from. Change it only for a mirror. + required: false + default: https://github.com/ConductionNL/keepiq/releases/download + +runs: + using: composite + steps: + - name: Run with Keepiq secrets + shell: bash + env: + KEEPIQ_URL: ${{ inputs.url }} + KEEPIQ_APP_ID: ${{ inputs.application-id }} + KEEPIQ_APP_KEY: ${{ inputs.private-key }} + KEEPIQ_SECRETS: ${{ inputs.secrets }} + KEEPIQ_RUN: ${{ inputs.run }} + KEEPIQ_EXPORT_ENV: ${{ inputs.export-env }} + KEEPIQ_VERSION: ${{ inputs.version }} + KEEPIQ_ACTION_REF: ${{ github.action_ref }} + KEEPIQ_DOWNLOAD_BASE: ${{ inputs.download-base }} + run: bash "${{ github.action_path }}/keepiq-action.sh" diff --git a/integrations/github-action/keepiq-action.sh b/integrations/github-action/keepiq-action.sh new file mode 100755 index 000000000..367a95c03 --- /dev/null +++ b/integrations/github-action/keepiq-action.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# The Keepiq GitHub Action (integrations/github-action/action.yml). +# +# 1. Installs the CLI for this runner from a cli-v* release and refuses it +# unless its SHA-256 matches the release's SHA256SUMS. +# 2. With KEEPIQ_RUN, runs that command through `keepiq ci run`: the values +# exist only in the command's environment and nothing is written to disk. +# 3. With KEEPIQ_EXPORT_ENV=true, masks every line of every value with +# ::add-mask:: and only then appends it to $GITHUB_ENV. +# 4. With neither, fails and names both options. +# +# Inputs arrive as environment variables (see action.yml). It can run outside +# GitHub too, which is how its test drives it. +set -euo pipefail + +fail() { echo "::error::$*" >&2; exit 1; } + +export_env="$(printf '%s' "${KEEPIQ_EXPORT_ENV:-false}" | tr '[:upper:]' '[:lower:]')" +if [ -z "${KEEPIQ_RUN:-}" ] && [ "$export_env" != "true" ]; then + fail "Nothing to do: set 'run' to run a command with the secrets, or set 'export-env: true' to export them to later steps." +fi + +# --- the secret list: NAME or NAME=ENV_VAR, one per line --- +names=() +targets=() +while IFS= read -r line || [ -n "$line" ]; do + line="$(printf '%s' "$line" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [ -z "$line" ] && continue + name="${line%%=*}" + target="" + [ "$name" != "$line" ] && target="${line#*=}" + if [ -z "$target" ]; then + target="KEEPIQ_$(printf '%s' "$name" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')" + fi + case "$target" in + [A-Za-z_]*) ;; + *) fail "Invalid environment variable name '$target' for secret '$name'." ;; + esac + if printf '%s' "$target" | grep -q '[^A-Za-z0-9_]'; then + fail "Invalid environment variable name '$target' for secret '$name'." + fi + names+=("$name") + targets+=("$target") +done <<< "${KEEPIQ_SECRETS:-}" +[ "${#names[@]}" -gt 0 ] || fail "No secret names given in 'secrets'." +for n in "${names[@]}"; do + case "$n" in *,*) fail "Secret name '$n' contains a comma, which keepiq ci run uses as a separator." ;; esac +done + +# --- install the CLI and check it against SHA256SUMS --- +version="${KEEPIQ_VERSION:-}" +if [ -z "$version" ]; then + case "${KEEPIQ_ACTION_REF:-}" in + cli-v*) version="$KEEPIQ_ACTION_REF" ;; + *) fail "Set 'version' to a CLI release such as cli-v0.3.0 (this action is not used at a cli-v tag, so there is no matching release)." ;; + esac +fi + +case "${RUNNER_OS:-$(uname -s)}" in + Linux) os=linux ;; + macOS | Darwin) os=darwin ;; + Windows | MINGW* | MSYS*) os=windows ;; + *) fail "Unsupported runner OS '${RUNNER_OS:-$(uname -s)}'." ;; +esac +case "${RUNNER_ARCH:-$(uname -m)}" in + X64 | x86_64 | amd64) arch=amd64 ;; + ARM64 | arm64 | aarch64) arch=arm64 ;; + *) fail "Unsupported runner architecture '${RUNNER_ARCH:-$(uname -m)}'." ;; +esac +ext="" +[ "$os" = windows ] && ext=".exe" +binary="keepiq-${os}-${arch}${ext}" + +dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/keepiq-cli.XXXXXX")" +trap 'rm -rf "$dir"' EXIT +base="${KEEPIQ_DOWNLOAD_BASE%/}/${version}" +curl -fsSL --retry 3 -o "$dir/$binary" "$base/$binary" || fail "Could not download $base/$binary." +curl -fsSL --retry 3 -o "$dir/SHA256SUMS" "$base/SHA256SUMS" || fail "Could not download $base/SHA256SUMS." + +want="$(awk -v f="$binary" '{ n = $2; sub(/^\*/, "", n); if (n == f) print $1 }' "$dir/SHA256SUMS")" +[ -n "$want" ] || fail "SHA256SUMS of $version has no line for $binary." +if command -v sha256sum > /dev/null 2>&1; then + got="$(sha256sum "$dir/$binary" | awk '{ print $1 }')" +else + got="$(shasum -a 256 "$dir/$binary" | awk '{ print $1 }')" +fi +if [ "$got" != "$want" ]; then + fail "Checksum mismatch for $binary from $version: got $got, SHA256SUMS says $want. Refusing to run it." +fi +chmod +x "$dir/$binary" +keepiq="$dir/$binary" +echo "Installed keepiq $version ($binary), checksum verified." + +# --- run mode: values only in the command's environment --- +if [ -n "${KEEPIQ_RUN:-}" ]; then + # keepiq ci run puts each value in KEEPIQ_. A NAME=ENV_VAR mapping is + # applied inside the wrapped shell, so it never touches disk either. + # The wrapped command gets the secrets, not the application key. + prelude="unset KEEPIQ_APP_KEY KEEPIQ_APP_KEY_FILE KEEPIQ_WRAPPED; " + for i in "${!names[@]}"; do + default="KEEPIQ_$(printf '%s' "${names[$i]}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')" + if [ "${targets[$i]}" != "$default" ]; then + prelude+="export ${targets[$i]}=\"\${${default}}\"; unset ${default}; " + fi + done + joined="$(IFS=,; printf '%s' "${names[*]}")" + set +e + KEEPIQ_WRAPPED="${prelude}${KEEPIQ_RUN}" "$keepiq" ci run "$joined" -- bash -c 'eval "$KEEPIQ_WRAPPED"' + status=$? + set -e + [ "$status" -eq 0 ] || exit "$status" +fi + +# --- export mode: mask, then write to $GITHUB_ENV --- +if [ "$export_env" = "true" ]; then + [ -n "${GITHUB_ENV:-}" ] || fail "export-env needs \$GITHUB_ENV, which only exists on a GitHub runner." + for i in "${!names[@]}"; do + json="$("$keepiq" ci fetch "${names[$i]}" --output json)" + if command -v jq > /dev/null 2>&1; then + value="$(printf '%s' "$json" | jq -j '.value')" + else + value="$(printf '%s' "$json" | python3 -c 'import json,sys; sys.stdout.write(json.load(sys.stdin)["value"])')" + fi + # Mask every line before the value can reach any log. + while IFS= read -r part || [ -n "$part" ]; do + [ -n "$part" ] && echo "::add-mask::$part" + done <<< "$value" + delimiter="KEEPIQ_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')" + case "$value" in *"$delimiter"*) fail "Value of '${names[$i]}' contains the generated delimiter; retry." ;; esac + { + printf '%s<<%s\n' "${targets[$i]}" "$delimiter" + printf '%s\n' "$value" + printf '%s\n' "$delimiter" + } >> "$GITHUB_ENV" + echo "Exported ${targets[$i]} (masked)." + done +fi diff --git a/integrations/gitlab-ci/keepiq.gitlab-ci.yml b/integrations/gitlab-ci/keepiq.gitlab-ci.yml new file mode 100644 index 000000000..97ae74a7a --- /dev/null +++ b/integrations/gitlab-ci/keepiq.gitlab-ci.yml @@ -0,0 +1,69 @@ +# Keepiq for GitLab CI. +# +# Include this file by URL at a CLI release tag, and let a job extend `.keepiq`: +# +# include: +# - remote: https://raw.githubusercontent.com/ConductionNL/keepiq/cli-v0.3.0/integrations/gitlab-ci/keepiq.gitlab-ci.yml +# +# migrate: +# extends: .keepiq +# variables: +# KEEPIQ_CLI_VERSION: cli-v0.3.0 +# script: +# - keepiq ci run DB_PASSWORD -- ./migrate.sh +# +# Set these CI/CD variables in the project (protect and mask them): +# KEEPIQ_URL the Keepiq (Nextcloud) address +# KEEPIQ_APP_ID the approved application's id +# KEEPIQ_APP_KEY the application's private key (PEM); a variable of type +# File works too, then name it KEEPIQ_APP_KEY_FILE +# +# `keepiq ci run` puts each value in KEEPIQ_ of the wrapped command only, +# so nothing is written to disk and nothing reaches the job log. GitLab cannot +# mask a value fetched while the job runs, so this template offers only that +# wrapped form, not an export to later commands. +# +# The job image needs a POSIX shell, curl or wget, and sha256sum. When a job +# needs its own before_script, keep the install with +# `- !reference [.keepiq, before_script]` as its first line. + +variables: + KEEPIQ_CLI_VERSION: "" + KEEPIQ_DOWNLOAD_BASE: https://github.com/ConductionNL/keepiq/releases/download + +.keepiq: + before_script: + - | + set -e + if [ -z "${KEEPIQ_CLI_VERSION:-}" ]; then + echo "Set KEEPIQ_CLI_VERSION to a CLI release such as cli-v0.3.0." >&2 + exit 1 + fi + case "$(uname -m)" in + x86_64|amd64) keepiq_arch=amd64 ;; + aarch64|arm64) keepiq_arch=arm64 ;; + *) echo "Unsupported architecture $(uname -m)." >&2; exit 1 ;; + esac + keepiq_bin="keepiq-linux-${keepiq_arch}" + keepiq_dir="${CI_PROJECT_DIR:-$PWD}/.keepiq-cli" + mkdir -p "$keepiq_dir" + keepiq_base="${KEEPIQ_DOWNLOAD_BASE%/}/${KEEPIQ_CLI_VERSION}" + for keepiq_file in "$keepiq_bin" SHA256SUMS; do + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 -o "$keepiq_dir/$keepiq_file" "$keepiq_base/$keepiq_file" + else + wget -q -O "$keepiq_dir/$keepiq_file" "$keepiq_base/$keepiq_file" + fi + done + keepiq_want="$(awk -v f="$keepiq_bin" '{ n = $2; sub(/^\*/, "", n); if (n == f) print $1 }' "$keepiq_dir/SHA256SUMS")" + keepiq_got="$(sha256sum "$keepiq_dir/$keepiq_bin" | awk '{ print $1 }')" + if [ -z "$keepiq_want" ] || [ "$keepiq_got" != "$keepiq_want" ]; then + echo "Checksum mismatch for $keepiq_bin from $KEEPIQ_CLI_VERSION (got $keepiq_got, SHA256SUMS says ${keepiq_want:-nothing}). Refusing to install it." >&2 + rm -rf "$keepiq_dir" + exit 1 + fi + mv "$keepiq_dir/$keepiq_bin" "$keepiq_dir/keepiq" + chmod +x "$keepiq_dir/keepiq" + rm -f "$keepiq_dir/SHA256SUMS" + export PATH="$keepiq_dir:$PATH" + echo "Installed keepiq $KEEPIQ_CLI_VERSION, checksum verified." diff --git a/integrations/kubernetes/Dockerfile b/integrations/kubernetes/Dockerfile new file mode 100644 index 000000000..9566987e5 --- /dev/null +++ b/integrations/kubernetes/Dockerfile @@ -0,0 +1,17 @@ +# ghcr.io/conductionnl/keepiq-operator. Build from the repository root, because +# the operator builds against sdk/go in this repository: +# docker build -f integrations/kubernetes/Dockerfile . +FROM golang:1.22 AS build +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY sdk/go ./sdk/go +COPY integrations/kubernetes ./integrations/kubernetes +WORKDIR /src/integrations/kubernetes +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/keepiq-operator ./cmd + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/keepiq-operator /keepiq-operator +USER 65532:65532 +ENTRYPOINT ["/keepiq-operator"] diff --git a/integrations/kubernetes/api/v1alpha1/groupversion.go b/integrations/kubernetes/api/v1alpha1/groupversion.go new file mode 100644 index 000000000..98485b9ed --- /dev/null +++ b/integrations/kubernetes/api/v1alpha1/groupversion.go @@ -0,0 +1,21 @@ +// Package v1alpha1 holds the keepiq.conduction.nl/v1alpha1 resources: +// KeepiqConnection and KeepiqSecret. +// +kubebuilder:object:generate=true +// +groupName=keepiq.conduction.nl +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is the API group and version of these resources. + GroupVersion = schema.GroupVersion{Group: "keepiq.conduction.nl", Version: "v1alpha1"} + + // SchemeBuilder registers the types with a scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types to a scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/integrations/kubernetes/api/v1alpha1/types.go b/integrations/kubernetes/api/v1alpha1/types.go new file mode 100644 index 000000000..7283da87b --- /dev/null +++ b/integrations/kubernetes/api/v1alpha1/types.go @@ -0,0 +1,131 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// SecretKeyRef points at one key of a Kubernetes Secret in the same namespace. +type SecretKeyRef struct { + Name string `json:"name"` + Key string `json:"key"` +} + +// KeepiqConnectionSpec says which Keepiq instance and application to use. +type KeepiqConnectionSpec struct { + // URL is the Keepiq (Nextcloud) address. Include /index.php when the + // instance has no pretty URLs. + URL string `json:"url"` + // ApplicationID is the approved Keepiq application's id. + ApplicationID string `json:"applicationId"` + // PrivateKeySecretRef is the Kubernetes Secret key holding the + // application private key (PEM). It never leaves the cluster. + PrivateKeySecretRef SecretKeyRef `json:"privateKeySecretRef"` + // CertificateSecretRef optionally holds the application certificate + // (PEM). When set, envelopes encrypted to another certificate are + // refused before decryption. + CertificateSecretRef *SecretKeyRef `json:"certificateSecretRef,omitempty"` +} + +// KeepiqConnection is one Keepiq application the operator authenticates as. +// +kubebuilder:object:root=true +type KeepiqConnection struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeepiqConnectionSpec `json:"spec"` +} + +// KeepiqConnectionList is a list of KeepiqConnection. +// +kubebuilder:object:root=true +type KeepiqConnectionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeepiqConnection `json:"items"` +} + +// KeepiqSecretItem maps one field of one Keepiq secret to one key of the +// target Kubernetes Secret. +type KeepiqSecretItem struct { + // Name is the exact Keepiq secret name. + Name string `json:"name"` + // Folder narrows the name to a slash-separated folder path. + Folder string `json:"folder,omitempty"` + // Field is key, login, or additionalFields.. + Field string `json:"field"` + // TargetKey is the key in the target Kubernetes Secret. + TargetKey string `json:"targetKey"` +} + +// TargetRef names the Kubernetes Secret the operator writes. +type TargetRef struct { + Name string `json:"name"` +} + +// RestartTarget is a workload to roll when a value changes. +type RestartTarget struct { + // Kind is Deployment or StatefulSet. + Kind string `json:"kind"` + Name string `json:"name"` +} + +// KeepiqSecretSpec says which Keepiq values go into which Kubernetes Secret. +type KeepiqSecretSpec struct { + // ConnectionRef names the KeepiqConnection in the same namespace. + ConnectionRef LocalObjectReference `json:"connectionRef"` + Target TargetRef `json:"target"` + // RefreshInterval is how often each item is polled (default 60s, at + // least 10s). + RefreshInterval *metav1.Duration `json:"refreshInterval,omitempty"` + RestartTargets []RestartTarget `json:"restartTargets,omitempty"` + Items []KeepiqSecretItem `json:"items"` +} + +// LocalObjectReference names an object in the same namespace. +type LocalObjectReference struct { + Name string `json:"name"` +} + +// ItemStatus records what the operator last saw for one item. It never holds +// a value. +type ItemStatus struct { + Name string `json:"name"` + Folder string `json:"folder,omitempty"` + SecretID string `json:"secretId,omitempty"` + ETag string `json:"etag,omitempty"` + LeaseID string `json:"leaseId,omitempty"` + LeaseExpires *metav1.Time `json:"leaseExpires,omitempty"` +} + +// KeepiqSecretStatus is the observed state. +type KeepiqSecretStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + Items []ItemStatus `json:"items,omitempty"` + // Checksum is a SHA-256 over the target Secret's data, so a change can be + // detected without keeping any value. + Checksum string `json:"checksum,omitempty"` + LastSynced *metav1.Time `json:"lastSynced,omitempty"` +} + +// KeepiqSecret syncs Keepiq application secrets into a Kubernetes Secret. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type KeepiqSecret struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeepiqSecretSpec `json:"spec"` + Status KeepiqSecretStatus `json:"status,omitempty"` +} + +// KeepiqSecretList is a list of KeepiqSecret. +// +kubebuilder:object:root=true +type KeepiqSecretList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeepiqSecret `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeepiqConnection{}, &KeepiqConnectionList{}, &KeepiqSecret{}, &KeepiqSecretList{}) +} diff --git a/integrations/kubernetes/api/v1alpha1/zz_deepcopy.go b/integrations/kubernetes/api/v1alpha1/zz_deepcopy.go new file mode 100644 index 000000000..1b865b57d --- /dev/null +++ b/integrations/kubernetes/api/v1alpha1/zz_deepcopy.go @@ -0,0 +1,131 @@ +// Hand-written deep copies (the shape controller-gen would generate). + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func (in *KeepiqConnection) DeepCopyInto(out *KeepiqConnection) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + if in.Spec.CertificateSecretRef != nil { + ref := *in.Spec.CertificateSecretRef + out.Spec.CertificateSecretRef = &ref + } +} + +func (in *KeepiqConnection) DeepCopy() *KeepiqConnection { + if in == nil { + return nil + } + out := new(KeepiqConnection) + in.DeepCopyInto(out) + return out +} + +func (in *KeepiqConnection) DeepCopyObject() runtime.Object { return in.DeepCopy() } + +func (in *KeepiqConnectionList) DeepCopyInto(out *KeepiqConnectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]KeepiqConnection, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +func (in *KeepiqConnectionList) DeepCopy() *KeepiqConnectionList { + if in == nil { + return nil + } + out := new(KeepiqConnectionList) + in.DeepCopyInto(out) + return out +} + +func (in *KeepiqConnectionList) DeepCopyObject() runtime.Object { return in.DeepCopy() } + +func (in *KeepiqSecretSpec) DeepCopyInto(out *KeepiqSecretSpec) { + *out = *in + if in.RefreshInterval != nil { + d := *in.RefreshInterval + out.RefreshInterval = &d + } + if in.RestartTargets != nil { + out.RestartTargets = append([]RestartTarget(nil), in.RestartTargets...) + } + if in.Items != nil { + out.Items = append([]KeepiqSecretItem(nil), in.Items...) + } +} + +func (in *KeepiqSecretStatus) DeepCopyInto(out *KeepiqSecretStatus) { + *out = *in + if in.Conditions != nil { + out.Conditions = make([]metav1.Condition, len(in.Conditions)) + for i := range in.Conditions { + in.Conditions[i].DeepCopyInto(&out.Conditions[i]) + } + } + if in.Items != nil { + out.Items = make([]ItemStatus, len(in.Items)) + for i := range in.Items { + out.Items[i] = in.Items[i] + if in.Items[i].LeaseExpires != nil { + out.Items[i].LeaseExpires = in.Items[i].LeaseExpires.DeepCopy() + } + } + } + if in.LastSynced != nil { + out.LastSynced = in.LastSynced.DeepCopy() + } +} + +func (in *KeepiqSecret) DeepCopyInto(out *KeepiqSecret) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +func (in *KeepiqSecret) DeepCopy() *KeepiqSecret { + if in == nil { + return nil + } + out := new(KeepiqSecret) + in.DeepCopyInto(out) + return out +} + +func (in *KeepiqSecret) DeepCopyObject() runtime.Object { return in.DeepCopy() } + +func (in *KeepiqSecretList) DeepCopyInto(out *KeepiqSecretList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + out.Items = make([]KeepiqSecret, len(in.Items)) + for i := range in.Items { + in.Items[i].DeepCopyInto(&out.Items[i]) + } + } +} + +func (in *KeepiqSecretList) DeepCopy() *KeepiqSecretList { + if in == nil { + return nil + } + out := new(KeepiqSecretList) + in.DeepCopyInto(out) + return out +} + +func (in *KeepiqSecretList) DeepCopyObject() runtime.Object { return in.DeepCopy() } diff --git a/integrations/kubernetes/charts/keepiq-operator/Chart.yaml b/integrations/kubernetes/charts/keepiq-operator/Chart.yaml new file mode 100644 index 000000000..c699c97f7 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: keepiq-operator +description: Syncs Keepiq application secrets into Kubernetes Secrets, decrypting only inside the cluster. +type: application +version: 0.1.0 +appVersion: "0.1.0" +home: https://github.com/ConductionNL/keepiq/tree/development/integrations/kubernetes +sources: + - https://github.com/ConductionNL/keepiq +keywords: [keepiq, secrets, operator] +maintainers: + - name: Conduction B.V. + email: info@conduction.nl diff --git a/integrations/kubernetes/charts/keepiq-operator/README.md b/integrations/kubernetes/charts/keepiq-operator/README.md new file mode 100644 index 000000000..2d5a11f0d --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/README.md @@ -0,0 +1,100 @@ +# keepiq-operator + +Get a Keepiq application secret into a pod with one resource and no scripting. +The operator decrypts inside your cluster, with an application key your cluster holds. + +## Install + +```sh +helm install keepiq-operator oci://ghcr.io/conductionnl/charts/keepiq-operator --version 0.1.0 --namespace shop +``` + +The operator watches only the namespace it runs in. Set `clusterWide=true` to watch every namespace. +Keep one Keepiq application per namespace or team, so a namespace reads only its own vault. + +## Sync a secret + +Store the application private key in a Kubernetes Secret, then point a connection at it: + +```yaml +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqConnection +metadata: + name: shop +spec: + url: https://cloud.example.org + applicationId: shop-prod + privateKeySecretRef: {name: keepiq-app-key, key: key.pem} + # Optional: refuse anything encrypted to another certificate. + certificateSecretRef: {name: keepiq-app-key, key: cert.pem} +--- +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqSecret +metadata: + name: shop-db +spec: + connectionRef: {name: shop} + target: {name: shop-db} + refreshInterval: 60s + restartTargets: + - {kind: Deployment, name: shop-api} + items: + - {name: db-password, field: key, targetKey: DB_PASSWORD} + - {name: db-password, field: login, targetKey: DB_USER} + - {name: db-password, field: additionalFields.host, targetKey: DB_HOST} +``` + +Secret `shop-db` then holds the three values. When someone rotates `db-password` in Keepiq, `shop-db` follows within one refresh interval. +Deployment `shop-api` rolls out new pods at that moment. + +`kubectl get keepiqsecrets` shows `Ready` and a reason. +An unknown name, two secrets with one name, a refused key or a wrong certificate each leave the target alone and raise an event. +The event for two secrets with one name lists both ids and folders. Add `folder:` to the item to pick one. + +No value ever lands in the resource status, an event or the operator log. + +A Kubernetes Secret is readable by anyone with Secret read rights in the namespace. +Turn on etcd encryption at rest, or use the recipe below. + +## Keep the value out of Kubernetes Secrets + +For a workload that must not have its value in etcd, let the pod fetch it at start. +An init container copies the static `keepiq` CLI into the pod, and the container starts its command through `keepiq ci run`: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: shop-migrate +spec: + volumes: + - name: keepiq + emptyDir: {} + - name: keepiq-key + secret: + secretName: keepiq-app-key + items: [{key: key.pem, path: key.pem}] + initContainers: + - name: keepiq-cli + image: ghcr.io/conductionnl/keepiq-cli:0.3.0 + command: ["/usr/local/bin/keepiq", "install", "/keepiq/keepiq"] + volumeMounts: [{name: keepiq, mountPath: /keepiq}] + containers: + - name: migrate + image: registry.example.org/shop/migrate:1.4 + command: ["/keepiq/keepiq", "ci", "run", "db-password", "--", "./migrate.sh"] + env: + - {name: KEEPIQ_URL, value: https://cloud.example.org} + - {name: KEEPIQ_APP_ID, value: shop-prod} + - {name: KEEPIQ_APP_KEY_FILE, value: /keepiq-key/key.pem} + volumeMounts: + - {name: keepiq, mountPath: /keepiq, readOnly: true} + - {name: keepiq-key, mountPath: /keepiq-key, readOnly: true} +``` + +`./migrate.sh` finds the value in `KEEPIQ_DB_PASSWORD`. It lives only in that process's environment; no Kubernetes Secret holds it. +The application key is still a Kubernetes Secret, mounted as a file. + +## Next step + +Register the Keepiq application for this namespace, put its key in `keepiq-app-key`, and apply the two resources above. diff --git a/integrations/kubernetes/charts/keepiq-operator/crds/keepiqconnections.yaml b/integrations/kubernetes/charts/keepiq-operator/crds/keepiqconnections.yaml new file mode 100644 index 000000000..a3f9aedfb --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/crds/keepiqconnections.yaml @@ -0,0 +1,60 @@ +# The KeepiqConnection resource: which Keepiq instance and application the +# operator authenticates as, and where the application key sits in the cluster. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keepiqconnections.keepiq.conduction.nl +spec: + group: keepiq.conduction.nl + names: + kind: KeepiqConnection + listKind: KeepiqConnectionList + plural: keepiqconnections + singular: keepiqconnection + shortNames: [kqc] + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - name: URL + type: string + jsonPath: .spec.url + - name: Application + type: string + jsonPath: .spec.applicationId + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + apiVersion: {type: string} + kind: {type: string} + metadata: {type: object} + spec: + type: object + required: [url, applicationId, privateKeySecretRef] + properties: + url: + type: string + description: The Keepiq (Nextcloud) address. Include /index.php when the instance has no pretty URLs. + pattern: '^https?://[^\s]+$' + applicationId: + type: string + minLength: 1 + description: The approved Keepiq application's id. + privateKeySecretRef: + type: object + description: The Kubernetes Secret key holding the application private key (PEM). It never leaves the cluster. + required: [name, key] + properties: + name: {type: string, minLength: 1} + key: {type: string, minLength: 1} + certificateSecretRef: + type: object + description: Optional. The Kubernetes Secret key holding the application certificate (PEM). When set, a key that does not belong to it, or an envelope encrypted to another certificate, is refused before decryption. + required: [name, key] + properties: + name: {type: string, minLength: 1} + key: {type: string, minLength: 1} diff --git a/integrations/kubernetes/charts/keepiq-operator/crds/keepiqsecrets.yaml b/integrations/kubernetes/charts/keepiq-operator/crds/keepiqsecrets.yaml new file mode 100644 index 000000000..06964a673 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/crds/keepiqsecrets.yaml @@ -0,0 +1,107 @@ +# The KeepiqSecret resource: which Keepiq secrets and fields go into which +# Kubernetes Secret, how often they are refreshed, and what restarts on change. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: keepiqsecrets.keepiq.conduction.nl +spec: + group: keepiq.conduction.nl + names: + kind: KeepiqSecret + listKind: KeepiqSecretList + plural: keepiqsecrets + singular: keepiqsecret + shortNames: [kqs] + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Target + type: string + jsonPath: .spec.target.name + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + - name: Reason + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].reason + schema: + openAPIV3Schema: + type: object + required: [spec] + properties: + apiVersion: {type: string} + kind: {type: string} + metadata: {type: object} + spec: + type: object + required: [connectionRef, target, items] + properties: + connectionRef: + type: object + required: [name] + properties: + name: {type: string, minLength: 1} + target: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + maxLength: 253 + pattern: '^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$' + refreshInterval: + type: string + default: 60s + maxLength: 32 + description: How often each item is polled, for example 60s or 5m. At least 10s. + x-kubernetes-validations: + - rule: "duration(self) >= duration('10s')" + message: refreshInterval must be at least 10s + restartTargets: + type: array + maxItems: 16 + items: + type: object + required: [kind, name] + properties: + kind: + type: string + enum: [Deployment, StatefulSet] + name: {type: string, minLength: 1} + items: + type: array + minItems: 1 + items: + type: object + required: [name, field, targetKey] + properties: + name: + type: string + minLength: 1 + description: The exact Keepiq secret name. + folder: + type: string + description: Narrows the name to a slash-separated folder path. + field: + type: string + description: key, login, or additionalFields.. + pattern: '^(key|login|additionalFields\.[A-Za-z0-9_.-]+)$' + targetKey: + type: string + description: The key in the target Kubernetes Secret. + maxLength: 253 + pattern: '^[-._a-zA-Z0-9]+$' + maxItems: 64 + # Every item needs its own targetKey: the API server refuses + # two items with the same one. + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: [targetKey] + status: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/integrations/kubernetes/charts/keepiq-operator/templates/_helpers.tpl b/integrations/kubernetes/charts/keepiq-operator/templates/_helpers.tpl new file mode 100644 index 000000000..560f8dcc7 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{- define "keepiq-operator.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "keepiq-operator.fullname" -}} +{{- if contains .Chart.Name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "keepiq-operator.labels" -}} +app.kubernetes.io/name: {{ include "keepiq-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }} +{{- end -}} + +{{- define "keepiq-operator.selector" -}} +app.kubernetes.io/name: {{ include "keepiq-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "keepiq-operator.serviceAccount" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "keepiq-operator.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} diff --git a/integrations/kubernetes/charts/keepiq-operator/templates/deployment.yaml b/integrations/kubernetes/charts/keepiq-operator/templates/deployment.yaml new file mode 100644 index 000000000..8ee833f78 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/templates/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "keepiq-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keepiq-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "keepiq-operator.selector" . | nindent 6 }} + template: + metadata: + labels: + {{- include "keepiq-operator.selector" . | nindent 8 }} + spec: + serviceAccountName: {{ include "keepiq-operator.serviceAccount" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + {{- if not .Values.clusterWide }} + - --watch-namespace={{ .Release.Namespace }} + {{- end }} + {{- if gt (int .Values.replicaCount) 1 }} + - --leader-elect + {{- end }} + {{- range .Values.extraArgs }} + - {{ . | quote }} + {{- end }} + ports: + - name: metrics + containerPort: 8080 + - name: probes + containerPort: 8081 + livenessProbe: + httpGet: {path: /healthz, port: probes} + readinessProbe: + httpGet: {path: /readyz, port: probes} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/integrations/kubernetes/charts/keepiq-operator/templates/rbac.yaml b/integrations/kubernetes/charts/keepiq-operator/templates/rbac.yaml new file mode 100644 index 000000000..ae2342363 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/templates/rbac.yaml @@ -0,0 +1,54 @@ +{{- $kind := ternary "ClusterRole" "Role" .Values.clusterWide }} +{{- $bindingKind := ternary "ClusterRoleBinding" "RoleBinding" .Values.clusterWide }} +# What the operator may do: read its two resources and the Secret that holds +# the application key, write the target Secrets it owns, patch the pod template +# of restart targets, and record events. Namespaced unless clusterWide is set. +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ $kind }} +metadata: + name: {{ include "keepiq-operator.fullname" . }} + {{- if not .Values.clusterWide }} + namespace: {{ .Release.Namespace }} + {{- end }} + labels: + {{- include "keepiq-operator.labels" . | nindent 4 }} +rules: + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets, keepiqconnections] + verbs: [get, list, watch] + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets/status] + verbs: [get, update, patch] + - apiGroups: [""] + resources: [secrets] + verbs: [get, list, watch, create, update, patch] + - apiGroups: [""] + resources: [events] + verbs: [create, patch] + - apiGroups: [events.k8s.io] + resources: [events] + verbs: [create, patch] + - apiGroups: [apps] + resources: [deployments, statefulsets] + verbs: [get, patch] + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: {{ $bindingKind }} +metadata: + name: {{ include "keepiq-operator.fullname" . }} + {{- if not .Values.clusterWide }} + namespace: {{ .Release.Namespace }} + {{- end }} + labels: + {{- include "keepiq-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: {{ $kind }} + name: {{ include "keepiq-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "keepiq-operator.serviceAccount" . }} + namespace: {{ .Release.Namespace }} diff --git a/integrations/kubernetes/charts/keepiq-operator/templates/serviceaccount.yaml b/integrations/kubernetes/charts/keepiq-operator/templates/serviceaccount.yaml new file mode 100644 index 000000000..95499eb95 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/templates/serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "keepiq-operator.serviceAccount" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "keepiq-operator.labels" . | nindent 4 }} +{{- end }} diff --git a/integrations/kubernetes/charts/keepiq-operator/values.yaml b/integrations/kubernetes/charts/keepiq-operator/values.yaml new file mode 100644 index 000000000..ee14d65d5 --- /dev/null +++ b/integrations/kubernetes/charts/keepiq-operator/values.yaml @@ -0,0 +1,41 @@ +image: + repository: ghcr.io/conductionnl/keepiq-operator + # Defaults to the chart's appVersion. + tag: "" + pullPolicy: IfNotPresent + +replicaCount: 1 + +# By default the operator watches only the namespace it is installed in, with a +# Role there. Set clusterWide to true to watch every namespace, with a +# ClusterRole. Recommended layout: one Keepiq application per namespace or team. +clusterWide: false + +serviceAccount: + create: true + name: "" + +resources: + requests: + cpu: 20m + memory: 64Mi + limits: + memory: 256Mi + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + +# Extra arguments, for example --zap-log-level=debug. +extraArgs: [] + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/integrations/kubernetes/cmd/main.go b/integrations/kubernetes/cmd/main.go new file mode 100644 index 000000000..f2674f978 --- /dev/null +++ b/integrations/kubernetes/cmd/main.go @@ -0,0 +1,75 @@ +// Command keepiq-operator runs the Keepiq Kubernetes operator. +package main + +import ( + "flag" + "os" + + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + v1 "github.com/ConductionNL/keepiq/integrations/kubernetes/api/v1alpha1" + "github.com/ConductionNL/keepiq/integrations/kubernetes/internal/controller" +) + +var version = "dev" + +func main() { + var metricsAddr, probeAddr, watchNamespace string + var leaderElect bool + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metrics endpoint binds to; 0 turns it off.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the health probes bind to.") + flag.StringVar(&watchNamespace, "watch-namespace", os.Getenv("WATCH_NAMESPACE"), "Namespace to watch; empty watches every namespace (needs the cluster-wide RBAC).") + flag.BoolVar(&leaderElect, "leader-elect", false, "Use leader election, for more than one replica.") + opts := zap.Options{} + opts.BindFlags(flag.CommandLine) + flag.Parse() + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + setup := ctrl.Log.WithName("setup") + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + setup.Error(err, "scheme") + os.Exit(1) + } + if err := v1.AddToScheme(scheme); err != nil { + setup.Error(err, "scheme") + os.Exit(1) + } + + options := ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: leaderElect, + LeaderElectionID: "keepiq-operator.keepiq.conduction.nl", + } + if watchNamespace != "" { + options.Cache = cache.Options{DefaultNamespaces: map[string]cache.Config{watchNamespace: {}}} + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), options) + if err != nil { + setup.Error(err, "manager") + os.Exit(1) + } + if err := (&controller.KeepiqSecretReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("keepiq-operator"), + }).SetupWithManager(mgr); err != nil { + setup.Error(err, "controller") + os.Exit(1) + } + _ = mgr.AddHealthzCheck("healthz", healthz.Ping) + _ = mgr.AddReadyzCheck("readyz", healthz.Ping) + setup.Info("starting keepiq-operator", "version", version, "namespace", watchNamespace) + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setup.Error(err, "manager stopped") + os.Exit(1) + } +} diff --git a/integrations/kubernetes/go.mod b/integrations/kubernetes/go.mod new file mode 100644 index 000000000..20b5a71af --- /dev/null +++ b/integrations/kubernetes/go.mod @@ -0,0 +1,71 @@ +module github.com/ConductionNL/keepiq/integrations/kubernetes + +go 1.22.0 + +require ( + github.com/ConductionNL/keepiq/sdk/go v0.0.0 + k8s.io/api v0.30.5 + k8s.io/apimachinery v0.30.5 + k8s.io/client-go v0.30.5 + sigs.k8s.io/controller-runtime v0.18.5 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/term v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.30.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) + +// The operator builds against the library in this repository, like the CLI. +replace github.com/ConductionNL/keepiq/sdk/go => ../../sdk/go diff --git a/integrations/kubernetes/go.sum b/integrations/kubernetes/go.sum new file mode 100644 index 000000000..804812417 --- /dev/null +++ b/integrations/kubernetes/go.sum @@ -0,0 +1,194 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.30.5 h1:Coz05sfEVywzGcA96AJPUfs2B8LBMnh+IIsM+HCfaz8= +k8s.io/api v0.30.5/go.mod h1:HfNBGFvq9iNK8dmTKjYIdAtMxu8BXTb9c1SJyO6QjKs= +k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= +k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= +k8s.io/apimachinery v0.30.5 h1:CQZO19GFgw4zcOjY2H+mJ3k1u1o7zFACTNCB7nu4O18= +k8s.io/apimachinery v0.30.5/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.5 h1:vEDSzfTz0F8TXcWVdXl+aqV7NAV8M3UvC2qnGTTCoKw= +k8s.io/client-go v0.30.5/go.mod h1:/q5fHHBmhAUesOOFJACpD7VJ4e57rVtTPDOsvXrPpMk= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.18.5 h1:nTHio/W+Q4aBlQMgbnC5hZb4IjIidyrizMai9P6n4Rk= +sigs.k8s.io/controller-runtime v0.18.5/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/integrations/kubernetes/internal/controller/helpers_test.go b/integrations/kubernetes/internal/controller/helpers_test.go new file mode 100644 index 000000000..576efd733 --- /dev/null +++ b/integrations/kubernetes/internal/controller/helpers_test.go @@ -0,0 +1,25 @@ +package controller + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "testing" +) + +func yamlish(v any) (string, error) { + raw, err := json.Marshal(v) + return string(raw), err +} + +func otherKeyPEM(t *testing.T) string { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + der, _ := x509.MarshalPKCS8PrivateKey(k) + return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} diff --git a/integrations/kubernetes/internal/controller/keepiqsecret_controller.go b/integrations/kubernetes/internal/controller/keepiqsecret_controller.go new file mode 100644 index 000000000..8386bd88d --- /dev/null +++ b/integrations/kubernetes/internal/controller/keepiqsecret_controller.go @@ -0,0 +1,470 @@ +// Package controller reconciles KeepiqSecret resources: it reads Keepiq +// application secrets through the machine API, decrypts them in the operator +// process with the application key held in the cluster, and writes them into +// a Kubernetes Secret. +package controller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + + v1 "github.com/ConductionNL/keepiq/integrations/kubernetes/api/v1alpha1" +) + +const ( + // ConditionReady is the one condition a KeepiqSecret reports. + ConditionReady = "Ready" + + // ChecksumAnnotation is patched onto restart targets' pod templates. + ChecksumAnnotation = "keepiq.conduction.nl/checksum" + + // DefaultRefresh and MinRefresh bound spec.refreshInterval. + DefaultRefresh = 60 * time.Second + MinRefresh = 10 * time.Second + + // Reasons on the Ready condition and on events. + ReasonSynced = "Synced" + ReasonConnectionNotFound = "ConnectionNotFound" + ReasonKeyNotFound = "KeySecretNotFound" + ReasonNotFound = "SecretNotFound" + ReasonAmbiguousName = "AmbiguousName" + ReasonTokenRefused = "TokenRefused" + ReasonFingerprintMismatch = "FingerprintMismatch" + ReasonFieldNotFound = "FieldNotFound" + ReasonKeepiqError = "KeepiqError" + ReasonTargetConflict = "TargetNotOwned" +) + +// KeepiqClient is the part of the Go library the operator uses. +type KeepiqClient interface { + GetByNameIfNoneMatch(name, folder, etag string) (*keepiq.Secret, error) + LeaseSupported() (bool, error) +} + +// ClientFactory builds a Keepiq client for a connection. +type ClientFactory func(url, applicationID, privateKeyPEM, certificatePEM string) (KeepiqClient, error) + +// DefaultClientFactory builds a keepiq.Client. +func DefaultClientFactory(url, applicationID, privateKeyPEM, certificatePEM string) (KeepiqClient, error) { + var opts []keepiq.Option + if certificatePEM != "" { + opts = append(opts, keepiq.WithCertificate(certificatePEM)) + } + return keepiq.New(url, applicationID, privateKeyPEM, opts...) +} + +// KeepiqSecretReconciler syncs KeepiqSecrets. +type KeepiqSecretReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Factory ClientFactory + Now func() time.Time + + mu sync.Mutex + clients map[string]KeepiqClient // by connection identity and key hash +} + +// failure is a reconcile outcome that sets Ready=False. +type failure struct { + reason string + message string +} + +func (f *failure) Error() string { return f.reason + ": " + f.message } + +func fail(reason, format string, args ...any) *failure { + return &failure{reason: reason, message: fmt.Sprintf(format, args...)} +} + +// +kubebuilder:rbac:groups=keepiq.conduction.nl,resources=keepiqsecrets;keepiqconnections,verbs=get;list;watch +// +kubebuilder:rbac:groups=keepiq.conduction.nl,resources=keepiqsecrets/status,verbs=get;update;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups=apps,resources=deployments;statefulsets,verbs=get;patch + +// Reconcile brings one KeepiqSecret's target Secret up to date. +func (r *KeepiqSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + var ks v1.KeepiqSecret + if err := r.Get(ctx, req.NamespacedName, &ks); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + interval := refreshInterval(&ks) + + result, err := r.sync(ctx, &ks, interval) + var f *failure + if errors.As(err, &f) { + // Never a value in here: messages are built from names, ids and + // reasons only. + logger.Info("keepiq secret not synced", "reason", f.reason, "message", f.message) + r.Recorder.Event(&ks, corev1.EventTypeWarning, f.reason, f.message) + meta.SetStatusCondition(&ks.Status.Conditions, metav1.Condition{ + Type: ConditionReady, Status: metav1.ConditionFalse, Reason: f.reason, Message: f.message, + ObservedGeneration: ks.Generation, + }) + ks.Status.ObservedGeneration = ks.Generation + if uerr := r.Status().Update(ctx, &ks); uerr != nil { + return ctrl.Result{}, uerr + } + return ctrl.Result{RequeueAfter: interval}, nil + } + if err != nil { + return ctrl.Result{}, err + } + return result, nil +} + +func refreshInterval(ks *v1.KeepiqSecret) time.Duration { + if ks.Spec.RefreshInterval == nil || ks.Spec.RefreshInterval.Duration == 0 { + return DefaultRefresh + } + if ks.Spec.RefreshInterval.Duration < MinRefresh { + return MinRefresh + } + return ks.Spec.RefreshInterval.Duration +} + +func (r *KeepiqSecretReconciler) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *KeepiqSecretReconciler) sync(ctx context.Context, ks *v1.KeepiqSecret, interval time.Duration) (ctrl.Result, error) { + kc, err := r.keepiqClient(ctx, ks) + if err != nil { + return ctrl.Result{}, err + } + + // The current target, if any. + var target corev1.Secret + targetKey := types.NamespacedName{Namespace: ks.Namespace, Name: ks.Spec.Target.Name} + exists := true + if err := r.Get(ctx, targetKey, &target); err != nil { + if !apierrors.IsNotFound(err) { + return ctrl.Result{}, err + } + exists = false + } + if exists && !metav1.IsControlledBy(&target, ks) { + return ctrl.Result{}, fail(ReasonTargetConflict, "Secret %s exists and is not managed by this KeepiqSecret", ks.Spec.Target.Name) + } + + leases, err := kc.LeaseSupported() + if err != nil { + return ctrl.Result{}, classify(err, "discovery") + } + + prior := map[string]v1.ItemStatus{} + for _, it := range ks.Status.Items { + prior[itemKey(it.Name, it.Folder)] = it + } + + data := map[string][]byte{} + var items []v1.ItemStatus + now := r.now() + for _, item := range ks.Spec.Items { + key := itemKey(item.Name, item.Folder) + st := prior[key] + st.Name, st.Folder = item.Name, item.Folder + + // Send the remembered ETag only when the target still holds the value + // it stands for; otherwise read the whole envelope again. + etag := st.ETag + if !exists || target.Data[item.TargetKey] == nil { + etag = "" + } + + // There is no renew route (keepiq#753): a read while the lease is + // live reuses it, so once it has lapsed read the whole envelope again + // for a fresh lease and the current value. + if leases && st.LeaseID != "" && st.LeaseExpires != nil && !now.Before(st.LeaseExpires.Time) { + st.LeaseID, st.LeaseExpires, etag = "", nil, "" + } + + secret, err := kc.GetByNameIfNoneMatch(item.Name, item.Folder, etag) + switch { + case errors.Is(err, keepiq.ErrNotModified): + data[item.TargetKey] = target.Data[item.TargetKey] + case err != nil: + return ctrl.Result{}, classify(err, item.Name) + default: + value, ferr := fieldValue(secret, item.Field) + if ferr != nil { + return ctrl.Result{}, ferr + } + data[item.TargetKey] = []byte(value) + st.SecretID, st.ETag = secret.ID, secret.ETag + if leases && secret.Lease != nil { + st.LeaseID, st.LeaseExpires = secret.Lease.ID, leaseTime(secret.Lease) + } + } + items = append(items, st) + } + + sum := checksum(data) + changed := !exists || sum != checksum(target.Data) + if !exists { + target = corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: ks.Namespace, Name: ks.Spec.Target.Name}, Type: corev1.SecretTypeOpaque} + target.Data = data + if err := controllerutil.SetControllerReference(ks, &target, r.Scheme); err != nil { + return ctrl.Result{}, err + } + if err := r.Create(ctx, &target); err != nil { + return ctrl.Result{}, err + } + } else if changed { + target.Data = data + if err := r.Update(ctx, &target); err != nil { + return ctrl.Result{}, err + } + } + + // A rotation: the target held other values before this loop. + if exists && changed { + for _, rt := range ks.Spec.RestartTargets { + if err := r.restart(ctx, ks.Namespace, rt, sum); err != nil { + return ctrl.Result{}, err + } + } + r.Recorder.Event(ks, corev1.EventTypeNormal, "Rotated", fmt.Sprintf("Secret %s updated with new values", ks.Spec.Target.Name)) + } + + ks.Status.Items = items + ks.Status.Checksum = sum + ks.Status.ObservedGeneration = ks.Generation + t := metav1.NewTime(now) + ks.Status.LastSynced = &t + meta.SetStatusCondition(&ks.Status.Conditions, metav1.Condition{ + Type: ConditionReady, Status: metav1.ConditionTrue, Reason: ReasonSynced, + Message: fmt.Sprintf("%d item(s) synced into Secret %s", len(items), ks.Spec.Target.Name), ObservedGeneration: ks.Generation, + }) + if err := r.Status().Update(ctx, ks); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: untilLeaseLapses(items, now, interval)}, nil +} + +// untilLeaseLapses shortens the requeue to just after the first lease that +// lapses before the next loop, so the read for a fresh lease comes on time. +func untilLeaseLapses(items []v1.ItemStatus, now time.Time, interval time.Duration) time.Duration { + next := interval + for _, it := range items { + if it.LeaseExpires == nil { + continue + } + if wait := it.LeaseExpires.Time.Sub(now) + time.Second; wait < next { + next = max(wait, time.Second) + } + } + return next +} + +// keepiqClient loads the connection and the key, and returns a cached client +// for that exact identity, so tokens are reused across loops. +func (r *KeepiqSecretReconciler) keepiqClient(ctx context.Context, ks *v1.KeepiqSecret) (KeepiqClient, error) { + var conn v1.KeepiqConnection + if err := r.Get(ctx, types.NamespacedName{Namespace: ks.Namespace, Name: ks.Spec.ConnectionRef.Name}, &conn); err != nil { + if apierrors.IsNotFound(err) { + return nil, fail(ReasonConnectionNotFound, "KeepiqConnection %s not found", ks.Spec.ConnectionRef.Name) + } + return nil, err + } + pem, err := r.secretValue(ctx, ks.Namespace, conn.Spec.PrivateKeySecretRef) + if err != nil { + return nil, err + } + cert := "" + if conn.Spec.CertificateSecretRef != nil { + if cert, err = r.secretValue(ctx, ks.Namespace, *conn.Spec.CertificateSecretRef); err != nil { + return nil, err + } + } + h := sha256.Sum256([]byte(conn.Spec.URL + "\x00" + conn.Spec.ApplicationID + "\x00" + pem + "\x00" + cert)) + id := ks.Namespace + "/" + conn.Name + "/" + hex.EncodeToString(h[:]) + + r.mu.Lock() + defer r.mu.Unlock() + if r.clients == nil { + r.clients = map[string]KeepiqClient{} + } + if c, ok := r.clients[id]; ok { + return c, nil + } + factory := r.Factory + if factory == nil { + factory = DefaultClientFactory + } + c, err := factory(conn.Spec.URL, conn.Spec.ApplicationID, pem, cert) + if err != nil { + if strings.Contains(err.Error(), "mismatch") { + return nil, fail(ReasonFingerprintMismatch, "the private key in Secret %s does not match the application certificate", conn.Spec.PrivateKeySecretRef.Name) + } + return nil, fail(ReasonKeyNotFound, "KeepiqConnection %s: %s", conn.Name, err.Error()) + } + r.clients[id] = c + return c, nil +} + +func (r *KeepiqSecretReconciler) secretValue(ctx context.Context, ns string, ref v1.SecretKeyRef) (string, error) { + var s corev1.Secret + if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: ref.Name}, &s); err != nil { + if apierrors.IsNotFound(err) { + return "", fail(ReasonKeyNotFound, "Secret %s not found", ref.Name) + } + return "", err + } + v, ok := s.Data[ref.Key] + if !ok || len(v) == 0 { + return "", fail(ReasonKeyNotFound, "Secret %s has no key %s", ref.Name, ref.Key) + } + return string(v), nil +} + +// restart patches the checksum annotation onto a workload's pod template. +func (r *KeepiqSecretReconciler) restart(ctx context.Context, ns string, rt v1.RestartTarget, sum string) error { + var obj client.Object + switch rt.Kind { + case "Deployment": + obj = &appsv1.Deployment{} + case "StatefulSet": + obj = &appsv1.StatefulSet{} + default: + return fail(ReasonKeepiqError, "restart target kind %s is not Deployment or StatefulSet", rt.Kind) + } + obj.SetNamespace(ns) + obj.SetName(rt.Name) + patch, _ := json.Marshal(map[string]any{ + "spec": map[string]any{"template": map[string]any{"metadata": map[string]any{"annotations": map[string]string{ChecksumAnnotation: sum}}}}, + }) + if err := r.Patch(ctx, obj, client.RawPatch(types.MergePatchType, patch)); err != nil { + if apierrors.IsNotFound(err) { + r.Recorder.Event(obj, corev1.EventTypeWarning, "RestartTargetNotFound", rt.Kind+" "+rt.Name+" not found") + return nil + } + return err + } + return nil +} + +// classify turns a library error into a Ready=False reason. Messages carry +// names, ids and folder paths, never a value. +func classify(err error, name string) error { + var amb *keepiq.AmbiguousNameError + var apiErr *keepiq.APIError + switch { + case errors.Is(err, keepiq.ErrNotFound): + return fail(ReasonNotFound, "Keepiq secret %q not found in the application vault", name) + case errors.As(err, &amb): + parts := make([]string, 0, len(amb.Candidates)) + for _, c := range amb.Candidates { + folder := c.FolderPath + if folder == "" { + folder = "/" + } + parts = append(parts, c.ID+" in "+folder) + } + return fail(ReasonAmbiguousName, "%d Keepiq secrets are named %q: %s; set a folder or rename one", len(amb.Candidates), name, strings.Join(parts, ", ")) + case errors.Is(err, keepiq.ErrUnauthorized): + return fail(ReasonTokenRefused, "Keepiq refused the application token; check the application id and key") + case errors.Is(err, keepiq.ErrKeyMismatch): + return fail(ReasonFingerprintMismatch, "Keepiq secret %q is encrypted to another certificate than this connection's", name) + case errors.As(err, &apiErr): + return fail(ReasonKeepiqError, "Keepiq answered %d for %q", apiErr.Status, name) + default: + if strings.Contains(err.Error(), "decrypt") { + return fail(ReasonFingerprintMismatch, "Keepiq secret %q does not decrypt with this connection's key", name) + } + return fail(ReasonKeepiqError, "reading %q: %s", name, err.Error()) + } +} + +// fieldValue picks key, login or additionalFields. from a secret. +func fieldValue(s *keepiq.Secret, field string) (string, error) { + switch { + case field == "key": + return s.Key, nil + case field == "login": + return s.Login, nil + case strings.HasPrefix(field, "additionalFields."): + name := strings.TrimPrefix(field, "additionalFields.") + var fields map[string]any + if s.AdditionalFields == "" || json.Unmarshal([]byte(s.AdditionalFields), &fields) != nil { + return "", fail(ReasonFieldNotFound, "Keepiq secret %q has no additional fields", s.Name) + } + v, ok := fields[name] + if !ok { + return "", fail(ReasonFieldNotFound, "Keepiq secret %q has no additional field %q", s.Name, name) + } + if str, ok := v.(string); ok { + return str, nil + } + raw, _ := json.Marshal(v) + return string(raw), nil + default: + return "", fail(ReasonFieldNotFound, "field %q is not key, login or additionalFields.", field) + } +} + +func itemKey(name, folder string) string { return folder + "\x00" + name } + +func leaseTime(l *keepiq.Lease) *metav1.Time { + t := l.ExpiresAt() + if t.IsZero() { + return nil + } + mt := metav1.NewTime(t) + return &mt +} + +// checksum is a SHA-256 over the sorted keys and values; it identifies the +// data without keeping it. +func checksum(data map[string][]byte) string { + keys := make([]string, 0, len(data)) + for k := range data { + keys = append(keys, k) + } + sort.Strings(keys) + h := sha256.New() + for _, k := range keys { + fmt.Fprintf(h, "%d:%s=%d:", len(k), k, len(data[k])) + h.Write(data[k]) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// SetupWithManager registers the reconciler and watches owned Secrets. +func (r *KeepiqSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + // Status writes do not change the generation, so they do not loop. + For(&v1.KeepiqSecret{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + Owns(&corev1.Secret{}). + Complete(r) +} diff --git a/integrations/kubernetes/internal/controller/keepiqsecret_controller_test.go b/integrations/kubernetes/internal/controller/keepiqsecret_controller_test.go new file mode 100644 index 000000000..be6006c46 --- /dev/null +++ b/integrations/kubernetes/internal/controller/keepiqsecret_controller_test.go @@ -0,0 +1,516 @@ +package controller + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + "github.com/ConductionNL/keepiq/sdk/go/keepiqtest" + + v1 "github.com/ConductionNL/keepiq/integrations/kubernetes/api/v1alpha1" +) + +// These tests run against a real kube-apiserver and etcd (envtest) with the +// chart's CRDs, and a stub Keepiq serving envelopes encrypted to the shared +// test key in sdk/testdata. KUBEBUILDER_ASSETS must point at the envtest +// binaries (setup-envtest use 1.30.0 -p path). + +var ( + k8s client.Client + scheme = runtime.NewScheme() +) + +func TestMain(m *testing.M) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + // Without the binaries nothing here can run; say so instead of passing. + println("KUBEBUILDER_ASSETS is not set: run setup-envtest use 1.30.0 -p path first") + os.Exit(1) + } + _ = clientgoscheme.AddToScheme(scheme) + _ = v1.AddToScheme(scheme) + env := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "charts", "keepiq-operator", "crds")}, + ErrorIfCRDPathMissing: true, + } + cfg, err := env.Start() + if err != nil { + panic(err) + } + k8s, err = client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + panic(err) + } + code := m.Run() + _ = env.Stop() + os.Exit(code) +} + +type fixture struct { + t *testing.T + ctx context.Context + ns string + stub *keepiqtest.Stub + recorder *record.FakeRecorder + r *KeepiqSecretReconciler +} + +var nsSeq int + +func setup(t *testing.T) *fixture { + t.Helper() + ctx := context.Background() + nsSeq++ + ns := "kq-" + strings.ToLower(strings.ReplaceAll(t.Name(), "_", "-")) + if len(ns) > 50 { + ns = ns[:50] + } + ns = strings.TrimRight(ns, "-") + "-" + time.Now().Format("150405") + "-" + string(rune('a'+nsSeq%26)) + if err := k8s.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}); err != nil { + t.Fatal(err) + } + stub, err := keepiqtest.Start("shop-prod") + if err != nil { + t.Fatal(err) + } + t.Cleanup(stub.Close) + // The fixture secret's name equals its value; give it a value that + // appears nowhere else, so a leak check can tell them apart. + if err := stub.SetValue("sec-cli-fixture", "key", dbPassword); err != nil { + t.Fatal(err) + } + rec := record.NewFakeRecorder(100) + f := &fixture{t: t, ctx: ctx, ns: ns, stub: stub, recorder: rec, + r: &KeepiqSecretReconciler{Client: k8s, Scheme: scheme, Recorder: rec}} + f.create(&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "keepiq-app-key", Namespace: ns}, + Data: map[string][]byte{"key.pem": []byte(stub.Fixture.PrivateKeyPem), "cert.pem": []byte(stub.Fixture.CertificatePem)}}) + f.create(&v1.KeepiqConnection{ObjectMeta: metav1.ObjectMeta{Name: "shop", Namespace: ns}, Spec: v1.KeepiqConnectionSpec{ + URL: stub.URL(), ApplicationID: "shop-prod", PrivateKeySecretRef: v1.SecretKeyRef{Name: "keepiq-app-key", Key: "key.pem"}, + }}) + return f +} + +func (f *fixture) create(obj client.Object) { + f.t.Helper() + if err := k8s.Create(f.ctx, obj); err != nil { + f.t.Fatalf("create %T: %v", obj, err) + } +} + +func (f *fixture) keepiqSecret(name string, items []v1.KeepiqSecretItem, restart ...v1.RestartTarget) *v1.KeepiqSecret { + ks := &v1.KeepiqSecret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: f.ns}, Spec: v1.KeepiqSecretSpec{ + ConnectionRef: v1.LocalObjectReference{Name: "shop"}, Target: v1.TargetRef{Name: "shop-db"}, + Items: items, RestartTargets: restart, + }} + f.create(ks) + return ks +} + +func (f *fixture) reconcile(name string) { + f.t.Helper() + res, err := f.r.Reconcile(f.ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: f.ns, Name: name}}) + if err != nil { + f.t.Fatalf("reconcile: %v", err) + } + if res.RequeueAfter != DefaultRefresh { + f.t.Fatalf("requeue after %v, want %v", res.RequeueAfter, DefaultRefresh) + } +} + +// reconcileResult runs one loop and returns its result without asserting the +// requeue, for tests where a lease shortens it. +func (f *fixture) reconcileResult(name string) ctrl.Result { + f.t.Helper() + res, err := f.r.Reconcile(f.ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: f.ns, Name: name}}) + if err != nil { + f.t.Fatalf("reconcile: %v", err) + } + return res +} + +func (f *fixture) status(name string) v1.KeepiqSecret { + f.t.Helper() + var ks v1.KeepiqSecret + if err := k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: name}, &ks); err != nil { + f.t.Fatal(err) + } + return ks +} + +func (f *fixture) target() (*corev1.Secret, bool) { + var s corev1.Secret + if err := k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: "shop-db"}, &s); err != nil { + return nil, false + } + return &s, true +} + +func (f *fixture) events() []string { + var out []string + for { + select { + case e := <-f.recorder.Events: + out = append(out, e) + default: + return out + } + } +} + +func (f *fixture) ready(name string) *metav1.Condition { + ks := f.status(name) + return meta.FindStatusCondition(ks.Status.Conditions, ConditionReady) +} + +// noValueLeaks fails when a plaintext appears in status, events or any request +// body the operator sent to Keepiq. +func (f *fixture) noValueLeaks(name string, values ...string) { + f.t.Helper() + ks := f.status(name) + raw, _ := yamlish(ks.Status) + events := strings.Join(f.events(), "\n") + for _, v := range append(values, "PRIVATE KEY") { + if strings.Contains(raw, v) { + f.t.Fatalf("status carries %q", v) + } + if strings.Contains(events, v) { + f.t.Fatalf("an event carries %q", v) + } + if f.stub.BodiesContain(v) { + f.t.Fatalf("a request to Keepiq carries %q", v) + } + } +} + +const dbPassword = "s3cret-db-pass-7f3a" + +func dbItems() []v1.KeepiqSecretItem { + return []v1.KeepiqSecretItem{ + {Name: "ci-fixture-db-password", Field: "key", TargetKey: "DB_PASSWORD"}, + {Name: "ci-fixture-db-password", Field: "login", TargetKey: "DB_USER"}, + {Name: "ci-fixture-db-password", Field: "additionalFields.host", TargetKey: "DB_HOST"}, + } +} + +// 1.2: invalid resources are rejected by the API server. +func TestCRDValidation(t *testing.T) { + f := setup(t) + bad := []struct { + name string + mut func(*v1.KeepiqSecret) + }{ + {"refresh under 10s", func(k *v1.KeepiqSecret) { k.Spec.RefreshInterval = &metav1.Duration{Duration: 5 * time.Second} }}, + {"unknown field", func(k *v1.KeepiqSecret) { k.Spec.Items[0].Field = "password" }}, + {"additionalFields without a name", func(k *v1.KeepiqSecret) { k.Spec.Items[0].Field = "additionalFields." }}, + {"duplicate target key", func(k *v1.KeepiqSecret) { + k.Spec.Items = append(k.Spec.Items, v1.KeepiqSecretItem{Name: "x", Field: "key", TargetKey: k.Spec.Items[0].TargetKey}) + }}, + {"no items", func(k *v1.KeepiqSecret) { k.Spec.Items = nil }}, + {"restart kind", func(k *v1.KeepiqSecret) { k.Spec.RestartTargets = []v1.RestartTarget{{Kind: "Pod", Name: "x"}} }}, + } + for i, b := range bad { + ks := &v1.KeepiqSecret{ObjectMeta: metav1.ObjectMeta{Name: "bad-" + string(rune('a'+i)), Namespace: f.ns}, Spec: v1.KeepiqSecretSpec{ + ConnectionRef: v1.LocalObjectReference{Name: "shop"}, Target: v1.TargetRef{Name: "t"}, + Items: []v1.KeepiqSecretItem{{Name: "n", Field: "key", TargetKey: "K"}}, + }} + b.mut(ks) + if err := k8s.Create(f.ctx, ks); err == nil { + t.Errorf("%s: accepted, want rejected", b.name) + } + } + good := &v1.KeepiqSecret{ObjectMeta: metav1.ObjectMeta{Name: "good", Namespace: f.ns}, Spec: v1.KeepiqSecretSpec{ + ConnectionRef: v1.LocalObjectReference{Name: "shop"}, Target: v1.TargetRef{Name: "t"}, + RefreshInterval: &metav1.Duration{Duration: 10 * time.Second}, + Items: []v1.KeepiqSecretItem{{Name: "n", Field: "additionalFields.host", TargetKey: "HOST"}}, + }} + if err := k8s.Create(f.ctx, good); err != nil { + t.Fatalf("valid resource rejected: %v", err) + } + var got v1.KeepiqSecret + _ = k8s.Get(f.ctx, client.ObjectKeyFromObject(good), &got) + if got.Spec.RefreshInterval.Duration != 10*time.Second { + t.Fatalf("refreshInterval = %v", got.Spec.RefreshInterval) + } + defaulted := &v1.KeepiqSecret{ObjectMeta: metav1.ObjectMeta{Name: "defaulted", Namespace: f.ns}, Spec: v1.KeepiqSecretSpec{ + ConnectionRef: v1.LocalObjectReference{Name: "shop"}, Target: v1.TargetRef{Name: "t"}, + Items: []v1.KeepiqSecretItem{{Name: "n", Field: "key", TargetKey: "K"}}, + }} + f.create(defaulted) + _ = k8s.Get(f.ctx, client.ObjectKeyFromObject(defaulted), &got) + if got.Spec.RefreshInterval == nil || got.Spec.RefreshInterval.Duration != DefaultRefresh { + t.Fatalf("default refreshInterval = %v, want 60s", got.Spec.RefreshInterval) + } +} + +// 2.1: the target Secret gets the decrypted values, owned by the KeepiqSecret; +// an unchanged second loop does not write it. +func TestSyncWritesTheDecryptedValues(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", dbItems()) + f.reconcile("db") + + target, ok := f.target() + if !ok { + t.Fatal("target Secret not created") + } + want := map[string]string{"DB_PASSWORD": dbPassword, "DB_USER": "ci-deployer", "DB_HOST": "db.internal.test"} + for k, v := range want { + if string(target.Data[k]) != v { + t.Fatalf("%s = %q, want %q", k, target.Data[k], v) + } + } + owner := f.status("db") + if !metav1.IsControlledBy(target, &owner) { + t.Fatalf("owner references %+v", target.OwnerReferences) + } + if c := f.ready("db"); c == nil || c.Status != metav1.ConditionTrue || c.Reason != ReasonSynced { + t.Fatalf("Ready = %+v", c) + } + st := f.status("db") + if len(st.Status.Items) != 3 || st.Status.Items[0].ETag == "" || st.Status.Items[0].SecretID != "sec-cli-fixture" { + t.Fatalf("item status %+v", st.Status.Items) + } + f.noValueLeaks("db", dbPassword, "ci-deployer", "db.internal.test") + + rv := target.ResourceVersion + exchanges := f.stub.Exchanges + f.reconcile("db") + again, _ := f.target() + if again.ResourceVersion != rv { + t.Fatal("an unchanged loop rewrote the target Secret") + } + if f.stub.Exchanges != exchanges { + t.Fatalf("token exchanged again (%d -> %d), want cached", exchanges, f.stub.Exchanges) + } +} + +// A target Secret deleted by hand comes back on the next loop, even though +// Keepiq answers 304 to the remembered ETag. +func TestDeletedTargetIsRecreated(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", dbItems()[:1]) + f.reconcile("db") + target, _ := f.target() + if err := k8s.Delete(f.ctx, target); err != nil { + t.Fatal(err) + } + f.reconcile("db") + again, ok := f.target() + if !ok || string(again.Data["DB_PASSWORD"]) != dbPassword { + t.Fatalf("target not recreated with the value: %v %+v", ok, again) + } +} + +// 2.3: a rotation updates the Secret and patches the Deployment template once. +func TestRotationRestartsTheWorkloadOnce(t *testing.T) { + f := setup(t) + replicas := int32(1) + labels := map[string]string{"app": "shop-api"} + f.create(&appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "shop-api", Namespace: f.ns}, Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: labels}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "api", Image: "busybox"}}}}, + }}) + f.keepiqSecret("db", dbItems()[:1], v1.RestartTarget{Kind: "Deployment", Name: "shop-api"}) + annotation := func() string { + var d appsv1.Deployment + _ = k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: "shop-api"}, &d) + return d.Spec.Template.Annotations[ChecksumAnnotation] + } + + f.reconcile("db") + if a := annotation(); a != "" { + t.Fatalf("first sync restarted the workload (%s)", a) + } + + if err := f.stub.SetValue("sec-cli-fixture", "key", "rotated-password-1"); err != nil { + t.Fatal(err) + } + f.reconcile("db") + target, _ := f.target() + if string(target.Data["DB_PASSWORD"]) != "rotated-password-1" { + t.Fatalf("after rotation DB_PASSWORD = %q", target.Data["DB_PASSWORD"]) + } + first := annotation() + if first == "" || first != f.status("db").Status.Checksum { + t.Fatalf("annotation %q, status checksum %q", first, f.status("db").Status.Checksum) + } + + f.reconcile("db") + if annotation() != first { + t.Fatal("an unchanged loop changed the annotation") + } + + _ = f.stub.SetValue("sec-cli-fixture", "key", "rotated-password-2") + f.reconcile("db") + if second := annotation(); second == first || second == "" { + t.Fatalf("second rotation: annotation %q (first %q)", second, first) + } + f.noValueLeaks("db", "rotated-password-1", "rotated-password-2") +} + +func (f *fixture) expectFailure(name, reason string, mustMention ...string) { + f.t.Helper() + c := f.ready(name) + if c == nil || c.Status != metav1.ConditionFalse || c.Reason != reason { + f.t.Fatalf("Ready = %+v, want False/%s", c, reason) + } + events := strings.Join(f.events(), "\n") + if !strings.Contains(events, reason) { + f.t.Fatalf("no %s event in %q", reason, events) + } + for _, m := range mustMention { + if !strings.Contains(events, m) { + f.t.Fatalf("event does not mention %q: %s", m, events) + } + } +} + +// 2.2: an unknown name leaves the target alone and says so. +func TestUnknownNameIsReported(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", []v1.KeepiqSecretItem{{Name: "nope", Field: "key", TargetKey: "X"}}) + f.reconcile("db") + f.expectFailure("db", ReasonNotFound, "nope") + if _, ok := f.target(); ok { + t.Fatal("target Secret created for a missing name") + } +} + +// 2.2: an ambiguous name lists both candidates and leaves the target unchanged. +func TestAmbiguousNameListsCandidates(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", dbItems()[:1]) + f.reconcile("db") + before, _ := f.target() + _ = f.events() + + if _, err := f.stub.Add("sec-twin", "ci-fixture-db-password", "other/team", map[string]string{"key": "twin-value"}); err != nil { + t.Fatal(err) + } + f.reconcile("db") + f.expectFailure("db", ReasonAmbiguousName, "sec-cli-fixture in ci/database", "sec-twin in other/team") + after, _ := f.target() + if after.ResourceVersion != before.ResourceVersion { + t.Fatal("target changed on an ambiguous name") + } + f.noValueLeaks("db", "twin-value", dbPassword) +} + +// 2.2 and the spec scenario: a key that does not match the certificate is +// FingerprintMismatch and the target does not change. +func TestWrongKeyIsFingerprintMismatch(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", dbItems()[:1]) + f.reconcile("db") + before, _ := f.target() + + other := otherKeyPEM(t) + var conn v1.KeepiqConnection + _ = k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: "shop"}, &conn) + conn.Spec.CertificateSecretRef = &v1.SecretKeyRef{Name: "keepiq-app-key", Key: "cert.pem"} + if err := k8s.Update(f.ctx, &conn); err != nil { + t.Fatal(err) + } + var key corev1.Secret + _ = k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: "keepiq-app-key"}, &key) + key.Data["key.pem"] = []byte(other) + if err := k8s.Update(f.ctx, &key); err != nil { + t.Fatal(err) + } + _ = f.events() + f.reconcile("db") + f.expectFailure("db", ReasonFingerprintMismatch) + after, _ := f.target() + if after.ResourceVersion != before.ResourceVersion { + t.Fatal("target changed with a wrong key") + } +} + +// 2.2: without a certificate a wrong key is refused at the token exchange. +func TestRefusedTokenIsReported(t *testing.T) { + f := setup(t) + var key corev1.Secret + _ = k8s.Get(f.ctx, types.NamespacedName{Namespace: f.ns, Name: "keepiq-app-key"}, &key) + key.Data["key.pem"] = []byte(otherKeyPEM(t)) + _ = k8s.Update(f.ctx, &key) + f.keepiqSecret("db", dbItems()[:1]) + f.reconcile("db") + f.expectFailure("db", ReasonTokenRefused) + if _, ok := f.target(); ok { + t.Fatal("target created with a refused token") + } +} + +// 2.4: with leases advertised there is no renewal (keepiq#753). The loop +// comes back just after the lease lapses, and then reads the whole envelope +// again for a fresh lease; while the lease is live it is left alone. +func TestALapsedLeaseIsReadAgain(t *testing.T) { + f := setup(t) + clock := time.Now() + f.r.Now = func() time.Time { return clock } + f.stub.Now = func() time.Time { return clock } + f.stub.Leases = true + f.stub.LeaseTTL = 45 * time.Second // shorter than the refresh interval + f.keepiqSecret("db", dbItems()[:1]) + if res := f.reconcileResult("db"); res.RequeueAfter > 46*time.Second || res.RequeueAfter < 45*time.Second { + t.Fatalf("requeue after %v, want just after the 45s lease", res.RequeueAfter) + } + st := f.status("db").Status.Items[0] + if st.LeaseID == "" || st.LeaseExpires == nil { + t.Fatalf("lease not recorded: %+v", st) + } + + clock = clock.Add(10 * time.Second) + f.reconcileResult("db") + if live := f.status("db").Status.Items[0]; live.LeaseID != st.LeaseID { + t.Fatalf("a live lease was replaced: %q -> %q", st.LeaseID, live.LeaseID) + } + + clock = clock.Add(40 * time.Second) + reads := countReads(f.stub) + f.reconcileResult("db") + fresh := f.status("db").Status.Items[0] + if countReads(f.stub) != reads+1 || fresh.LeaseID == st.LeaseID || fresh.LeaseID == "" { + t.Fatalf("after the lease lapsed: reads %d -> %d, lease %q -> %q", reads, countReads(f.stub), st.LeaseID, fresh.LeaseID) + } + if c := f.ready("db"); c.Status != metav1.ConditionTrue { + t.Fatalf("Ready = %+v", c) + } +} + +// 2.4: against an instance without leases nothing about leases is recorded. +func TestWithoutLeasesNothingIsRenewed(t *testing.T) { + f := setup(t) + f.keepiqSecret("db", dbItems()[:1]) + f.reconcile("db") + f.reconcile("db") + if st := f.status("db").Status.Items[0]; st.LeaseID != "" || st.LeaseExpires != nil { + t.Fatalf("lease state without leases: %+v", st) + } +} + +func countReads(s *keepiqtest.Stub) int { + s.Mu.Lock() + defer s.Mu.Unlock() + n := 0 + for _, r := range s.Requests { + if strings.HasPrefix(r, "GET ") && strings.Contains(r, "/by-name/") { + n++ + } + } + return n +} diff --git a/integrations/kubernetes/internal/controller/live_test.go b/integrations/kubernetes/internal/controller/live_test.go new file mode 100644 index 000000000..105ec1cd9 --- /dev/null +++ b/integrations/kubernetes/internal/controller/live_test.go @@ -0,0 +1,62 @@ +package controller + +import ( + "context" + "os" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + + v1 "github.com/ConductionNL/keepiq/integrations/kubernetes/api/v1alpha1" +) + +// TestLiveSyncFromARealInstance syncs one secret from a real Keepiq into an +// envtest Secret. It runs only with: +// +// KEEPIQ_LIVE_URL the instance, for example http://localhost:8080/index.php +// KEEPIQ_LIVE_APP_ID an approved application +// KEEPIQ_LIVE_KEY_FILE that application's private key (PEM) +// KEEPIQ_LIVE_SECRET the name of a secret in its vault +func TestLiveSyncFromARealInstance(t *testing.T) { + url, app, keyFile, name := os.Getenv("KEEPIQ_LIVE_URL"), os.Getenv("KEEPIQ_LIVE_APP_ID"), os.Getenv("KEEPIQ_LIVE_KEY_FILE"), os.Getenv("KEEPIQ_LIVE_SECRET") + if url == "" || app == "" || keyFile == "" || name == "" { + t.Skip("set KEEPIQ_LIVE_URL, KEEPIQ_LIVE_APP_ID, KEEPIQ_LIVE_KEY_FILE and KEEPIQ_LIVE_SECRET to run the live sync") + } + pem, err := os.ReadFile(keyFile) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + ns := "keepiq-live" + _ = k8s.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + _ = k8s.Create(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "live-key", Namespace: ns}, Data: map[string][]byte{"key.pem": pem}}) + _ = k8s.Create(ctx, &v1.KeepiqConnection{ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: ns}, Spec: v1.KeepiqConnectionSpec{ + URL: url, ApplicationID: app, PrivateKeySecretRef: v1.SecretKeyRef{Name: "live-key", Key: "key.pem"}, + }}) + ks := &v1.KeepiqSecret{ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: ns}, Spec: v1.KeepiqSecretSpec{ + ConnectionRef: v1.LocalObjectReference{Name: "live"}, Target: v1.TargetRef{Name: "live-target"}, + Items: []v1.KeepiqSecretItem{{Name: name, Field: "key", TargetKey: "VALUE"}}, + }} + if err := k8s.Create(ctx, ks); err != nil { + t.Fatal(err) + } + r := &KeepiqSecretReconciler{Client: k8s, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "live"}}); err != nil { + t.Fatal(err) + } + var got v1.KeepiqSecret + _ = k8s.Get(ctx, types.NamespacedName{Namespace: ns, Name: "live"}, &got) + if c := meta.FindStatusCondition(got.Status.Conditions, ConditionReady); c == nil || c.Status != metav1.ConditionTrue { + t.Fatalf("Ready = %+v", c) + } + var target corev1.Secret + if err := k8s.Get(ctx, types.NamespacedName{Namespace: ns, Name: "live-target"}, &target); err != nil || len(target.Data["VALUE"]) == 0 { + t.Fatalf("target: %v", err) + } + t.Logf("synced %d bytes from %s", len(target.Data["VALUE"]), url) +} diff --git a/integrations/kubernetes/test/chart.sh b/integrations/kubernetes/test/chart.sh new file mode 100755 index 000000000..a55919dcd --- /dev/null +++ b/integrations/kubernetes/test/chart.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# helm lint and helm template snapshot tests for the chart. +# integrations/kubernetes/test/chart.sh compare with the snapshots +# integrations/kubernetes/test/chart.sh --update rewrite them +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +chart="$here/../charts/keepiq-operator" +snap="$here/snapshots" +mkdir -p "$snap" +helm lint "$chart" +helm lint "$chart" --set clusterWide=true +status=0 +for case in default cluster-wide; do + args=(--namespace shop) + [ "$case" = cluster-wide ] && args+=(--set clusterWide=true --set replicaCount=2) + out="$(helm template keepiq-operator "$chart" "${args[@]}")" + if [ "${1:-}" = "--update" ]; then + printf '%s\n' "$out" > "$snap/$case.yaml" + elif ! diff -u "$snap/$case.yaml" <(printf '%s\n' "$out"); then + echo "snapshot $case differs; run with --update after checking the diff" >&2 + status=1 + fi +done +# Namespaced by default: a Role and a RoleBinding, and --watch-namespace. +grep -q '^kind: Role$' "$snap/default.yaml" +grep -q -- '--watch-namespace=shop' "$snap/default.yaml" +! grep -q 'kind: ClusterRole' "$snap/default.yaml" +# Cluster-wide on request: a ClusterRole, no namespace restriction, leader election. +grep -q '^kind: ClusterRole$' "$snap/cluster-wide.yaml" +! grep -q -- '--watch-namespace' "$snap/cluster-wide.yaml" +grep -q -- '--leader-elect' "$snap/cluster-wide.yaml" +exit $status diff --git a/integrations/kubernetes/test/kind.sh b/integrations/kubernetes/test/kind.sh new file mode 100755 index 000000000..f7fcee491 --- /dev/null +++ b/integrations/kubernetes/test/kind.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# End-to-end test on a kind cluster: the operator syncs a secret from a stub +# Keepiq running in the cluster, a rotation reaches the Secret, and the +# no-Secret recipe pod reads its value from the process environment. +# +# Needs: kind, kubectl, helm, docker, and images built beforehand: +# keepiq-operator:e2e (integrations/kubernetes/Dockerfile) +# keepiq-cli:e2e (cli/Dockerfile) +# Run from the repository root. Used by .github/workflows/integrations-kubernetes.yml. +set -euo pipefail +root="$(pwd)" +cluster="${KIND_CLUSTER:-keepiq-e2e}" +ns=shop + +kind create cluster --name "$cluster" --wait 120s +trap 'kind delete cluster --name "$cluster"' EXIT +kind load docker-image keepiq-operator:e2e keepiq-cli:e2e --name "$cluster" + +kubectl create namespace "$ns" +value="$(openssl rand -hex 16)" +recipe_value="$(openssl rand -hex 16)" + +# The stub Keepiq, from sdk/testdata, inside the cluster. +kubectl -n "$ns" create configmap keepiq-stub \ + --from-file=stub_server.py="$root/sdk/testdata/stub_server.py" \ + --from-file=machine_envelope.json="$root/sdk/testdata/machine_envelope.json" +kubectl -n "$ns" apply -f - << YAML +apiVersion: v1 +kind: Pod +metadata: {name: keepiq-stub, labels: {app: keepiq-stub}} +spec: + containers: + - name: stub + image: python:3.12-slim + command: [sh, -c, "pip install -q cryptography && cp /stub/* /tmp/ && python /tmp/stub_server.py --host 0.0.0.0 --port 8080 --application shop-prod --add db-password=$value --add migrate-password=$recipe_value"] + volumeMounts: [{name: stub, mountPath: /stub}] + readinessProbe: + httpGet: {path: /index.php/apps/keepiq/api/v1/app/.well-known/keepiq, port: 8080} + volumes: [{name: stub, configMap: {name: keepiq-stub}}] +--- +apiVersion: v1 +kind: Service +metadata: {name: keepiq} +spec: + selector: {app: keepiq-stub} + ports: [{port: 80, targetPort: 8080}] +YAML +kubectl -n "$ns" wait --for=condition=Ready pod/keepiq-stub --timeout=180s + +python3 - "$root/sdk/testdata/machine_envelope.json" > /tmp/keepiq-key.pem << 'PY' +import json, sys +print(json.load(open(sys.argv[1]))["privateKeyPem"], end="") +PY +kubectl -n "$ns" create secret generic keepiq-app-key --from-file=key.pem=/tmp/keepiq-key.pem +rm -f /tmp/keepiq-key.pem + +helm install keepiq-operator "$root/integrations/kubernetes/charts/keepiq-operator" -n "$ns" \ + --set image.repository=keepiq-operator --set image.tag=e2e --set image.pullPolicy=Never --wait --timeout 180s + +kubectl -n "$ns" apply -f - << 'YAML' +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqConnection +metadata: {name: shop} +spec: + url: http://keepiq.shop.svc/index.php + applicationId: shop-prod + privateKeySecretRef: {name: keepiq-app-key, key: key.pem} +--- +apiVersion: keepiq.conduction.nl/v1alpha1 +kind: KeepiqSecret +metadata: {name: shop-db} +spec: + connectionRef: {name: shop} + target: {name: shop-db} + refreshInterval: 10s + items: + - {name: db-password, field: key, targetKey: DB_PASSWORD} +YAML + +for _ in $(seq 60); do + got="$(kubectl -n "$ns" get secret shop-db -o jsonpath='{.data.DB_PASSWORD}' 2> /dev/null | base64 -d || true)" + [ "$got" = "$value" ] && break + sleep 2 +done +[ "$got" = "$value" ] || { kubectl -n "$ns" describe keepiqsecret shop-db; echo "Secret shop-db never got the value" >&2; exit 1; } +kubectl -n "$ns" get keepiqsecret shop-db -o jsonpath='{.status.conditions[0].status}' | grep -qx True +echo "ok - the operator synced the value into Secret shop-db" +if kubectl -n "$ns" get keepiqsecret shop-db -o yaml | grep -qF "$value"; then echo "the value leaked into the resource" >&2; exit 1; fi +if kubectl -n "$ns" logs deploy/keepiq-operator | grep -qF "$value"; then echo "the value leaked into the operator log" >&2; exit 1; fi +echo "ok - no value in the resource or the operator log" + +# The recipe: init container installs the CLI, the app runs through keepiq ci run. +kubectl -n "$ns" apply -f - << 'YAML' +apiVersion: v1 +kind: Pod +metadata: {name: recipe} +spec: + restartPolicy: Never + volumes: + - {name: keepiq, emptyDir: {}} + - name: keepiq-key + secret: {secretName: keepiq-app-key, items: [{key: key.pem, path: key.pem}]} + initContainers: + - name: keepiq-cli + image: keepiq-cli:e2e + imagePullPolicy: Never + command: ["/usr/local/bin/keepiq", "install", "/keepiq/keepiq"] + volumeMounts: [{name: keepiq, mountPath: /keepiq}] + containers: + - name: app + image: busybox:1.36 + command: ["/keepiq/keepiq", "ci", "run", "migrate-password", "--", "sh", "-c", "printf %s \"$KEEPIQ_MIGRATE_PASSWORD\" | sha256sum"] + env: + - {name: KEEPIQ_URL, value: http://keepiq.shop.svc/index.php} + - {name: KEEPIQ_APP_ID, value: shop-prod} + - {name: KEEPIQ_APP_KEY_FILE, value: /keepiq-key/key.pem} + volumeMounts: + - {name: keepiq, mountPath: /keepiq, readOnly: true} + - {name: keepiq-key, mountPath: /keepiq-key, readOnly: true} +YAML +kubectl -n "$ns" wait --for=jsonpath='{.status.phase}'=Succeeded pod/recipe --timeout=120s +want="$(printf %s "$recipe_value" | sha256sum | cut -c1-64)" +kubectl -n "$ns" logs recipe | grep -q "$want" || { kubectl -n "$ns" logs recipe; echo "recipe pod did not see the value" >&2; exit 1; } +echo "ok - the recipe pod read the value from its environment" +for s in $(kubectl -n "$ns" get secrets -o name); do + if kubectl -n "$ns" get "$s" -o json | python3 -c 'import base64,json,sys; d=json.load(sys.stdin).get("data") or {}; v=sys.argv[1]; sys.exit(0 if any(v in base64.b64decode(x).decode("utf-8","replace") for x in d.values()) else 1)' "$recipe_value"; then + echo "$s holds the recipe value" >&2; exit 1 + fi +done +echo "ok - no Kubernetes Secret holds the recipe value" diff --git a/integrations/kubernetes/test/snapshots/cluster-wide.yaml b/integrations/kubernetes/test/snapshots/cluster-wide.yaml new file mode 100644 index 000000000..611109cac --- /dev/null +++ b/integrations/kubernetes/test/snapshots/cluster-wide.yaml @@ -0,0 +1,127 @@ +--- +# Source: keepiq-operator/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +--- +# Source: keepiq-operator/templates/rbac.yaml +# What the operator may do: read its two resources and the Secret that holds +# the application key, write the target Secrets it owns, patch the pod template +# of restart targets, and record events. Namespaced unless clusterWide is set. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: keepiq-operator + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +rules: + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets, keepiqconnections] + verbs: [get, list, watch] + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets/status] + verbs: [get, update, patch] + - apiGroups: [""] + resources: [secrets] + verbs: [get, list, watch, create, update, patch] + - apiGroups: [""] + resources: [events] + verbs: [create, patch] + - apiGroups: [events.k8s.io] + resources: [events] + verbs: [create, patch] + - apiGroups: [apps] + resources: [deployments, statefulsets] + verbs: [get, patch] + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] +--- +# Source: keepiq-operator/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: keepiq-operator + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: keepiq-operator +subjects: + - kind: ServiceAccount + name: keepiq-operator + namespace: shop +--- +# Source: keepiq-operator/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + template: + metadata: + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + spec: + serviceAccountName: keepiq-operator + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: operator + image: "ghcr.io/conductionnl/keepiq-operator:0.1.0" + imagePullPolicy: IfNotPresent + args: + - --leader-elect + ports: + - name: metrics + containerPort: 8080 + - name: probes + containerPort: 8081 + livenessProbe: + httpGet: {path: /healthz, port: probes} + readinessProbe: + httpGet: {path: /readyz, port: probes} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + resources: + limits: + memory: 256Mi + requests: + cpu: 20m + memory: 64Mi diff --git a/integrations/kubernetes/test/snapshots/default.yaml b/integrations/kubernetes/test/snapshots/default.yaml new file mode 100644 index 000000000..9d243d8c8 --- /dev/null +++ b/integrations/kubernetes/test/snapshots/default.yaml @@ -0,0 +1,129 @@ +--- +# Source: keepiq-operator/templates/serviceaccount.yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +--- +# Source: keepiq-operator/templates/rbac.yaml +# What the operator may do: read its two resources and the Secret that holds +# the application key, write the target Secrets it owns, patch the pod template +# of restart targets, and record events. Namespaced unless clusterWide is set. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +rules: + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets, keepiqconnections] + verbs: [get, list, watch] + - apiGroups: [keepiq.conduction.nl] + resources: [keepiqsecrets/status] + verbs: [get, update, patch] + - apiGroups: [""] + resources: [secrets] + verbs: [get, list, watch, create, update, patch] + - apiGroups: [""] + resources: [events] + verbs: [create, patch] + - apiGroups: [events.k8s.io] + resources: [events] + verbs: [create, patch] + - apiGroups: [apps] + resources: [deployments, statefulsets] + verbs: [get, patch] + - apiGroups: [coordination.k8s.io] + resources: [leases] + verbs: [get, list, watch, create, update, patch] +--- +# Source: keepiq-operator/templates/rbac.yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: keepiq-operator +subjects: + - kind: ServiceAccount + name: keepiq-operator + namespace: shop +--- +# Source: keepiq-operator/templates/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: keepiq-operator + namespace: shop + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + app.kubernetes.io/version: "0.1.0" + app.kubernetes.io/managed-by: Helm + helm.sh/chart: keepiq-operator-0.1.0 +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + template: + metadata: + labels: + app.kubernetes.io/name: keepiq-operator + app.kubernetes.io/instance: keepiq-operator + spec: + serviceAccountName: keepiq-operator + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: operator + image: "ghcr.io/conductionnl/keepiq-operator:0.1.0" + imagePullPolicy: IfNotPresent + args: + - --watch-namespace=shop + ports: + - name: metrics + containerPort: 8080 + - name: probes + containerPort: 8081 + livenessProbe: + httpGet: {path: /healthz, port: probes} + readinessProbe: + httpGet: {path: /readyz, port: probes} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + resources: + limits: + memory: 256Mi + requests: + cpu: 20m + memory: 64Mi diff --git a/integrations/runner/.gitignore b/integrations/runner/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/integrations/runner/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/integrations/runner/Dockerfile b/integrations/runner/Dockerfile new file mode 100644 index 000000000..2149fbe17 --- /dev/null +++ b/integrations/runner/Dockerfile @@ -0,0 +1,18 @@ +# ghcr.io/conductionnl/keepiq-runner. Build from the repository root, because +# the runner builds against sdk/go in this repository: +# docker build -f integrations/runner/Dockerfile . +FROM golang:1.22 AS build +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +WORKDIR /src +COPY sdk/go ./sdk/go +COPY integrations/runner ./integrations/runner +WORKDIR /src/integrations/runner +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/keepiq-runner ./cmd/keepiq-runner + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/keepiq-runner /usr/local/bin/keepiq-runner +USER 65532:65532 +ENTRYPOINT ["/usr/local/bin/keepiq-runner"] +CMD ["run", "--config", "/etc/keepiq-runner/runner.yaml"] diff --git a/integrations/runner/cmd/keepiq-runner/main.go b/integrations/runner/cmd/keepiq-runner/main.go new file mode 100644 index 000000000..7d7ccc216 --- /dev/null +++ b/integrations/runner/cmd/keepiq-runner/main.go @@ -0,0 +1,116 @@ +// Command keepiq-runner rotates credentials at their targets and pushes +// secrets to cloud secret stores, as one Keepiq application, decrypting only +// in its own process. +// +// keepiq-runner run [--once] [--config runner.yaml] +// keepiq-runner check [--config runner.yaml] +// keepiq-runner version +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/connectors" + "github.com/ConductionNL/keepiq/integrations/runner/internal/destinations" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" + "github.com/ConductionNL/keepiq/integrations/runner/internal/runner" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" + "github.com/ConductionNL/keepiq/integrations/runner/internal/syncer" +) + +var version = "dev" + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + switch os.Args[1] { + case "version", "--version": + fmt.Println("keepiq-runner", version) + case "check", "run": + fs := flag.NewFlagSet(os.Args[1], flag.ExitOnError) + path := fs.String("config", "runner.yaml", "path to runner.yaml") + once := fs.Bool("once", false, "run what is due once and exit (for cron and CronJobs)") + _ = fs.Parse(os.Args[2:]) + cfg, err := config.Load(*path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if os.Args[1] == "check" { + fmt.Printf("%s: %d rotation(s), %d sync set(s)\n", *path, len(cfg.Rotations), len(cfg.Syncs)) + return + } + os.Exit(run(cfg, *once)) + default: + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: keepiq-runner run [--once] [--config runner.yaml] | check [--config runner.yaml] | version") +} + +func run(cfg *config.Config, once bool) int { + red := logx.NewRedactor() + log := logx.New(os.Stderr, red, slog.LevelInfo).With("app", cfg.Keepiq.ApplicationID) + kc, err := client(cfg) + if err != nil { + log.Error("cannot start", "error", err.Error()) + return 1 + } + dir, err := state.Open(cfg.StateDir) + if err != nil { + log.Error("cannot open the state directory", "error", err.Error()) + return 1 + } + r := &runner.Runner{ + Config: cfg, Keepiq: kc, State: dir, Log: log, Now: time.Now, + Rotator: &rotate.Rotator{Keepiq: kc, State: dir, Connectors: connectors.New, Log: log, Redactor: red}, + Syncer: &syncer.Syncer{Keepiq: kc, State: dir, Destinations: destinations.New, Log: log, Redactor: red, Now: time.Now}, + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := r.Start(ctx); err != nil { + log.Error("journal recovery finished with errors", "error", err.Error()) + } + if once { + if err := r.Tick(ctx, true); err != nil { + log.Error("run finished with errors", "error", err.Error()) + return 1 + } + return 0 + } + log.Info("keepiq-runner started", "version", version, "rotations", len(cfg.Rotations), "syncs", len(cfg.Syncs)) + _ = r.Daemon(ctx) + return 0 +} + +func client(cfg *config.Config) (*keepiq.Client, error) { + key, err := os.ReadFile(cfg.Keepiq.PrivateKeyFile) + if err != nil { + return nil, err + } + var opts []keepiq.Option + if cfg.Keepiq.CertificateFile != "" { + cert, err := os.ReadFile(cfg.Keepiq.CertificateFile) + if err != nil { + return nil, err + } + opts = append(opts, keepiq.WithCertificate(string(cert))) + } + return keepiq.New(cfg.Keepiq.URL, cfg.Keepiq.ApplicationID, string(key), opts...) +} diff --git a/integrations/runner/go.mod b/integrations/runner/go.mod new file mode 100644 index 000000000..49c5b7de5 --- /dev/null +++ b/integrations/runner/go.mod @@ -0,0 +1,39 @@ +module github.com/ConductionNL/keepiq/integrations/runner + +go 1.22.0 + +require ( + github.com/ConductionNL/keepiq/sdk/go v0.0.0 + github.com/aws/aws-sdk-go-v2 v1.30.5 + github.com/aws/aws-sdk-go-v2/config v1.27.33 + github.com/aws/aws-sdk-go-v2/credentials v1.17.32 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.8 + github.com/go-sql-driver/mysql v1.8.1 + github.com/jackc/pgx/v5 v5.6.0 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.27.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 // indirect + github.com/aws/smithy-go v1.20.4 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/kr/text v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect +) + +// The runner builds against the library in this repository, like the CLI. +replace github.com/ConductionNL/keepiq/sdk/go => ../../sdk/go diff --git a/integrations/runner/go.sum b/integrations/runner/go.sum new file mode 100644 index 000000000..ac4b268c1 --- /dev/null +++ b/integrations/runner/go.sum @@ -0,0 +1,73 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aws/aws-sdk-go-v2 v1.30.5 h1:mWSRTwQAb0aLE17dSzztCVJWI9+cRMgqebndjwDyK0g= +github.com/aws/aws-sdk-go-v2 v1.30.5/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/config v1.27.33 h1:Nof9o/MsmH4oa0s2q9a0k7tMz5x/Yj5k06lDODWz3BU= +github.com/aws/aws-sdk-go-v2/config v1.27.33/go.mod h1:kEqdYzRb8dd8Sy2pOdEbExTTF5v7ozEXX0McgPE7xks= +github.com/aws/aws-sdk-go-v2/credentials v1.17.32 h1:7Cxhp/BnT2RcGy4VisJ9miUPecY+lyE9I8JvcZofn9I= +github.com/aws/aws-sdk-go-v2/credentials v1.17.32/go.mod h1:P5/QMF3/DCHbXGEGkdbilXHsyTBX5D3HSwcrSc9p20I= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13 h1:pfQ2sqNpMVK6xz2RbqLEL0GH87JOwSxPV2rzm8Zsb74= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.13/go.mod h1:NG7RXPUlqfsCLLFfi0+IpKN4sCB9D9fw/qTaSB+xRoU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17 h1:pI7Bzt0BJtYA0N/JEC6B8fJ4RBrEMi1LBrkMdFYNSnQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.17/go.mod h1:Dh5zzJYMtxfIjYW+/evjQ8uj2OyR/ve2KROHGHlSFqE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17 h1:Mqr/V5gvrhA2gvgnF42Zh5iMiQNcOYthFYwCyrnuWlc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.17/go.mod h1:aLJpZlCmjE+V+KtN1q1uyZkfnUWpQGpbsn89XPKyzfU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19 h1:rfprUlsdzgl7ZL2KlXiUAoJnI/VxfHCvDFr2QDFj6u4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.19/go.mod h1:SCWkEdRq8/7EK60NcvvQ6NXKuTcchAD4ROAsC37VEZE= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.8 h1:HNXhQReFG2fbucvPRxDabbIGQf/6dieOfTnzoGPEqXI= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.8/go.mod h1:BYr9P/rrcLNJ8A36nT15p8tpoVDZ5lroHuMn/njecBw= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.7 h1:pIaGg+08llrP7Q5aiz9ICWbY8cqhTkyy+0SHvfzQpTc= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.7/go.mod h1:eEygMHnTKH/3kNp9Jr1n3PdejuSNcgwLe1dWgQtO0VQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7 h1:/Cfdu0XV3mONYKaOt1Gr0k1KvQzkzPyiKUdlWJqy+J4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.7/go.mod h1:bCbAxKDqNvkHxRaIMnyVPXPo+OaPRwvmgzMxbz1VKSA= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.7 h1:NKTa1eqZYw8tiHSRGpP0VtTdub/8KNk8sDkNPFaOKDE= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.7/go.mod h1:NXi1dIAGteSaRLqYgarlhP/Ij0cFT+qmCwiJqWh/U5o= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/runner/internal/config/config.go b/integrations/runner/internal/config/config.go new file mode 100644 index 000000000..d9a6b1cf2 --- /dev/null +++ b/integrations/runner/internal/config/config.go @@ -0,0 +1,216 @@ +// Package config loads runner.yaml. It names Keepiq secrets, never values: +// every credential the runner needs is itself a secret in the application's +// own vault. +package config + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/robfig/cron/v3" + "gopkg.in/yaml.v3" +) + +// Config is runner.yaml. +type Config struct { + Keepiq Keepiq `yaml:"keepiq"` + StateDir string `yaml:"stateDir"` + Tick Duration `yaml:"tick"` // how often the daemon checks schedules (default 30s) + Rotations []Rotation `yaml:"rotations"` + Syncs []Sync `yaml:"syncs"` +} + +// Keepiq is the instance and the application the runner acts as. +type Keepiq struct { + URL string `yaml:"url"` + ApplicationID string `yaml:"applicationId"` + PrivateKeyFile string `yaml:"privateKeyFile"` + // CertificateFile is optional; with it, envelopes for another certificate are refused. + CertificateFile string `yaml:"certificateFile"` +} + +// Rotation rotates one Keepiq secret at its target. +type Rotation struct { + Secret string `yaml:"secret"` + Folder string `yaml:"folder"` + Connector string `yaml:"connector"` // postgres, mysql or exec + Target map[string]string `yaml:"target"` // connector options, no secrets + Command []string `yaml:"command"` // exec connector + // AdminSecret is the Keepiq secret holding the target admin credential + // (login = user, key = password). Not used by exec. + AdminSecret string `yaml:"adminSecret"` + Schedule string `yaml:"schedule"` // cron, five fields + FollowExpiry bool `yaml:"followExpiry"` + LeadTime Duration `yaml:"leadTime"` // default 168h + Generator Generator `yaml:"generator"` +} + +// Generator shapes new passwords. +type Generator struct { + Length int `yaml:"length"` // default 32 + Classes []string `yaml:"classes"` // lower, upper, digits, symbols (default all but symbols) +} + +// Sync pushes a set of Keepiq secrets to one destination. +type Sync struct { + Name string `yaml:"name"` + Secrets []string `yaml:"secrets"` + Folder string `yaml:"folder"` + Destination string `yaml:"destination"` // aws-secrets-manager, azure-key-vault, github-actions, exec + Options map[string]string `yaml:"options"` + Command []string `yaml:"command"` // exec destination + // CredentialsSecret is the Keepiq secret with destination credentials; + // empty uses the ambient identity (AWS default chain, Azure managed identity). + CredentialsSecret string `yaml:"credentialsSecret"` + Interval Duration `yaml:"interval"` // default 60s +} + +// Duration is a time.Duration written as "60s" or "168h". +type Duration struct{ time.Duration } + +// UnmarshalYAML parses a Go duration string. +func (d *Duration) UnmarshalYAML(n *yaml.Node) error { + var s string + if err := n.Decode(&s); err != nil { + return err + } + v, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + d.Duration = v + return nil +} + +var ( + connectors = map[string]bool{"postgres": true, "mysql": true, "exec": true} + destinations = map[string]bool{"aws-secrets-manager": true, "azure-key-vault": true, "github-actions": true, "exec": true} + classes = map[string]bool{"lower": true, "upper": true, "digits": true, "symbols": true} +) + +// CronParser parses the five-field schedules. +var CronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + +// Load reads and validates a config file and fills defaults. +func Load(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return Parse(raw) +} + +// Parse validates YAML bytes and fills defaults. Unknown keys are refused, so +// a typo cannot silently turn a check off. +func Parse(raw []byte) (*Config, error) { + var c Config + dec := yaml.NewDecoder(strings.NewReader(string(raw))) + dec.KnownFields(true) + if err := dec.Decode(&c); err != nil { + return nil, fmt.Errorf("runner.yaml: %w", err) + } + var errs []string + add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) } + if c.Keepiq.URL == "" || c.Keepiq.ApplicationID == "" || c.Keepiq.PrivateKeyFile == "" { + add("keepiq.url, keepiq.applicationId and keepiq.privateKeyFile are required") + } + if c.StateDir == "" { + add("stateDir is required (the journal and sync state live there)") + } + if c.Tick.Duration == 0 { + c.Tick.Duration = 30 * time.Second + } + if len(c.Rotations) == 0 && len(c.Syncs) == 0 { + add("configure at least one rotation or sync") + } + seen := map[string]bool{} + for i := range c.Rotations { + r := &c.Rotations[i] + at := fmt.Sprintf("rotations[%d]", i) + if r.Secret == "" { + add("%s.secret is required", at) + } + key := r.Folder + "/" + r.Secret + if seen[key] { + add("%s: secret %q is rotated twice", at, r.Secret) + } + seen[key] = true + if !connectors[r.Connector] { + add("%s.connector must be postgres, mysql or exec", at) + } + if r.Connector == "exec" && len(r.Command) == 0 { + add("%s.command is required for the exec connector", at) + } + if (r.Connector == "postgres" || r.Connector == "mysql") && (r.AdminSecret == "" || r.Target["host"] == "") { + add("%s needs adminSecret and target.host", at) + } + if r.Schedule == "" && !r.FollowExpiry { + add("%s needs a schedule, followExpiry, or both", at) + } + if r.Schedule != "" { + if _, err := CronParser.Parse(r.Schedule); err != nil { + add("%s.schedule: %v", at, err) + } + } + if r.LeadTime.Duration == 0 { + r.LeadTime.Duration = 7 * 24 * time.Hour + } + if r.Generator.Length == 0 { + r.Generator.Length = 32 + } + if r.Generator.Length < 16 || r.Generator.Length > 256 { + add("%s.generator.length must be between 16 and 256", at) + } + if len(r.Generator.Classes) == 0 { + r.Generator.Classes = []string{"lower", "upper", "digits"} + } + for _, cl := range r.Generator.Classes { + if !classes[cl] { + add("%s.generator.classes: unknown class %q", at, cl) + } + } + } + names := map[string]bool{} + for i := range c.Syncs { + s := &c.Syncs[i] + at := fmt.Sprintf("syncs[%d]", i) + if s.Name == "" { + s.Name = fmt.Sprintf("%s-%d", s.Destination, i) + } + if names[s.Name] { + add("%s: name %q is used twice", at, s.Name) + } + names[s.Name] = true + if len(s.Secrets) == 0 { + add("%s.secrets needs at least one name", at) + } + if !destinations[s.Destination] { + add("%s.destination must be aws-secrets-manager, azure-key-vault, github-actions or exec", at) + } + if s.Destination == "exec" && len(s.Command) == 0 { + add("%s.command is required for the exec destination", at) + } + if s.Destination == "azure-key-vault" && s.Options["vaultUrl"] == "" { + add("%s.options.vaultUrl is required", at) + } + if s.Destination == "github-actions" && s.Options["repository"] == "" && s.Options["organization"] == "" { + add("%s.options needs repository or organization", at) + } + if s.Destination == "github-actions" && s.CredentialsSecret == "" { + add("%s.credentialsSecret is required (a GitHub token)", at) + } + if s.Interval.Duration == 0 { + s.Interval.Duration = 60 * time.Second + } + if s.Interval.Duration < 10*time.Second { + add("%s.interval must be at least 10s", at) + } + } + if len(errs) > 0 { + return nil, errors.New("runner.yaml: " + strings.Join(errs, "; ")) + } + return &c, nil +} diff --git a/integrations/runner/internal/config/config_test.go b/integrations/runner/internal/config/config_test.go new file mode 100644 index 000000000..1d55a28fd --- /dev/null +++ b/integrations/runner/internal/config/config_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "strings" + "testing" + "time" +) + +const valid = ` +keepiq: + url: https://cloud.example.org + applicationId: ops-runner + privateKeyFile: /run/keepiq/key.pem +stateDir: /var/lib/keepiq-runner +rotations: + - secret: pg-app-password + connector: postgres + target: {host: "db:5432", database: app} + adminSecret: pg-admin + schedule: "0 3 * * 0" + - secret: legacy-api + connector: exec + command: [/usr/local/bin/rotate-legacy] + followExpiry: true +syncs: + - secrets: [stripe-key] + destination: aws-secrets-manager + options: {region: eu-west-1, prefix: prod/} +` + +func TestValidConfigGetsDefaults(t *testing.T) { + c, err := Parse([]byte(valid)) + if err != nil { + t.Fatal(err) + } + r := c.Rotations[0] + if r.Generator.Length != 32 || len(r.Generator.Classes) != 3 || r.LeadTime.Duration != 7*24*time.Hour { + t.Fatalf("rotation defaults %+v", r) + } + if c.Syncs[0].Interval.Duration != time.Minute || c.Syncs[0].Name != "aws-secrets-manager-0" || c.Tick.Duration != 30*time.Second { + t.Fatalf("sync defaults %+v", c.Syncs[0]) + } +} + +func TestInvalidConfigsAreRefusedWithAReason(t *testing.T) { + cases := map[string]string{ + "unknown key": strings.Replace(valid, "stateDir:", "statedir:", 1), + "no state dir": strings.Replace(valid, "stateDir: /var/lib/keepiq-runner", "", 1), + "bad connector": strings.Replace(valid, "connector: postgres", "connector: oracle", 1), + "bad cron": strings.Replace(valid, `"0 3 * * 0"`, `"every sunday"`, 1), + "postgres no admin": strings.Replace(valid, "adminSecret: pg-admin", "", 1), + "exec no command": strings.Replace(valid, "command: [/usr/local/bin/rotate-legacy]", "", 1), + "no schedule": strings.Replace(valid, "followExpiry: true", "", 1), + "bad destination": strings.Replace(valid, "destination: aws-secrets-manager", "destination: vault", 1), + "short interval": strings.Replace(valid, "options: {region", "interval: 5s\n options: {region", 1), + "short password": strings.Replace(valid, "schedule: \"0 3 * * 0\"", "schedule: \"0 3 * * 0\"\n generator: {length: 8}", 1), + "github without token": strings.Replace(valid, "destination: aws-secrets-manager\n options: {region: eu-west-1, prefix: prod/}", "destination: github-actions\n options: {repository: example/app}", 1), + } + for name, raw := range cases { + if _, err := Parse([]byte(raw)); err == nil { + t.Errorf("%s: accepted", name) + } + } +} diff --git a/integrations/runner/internal/connectors/connectors.go b/integrations/runner/internal/connectors/connectors.go new file mode 100644 index 000000000..42a85996d --- /dev/null +++ b/integrations/runner/internal/connectors/connectors.go @@ -0,0 +1,185 @@ +// Package connectors sets and proves passwords at rotation targets: +// PostgreSQL, MySQL, and any system through an exec hook. +package connectors + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net" + "net/url" + "os/exec" + "strings" + "time" + + "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" +) + +// New builds the connector a rotation names. +func New(r config.Rotation) (rotate.Connector, error) { + switch r.Connector { + case "postgres": + return &Postgres{Host: r.Target["host"], Database: r.Target["database"], SSLMode: r.Target["sslmode"]}, nil + case "mysql": + return &MySQL{Host: r.Target["host"], Database: r.Target["database"], UserHost: r.Target["userHost"], TLS: r.Target["tls"]}, nil + case "exec": + return &Exec{Command: r.Command}, nil + } + return nil, fmt.Errorf("unknown connector %q", r.Connector) +} + +// sqlString quotes a string literal for SQL. Generated values contain no +// quote or backslash; this is the guard for anything else. +func sqlString(v string) string { + return "'" + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), "'", "''") + "'" +} + +// Postgres rotates a role's password with ALTER ROLE. +type Postgres struct { + Host string // host:port + Database string + SSLMode string // default prefer +} + +func (p *Postgres) dsn(user, password string) string { + host, port, err := net.SplitHostPort(p.Host) + if err != nil { + host, port = p.Host, "5432" + } + db := p.Database + if db == "" { + db = "postgres" + } + mode := p.SSLMode + if mode == "" { + mode = "prefer" + } + u := url.URL{Scheme: "postgres", User: url.UserPassword(user, password), Host: net.JoinHostPort(host, port), Path: "/" + db, + RawQuery: url.Values{"sslmode": {mode}, "connect_timeout": {"10"}}.Encode()} + return u.String() +} + +// Set runs ALTER ROLE as the admin. +func (p *Postgres) Set(ctx context.Context, admin rotate.Credential, user, _, next string) error { + conn, err := pgx.Connect(ctx, p.dsn(admin.User, admin.Password)) + if err != nil { + return fmt.Errorf("postgres: admin login: %w", scrub(err)) + } + defer conn.Close(ctx) + // ALTER ROLE takes no bind parameters; the identifier and the literal are quoted. + _, err = conn.Exec(ctx, "ALTER ROLE "+pgx.Identifier{user}.Sanitize()+" WITH PASSWORD "+sqlString(next)) + if err != nil { + return fmt.Errorf("postgres: ALTER ROLE %s failed: %w", user, scrub(err)) + } + return nil +} + +// Login connects as the user. +func (p *Postgres) Login(ctx context.Context, user, password string) error { + conn, err := pgx.Connect(ctx, p.dsn(user, password)) + if err != nil { + return fmt.Errorf("postgres: login as %s failed: %w", user, scrub(err)) + } + defer conn.Close(ctx) + return conn.Ping(ctx) +} + +// MySQL rotates an account's password with ALTER USER. +type MySQL struct { + Host string // host:port + Database string + UserHost string // the account's host part, default % + TLS string // go-sql-driver tls value, default preferred +} + +func (m *MySQL) open(user, password string) (*sql.DB, error) { + cfg := mysql.NewConfig() + cfg.User, cfg.Passwd, cfg.Net, cfg.Addr, cfg.DBName = user, password, "tcp", m.Host, m.Database + cfg.Timeout = 10 * time.Second + cfg.TLSConfig = m.TLS + if cfg.TLSConfig == "" { + cfg.TLSConfig = "preferred" + } + cfg.AllowNativePasswords = true + return sql.Open("mysql", cfg.FormatDSN()) +} + +// Set runs ALTER USER as the admin. +func (m *MySQL) Set(ctx context.Context, admin rotate.Credential, user, _, next string) error { + db, err := m.open(admin.User, admin.Password) + if err != nil { + return err + } + defer db.Close() + host := m.UserHost + if host == "" { + host = "%" + } + if _, err := db.ExecContext(ctx, "ALTER USER "+sqlString(user)+"@"+sqlString(host)+" IDENTIFIED BY "+sqlString(next)); err != nil { + return fmt.Errorf("mysql: ALTER USER %s failed: %w", user, scrub(err)) + } + return nil +} + +// Login connects as the user. +func (m *MySQL) Login(ctx context.Context, user, password string) error { + db, err := m.open(user, password) + if err != nil { + return err + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("mysql: login as %s failed: %w", user, scrub(err)) + } + return nil +} + +// Exec rotates through a command. It gets one JSON object on stdin: +// +// {"action":"set","user":"app","current":"…","new":"…"} +// {"action":"login","user":"app","password":"…"} +// +// and answers with its exit code (0 is success). Its output is not read or +// logged, so a hook cannot leak a value into the runner's log. +type Exec struct{ Command []string } + +func (e *Exec) run(ctx context.Context, payload map[string]string) error { + body, _ := json.Marshal(payload) + cmd := exec.CommandContext(ctx, e.Command[0], e.Command[1:]...) + cmd.Stdin = bytes.NewReader(body) + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("exec %s %s: exit %d", e.Command[0], payload["action"], ee.ExitCode()) + } + return fmt.Errorf("exec %s: %w", e.Command[0], err) + } + return nil +} + +// Set calls the hook with action set. +func (e *Exec) Set(ctx context.Context, _ rotate.Credential, user, current, next string) error { + return e.run(ctx, map[string]string{"action": "set", "user": user, "current": current, "new": next}) +} + +// Login calls the hook with action login. +func (e *Exec) Login(ctx context.Context, user, password string) error { + return e.run(ctx, map[string]string{"action": "login", "user": user, "password": password}) +} + +// scrub keeps a driver error but never its connection string. +func scrub(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if i := strings.Index(msg, "postgres://"); i >= 0 { + msg = msg[:i] + "(connection string removed)" + } + return fmt.Errorf("%s", msg) +} diff --git a/integrations/runner/internal/connectors/connectors_test.go b/integrations/runner/internal/connectors/connectors_test.go new file mode 100644 index 000000000..d2a01847e --- /dev/null +++ b/integrations/runner/internal/connectors/connectors_test.go @@ -0,0 +1,109 @@ +package connectors + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" +) + +// The database tests need a server. Each gets an admin account and creates a +// throwaway login to rotate: +// +// KEEPIQ_TEST_POSTGRES="host:5432 adminUser adminPassword" +// KEEPIQ_TEST_MYSQL="host:3306 adminUser adminPassword" +func dbEnv(t *testing.T, name string) (host string, admin rotate.Credential) { + t.Helper() + parts := strings.Fields(os.Getenv(name)) + if len(parts) != 3 { + t.Skipf("set %s=\"host:port adminUser adminPassword\" to run against a real server", name) + } + return parts[0], rotate.Credential{User: parts[1], Password: parts[2]} +} + +// rotateAgainst sets a new password as the admin and proves the new one logs +// in and the old one no longer does. +func rotateAgainst(t *testing.T, c rotate.Connector, admin rotate.Credential, user, old string) { + t.Helper() + ctx := context.Background() + if err := c.Login(ctx, user, old); err != nil { + t.Fatalf("old password does not log in before the rotation: %v", err) + } + next, _ := rotate.Generate(config.Generator{Length: 32, Classes: []string{"lower", "upper", "digits", "symbols"}}) + if err := c.Set(ctx, admin, user, old, next); err != nil { + t.Fatal(err) + } + if err := c.Login(ctx, user, next); err != nil { + t.Fatalf("new password refused: %v", err) + } + if err := c.Login(ctx, user, old); err == nil { + t.Fatal("old password still logs in") + } + err := c.Login(ctx, user, "wrong-"+next) + if err == nil || strings.Contains(err.Error(), next) { + t.Fatalf("a refused login must fail without echoing a value: %v", err) + } +} + +func TestPostgres(t *testing.T) { + host, admin := dbEnv(t, "KEEPIQ_TEST_POSTGRES") + ctx := context.Background() + p := &Postgres{Host: host, SSLMode: "disable"} + if err := p.Set(ctx, admin, admin.User, admin.Password, admin.Password); err != nil { + t.Fatalf("admin cannot log in: %v", err) + } + pg, err := pgxConnect(ctx, p, admin) + if err != nil { + t.Fatal(err) + } + _, _ = pg.Exec(ctx, "DROP ROLE IF EXISTS keepiq_rotate_test") + if _, err := pg.Exec(ctx, "CREATE ROLE keepiq_rotate_test LOGIN PASSWORD 'initial-pass-1'"); err != nil { + t.Fatal(err) + } + pg.Close(ctx) + rotateAgainst(t, p, admin, "keepiq_rotate_test", "initial-pass-1") +} + +func TestMySQL(t *testing.T) { + host, admin := dbEnv(t, "KEEPIQ_TEST_MYSQL") + ctx := context.Background() + m := &MySQL{Host: host, TLS: "false"} + db, err := m.open(admin.User, admin.Password) + if err != nil { + t.Fatal(err) + } + _, _ = db.ExecContext(ctx, "DROP USER IF EXISTS 'keepiq_rotate_test'@'%'") + if _, err := db.ExecContext(ctx, "CREATE USER 'keepiq_rotate_test'@'%' IDENTIFIED BY 'initial-pass-1'"); err != nil { + t.Fatal(err) + } + db.Close() + rotateAgainst(t, m, admin, "keepiq_rotate_test", "initial-pass-1") +} + +// The exec connector passes JSON on stdin and reads only the exit code. +func TestExecConnector(t *testing.T) { + dir := t.TempDir() + store := filepath.Join(dir, "password") + _ = os.WriteFile(store, []byte("old-value-1"), 0o600) + script := filepath.Join(dir, "hook.sh") + // A tiny target: "set" writes the new value when current matches; + // "login" succeeds when the password matches. + _ = os.WriteFile(script, []byte(`#!/bin/sh +in=$(cat) +field() { printf '%s' "$in" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"; } +case "$(field action)" in + set) [ "$(cat `+store+`)" = "$(field current)" ] || exit 2; printf '%s' "$(field new)" > `+store+` ;; + login) [ "$(cat `+store+`)" = "$(field password)" ] || exit 1 ;; + *) exit 9 ;; +esac +`), 0o755) + c, err := New(config.Rotation{Connector: "exec", Command: []string{script}}) + if err != nil { + t.Fatal(err) + } + rotateAgainst(t, c, rotate.Credential{}, "app", "old-value-1") +} diff --git a/integrations/runner/internal/connectors/pgx_test.go b/integrations/runner/internal/connectors/pgx_test.go new file mode 100644 index 000000000..751ca3a34 --- /dev/null +++ b/integrations/runner/internal/connectors/pgx_test.go @@ -0,0 +1,13 @@ +package connectors + +import ( + "context" + + "github.com/jackc/pgx/v5" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" +) + +func pgxConnect(ctx context.Context, p *Postgres, admin rotate.Credential) (*pgx.Conn, error) { + return pgx.Connect(ctx, p.dsn(admin.User, admin.Password)) +} diff --git a/integrations/runner/internal/destinations/destinations.go b/integrations/runner/internal/destinations/destinations.go new file mode 100644 index 000000000..422bcd733 --- /dev/null +++ b/integrations/runner/internal/destinations/destinations.go @@ -0,0 +1,337 @@ +// Package destinations pushes values to AWS Secrets Manager, Azure Key Vault, +// GitHub Actions secrets and exec hooks. +package destinations + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" + "golang.org/x/crypto/nacl/box" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/syncer" +) + +// HTTPClient is used by the Azure and GitHub destinations. +var HTTPClient = &http.Client{Timeout: 30 * time.Second} + +// New builds the destination of a sync set. creds is the decrypted +// credentials secret, or nil for the ambient identity. +func New(s config.Sync, creds *keepiq.Secret) (syncer.Destination, error) { + switch s.Destination { + case "aws-secrets-manager": + return NewAWS(context.Background(), s.Options, creds) + case "azure-key-vault": + return NewAzure(s.Options, creds) + case "github-actions": + return NewGitHub(s.Options, creds) + case "exec": + return &Exec{Command: s.Command}, nil + } + return nil, fmt.Errorf("unknown destination %q", s.Destination) +} + +// --- AWS Secrets Manager --- + +// AWS pushes with PutSecretValue, creating the secret on first push. +// Options: region, prefix, endpoint (for a local emulator). Credentials: +// login = access key id, key = secret access key, additionalFields +// {"sessionToken": …} optional; without them the default AWS chain is used. +type AWS struct { + Client *secretsmanager.Client + Prefix string +} + +// NewAWS builds the client. +func NewAWS(ctx context.Context, opts map[string]string, creds *keepiq.Secret) (*AWS, error) { + var lo []func(*awsconfig.LoadOptions) error + if r := opts["region"]; r != "" { + lo = append(lo, awsconfig.WithRegion(r)) + } + if creds != nil { + session := extra(creds, "sessionToken") + lo = append(lo, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(creds.Login, creds.Key, session))) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, lo...) + if err != nil { + return nil, err + } + client := secretsmanager.NewFromConfig(cfg, func(o *secretsmanager.Options) { + if ep := opts["endpoint"]; ep != "" { + o.BaseEndpoint = aws.String(ep) + } + }) + return &AWS{Client: client, Prefix: opts["prefix"]}, nil +} + +// Push stores the value as the secret's current version. +func (a *AWS) Push(ctx context.Context, name, value string) error { + id := a.Prefix + name + _, err := a.Client.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{SecretId: aws.String(id), SecretString: aws.String(value)}) + var nf *smtypes.ResourceNotFoundException + if errors.As(err, &nf) { + _, err = a.Client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{ + Name: aws.String(id), SecretString: aws.String(value), + Description: aws.String("Synced from Keepiq by keepiq-runner"), + }) + } + if err != nil { + return fmt.Errorf("aws-secrets-manager %s: %w", id, err) + } + return nil +} + +// --- Azure Key Vault --- + +// Azure pushes with Set Secret (REST, api-version 7.4). Options: vaultUrl, +// prefix, authorityHost (default login.microsoftonline.com), imdsEndpoint. +// Credentials: login = client id, key = client secret, additionalFields +// {"tenantId": …}; without them the managed identity endpoint is used. +type Azure struct { + VaultURL, Prefix, AuthorityHost, IMDS string + ClientID, ClientSecret, TenantID string + + mu sync.Mutex + token string + expiry time.Time +} + +// NewAzure builds the destination. +func NewAzure(opts map[string]string, creds *keepiq.Secret) (*Azure, error) { + a := &Azure{VaultURL: strings.TrimRight(opts["vaultUrl"], "/"), Prefix: opts["prefix"], + AuthorityHost: opts["authorityHost"], IMDS: opts["imdsEndpoint"]} + if a.AuthorityHost == "" { + a.AuthorityHost = "https://login.microsoftonline.com" + } + if a.IMDS == "" { + a.IMDS = "http://169.254.169.254/metadata/identity/oauth2/token" + } + if creds != nil { + a.ClientID, a.ClientSecret, a.TenantID = creds.Login, creds.Key, extra(creds, "tenantId") + if a.TenantID == "" || a.ClientID == "" { + return nil, errors.New("azure-key-vault: the credentials secret needs login (client id), key (client secret) and additionalFields.tenantId") + } + } + return a, nil +} + +var azureName = regexp.MustCompile(`[^0-9A-Za-z-]`) + +// AzureName maps a Keepiq name to a Key Vault name (letters, digits, dashes). +func AzureName(prefix, name string) string { return azureName.ReplaceAllString(prefix+name, "-") } + +// Push sets the secret. +func (a *Azure) Push(ctx context.Context, name, value string) error { + tok, err := a.accessToken(ctx) + if err != nil { + return err + } + body, _ := json.Marshal(map[string]any{"value": value, "tags": map[string]string{"source": "keepiq"}}) + u := a.VaultURL + "/secrets/" + url.PathEscape(AzureName(a.Prefix, name)) + "?api-version=7.4" + req, _ := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + return expect(req, http.StatusOK, "azure-key-vault "+name) +} + +func (a *Azure) accessToken(ctx context.Context) (string, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.token != "" && time.Now().Before(a.expiry) { + return a.token, nil + } + var req *http.Request + if a.ClientID != "" { + form := url.Values{"grant_type": {"client_credentials"}, "client_id": {a.ClientID}, "client_secret": {a.ClientSecret}, "scope": {"https://vault.azure.net/.default"}} + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, a.AuthorityHost+"/"+url.PathEscape(a.TenantID)+"/oauth2/v2.0/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } else { + req, _ = http.NewRequestWithContext(ctx, http.MethodGet, a.IMDS+"?api-version=2018-02-01&resource="+url.QueryEscape("https://vault.azure.net"), nil) + req.Header.Set("Metadata", "true") + } + resp, err := HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("azure token: %w", err) + } + defer resp.Body.Close() + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn json.RawMessage `json:"expires_in"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&tok) != nil || tok.AccessToken == "" { + return "", fmt.Errorf("azure token: answered %d", resp.StatusCode) + } + secs := 300 + _, _ = fmt.Sscanf(strings.Trim(string(tok.ExpiresIn), `"`), "%d", &secs) + a.token, a.expiry = tok.AccessToken, time.Now().Add(time.Duration(secs)*time.Second-time.Minute) + return a.token, nil +} + +// --- GitHub Actions secrets --- + +// GitHub stores repository, environment or organisation secrets through the +// REST API, sealed with the target's public key (libsodium sealed box), as +// GitHub requires. Options: repository (owner/name) with optional +// environment, or organization with optional visibility (default private); +// apiUrl for GitHub Enterprise; prefix. Credentials: key = a token. +type GitHub struct { + API, Repository, Environment, Organization, Visibility, Prefix, Token string +} + +// NewGitHub builds the destination. +func NewGitHub(opts map[string]string, creds *keepiq.Secret) (*GitHub, error) { + if creds == nil || creds.Key == "" { + return nil, errors.New("github-actions: the credentials secret must hold a token in key") + } + g := &GitHub{API: strings.TrimRight(opts["apiUrl"], "/"), Repository: opts["repository"], Environment: opts["environment"], + Organization: opts["organization"], Visibility: opts["visibility"], Prefix: opts["prefix"], Token: creds.Key} + if g.API == "" { + g.API = "https://api.github.com" + } + if g.Visibility == "" { + g.Visibility = "private" + } + return g, nil +} + +var ghName = regexp.MustCompile(`[^A-Z0-9_]`) + +// GitHubName maps a Keepiq name to a GitHub secret name (A-Z, 0-9, _). +func GitHubName(prefix, name string) string { + n := ghName.ReplaceAllString(strings.ToUpper(prefix+name), "_") + if n != "" && n[0] >= '0' && n[0] <= '9' { + n = "_" + n + } + return n +} + +func (g *GitHub) base() string { + switch { + case g.Organization != "": + return g.API + "/orgs/" + url.PathEscape(g.Organization) + "/actions/secrets" + case g.Environment != "": + return g.API + "/repos/" + g.Repository + "/environments/" + url.PathEscape(g.Environment) + "/secrets" + default: + return g.API + "/repos/" + g.Repository + "/actions/secrets" + } +} + +// Seal encrypts value as a libsodium sealed box for the base64 public key. +func Seal(value, publicKeyB64 string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil || len(raw) != 32 { + return "", errors.New("github public key is not a 32-byte base64 key") + } + var pk [32]byte + copy(pk[:], raw) + out, err := box.SealAnonymous(nil, []byte(value), &pk, rand.Reader) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(out), nil +} + +// Push seals and stores the value. +func (g *GitHub) Push(ctx context.Context, name, value string) error { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, g.base()+"/public-key", nil) + g.headers(req) + resp, err := HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("github public key: %w", err) + } + var pk struct { + KeyID string `json:"key_id"` + Key string `json:"key"` + } + err = json.NewDecoder(resp.Body).Decode(&pk) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || err != nil { + return fmt.Errorf("github public key: answered %d", resp.StatusCode) + } + sealed, err := Seal(value, pk.Key) + if err != nil { + return err + } + body := map[string]any{"encrypted_value": sealed, "key_id": pk.KeyID} + if g.Organization != "" { + body["visibility"] = g.Visibility + } + raw, _ := json.Marshal(body) + put, _ := http.NewRequestWithContext(ctx, http.MethodPut, g.base()+"/"+GitHubName(g.Prefix, name), bytes.NewReader(raw)) + g.headers(put) + put.Header.Set("Content-Type", "application/json") + return expect(put, 0, "github-actions "+name) +} + +func (g *GitHub) headers(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+g.Token) + r.Header.Set("Accept", "application/vnd.github+json") + r.Header.Set("X-GitHub-Api-Version", "2022-11-28") +} + +// --- exec --- + +// Exec pushes through a command: one JSON object {"name": …, "value": …} on +// stdin, exit code 0 for success. Its output is not read. +type Exec struct{ Command []string } + +// Push runs the hook. +func (e *Exec) Push(ctx context.Context, name, value string) error { + body, _ := json.Marshal(map[string]string{"name": name, "value": value}) + cmd := exec.CommandContext(ctx, e.Command[0], e.Command[1:]...) + cmd.Stdin = bytes.NewReader(body) + if err := cmd.Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("exec %s: exit %d", e.Command[0], ee.ExitCode()) + } + return fmt.Errorf("exec %s: %w", e.Command[0], err) + } + return nil +} + +// --- helpers --- + +// expect sends req and wants status (0 means any 2xx). The response body is +// not echoed, so a destination error cannot carry a value into the log. +func expect(req *http.Request, status int, what string) error { + resp, err := HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("%s: %w", what, err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + ok := resp.StatusCode == status || (status == 0 && resp.StatusCode/100 == 2) + if !ok { + return fmt.Errorf("%s: answered %d", what, resp.StatusCode) + } + return nil +} + +func extra(s *keepiq.Secret, field string) string { + var m map[string]any + if json.Unmarshal([]byte(s.AdditionalFields), &m) != nil { + return "" + } + v, _ := m[field].(string) + return v +} diff --git a/integrations/runner/internal/destinations/destinations_test.go b/integrations/runner/internal/destinations/destinations_test.go new file mode 100644 index 000000000..56a6318e1 --- /dev/null +++ b/integrations/runner/internal/destinations/destinations_test.go @@ -0,0 +1,205 @@ +package destinations + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "golang.org/x/crypto/nacl/box" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" +) + +// awsStub speaks the Secrets Manager JSON protocol (X-Amz-Target): the +// secret does not exist until CreateSecret, as on a fresh account. +func awsStub(t *testing.T) (*httptest.Server, map[string]string, *[]string) { + store := map[string]string{} + var calls []string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), "secretsmanager.") + calls = append(calls, op) + if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=AKIATEST/") { + w.WriteHeader(403) + return + } + var in map[string]string + _ = json.NewDecoder(r.Body).Decode(&in) + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + switch op { + case "PutSecretValue": + if _, ok := store[in["SecretId"]]; !ok { + w.WriteHeader(400) + _, _ = io.WriteString(w, `{"__type":"ResourceNotFoundException","message":"Secrets Manager can't find the specified secret."}`) + return + } + store[in["SecretId"]] = in["SecretString"] + _, _ = io.WriteString(w, `{"ARN":"arn:x","Name":"`+in["SecretId"]+`","VersionId":"v2"}`) + case "CreateSecret": + store[in["Name"]] = in["SecretString"] + _, _ = io.WriteString(w, `{"ARN":"arn:x","Name":"`+in["Name"]+`","VersionId":"v1"}`) + default: + w.WriteHeader(400) + } + })) + t.Cleanup(srv.Close) + return srv, store, &calls +} + +func TestAWSCreatesThenUpdates(t *testing.T) { + srv, store, calls := awsStub(t) + creds := &keepiq.Secret{Login: "AKIATEST", Key: "aws-secret-key"} + a, err := NewAWS(context.Background(), map[string]string{"region": "eu-west-1", "prefix": "prod/", "endpoint": srv.URL}, creds) + if err != nil { + t.Fatal(err) + } + if err := a.Push(context.Background(), "stripe-key", "sk_live_one"); err != nil { + t.Fatal(err) + } + if err := a.Push(context.Background(), "stripe-key", "sk_live_two"); err != nil { + t.Fatal(err) + } + if store["prod/stripe-key"] != "sk_live_two" || strings.Join(*calls, ",") != "PutSecretValue,CreateSecret,PutSecretValue" { + t.Fatalf("store %v calls %v", store, *calls) + } +} + +func TestAzureClientCredentialsAndSetSecret(t *testing.T) { + var got struct{ name, value, auth, form string } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/tenant-1/oauth2/v2.0/token": + _ = r.ParseForm() + got.form = r.Form.Get("client_id") + "|" + r.Form.Get("scope") + "|" + r.Form.Get("grant_type") + _, _ = io.WriteString(w, `{"access_token":"az-token","expires_in":3599}`) + case strings.HasPrefix(r.URL.Path, "/secrets/") && r.Method == http.MethodPut: + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + got.name, got.value, got.auth = strings.TrimPrefix(r.URL.Path, "/secrets/"), in["value"].(string), r.Header.Get("Authorization") + if r.URL.Query().Get("api-version") != "7.4" { + w.WriteHeader(400) + return + } + _, _ = io.WriteString(w, `{"id":"x"}`) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + creds := &keepiq.Secret{Login: "client-1", Key: "client-secret", AdditionalFields: `{"tenantId":"tenant-1"}`} + a, err := NewAzure(map[string]string{"vaultUrl": srv.URL, "authorityHost": srv.URL, "prefix": "prod-"}, creds) + if err != nil { + t.Fatal(err) + } + if err := a.Push(context.Background(), "stripe_key.v2", "sk_live_one"); err != nil { + t.Fatal(err) + } + if got.name != "prod-stripe-key-v2" || got.value != "sk_live_one" || got.auth != "Bearer az-token" || got.form != "client-1|https://vault.azure.net/.default|client_credentials" { + t.Fatalf("azure saw %+v", got) + } +} + +func TestAzureManagedIdentity(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/imds" { + if r.Header.Get("Metadata") != "true" { + w.WriteHeader(400) + return + } + _, _ = io.WriteString(w, `{"access_token":"mi-token","expires_in":"3599"}`) + return + } + if r.Header.Get("Authorization") != "Bearer mi-token" { + w.WriteHeader(401) + return + } + _, _ = io.WriteString(w, `{}`) + })) + defer srv.Close() + a, _ := NewAzure(map[string]string{"vaultUrl": srv.URL, "imdsEndpoint": srv.URL + "/imds"}, nil) + if err := a.Push(context.Background(), "x", "v"); err != nil { + t.Fatal(err) + } +} + +// GitHub gets a sealed box only the repository key can open. +func TestGitHubSealsWithTheRepositoryKey(t *testing.T) { + pub, priv, _ := box.GenerateKey(rand.Reader) + var put map[string]string + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer ghp_test" { + w.WriteHeader(401) + return + } + switch { + case strings.HasSuffix(r.URL.Path, "/public-key"): + _ = json.NewEncoder(w).Encode(map[string]string{"key_id": "k-1", "key": base64.StdEncoding.EncodeToString(pub[:])}) + case r.Method == http.MethodPut: + path = r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&put) + w.WriteHeader(201) + } + })) + defer srv.Close() + g, err := NewGitHub(map[string]string{"apiUrl": srv.URL, "repository": "example/app"}, &keepiq.Secret{Key: "ghp_test"}) + if err != nil { + t.Fatal(err) + } + if err := g.Push(context.Background(), "deploy-token", "dt_secret_value"); err != nil { + t.Fatal(err) + } + if path != "/repos/example/app/actions/secrets/DEPLOY_TOKEN" || put["key_id"] != "k-1" { + t.Fatalf("PUT %s %v", path, put) + } + if strings.Contains(put["encrypted_value"], "dt_secret_value") { + t.Fatal("value sent in the clear") + } + sealed, _ := base64.StdEncoding.DecodeString(put["encrypted_value"]) + opened, ok := box.OpenAnonymous(nil, sealed, pub, priv) + if !ok || string(opened) != "dt_secret_value" { + t.Fatalf("the repository key does not open the box: %v %q", ok, opened) + } + org, _ := NewGitHub(map[string]string{"organization": "example"}, &keepiq.Secret{Key: "t"}) + if org.base() != "https://api.github.com/orgs/example/actions/secrets" { + t.Fatal(org.base()) + } + env, _ := NewGitHub(map[string]string{"repository": "example/app", "environment": "prod"}, &keepiq.Secret{Key: "t"}) + if env.base() != "https://api.github.com/repos/example/app/environments/prod/secrets" { + t.Fatal(env.base()) + } + if GitHubName("", "1st-key.v2") != "_1ST_KEY_V2" { + t.Fatal(GitHubName("", "1st-key.v2")) + } +} + +func TestExecDestination(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "got.json") + script := filepath.Join(dir, "push.sh") + _ = os.WriteFile(script, []byte("#!/bin/sh\ncat > "+out+"\n"), 0o755) + if err := (&Exec{Command: []string{script}}).Push(context.Background(), "stripe-key", "sk_live_one"); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(out) + if string(raw) != `{"name":"stripe-key","value":"sk_live_one"}` { + t.Fatalf("hook got %s", raw) + } + fail := filepath.Join(dir, "fail.sh") + _ = os.WriteFile(fail, []byte("#!/bin/sh\necho sk_live_one >&2\nexit 4\n"), 0o755) + err := (&Exec{Command: []string{fail}}).Push(context.Background(), "stripe-key", "sk_live_one") + if err == nil || !strings.Contains(err.Error(), "exit 4") || strings.Contains(err.Error(), "sk_live_one") { + t.Fatalf("exec failure: %v", err) + } +} diff --git a/integrations/runner/internal/logx/logx.go b/integrations/runner/internal/logx/logx.go new file mode 100644 index 000000000..d696956fe --- /dev/null +++ b/integrations/runner/internal/logx/logx.go @@ -0,0 +1,90 @@ +// Package logx is the runner's structured log. The runner never passes a +// value to it; as a second line of defence every value the runner handles is +// registered with Forget, and any log record that would contain one has it +// replaced by [REDACTED]. +package logx + +import ( + "context" + "io" + "log/slog" + "strings" + "sync" +) + +// Redactor remembers values that must never be printed. +type Redactor struct { + mu sync.RWMutex + values map[string]struct{} +} + +// NewRedactor returns an empty redactor. +func NewRedactor() *Redactor { return &Redactor{values: map[string]struct{}{}} } + +// Forget registers a value to redact. Values shorter than 4 bytes are not +// registered, so they cannot blank out ordinary words. +func (r *Redactor) Forget(v string) { + if len(v) < 4 { + return + } + r.mu.Lock() + r.values[v] = struct{}{} + r.mu.Unlock() +} + +// Clean replaces every registered value in s. +func (r *Redactor) Clean(s string) string { + r.mu.RLock() + defer r.mu.RUnlock() + for v := range r.values { + if strings.Contains(s, v) { + s = strings.ReplaceAll(s, v, "[REDACTED]") + } + } + return s +} + +type handler struct { + next slog.Handler + r *Redactor +} + +// New returns a JSON logger on w that redacts registered values. +func New(w io.Writer, r *Redactor, level slog.Level) *slog.Logger { + return slog.New(&handler{next: slog.NewJSONHandler(w, &slog.HandlerOptions{Level: level}), r: r}) +} + +func (h *handler) Enabled(ctx context.Context, l slog.Level) bool { return h.next.Enabled(ctx, l) } + +func (h *handler) Handle(ctx context.Context, rec slog.Record) error { + out := slog.NewRecord(rec.Time, rec.Level, h.r.Clean(rec.Message), rec.PC) + rec.Attrs(func(a slog.Attr) bool { + out.AddAttrs(h.clean(a)) + return true + }) + return h.next.Handle(ctx, out) +} + +func (h *handler) clean(a slog.Attr) slog.Attr { + if a.Value.Kind() == slog.KindGroup { + attrs := a.Value.Group() + cleaned := make([]any, 0, len(attrs)) + for _, g := range attrs { + cleaned = append(cleaned, h.clean(g)) + } + return slog.Group(a.Key, cleaned...) + } + return slog.String(a.Key, h.r.Clean(a.Value.String())) +} + +func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { + cleaned := make([]slog.Attr, 0, len(attrs)) + for _, a := range attrs { + cleaned = append(cleaned, h.clean(a)) + } + return &handler{next: h.next.WithAttrs(cleaned), r: h.r} +} + +func (h *handler) WithGroup(name string) slog.Handler { + return &handler{next: h.next.WithGroup(name), r: h.r} +} diff --git a/integrations/runner/internal/logx/logx_test.go b/integrations/runner/internal/logx/logx_test.go new file mode 100644 index 000000000..2bd3df9f2 --- /dev/null +++ b/integrations/runner/internal/logx/logx_test.go @@ -0,0 +1,23 @@ +package logx + +import ( + "bytes" + "errors" + "log/slog" + "strings" + "testing" +) + +func TestRegisteredValuesNeverReachTheLog(t *testing.T) { + var buf bytes.Buffer + r := NewRedactor() + log := New(&buf, r, slog.LevelInfo) + r.Forget("hunter2-secret") + log.With("ctx", "hunter2-secret").Info("value hunter2-secret leaked", "error", errors.New("login hunter2-secret refused").Error(), slog.Group("g", "v", "hunter2-secret")) + if strings.Contains(buf.String(), "hunter2-secret") { + t.Fatalf("log carries the value: %s", buf.String()) + } + if strings.Count(buf.String(), "[REDACTED]") != 4 { + t.Fatalf("want 4 redactions: %s", buf.String()) + } +} diff --git a/integrations/runner/internal/rotate/rotate.go b/integrations/runner/internal/rotate/rotate.go new file mode 100644 index 000000000..00245bd8c --- /dev/null +++ b/integrations/runner/internal/rotate/rotate.go @@ -0,0 +1,300 @@ +// Package rotate changes a credential at its target and only then records it +// in Keepiq (prove-then-record), with a journal so a crash never loses the +// only copy of a new value. +package rotate + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "log/slog" + "math/big" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + kcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" +) + +// Keepiq is the part of the Go library the runner uses. +type Keepiq interface { + GetByNameIfNoneMatch(name, folder, etag string) (*keepiq.Secret, error) + UpdateIfMatch(id, etag string, fields map[string]string) (*keepiq.Secret, error) + PublicKey() *rsa.PublicKey + Decrypt(ciphertext string) (string, error) +} + +// Credential is a target login. +type Credential struct { + User string + Password string +} + +// Connector changes and proves a password at one target. +type Connector interface { + // Set changes user's password from current to next, as the admin. + Set(ctx context.Context, admin Credential, user, current, next string) error + // Login proves user can log in with password. + Login(ctx context.Context, user, password string) error +} + +// ConnectorFactory builds the connector of a rotation. +type ConnectorFactory func(r config.Rotation) (Connector, error) + +// Errors a rotation can end with. +var ( + // ErrProofFailed: the target took the new value but refused a login with + // it; the old value was set back and Keepiq is unchanged. + ErrProofFailed = errors.New("the target refused a login with the new value; the old value was set back") + // ErrConflict: the secret changed in Keepiq during the rotation (412); + // the old value was set back at the target and the write was not retried. + ErrConflict = errors.New("the secret changed in Keepiq during the rotation; the old value was set back at the target") +) + +// Rotator runs rotations. +type Rotator struct { + Keepiq Keepiq + State *state.Dir + Connectors ConnectorFactory + Log *slog.Logger + Redactor *logx.Redactor + Now func() time.Time + + // AfterTargetSet runs right after the target accepted the new value. Tests + // use it to stop the process at the worst moment. + AfterTargetSet func() +} + +func (r *Rotator) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} + +func (r *Rotator) forget(v string) { + if r.Redactor != nil { + r.Redactor.Forget(v) + } +} + +// Rotate runs one rotation now. +func (r *Rotator) Rotate(ctx context.Context, rot config.Rotation) error { + log := r.Log.With("secret", rot.Secret, "connector", rot.Connector) + current, err := r.Keepiq.GetByNameIfNoneMatch(rot.Secret, rot.Folder, "") + if err != nil { + return fmt.Errorf("read %s: %w", rot.Secret, err) + } + r.forget(current.Key) + user := rot.Target["user"] + if user == "" { + user = current.Login + } + if user == "" { + return fmt.Errorf("%s: no user: set target.user or the secret's login", rot.Secret) + } + admin, err := r.admin(rot) + if err != nil { + return err + } + conn, err := r.Connectors(rot) + if err != nil { + return err + } + next, err := Generate(rot.Generator) + if err != nil { + return err + } + r.forget(next) + + entry, err := r.journal(current, rot, user, next) + if err != nil { + return fmt.Errorf("journal: %w", err) + } + if err := conn.Set(ctx, admin, user, current.Key, next); err != nil { + _ = r.State.DeleteJournal(current.ID) + return fmt.Errorf("%s: the target refused the change: %w", rot.Secret, err) + } + entry.Stage = "set" + if err := r.State.PutJournal(entry); err != nil { + log.Error("could not mark the journal entry; recovery will prove the login instead", "error", err.Error()) + } + if r.AfterTargetSet != nil { + r.AfterTargetSet() + } + if err := conn.Login(ctx, user, next); err != nil { + if serr := conn.Set(ctx, admin, user, next, current.Key); serr != nil { + log.Error("set-back failed; the journal keeps both values encrypted", "error", serr.Error()) + return fmt.Errorf("%s: %w; set-back also failed: %v", rot.Secret, ErrProofFailed, serr) + } + _ = r.State.DeleteJournal(current.ID) + log.Warn("rotation not recorded: the new value did not log in", "error", err.Error()) + return fmt.Errorf("%s: %w", rot.Secret, ErrProofFailed) + } + return r.writeBack(ctx, conn, admin, entry, current.Key, next, log) +} + +// writeBack records the proven value in Keepiq with If-Match. +func (r *Rotator) writeBack(ctx context.Context, conn Connector, admin Credential, entry state.JournalEntry, old, next string, log *slog.Logger) error { + _, err := r.Keepiq.UpdateIfMatch(entry.SecretID, entry.ETag, map[string]string{"key": next}) + switch { + case err == nil: + if derr := r.State.DeleteJournal(entry.SecretID); derr != nil { + log.Error("could not remove the journal entry", "error", derr.Error()) + } + log.Info("rotated", "secretId", entry.SecretID) + return nil + case errors.Is(err, keepiq.ErrPreconditionFailed): + if serr := conn.Set(ctx, admin, entry.User, next, old); serr != nil { + log.Error("conflict, and the set-back failed; the journal keeps both values encrypted", "error", serr.Error()) + return fmt.Errorf("%s: %w; set-back failed: %v", entry.Name, ErrConflict, serr) + } + _ = r.State.DeleteJournal(entry.SecretID) + log.Error("conflict: the secret changed in Keepiq during the rotation", "secretId", entry.SecretID) + return fmt.Errorf("%s: %w", entry.Name, ErrConflict) + default: + // Keep the journal entry: the next start retries the write-back. + log.Error("write-back failed; the journal entry stays for the next start", "error", err.Error()) + return fmt.Errorf("%s: write-back: %w", entry.Name, err) + } +} + +func (r *Rotator) journal(current *keepiq.Secret, rot config.Rotation, user, next string) (state.JournalEntry, error) { + pub := r.Keepiq.PublicKey() + newCT, err := kcrypto.EncryptField(next, pub) + if err != nil { + return state.JournalEntry{}, err + } + oldCT, err := kcrypto.EncryptField(current.Key, pub) + if err != nil { + return state.JournalEntry{}, err + } + e := state.JournalEntry{ + SecretID: current.ID, Name: rot.Secret, Folder: rot.Folder, ETag: current.ETag, User: user, + NewValue: newCT, OldValue: oldCT, Stage: "pending", CreatedAt: r.now().UTC(), + } + return e, r.State.PutJournal(e) +} + +func (r *Rotator) admin(rot config.Rotation) (Credential, error) { + if rot.AdminSecret == "" { + return Credential{}, nil + } + s, err := r.Keepiq.GetByNameIfNoneMatch(rot.AdminSecret, rot.Folder, "") + if err != nil { + return Credential{}, fmt.Errorf("read admin secret %s: %w", rot.AdminSecret, err) + } + r.forget(s.Key) + return Credential{User: s.Login, Password: s.Key}, nil +} + +// Recover completes the rotations a previous run left in the journal. An +// entry whose new value logs in at the target is written back; one whose new +// value does not (the target never changed, or was set back) is dropped. +func (r *Rotator) Recover(ctx context.Context, rotations []config.Rotation) error { + entries, err := r.State.Journal() + if err != nil { + return err + } + var errs []error + for _, e := range entries { + log := r.Log.With("secret", e.Name, "secretId", e.SecretID, "recovery", true) + rot, ok := find(rotations, e.Name, e.Folder) + if !ok { + log.Error("journal entry for a rotation no longer configured; left in place") + errs = append(errs, fmt.Errorf("%s: no rotation configured for the journal entry", e.Name)) + continue + } + next, err := r.Keepiq.Decrypt(e.NewValue) + if err != nil { + errs = append(errs, fmt.Errorf("%s: journal does not decrypt with this key: %w", e.Name, err)) + continue + } + old, err := r.Keepiq.Decrypt(e.OldValue) + if err != nil { + errs = append(errs, fmt.Errorf("%s: journal does not decrypt with this key: %w", e.Name, err)) + continue + } + r.forget(next) + r.forget(old) + conn, err := r.Connectors(rot) + if err != nil { + errs = append(errs, err) + continue + } + if err := conn.Login(ctx, e.User, next); err != nil { + log.Info("the new value never reached the target; dropping the journal entry") + _ = r.State.DeleteJournal(e.SecretID) + continue + } + admin, err := r.admin(rot) + if err != nil { + errs = append(errs, err) + continue + } + log.Info("completing an interrupted rotation") + if err := r.writeBack(ctx, conn, admin, e, old, next, log); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func find(rotations []config.Rotation, name, folder string) (config.Rotation, bool) { + for _, r := range rotations { + if r.Secret == name && r.Folder == folder { + return r, true + } + } + return config.Rotation{}, false +} + +const ( + lower = "abcdefghijkmnopqrstuvwxyz" + upper = "ABCDEFGHJKLMNPQRSTUVWXYZ" + digits = "23456789" + symbols = "!#%+,-.:=@^_~" +) + +// Generate makes a password from crypto/rand with at least one character of +// every class. The symbol set avoids quotes, backslashes and spaces, so a +// value never needs escaping in a shell, SQL or a connection string. +func Generate(g config.Generator) (string, error) { + sets := map[string]string{"lower": lower, "upper": upper, "digits": digits, "symbols": symbols} + var alphabet string + var required []string + for _, c := range g.Classes { + alphabet += sets[c] + required = append(required, sets[c]) + } + if alphabet == "" || g.Length < len(required) { + return "", errors.New("generator: no character classes, or length below the number of classes") + } + out := make([]byte, g.Length) + for i := range out { + set := alphabet + if i < len(required) { + set = required[i] + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(set)))) + if err != nil { + return "", err + } + out[i] = set[n.Int64()] + } + // Shuffle so the required characters are not always first. + for i := len(out) - 1; i > 0; i-- { + n, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + return "", err + } + j := n.Int64() + out[i], out[j] = out[j], out[i] + } + return string(out), nil +} diff --git a/integrations/runner/internal/rotate/rotate_test.go b/integrations/runner/internal/rotate/rotate_test.go new file mode 100644 index 000000000..9f27b3d53 --- /dev/null +++ b/integrations/runner/internal/rotate/rotate_test.go @@ -0,0 +1,348 @@ +package rotate + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + kcrypto "github.com/ConductionNL/keepiq/sdk/go/crypto" + "github.com/ConductionNL/keepiq/sdk/go/keepiqtest" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" +) + +// fileTarget is a fake rotation target whose password lives in a file, so a +// second process (after a crash) sees what the first one set. +type fileTarget struct { + path string + refuseNew bool // Login refuses any value but the original + original string + onSet func() // runs inside Set, after the change + setCalls int + adminSeen Credential +} + +func (f *fileTarget) Set(_ context.Context, admin Credential, _ string, current, next string) error { + f.setCalls++ + f.adminSeen = admin + have, _ := os.ReadFile(f.path) + if string(have) != current { + return errors.New("current password does not match the target") + } + if err := os.WriteFile(f.path, []byte(next), 0o600); err != nil { + return err + } + if f.onSet != nil { + f.onSet() + } + return nil +} + +func (f *fileTarget) Login(_ context.Context, _ string, password string) error { + have, _ := os.ReadFile(f.path) + if string(have) != password || (f.refuseNew && password != f.original) { + return errors.New("login refused") + } + return nil +} + +func (f *fileTarget) value() string { + b, _ := os.ReadFile(f.path) + return string(b) +} + +const oldPassword = "old-target-password-1" + +type env struct { + stub *keepiqtest.Stub + kc *keepiq.Client + dir *state.Dir + target *fileTarget + logs *bytes.Buffer + rot *Rotator + cfg config.Rotation +} + +func setup(t *testing.T, stub *keepiqtest.Stub, stateDir, targetFile string) *env { + t.Helper() + if stub == nil { + var err error + if stub, err = keepiqtest.Start("ops-runner"); err != nil { + t.Fatal(err) + } + t.Cleanup(stub.Close) + if _, err := stub.Add("sec-pg", "pg-app-password", "", map[string]string{"key": oldPassword, "login": "app"}); err != nil { + t.Fatal(err) + } + if _, err := stub.Add("sec-admin", "pg-admin", "", map[string]string{"key": "admin-password-9", "login": "postgres"}); err != nil { + t.Fatal(err) + } + } + kc, err := keepiq.New(stub.URL(), "ops-runner", stub.Fixture.PrivateKeyPem) + if err != nil { + t.Fatal(err) + } + if stateDir == "" { + stateDir = t.TempDir() + } + dir, err := state.Open(stateDir) + if err != nil { + t.Fatal(err) + } + if targetFile == "" { + targetFile = filepath.Join(t.TempDir(), "target") + _ = os.WriteFile(targetFile, []byte(oldPassword), 0o600) + } + target := &fileTarget{path: targetFile, original: oldPassword} + logs := &bytes.Buffer{} + red := logx.NewRedactor() + e := &env{stub: stub, kc: kc, dir: dir, target: target, logs: logs, + cfg: config.Rotation{Secret: "pg-app-password", Connector: "test", AdminSecret: "pg-admin", + Generator: config.Generator{Length: 32, Classes: []string{"lower", "upper", "digits", "symbols"}}}} + e.rot = &Rotator{Keepiq: kc, State: dir, Log: logx.New(logs, red, slog.LevelDebug), Redactor: red, + Connectors: func(config.Rotation) (Connector, error) { return target, nil }} + return e +} + +func (e *env) stored(t *testing.T) string { + v, err := e.stub.Plain("sec-pg", "key") + if err != nil { + t.Fatal(err) + } + return v +} + +func (e *env) noLeaks(t *testing.T, values ...string) { + t.Helper() + files, _ := filepath.Glob(filepath.Join(e.dir.Path, "*", "*")) + more, _ := filepath.Glob(filepath.Join(e.dir.Path, "*")) + for _, v := range append(values, "admin-password-9") { + if strings.Contains(e.logs.String(), v) { + t.Fatalf("log carries %q:\n%s", v, e.logs.String()) + } + if e.stub.BodiesContain(v) { + t.Fatalf("a request to Keepiq carries %q", v) + } + for _, f := range append(files, more...) { + if b, err := os.ReadFile(f); err == nil && strings.Contains(string(b), v) { + t.Fatalf("state file %s carries %q", f, v) + } + } + } +} + +func TestRotationProvesThenRecords(t *testing.T) { + e := setup(t, nil, "", "") + if err := e.rot.Rotate(context.Background(), e.cfg); err != nil { + t.Fatal(err) + } + next := e.target.value() + if next == oldPassword || len(next) != 32 { + t.Fatalf("target value %q", next) + } + if got := e.stored(t); got != next { + t.Fatalf("Keepiq holds %q, target %q", got, next) + } + if e.target.Login(context.Background(), "app", oldPassword) == nil { + t.Fatal("the old password still logs in") + } + if e.target.adminSeen.User != "postgres" || e.target.adminSeen.Password != "admin-password-9" { + t.Fatalf("admin credential %+v", e.target.adminSeen) + } + if j, _ := e.dir.Journal(); len(j) != 0 { + t.Fatalf("journal left: %+v", j) + } + e.noLeaks(t, next, oldPassword) +} + +func TestFailedProofKeepsTheOldCredential(t *testing.T) { + e := setup(t, nil, "", "") + e.target.refuseNew = true + etag := e.stub.ETagOf("sec-pg") + err := e.rot.Rotate(context.Background(), e.cfg) + if !errors.Is(err, ErrProofFailed) { + t.Fatalf("want ErrProofFailed, got %v", err) + } + if e.target.value() != oldPassword { + t.Fatalf("target not set back: %q", e.target.value()) + } + if e.stub.PutCount != 0 || e.stub.ETagOf("sec-pg") != etag || e.stored(t) != oldPassword { + t.Fatal("Keepiq changed after a failed proof") + } + if j, _ := e.dir.Journal(); len(j) != 0 { + t.Fatal("journal left after a set-back") + } +} + +func TestConcurrentChangeIsNeverOverwritten(t *testing.T) { + e := setup(t, nil, "", "") + e.target.onSet = func() { + e.target.onSet = nil // only during the rotation's own change + if err := e.stub.SetValue("sec-pg", "key", "changed-by-a-human"); err != nil { + t.Fatal(err) + } + } + err := e.rot.Rotate(context.Background(), e.cfg) + if !errors.Is(err, ErrConflict) { + t.Fatalf("want ErrConflict, got %v", err) + } + if e.target.value() != oldPassword { + t.Fatalf("target not restored: %q", e.target.value()) + } + if e.stored(t) != "changed-by-a-human" || e.stub.PutCount != 0 { + t.Fatal("the runner overwrote the concurrent change") + } + if !strings.Contains(e.logs.String(), "conflict") || !strings.Contains(e.logs.String(), "pg-app-password") { + t.Fatalf("no conflict logged for the secret: %s", e.logs.String()) + } +} + +// TestCrashAfterTargetChangeIsCompletedOnNextStart runs the rotation in a +// child process that exits right after the target accepted the new value, +// then starts again here and checks the journal completes the write-back. +func TestCrashAfterTargetChangeIsCompletedOnNextStart(t *testing.T) { + if os.Getenv("KEEPIQ_RUNNER_CRASH_CHILD") == "1" { + return + } + e := setup(t, nil, "", "") + cmd := exec.Command(os.Args[0], "-test.run", "^TestCrashChild$") + cmd.Env = append(os.Environ(), "KEEPIQ_RUNNER_CRASH_CHILD=1", "KEEPIQ_STUB_URL="+e.stub.URL(), + "KEEPIQ_STATE_DIR="+e.dir.Path, "KEEPIQ_TARGET_FILE="+e.target.path) + out, err := cmd.CombinedOutput() + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 3 { + t.Fatalf("child did not crash as planned: %v\n%s", err, out) + } + next := e.target.value() + if next == oldPassword { + t.Fatal("the child never changed the target") + } + if e.stored(t) != oldPassword { + t.Fatal("the child wrote back before crashing") + } + j, _ := e.dir.Journal() + if len(j) != 1 || j[0].Stage != "set" { + t.Fatalf("journal after the crash: %+v", j) + } + e.noLeaks(t, next) + + // Next start. + if err := e.rot.Recover(context.Background(), []config.Rotation{e.cfg}); err != nil { + t.Fatal(err) + } + if e.stored(t) != next { + t.Fatalf("recovery did not write back: Keepiq %q, target %q", e.stored(t), next) + } + if j, _ := e.dir.Journal(); len(j) != 0 { + t.Fatal("journal entry not removed after recovery") + } +} + +// TestCrashChild is the child half of the crash test. +func TestCrashChild(t *testing.T) { + if os.Getenv("KEEPIQ_RUNNER_CRASH_CHILD") != "1" { + t.Skip("child process of TestCrashAfterTargetChangeIsCompletedOnNextStart") + } + f, err := keepiqtest.LoadFixture() + if err != nil { + t.Fatal(err) + } + kc, err := keepiq.New(os.Getenv("KEEPIQ_STUB_URL"), "ops-runner", f.PrivateKeyPem) + if err != nil { + t.Fatal(err) + } + dir, _ := state.Open(os.Getenv("KEEPIQ_STATE_DIR")) + target := &fileTarget{path: os.Getenv("KEEPIQ_TARGET_FILE"), original: oldPassword} + r := &Rotator{Keepiq: kc, State: dir, Log: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + Connectors: func(config.Rotation) (Connector, error) { return target, nil }, + AfterTargetSet: func() { os.Exit(3) }} + _ = r.Rotate(context.Background(), config.Rotation{Secret: "pg-app-password", Connector: "test", AdminSecret: "pg-admin", + Generator: config.Generator{Length: 24, Classes: []string{"lower", "digits"}}}) + t.Fatal("the child should have exited inside the rotation") +} + +// A journal entry whose new value never reached the target is dropped, and +// Keepiq keeps the old value. +func TestRecoveryDropsAnEntryThatNeverReachedTheTarget(t *testing.T) { + e := setup(t, nil, "", "") + e.target.onSet = func() { panic("no set expected") } + ct, _ := keepiqEncrypt(e.kc, "never-set-value") + old, _ := keepiqEncrypt(e.kc, oldPassword) + _ = e.dir.PutJournal(state.JournalEntry{SecretID: "sec-pg", Name: "pg-app-password", ETag: e.stub.ETagOf("sec-pg"), User: "app", + NewValue: ct, OldValue: old, Stage: "pending", CreatedAt: time.Now()}) + if err := e.rot.Recover(context.Background(), []config.Rotation{e.cfg}); err != nil { + t.Fatal(err) + } + if e.stored(t) != oldPassword || e.stub.PutCount != 0 { + t.Fatal("recovery wrote a value the target never took") + } + if j, _ := e.dir.Journal(); len(j) != 0 { + t.Fatal("entry not dropped") + } +} + +func TestGenerate(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 50; i++ { + v, err := Generate(config.Generator{Length: 20, Classes: []string{"lower", "upper", "digits", "symbols"}}) + if err != nil { + t.Fatal(err) + } + if len(v) != 20 || seen[v] { + t.Fatalf("value %q", v) + } + seen[v] = true + if !strings.ContainsAny(v, lower) || !strings.ContainsAny(v, upper) || !strings.ContainsAny(v, digits) || !strings.ContainsAny(v, symbols) { + t.Fatalf("%q misses a class", v) + } + if strings.ContainsAny(v, "'\"\\ `$") { + t.Fatalf("%q holds a character that needs escaping", v) + } + } +} + +func TestDueBySchedule(t *testing.T) { + rot := config.Rotation{Schedule: "0 3 * * 0"} // Sundays 03:00 + start := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) // a Thursday + if due, _ := Due(rot, stateAt(time.Time{}), start.Add(time.Hour), start, ""); due { + t.Fatal("due before the first Sunday") + } + sunday := time.Date(2026, 10, 4, 3, 0, 30, 0, time.UTC) + if due, why := Due(rot, stateAt(time.Time{}), sunday, start, ""); !due || why != "schedule" { + t.Fatal("not due on Sunday 03:00") + } + if due, _ := Due(rot, stateAt(sunday), sunday.Add(time.Hour), start, ""); due { + t.Fatal("due twice in one week") + } +} + +func TestDueByExpiryLeadTime(t *testing.T) { + rot := config.Rotation{FollowExpiry: true, LeadTime: config.Duration{Duration: 7 * 24 * time.Hour}} + now := time.Date(2026, 11, 26, 0, 0, 0, 0, time.UTC) + if due, why := Due(rot, stateAt(time.Time{}), now, now, "2026-12-01T00:00:00+00:00"); !due || why != "expiry" { + t.Fatal("not due 5 days before expiry with a 7-day lead") + } + if due, _ := Due(rot, stateAt(time.Time{}), now, now, "2026-12-10T00:00:00+00:00"); due { + t.Fatal("due 14 days before expiry") + } + st := state.RotationState{LastRotated: now} + if due, _ := Due(rot, st, now.Add(time.Hour), now, "2026-12-01T00:00:00+00:00"); due { + t.Fatal("due again in the same expiry window") + } +} + +func stateAt(lastRun time.Time) state.RotationState { return state.RotationState{LastRun: lastRun} } + +func keepiqEncrypt(kc *keepiq.Client, v string) (string, error) { + return kcrypto.EncryptField(v, kc.PublicKey()) +} diff --git a/integrations/runner/internal/rotate/schedule.go b/integrations/runner/internal/rotate/schedule.go new file mode 100644 index 000000000..5706a0104 --- /dev/null +++ b/integrations/runner/internal/rotate/schedule.go @@ -0,0 +1,41 @@ +package rotate + +import ( + "time" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" +) + +// Due reports whether a rotation should run at now. +// +// - Schedule: it runs when the cron schedule fired since the last run (or +// since since, the runner's first start, so a fresh install does not +// rotate everything at once). +// - followExpiry: it runs once the secret's expiresAt is within the lead +// time, and not again for the same expiry window. The write-back does not +// move expiresAt, so the window is remembered as lastRotated. +func Due(rot config.Rotation, st state.RotationState, now, since time.Time, expiresAt string) (bool, string) { + if rot.Schedule != "" { + sched, err := config.CronParser.Parse(rot.Schedule) + if err == nil { + from := st.LastRun + if from.IsZero() { + from = since + } + if next := sched.Next(from); !next.After(now) { + return true, "schedule" + } + } + } + if rot.FollowExpiry && expiresAt != "" { + exp, err := time.Parse(time.RFC3339, expiresAt) + if err == nil { + windowStart := exp.Add(-rot.LeadTime.Duration) + if !now.Before(windowStart) && st.LastRotated.Before(windowStart) { + return true, "expiry" + } + } + } + return false, "" +} diff --git a/integrations/runner/internal/runner/runner.go b/integrations/runner/internal/runner/runner.go new file mode 100644 index 000000000..b52f43d54 --- /dev/null +++ b/integrations/runner/internal/runner/runner.go @@ -0,0 +1,136 @@ +// Package runner ties rotations and syncs to one Keepiq application and runs +// them once or as a daemon. +package runner + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" + "github.com/ConductionNL/keepiq/integrations/runner/internal/syncer" +) + +// Client is the Keepiq surface the runner needs (keepiq.Client has it). +type Client interface { + rotate.Keepiq + syncer.Keepiq +} + +// Runner is one configured runner. +type Runner struct { + Config *config.Config + Keepiq Client + State *state.Dir + Rotator *rotate.Rotator + Syncer *syncer.Syncer + Log *slog.Logger + Now func() time.Time + + started time.Time + meta map[string]meta + lastSync map[string]time.Time +} + +type meta struct{ etag, expiresAt string } + +// Start recovers interrupted rotations from the journal. Call it once before +// RunOnce or Daemon. +func (r *Runner) Start(ctx context.Context) error { + r.started = r.Now() + r.meta = map[string]meta{} + r.lastSync = map[string]time.Time{} + return r.Rotator.Recover(ctx, r.Config.Rotations) +} + +// Tick runs every rotation that is due and every sync whose interval passed. +// once makes a rotation that never ran count as due (for cron or CronJob use). +func (r *Runner) Tick(ctx context.Context, once bool) error { + now := r.Now() + states, err := r.State.LoadRotations() + if err != nil { + return err + } + var errs []error + for _, rot := range r.Config.Rotations { + key := rot.Folder + "/" + rot.Secret + st := states[key] + since := r.started + if once && st.LastRun.IsZero() { + since = time.Time{} + } + expiresAt := "" + if rot.FollowExpiry { + if expiresAt, err = r.expiresAt(rot); err != nil { + errs = append(errs, err) + continue + } + } + due, why := rotate.Due(rot, st, now, since, expiresAt) + if once && st.LastRun.IsZero() && rot.Schedule != "" { + due, why = true, "first run" + } + if !due { + continue + } + r.Log.Info("rotation due", "secret", rot.Secret, "reason", why) + st.LastRun = now + if err := r.Rotator.Rotate(ctx, rot); err != nil { + errs = append(errs, err) + } else { + st.LastRotated = now + delete(r.meta, key) + } + states[key] = st + if err := r.State.SaveRotations(states); err != nil { + errs = append(errs, err) + } + } + for _, sc := range r.Config.Syncs { + if last, ok := r.lastSync[sc.Name]; ok && !once && now.Sub(last) < sc.Interval.Duration { + continue + } + r.lastSync[sc.Name] = now + if err := r.Syncer.RunOnce(ctx, sc); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// expiresAt reads a rotated secret's expiry date, reusing the last read on 304. +func (r *Runner) expiresAt(rot config.Rotation) (string, error) { + key := rot.Folder + "/" + rot.Secret + m := r.meta[key] + s, err := r.Keepiq.GetByNameIfNoneMatch(rot.Secret, rot.Folder, m.etag) + if errors.Is(err, keepiq.ErrNotModified) { + return m.expiresAt, nil + } + if err != nil { + return "", fmt.Errorf("read %s: %w", rot.Secret, err) + } + r.meta[key] = meta{etag: s.ETag, expiresAt: s.ExpiresAt} + return s.ExpiresAt, nil +} + +// Daemon ticks until ctx ends. Errors are logged, never fatal. +func (r *Runner) Daemon(ctx context.Context) error { + t := time.NewTicker(r.Config.Tick.Duration) + defer t.Stop() + for { + if err := r.Tick(ctx, false); err != nil { + r.Log.Error("tick finished with errors", "error", err.Error()) + } + select { + case <-ctx.Done(): + return nil + case <-t.C: + } + } +} diff --git a/integrations/runner/internal/runner/runner_test.go b/integrations/runner/internal/runner/runner_test.go new file mode 100644 index 000000000..e630d0eb9 --- /dev/null +++ b/integrations/runner/internal/runner/runner_test.go @@ -0,0 +1,129 @@ +package runner + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + "github.com/ConductionNL/keepiq/sdk/go/keepiqtest" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/rotate" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" + "github.com/ConductionNL/keepiq/integrations/runner/internal/syncer" +) + +type memTarget struct{ pw string } + +func (m *memTarget) Set(_ context.Context, _ rotate.Credential, _, current, next string) error { + if current != m.pw { + return errors.New("mismatch") + } + m.pw = next + return nil +} +func (m *memTarget) Login(_ context.Context, _, pw string) error { + if pw != m.pw { + return errors.New("refused") + } + return nil +} + +type memDest struct{ got map[string]string } + +func (d *memDest) Push(_ context.Context, n, v string) error { d.got[n] = v; return nil } + +func build(t *testing.T, now *time.Time) (*Runner, *keepiqtest.Stub, *memTarget, *memDest, *bytes.Buffer) { + stub, err := keepiqtest.Start("ops-runner") + if err != nil { + t.Fatal(err) + } + t.Cleanup(stub.Close) + _, _ = stub.Add("sec-pg", "pg-app-password", "", map[string]string{"key": "initial-pg-1", "login": "app"}) + _, _ = stub.Add("sec-stripe", "stripe-key", "", map[string]string{"key": "sk_live_one"}) + kc, _ := keepiq.New(stub.URL(), "ops-runner", stub.Fixture.PrivateKeyPem) + cfg, err := config.Parse([]byte(` +keepiq: {url: x://y, applicationId: ops-runner, privateKeyFile: /k} +stateDir: /s +rotations: + - {secret: pg-app-password, connector: exec, command: [x], schedule: "0 3 * * 0", followExpiry: true} +syncs: + - {secrets: [stripe-key], destination: exec, command: [x]} +`)) + if err != nil { + t.Fatal(err) + } + dir, _ := state.Open(t.TempDir()) + target := &memTarget{pw: "initial-pg-1"} + dest := &memDest{got: map[string]string{}} + logs := &bytes.Buffer{} + red := logx.NewRedactor() + log := logx.New(logs, red, slog.LevelDebug) + clock := func() time.Time { return *now } + r := &Runner{Config: cfg, Keepiq: kc, State: dir, Log: log, Now: clock, + Rotator: &rotate.Rotator{Keepiq: kc, State: dir, Log: log, Redactor: red, Now: clock, + Connectors: func(config.Rotation) (rotate.Connector, error) { return target, nil }}, + Syncer: &syncer.Syncer{Keepiq: kc, State: dir, Log: log, Redactor: red, Now: clock, + Destinations: func(config.Sync, *keepiq.Secret) (syncer.Destination, error) { return dest, nil }}} + if err := r.Start(context.Background()); err != nil { + t.Fatal(err) + } + return r, stub, target, dest, logs +} + +// run --once rotates a scheduled rotation that never ran and runs every sync. +func TestOnceRotatesAndSyncs(t *testing.T) { + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + r, stub, target, dest, logs := build(t, &now) + if err := r.Tick(context.Background(), true); err != nil { + t.Fatal(err) + } + stored, _ := stub.Plain("sec-pg", "key") + if target.pw == "initial-pg-1" || stored != target.pw { + t.Fatalf("not rotated: target %q keepiq %q", target.pw, stored) + } + if dest.got["stripe-key"] != "sk_live_one" { + t.Fatalf("not synced: %v", dest.got) + } + for _, v := range []string{target.pw, "initial-pg-1", "sk_live_one"} { + if strings.Contains(logs.String(), v) || stub.BodiesContain(v) { + t.Fatalf("%q leaked", v) + } + } +} + +// The daemon does not rotate at start; it waits for the schedule, or for the +// expiry lead time. +func TestDaemonWaitsForScheduleOrExpiry(t *testing.T) { + now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) // Thursday + r, stub, target, _, _ := build(t, &now) + _ = r.Tick(context.Background(), false) + if target.pw != "initial-pg-1" { + t.Fatal("rotated at start") + } + stub.SetExpiry("sec-pg", "2026-10-05T00:00:00+00:00") // within 7 days + now = now.Add(time.Minute) + if err := r.Tick(context.Background(), false); err != nil { + t.Fatal(err) + } + if target.pw == "initial-pg-1" { + t.Fatal("not rotated ahead of expiry") + } + rotated := target.pw + now = now.Add(time.Minute) + _ = r.Tick(context.Background(), false) + if target.pw != rotated { + t.Fatal("rotated twice in one expiry window") + } + now = time.Date(2026, 10, 4, 3, 0, 10, 0, time.UTC) // Sunday 03:00 + _ = r.Tick(context.Background(), false) + if target.pw == rotated { + t.Fatal("schedule did not fire") + } +} diff --git a/integrations/runner/internal/state/state.go b/integrations/runner/internal/state/state.go new file mode 100644 index 000000000..b415b6c05 --- /dev/null +++ b/integrations/runner/internal/state/state.go @@ -0,0 +1,163 @@ +// Package state is the runner's state directory: the rotation journal (values +// as ciphertext only), and the schedule and sync bookkeeping (ids, names, +// times and ETags only). Files are written atomically with mode 0600. +package state + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "sort" + "time" +) + +// Dir is one state directory. +type Dir struct{ Path string } + +// Open creates the directory (0700) if needed. +func Open(path string) (*Dir, error) { + if err := os.MkdirAll(filepath.Join(path, "journal"), 0o700); err != nil { + return nil, err + } + return &Dir{Path: path}, nil +} + +// JournalEntry is one rotation in flight. NewValue and OldValue are +// encrypted to the application's own key; nothing else is secret. +type JournalEntry struct { + SecretID string `json:"secretId"` + Name string `json:"name"` + Folder string `json:"folder,omitempty"` + ETag string `json:"etag"` + User string `json:"user"` + NewValue string `json:"newValueCiphertext"` + OldValue string `json:"oldValueCiphertext"` + Stage string `json:"stage"` // pending (before the target change) or set (target changed) + CreatedAt time.Time `json:"createdAt"` +} + +var unsafe = regexp.MustCompile(`[^A-Za-z0-9._-]`) + +func (d *Dir) journalPath(secretID string) string { + return filepath.Join(d.Path, "journal", unsafe.ReplaceAllString(secretID, "_")+".json") +} + +// PutJournal writes or replaces an entry. +func (d *Dir) PutJournal(e JournalEntry) error { return d.write(d.journalPath(e.SecretID), e) } + +// DeleteJournal removes an entry. +func (d *Dir) DeleteJournal(secretID string) error { + err := os.Remove(d.journalPath(secretID)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// Journal lists the entries, oldest first. +func (d *Dir) Journal() ([]JournalEntry, error) { + files, err := filepath.Glob(filepath.Join(d.Path, "journal", "*.json")) + if err != nil { + return nil, err + } + var out []JournalEntry + for _, f := range files { + var e JournalEntry + if err := d.read(f, &e); err != nil { + return nil, err + } + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out, nil +} + +// Rotations is the schedule bookkeeping, keyed by folder/name. +type Rotations map[string]RotationState + +// RotationState says when a rotation last ran and last succeeded. +type RotationState struct { + LastRun time.Time `json:"lastRun"` + LastRotated time.Time `json:"lastRotated"` +} + +// LoadRotations reads rotations.json (empty when missing). +func (d *Dir) LoadRotations() (Rotations, error) { + r := Rotations{} + err := d.read(filepath.Join(d.Path, "rotations.json"), &r) + if errors.Is(err, os.ErrNotExist) { + return Rotations{}, nil + } + return r, err +} + +// SaveRotations writes rotations.json. +func (d *Dir) SaveRotations(r Rotations) error { + return d.write(filepath.Join(d.Path, "rotations.json"), r) +} + +// SyncState is one sync set's bookkeeping. +type SyncState struct { + LastPoll time.Time `json:"lastPoll"` + Entries map[string]EntryState `json:"entries"` +} + +// EntryState is one destination entry: the ETag last pushed, and retry state. +type EntryState struct { + PushedETag string `json:"pushedEtag,omitempty"` + Dirty bool `json:"dirty,omitempty"` + Failures int `json:"failures,omitempty"` + NextAttempt time.Time `json:"nextAttempt,omitempty"` +} + +// LoadSync reads sync-.json. +func (d *Dir) LoadSync(name string) (*SyncState, error) { + s := &SyncState{Entries: map[string]EntryState{}} + err := d.read(filepath.Join(d.Path, "sync-"+unsafe.ReplaceAllString(name, "_")+".json"), s) + if errors.Is(err, os.ErrNotExist) { + return &SyncState{Entries: map[string]EntryState{}}, nil + } + if s.Entries == nil { + s.Entries = map[string]EntryState{} + } + return s, err +} + +// SaveSync writes sync-.json. +func (d *Dir) SaveSync(name string, s *SyncState) error { + return d.write(filepath.Join(d.Path, "sync-"+unsafe.ReplaceAllString(name, "_")+".json"), s) +} + +func (d *Dir) read(path string, v any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, v) +} + +func (d *Dir) write(path string, v any) error { + raw, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := f.Write(raw); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/integrations/runner/internal/syncer/syncer.go b/integrations/runner/internal/syncer/syncer.go new file mode 100644 index 000000000..d795820a5 --- /dev/null +++ b/integrations/runner/internal/syncer/syncer.go @@ -0,0 +1,155 @@ +// Package syncer pushes changed Keepiq secrets to cloud secret stores. It +// polls updated_since, reads each changed secret of a sync set, decrypts it in +// the runner, and pushes it. Per destination entry it keeps only the ETag it +// last pushed, so an unchanged secret is never pushed again. +package syncer + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" +) + +// Keepiq is the part of the Go library the syncer uses. +type Keepiq interface { + List(updatedSince time.Time) ([]*keepiq.Secret, error) + GetByNameIfNoneMatch(name, folder, etag string) (*keepiq.Secret, error) +} + +// Destination stores one value under one name. +type Destination interface { + Push(ctx context.Context, name, value string) error +} + +// DestinationFactory builds the destination of a sync set; creds is the +// decrypted credentials secret, or nil for the ambient identity. +type DestinationFactory func(s config.Sync, creds *keepiq.Secret) (Destination, error) + +// Syncer runs sync sets. +type Syncer struct { + Keepiq Keepiq + State *state.Dir + Destinations DestinationFactory + Log *slog.Logger + Redactor *logx.Redactor + Now func() time.Time +} + +// Backoff after n consecutive failures: 30s, 1m, 2m … capped at 30m. +func Backoff(n int) time.Duration { + d := 30 * time.Second + for i := 1; i < n && d < 30*time.Minute; i++ { + d *= 2 + } + if d > 30*time.Minute { + d = 30 * time.Minute + } + return d +} + +// overlap re-reads a little before the last poll, so a write that lands in +// the same second as the poll is not missed; the ETag check makes it free. +const overlap = 5 * time.Second + +// RunOnce polls and pushes one sync set. A failed push is retried later with +// backoff and never blocks the other entries. +func (s *Syncer) RunOnce(ctx context.Context, sc config.Sync) error { + now := s.Now() + st, err := s.State.LoadSync(sc.Name) + if err != nil { + return err + } + log := s.Log.With("sync", sc.Name, "destination", sc.Destination) + + since := time.Time{} + if !st.LastPoll.IsZero() { + since = st.LastPoll.Add(-overlap) + } + changed, err := s.Keepiq.List(since) + if err != nil { + return fmt.Errorf("sync %s: list: %w", sc.Name, err) + } + wanted := map[string]bool{} + for _, n := range sc.Secrets { + wanted[n] = true + } + for _, c := range changed { + if wanted[c.Name] && (sc.Folder == "" || c.FolderPath == sc.Folder) { + e := st.Entries[c.Name] + e.Dirty = true + st.Entries[c.Name] = e + } + } + // A name never pushed yet is dirty too (first run, or newly configured). + for _, n := range sc.Secrets { + if e, ok := st.Entries[n]; !ok || e.PushedETag == "" { + e.Dirty = true + st.Entries[n] = e + } + } + + var dest Destination + var errs []error + for _, name := range sc.Secrets { + e := st.Entries[name] + if !e.Dirty || now.Before(e.NextAttempt) { + continue + } + secret, err := s.Keepiq.GetByNameIfNoneMatch(name, sc.Folder, e.PushedETag) + if errors.Is(err, keepiq.ErrNotModified) { + e.Dirty = false + st.Entries[name] = e + continue + } + if err == nil && dest == nil { + dest, err = s.destination(sc) + } + if err == nil { + s.forget(secret.Key) + err = dest.Push(ctx, name, secret.Key) + } + if err != nil { + e.Failures++ + e.NextAttempt = now.Add(Backoff(e.Failures)) + st.Entries[name] = e + log.Error("push failed; retrying later", "secret", name, "failures", e.Failures, "retryAt", e.NextAttempt.Format(time.RFC3339), "error", err.Error()) + errs = append(errs, fmt.Errorf("%s: %w", name, err)) + continue + } + st.Entries[name] = state.EntryState{PushedETag: secret.ETag} + log.Info("pushed", "secret", name) + } + st.LastPoll = now + if err := s.State.SaveSync(sc.Name, st); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func (s *Syncer) destination(sc config.Sync) (Destination, error) { + var creds *keepiq.Secret + if sc.CredentialsSecret != "" { + c, err := s.Keepiq.GetByNameIfNoneMatch(sc.CredentialsSecret, sc.Folder, "") + if err != nil { + return nil, fmt.Errorf("read credentials %s: %w", sc.CredentialsSecret, err) + } + s.forget(c.Key) + s.forget(c.Login) + creds = c + } + return s.Destinations(sc, creds) +} + +func (s *Syncer) forget(v string) { + if s.Redactor != nil { + s.Redactor.Forget(v) + } +} diff --git a/integrations/runner/internal/syncer/syncer_test.go b/integrations/runner/internal/syncer/syncer_test.go new file mode 100644 index 000000000..6c3b586fc --- /dev/null +++ b/integrations/runner/internal/syncer/syncer_test.go @@ -0,0 +1,132 @@ +package syncer + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" + "github.com/ConductionNL/keepiq/sdk/go/keepiqtest" + + "github.com/ConductionNL/keepiq/integrations/runner/internal/config" + "github.com/ConductionNL/keepiq/integrations/runner/internal/logx" + "github.com/ConductionNL/keepiq/integrations/runner/internal/state" +) + +type recorder struct { + pushes []string + values map[string]string + fail map[string]bool +} + +func (r *recorder) Push(_ context.Context, name, value string) error { + if r.fail[name] { + return errors.New("destination down") + } + r.pushes = append(r.pushes, name) + r.values[name] = value + return nil +} + +func setup(t *testing.T) (*keepiqtest.Stub, *Syncer, *recorder, *bytes.Buffer, *time.Time) { + stub, err := keepiqtest.Start("ops-runner") + if err != nil { + t.Fatal(err) + } + t.Cleanup(stub.Close) + _, _ = stub.Add("sec-stripe", "stripe-key", "", map[string]string{"key": "sk_live_one"}) + _, _ = stub.Add("sec-deploy", "deploy-token", "", map[string]string{"key": "dt_one"}) + _, _ = stub.Add("sec-other", "not-synced", "", map[string]string{"key": "other_one"}) + kc, err := keepiq.New(stub.URL(), "ops-runner", stub.Fixture.PrivateKeyPem) + if err != nil { + t.Fatal(err) + } + dir, _ := state.Open(t.TempDir()) + rec := &recorder{values: map[string]string{}, fail: map[string]bool{}} + logs := &bytes.Buffer{} + red := logx.NewRedactor() + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + s := &Syncer{Keepiq: kc, State: dir, Log: logx.New(logs, red, slog.LevelDebug), Redactor: red, + Now: func() time.Time { return now }, + Destinations: func(config.Sync, *keepiq.Secret) (Destination, error) { return rec, nil }} + return stub, s, rec, logs, &now +} + +var set = config.Sync{Name: "aws-prod", Secrets: []string{"stripe-key", "deploy-token"}, Destination: "exec"} + +func TestChangedSecretIsPushedOnceAndUnchangedNever(t *testing.T) { + stub, s, rec, logs, now := setup(t) + ctx := context.Background() + if err := s.RunOnce(ctx, set); err != nil { + t.Fatal(err) + } + if strings.Join(rec.pushes, ",") != "stripe-key,deploy-token" || rec.values["stripe-key"] != "sk_live_one" { + t.Fatalf("first run pushed %v %v", rec.pushes, rec.values) + } + *now = now.Add(time.Minute) + if err := s.RunOnce(ctx, set); err != nil { + t.Fatal(err) + } + if len(rec.pushes) != 2 { + t.Fatalf("an unchanged secret was pushed again: %v", rec.pushes) + } + _ = stub.SetValue("sec-stripe", "key", "sk_live_two") + _ = stub.SetValue("sec-other", "key", "other_two") + *now = now.Add(time.Minute) + if err := s.RunOnce(ctx, set); err != nil { + t.Fatal(err) + } + if strings.Join(rec.pushes, ",") != "stripe-key,deploy-token,stripe-key" || rec.values["stripe-key"] != "sk_live_two" { + t.Fatalf("after a change pushed %v", rec.pushes) + } + *now = now.Add(time.Minute) + _ = s.RunOnce(ctx, set) + if len(rec.pushes) != 3 { + t.Fatalf("pushed again without a change: %v", rec.pushes) + } + for _, v := range []string{"sk_live_one", "sk_live_two", "dt_one"} { + if strings.Contains(logs.String(), v) || stub.BodiesContain(v) { + t.Fatalf("%q leaked into the log or a request", v) + } + } +} + +func TestFailedPushBacksOffWithoutBlockingOthers(t *testing.T) { + _, s, rec, _, now := setup(t) + ctx := context.Background() + rec.fail["stripe-key"] = true + if err := s.RunOnce(ctx, set); err == nil { + t.Fatal("a failed push must be reported") + } + if strings.Join(rec.pushes, ",") != "deploy-token" { + t.Fatalf("the other entry was blocked: %v", rec.pushes) + } + st, _ := s.State.LoadSync("aws-prod") + e := st.Entries["stripe-key"] + if e.Failures != 1 || !e.Dirty || e.NextAttempt != now.Add(30*time.Second) { + t.Fatalf("retry state %+v", e) + } + rec.fail["stripe-key"] = false + *now = now.Add(10 * time.Second) + _ = s.RunOnce(ctx, set) + if len(rec.pushes) != 1 { + t.Fatal("retried before the backoff ended") + } + *now = now.Add(30 * time.Second) + if err := s.RunOnce(ctx, set); err != nil { + t.Fatal(err) + } + if strings.Join(rec.pushes, ",") != "deploy-token,stripe-key" { + t.Fatalf("not retried after the backoff: %v", rec.pushes) + } +} + +func TestBackoffGrowsAndCaps(t *testing.T) { + if Backoff(1) != 30*time.Second || Backoff(2) != time.Minute || Backoff(20) != 30*time.Minute { + t.Fatal("backoff steps") + } +} diff --git a/integrations/runner/runner.example.yaml b/integrations/runner/runner.example.yaml new file mode 100644 index 000000000..542e0d51a --- /dev/null +++ b/integrations/runner/runner.example.yaml @@ -0,0 +1,38 @@ +# keepiq-runner configuration. It names Keepiq secrets, never values: every +# credential the runner needs is a secret in the same application vault. +keepiq: + url: https://cloud.example.org + applicationId: ops-runner + privateKeyFile: /run/keepiq/key.pem + # certificateFile: /run/keepiq/cert.pem # optional: refuse envelopes for another certificate + +stateDir: /var/lib/keepiq-runner # journal (ciphertext only) and sync state (ETags only) + +rotations: + - secret: pg-app-password # login = the role, key = its password + connector: postgres + target: {host: "db.internal:5432", database: app, sslmode: require} + adminSecret: pg-admin # login = admin role, key = its password + schedule: "0 3 * * 0" # Sundays 03:00 + followExpiry: true # also rotate when expiresAt is within the lead time + leadTime: 168h + generator: {length: 32, classes: [lower, upper, digits, symbols]} + + - secret: legacy-api-password + connector: exec # JSON on stdin, exit code as the answer + command: [/usr/local/bin/rotate-legacy-api] + followExpiry: true + +syncs: + - name: aws-prod + secrets: [stripe-key, deploy-token] + destination: aws-secrets-manager + options: {region: eu-west-1, prefix: prod/} + # credentialsSecret: aws-sync # login = access key id, key = secret key; omit for the default AWS chain + interval: 60s + + - name: github-app + secrets: [deploy-token] + destination: github-actions + options: {repository: example/app} + credentialsSecret: github-token # key = a token that may write Actions secrets diff --git a/integrations/siem/sentinel/README.md b/integrations/siem/sentinel/README.md new file mode 100644 index 000000000..33cf722e2 --- /dev/null +++ b/integrations/siem/sentinel/README.md @@ -0,0 +1,40 @@ +# Keepiq audit events in Microsoft Sentinel + +Keepiq posts each sanitized audit event as one row to the Azure Monitor Logs +Ingestion API. This template creates the table `KeepiqAudit_CL` and the data +collection rule with the stream `Custom-KeepiqAudit` that receive them. + +## Set it up + +1. Create a data collection endpoint in the region of your Sentinel workspace, + or reuse one. Note its logs ingestion URL and its resource id. +2. Deploy the template: + + ```sh + az deployment group create --resource-group --template-file keepiq-dcr.json \ + --parameters workspaceName= dataCollectionEndpointResourceId= + ``` + + The output `dataCollectionRuleImmutableId` is the rule id Keepiq needs. +3. Register an application in Microsoft Entra ID and create a client secret. +4. Give that application one role on the new data collection rule only: + **Monitoring Metrics Publisher**. It needs nothing else. +5. In Nextcloud, open the Keepiq admin settings, add a SIEM sink, pick + **Microsoft Sentinel**, and fill in the logs ingestion URL, the tenant id, + the client id, the rule's immutable id and the client secret. Press + **Test**. + +## What arrives + +| Column | From | +|---|---| +| `TimeGenerated` | the event time | +| `EventType` | for example `suite.revoked` | +| `Category` | the part before the dot | +| `ActorType`, `ActorId` | who did it | +| `ObjectType`, `ObjectId` | what it was done to | +| `Metadata` | the whitelisted metadata (dynamic) | + +No secret value, login, additional field, ciphertext or key ever arrives. +When Keepiq adds a column, update the template too; a Keepiq test keeps the +template and the sender in step. diff --git a/integrations/siem/sentinel/keepiq-dcr.json b/integrations/siem/sentinel/keepiq-dcr.json new file mode 100644 index 000000000..75707f3e1 --- /dev/null +++ b/integrations/siem/sentinel/keepiq-dcr.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "description": "Keepiq SIEM audit export: the KeepiqAudit_CL table and the data collection rule with stream Custom-KeepiqAudit. Columns match lib/Service/Siem/SentinelRowFormatter.php." + }, + "parameters": { + "workspaceName": { + "type": "string", + "metadata": { + "description": "The Log Analytics workspace Microsoft Sentinel uses." + } + }, + "dataCollectionEndpointResourceId": { + "type": "string", + "metadata": { + "description": "Resource id of the data collection endpoint Keepiq posts to." + } + }, + "dataCollectionRuleName": { + "type": "string", + "defaultValue": "dcr-keepiq-audit" + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "retentionInDays": { + "type": "int", + "defaultValue": 90 + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces/tables", + "apiVersion": "2022-10-01", + "name": "[format('{0}/KeepiqAudit_CL', parameters('workspaceName'))]", + "properties": { + "retentionInDays": "[parameters('retentionInDays')]", + "schema": { + "name": "KeepiqAudit_CL", + "columns": [ + { + "name": "TimeGenerated", + "type": "datetime" + }, + { + "name": "EventType", + "type": "string" + }, + { + "name": "Category", + "type": "string" + }, + { + "name": "ActorType", + "type": "string" + }, + { + "name": "ActorId", + "type": "string" + }, + { + "name": "ObjectType", + "type": "string" + }, + { + "name": "ObjectId", + "type": "string" + }, + { + "name": "Metadata", + "type": "dynamic" + } + ] + } + } + }, + { + "type": "Microsoft.Insights/dataCollectionRules", + "apiVersion": "2023-03-11", + "name": "[parameters('dataCollectionRuleName')]", + "location": "[parameters('location')]", + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces/tables', parameters('workspaceName'), 'KeepiqAudit_CL')]" + ], + "properties": { + "dataCollectionEndpointId": "[parameters('dataCollectionEndpointResourceId')]", + "streamDeclarations": { + "Custom-KeepiqAudit": { + "columns": [ + { + "name": "TimeGenerated", + "type": "datetime" + }, + { + "name": "EventType", + "type": "string" + }, + { + "name": "Category", + "type": "string" + }, + { + "name": "ActorType", + "type": "string" + }, + { + "name": "ActorId", + "type": "string" + }, + { + "name": "ObjectType", + "type": "string" + }, + { + "name": "ObjectId", + "type": "string" + }, + { + "name": "Metadata", + "type": "dynamic" + } + ] + } + }, + "destinations": { + "logAnalytics": [ + { + "name": "sentinelWorkspace", + "workspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', parameters('workspaceName'))]" + } + ] + }, + "dataFlows": [ + { + "streams": [ + "Custom-KeepiqAudit" + ], + "destinations": [ + "sentinelWorkspace" + ], + "transformKql": "source", + "outputStream": "Custom-KeepiqAudit_CL" + } + ] + } + } + ], + "outputs": { + "dataCollectionRuleImmutableId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/dataCollectionRules', parameters('dataCollectionRuleName')), '2023-03-11').immutableId]" + } + } +} diff --git a/integrations/siem/splunk/README.md b/integrations/siem/splunk/README.md new file mode 100644 index 000000000..1c8ca0904 --- /dev/null +++ b/integrations/siem/splunk/README.md @@ -0,0 +1,21 @@ +# Keepiq audit events in Splunk + +Keepiq posts each sanitized audit event to the HTTP Event Collector (HEC) +with sourcetype `keepiq:audit`. + +## Set it up + +1. Copy `props.conf` into an app on your search heads and indexers, for + example `$SPLUNK_HOME/etc/apps/keepiq/local/props.conf`. +2. Create an index for the events, for example `keepiq`. +3. Create a HEC token that may write to that one index only, with source type + `keepiq:audit`. Do not give it more indexes. +4. In Nextcloud, open the Keepiq admin settings, add a SIEM sink, pick + **Splunk HTTP Event Collector**, and fill in + `https://:8088/services/collector/event`, the token and the + index. Press **Test**. + +Search with `index=keepiq sourcetype=keepiq:audit`. The event fields are +`eventType`, `category`, `actorType`, `actorId`, `objectType`, `objectId`, +`occurredAt` and `metadata.*`. No secret value, login, additional field, +ciphertext or key ever arrives. diff --git a/integrations/siem/splunk/props.conf b/integrations/siem/splunk/props.conf new file mode 100644 index 000000000..32a111c08 --- /dev/null +++ b/integrations/siem/splunk/props.conf @@ -0,0 +1,13 @@ +# Keepiq SIEM audit export: the keepiq:audit sourcetype. +# +# Keepiq posts each audit event to the HTTP Event Collector event endpoint +# with "time" in the envelope, so Splunk takes the timestamp from there and +# needs no timestamp extraction. The event is JSON; fields are extracted at +# search time. +[keepiq:audit] +KV_MODE = json +SHOULD_LINEMERGE = false +TRUNCATE = 0 +category = Authentication +description = Keepiq audit events (sanitized metadata only) +pulldown_type = true diff --git a/integrations/terraform-provider-keepiq/.goreleaser.yml b/integrations/terraform-provider-keepiq/.goreleaser.yml new file mode 100644 index 000000000..105c2fcf2 --- /dev/null +++ b/integrations/terraform-provider-keepiq/.goreleaser.yml @@ -0,0 +1,28 @@ +# Used in the mirror repository ConductionNL/terraform-provider-keepiq, where +# the Terraform and OpenTofu registries pick up signed releases. The source of +# truth is integrations/terraform-provider-keepiq in ConductionNL/keepiq. +version: 2 +builds: + - env: [CGO_ENABLED=0] + mod_timestamp: "{{ .CommitTimestamp }}" + flags: [-trimpath] + ldflags: ["-s -w -X main.version={{ .Version }}"] + goos: [linux, darwin, windows, freebsd] + goarch: [amd64, arm64] + binary: "{{ .ProjectName }}_v{{ .Version }}" +archives: + - format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" +checksum: + extra_files: + - glob: terraform-registry-manifest.json + name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" + name_template: "{{ .ProjectName }}_{{ .Version }}_SHA256SUMS" + algorithm: sha256 +signs: + - artifacts: checksum + args: ["--batch", "--local-user", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"] +release: + extra_files: + - glob: terraform-registry-manifest.json + name_template: "{{ .ProjectName }}_{{ .Version }}_manifest.json" diff --git a/integrations/terraform-provider-keepiq/README.md b/integrations/terraform-provider-keepiq/README.md new file mode 100644 index 000000000..ab74a4d11 --- /dev/null +++ b/integrations/terraform-provider-keepiq/README.md @@ -0,0 +1,28 @@ +# terraform-provider-keepiq + +Manage Keepiq application secrets as code with Terraform 1.11+ or OpenTofu. +Values are decrypted and encrypted in the provider and never written to plan or state. + +- `ephemeral "keepiq_secret"` reads a value for one run. +- `resource "keepiq_secret"` writes values through write-only arguments; bump `value_wo_version` to write again. +- `data "keepiq_secret_metadata"` returns timestamps, expiry and fingerprint, never a value. +- `resource "keepiq_application"` registers an application from a CSR and approves it, through Keepiq's admin API. Destroy deletes its vault, so it needs `allow_vault_deletion = true`. +- `resource "keepiq_application_lease_policy"` sets an application's lease TTL override. + +The secret resources authenticate as an application (`application_id`, `private_key`). The application resources authenticate as a Nextcloud user with an app password (`admin_user`, `admin_password`); give that user only the "Applications and machine access" admin area. + +Reference: [docs/](docs/). User guide: `docs/terraform.md` in the Keepiq repository. + +## Develop + +```sh +go test ./... # unit tests +KEEPIQ_TF_BIN=$(which terraform) go test ./... # plus the end-to-end test against Terraform 1.11+ +KEEPIQ_LIVE_URL=https://cloud.example/index.php KEEPIQ_ADMIN_USER=svc KEEPIQ_ADMIN_PASSWORD=... go test ./... # plus the admin API against a live Keepiq +scripts/docs.sh "$(which terraform)" # regenerate docs/ +``` + +This directory is the source of truth. Tag `tf-vX.Y.Z` in ConductionNL/keepiq to +release: the workflow copies it to the mirror repository +ConductionNL/terraform-provider-keepiq, which the registries read. Do not change +the mirror by hand. diff --git a/integrations/terraform-provider-keepiq/docs/data-sources/secret_metadata.md b/integrations/terraform-provider-keepiq/docs/data-sources/secret_metadata.md new file mode 100644 index 000000000..b7d44b292 --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/data-sources/secret_metadata.md @@ -0,0 +1,42 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq_secret_metadata Data Source - keepiq" +subcategory: "" +description: |- + Metadata of one application secret: id, timestamps, expiry and certificate fingerprint. It offers no value; use the keepiq_secret ephemeral resource for that. +--- + +# keepiq_secret_metadata (Data Source) + +Metadata of one application secret: id, timestamps, expiry and certificate fingerprint. It offers no value; use the keepiq_secret ephemeral resource for that. + +## Example Usage + +```terraform +data "keepiq_secret_metadata" "db" { + name = "db-password" +} + +output "db_password_expires_at" { + value = data.keepiq_secret_metadata.db.expires_at +} +``` + + +## Schema + +### Optional + +- `folder` (String) +- `name` (String) + +### Read-Only + +- `certificate_fingerprint` (String) +- `created_at` (String) +- `expires_at` (String) +- `folder_path` (String) +- `id` (String) The ID of this resource. +- `key_updated_at` (String) +- `updated_at` (String) +- `url` (String) diff --git a/integrations/terraform-provider-keepiq/docs/ephemeral-resources/secret.md b/integrations/terraform-provider-keepiq/docs/ephemeral-resources/secret.md new file mode 100644 index 000000000..e08d02e9b --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/ephemeral-resources/secret.md @@ -0,0 +1,41 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq_secret Ephemeral Resource - keepiq" +subcategory: "" +description: |- + Reads and decrypts one application secret for this run. Terraform never stores the result in plan or state; pass it to a provider configuration or a write-only argument. +--- + +# keepiq_secret (Ephemeral Resource) + +Reads and decrypts one application secret for this run. Terraform never stores the result in plan or state; pass it to a provider configuration or a write-only argument. + +## Example Usage + +```terraform +ephemeral "keepiq_secret" "db" { + name = "db-password" +} + +# Hand the value to another provider; it never reaches plan or state. +provider "postgresql" { + host = "db.internal" + username = "app" + password = ephemeral.keepiq_secret.db.value +} +``` + + +## Schema + +### Optional + +- `folder` (String) Narrows name to a slash-separated folder path. +- `id` (String) The secret id. Set it, or set name. +- `name` (String) The exact secret name. + +### Read-Only + +- `additional_fields` (String, Sensitive) The decrypted additional fields (JSON). +- `login` (String, Sensitive) The decrypted login field. +- `value` (String, Sensitive) The decrypted key field. diff --git a/integrations/terraform-provider-keepiq/docs/index.md b/integrations/terraform-provider-keepiq/docs/index.md new file mode 100644 index 000000000..b9be94f28 --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/index.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq Provider" +subcategory: "" +description: |- + Manage Keepiq application secrets as code. Values are decrypted and encrypted in the provider and never written to plan or state. +--- + +# keepiq Provider + +Manage Keepiq application secrets as code. Values are decrypted and encrypted in the provider and never written to plan or state. + +## Example Usage + +```terraform +# Reads KEEPIQ_URL, KEEPIQ_APP_ID and KEEPIQ_APP_KEY (or KEEPIQ_APP_KEY_FILE) +# from the environment, so no key sits in the configuration. +provider "keepiq" {} +``` + + +## Schema + +### Optional + +- `admin_password` (String, Sensitive) A Nextcloud app password of admin_user. Defaults to KEEPIQ_ADMIN_PASSWORD, or the file named in KEEPIQ_ADMIN_PASSWORD_FILE. +- `admin_user` (String) A Nextcloud user for the admin API (keepiq_application and keepiq_application_lease_policy), holding the Applications and machine access area. Defaults to KEEPIQ_ADMIN_USER. +- `application_id` (String) The approved Keepiq application. Defaults to KEEPIQ_APP_ID. +- `certificate` (String) The application's certificate (PEM). With it, the provider checks the key belongs to it and refuses any secret encrypted to another certificate. Defaults to the file named in KEEPIQ_APP_CERT_FILE. +- `private_key` (String, Sensitive) The application's private key (PEM). Defaults to KEEPIQ_APP_KEY, or the file named in KEEPIQ_APP_KEY_FILE. It never leaves the provider. +- `url` (String) The Keepiq (Nextcloud) address; include /index.php without pretty URLs. Defaults to KEEPIQ_URL. diff --git a/integrations/terraform-provider-keepiq/docs/resources/application.md b/integrations/terraform-provider-keepiq/docs/resources/application.md new file mode 100644 index 000000000..07ee664b0 --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/resources/application.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq_application Resource - keepiq" +subcategory: "" +description: |- + A Keepiq application, registered and approved through the admin API. Needs admin_user and admin_password on the provider, for an account holding the Applications and machine access area. The private key stays with whoever made the CSR: generating it with tls_private_key stores it in state. +--- + +# keepiq_application (Resource) + +A Keepiq application, registered and approved through the admin API. Needs admin_user and admin_password on the provider, for an account holding the Applications and machine access area. The private key stays with whoever made the CSR: generating it with tls_private_key stores it in state. + +## Example Usage + +```terraform +# Needs admin_user and admin_password on the provider (or KEEPIQ_ADMIN_USER +# and KEEPIQ_ADMIN_PASSWORD), for an account holding the Keepiq +# "Applications and machine access" admin area. +variable "ci_runner_csr" { + type = string +} + +resource "keepiq_application" "ci_runner" { + name = "ci-runner" + description = "Build pipeline" + csr_pem = var.ci_runner_csr + + # Destroy deletes the application and its vault only when this is true. + allow_vault_deletion = false +} + +output "ci_runner_certificate" { + value = keepiq_application.ci_runner.certificate_pem +} +``` + + +## Schema + +### Required + +- `name` (String) The application name. + +### Optional + +- `allow_vault_deletion` (Boolean) Destroy deletes the application and its vault only when this is true. +- `csr_pem` (String) A PKCS#10 CSR in PEM. Keepiq signs it and the certificate appears in certificate_pem. +- `description` (String) A description. +- `type` (String) internal or external (default). + +### Read-Only + +- `certificate_pem` (String) The application's certificate (PEM). +- `id` (String) The ID of this resource. +- `status` (String) The application status in Keepiq. + +## Import + +Import is supported using the following syntax: + +```shell +# Import an application by its Keepiq id. +terraform import keepiq_application.ci_runner 4f0c6a1e-0000-4000-8000-000000000000 +``` diff --git a/integrations/terraform-provider-keepiq/docs/resources/application_lease_policy.md b/integrations/terraform-provider-keepiq/docs/resources/application_lease_policy.md new file mode 100644 index 000000000..0baced74c --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/resources/application_lease_policy.md @@ -0,0 +1,50 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq_application_lease_policy Resource - keepiq" +subcategory: "" +description: |- + The lease TTL override of one Keepiq application, through the admin API. An argument left out inherits the instance setting; destroy removes the override. Needs admin_user and admin_password on the provider. +--- + +# keepiq_application_lease_policy (Resource) + +The lease TTL override of one Keepiq application, through the admin API. An argument left out inherits the instance setting; destroy removes the override. Needs admin_user and admin_password on the provider. + +## Example Usage + +```terraform +resource "keepiq_application_lease_policy" "ci_runner" { + application_id = keepiq_application.ci_runner.id + default_ttl = 600 # seconds + max_ttl = 3600 # seconds + # renewable left out: inherits the instance setting. +} +``` + + +## Schema + +### Required + +- `application_id` (String) The application. + +### Optional + +- `default_ttl` (Number) Default lease TTL in seconds, at least 60. +- `max_ttl` (Number) Maximum lease TTL in seconds, at least 60. +- `renewable` (Boolean) Whether a lease may be renewed. + +### Read-Only + +- `effective_default_ttl` (Number) The default TTL that applies. +- `effective_max_ttl` (Number) The maximum TTL that applies. +- `effective_renewable` (Boolean) Whether renewal applies. + +## Import + +Import is supported using the following syntax: + +```shell +# Import a lease policy by its application's Keepiq id. +terraform import keepiq_application_lease_policy.ci_runner 4f0c6a1e-0000-4000-8000-000000000000 +``` diff --git a/integrations/terraform-provider-keepiq/docs/resources/secret.md b/integrations/terraform-provider-keepiq/docs/resources/secret.md new file mode 100644 index 000000000..d41c586f5 --- /dev/null +++ b/integrations/terraform-provider-keepiq/docs/resources/secret.md @@ -0,0 +1,62 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "keepiq_secret Resource - keepiq" +subcategory: "" +description: |- + A secret in the application's vault. Its values are write-only: the provider encrypts them to the application's key and Terraform never stores them. Change value_wo_version to write new values. Destroy removes the secret from state only; an administrator deletes it in Keepiq. Needs Terraform 1.11 or later. +--- + +# keepiq_secret (Resource) + +A secret in the application's vault. Its values are write-only: the provider encrypts them to the application's key and Terraform never stores them. Change value_wo_version to write new values. Destroy removes the secret from state only; an administrator deletes it in Keepiq. Needs Terraform 1.11 or later. + +## Example Usage + +```terraform +variable "api_token" { + type = string + sensitive = true + ephemeral = true +} + +resource "keepiq_secret" "api_token" { + name = "api-token" + url = "https://api.example.org" + value_wo = var.api_token + value_wo_version = 1 # bump to write a new value +} +``` + + +## Schema + +### Required + +- `name` (String) The secret name. +- `value_wo` (String, Sensitive) The value (key field). Write-only. +- `value_wo_version` (Number) Change this number to write the write-only values again; Terraform cannot compare values it never stores. + +### Optional + +- `additional_fields_wo` (String, Sensitive) The additional fields (JSON). Write-only. +- `login_wo` (String, Sensitive) The login field. Write-only. +- `type_id` (String) The secret type id; Keepiq picks one when empty. +- `url` (String) A URL stored with the secret (not secret). + +### Read-Only + +- `etag` (String) +- `expires_at` (String) +- `folder_path` (String) +- `id` (String) The ID of this resource. +- `key_updated_at` (String) +- `updated_at` (String) + +## Import + +Import is supported using the following syntax: + +```shell +# Adopt an existing secret by its id, then set value_wo and value_wo_version. +terraform import keepiq_secret.api_token sec-1234 +``` diff --git a/integrations/terraform-provider-keepiq/examples/data-sources/keepiq_secret_metadata/data-source.tf b/integrations/terraform-provider-keepiq/examples/data-sources/keepiq_secret_metadata/data-source.tf new file mode 100644 index 000000000..4593278e1 --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/data-sources/keepiq_secret_metadata/data-source.tf @@ -0,0 +1,7 @@ +data "keepiq_secret_metadata" "db" { + name = "db-password" +} + +output "db_password_expires_at" { + value = data.keepiq_secret_metadata.db.expires_at +} diff --git a/integrations/terraform-provider-keepiq/examples/ephemeral-resources/keepiq_secret/ephemeral-resource.tf b/integrations/terraform-provider-keepiq/examples/ephemeral-resources/keepiq_secret/ephemeral-resource.tf new file mode 100644 index 000000000..ee6fd0e29 --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/ephemeral-resources/keepiq_secret/ephemeral-resource.tf @@ -0,0 +1,10 @@ +ephemeral "keepiq_secret" "db" { + name = "db-password" +} + +# Hand the value to another provider; it never reaches plan or state. +provider "postgresql" { + host = "db.internal" + username = "app" + password = ephemeral.keepiq_secret.db.value +} diff --git a/integrations/terraform-provider-keepiq/examples/provider/provider.tf b/integrations/terraform-provider-keepiq/examples/provider/provider.tf new file mode 100644 index 000000000..a57373ceb --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/provider/provider.tf @@ -0,0 +1,3 @@ +# Reads KEEPIQ_URL, KEEPIQ_APP_ID and KEEPIQ_APP_KEY (or KEEPIQ_APP_KEY_FILE) +# from the environment, so no key sits in the configuration. +provider "keepiq" {} diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/import.sh b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/import.sh new file mode 100644 index 000000000..275b257ac --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/import.sh @@ -0,0 +1,2 @@ +# Import an application by its Keepiq id. +terraform import keepiq_application.ci_runner 4f0c6a1e-0000-4000-8000-000000000000 diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/resource.tf b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/resource.tf new file mode 100644 index 000000000..ff3bb813b --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application/resource.tf @@ -0,0 +1,19 @@ +# Needs admin_user and admin_password on the provider (or KEEPIQ_ADMIN_USER +# and KEEPIQ_ADMIN_PASSWORD), for an account holding the Keepiq +# "Applications and machine access" admin area. +variable "ci_runner_csr" { + type = string +} + +resource "keepiq_application" "ci_runner" { + name = "ci-runner" + description = "Build pipeline" + csr_pem = var.ci_runner_csr + + # Destroy deletes the application and its vault only when this is true. + allow_vault_deletion = false +} + +output "ci_runner_certificate" { + value = keepiq_application.ci_runner.certificate_pem +} diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/import.sh b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/import.sh new file mode 100644 index 000000000..55f4d4574 --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/import.sh @@ -0,0 +1,2 @@ +# Import a lease policy by its application's Keepiq id. +terraform import keepiq_application_lease_policy.ci_runner 4f0c6a1e-0000-4000-8000-000000000000 diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/resource.tf b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/resource.tf new file mode 100644 index 000000000..1c800c16d --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_application_lease_policy/resource.tf @@ -0,0 +1,6 @@ +resource "keepiq_application_lease_policy" "ci_runner" { + application_id = keepiq_application.ci_runner.id + default_ttl = 600 # seconds + max_ttl = 3600 # seconds + # renewable left out: inherits the instance setting. +} diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/import.sh b/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/import.sh new file mode 100644 index 000000000..3c995e06d --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/import.sh @@ -0,0 +1,2 @@ +# Adopt an existing secret by its id, then set value_wo and value_wo_version. +terraform import keepiq_secret.api_token sec-1234 diff --git a/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/resource.tf b/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/resource.tf new file mode 100644 index 000000000..aa27ba91a --- /dev/null +++ b/integrations/terraform-provider-keepiq/examples/resources/keepiq_secret/resource.tf @@ -0,0 +1,12 @@ +variable "api_token" { + type = string + sensitive = true + ephemeral = true +} + +resource "keepiq_secret" "api_token" { + name = "api-token" + url = "https://api.example.org" + value_wo = var.api_token + value_wo_version = 1 # bump to write a new value +} diff --git a/integrations/terraform-provider-keepiq/go.mod b/integrations/terraform-provider-keepiq/go.mod new file mode 100644 index 000000000..955a45329 --- /dev/null +++ b/integrations/terraform-provider-keepiq/go.mod @@ -0,0 +1,33 @@ +module github.com/ConductionNL/terraform-provider-keepiq + +go 1.22.0 + +require github.com/ConductionNL/keepiq/sdk/go v0.0.0 + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.5.0 // indirect + github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-framework v1.14.1 + github.com/hashicorp/terraform-plugin-go v0.26.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.4 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.0.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.3 // indirect +) + +replace github.com/ConductionNL/keepiq/sdk/go => ../../sdk/go diff --git a/integrations/terraform-provider-keepiq/go.sum b/integrations/terraform-provider-keepiq/go.sum new file mode 100644 index 000000000..9b47c13c1 --- /dev/null +++ b/integrations/terraform-provider-keepiq/go.sum @@ -0,0 +1,89 @@ +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= +github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY= +github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4= +github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= +github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= +github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= +github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= +github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= +github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/terraform-provider-keepiq/internal/provider/admin_client.go b/integrations/terraform-provider-keepiq/internal/provider/admin_client.go new file mode 100644 index 000000000..9a64c72e9 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/admin_client.go @@ -0,0 +1,151 @@ +package provider + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// ErrNotFound is what the admin client returns for a 404. +var ErrNotFound = errors.New("not found in Keepiq") + +// AdminApplication is an application as the Keepiq admin API returns it. +type AdminApplication struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Status string `json:"status"` + Certificate string `json:"certificate"` +} + +// LeaseValues are the three lease policy values. +type LeaseValues struct { + DefaultTTL *int64 `json:"defaultTtl"` + MaxTTL *int64 `json:"maxTtl"` + Renewable *bool `json:"renewable"` +} + +// LeasePolicy is an application's lease policy: its override and the +// values that apply. +type LeasePolicy struct { + Override *LeaseValues `json:"override"` + Effective LeaseValues `json:"effective"` +} + +// AdminClient is the part of the Keepiq admin API (/api/v1/admin) the +// provider uses. +type AdminClient interface { + CreateApplication(name, description, appType, csr string) (*AdminApplication, error) + GetApplication(id string) (*AdminApplication, error) + ApproveApplication(id string) (*AdminApplication, error) + DeleteApplication(id string) error + GetLeasePolicy(id string) (*LeasePolicy, error) + SetLeasePolicy(id string, v LeaseValues) (*LeasePolicy, error) +} + +// adminHTTP calls the admin API with a Nextcloud app password over HTTP +// Basic and the OCS-APIRequest header, as the admin API document asks. +type adminHTTP struct { + base, user, password string + http *http.Client +} + +// NewAdminClient returns an admin API client for base (the Keepiq address, +// including /index.php without pretty URLs). +func NewAdminClient(base, user, password string) AdminClient { + return &adminHTTP{ + base: strings.TrimRight(base, "/") + "/apps/keepiq/api/v1/admin", + user: user, + password: password, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *adminHTTP) do(method, path string, body any, out any) error { + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, c.base+path, reader) + if err != nil { + return err + } + req.SetBasicAuth(c.user, c.password) + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + switch { + case resp.StatusCode == http.StatusNotFound: + return ErrNotFound + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + return fmt.Errorf("Keepiq refused the admin call (%d): the admin user needs the Applications and machine access area", resp.StatusCode) + case resp.StatusCode >= 300: + var e struct { + Message string `json:"message"` + } + _ = json.Unmarshal(raw, &e) + if e.Message == "" { + e.Message = http.StatusText(resp.StatusCode) + } + return fmt.Errorf("Keepiq answered %d: %s", resp.StatusCode, e.Message) + } + if out == nil { + return nil + } + return json.Unmarshal(raw, out) +} + +func (c *adminHTTP) CreateApplication(name, description, appType, csr string) (*AdminApplication, error) { + body := map[string]string{"name": name, "type": appType} + if description != "" { + body["description"] = description + } + if csr != "" { + body["csr"] = csr + } + var a AdminApplication + return &a, c.do(http.MethodPost, "/applications", body, &a) +} + +func (c *adminHTTP) GetApplication(id string) (*AdminApplication, error) { + var a AdminApplication + return &a, c.do(http.MethodGet, "/applications/"+url.PathEscape(id), nil, &a) +} + +func (c *adminHTTP) ApproveApplication(id string) (*AdminApplication, error) { + var a AdminApplication + return &a, c.do(http.MethodPost, "/applications/"+url.PathEscape(id)+"/approve", nil, &a) +} + +func (c *adminHTTP) DeleteApplication(id string) error { + return c.do(http.MethodDelete, "/applications/"+url.PathEscape(id), nil, nil) +} + +func (c *adminHTTP) GetLeasePolicy(id string) (*LeasePolicy, error) { + var p LeasePolicy + return &p, c.do(http.MethodGet, "/applications/"+url.PathEscape(id)+"/lease-policy", nil, &p) +} + +func (c *adminHTTP) SetLeasePolicy(id string, v LeaseValues) (*LeasePolicy, error) { + var p LeasePolicy + return &p, c.do(http.MethodPut, "/applications/"+url.PathEscape(id)+"/lease-policy", v, &p) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/admin_live_test.go b/integrations/terraform-provider-keepiq/internal/provider/admin_live_test.go new file mode 100644 index 000000000..50c9d3b6f --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/admin_live_test.go @@ -0,0 +1,52 @@ +package provider + +import ( + "errors" + "fmt" + "os" + "testing" + "time" +) + +// TestAdminAPILive runs the admin calls behind keepiq_application and +// keepiq_application_lease_policy against a real Keepiq when KEEPIQ_LIVE_URL +// (with /index.php when needed), KEEPIQ_ADMIN_USER and KEEPIQ_ADMIN_PASSWORD +// are set: register, read, set and clear the lease override, delete. +func TestAdminAPILive(t *testing.T) { + base, user, pass := os.Getenv("KEEPIQ_LIVE_URL"), os.Getenv("KEEPIQ_ADMIN_USER"), os.Getenv("KEEPIQ_ADMIN_PASSWORD") + if base == "" || user == "" || pass == "" { + t.Skip("set KEEPIQ_LIVE_URL, KEEPIQ_ADMIN_USER and KEEPIQ_ADMIN_PASSWORD to run against a live Keepiq") + } + c := NewAdminClient(base, user, pass) + a, err := c.CreateApplication(fmt.Sprintf("tf-live-%d", time.Now().Unix()), "terraform provider live test", "external", "") + if err != nil { + t.Fatal(err) + } + defer func() { + if err := c.DeleteApplication(a.ID); err != nil { + t.Errorf("delete: %v", err) + } + if _, err := c.GetApplication(a.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("after delete: %v", err) + } + }() + if a.Status == "pending" { + if a, err = c.ApproveApplication(a.ID); err != nil { + t.Fatal(err) + } + } + if a.Status != "active" { + t.Fatalf("status %q", a.Status) + } + ttl := int64(600) + p, err := c.SetLeasePolicy(a.ID, LeaseValues{DefaultTTL: &ttl}) + if err != nil { + t.Fatal(err) + } + if p.Effective.DefaultTTL == nil || *p.Effective.DefaultTTL != 600 { + t.Fatalf("effective %+v", p.Effective) + } + if p, err = c.SetLeasePolicy(a.ID, LeaseValues{}); err != nil || (p.Override != nil && p.Override.DefaultTTL != nil) { + t.Fatalf("clear: %v %+v", err, p.Override) + } +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/admin_test.go b/integrations/terraform-provider-keepiq/internal/provider/admin_test.go new file mode 100644 index 000000000..6457f0272 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/admin_test.go @@ -0,0 +1,238 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// stubAdmin is a Keepiq admin API in memory: one application that starts +// pending, its approval and its lease override. It refuses any call without +// the app password and the OCS-APIRequest header. +type stubAdmin struct { + t *testing.T + calls []string + status string + deleted bool + override map[string]any + lastLease map[string]any +} + +func (s *stubAdmin) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "svc-apps" || pass != "app-password" || r.Header.Get("OCS-APIRequest") != "true" { + w.WriteHeader(http.StatusUnauthorized) + return + } + path := strings.TrimPrefix(r.URL.Path, "/index.php/apps/keepiq/api/v1/admin") + s.calls = append(s.calls, r.Method+" "+path) + app := map[string]any{"id": "app-1", "name": "ci-runner", "type": "external", "status": s.status, "certificate": "-----BEGIN CERTIFICATE-----"} + switch { + case r.Method == http.MethodPost && path == "/applications": + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if body["name"] != "ci-runner" || body["csr"] != "CSR" { + s.t.Errorf("create body %v", body) + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(app) + case r.Method == http.MethodPost && path == "/applications/app-1/approve": + s.status = "active" + app["status"] = "active" + _ = json.NewEncoder(w).Encode(app) + case r.Method == http.MethodGet && path == "/applications/app-1": + if s.deleted { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(app) + case r.Method == http.MethodDelete && path == "/applications/app-1": + s.deleted = true + _ = json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + case path == "/applications/app-1/lease-policy": + if r.Method == http.MethodPut { + _ = json.NewDecoder(r.Body).Decode(&s.lastLease) + s.override = s.lastLease + } + eff := map[string]any{"defaultTtl": 900, "maxTtl": 86400, "renewable": true} + if v, ok := s.override["defaultTtl"]; ok && v != nil { + eff["defaultTtl"] = v + } + _ = json.NewEncoder(w).Encode(map[string]any{"override": s.override, "effective": eff}) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func newStub(t *testing.T) (*stubAdmin, AdminClient) { + t.Helper() + s := &stubAdmin{t: t, status: "pending", override: map[string]any{"defaultTtl": nil, "maxTtl": nil, "renewable": nil}} + srv := httptest.NewServer(s) + t.Cleanup(srv.Close) + return s, NewAdminClient(srv.URL+"/index.php", "svc-apps", "app-password") +} + +// The admin client sends the app password and the OCS-APIRequest header, +// and a refusal names the area the account needs. +func TestAdminClientAuthAndRefusal(t *testing.T) { + _, c := newStub(t) + if _, err := c.GetApplication("app-1"); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(&stubAdmin{t: t}) + defer srv.Close() + _, err := NewAdminClient(srv.URL+"/index.php", "svc-apps", "wrong").GetApplication("app-1") + if err == nil || !strings.Contains(err.Error(), "Applications and machine access") { + t.Fatalf("err %v", err) + } + if _, err := c.GetApplication("missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing: %v", err) + } +} + +func applicationPlan(t *testing.T, allow bool) (tfsdk.Plan, resource.SchemaResponse) { + return applicationValues(t, allow, tftypes.NewValue(tftypes.String, tftypes.UnknownValue)) +} + +// applicationValues builds the resource object with id set to id and the +// other computed values unknown. +func applicationValues(t *testing.T, allow bool, id tftypes.Value) (tfsdk.Plan, resource.SchemaResponse) { + t.Helper() + var rs resource.SchemaResponse + NewApplicationResource().Schema(context.Background(), resource.SchemaRequest{}, &rs) + typ := rs.Schema.Type().TerraformType(context.Background()).(tftypes.Object) + vals := map[string]tftypes.Value{} + for name, at := range typ.AttributeTypes { + vals[name] = tftypes.NewValue(at, nil) + } + vals["name"] = tftypes.NewValue(tftypes.String, "ci-runner") + vals["type"] = tftypes.NewValue(tftypes.String, "external") + vals["csr_pem"] = tftypes.NewValue(tftypes.String, "CSR") + vals["allow_vault_deletion"] = tftypes.NewValue(tftypes.Bool, allow) + for _, computed := range []string{"certificate_pem", "status"} { + vals[computed] = tftypes.NewValue(tftypes.String, tftypes.UnknownValue) + } + vals["id"] = id + return tfsdk.Plan{Schema: rs.Schema, Raw: tftypes.NewValue(typ, vals)}, rs +} + +// Create registers from the CSR, approves a pending application and stores +// the certificate; read after a deletion in Keepiq drops it from state. +func TestApplicationCreateApprovesAndReadsTheCertificate(t *testing.T) { + s, c := newStub(t) + r := &applicationResource{admin: c} + plan, rs := applicationPlan(t, false) + resp := resource.CreateResponse{State: tfsdk.State{Schema: rs.Schema}} + r.Create(context.Background(), resource.CreateRequest{Plan: plan}, &resp) + if resp.Diagnostics.HasError() { + t.Fatal(resp.Diagnostics) + } + var got applicationModel + resp.State.Get(context.Background(), &got) + if got.ID.ValueString() != "app-1" || got.Status.ValueString() != "active" || got.CertificatePEM.ValueString() == "" { + t.Fatalf("state %+v", got) + } + want := []string{"POST /applications", "POST /applications/app-1/approve", "GET /applications/app-1"} + if strings.Join(s.calls, ",") != strings.Join(want, ",") { + t.Fatalf("calls %v", s.calls) + } + + s.deleted = true + read := resource.ReadResponse{State: resp.State} + r.Read(context.Background(), resource.ReadRequest{State: resp.State}, &read) + if read.Diagnostics.HasError() || !read.State.Raw.IsNull() { + t.Fatalf("a deleted application stays in state: %v", read.Diagnostics) + } +} + +// Destroy without allow_vault_deletion is refused and deletes nothing. +func TestApplicationDestroyIsRefusedWithoutTheFlag(t *testing.T) { + s, c := newStub(t) + r := &applicationResource{admin: c} + for _, allow := range []bool{false, true} { + plan, rs := applicationValues(t, allow, tftypes.NewValue(tftypes.String, "app-1")) + state := tfsdk.State{Schema: rs.Schema, Raw: plan.Raw} + var del resource.DeleteResponse + r.Delete(context.Background(), resource.DeleteRequest{State: state}, &del) + if !allow { + if !del.Diagnostics.HasError() || del.Diagnostics.Errors()[0].Detail() != VaultDeletionRefused { + t.Fatalf("diagnostics %v", del.Diagnostics) + } + if s.deleted { + t.Fatal("deleted without allow_vault_deletion") + } + continue + } + if del.Diagnostics.HasError() || !s.deleted { + t.Fatalf("allowed destroy did not delete: %v", del.Diagnostics) + } + } +} + +// The lease policy writes the override, a left-out value inherits, and +// destroy removes the override. +func TestLeasePolicyWritesAndRemovesTheOverride(t *testing.T) { + s, c := newStub(t) + r := &leasePolicyResource{admin: c} + var rs resource.SchemaResponse + r.Schema(context.Background(), resource.SchemaRequest{}, &rs) + typ := rs.Schema.Type().TerraformType(context.Background()).(tftypes.Object) + vals := map[string]tftypes.Value{} + for name, at := range typ.AttributeTypes { + vals[name] = tftypes.NewValue(at, tftypes.UnknownValue) + } + vals["application_id"] = tftypes.NewValue(tftypes.String, "app-1") + vals["default_ttl"] = tftypes.NewValue(tftypes.Number, 600) + vals["max_ttl"] = tftypes.NewValue(tftypes.Number, nil) + vals["renewable"] = tftypes.NewValue(tftypes.Bool, nil) + plan := tfsdk.Plan{Schema: rs.Schema, Raw: tftypes.NewValue(typ, vals)} + + resp := resource.CreateResponse{State: tfsdk.State{Schema: rs.Schema}} + r.Create(context.Background(), resource.CreateRequest{Plan: plan}, &resp) + if resp.Diagnostics.HasError() { + t.Fatal(resp.Diagnostics) + } + if s.lastLease["defaultTtl"] != float64(600) || s.lastLease["maxTtl"] != nil { + t.Fatalf("sent %v", s.lastLease) + } + var got leasePolicyModel + resp.State.Get(context.Background(), &got) + if got.EffectiveDefault.ValueInt64() != 600 || got.EffectiveMax.ValueInt64() != 86400 || !got.MaxTTL.IsNull() { + t.Fatalf("state %+v", got) + } + + var del resource.DeleteResponse + r.Delete(context.Background(), resource.DeleteRequest{State: resp.State}, &del) + if del.Diagnostics.HasError() || s.lastLease["defaultTtl"] != nil { + t.Fatalf("destroy left the override: %v %v", del.Diagnostics, s.lastLease) + } +} + +// The admin password is sensitive, and the provider lists both resources. +func TestAdminSchema(t *testing.T) { + var ps provider.SchemaResponse + New("test")().Schema(context.Background(), provider.SchemaRequest{}, &ps) + if !ps.Schema.Attributes["admin_password"].IsSensitive() { + t.Fatal("admin_password is not sensitive") + } + names := map[string]bool{} + for _, f := range New("test")().Resources(context.Background()) { + var m resource.MetadataResponse + f().Metadata(context.Background(), resource.MetadataRequest{ProviderTypeName: "keepiq"}, &m) + names[m.TypeName] = true + } + for _, n := range []string{"keepiq_secret", "keepiq_application", "keepiq_application_lease_policy"} { + if !names[n] { + t.Fatalf("missing %s in %v", n, names) + } + } +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/data_secret_metadata.go b/integrations/terraform-provider-keepiq/internal/provider/data_secret_metadata.go new file mode 100644 index 000000000..4730fd5fe --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/data_secret_metadata.go @@ -0,0 +1,76 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// NewSecretMetadataDataSource is data "keepiq_secret_metadata": everything +// about a secret except its values (data sources are stored in state). +func NewSecretMetadataDataSource() datasource.DataSource { return &secretMetadata{} } + +type secretMetadata struct{ client Client } + +var _ datasource.DataSourceWithConfigure = &secretMetadata{} + +type secretMetadataModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Folder types.String `tfsdk:"folder"` + FolderPath types.String `tfsdk:"folder_path"` + URL types.String `tfsdk:"url"` + CreatedAt types.String `tfsdk:"created_at"` + UpdatedAt types.String `tfsdk:"updated_at"` + KeyUpdatedAt types.String `tfsdk:"key_updated_at"` + ExpiresAt types.String `tfsdk:"expires_at"` + CertificateFingerprint types.String `tfsdk:"certificate_fingerprint"` +} + +// MetadataAttributes are the data source's attributes; a test checks none is a value. +var MetadataAttributes = []string{"id", "name", "folder", "folder_path", "url", "created_at", "updated_at", "key_updated_at", "expires_at", "certificate_fingerprint"} + +func (d *secretMetadata) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secret_metadata" +} + +func (d *secretMetadata) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Metadata of one application secret: id, timestamps, expiry and certificate fingerprint. It offers no value; use the keepiq_secret ephemeral resource for that.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Optional: true, Computed: true}, + "name": schema.StringAttribute{Optional: true, Computed: true}, + "folder": schema.StringAttribute{Optional: true}, + "folder_path": schema.StringAttribute{Computed: true}, + "url": schema.StringAttribute{Computed: true}, + "created_at": schema.StringAttribute{Computed: true}, + "updated_at": schema.StringAttribute{Computed: true}, + "key_updated_at": schema.StringAttribute{Computed: true}, + "expires_at": schema.StringAttribute{Computed: true}, + "certificate_fingerprint": schema.StringAttribute{Computed: true}, + }, + } +} + +func (d *secretMetadata) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = clientFrom(req.ProviderData, resp.Diagnostics.AddError) +} + +func (d *secretMetadata) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var m secretMetadataModel + resp.Diagnostics.Append(req.Config.Get(ctx, &m)...) + if resp.Diagnostics.HasError() || d.client == nil { + return + } + s, err := lookup(d.client, m.ID, m.Name, m.Folder) + if err != nil { + resp.Diagnostics.AddError("Cannot read Keepiq secret", explain(err, "the secret "+m.Name.ValueString()+m.ID.ValueString())) + return + } + m.ID, m.Name, m.FolderPath, m.URL = types.StringValue(s.ID), types.StringValue(s.Name), types.StringValue(s.FolderPath), strOrNull(s.URL) + m.CreatedAt, m.UpdatedAt, m.KeyUpdatedAt = strOrNull(s.CreatedAt), strOrNull(s.UpdatedAt), strOrNull(s.KeyUpdatedAt) + m.ExpiresAt, m.CertificateFingerprint = strOrNull(s.ExpiresAt), strOrNull(s.CertificateFingerprint) + resp.Diagnostics.Append(resp.State.Set(ctx, &m)...) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/e2e_test.go b/integrations/terraform-provider-keepiq/internal/provider/e2e_test.go new file mode 100644 index 000000000..a5871e151 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/e2e_test.go @@ -0,0 +1,217 @@ +package provider + +import ( + "archive/zip" + "bytes" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ConductionNL/keepiq/sdk/go/keepiqtest" +) + +// TestTerraformEndToEnd drives the real Terraform CLI (1.11 or later, from +// KEEPIQ_TF_BIN) against this provider, built here and loaded through +// dev_overrides, and a stub Keepiq. It checks the acceptance criteria: +// values reach Keepiq and another resource, and no plan or state file holds +// them; value_wo_version is the only write trigger; destroy warns and leaves +// the secret; import adopts a secret. +func TestTerraformEndToEnd(t *testing.T) { + tf := os.Getenv("KEEPIQ_TF_BIN") + if tf == "" { + t.Skip("set KEEPIQ_TF_BIN to a terraform 1.11+ binary to run the end-to-end test") + } + stub, err := keepiqtest.Start("infra") + if err != nil { + t.Fatal(err) + } + defer stub.Close() + const source = "db-password-from-keepiq-77" + if _, err := stub.Add("sec-db", "db-password", "", map[string]string{"key": source}); err != nil { + t.Fatal(err) + } + + work := t.TempDir() + bin := filepath.Join(work, "bin") + _, self, _, _ := runtime.Caller(0) + module := filepath.Join(filepath.Dir(self), "..", "..") + build := exec.Command("go", "build", "-buildvcs=false", "-o", filepath.Join(bin, "terraform-provider-keepiq"), ".") + build.Dir = module + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build provider: %v\n%s", err, out) + } + rc := filepath.Join(work, "terraformrc") + _ = os.WriteFile(rc, []byte(`provider_installation { + dev_overrides { "conductionnl/keepiq" = "`+bin+`" } + direct {} +} +`), 0o644) + dir := filepath.Join(work, "config") + _ = os.MkdirAll(dir, 0o755) + env := append(os.Environ(), "TF_CLI_CONFIG_FILE="+rc, "TF_IN_AUTOMATION=1", "CHECKPOINT_DISABLE=1", + "KEEPIQ_URL="+stub.URL(), "KEEPIQ_APP_ID=infra", "KEEPIQ_APP_KEY="+stub.Fixture.PrivateKeyPem) + run := func(args ...string) string { + t.Helper() + cmd := exec.Command(tf, append([]string{args[0], "-no-color"}, args[1:]...)...) + cmd.Dir, cmd.Env = dir, env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("terraform %s: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) + } + write := func(value string, version int) { + cfg := `terraform { + required_providers { + keepiq = { source = "conductionnl/keepiq" } + } +} +provider "keepiq" {} + +ephemeral "keepiq_secret" "db" { + name = "db-password" +} + +resource "keepiq_secret" "api" { + name = "api-token" + value_wo = "` + value + `" + value_wo_version = ` + itoa(version) + ` +} + +# The ephemeral value feeds another resource through a write-only argument. +resource "keepiq_secret" "copy" { + name = "db-password-copy" + value_wo = ephemeral.keepiq_secret.db.value + value_wo_version = 1 +} + +data "keepiq_secret_metadata" "db" { + name = "db-password" +} + +output "db_fingerprint" { + value = data.keepiq_secret_metadata.db.certificate_fingerprint +} +` + if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + } + noValueOnDisk := func(values ...string) { + t.Helper() + _ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || strings.HasSuffix(p, "main.tf") || strings.Contains(p, ".terraform"+string(os.PathSeparator)+"providers") { + return nil + } + raw, _ := os.ReadFile(p) + for _, content := range expand(p, raw) { + for _, v := range values { + if strings.Contains(content, v) { + t.Fatalf("%s holds the value %q", p, v) + } + } + } + return nil + }) + } + secretByName := func(name string) (string, string) { + stub.Mu.Lock() + var id string + for k, e := range stub.Envelopes { + if e["secret"].(map[string]any)["name"] == name { + id = k + } + } + stub.Mu.Unlock() + v, _ := stub.Plain(id, "key") + return id, v + } + + // Create: plan to a file, apply the file. + write("first-token-value-1", 1) + run("plan", "-out=tfplan") + run("apply", "tfplan") + if _, v := secretByName("api-token"); v != "first-token-value-1" { + t.Fatalf("api-token holds %q", v) + } + if _, v := secretByName("db-password-copy"); v != source { + t.Fatalf("the ephemeral value did not reach the other resource: %q", v) + } + if !strings.Contains(run("output", "db_fingerprint"), "sha256:") { + t.Fatal("metadata data source has no fingerprint") + } + noValueOnDisk("first-token-value-1", source) + + // A new value without a new version writes nothing. + write("ignored-token-value-2", 1) + if out := run("plan"); !strings.Contains(out, "No changes") { + t.Fatalf("a value change without a version change planned a write:\n%s", out) + } + + // Bumping the version writes the new value. + write("second-token-value-3", 2) + run("apply", "-auto-approve") + if _, v := secretByName("api-token"); v != "second-token-value-3" { + t.Fatalf("after the version bump api-token holds %q", v) + } + noValueOnDisk("first-token-value-1", "second-token-value-3", "ignored-token-value-2", source) + + // Destroy leaves the secrets in Keepiq and names them in a warning. + out := strings.Join(strings.Fields(run("destroy", "-auto-approve")), " ") // Terraform wraps warnings + if !strings.Contains(out, "api-token") || !strings.Contains(out, "still exists in the application vault") { + t.Fatalf("destroy did not warn by name:\n%s", out) + } + if id, v := secretByName("api-token"); id == "" || v != "second-token-value-3" { + t.Fatal("destroy removed the secret from Keepiq") + } + + // Import adopts the existing secret. + apiID, _ := secretByName("api-token") + write("second-token-value-3", 2) + run("import", "keepiq_secret.api", apiID) + state := run("show") + if !strings.Contains(state, apiID) || strings.Contains(state, "second-token-value-3") { + t.Fatalf("import state:\n%s", state) + } +} + +// expand returns a file's content, and for a zip (a saved plan) each member's. +// A saved plan embeds a snapshot of the configuration (tfconfig/), which holds +// whatever literal the author typed into main.tf; that is the author's text, +// not something the provider stored, so it is left out. The plan proper +// (tfplan), the prior state and the config of an ephemeral value are checked. +func expand(path string, raw []byte) []string { + if zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))); err == nil { + var out []string + for _, f := range zr.File { + if strings.HasPrefix(f.Name, "tfconfig/") { + continue + } + if rc, err := f.Open(); err == nil { + b, _ := io.ReadAll(rc) + rc.Close() + out = append(out, string(b)) + } + } + return out + } + out := []string{string(raw)} + if strings.HasSuffix(path, ".tfstate") { + var v any + if json.Unmarshal(raw, &v) == nil { + b, _ := json.Marshal(v) + out = append(out, string(b)) + } + } + return out +} + +func itoa(i int) string { + b, _ := json.Marshal(i) + return string(b) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/ephemeral_secret.go b/integrations/terraform-provider-keepiq/internal/provider/ephemeral_secret.go new file mode 100644 index 000000000..192de1bb1 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/ephemeral_secret.go @@ -0,0 +1,64 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// NewSecretEphemeral is ephemeral "keepiq_secret": a value for one run, +// never written to plan or state. +func NewSecretEphemeral() ephemeral.EphemeralResource { return &secretEphemeral{} } + +type secretEphemeral struct{ client Client } + +var _ ephemeral.EphemeralResourceWithConfigure = &secretEphemeral{} + +type secretEphemeralModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Folder types.String `tfsdk:"folder"` + Value types.String `tfsdk:"value"` + Login types.String `tfsdk:"login"` + AdditionalFields types.String `tfsdk:"additional_fields"` +} + +func (e *secretEphemeral) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secret" +} + +func (e *secretEphemeral) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Reads and decrypts one application secret for this run. Terraform never stores the result in plan or state; pass it to a provider configuration or a write-only argument.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Optional: true, Computed: true, Description: "The secret id. Set it, or set name."}, + "name": schema.StringAttribute{Optional: true, Computed: true, Description: "The exact secret name."}, + "folder": schema.StringAttribute{Optional: true, Description: "Narrows name to a slash-separated folder path."}, + "value": schema.StringAttribute{Computed: true, Sensitive: true, Description: "The decrypted key field."}, + "login": schema.StringAttribute{Computed: true, Sensitive: true, Description: "The decrypted login field."}, + "additional_fields": schema.StringAttribute{Computed: true, Sensitive: true, Description: "The decrypted additional fields (JSON)."}, + }, + } +} + +func (e *secretEphemeral) Configure(_ context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + e.client = clientFrom(req.ProviderData, resp.Diagnostics.AddError) +} + +func (e *secretEphemeral) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var m secretEphemeralModel + resp.Diagnostics.Append(req.Config.Get(ctx, &m)...) + if resp.Diagnostics.HasError() || e.client == nil { + return + } + s, err := lookup(e.client, m.ID, m.Name, m.Folder) + if err != nil { + resp.Diagnostics.AddError("Cannot read Keepiq secret", explain(err, "the secret "+m.Name.ValueString()+m.ID.ValueString())) + return + } + m.ID, m.Name = types.StringValue(s.ID), types.StringValue(s.Name) + m.Value, m.Login, m.AdditionalFields = types.StringValue(s.Key), types.StringValue(s.Login), types.StringValue(s.AdditionalFields) + resp.Diagnostics.Append(resp.Result.Set(ctx, &m)...) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/lookup.go b/integrations/terraform-provider-keepiq/internal/provider/lookup.go new file mode 100644 index 000000000..68901a323 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/lookup.go @@ -0,0 +1,53 @@ +package provider + +import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/types" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" +) + +// lookup reads a secret by id, or by name and optional folder. +func lookup(c Client, id, name, folder types.String) (*keepiq.Secret, error) { + if !id.IsNull() && id.ValueString() != "" { + return c.GetByID(id.ValueString()) + } + if name.IsNull() || name.ValueString() == "" { + return nil, errors.New("set either id, or name (with an optional folder)") + } + return c.GetByNameIfNoneMatch(name.ValueString(), folder.ValueString(), "") +} + +// explain turns a library error into a diagnostic detail without any value. +func explain(err error, what string) string { + var amb *keepiq.AmbiguousNameError + switch { + case errors.Is(err, keepiq.ErrNotFound): + return fmt.Sprintf("%s was not found in the application vault.", what) + case errors.As(err, &amb): + parts := make([]string, 0, len(amb.Candidates)) + for _, c := range amb.Candidates { + f := c.FolderPath + if f == "" { + f = "/" + } + parts = append(parts, c.ID+" in "+f) + } + return fmt.Sprintf("%d secrets are named %s: %s. Set folder or use id.", len(amb.Candidates), what, strings.Join(parts, ", ")) + case errors.Is(err, keepiq.ErrUnauthorized): + return "Keepiq refused the application token: check application_id and private_key." + case errors.Is(err, keepiq.ErrKeyMismatch): + return what + " is encrypted to another certificate than the configured one." + } + return err.Error() +} + +func strOrNull(s string) types.String { + if s == "" { + return types.StringNull() + } + return types.StringValue(s) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/provider.go b/integrations/terraform-provider-keepiq/internal/provider/provider.go new file mode 100644 index 000000000..c10962e5b --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/provider.go @@ -0,0 +1,206 @@ +// Package provider implements the keepiq Terraform provider. +package provider + +import ( + "context" + "os" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" +) + +// Client is the part of the Go library the provider uses. +type Client interface { + GetByNameIfNoneMatch(name, folder, etag string) (*keepiq.Secret, error) + GetByID(id string) (*keepiq.Secret, error) + Create(fields map[string]string) (*keepiq.Secret, error) + Update(id string, fields map[string]string) (*keepiq.Secret, error) + UpdateIfMatch(id, etag string, fields map[string]string) (*keepiq.Secret, error) +} + +// KeepiqProvider is the provider. +type KeepiqProvider struct{ version string } + +// New returns the provider factory. +func New(version string) func() provider.Provider { + return func() provider.Provider { return &KeepiqProvider{version: version} } +} + +var ( + _ provider.Provider = &KeepiqProvider{} + _ provider.ProviderWithEphemeralResources = &KeepiqProvider{} +) + +type providerModel struct { + URL types.String `tfsdk:"url"` + ApplicationID types.String `tfsdk:"application_id"` + PrivateKey types.String `tfsdk:"private_key"` + CertificatePEM types.String `tfsdk:"certificate"` + AdminUser types.String `tfsdk:"admin_user"` + AdminPassword types.String `tfsdk:"admin_password"` +} + +// clients is what Configure hands to resources: the application's machine +// client for secrets, and the admin API client for applications. Either may +// be nil when its credentials are not configured. +type clients struct { + machine Client + admin AdminClient +} + +// Metadata names the provider. +func (p *KeepiqProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "keepiq" + resp.Version = p.version +} + +// Schema is the provider configuration. +func (p *KeepiqProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Manage Keepiq application secrets as code. Values are decrypted and encrypted in the provider and never written to plan or state.", + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Optional: true, + Description: "The Keepiq (Nextcloud) address; include /index.php without pretty URLs. Defaults to KEEPIQ_URL.", + }, + "application_id": schema.StringAttribute{ + Optional: true, + Description: "The approved Keepiq application. Defaults to KEEPIQ_APP_ID.", + }, + "private_key": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "The application's private key (PEM). Defaults to KEEPIQ_APP_KEY, or the file named in KEEPIQ_APP_KEY_FILE. It never leaves the provider.", + }, + "certificate": schema.StringAttribute{ + Optional: true, + Description: "The application's certificate (PEM). With it, the provider checks the key belongs to it and refuses any secret encrypted to another certificate. Defaults to the file named in KEEPIQ_APP_CERT_FILE.", + }, + "admin_user": schema.StringAttribute{ + Optional: true, + Description: "A Nextcloud user for the admin API (keepiq_application and keepiq_application_lease_policy), holding the Applications and machine access area. Defaults to KEEPIQ_ADMIN_USER.", + }, + "admin_password": schema.StringAttribute{ + Optional: true, + Sensitive: true, + Description: "A Nextcloud app password of admin_user. Defaults to KEEPIQ_ADMIN_PASSWORD, or the file named in KEEPIQ_ADMIN_PASSWORD_FILE.", + }, + }, + } +} + +func pick(v types.String, env string) string { + if !v.IsNull() && !v.IsUnknown() && v.ValueString() != "" { + return v.ValueString() + } + return os.Getenv(env) +} + +func fromFile(env string) string { + if f := os.Getenv(env); f != "" { + if b, err := os.ReadFile(f); err == nil { + return string(b) + } + } + return "" +} + +// Configure builds the Keepiq client. +func (p *KeepiqProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + var m providerModel + resp.Diagnostics.Append(req.Config.Get(ctx, &m)...) + if resp.Diagnostics.HasError() { + return + } + url, app := pick(m.URL, "KEEPIQ_URL"), pick(m.ApplicationID, "KEEPIQ_APP_ID") + key := pick(m.PrivateKey, "KEEPIQ_APP_KEY") + if key == "" { + key = fromFile("KEEPIQ_APP_KEY_FILE") + } + cert := pick(m.CertificatePEM, "") + if cert == "" { + cert = fromFile("KEEPIQ_APP_CERT_FILE") + } + adminUser, adminPassword := pick(m.AdminUser, "KEEPIQ_ADMIN_USER"), pick(m.AdminPassword, "KEEPIQ_ADMIN_PASSWORD") + if adminPassword == "" { + adminPassword = strings.TrimSpace(fromFile("KEEPIQ_ADMIN_PASSWORD_FILE")) + } + machine := app != "" && key != "" + admin := adminUser != "" && adminPassword != "" + if url == "" || (!machine && !admin) { + resp.Diagnostics.AddError("Keepiq provider is not configured", + "Set url, and application_id with private_key (or KEEPIQ_URL, KEEPIQ_APP_ID and KEEPIQ_APP_KEY or KEEPIQ_APP_KEY_FILE) for secrets, and/or admin_user with admin_password (or KEEPIQ_ADMIN_USER and KEEPIQ_ADMIN_PASSWORD) for applications.") + return + } + data := &clients{} + if machine { + var opts []keepiq.Option + if cert != "" { + opts = append(opts, keepiq.WithCertificate(cert)) + } + c, err := keepiq.New(url, app, key, opts...) + if err != nil { + resp.Diagnostics.AddError("Keepiq provider cannot start", err.Error()) + return + } + data.machine = Client(c) + } + if admin { + data.admin = NewAdminClient(url, adminUser, adminPassword) + } + resp.ResourceData = data + resp.DataSourceData = data + resp.EphemeralResourceData = data +} + +// Resources lists the resources. +func (p *KeepiqProvider) Resources(context.Context) []func() resource.Resource { + return []func() resource.Resource{NewSecretResource, NewApplicationResource, NewApplicationLeasePolicyResource} +} + +// DataSources lists the data sources. +func (p *KeepiqProvider) DataSources(context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{NewSecretMetadataDataSource} +} + +// EphemeralResources lists the ephemeral resources. +func (p *KeepiqProvider) EphemeralResources(context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{NewSecretEphemeral} +} + +func clientFrom(data any, add func(string, string)) Client { + switch d := data.(type) { + case nil: + return nil + case Client: + return d + case *clients: + if d.machine == nil { + add("Keepiq secrets are not configured", "Set application_id and private_key on the provider (or KEEPIQ_APP_ID and KEEPIQ_APP_KEY) to manage secrets.") + } + return d.machine + } + add("Unexpected provider data", "the provider passed a value that is not a Keepiq client") + return nil +} + +func adminFrom(data any, add func(string, string)) AdminClient { + switch d := data.(type) { + case nil: + return nil + case *clients: + if d.admin == nil { + add("Keepiq admin API is not configured", "Set admin_user and admin_password on the provider (or KEEPIQ_ADMIN_USER and KEEPIQ_ADMIN_PASSWORD) to manage applications.") + } + return d.admin + } + add("Unexpected provider data", "the provider passed a value that is not a Keepiq client") + return nil +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/provider_test.go b/integrations/terraform-provider-keepiq/internal/provider/provider_test.go new file mode 100644 index 000000000..e09194cf6 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/provider_test.go @@ -0,0 +1,122 @@ +package provider + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + ephschema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// The provider's own schema: the key is sensitive, every value attribute of +// the resource is write-only and sensitive, and the ephemeral values are +// sensitive. +func TestSchemas(t *testing.T) { + ctx := context.Background() + p := New("test")() + var ps provider.SchemaResponse + p.Schema(ctx, provider.SchemaRequest{}, &ps) + if !ps.Schema.Attributes["private_key"].IsSensitive() { + t.Fatal("private_key is not sensitive") + } + + var rs resource.SchemaResponse + NewSecretResource().Schema(ctx, resource.SchemaRequest{}, &rs) + if rs.Diagnostics.HasError() { + t.Fatal(rs.Diagnostics) + } + for _, n := range []string{"value_wo", "login_wo", "additional_fields_wo"} { + a := rs.Schema.Attributes[n].(rschema.StringAttribute) + if !a.WriteOnly || !a.Sensitive { + t.Fatalf("%s: write-only %v sensitive %v", n, a.WriteOnly, a.Sensitive) + } + } + for n, a := range rs.Schema.Attributes { + if a.IsComputed() && (n == "value" || n == "login" || n == "additional_fields") { + t.Fatalf("resource stores a value attribute %s", n) + } + } + + var es ephemeral.SchemaResponse + NewSecretEphemeral().Schema(ctx, ephemeral.SchemaRequest{}, &es) + for _, n := range []string{"value", "login", "additional_fields"} { + if !es.Schema.Attributes[n].(ephschema.StringAttribute).Sensitive { + t.Fatalf("ephemeral %s is not sensitive", n) + } + } +} + +// 1.5: the metadata data source has no value attribute. +func TestMetadataHasNoValue(t *testing.T) { + var ds datasource.SchemaResponse + NewSecretMetadataDataSource().Schema(context.Background(), datasource.SchemaRequest{}, &ds) + if len(ds.Schema.Attributes) != len(MetadataAttributes) { + t.Fatalf("attributes %d, want %d", len(ds.Schema.Attributes), len(MetadataAttributes)) + } + for _, n := range MetadataAttributes { + if _, ok := ds.Schema.Attributes[n].(dsschema.StringAttribute); !ok { + t.Fatalf("missing %s", n) + } + } + for _, banned := range []string{"value", "login", "additional_fields", "key"} { + if _, ok := ds.Schema.Attributes[banned]; ok { + t.Fatalf("data source offers %s", banned) + } + } +} + +func configWith(t *testing.T, value tftypes.Value) tfsdk.Config { + t.Helper() + var rs resource.SchemaResponse + NewSecretResource().Schema(context.Background(), resource.SchemaRequest{}, &rs) + typ := rs.Schema.Type().TerraformType(context.Background()).(tftypes.Object) + vals := map[string]tftypes.Value{} + for name, at := range typ.AttributeTypes { + vals[name] = tftypes.NewValue(at, nil) + } + vals["name"] = tftypes.NewValue(tftypes.String, "api-token") + vals["value_wo"] = value + vals["value_wo_version"] = tftypes.NewValue(tftypes.Number, 1) + return tfsdk.Config{Schema: rs.Schema, Raw: tftypes.NewValue(typ, vals)} +} + +// 1.6: a client without write-only support is refused with a clear message. +func TestWriteOnlyRefusedOnOlderTerraform(t *testing.T) { + r := &secretResource{} + var resp resource.ValidateConfigResponse + r.ValidateConfig(context.Background(), resource.ValidateConfigRequest{ + Config: configWith(t, tftypes.NewValue(tftypes.String, "s3cret")), + }, &resp) + if !resp.Diagnostics.HasError() || resp.Diagnostics.Errors()[0].Detail() != WriteOnlyUnsupported { + t.Fatalf("diagnostics %v", resp.Diagnostics) + } + var ok resource.ValidateConfigResponse + req := resource.ValidateConfigRequest{Config: configWith(t, tftypes.NewValue(tftypes.String, "s3cret"))} + req.ClientCapabilities.WriteOnlyAttributesAllowed = true + r.ValidateConfig(context.Background(), req, &ok) + if ok.Diagnostics.HasError() { + t.Fatalf("a capable client was refused: %v", ok.Diagnostics) + } +} + +// 1.4: destroy warns and names the secret. +func TestDestroyWarnsAndNamesTheSecret(t *testing.T) { + var rs resource.SchemaResponse + NewSecretResource().Schema(context.Background(), resource.SchemaRequest{}, &rs) + cfg := configWith(t, tftypes.NewValue(tftypes.String, nil)) + var resp resource.DeleteResponse + (&secretResource{}).Delete(context.Background(), resource.DeleteRequest{State: tfsdk.State{Schema: rs.Schema, Raw: cfg.Raw}}, &resp) + if resp.Diagnostics.HasError() || resp.Diagnostics.WarningsCount() != 1 { + t.Fatalf("diagnostics %v", resp.Diagnostics) + } + if d := resp.Diagnostics.Warnings()[0].Detail(); d != DestroyWarning("api-token") { + t.Fatalf("warning %q", d) + } +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/resource_application.go b/integrations/terraform-provider-keepiq/internal/provider/resource_application.go new file mode 100644 index 000000000..6aa4feaba --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/resource_application.go @@ -0,0 +1,161 @@ +package provider + +import ( + "context" + "errors" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// NewApplicationResource is resource "keepiq_application": an application +// registered and approved through the admin API. +func NewApplicationResource() resource.Resource { return &applicationResource{} } + +type applicationResource struct{ admin AdminClient } + +var ( + _ resource.ResourceWithConfigure = &applicationResource{} + _ resource.ResourceWithImportState = &applicationResource{} +) + +type applicationModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Type types.String `tfsdk:"type"` + CSRPEM types.String `tfsdk:"csr_pem"` + CertificatePEM types.String `tfsdk:"certificate_pem"` + Status types.String `tfsdk:"status"` + AllowVaultDeletion types.Bool `tfsdk:"allow_vault_deletion"` +} + +// VaultDeletionRefused is the error a destroy gives without allow_vault_deletion. +const VaultDeletionRefused = "Deleting a Keepiq application deletes its vault and every secret in it. Set allow_vault_deletion = true on this resource, apply, and destroy again if that is what you want." + +func (r *applicationResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_application" +} + +func (r *applicationResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + replace := []planmodifier.String{stringplanmodifier.RequiresReplace()} + keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()} + resp.Schema = schema.Schema{ + Description: "A Keepiq application, registered and approved through the admin API. Needs admin_user and admin_password on the provider, for an account holding the Applications and machine access area. The private key stays with whoever made the CSR: generating it with tls_private_key stores it in state.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: keep}, + "name": schema.StringAttribute{Required: true, PlanModifiers: replace, Description: "The application name."}, + "description": schema.StringAttribute{Optional: true, PlanModifiers: replace, Description: "A description."}, + "type": schema.StringAttribute{Optional: true, Computed: true, Default: stringdefault.StaticString("external"), + PlanModifiers: replace, Validators: []validator.String{oneOf{"internal", "external"}}, + Description: "internal or external (default)."}, + "csr_pem": schema.StringAttribute{Optional: true, PlanModifiers: replace, + Description: "A PKCS#10 CSR in PEM. Keepiq signs it and the certificate appears in certificate_pem."}, + "certificate_pem": schema.StringAttribute{Computed: true, PlanModifiers: keep, Description: "The application's certificate (PEM)."}, + "status": schema.StringAttribute{Computed: true, Description: "The application status in Keepiq."}, + "allow_vault_deletion": schema.BoolAttribute{Optional: true, Computed: true, Default: booldefault.StaticBool(false), + Description: "Destroy deletes the application and its vault only when this is true."}, + }, + } +} + +func (r *applicationResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.admin = adminFrom(req.ProviderData, resp.Diagnostics.AddError) +} + +func fillApplication(m *applicationModel, a *AdminApplication) { + m.ID, m.Name, m.Status = types.StringValue(a.ID), types.StringValue(a.Name), types.StringValue(a.Status) + if a.Type != "" { + m.Type = types.StringValue(a.Type) + } + if !m.Description.IsNull() { + m.Description = strOrNull(a.Description) + } + m.CertificatePEM = strOrNull(a.Certificate) +} + +func (r *applicationResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan applicationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() || r.admin == nil { + return + } + a, err := r.admin.CreateApplication(plan.Name.ValueString(), plan.Description.ValueString(), plan.Type.ValueString(), plan.CSRPEM.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Cannot register Keepiq application", err.Error()) + return + } + // An administrator's registration is active at once; approve only what + // is still pending. + if a.Status == "pending" { + if a, err = r.admin.ApproveApplication(a.ID); err != nil { + resp.Diagnostics.AddError("Cannot approve Keepiq application", err.Error()) + return + } + } + if full, err := r.admin.GetApplication(a.ID); err == nil { + a = full + } + fillApplication(&plan, a) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *applicationResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state applicationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() || r.admin == nil { + return + } + a, err := r.admin.GetApplication(state.ID.ValueString()) + if errors.Is(err, ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Cannot read Keepiq application", err.Error()) + return + } + fillApplication(&state, a) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Update changes only allow_vault_deletion; every other argument replaces. +func (r *applicationResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state applicationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + state.AllowVaultDeletion = plan.AllowVaultDeletion + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *applicationResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state applicationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + if !state.AllowVaultDeletion.ValueBool() { + resp.Diagnostics.AddError("Keepiq application not deleted", VaultDeletionRefused) + return + } + if r.admin == nil { + return + } + if err := r.admin.DeleteApplication(state.ID.ValueString()); err != nil && !errors.Is(err, ErrNotFound) { + resp.Diagnostics.AddError("Cannot delete Keepiq application", err.Error()) + } +} + +func (r *applicationResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/resource_lease_policy.go b/integrations/terraform-provider-keepiq/internal/provider/resource_lease_policy.go new file mode 100644 index 000000000..745da0b90 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/resource_lease_policy.go @@ -0,0 +1,165 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// NewApplicationLeasePolicyResource is resource +// "keepiq_application_lease_policy": an application's lease TTL override. +func NewApplicationLeasePolicyResource() resource.Resource { return &leasePolicyResource{} } + +type leasePolicyResource struct{ admin AdminClient } + +var ( + _ resource.ResourceWithConfigure = &leasePolicyResource{} + _ resource.ResourceWithImportState = &leasePolicyResource{} +) + +type leasePolicyModel struct { + ApplicationID types.String `tfsdk:"application_id"` + DefaultTTL types.Int64 `tfsdk:"default_ttl"` + MaxTTL types.Int64 `tfsdk:"max_ttl"` + Renewable types.Bool `tfsdk:"renewable"` + EffectiveDefault types.Int64 `tfsdk:"effective_default_ttl"` + EffectiveMax types.Int64 `tfsdk:"effective_max_ttl"` + EffectiveRenewable types.Bool `tfsdk:"effective_renewable"` +} + +func (r *leasePolicyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_application_lease_policy" +} + +func (r *leasePolicyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "The lease TTL override of one Keepiq application, through the admin API. An argument left out inherits the instance setting; destroy removes the override. Needs admin_user and admin_password on the provider.", + Attributes: map[string]schema.Attribute{ + "application_id": schema.StringAttribute{Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, Description: "The application."}, + "default_ttl": schema.Int64Attribute{Optional: true, Description: "Default lease TTL in seconds, at least 60."}, + "max_ttl": schema.Int64Attribute{Optional: true, Description: "Maximum lease TTL in seconds, at least 60."}, + "renewable": schema.BoolAttribute{Optional: true, Description: "Whether a lease may be renewed."}, + "effective_default_ttl": schema.Int64Attribute{Computed: true, Description: "The default TTL that applies."}, + "effective_max_ttl": schema.Int64Attribute{Computed: true, Description: "The maximum TTL that applies."}, + "effective_renewable": schema.BoolAttribute{Computed: true, Description: "Whether renewal applies."}, + }, + } +} + +func (r *leasePolicyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.admin = adminFrom(req.ProviderData, resp.Diagnostics.AddError) +} + +func int64Ptr(v types.Int64) *int64 { + if v.IsNull() || v.IsUnknown() { + return nil + } + n := v.ValueInt64() + return &n +} + +func boolPtr(v types.Bool) *bool { + if v.IsNull() || v.IsUnknown() { + return nil + } + b := v.ValueBool() + return &b +} + +func int64OrNull(p *int64) types.Int64 { + if p == nil { + return types.Int64Null() + } + return types.Int64Value(*p) +} + +func boolOrNull(p *bool) types.Bool { + if p == nil { + return types.BoolNull() + } + return types.BoolValue(*p) +} + +func fillLease(m *leasePolicyModel, p *LeasePolicy) { + if p.Override != nil { + m.DefaultTTL, m.MaxTTL, m.Renewable = int64OrNull(p.Override.DefaultTTL), int64OrNull(p.Override.MaxTTL), boolOrNull(p.Override.Renewable) + } + m.EffectiveDefault, m.EffectiveMax, m.EffectiveRenewable = int64OrNull(p.Effective.DefaultTTL), int64OrNull(p.Effective.MaxTTL), boolOrNull(p.Effective.Renewable) +} + +func (r *leasePolicyResource) write(ctx context.Context, plan leasePolicyModel, set func(any) error, add func(string, string)) { + if r.admin == nil { + return + } + p, err := r.admin.SetLeasePolicy(plan.ApplicationID.ValueString(), LeaseValues{ + DefaultTTL: int64Ptr(plan.DefaultTTL), MaxTTL: int64Ptr(plan.MaxTTL), Renewable: boolPtr(plan.Renewable), + }) + if err != nil { + add("Cannot set Keepiq lease policy", err.Error()) + return + } + fillLease(&plan, p) + if err := set(&plan); err != nil { + add("Cannot store Keepiq lease policy", err.Error()) + } + _ = ctx +} + +func (r *leasePolicyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan leasePolicyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.write(ctx, plan, func(v any) error { resp.Diagnostics.Append(resp.State.Set(ctx, v)...); return nil }, resp.Diagnostics.AddError) +} + +func (r *leasePolicyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan leasePolicyModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + r.write(ctx, plan, func(v any) error { resp.Diagnostics.Append(resp.State.Set(ctx, v)...); return nil }, resp.Diagnostics.AddError) +} + +func (r *leasePolicyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state leasePolicyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() || r.admin == nil { + return + } + p, err := r.admin.GetLeasePolicy(state.ApplicationID.ValueString()) + if err == ErrNotFound { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Cannot read Keepiq lease policy", err.Error()) + return + } + fillLease(&state, p) + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// Delete removes the override: every value inherits again. +func (r *leasePolicyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state leasePolicyModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() || r.admin == nil { + return + } + if _, err := r.admin.SetLeasePolicy(state.ApplicationID.ValueString(), LeaseValues{}); err != nil && err != ErrNotFound { + resp.Diagnostics.AddError("Cannot remove Keepiq lease policy", err.Error()) + } +} + +func (r *leasePolicyResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("application_id"), req, resp) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/resource_secret.go b/integrations/terraform-provider-keepiq/internal/provider/resource_secret.go new file mode 100644 index 000000000..7694ced1f --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/resource_secret.go @@ -0,0 +1,206 @@ +package provider + +import ( + "context" + "errors" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + keepiq "github.com/ConductionNL/keepiq/sdk/go" +) + +// NewSecretResource is resource "keepiq_secret": a secret in the +// application's vault whose values are write-only, so plan and state never +// hold them. +func NewSecretResource() resource.Resource { return &secretResource{} } + +type secretResource struct{ client Client } + +var ( + _ resource.ResourceWithConfigure = &secretResource{} + _ resource.ResourceWithImportState = &secretResource{} + _ resource.ResourceWithValidateConfig = &secretResource{} +) + +type secretModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + URL types.String `tfsdk:"url"` + TypeID types.String `tfsdk:"type_id"` + ValueWO types.String `tfsdk:"value_wo"` + LoginWO types.String `tfsdk:"login_wo"` + AdditionalFieldsWO types.String `tfsdk:"additional_fields_wo"` + ValueWOVersion types.Int64 `tfsdk:"value_wo_version"` + FolderPath types.String `tfsdk:"folder_path"` + ETag types.String `tfsdk:"etag"` + KeyUpdatedAt types.String `tfsdk:"key_updated_at"` + UpdatedAt types.String `tfsdk:"updated_at"` + ExpiresAt types.String `tfsdk:"expires_at"` +} + +func (r *secretResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secret" +} + +func (r *secretResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + keep := []planmodifier.String{stringplanmodifier.UseStateForUnknown()} + resp.Schema = schema.Schema{ + Description: "A secret in the application's vault. Its values are write-only: the provider encrypts them to the application's key and Terraform never stores them. Change value_wo_version to write new values. Destroy removes the secret from state only; an administrator deletes it in Keepiq. Needs Terraform 1.11 or later.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Computed: true, PlanModifiers: keep}, + "name": schema.StringAttribute{Required: true, Description: "The secret name."}, + "url": schema.StringAttribute{Optional: true, Description: "A URL stored with the secret (not secret)."}, + "type_id": schema.StringAttribute{Optional: true, Description: "The secret type id; Keepiq picks one when empty."}, + "value_wo": schema.StringAttribute{Required: true, Sensitive: true, WriteOnly: true, Description: "The value (key field). Write-only."}, + "login_wo": schema.StringAttribute{Optional: true, Sensitive: true, WriteOnly: true, Description: "The login field. Write-only."}, + "additional_fields_wo": schema.StringAttribute{Optional: true, Sensitive: true, WriteOnly: true, + Description: "The additional fields (JSON). Write-only."}, + "value_wo_version": schema.Int64Attribute{Required: true, + Description: "Change this number to write the write-only values again; Terraform cannot compare values it never stores."}, + "folder_path": schema.StringAttribute{Computed: true, PlanModifiers: keep}, + "etag": schema.StringAttribute{Computed: true}, + "key_updated_at": schema.StringAttribute{Computed: true}, + "updated_at": schema.StringAttribute{Computed: true}, + "expires_at": schema.StringAttribute{Computed: true}, + }, + } +} + +func (r *secretResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = clientFrom(req.ProviderData, resp.Diagnostics.AddError) +} + +// WriteOnlyUnsupported is the diagnostic for a Terraform without write-only arguments. +const WriteOnlyUnsupported = "This Terraform version cannot keep value_wo out of state. Use Terraform 1.11 or later (or an OpenTofu release with write-only arguments); until then, read values with the keepiq_secret ephemeral resource." + +// ValidateConfig refuses write-only values on a client that would store them. +func (r *secretResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { + if req.ClientCapabilities.WriteOnlyAttributesAllowed { + return + } + var v types.String + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("value_wo"), &v)...) + if !v.IsNull() { + resp.Diagnostics.AddAttributeError(path.Root("value_wo"), "Write-only arguments are not supported", WriteOnlyUnsupported) + } +} + +// writeFields builds the write from config (write-only values live only there). +func writeFields(cfg secretModel, withValues bool) map[string]string { + f := map[string]string{"name": cfg.Name.ValueString()} + if !cfg.URL.IsNull() { + f["url"] = cfg.URL.ValueString() + } + if !cfg.TypeID.IsNull() { + f["typeId"] = cfg.TypeID.ValueString() + } + if withValues { + f["key"] = cfg.ValueWO.ValueString() + if !cfg.LoginWO.IsNull() { + f["login"] = cfg.LoginWO.ValueString() + } + if !cfg.AdditionalFieldsWO.IsNull() { + f["additionalFields"] = cfg.AdditionalFieldsWO.ValueString() + } + } + return f +} + +// fill copies metadata into the state model; write-only values stay null. +func fill(m *secretModel, s *keepiq.Secret) { + m.ID, m.Name, m.FolderPath = types.StringValue(s.ID), types.StringValue(s.Name), types.StringValue(s.FolderPath) + // url is optional and not computed: follow Keepiq only when the + // configuration manages it, so an unmanaged url never shows as a diff. + if !m.URL.IsNull() { + m.URL = strOrNull(s.URL) + } + m.ETag, m.KeyUpdatedAt, m.UpdatedAt, m.ExpiresAt = strOrNull(s.ETag), strOrNull(s.KeyUpdatedAt), strOrNull(s.UpdatedAt), strOrNull(s.ExpiresAt) + m.ValueWO, m.LoginWO, m.AdditionalFieldsWO = types.StringNull(), types.StringNull(), types.StringNull() +} + +func (r *secretResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var cfg, plan secretModel + resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + s, err := r.client.Create(writeFields(cfg, true)) + if err != nil { + resp.Diagnostics.AddError("Cannot create Keepiq secret", explain(err, cfg.Name.ValueString())) + return + } + fill(&plan, s) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// Read records what Keepiq holds now. A value rotated outside Terraform moves +// key_updated_at and etag, which are computed, so Terraform shows no diff: +// only value_wo_version triggers a Terraform write. +func (r *secretResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var st secretModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + s, err := r.client.GetByID(st.ID.ValueString()) + if errors.Is(err, keepiq.ErrNotFound) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError("Cannot read Keepiq secret", explain(err, st.Name.ValueString())) + return + } + fill(&st, s) + resp.Diagnostics.Append(resp.State.Set(ctx, &st)...) +} + +func (r *secretResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var cfg, plan, st secretModel + resp.Diagnostics.Append(req.Config.Get(ctx, &cfg)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + if resp.Diagnostics.HasError() { + return + } + withValues := !plan.ValueWOVersion.Equal(st.ValueWOVersion) + // The ETag from the last refresh guards the write: a change made in + // Keepiq since then is refused instead of overwritten. + s, err := r.client.UpdateIfMatch(st.ID.ValueString(), st.ETag.ValueString(), writeFields(cfg, withValues)) + if errors.Is(err, keepiq.ErrPreconditionFailed) { + resp.Diagnostics.AddError("Keepiq secret changed during this run", + "The secret "+st.Name.ValueString()+" changed in Keepiq after Terraform read it. Run terraform apply again.") + return + } + if err != nil { + resp.Diagnostics.AddError("Cannot update Keepiq secret", explain(err, st.Name.ValueString())) + return + } + plan.ID = st.ID + fill(&plan, s) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// DestroyWarning names the secret and says who deletes it. +func DestroyWarning(name string) string { + return "Terraform no longer manages the Keepiq secret " + name + ", but it still exists in the application vault: the machine API cannot delete secrets, by design. An administrator deletes it in Keepiq." +} + +// Delete removes the secret from state only and warns. +func (r *secretResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var st secretModel + resp.Diagnostics.Append(req.State.Get(ctx, &st)...) + resp.Diagnostics.AddWarning("Keepiq secret left in the vault", DestroyWarning(st.Name.ValueString())) +} + +// ImportState adopts an existing secret by id. Set value_wo and +// value_wo_version afterwards; the first apply then writes the value. +func (r *secretResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/integrations/terraform-provider-keepiq/internal/provider/validators.go b/integrations/terraform-provider-keepiq/internal/provider/validators.go new file mode 100644 index 000000000..414957fe1 --- /dev/null +++ b/integrations/terraform-provider-keepiq/internal/provider/validators.go @@ -0,0 +1,30 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +// oneOf accepts only the listed string values. +type oneOf []string + +func (v oneOf) Description(context.Context) string { + return "one of: " + strings.Join(v, ", ") +} + +func (v oneOf) MarkdownDescription(ctx context.Context) string { return v.Description(ctx) } + +func (v oneOf) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + for _, ok := range v { + if req.ConfigValue.ValueString() == ok { + return + } + } + resp.Diagnostics.AddAttributeError(req.Path, "Invalid value", fmt.Sprintf("%q is not %s", req.ConfigValue.ValueString(), v.Description(context.Background()))) +} diff --git a/integrations/terraform-provider-keepiq/main.go b/integrations/terraform-provider-keepiq/main.go new file mode 100644 index 000000000..8867ff03c --- /dev/null +++ b/integrations/terraform-provider-keepiq/main.go @@ -0,0 +1,29 @@ +// Command terraform-provider-keepiq is the Terraform and OpenTofu provider +// for Keepiq application secrets. Values are decrypted and encrypted only in +// this process and never reach plan or state. +package main + +import ( + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + + "github.com/ConductionNL/terraform-provider-keepiq/internal/provider" +) + +var version = "dev" + +func main() { + var debug bool + flag.BoolVar(&debug, "debug", false, "run with support for debuggers like delve") + flag.Parse() + err := providerserver.Serve(context.Background(), provider.New(version), providerserver.ServeOpts{ + Address: "registry.terraform.io/conductionnl/keepiq", + Debug: debug, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/integrations/terraform-provider-keepiq/scripts/docs.sh b/integrations/terraform-provider-keepiq/scripts/docs.sh new file mode 100755 index 000000000..5b521a628 --- /dev/null +++ b/integrations/terraform-provider-keepiq/scripts/docs.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Regenerate docs/ with tfplugindocs from the provider's real schema. +# scripts/docs.sh [--check] +# With --check it fails when docs/ is not current (CI). +# +# The schema is exported through a dev_overrides install of a fresh build, +# because tfplugindocs's own Terraform download needs a reachable release +# signing key. The registry address is shortened to "keepiq" for tfplugindocs. +set -euo pipefail +tf="${1:?usage: scripts/docs.sh [--check]}" +here="$(cd "$(dirname "$0")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +(cd "$here" && go build -buildvcs=false -o "$work/bin/terraform-provider-keepiq" .) +printf 'provider_installation {\n dev_overrides { "conductionnl/keepiq" = "%s" }\n direct {}\n}\n' "$work/bin" > "$work/rc" +mkdir -p "$work/cfg" +printf 'terraform {\n required_providers {\n keepiq = { source = "conductionnl/keepiq" }\n }\n}\n' > "$work/cfg/main.tf" +(cd "$work/cfg" && TF_CLI_CONFIG_FILE="$work/rc" "$tf" providers schema -json) | sed 's#registry.terraform.io/conductionnl/keepiq#keepiq#' > "$work/schema.json" +cd "$here" +GOFLAGS=-buildvcs=false go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@v0.20.1 generate --provider-name keepiq --providers-schema "$work/schema.json" +if [ "${2:-}" = "--check" ]; then + git diff --exit-code -- docs || { echo "docs/ is not current: run scripts/docs.sh and commit" >&2; exit 1; } + [ -z "$(git status --porcelain -- docs)" ] || { echo "docs/ has new files: run scripts/docs.sh and commit" >&2; exit 1; } +fi diff --git a/integrations/terraform-provider-keepiq/terraform-registry-manifest.json b/integrations/terraform-provider-keepiq/terraform-registry-manifest.json new file mode 100644 index 000000000..295001a07 --- /dev/null +++ b/integrations/terraform-provider-keepiq/terraform-registry-manifest.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "metadata": { + "protocol_versions": ["6.0"] + } +} diff --git a/integrations/test/run.sh b/integrations/test/run.sh new file mode 100755 index 000000000..63bd928ba --- /dev/null +++ b/integrations/test/run.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Drives the GitHub Action script and the GitLab template's install against a +# stub Keepiq (sdk/testdata/stub_server.py) and a local "release" directory. +# +# integrations/test/run.sh +# +# Needs bash, curl, sha256sum, jq and python3 with cryptography and PyYAML. +# Used by .github/workflows/integrations.yml; runs the same on a laptop. +set -euo pipefail + +root="$(cd "$(dirname "$0")/../.." && pwd)" +cli="${1:?usage: run.sh }" +arch="$(uname -m)" +case "$arch" in x86_64 | amd64) arch=amd64 ;; aarch64 | arm64) arch=arm64 ;; esac + +work="$(mktemp -d)" +pids=() +cleanup() { + for p in "${pids[@]}"; do kill "$p" 2> /dev/null || true; done + rm -rf "$work" +} +trap cleanup EXIT + +pass=0 +ok() { pass=$((pass + 1)); echo "ok - $*"; } +no() { echo "not ok - $*" >&2; exit 1; } + +port() { python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])'; } + +# A release directory: the binary, a SHA256SUMS line for it, and a tampered twin. +mkdir -p "$work/release/cli-vtest" "$work/release/cli-vbad" +cp "$cli" "$work/release/cli-vtest/keepiq-linux-$arch" +(cd "$work/release/cli-vtest" && sha256sum "keepiq-linux-$arch" > SHA256SUMS) +cp "$cli" "$work/release/cli-vbad/keepiq-linux-$arch" +printf 'tampered' >> "$work/release/cli-vbad/keepiq-linux-$arch" +cp "$work/release/cli-vtest/SHA256SUMS" "$work/release/cli-vbad/SHA256SUMS" + +rport="$(port)" +(cd "$work/release" && exec python3 -m http.server "$rport" --bind 127.0.0.1 > /dev/null 2>&1) & +pids+=($!) + +db_value='hunter2-db-password' +token_value=$'first line of the token\nsecond line of the token' +sport="$(port)" +python3 "$root/sdk/testdata/stub_server.py" --port "$sport" --add "DB_PASSWORD=$db_value" --add "API_TOKEN=$token_value" > /dev/null 2>&1 & +pids+=($!) +for _ in $(seq 50); do + curl -fs "http://127.0.0.1:$sport/index.php/apps/keepiq/api/v1/app/.well-known/keepiq" > /dev/null 2>&1 && curl -fs "http://127.0.0.1:$rport/cli-vtest/SHA256SUMS" > /dev/null 2>&1 && break + sleep 0.2 +done + +key="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["privateKeyPem"])' "$root/sdk/testdata/machine_envelope.json")" +action() { + env -i PATH="$PATH" HOME="$work/home" RUNNER_TEMP="$work/runner-temp" \ + KEEPIQ_URL="http://127.0.0.1:$sport/index.php" KEEPIQ_APP_ID=billing KEEPIQ_APP_KEY="$key" \ + KEEPIQ_DOWNLOAD_BASE="http://127.0.0.1:$rport" KEEPIQ_ACTION_REF="" "$@" \ + bash "$root/integrations/github-action/keepiq-action.sh" +} +mkdir -p "$work/home" "$work/runner-temp" + +# 1. run mode: the command sees the value, not the application key. +out="$(action KEEPIQ_VERSION=cli-vtest KEEPIQ_SECRETS=DB_PASSWORD \ + KEEPIQ_RUN='test "$KEEPIQ_DB_PASSWORD" = "hunter2-db-password" && test -z "${KEEPIQ_APP_KEY:-}" && echo ran-with-secret' 2>&1)" || no "run mode failed: $out" +grep -q 'ran-with-secret' <<< "$out" || no "run mode: the command did not see KEEPIQ_DB_PASSWORD: $out" +grep -q 'checksum verified' <<< "$out" || no "run mode: no checksum line: $out" +ok "run mode puts the value in the command's environment, without the application key" + +# 2. nothing on disk holds the value. +if grep -rqF "$db_value" "$work/home" "$work/runner-temp" 2> /dev/null; then no "a file holds the secret value"; fi +ok "run mode writes the value to no file" + +# 3. NAME=ENV_VAR mapping. +out="$(action KEEPIQ_VERSION=cli-vtest KEEPIQ_SECRETS=$'DB_PASSWORD=PGPASSWORD\n' \ + KEEPIQ_RUN='test "$PGPASSWORD" = "hunter2-db-password" && test -z "${KEEPIQ_DB_PASSWORD:-}" && echo mapped' 2>&1)" || no "mapping failed: $out" +grep -q mapped <<< "$out" || no "mapping: PGPASSWORD not set: $out" +ok "NAME=ENV_VAR maps the value to the chosen variable" + +# 4. export mode: every line masked before it is written to GITHUB_ENV. +genv="$work/github_env" +: > "$genv" +out="$(action KEEPIQ_VERSION=cli-vtest KEEPIQ_SECRETS=API_TOKEN KEEPIQ_EXPORT_ENV=true GITHUB_ENV="$genv" 2>&1)" || no "export failed: $out" +grep -qxF '::add-mask::first line of the token' <<< "$out" || no "export: first line not masked: $out" +grep -qxF '::add-mask::second line of the token' <<< "$out" || no "export: second line not masked: $out" +if grep -v '^::add-mask::' <<< "$out" | grep -qF 'line of the token'; then no "export: the value reached the log unmasked: $out"; fi +exported="$(bash -c 'set -a; while IFS= read -r l; do + if [[ "$l" =~ ^([A-Z_]+)\<\<(.*)$ ]]; then n="${BASH_REMATCH[1]}"; d="${BASH_REMATCH[2]}"; v=""; first=1; + while IFS= read -r x && [ "$x" != "$d" ]; do if [ $first = 1 ]; then v="$x"; first=0; else v="$v"$'"'"'\n'"'"'"$x"; fi; done + printf "%s" "$v"; fi; done < "$1"' _ "$genv")" +[ "$exported" = "$token_value" ] || no "export: GITHUB_ENV holds '$exported'" +ok "export-env masks every line and writes the multi-line value to GITHUB_ENV" + +# 5. neither run nor export-env: fail and name both. +if out="$(action KEEPIQ_VERSION=cli-vtest KEEPIQ_SECRETS=DB_PASSWORD 2>&1)"; then no "no mode: should fail"; fi +grep -q "'run'" <<< "$out" && grep -q "'export-env: true'" <<< "$out" || no "no mode: message does not name both options: $out" +ok "without run or export-env the step fails and names both options" + +# 6. a binary that does not match SHA256SUMS is refused before it runs. +if out="$(action KEEPIQ_VERSION=cli-vbad KEEPIQ_SECRETS=DB_PASSWORD KEEPIQ_RUN='echo should-not-run' 2>&1)"; then no "tampered binary: should fail"; fi +grep -q 'Checksum mismatch' <<< "$out" || no "tampered binary: no checksum error: $out" +grep -q 'should-not-run' <<< "$out" && no "tampered binary ran" +ok "a binary that does not match SHA256SUMS is refused" + +# 7. no version and no cli-v action ref: a clear error. +if out="$(action KEEPIQ_SECRETS=DB_PASSWORD KEEPIQ_RUN=true 2>&1)"; then no "no version: should fail"; fi +grep -q "Set 'version'" <<< "$out" || no "no version: unclear error: $out" +ok "without a version or a cli-v tag the step says what to set" + +# 8. GitLab: the template's .keepiq before_script installs a checked CLI, and a +# job script wrapped with keepiq ci run sees the value. +before="$(python3 -c 'import sys,yaml; print("\n".join(yaml.safe_load(open(sys.argv[1]))[".keepiq"]["before_script"]))' "$root/integrations/gitlab-ci/keepiq.gitlab-ci.yml")" +mkdir -p "$work/gitlab" +# gitlab : before_script and script share one +# shell, as in a GitLab job. +gitlab() { + (cd "$work/gitlab" && env -i PATH="$PATH" HOME="$work/home" CI_PROJECT_DIR="$work/gitlab" \ + KEEPIQ_URL="http://127.0.0.1:$sport/index.php" KEEPIQ_APP_ID=billing KEEPIQ_APP_KEY="$key" \ + KEEPIQ_DOWNLOAD_BASE="http://127.0.0.1:$rport" KEEPIQ_CLI_VERSION="$1" sh -c "$before +$2") +} +cat > "$work/gitlab/migrate.sh" << 'SH' +#!/bin/sh +test "$KEEPIQ_DB_PASSWORD" = "hunter2-db-password" && echo migrated +SH +chmod +x "$work/gitlab/migrate.sh" +out="$(gitlab cli-vtest 'keepiq ci run DB_PASSWORD -- ./migrate.sh' 2>&1)" || no "gitlab job failed: $out" +grep -q migrated <<< "$out" || no "gitlab: migrate.sh did not see the value: $out" +ok "a GitLab job extending .keepiq runs its command with the secret in its environment" +if out="$(gitlab cli-vbad 'echo should-not-run' 2>&1)"; then no "gitlab tampered: should fail"; fi +grep -q 'Checksum mismatch' <<< "$out" || no "gitlab tampered: $out" +ok "the GitLab template refuses a binary that does not match SHA256SUMS" + +echo "all $pass checks passed" diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index 9713c996a..bdcb156ca 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 4 + "uncovered": 0 } diff --git a/l10n/be.js b/l10n/be.js index 50406b31e..e2d0e0963 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны.", + "Shared with groups": "Абагулена з групамі", + "Not shared with any group yet.": "Яшчэ не абагулена ні з адной групай.", + "Revoke the share with {group}": "Адклікаць абагульванне з {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Абагулена з {group}: {received} удзельнікаў атрымалі, {skipped} не, бо ў іх яшчэ не наладжана шыфраванне.", + "Search groups": "Шукаць групы", + "Failed to share": "Не ўдалося абагуліць", + "Columns": "Слупкі", + "Column {number}": "Слупок {number}", + "Map one column to Name. Every secret needs a name.": "Звяжыце адзін слупок з назвай. Кожны сакрэт павінен мець назву.", + "Notes": "Нататкі", + "Do not import": "Не імпартаваць", + "Hide this value": "Схаваць гэта значэнне", + "Show this value": "Паказаць гэта значэнне", + "Defaults": "Па змаўчанні", + "New secrets start as this type, and your secret list opens in this view.": "Новыя сакрэты ствараюцца з гэтым тыпам, а спіс сакрэтаў адкрываецца ў гэтым выглядзе.", + "Default item type": "Тып элемента па змаўчанні", + "Cards": "Карткі", + "Table": "Табліца", + "Could not save your default": "Не ўдалося захаваць значэнне па змаўчанні", + "Recently used": "Нядаўна выкарыстаныя", + "Opened": "Адкрыта", + "You have not opened any secrets yet": "Вы яшчэ не адкрывалі сакрэты", + "Could not delete the item type.": "Не ўдалося выдаліць тып элемента.", + "Could not load the item types.": "Не ўдалося загрузіць тыпы элементаў.", + "Could not save the item type.": "Не ўдалося захаваць тып элемента.", + "Delete item type": "Выдаліць тып элемента", + "Edit item type": "Рэдагаваць тып элемента", + "Fields": "Палі", + "Fields: {count}": "Палі: {count}", + "Hidden": "Схаванае", + "Item types": "Тыпы элементаў", + "Move up": "Уверх", + "New item type": "Новы тып элемента", + "No item types defined yet.": "Тыпы элементаў яшчэ не вызначаны.", + "Required": "Абавязковае", + "Text": "Тэкст", + "This field is required": "Гэта поле абавязковае", + "Web address": "Вэб-адрас", + "{label} (required)": "{label} (абавязкова)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Выдаліць «{name}»? Сакрэты гэтага тыпу застануцца чытэльнымі і стануць элементамі Логін.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Тыпы элементаў, якія вы вызначыце тут, з'явяцца ва ўсіх у акне Новы сакрэт з выбранымі вамі палямі.", + "Secret moved to the trash": "Сакрэт перамешчаны ў сметніцу", + "Secret restored from the trash": "Сакрэт адноўлены са сметніцы", + "Secret deleted for good": "Сакрэт выдалены назаўсёды", + "Secret archived": "Сакрэт архіваваны", + "Secret unarchived": "Сакрэт выняты з архіва", + "Unarchive": "Выняць з архіва", + "Could not archive the secret": "Не ўдалося архіваваць сакрэт", + "Could not unarchive the secret": "Не ўдалося выняць сакрэт з архіва", + "Archive {count} secrets": "Архіваваць сакрэты: {count}", + "Unarchive {count} secrets": "Выняць з архіва сакрэты: {count}", + "Restore {count} secrets": "Аднавіць сакрэты: {count}", + "Delete {count} secrets for good": "Выдаліць назаўсёды сакрэты: {count}", + "Done for {ok} of {total} secrets": "Гатова для {ok} з {total} сакрэтаў", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архіваваныя сакрэты знікаюць са спіса сховішча, пошуку, аўтазапаўнення і справаздачы пра стан. Агульны доступ да іх захоўваецца. Іх можна знайсці ў Архіве.", + "These secrets come back to the vault list, search and autofill.": "Гэтыя сакрэты вяртаюцца ў спіс сховішча, пошук і аўтазапаўненне.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Гэтыя сакрэты вяртаюцца ў спіс сховішча. Ранейшы агульны доступ не вяртаецца, таму пры патрэбе падзяліцеся імі зноў.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Гэта выдаліць сакрэты разам з укладаннямі і гісторыяй версій. Гэта нельга адрабіць.", + "Delete for good": "Выдаліць назаўсёды", + "Trash": "Сметніца", + "The trash is empty": "Сметніца пустая", + "No archived secrets": "Няма архіваваных сакрэтаў", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Выдаленыя сакрэты чакаюць тут да канца тэрміну захоўвання, пасля чаго выдаляюцца назаўсёды.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архівуйце сакрэт на яго панэлі звестак, каб прыбраць яго са спіса сховішча, пошуку і аўтазапаўнення.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Абмежаванні для зашыфраваных укладанняў (прымяняюцца на серверы да захаваных зашыфраваных байтаў), захоўванне гісторыі версій і колькі часу выдаленыя сакрэты застаюцца ў сметніцы.", + "Days a deleted secret stays in the trash (1 to 365)": "Колькі дзён выдалены сакрэт застаецца ў сметніцы (ад 1 да 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Сакрэт будзе перамешчаны ў сметніцу, а агульны доступ да яго адразу спыніцца. Яго можна аднавіць са сметніцы да канца тэрміну захоўвання: 30 дзён, калі адміністратар не змяніў гэты тэрмін.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Сакрэты будуць перамешчаны ў сметніцу ({count}), а агульны доступ да іх адразу спыніцца. Іх можна аднавіць са сметніцы да канца тэрміну захоўвання.", + "Remove {name} from favourites": "Прыбраць {name} з абранага", + "Add {name} to favourites": "Дадаць {name} у абранае", + "Could not change the favourite": "Не ўдалося змяніць абранае", + "Remove from favourites": "Прыбраць з абранага", + "Add to favourites": "Дадаць у абранае", + "Tags": "Меткі", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Меткі не шыфруюцца. Адміністратары сервера могуць іх чытаць, як і назвы папак.", + "Favourites": "Абранае", + "Filter by tag": "Фільтр па метцы", + "All tags": "Усе меткі", + "Last used": "Апошняе выкарыстанне", + "Tags for {count} secrets": "Меткі для {count} сакрэтаў", + "Tag": "Метка", + "Remove tag": "Прыбраць метку", + "Add tag": "Дадаць метку", + "Could not change the tags. Try again.": "Не ўдалося змяніць меткі. Паспрабуйце яшчэ раз.", + "Could not approve the application. It is still in the queue.": "Не ўдалося адобрыць заяўку. Яна ўсё яшчэ ў чарзе.", + "Could not reject the application. It is still in the queue.": "Не ўдалося адхіліць заяўку. Яна ўсё яшчэ ў чарзе.", + "Removed the user from {count} team folders.": "Карыстальніка выдалена з камандных папак: {count}.", + "Approve a share": "Адобрыць агульны доступ", + "This approval link is incomplete. Open it again from the notification.": "Спасылка для адабрэння няпоўная. Адкрыйце яе зноў з апавяшчэння.", + "Deny": "Адхіліць", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} далучыўся да групы, з якой вы дзеліцеся сакрэтам. Падзяліцца сакрэтам і з ім?", + "{requester} asks you to share a secret with {user}.": "{requester} просіць вас падзяліцца сакрэтам з {user}.", + "Shared. The recipient can now open the secret.": "Доступ нададзены. Цяпер атрымальнік можа адкрыць сакрэт.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Атрымальнік яшчэ не наладзіў Keepiq, таму доступ не нададзены. Паспрабуйце зноў, калі ён гэта зробіць.", + "Could not share the secret. Only its owner can approve this.": "Не ўдалося падзяліцца сакрэтам. Адобрыць гэта можа толькі яго ўладальнік.", + "Could not share the secret. Try again.": "Не ўдалося падзяліцца сакрэтам. Паспрабуйце яшчэ раз.", + "Denied. Nothing was shared.": "Адхілена. Доступ не нададзены.", + "Could not deny the request. Try again.": "Не ўдалося адхіліць запыт. Паспрабуйце яшчэ раз.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просіць вас падзяліцца сакрэтам \"%2$s\" з %3$s.", + "Expires on (optional)": "Тэрмін дзеяння да (неабавязкова)", + "Hand over to": "Перадаць", + "Choose a recipient": "Выберыце атрымальніка", + "Hand over temporarily": "Перадаць часова", + "Expiry rules": "Правілы тэрміну дзеяння", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Задайце, колькі могуць дзейнічаць паролі аднаго тыпу элементаў або адной папкі і калі нагадваць. Калі ўжываецца некалькі дат, улічваецца самая ранняя.", + "Delete rule": "Выдаліць правіла", + "Set by your administrator": "Зададзена адміністратарам", + "No expiry rules yet.": "Правілаў тэрміну дзеяння пакуль няма.", + "Applies to": "Ужываецца да", + "Item type": "Тып элемента", + "Maximum age in days (empty for reminders only)": "Максімальны ўзрост у днях (пуста толькі для нагадванняў)", + "Remind me this many days before, comma separated": "За колькі дзён нагадаць, праз коску", + "Save rule": "Захаваць правіла", + "An item type": "Тып элемента", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тып {name}", + "Expires after {days} days": "Тэрмін мінае праз {days} дз.", + "Reminders {days} days before": "Нагадванні за {days} дз.", + "Could not save the expiry rule.": "Не ўдалося захаваць правіла тэрміну дзеяння.", + "Could not delete the expiry rule.": "Не ўдалося выдаліць правіла тэрміну дзеяння.", + "All statuses": "Усе статусы", + "Compromised": "Скампраметаваны", + "Could not load the members.": "Не ўдалося загрузіць удзельнікаў.", + "Emergency contact": "Экстраны кантакт", + "Leaving user": "Карыстальнік, які сыходзіць", + "No": "Не", + "No users match this filter.": "Ніводзін карыстальнік не адпавядае гэтаму фільтру.", + "Not set up": "Не наладжана", + "Revoke suite": "Адклікаць набор", + "Revoked": "Адкліканы", + "Search users": "Шукаць карыстальнікаў", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Паглядзіце, хто з карыстальнікаў наладзіў сховішча. Пачніце звальненне або адклічце набор з радка.", + "Successor": "Пераемнік", + "Team folders": "Камандныя папкі", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Карыстальнік усё яшчэ ў групе {groups}, якая ўваходзіць у камандную папку. Выдаліце яго з групы або адключыце ўліковы запіс.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс.", + "Vault status": "Статус сховішча", + "Yes": "Так", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Экспарт у CXF НЕ ЗАШЫФРАВАНЫ. Кожны пароль і лагін будуць чытэльнымі як звычайны тэкст у спампаваным файле. Зберагайце файл у надзейным месцы і выдаліце яго адразу пасля выкарыстання.", + "Root certificate expiring soon": "Тэрмін дзеяння каранёвага сертыфіката хутка скончыцца", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Тэрмін дзеяння каранёвага сертыфіката сховішча сканчаецца праз %1$d дз. Абнавіце яго да гэтага. Абнаўленне наноў падпісвае кожны набор шыфравання.", + "Compromise recovery aborted": "Аднаўленне пасля кампраметацыі перапынена", + "Key rotation ended by a compromise revoke": "Змена ключа завершана адкліканнем з-за кампраметацыі", + "Encryption suite revoke refused": "Адкліканне набору шыфравання адхілена", + "Master password proof refused": "Пацвярджэнне галоўнага пароля адхілена", + "Your current master password": "Ваш бягучы галоўны пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "У %n экстранага кантакту быў запыт доступу ў чаканні, калі змена ключа выдаліла яго. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Гэтыя экстраныя кантакты не былі перанесены на ваш новы ключ. Іх экстраны доступ выдалены. Дадайце іх зноў у раздзеле Экстраны доступ, калі яны вам яшчэ патрэбныя.", + "Renew root certificate": "Абнавіць каранёвы сертыфікат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Будуць створаны новы каранёвы і прамежкавы сертыфікаты. Кожны актыўны набор шыфравання будзе падпісаны зноў. Гэта нельга адмяніць.", + "Renew root": "Абнавіць корань", + "Root renewed. {n} encryption suites signed again.": "Корань абноўлены. Зноў падпісана набораў шыфравання: {n}.", + "Could not renew the root certificate.": "Не ўдалося абнавіць каранёвы сертыфікат.", + "Lease policy for this application": "Палітыка арэнды для гэтай праграмы", + "In force now: {default} seconds by default, {max} seconds at most.": "Зараз дзейнічае: {default} секунд па змаўчанні, не больш за {max} секунд.", + "Leases are not renewable": "Арэнду нельга падаўжаць", + "Lease policy saved.": "Палітыка арэнды захавана.", + "Leave a field empty to use the instance value.": "Пакіньце поле пустым, каб выкарыстаць значэнне экзэмпляра.", + "Instance value: {value}": "Значэнне экзэмпляра: {value}", + "Renewal": "Падаўжэнне", + "Use the instance value ({value})": "Выкарыстаць значэнне экзэмпляра ({value})", + "Allowed": "Дазволена", + "Not allowed": "Не дазволена", + "Save lease policy": "Захаваць палітыку арэнды", + "Only an administrator can change this policy.": "Змяніць гэтую палітыку можа толькі адміністратар.", + "Could not save the lease policy.": "Не ўдалося захаваць палітыку арэнды.", + "{member} got access from {confirmer}.": "{member} атрымаў доступ ад {confirmer}.", + "Automatically confirm new team folder members": "Аўтаматычна пацвярджаць новых удзельнікаў камандных папак", + "Gave %n new member access to a team folder.": "%n новы ўдзельнік атрымаў доступ да каманднай папкі.", + "Gave %n new members access to a team folder.": "Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі.", + "Give new team folder members access without waiting for the folder owner.": "Давайце доступ новым удзельнікам, не чакаючы ўладальніка папкі.", + "New team folder members": "Новыя ўдзельнікі камандных папак", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Уладальнік або ўдзельнік з правам запісу пацвярджае іх з адкрытага сховішча. Keepiq ніколі не расшыфроўвае на серверы.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Чаканне, пакуль удзельнік з правам запісу адкрые Keepiq. Можна падзяліцца і зараз.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Частка рэакцыі на кампраметацыю не выканана ({failed} крок(аў)). Праверце журнал сервера, а потым зноў адклічце набор, каб завяршыць яе.", + "This also revoked suite {suite} and ended key migration {migration}.": "Гэта таксама адклікала набор {suite} і завяршыла міграцыю ключоў {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Адкліканне другога набору выдаліла %n кантакт аварыйнага доступу.", + "Revoking the second suite deleted %n emergency-access contacts.": "Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу.", + "A suite revoked as compromised cannot be reinstated.": "Набор, адкліканы як скампраметаваны, нельга аднавіць.", + "Archives to keep": "Колькі архіваў захоўваць", + "Back up every vault automatically": "Аўтаматычна ствараць копію кожнага сховішча", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Стварайце копію кожнага сховішча па раскладзе. Архівы ўтрымліваюць толькі шыфратэкст і аднаўляюцца праз occ.", + "Back up now": "Стварыць копію зараз", + "Backup public key (PEM, optional)": "Адкрыты ключ рэзервовай копіі (PEM, неабавязкова)", + "Backup requested for the next cron run": "Копію запытана на наступны запуск cron", + "Encrypted": "Зашыфравана", + "Every (hours)": "Кожныя (гадзін)", + "Last backup {when} failed: {error}": "Апошняя копія {when} не ўдалася: {error}", + "Last backup {when} succeeded.": "Апошняя копія {when} створана.", + "No archives yet.": "Архіваў пакуль няма.", + "Size": "Памер", + "Vault backups": "Рэзервовыя копіі сховішча", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "З ключом кожны архіў шыфруецца для яго. Захоўвайце закрыты ключ па-за гэтым серверам: ён патрэбны для праверкі ці аднаўлення.", + "Written": "Запісана", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n карыстальнік у вобласці дзеяння яшчэ не мае двухфактарнага ўваходу і не можа адкрыць сховішча, пакуль гэта ўключана.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Рэзервовыя коды не ўлічваюцца. Калі вашы карыстальнікі ўваходзяць праз пастаўшчыка ідэнтычнасці з уласным другім фактарам, выключыце іх групы.", + "Block personal vault export": "Забараніць экспарт асабістага сховішча", + "Keep work logins in team folders": "Захоўваць працоўныя ўліковыя даныя ў камандных папках", + "Move to a team folder": "Перамясціць у камандную папку", + "Not in a team folder": "Не ў каманднай папцы", + "Only for these groups (empty is everyone)": "Толькі для гэтых груп (пуста азначае ўсіх)", + "Require two-factor login before the vault opens": "Патрабаваць двухфактарны ўваход перад адкрыццём сховішча", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правілы для кожнага сховішча. Кожнае дзейнічае для ўсіх або толькі для выбраных груп.", + "Secret types that belong in a team folder": "Тыпы сакрэтаў, якія належаць да каманднай папкі", + "Set up two-factor login": "Наладзіць двухфактарны ўваход", + "Team folder you can write to": "Камандная папка, у якую вы можаце запісваць", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Карыстальнікі не могуць спампаваць рэзервовую копію, CSV ці файл пераносу. Іх пакет асабістых даных застаецца даступным.", + "Users cannot save these secret types in a personal folder.": "Карыстальнікі не могуць захоўваць гэтыя тыпы сакрэтаў у асабістай папцы.", + "Vault policies": "Правілы сховішча", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша арганізацыя не дазваляе экспарт асабістага сховішча. Ваш пакет асабістых даных у наладах застаецца даступным.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша арганізацыя захоўвае гэтыя сакрэты ў каманднай папцы. Перамясціце кожны ў камандную папку.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша арганізацыя захоўвае гэты тып сакрэту ў каманднай папцы. Выберыце адну са сваіх камандных папак або тую, у якую вы можаце запісваць.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша арганізацыя патрабуе двухфактарны ўваход, перш чым вы зможаце адкрыць сховішча.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Карыстальнікі выбіраюць, як доўга пашырэнне застаецца разблакаваным падчас бяздзейнасці. Вы задаеце найбольшы час, які можна выбраць.", + "Longest idle time before the extension locks": "Найбольшы час бяздзейнасці да блакіроўкі пашырэння", + "1 minute": "1 хвіліна", + "5 minutes": "5 хвілін", + "15 minutes": "15 хвілін", + "1 hour": "1 гадзіна", + "4 hours": "4 гадзіны", + "Connector": "Канектар", + "Directory (tenant) ID": "ІД каталога (арандатара)", + "Application (client) ID": "ІД праграмы (кліента)", + "Data collection rule immutable ID": "Нязменны ІД правіла збору даных", + "Stream name": "Назва патоку", + "Splunk index (optional)": "Індэкс Splunk (неабавязкова)", + "Sourcetype (optional)": "Sourcetype (неабавязкова)", + "Leave blank to keep the current one": "Пакіньце пустым, каб захаваць бягучае значэнне", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF праз syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Канчатковы пункт збору даных (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Сакрэт кліента (толькі запіс)", + "HEC token (write-only)": "Токен HEC (толькі запіс)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Перасылайце дазволеныя падзеі аўдыту ў Splunk, Microsoft Sentinel, прымач syslog або вэб-хук. Паведамленні змяшчаюць толькі ачышчаныя метаданыя: ніякае сакрэтнае значэнне, імя, лагін ці шыфратэкст ніколі не пакідае сервер.", + "%n change waiting to sync": "%n змена чакае сінхранізацыі", + "%n changes waiting to sync": "%n змен чакаюць сінхранізацыі", + "Changes that could not sync": "Змены, якія не ўдалося сінхранізаваць", + "Choose a version": "Выбраць версію", + "Copy value": "Скапіраваць значэнне", + "Deleted": "Выдалена", + "Discard": "Адкінуць", + "Keep my offline change": "Пакінуць маю змену без сеткі", + "Keep the server version": "Пакінуць версію з сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без сеткі Keepiq даступны толькі для чытання. Адміністратар не ўключыў рэдагаванне без сеткі.", + "Let users edit secrets offline": "Дазволіць карыстальнікам рэдагаваць сакрэты без сеткі", + "Not synced yet": "Яшчэ не сінхранізавана", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Змены без сеткі застаюцца на прыладзе, зашыфраваныя для карыстальніка, і сінхранізуюцца пры наступнай разблакіроўцы ў сетцы. Абагульванне, папкі і далучэнні па-ранейшаму патрабуюць злучэння.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без сеткі. Праўкі, перамяшчэнні і выдаленні застаюцца на гэтай прыладзе і сінхранізуюцца, калі вы зноў будзеце ў сетцы. Абагульванне і далучэнні патрабуюць злучэння.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без сеткі. Вашы змены застаюцца на гэтай прыладзе і сінхранізуюцца, калі вы зноў будзеце ў сетцы. Апошняя сінхранізацыя {when}.", + "Open my changes": "Адкрыць мае змены", + "Sharing needs a connection": "Абагульванне патрабуе злучэння", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Хтосьці змяніў гэты сакрэт на серверы пасля стварэння вашай копіі без сеткі. Выберыце, якую версію пакінуць.", + "Sync or discard your offline changes before you rotate your keys.": "Сінхранізуйце або адкіньце змены без сеткі, перш чым мяняць ключы.", + "That password did not open your changes.": "Гэты пароль не адкрыў вашы змены.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Здымак без сеткі захоўвае зашыфраваныя сакрэты (адкрываюцца толькі ключом, атрыманым з галоўнага пароля карыстальніка, роўна як на серверы) і шыфруе назвы, URL і назвы папак пры захоўванні. Доступ без сеткі толькі для чытання, калі ніжэй вы не дазволіце рэдагаванне без сеткі. Адключыце гэта для прылад, якія ніколі не павінны кэшаваць уліковыя даныя; адключэнне ачышчае наяўныя кэшы пры наступнай загрузцы.", + "The previous vault copy is gone, so these changes cannot be opened.": "Папярэдняй копіі сховішча больш няма, таму гэтыя змены нельга адкрыць.", + "The server version": "Версія з сервера", + "This secret changed while you were offline": "Гэты сакрэт змяніўся, пакуль вы былі без сеткі", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Вы выдалілі гэты сакрэт без сеткі, але з таго часу яго змянілі на серверы. Выберыце, якую версію пакінуць.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Вашы ключы змяніліся на іншай прыладзе. Увядзіце папярэдні галоўны пароль, каб сінхранізаваць змены без сеткі, або адкіньце іх.", + "Your offline change": "Ваша змена без сеткі", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["У %n экстранага кантакту быў запыт доступу ў чаканні, калі змена ключа выдаліла яго. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.","У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.","У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n элемент нельга падаць у CXF, ён будзе прапушчаны.","%n элементаў нельга падаць у CXF, яны будуць прапушчаны.","%n элементаў нельга падаць у CXF, яны будуць прапушчаны."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n старая версія была выдалена, бо перанесці можна толькі нядаўнюю гісторыю.","%n старых версій былі выдалены, бо перанесці можна толькі нядаўнюю гісторыю.","%n старых версій былі выдалены, бо перанесці можна толькі нядаўнюю гісторыю."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Яшчэ трэба зашыфраваць і даць %n копію сакрэту.","Яшчэ трэба зашыфраваць і даць %n копій сакрэтаў.","Яшчэ трэба зашыфраваць і даць %n копій сакрэтаў."], + "_%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 сакрэт не ўдалося расшыфраваць, і яго няма ў гэтым экспарце.","%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце.","%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n сакрэт не ўдалося расшыфраваць вашым старым ключом, таму ён не быў перанесены.","%n сакрэтаў не ўдалося расшыфраваць вашым старым ключом, таму яны не былі перанесены.","%n сакрэтаў не ўдалося расшыфраваць вашым старым ключом, таму яны не былі перанесены."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n сакрэт не быў перанесены.","%n сакрэтаў не былі перанесены.","%n сакрэтаў не былі перанесены."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n сакрэт усё яшчэ зашыфраваны вашым папярэднім ключом.","%n сакрэтаў усё яшчэ зашыфраваныя вашым папярэднім ключом.","%n сакрэтаў усё яшчэ зашыфраваныя вашым папярэднім ключом."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n сакрэт прапушчаны, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова.","%n сакрэтаў прапушчана, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова.","%n сакрэтаў прапушчана, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова."], + "_%n secret_::_%n secrets_": ["%n сакрэт","%n сакрэтаў","%n сакрэтаў"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n карыстальнік у вобласці дзеяння яшчэ не мае двухфактарнага ўваходу і не можа адкрыць сховішча, пакуль гэта ўключана.","Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана.","Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Усё роўна завяршыць, страціўшы доступ да %n сакрэту","Усё роўна завяршыць, страціўшы доступ да %n сакрэтаў","Усё роўна завяршыць, страціўшы доступ да %n сакрэтаў"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n новы ўдзельнік атрымаў доступ да каманднай папкі.","Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі.","Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ратацыя ключа завершана. %n сакрэт перашыфраваны вашым новым ключом.","Ратацыя ключа завершана. %n сакрэтаў перашыфравана вашым новым ключом.","Ратацыя ключа завершана. %n сакрэтаў перашыфравана вашым новым ключом."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Адкліканне другога набору выдаліла %n кантакт аварыйнага доступу.","Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу.","Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Адкліканне гэтага набору выдаліла %n кантакт аварыйнага доступу.","Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу.","Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["сустракаецца ў ўцечках %n раз","сустракаецца ў ўцечках %n разоў","сустракаецца ў ўцечках %n разоў"], + "_shared with %n secret_::_shared with %n secrets_": ["дадзена %n сакрэту","дадзена %n сакрэтам","дадзена %n сакрэтам"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Гэта папка змяшчае %n сакрэт напрамую.","Гэта папка змяшчае %n сакрэтаў напрамую.","Гэта папка змяшчае %n сакрэтаў напрамую."], + "_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._": ["Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.","Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.","Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n змена чакае сінхранізацыі","%n змен чакаюць сінхранізацыі","%n змен чакаюць сінхранізацыі"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Карыстальнік усё яшчэ ў групе {groups}, якая ўваходзіць у камандную папку. Выдаліце яго з групы або адключыце ўліковы запіс.","Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс.","Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс."], + "Allow approval from another device": "Дазволіць пацвярджэнне з іншай прылады", + "App": "Праграма", + "Approve a new device": "Пацвердзіць новую прыладу", + "Approve from another device": "Пацвердзіць з іншай прылады", + "Asked at": "Запытана ў", + "Check that the new device shows these words:": "Праверце, што новая прылада паказвае гэтыя словы:", + "Denied. If you did not ask, end your other sessions:": "Адхілена. Калі вы гэтага не запытвалі, завяршыце іншыя сеансы:", + "Device": "Прылада", + "IP address": "IP-адрас", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Дазваляе карыстальнікам разблакаваць новы браўзер, пацвердзіўшы яго з прылады, на якой Keepiq ужо разблакаваны.", + "New device approval": "Пацвярджэнне новых прылад", + "Nextcloud security settings": "Налады бяспекі Nextcloud", + "Only approve a device you are using right now.": "Пацвярджайце толькі прыладу, якой карыстаецеся зараз.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Адкрыйце Keepiq на прыладзе, дзе ён разблакаваны, і пацвердзіце гэтую прыладу. Праверце, што там паказаны тыя ж словы:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Прылада, якая пацвярджае, запячатвае ключ разблакавання для новай прылады. Сервер толькі перадае яго і не можа яго адкрыць.", + "The master password is not right, or the request has ended.": "Галоўны пароль няправільны, або запыт завершаны.", + "The request expired. Ask again or use your master password.": "Тэрмін запыту скончыўся. Запытайце зноў або выкарыстайце галоўны пароль.", + "The request was denied.": "Запыт адхілены.", + "Too many requests. Try again in an hour or use your master password.": "Занадта шмат запытаў. Паспрабуйце праз гадзіну або выкарыстайце галоўны пароль.", + "Unknown device": "Невядомая прылада", + "Web app": "Вэб-праграма", + "A device": "Прылада", + "A new device asks to open your vault": "Новая прылада просіць адкрыць ваша сховішча", + "%s asks to be approved. Only approve a device you are using right now.": "%s просіць пацвярджэння. Пацвярджайце толькі прыладу, якой карыстаецеся зараз.", + "Access ends on (optional)": "Доступ заканчваецца (неабавязкова)", + "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.": "Праграмы Keepiq не будуць паказваць або капіраваць пароль. Чалавек з тэхнічнымі навыкамі ўсё роўна можа прачытаць яго на сваёй прыладзе. Змяніце яго, калі доступ скончыцца.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Гэты сакрэт толькі для выкарыстання. Увайдзіце праз пашырэнне браўзера Keepiq.", + "Until {date}": "Да {date}", + "Use only": "Толькі выкарыстанне", + "Use only (can sign in, cannot view or copy)": "Толькі выкарыстанне (можа ўвайсці, не можа праглядаць або капіраваць)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Вы можаце ўвайсці з гэтымі ўліковымі данымі праз пашырэнне браўзера Keepiq. Уладальнік вырашыў не дазваляць вам праглядаць або капіраваць іх.", + "Your access ends on {date}": "Ваш доступ заканчваецца {date}", + "Your access to this secret has ended": "Ваш доступ да гэтага сакрэту скончыўся", + "Your access to \"%s\" ends tomorrow": "Ваш доступ да «%s» заканчваецца заўтра", + "Your access to \"%s\" has ended": "Ваш доступ да «%s» скончыўся", + "%1$s no longer has access to \"%2$s\"": "%1$s больш не мае доступу да «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s мог(ла) бачыць гэты пароль. Змяніце яго, калі %1$s больш не павінен(на) яго ведаць.", + "%s could not view this password in Keepiq.": "%s не змог(ла) праглядзець гэты пароль у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} з {threshold} пацвярджэнняў", + "a recovery officer": "супрацоўнік па аднаўленні", + "Account recovery": "Аднаўленне ўліковага запісу", + "Approvals needed": "Патрэбна пацвярджэнняў", + "Ask {user} which words they see, by phone or in person. They must be:": "Спытайце ў {user}, якія словы ён бачыць, па тэлефоне або асабіста. Яны павінны быць:", + "Check again": "Праверыць зноў", + "Create the recovery key": "Стварыць ключ аднаўлення", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Стварыце ключ аднаўлення. Ваш браўзер стварае яго і дае кожнаму супрацоўніку копію, якую можа адкрыць толькі ён.", + "Decline": "Адхіліць", + "Enrol in account recovery": "Запісацца на аднаўленне ўліковага запісу", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Запішыцеся, каб ваша арганізацыя дапамагла вярнуць сховішча, калі вы забудзеце галоўны пароль.", + "Every user is enrolled": "Усе карыстальнікі запісаныя", + "Finish the recovery in the browser you asked from.": "Завяршыце аднаўленне ў браўзеры, з якога вы прасілі.", + "Forgot your master password?": "Забылі галоўны пароль?", + "Hand the key over": "Перадаць ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дазволіць карыстальнікам, якія забылі галоўны пароль, вярнуць сховішча з пацвярджэннем супрацоўнікаў па аднаўленні, якіх вы прызначаеце.", + "New master password": "Новы галоўны пароль", + "No one is asking to recover their account.": "Ніхто не просіць аднавіць уліковы запіс.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа аднаўлення яшчэ няма. Адзін з супрацоўнікаў стварае яго ў сваіх наладах Keepiq.", + "Off": "Выключана", + "Officer {user} has no encryption set up yet.": "Супрацоўнік {user} яшчэ не наладзіў шыфраванне.", + "Officers (user IDs, separated by commas)": "Супрацоўнікі (ID карыстальнікаў праз коску)", + "Policy": "Палітыка", + "Publish this fingerprint internally, so users can check it before they enrol.": "Апублікуйце гэты адбітак унутры арганізацыі, каб карыстальнікі маглі праверыць яго перад запісам.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Адноўлена з дапамогай {officer}. Зараз змяніце ключ сховішча ў Наладах, Бяспека: \"Мой галоўны пароль скампраметаваны\".", + "Recovery key fingerprint: {fingerprint}": "Адбітак ключа аднаўлення: {fingerprint}", + "Recovery officer": "Супрацоўнік па аднаўленні", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Выдаленыя супрацоўнікі губляюць сваю копію зараз, але маглі адкрыць яе раней. Няхай супрацоўнік створыць новы ключ аднаўлення.", + "Repeat the new master password": "Паўтарыце новы галоўны пароль", + "Retire this recovery key": "Выключыць гэты ключ аднаўлення", + "Set the new master password": "Задаць новы галоўны пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертыфікат аднаўлення выдадзены не гэтым Keepiq. Не запісвайцеся і паведаміце адміністратару.", + "The words match, approve": "Словы супадаюць, пацвердзіць", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Гэты карыстальнік запісаны на аднаўленне ўліковага запісу. Аднаўленне захоўвае яго сакрэты; адкліканне выдаляе яго запіс.", + "Users may enrol": "Карыстальнікі могуць запісвацца", + "Withdraw from account recovery": "Выйсці з аднаўлення ўліковага запісу", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Вы запісаны на аднаўленне ўліковага запісу. Адбітак ключа аднаўлення: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Вы запісаны. Калі вы забудзеце галоўны пароль, ваша арганізацыя дапаможа вярнуць сховішча.", + "Your key is back. Choose a new master password.": "Ваш ключ вернуты. Выберыце новы галоўны пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Вашы супрацоўнікі па аднаўленні папярэджаны. Прачытайце ім гэтыя словы, калі яны патэлефануюць або сустрэнуцца з вамі:", + "You are now an account recovery officer": "Цяпер вы супрацоўнік па аднаўленні ўліковых запісаў", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просіць аднавіць уліковы запіс. Звярыце з ім словы перад пацвярджэннем.", + "A user": "Карыстальнік", + "Your account recovery request was declined": "Ваш запыт на аднаўленне ўліковага запісу адхілены", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Аднаўленне ўліковага запісу гатова. Адкрыйце Keepiq у браўзеры, з якога вы прасілі.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просіць адзін раз разблакаваць новую прыладу. Галоўны пароль застаецца ранейшым.", + "Ask your organisation instead": "Лепш папрасіць сваю арганізацыю", + "The request ended. Ask again or use your master password.": "Запыт завершаны. Папрасіце яшчэ раз або ўвядзіце галоўны пароль.", + "Added by {user}": "Дададзена карыстальнікам {user}", + "Editor": "Рэдактар", + "Manager": "Кіраўнік", + "Role of {member}": "Роля {member}", + "Team folders you manage": "Камандныя папкі, якімі вы кіруеце", + "Viewer": "Глядач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас няма копіі гэтых сакрэтаў, таму новыя ўдзельнікі іх яшчэ не атрымалі. Уладальнік можа падзяліцца імі: {names}", + "Admin areas": "Вобласці адміністравання", + "Give a group only the parts of Keepiq administration it needs.": "Дайце групе толькі тыя часткі адміністравання Keepiq, якія ёй патрэбныя.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Дэлегуйце адну ці некалькі абласцей групе на старонцы правоў адміністравання. Адміністратары экзэмпляра маюць усе вобласці.", + "Open administration privileges": "Адкрыць правы адміністравання", + "Policies": "Палітыкі", + "Applications and machine access": "Праграмы і доступ машын", + "People and offboarding": "Людзі і звальненне", + "Audit and compliance": "Аўдыт і адпаведнасць", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версія, цэнтр сертыфікацыі, далучэнні, аўтаномны кэш, праверка ўцечак, тыпы сакрэтаў і рэзервовыя копіі", + "master password, organisation password, vault policies, rotation, version history and trash": "галоўны пароль, пароль арганізацыі, палітыкі сховішча, ратацыя, гісторыя версій і сметніца", + "application queue, application requests and machine leases": "чарга праграм, запыты праграм і арэнды машын", + "team offboarding, encryption suites and admin handover": "звальненне з каманды, наборы шыфравання і перадача адміністратару", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аўдыту, справаздачы аб адпаведнасці, экспарт у SIEM і абвесткі пра прынады", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колькі версій сакрэту захоўваецца, як доўга і як доўга выдаленыя сакрэты застаюцца ў сметніцы.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Абмежаванні для зашыфраваных далучэнняў, якія сервер прымяняе да захаваных зашыфраваных байтаў.", + "Type the suite ID again to confirm": "Увядзіце ID набору яшчэ раз для пацвярджэння", + "This does not match the suite ID.": "Гэта не супадае з ID набору.", + "Confirm with your master password": "Пацвердзіце асноўным паролем", + "Confirm": "Пацвердзіць", + "That master password is not right.": "Гэты асноўны пароль няправільны.", + "You are sharing with someone new. Enter your master password to confirm.": "Вы дзеліцеся з новым чалавекам. Увядзіце асноўны пароль для пацвярджэння.", + "Enter your master password to confirm this share.": "Увядзіце асноўны пароль, каб пацвердзіць гэты доступ.", + "Enter your master password to confirm this delegation.": "Увядзіце асноўны пароль, каб пацвердзіць гэта дэлегаванне.", + "Approve {member}": "Ухваліць {member}", + "Recipient": "Атрымальнік", + "No vault yet": "Яшчэ няма сховішча", + "No matching users": "Няма адпаведных карыстальнікаў", + "Partner organisations": "Партнёрскія арганізацыі", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Абменьвайцеся сакрэтамі з іншым Keepiq. Абодва адміністратары дадаюць адзін аднаго і перад захаваннем звяраюць каранёвыя адбіткі па тэлефоне або асабіста.", + "Federation needs Nextcloud 33 or later.": "Для федэрацыі патрэбны Nextcloud 33 або навейшы.", + "Your root fingerprint": "Ваш каранёвы адбітак", + "No partners yet.": "Партнёраў пакуль няма.", + "Users here may share to this partner": "Карыстальнікі тут могуць дзяліцца з гэтым партнёрам", + "This partner may share to users here": "Гэты партнёр можа дзяліцца з карыстальнікамі тут", + "Partner address": "Адрас партнёра", + "Check partner": "Праверыць партнёра", + "Partner root fingerprint": "Каранёвы адбітак партнёра", + "I compared this fingerprint with the partner's administrator": "Я звярыў гэты адбітак з адміністратарам партнёра", + "Add partner": "Дадаць партнёра", + "A secret from another organisation": "Сакрэт з іншай арганізацыі", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Карыстальнік %1$s даў вам доступ да \"%2$s\". Прыміце яго ў раздзеле Уваходныя з іншых арганізацый.", + "Incoming from other organisations": "Уваходныя з іншых арганізацый", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Людзі з партнёрскіх арганізацый могуць даць вам доступ да сакрэту. Прыміце яго, каб захоўваць копію толькі для чытання ў сваім сховішчы.", + "Nothing shared with you yet": "Вам яшчэ нічога не дадзена", + "Secrets that people in partner organisations share with you appear here.": "Тут з’яўляюцца сакрэты, да якіх людзі з партнёрскіх арганізацый даюць вам доступ.", + "From {sender}": "Ад {sender}", + "Accept": "Прыняць", + "Open in vault": "Адкрыць у сховішчы", + "The other organisation did not hand over the secret. Try again later.": "Іншая арганізацыя не перадала сакрэт. Паспрабуйце пазней.", + "Set up your vault before you accept a shared secret.": "Наладзьце сховішча, перш чым прымаць агульны сакрэт.", + "Something went wrong. Try again.": "Нешта пайшло не так. Паспрабуйце яшчэ раз.", + "Waiting for your answer": "Чакае вашага адказу", + "In your vault, read-only": "У вашым сховішчы, толькі для чытання", + "Withdrawn by the sender": "Адклікана адпраўніком", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Карыстальнік {sender} даў доступ да гэтага з іншай арганізацыі. Вы можаце яго чытаць, але не змяняць і не перадаваць.", + "Someone": "Хтосьці", + "Share with someone at another organisation": "Падзяліцца з кімсьці з іншай арганізацыі", + "Their account at the other organisation": "Уліковы запіс гэтага чалавека ў іншай арганізацыі", + "Check account": "Праверыць уліковы запіс", + "Certificate fingerprint of {account}": "Адбітак сертыфіката {account}", + "Compare it with them by phone if you want to be sure.": "Звярыце яго з гэтым чалавекам па тэлефоне, калі хочаце быць упэўненымі.", + "Shared. {account} can accept it in their own vault.": "Доступ дадзены. {account} можа прыняць сакрэт у сваім сховішчы.", + "The certificate could not be verified. Nothing was shared.": "Не ўдалося праверыць сертыфікат. Нічога не перададзена.", + "That organisation is not one of your partners.": "Гэтая арганізацыя не з’яўляецца вашым партнёрам.", + "No one with that account can receive secrets from you.": "Ніхто з гэтым уліковым запісам не можа атрымліваць ад вас сакрэты.", + "The other organisation did not answer. Try again later.": "Іншая арганізацыя не адказала. Паспрабуйце пазней.", + "This secret is already shared with that account.": "Доступ да гэтага сакрэту ўжо дадзены гэтаму ўліковаму запісу.", + "Other organisations": "Іншыя арганізацыі", + "Receive secrets from other organisations": "Атрымліваць сакрэты з іншых арганізацый", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тады людзі з партнёрскіх арганізацый змогуць знайсці ваш уліковы запіс і даваць вам доступ да сакрэтаў. Кожны з іх вы прымаеце самі.", + "Shared": "Доступ дадзены", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Прыпынена: змяніўся іх сертыфікат або партнёрства. Доступ можна адклікаць або даць зноў.", + "Their organisation did not get the last change. Revoke it or share again.": "Іх арганізацыя не атрымала апошнюю змену. Доступ можна адклікаць або даць зноў.", + "Being withdrawn": "Адклікаецца", + "Shared with another organisation": "Супольны доступ дадзены іншай арганізацыі", + "Change sent to another organisation": "Змена адпраўлена іншай арганізацыі", + "Share with another organisation revoked": "Супольны доступ для іншай арганізацыі адкліканы", + "Share with another organisation paused": "Супольны доступ для іншай арганізацыі прыпынены", + "Another organisation did not get a change": "Іншая арганізацыя не атрымала змену", + "Secret received from another organisation": "Сакрэт атрыманы з іншай арганізацыі", + "Secret from another organisation accepted": "Сакрэт з іншай арганізацыі прыняты", + "Secret from another organisation declined": "Сакрэт з іншай арганізацыі адхілены", + "Copy from another organisation updated": "Копія з іншай арганізацыі абноўлена", + "Copy from another organisation removed": "Копія з іншай арганізацыі выдалена", + "Declined: they removed their copy. Share again if they need it.": "Адхілена: атрымальнік выдаліў сваю копію. Дайце доступ зноў, калі ён патрэбны.", + "Recipient at another organisation removed their copy": "Атрымальнік з іншай арганізацыі выдаліў сваю копію", + "Removed the user from %n team folder.": "Карыстальніка выдалена з %n камандной папкі.", + "Removed the user from %n team folders.": "Карыстальніка выдалена з камандных папак: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Карыстальніка выдалена з %n камандной папкі.","Карыстальніка выдалена з камандных папак: %n.","Карыстальніка выдалена з камандных папак: %n."], + "A restored copy came from a share that has ended. It stays read-only.": "Адноўленая копія паходзіць з доступу, які скончыўся. Яна застаецца толькі для чытання.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не ўдалося звязацца з арганізацыяй, якая падзялілася адноўленай копіяй. Копія застаецца толькі для чытання і не атрымлівае іх змены.", + "Recipient at another organisation restored their copy": "Атрымальнік з іншай арганізацыі аднавіў сваю копію" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/be.json b/l10n/be.json index 0c3c43f17..d9fcdd9b1 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1181,7 +1181,592 @@ "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.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ратацыя ключа выдаліла надзвычайны доступ гэтага кантакту. Прызначце яго зноў, калі ён вам яшчэ патрэбны.", + "Shared with groups": "Абагулена з групамі", + "Not shared with any group yet.": "Яшчэ не абагулена ні з адной групай.", + "Revoke the share with {group}": "Адклікаць абагульванне з {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Абагулена з {group}: {received} удзельнікаў атрымалі, {skipped} не, бо ў іх яшчэ не наладжана шыфраванне.", + "Search groups": "Шукаць групы", + "Failed to share": "Не ўдалося абагуліць", + "Columns": "Слупкі", + "Column {number}": "Слупок {number}", + "Map one column to Name. Every secret needs a name.": "Звяжыце адзін слупок з назвай. Кожны сакрэт павінен мець назву.", + "Notes": "Нататкі", + "Do not import": "Не імпартаваць", + "Hide this value": "Схаваць гэта значэнне", + "Show this value": "Паказаць гэта значэнне", + "Defaults": "Па змаўчанні", + "New secrets start as this type, and your secret list opens in this view.": "Новыя сакрэты ствараюцца з гэтым тыпам, а спіс сакрэтаў адкрываецца ў гэтым выглядзе.", + "Default item type": "Тып элемента па змаўчанні", + "Cards": "Карткі", + "Table": "Табліца", + "Could not save your default": "Не ўдалося захаваць значэнне па змаўчанні", + "Recently used": "Нядаўна выкарыстаныя", + "Opened": "Адкрыта", + "You have not opened any secrets yet": "Вы яшчэ не адкрывалі сакрэты", + "Could not delete the item type.": "Не ўдалося выдаліць тып элемента.", + "Could not load the item types.": "Не ўдалося загрузіць тыпы элементаў.", + "Could not save the item type.": "Не ўдалося захаваць тып элемента.", + "Delete item type": "Выдаліць тып элемента", + "Edit item type": "Рэдагаваць тып элемента", + "Fields": "Палі", + "Fields: {count}": "Палі: {count}", + "Hidden": "Схаванае", + "Item types": "Тыпы элементаў", + "Move up": "Уверх", + "New item type": "Новы тып элемента", + "No item types defined yet.": "Тыпы элементаў яшчэ не вызначаны.", + "Required": "Абавязковае", + "Text": "Тэкст", + "This field is required": "Гэта поле абавязковае", + "Web address": "Вэб-адрас", + "{label} (required)": "{label} (абавязкова)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Выдаліць «{name}»? Сакрэты гэтага тыпу застануцца чытэльнымі і стануць элементамі Логін.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Тыпы элементаў, якія вы вызначыце тут, з'явяцца ва ўсіх у акне Новы сакрэт з выбранымі вамі палямі.", + "Secret moved to the trash": "Сакрэт перамешчаны ў сметніцу", + "Secret restored from the trash": "Сакрэт адноўлены са сметніцы", + "Secret deleted for good": "Сакрэт выдалены назаўсёды", + "Secret archived": "Сакрэт архіваваны", + "Secret unarchived": "Сакрэт выняты з архіва", + "Unarchive": "Выняць з архіва", + "Could not archive the secret": "Не ўдалося архіваваць сакрэт", + "Could not unarchive the secret": "Не ўдалося выняць сакрэт з архіва", + "Archive {count} secrets": "Архіваваць сакрэты: {count}", + "Unarchive {count} secrets": "Выняць з архіва сакрэты: {count}", + "Restore {count} secrets": "Аднавіць сакрэты: {count}", + "Delete {count} secrets for good": "Выдаліць назаўсёды сакрэты: {count}", + "Done for {ok} of {total} secrets": "Гатова для {ok} з {total} сакрэтаў", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архіваваныя сакрэты знікаюць са спіса сховішча, пошуку, аўтазапаўнення і справаздачы пра стан. Агульны доступ да іх захоўваецца. Іх можна знайсці ў Архіве.", + "These secrets come back to the vault list, search and autofill.": "Гэтыя сакрэты вяртаюцца ў спіс сховішча, пошук і аўтазапаўненне.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Гэтыя сакрэты вяртаюцца ў спіс сховішча. Ранейшы агульны доступ не вяртаецца, таму пры патрэбе падзяліцеся імі зноў.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Гэта выдаліць сакрэты разам з укладаннямі і гісторыяй версій. Гэта нельга адрабіць.", + "Delete for good": "Выдаліць назаўсёды", + "Trash": "Сметніца", + "The trash is empty": "Сметніца пустая", + "No archived secrets": "Няма архіваваных сакрэтаў", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Выдаленыя сакрэты чакаюць тут да канца тэрміну захоўвання, пасля чаго выдаляюцца назаўсёды.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архівуйце сакрэт на яго панэлі звестак, каб прыбраць яго са спіса сховішча, пошуку і аўтазапаўнення.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Абмежаванні для зашыфраваных укладанняў (прымяняюцца на серверы да захаваных зашыфраваных байтаў), захоўванне гісторыі версій і колькі часу выдаленыя сакрэты застаюцца ў сметніцы.", + "Days a deleted secret stays in the trash (1 to 365)": "Колькі дзён выдалены сакрэт застаецца ў сметніцы (ад 1 да 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Сакрэт будзе перамешчаны ў сметніцу, а агульны доступ да яго адразу спыніцца. Яго можна аднавіць са сметніцы да канца тэрміну захоўвання: 30 дзён, калі адміністратар не змяніў гэты тэрмін.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Сакрэты будуць перамешчаны ў сметніцу ({count}), а агульны доступ да іх адразу спыніцца. Іх можна аднавіць са сметніцы да канца тэрміну захоўвання.", + "Remove {name} from favourites": "Прыбраць {name} з абранага", + "Add {name} to favourites": "Дадаць {name} у абранае", + "Could not change the favourite": "Не ўдалося змяніць абранае", + "Remove from favourites": "Прыбраць з абранага", + "Add to favourites": "Дадаць у абранае", + "Tags": "Меткі", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Меткі не шыфруюцца. Адміністратары сервера могуць іх чытаць, як і назвы папак.", + "Favourites": "Абранае", + "Filter by tag": "Фільтр па метцы", + "All tags": "Усе меткі", + "Last used": "Апошняе выкарыстанне", + "Tags for {count} secrets": "Меткі для {count} сакрэтаў", + "Tag": "Метка", + "Remove tag": "Прыбраць метку", + "Add tag": "Дадаць метку", + "Could not change the tags. Try again.": "Не ўдалося змяніць меткі. Паспрабуйце яшчэ раз.", + "Could not approve the application. It is still in the queue.": "Не ўдалося адобрыць заяўку. Яна ўсё яшчэ ў чарзе.", + "Could not reject the application. It is still in the queue.": "Не ўдалося адхіліць заяўку. Яна ўсё яшчэ ў чарзе.", + "Removed the user from {count} team folders.": "Карыстальніка выдалена з камандных папак: {count}.", + "Approve a share": "Адобрыць агульны доступ", + "This approval link is incomplete. Open it again from the notification.": "Спасылка для адабрэння няпоўная. Адкрыйце яе зноў з апавяшчэння.", + "Deny": "Адхіліць", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} далучыўся да групы, з якой вы дзеліцеся сакрэтам. Падзяліцца сакрэтам і з ім?", + "{requester} asks you to share a secret with {user}.": "{requester} просіць вас падзяліцца сакрэтам з {user}.", + "Shared. The recipient can now open the secret.": "Доступ нададзены. Цяпер атрымальнік можа адкрыць сакрэт.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Атрымальнік яшчэ не наладзіў Keepiq, таму доступ не нададзены. Паспрабуйце зноў, калі ён гэта зробіць.", + "Could not share the secret. Only its owner can approve this.": "Не ўдалося падзяліцца сакрэтам. Адобрыць гэта можа толькі яго ўладальнік.", + "Could not share the secret. Try again.": "Не ўдалося падзяліцца сакрэтам. Паспрабуйце яшчэ раз.", + "Denied. Nothing was shared.": "Адхілена. Доступ не нададзены.", + "Could not deny the request. Try again.": "Не ўдалося адхіліць запыт. Паспрабуйце яшчэ раз.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просіць вас падзяліцца сакрэтам \"%2$s\" з %3$s.", + "Expires on (optional)": "Тэрмін дзеяння да (неабавязкова)", + "Hand over to": "Перадаць", + "Choose a recipient": "Выберыце атрымальніка", + "Hand over temporarily": "Перадаць часова", + "Expiry rules": "Правілы тэрміну дзеяння", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Задайце, колькі могуць дзейнічаць паролі аднаго тыпу элементаў або адной папкі і калі нагадваць. Калі ўжываецца некалькі дат, улічваецца самая ранняя.", + "Delete rule": "Выдаліць правіла", + "Set by your administrator": "Зададзена адміністратарам", + "No expiry rules yet.": "Правілаў тэрміну дзеяння пакуль няма.", + "Applies to": "Ужываецца да", + "Item type": "Тып элемента", + "Maximum age in days (empty for reminders only)": "Максімальны ўзрост у днях (пуста толькі для нагадванняў)", + "Remind me this many days before, comma separated": "За колькі дзён нагадаць, праз коску", + "Save rule": "Захаваць правіла", + "An item type": "Тып элемента", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тып {name}", + "Expires after {days} days": "Тэрмін мінае праз {days} дз.", + "Reminders {days} days before": "Нагадванні за {days} дз.", + "Could not save the expiry rule.": "Не ўдалося захаваць правіла тэрміну дзеяння.", + "Could not delete the expiry rule.": "Не ўдалося выдаліць правіла тэрміну дзеяння.", + "All statuses": "Усе статусы", + "Compromised": "Скампраметаваны", + "Could not load the members.": "Не ўдалося загрузіць удзельнікаў.", + "Emergency contact": "Экстраны кантакт", + "Leaving user": "Карыстальнік, які сыходзіць", + "No": "Не", + "No users match this filter.": "Ніводзін карыстальнік не адпавядае гэтаму фільтру.", + "Not set up": "Не наладжана", + "Revoke suite": "Адклікаць набор", + "Revoked": "Адкліканы", + "Search users": "Шукаць карыстальнікаў", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Паглядзіце, хто з карыстальнікаў наладзіў сховішча. Пачніце звальненне або адклічце набор з радка.", + "Successor": "Пераемнік", + "Team folders": "Камандныя папкі", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Карыстальнік усё яшчэ ў групе {groups}, якая ўваходзіць у камандную папку. Выдаліце яго з групы або адключыце ўліковы запіс.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс.", + "Vault status": "Статус сховішча", + "Yes": "Так", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Экспарт у CXF НЕ ЗАШЫФРАВАНЫ. Кожны пароль і лагін будуць чытэльнымі як звычайны тэкст у спампаваным файле. Зберагайце файл у надзейным месцы і выдаліце яго адразу пасля выкарыстання.", + "Root certificate expiring soon": "Тэрмін дзеяння каранёвага сертыфіката хутка скончыцца", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Тэрмін дзеяння каранёвага сертыфіката сховішча сканчаецца праз %1$d дз. Абнавіце яго да гэтага. Абнаўленне наноў падпісвае кожны набор шыфравання.", + "Compromise recovery aborted": "Аднаўленне пасля кампраметацыі перапынена", + "Key rotation ended by a compromise revoke": "Змена ключа завершана адкліканнем з-за кампраметацыі", + "Encryption suite revoke refused": "Адкліканне набору шыфравання адхілена", + "Master password proof refused": "Пацвярджэнне галоўнага пароля адхілена", + "Your current master password": "Ваш бягучы галоўны пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "У %n экстранага кантакту быў запыт доступу ў чаканні, калі змена ключа выдаліла яго. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Гэтыя экстраныя кантакты не былі перанесены на ваш новы ключ. Іх экстраны доступ выдалены. Дадайце іх зноў у раздзеле Экстраны доступ, калі яны вам яшчэ патрэбныя.", + "Renew root certificate": "Абнавіць каранёвы сертыфікат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Будуць створаны новы каранёвы і прамежкавы сертыфікаты. Кожны актыўны набор шыфравання будзе падпісаны зноў. Гэта нельга адмяніць.", + "Renew root": "Абнавіць корань", + "Root renewed. {n} encryption suites signed again.": "Корань абноўлены. Зноў падпісана набораў шыфравання: {n}.", + "Could not renew the root certificate.": "Не ўдалося абнавіць каранёвы сертыфікат.", + "Lease policy for this application": "Палітыка арэнды для гэтай праграмы", + "In force now: {default} seconds by default, {max} seconds at most.": "Зараз дзейнічае: {default} секунд па змаўчанні, не больш за {max} секунд.", + "Leases are not renewable": "Арэнду нельга падаўжаць", + "Lease policy saved.": "Палітыка арэнды захавана.", + "Leave a field empty to use the instance value.": "Пакіньце поле пустым, каб выкарыстаць значэнне экзэмпляра.", + "Instance value: {value}": "Значэнне экзэмпляра: {value}", + "Renewal": "Падаўжэнне", + "Use the instance value ({value})": "Выкарыстаць значэнне экзэмпляра ({value})", + "Allowed": "Дазволена", + "Not allowed": "Не дазволена", + "Save lease policy": "Захаваць палітыку арэнды", + "Only an administrator can change this policy.": "Змяніць гэтую палітыку можа толькі адміністратар.", + "Could not save the lease policy.": "Не ўдалося захаваць палітыку арэнды.", + "{member} got access from {confirmer}.": "{member} атрымаў доступ ад {confirmer}.", + "Automatically confirm new team folder members": "Аўтаматычна пацвярджаць новых удзельнікаў камандных папак", + "Gave %n new member access to a team folder.": "%n новы ўдзельнік атрымаў доступ да каманднай папкі.", + "Gave %n new members access to a team folder.": "Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі.", + "Give new team folder members access without waiting for the folder owner.": "Давайце доступ новым удзельнікам, не чакаючы ўладальніка папкі.", + "New team folder members": "Новыя ўдзельнікі камандных папак", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Уладальнік або ўдзельнік з правам запісу пацвярджае іх з адкрытага сховішча. Keepiq ніколі не расшыфроўвае на серверы.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Чаканне, пакуль удзельнік з правам запісу адкрые Keepiq. Можна падзяліцца і зараз.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Частка рэакцыі на кампраметацыю не выканана ({failed} крок(аў)). Праверце журнал сервера, а потым зноў адклічце набор, каб завяршыць яе.", + "This also revoked suite {suite} and ended key migration {migration}.": "Гэта таксама адклікала набор {suite} і завяршыла міграцыю ключоў {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Адкліканне другога набору выдаліла %n кантакт аварыйнага доступу.", + "Revoking the second suite deleted %n emergency-access contacts.": "Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу.", + "A suite revoked as compromised cannot be reinstated.": "Набор, адкліканы як скампраметаваны, нельга аднавіць.", + "Archives to keep": "Колькі архіваў захоўваць", + "Back up every vault automatically": "Аўтаматычна ствараць копію кожнага сховішча", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Стварайце копію кожнага сховішча па раскладзе. Архівы ўтрымліваюць толькі шыфратэкст і аднаўляюцца праз occ.", + "Back up now": "Стварыць копію зараз", + "Backup public key (PEM, optional)": "Адкрыты ключ рэзервовай копіі (PEM, неабавязкова)", + "Backup requested for the next cron run": "Копію запытана на наступны запуск cron", + "Encrypted": "Зашыфравана", + "Every (hours)": "Кожныя (гадзін)", + "Last backup {when} failed: {error}": "Апошняя копія {when} не ўдалася: {error}", + "Last backup {when} succeeded.": "Апошняя копія {when} створана.", + "No archives yet.": "Архіваў пакуль няма.", + "Size": "Памер", + "Vault backups": "Рэзервовыя копіі сховішча", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "З ключом кожны архіў шыфруецца для яго. Захоўвайце закрыты ключ па-за гэтым серверам: ён патрэбны для праверкі ці аднаўлення.", + "Written": "Запісана", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n карыстальнік у вобласці дзеяння яшчэ не мае двухфактарнага ўваходу і не можа адкрыць сховішча, пакуль гэта ўключана.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Рэзервовыя коды не ўлічваюцца. Калі вашы карыстальнікі ўваходзяць праз пастаўшчыка ідэнтычнасці з уласным другім фактарам, выключыце іх групы.", + "Block personal vault export": "Забараніць экспарт асабістага сховішча", + "Keep work logins in team folders": "Захоўваць працоўныя ўліковыя даныя ў камандных папках", + "Move to a team folder": "Перамясціць у камандную папку", + "Not in a team folder": "Не ў каманднай папцы", + "Only for these groups (empty is everyone)": "Толькі для гэтых груп (пуста азначае ўсіх)", + "Require two-factor login before the vault opens": "Патрабаваць двухфактарны ўваход перад адкрыццём сховішча", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правілы для кожнага сховішча. Кожнае дзейнічае для ўсіх або толькі для выбраных груп.", + "Secret types that belong in a team folder": "Тыпы сакрэтаў, якія належаць да каманднай папкі", + "Set up two-factor login": "Наладзіць двухфактарны ўваход", + "Team folder you can write to": "Камандная папка, у якую вы можаце запісваць", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Карыстальнікі не могуць спампаваць рэзервовую копію, CSV ці файл пераносу. Іх пакет асабістых даных застаецца даступным.", + "Users cannot save these secret types in a personal folder.": "Карыстальнікі не могуць захоўваць гэтыя тыпы сакрэтаў у асабістай папцы.", + "Vault policies": "Правілы сховішча", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша арганізацыя не дазваляе экспарт асабістага сховішча. Ваш пакет асабістых даных у наладах застаецца даступным.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша арганізацыя захоўвае гэтыя сакрэты ў каманднай папцы. Перамясціце кожны ў камандную папку.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша арганізацыя захоўвае гэты тып сакрэту ў каманднай папцы. Выберыце адну са сваіх камандных папак або тую, у якую вы можаце запісваць.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша арганізацыя патрабуе двухфактарны ўваход, перш чым вы зможаце адкрыць сховішча.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Карыстальнікі выбіраюць, як доўга пашырэнне застаецца разблакаваным падчас бяздзейнасці. Вы задаеце найбольшы час, які можна выбраць.", + "Longest idle time before the extension locks": "Найбольшы час бяздзейнасці да блакіроўкі пашырэння", + "1 minute": "1 хвіліна", + "5 minutes": "5 хвілін", + "15 minutes": "15 хвілін", + "1 hour": "1 гадзіна", + "4 hours": "4 гадзіны", + "Connector": "Канектар", + "Directory (tenant) ID": "ІД каталога (арандатара)", + "Application (client) ID": "ІД праграмы (кліента)", + "Data collection rule immutable ID": "Нязменны ІД правіла збору даных", + "Stream name": "Назва патоку", + "Splunk index (optional)": "Індэкс Splunk (неабавязкова)", + "Sourcetype (optional)": "Sourcetype (неабавязкова)", + "Leave blank to keep the current one": "Пакіньце пустым, каб захаваць бягучае значэнне", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF праз syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Канчатковы пункт збору даных (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Сакрэт кліента (толькі запіс)", + "HEC token (write-only)": "Токен HEC (толькі запіс)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Перасылайце дазволеныя падзеі аўдыту ў Splunk, Microsoft Sentinel, прымач syslog або вэб-хук. Паведамленні змяшчаюць толькі ачышчаныя метаданыя: ніякае сакрэтнае значэнне, імя, лагін ці шыфратэкст ніколі не пакідае сервер.", + "%n change waiting to sync": "%n змена чакае сінхранізацыі", + "%n changes waiting to sync": "%n змен чакаюць сінхранізацыі", + "Changes that could not sync": "Змены, якія не ўдалося сінхранізаваць", + "Choose a version": "Выбраць версію", + "Copy value": "Скапіраваць значэнне", + "Deleted": "Выдалена", + "Discard": "Адкінуць", + "Keep my offline change": "Пакінуць маю змену без сеткі", + "Keep the server version": "Пакінуць версію з сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без сеткі Keepiq даступны толькі для чытання. Адміністратар не ўключыў рэдагаванне без сеткі.", + "Let users edit secrets offline": "Дазволіць карыстальнікам рэдагаваць сакрэты без сеткі", + "Not synced yet": "Яшчэ не сінхранізавана", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Змены без сеткі застаюцца на прыладзе, зашыфраваныя для карыстальніка, і сінхранізуюцца пры наступнай разблакіроўцы ў сетцы. Абагульванне, папкі і далучэнні па-ранейшаму патрабуюць злучэння.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без сеткі. Праўкі, перамяшчэнні і выдаленні застаюцца на гэтай прыладзе і сінхранізуюцца, калі вы зноў будзеце ў сетцы. Абагульванне і далучэнні патрабуюць злучэння.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без сеткі. Вашы змены застаюцца на гэтай прыладзе і сінхранізуюцца, калі вы зноў будзеце ў сетцы. Апошняя сінхранізацыя {when}.", + "Open my changes": "Адкрыць мае змены", + "Sharing needs a connection": "Абагульванне патрабуе злучэння", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Хтосьці змяніў гэты сакрэт на серверы пасля стварэння вашай копіі без сеткі. Выберыце, якую версію пакінуць.", + "Sync or discard your offline changes before you rotate your keys.": "Сінхранізуйце або адкіньце змены без сеткі, перш чым мяняць ключы.", + "That password did not open your changes.": "Гэты пароль не адкрыў вашы змены.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Здымак без сеткі захоўвае зашыфраваныя сакрэты (адкрываюцца толькі ключом, атрыманым з галоўнага пароля карыстальніка, роўна як на серверы) і шыфруе назвы, URL і назвы папак пры захоўванні. Доступ без сеткі толькі для чытання, калі ніжэй вы не дазволіце рэдагаванне без сеткі. Адключыце гэта для прылад, якія ніколі не павінны кэшаваць уліковыя даныя; адключэнне ачышчае наяўныя кэшы пры наступнай загрузцы.", + "The previous vault copy is gone, so these changes cannot be opened.": "Папярэдняй копіі сховішча больш няма, таму гэтыя змены нельга адкрыць.", + "The server version": "Версія з сервера", + "This secret changed while you were offline": "Гэты сакрэт змяніўся, пакуль вы былі без сеткі", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Вы выдалілі гэты сакрэт без сеткі, але з таго часу яго змянілі на серверы. Выберыце, якую версію пакінуць.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Вашы ключы змяніліся на іншай прыладзе. Увядзіце папярэдні галоўны пароль, каб сінхранізаваць змены без сеткі, або адкіньце іх.", + "Your offline change": "Ваша змена без сеткі", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "У %n экстранага кантакту быў запыт доступу ў чаканні, калі змена ключа выдаліла яго. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць.", + "У экстраных кантактаў (%n) быў запыт доступу ў чаканні, калі змена ключа выдаліла іх. Праверце, хто запытваў, перш чым зноў кагосьці дадаваць." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n элемент нельга падаць у CXF, ён будзе прапушчаны.", + "%n элементаў нельга падаць у CXF, яны будуць прапушчаны.", + "%n элементаў нельга падаць у CXF, яны будуць прапушчаны." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n старая версія была выдалена, бо перанесці можна толькі нядаўнюю гісторыю.", + "%n старых версій былі выдалены, бо перанесці можна толькі нядаўнюю гісторыю.", + "%n старых версій былі выдалены, бо перанесці можна толькі нядаўнюю гісторыю." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Яшчэ трэба зашыфраваць і даць %n копію сакрэту.", + "Яшчэ трэба зашыфраваць і даць %n копій сакрэтаў.", + "Яшчэ трэба зашыфраваць і даць %n копій сакрэтаў." + ], + "_%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 сакрэт не ўдалося расшыфраваць, і яго няма ў гэтым экспарце.", + "%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце.", + "%n сакрэтаў не ўдалося расшыфраваць, і іх няма ў гэтым экспарце." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n сакрэт не ўдалося расшыфраваць вашым старым ключом, таму ён не быў перанесены.", + "%n сакрэтаў не ўдалося расшыфраваць вашым старым ключом, таму яны не былі перанесены.", + "%n сакрэтаў не ўдалося расшыфраваць вашым старым ключом, таму яны не былі перанесены." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n сакрэт не быў перанесены.", + "%n сакрэтаў не былі перанесены.", + "%n сакрэтаў не былі перанесены." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n сакрэт усё яшчэ зашыфраваны вашым папярэднім ключом.", + "%n сакрэтаў усё яшчэ зашыфраваныя вашым папярэднім ключом.", + "%n сакрэтаў усё яшчэ зашыфраваныя вашым папярэднім ключом." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n сакрэт прапушчаны, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова.", + "%n сакрэтаў прапушчана, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова.", + "%n сакрэтаў прапушчана, бо ў наступніка яшчэ няма копіі — дадайце наступніка ў папку і запусціце нанова." + ], + "_%n secret_::_%n secrets_": [ + "%n сакрэт", + "%n сакрэтаў", + "%n сакрэтаў" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n карыстальнік у вобласці дзеяння яшчэ не мае двухфактарнага ўваходу і не можа адкрыць сховішча, пакуль гэта ўключана.", + "Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана.", + "Карыстальнікі ў вобласці дзеяння (%n) яшчэ не маюць двухфактарнага ўваходу і не могуць адкрыць сховішча, пакуль гэта ўключана." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Усё роўна завяршыць, страціўшы доступ да %n сакрэту", + "Усё роўна завяршыць, страціўшы доступ да %n сакрэтаў", + "Усё роўна завяршыць, страціўшы доступ да %n сакрэтаў" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n новы ўдзельнік атрымаў доступ да каманднай папкі.", + "Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі.", + "Новыя ўдзельнікі (%n) атрымалі доступ да каманднай папкі." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ратацыя ключа завершана. %n сакрэт перашыфраваны вашым новым ключом.", + "Ратацыя ключа завершана. %n сакрэтаў перашыфравана вашым новым ключом.", + "Ратацыя ключа завершана. %n сакрэтаў перашыфравана вашым новым ключом." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Адкліканне другога набору выдаліла %n кантакт аварыйнага доступу.", + "Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу.", + "Адкліканне другога набору выдаліла %n кантактаў аварыйнага доступу." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Адкліканне гэтага набору выдаліла %n кантакт аварыйнага доступу.", + "Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу.", + "Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "сустракаецца ў ўцечках %n раз", + "сустракаецца ў ўцечках %n разоў", + "сустракаецца ў ўцечках %n разоў" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "дадзена %n сакрэту", + "дадзена %n сакрэтам", + "дадзена %n сакрэтам" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Гэта папка змяшчае %n сакрэт напрамую.", + "Гэта папка змяшчае %n сакрэтаў напрамую.", + "Гэта папка змяшчае %n сакрэтаў напрамую." + ], + "_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._": [ + "Ратацыя ключа выдаліла %n экстраны кантакт. Праверце «Надзвычайны доступ» і дадайце яго зноў, калі ён вам яшчэ патрэбны.", + "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя.", + "Ратацыя ключа выдаліла %n экстраных кантактаў. Праверце «Надзвычайны доступ» і дадайце іх зноў, калі яны вам яшчэ патрэбныя." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n змена чакае сінхранізацыі", + "%n змен чакаюць сінхранізацыі", + "%n змен чакаюць сінхранізацыі" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Карыстальнік усё яшчэ ў групе {groups}, якая ўваходзіць у камандную папку. Выдаліце яго з групы або адключыце ўліковы запіс.", + "Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс.", + "Карыстальнік усё яшчэ ў групах {groups}, якія ўваходзяць у камандныя папкі. Выдаліце яго з груп або адключыце ўліковы запіс." + ], + "Allow approval from another device": "Дазволіць пацвярджэнне з іншай прылады", + "App": "Праграма", + "Approve a new device": "Пацвердзіць новую прыладу", + "Approve from another device": "Пацвердзіць з іншай прылады", + "Asked at": "Запытана ў", + "Check that the new device shows these words:": "Праверце, што новая прылада паказвае гэтыя словы:", + "Denied. If you did not ask, end your other sessions:": "Адхілена. Калі вы гэтага не запытвалі, завяршыце іншыя сеансы:", + "Device": "Прылада", + "IP address": "IP-адрас", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Дазваляе карыстальнікам разблакаваць новы браўзер, пацвердзіўшы яго з прылады, на якой Keepiq ужо разблакаваны.", + "New device approval": "Пацвярджэнне новых прылад", + "Nextcloud security settings": "Налады бяспекі Nextcloud", + "Only approve a device you are using right now.": "Пацвярджайце толькі прыладу, якой карыстаецеся зараз.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Адкрыйце Keepiq на прыладзе, дзе ён разблакаваны, і пацвердзіце гэтую прыладу. Праверце, што там паказаны тыя ж словы:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Прылада, якая пацвярджае, запячатвае ключ разблакавання для новай прылады. Сервер толькі перадае яго і не можа яго адкрыць.", + "The master password is not right, or the request has ended.": "Галоўны пароль няправільны, або запыт завершаны.", + "The request expired. Ask again or use your master password.": "Тэрмін запыту скончыўся. Запытайце зноў або выкарыстайце галоўны пароль.", + "The request was denied.": "Запыт адхілены.", + "Too many requests. Try again in an hour or use your master password.": "Занадта шмат запытаў. Паспрабуйце праз гадзіну або выкарыстайце галоўны пароль.", + "Unknown device": "Невядомая прылада", + "Web app": "Вэб-праграма", + "A device": "Прылада", + "A new device asks to open your vault": "Новая прылада просіць адкрыць ваша сховішча", + "%s asks to be approved. Only approve a device you are using right now.": "%s просіць пацвярджэння. Пацвярджайце толькі прыладу, якой карыстаецеся зараз.", + "Access ends on (optional)": "Доступ заканчваецца (неабавязкова)", + "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.": "Праграмы Keepiq не будуць паказваць або капіраваць пароль. Чалавек з тэхнічнымі навыкамі ўсё роўна можа прачытаць яго на сваёй прыладзе. Змяніце яго, калі доступ скончыцца.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Гэты сакрэт толькі для выкарыстання. Увайдзіце праз пашырэнне браўзера Keepiq.", + "Until {date}": "Да {date}", + "Use only": "Толькі выкарыстанне", + "Use only (can sign in, cannot view or copy)": "Толькі выкарыстанне (можа ўвайсці, не можа праглядаць або капіраваць)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Вы можаце ўвайсці з гэтымі ўліковымі данымі праз пашырэнне браўзера Keepiq. Уладальнік вырашыў не дазваляць вам праглядаць або капіраваць іх.", + "Your access ends on {date}": "Ваш доступ заканчваецца {date}", + "Your access to this secret has ended": "Ваш доступ да гэтага сакрэту скончыўся", + "Your access to \"%s\" ends tomorrow": "Ваш доступ да «%s» заканчваецца заўтра", + "Your access to \"%s\" has ended": "Ваш доступ да «%s» скончыўся", + "%1$s no longer has access to \"%2$s\"": "%1$s больш не мае доступу да «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s мог(ла) бачыць гэты пароль. Змяніце яго, калі %1$s больш не павінен(на) яго ведаць.", + "%s could not view this password in Keepiq.": "%s не змог(ла) праглядзець гэты пароль у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} з {threshold} пацвярджэнняў", + "a recovery officer": "супрацоўнік па аднаўленні", + "Account recovery": "Аднаўленне ўліковага запісу", + "Approvals needed": "Патрэбна пацвярджэнняў", + "Ask {user} which words they see, by phone or in person. They must be:": "Спытайце ў {user}, якія словы ён бачыць, па тэлефоне або асабіста. Яны павінны быць:", + "Check again": "Праверыць зноў", + "Create the recovery key": "Стварыць ключ аднаўлення", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Стварыце ключ аднаўлення. Ваш браўзер стварае яго і дае кожнаму супрацоўніку копію, якую можа адкрыць толькі ён.", + "Decline": "Адхіліць", + "Enrol in account recovery": "Запісацца на аднаўленне ўліковага запісу", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Запішыцеся, каб ваша арганізацыя дапамагла вярнуць сховішча, калі вы забудзеце галоўны пароль.", + "Every user is enrolled": "Усе карыстальнікі запісаныя", + "Finish the recovery in the browser you asked from.": "Завяршыце аднаўленне ў браўзеры, з якога вы прасілі.", + "Forgot your master password?": "Забылі галоўны пароль?", + "Hand the key over": "Перадаць ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дазволіць карыстальнікам, якія забылі галоўны пароль, вярнуць сховішча з пацвярджэннем супрацоўнікаў па аднаўленні, якіх вы прызначаеце.", + "New master password": "Новы галоўны пароль", + "No one is asking to recover their account.": "Ніхто не просіць аднавіць уліковы запіс.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа аднаўлення яшчэ няма. Адзін з супрацоўнікаў стварае яго ў сваіх наладах Keepiq.", + "Off": "Выключана", + "Officer {user} has no encryption set up yet.": "Супрацоўнік {user} яшчэ не наладзіў шыфраванне.", + "Officers (user IDs, separated by commas)": "Супрацоўнікі (ID карыстальнікаў праз коску)", + "Policy": "Палітыка", + "Publish this fingerprint internally, so users can check it before they enrol.": "Апублікуйце гэты адбітак унутры арганізацыі, каб карыстальнікі маглі праверыць яго перад запісам.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Адноўлена з дапамогай {officer}. Зараз змяніце ключ сховішча ў Наладах, Бяспека: \"Мой галоўны пароль скампраметаваны\".", + "Recovery key fingerprint: {fingerprint}": "Адбітак ключа аднаўлення: {fingerprint}", + "Recovery officer": "Супрацоўнік па аднаўленні", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Выдаленыя супрацоўнікі губляюць сваю копію зараз, але маглі адкрыць яе раней. Няхай супрацоўнік створыць новы ключ аднаўлення.", + "Repeat the new master password": "Паўтарыце новы галоўны пароль", + "Retire this recovery key": "Выключыць гэты ключ аднаўлення", + "Set the new master password": "Задаць новы галоўны пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертыфікат аднаўлення выдадзены не гэтым Keepiq. Не запісвайцеся і паведаміце адміністратару.", + "The words match, approve": "Словы супадаюць, пацвердзіць", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Гэты карыстальнік запісаны на аднаўленне ўліковага запісу. Аднаўленне захоўвае яго сакрэты; адкліканне выдаляе яго запіс.", + "Users may enrol": "Карыстальнікі могуць запісвацца", + "Withdraw from account recovery": "Выйсці з аднаўлення ўліковага запісу", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Вы запісаны на аднаўленне ўліковага запісу. Адбітак ключа аднаўлення: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Вы запісаны. Калі вы забудзеце галоўны пароль, ваша арганізацыя дапаможа вярнуць сховішча.", + "Your key is back. Choose a new master password.": "Ваш ключ вернуты. Выберыце новы галоўны пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Вашы супрацоўнікі па аднаўленні папярэджаны. Прачытайце ім гэтыя словы, калі яны патэлефануюць або сустрэнуцца з вамі:", + "You are now an account recovery officer": "Цяпер вы супрацоўнік па аднаўленні ўліковых запісаў", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просіць аднавіць уліковы запіс. Звярыце з ім словы перад пацвярджэннем.", + "A user": "Карыстальнік", + "Your account recovery request was declined": "Ваш запыт на аднаўленне ўліковага запісу адхілены", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Аднаўленне ўліковага запісу гатова. Адкрыйце Keepiq у браўзеры, з якога вы прасілі.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просіць адзін раз разблакаваць новую прыладу. Галоўны пароль застаецца ранейшым.", + "Ask your organisation instead": "Лепш папрасіць сваю арганізацыю", + "The request ended. Ask again or use your master password.": "Запыт завершаны. Папрасіце яшчэ раз або ўвядзіце галоўны пароль.", + "Added by {user}": "Дададзена карыстальнікам {user}", + "Editor": "Рэдактар", + "Manager": "Кіраўнік", + "Role of {member}": "Роля {member}", + "Team folders you manage": "Камандныя папкі, якімі вы кіруеце", + "Viewer": "Глядач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас няма копіі гэтых сакрэтаў, таму новыя ўдзельнікі іх яшчэ не атрымалі. Уладальнік можа падзяліцца імі: {names}", + "Admin areas": "Вобласці адміністравання", + "Give a group only the parts of Keepiq administration it needs.": "Дайце групе толькі тыя часткі адміністравання Keepiq, якія ёй патрэбныя.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Дэлегуйце адну ці некалькі абласцей групе на старонцы правоў адміністравання. Адміністратары экзэмпляра маюць усе вобласці.", + "Open administration privileges": "Адкрыць правы адміністравання", + "Policies": "Палітыкі", + "Applications and machine access": "Праграмы і доступ машын", + "People and offboarding": "Людзі і звальненне", + "Audit and compliance": "Аўдыт і адпаведнасць", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версія, цэнтр сертыфікацыі, далучэнні, аўтаномны кэш, праверка ўцечак, тыпы сакрэтаў і рэзервовыя копіі", + "master password, organisation password, vault policies, rotation, version history and trash": "галоўны пароль, пароль арганізацыі, палітыкі сховішча, ратацыя, гісторыя версій і сметніца", + "application queue, application requests and machine leases": "чарга праграм, запыты праграм і арэнды машын", + "team offboarding, encryption suites and admin handover": "звальненне з каманды, наборы шыфравання і перадача адміністратару", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аўдыту, справаздачы аб адпаведнасці, экспарт у SIEM і абвесткі пра прынады", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колькі версій сакрэту захоўваецца, як доўга і як доўга выдаленыя сакрэты застаюцца ў сметніцы.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Абмежаванні для зашыфраваных далучэнняў, якія сервер прымяняе да захаваных зашыфраваных байтаў.", + "Type the suite ID again to confirm": "Увядзіце ID набору яшчэ раз для пацвярджэння", + "This does not match the suite ID.": "Гэта не супадае з ID набору.", + "Confirm with your master password": "Пацвердзіце асноўным паролем", + "Confirm": "Пацвердзіць", + "That master password is not right.": "Гэты асноўны пароль няправільны.", + "You are sharing with someone new. Enter your master password to confirm.": "Вы дзеліцеся з новым чалавекам. Увядзіце асноўны пароль для пацвярджэння.", + "Enter your master password to confirm this share.": "Увядзіце асноўны пароль, каб пацвердзіць гэты доступ.", + "Enter your master password to confirm this delegation.": "Увядзіце асноўны пароль, каб пацвердзіць гэта дэлегаванне.", + "Approve {member}": "Ухваліць {member}", + "Recipient": "Атрымальнік", + "No vault yet": "Яшчэ няма сховішча", + "No matching users": "Няма адпаведных карыстальнікаў", + "Partner organisations": "Партнёрскія арганізацыі", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Абменьвайцеся сакрэтамі з іншым Keepiq. Абодва адміністратары дадаюць адзін аднаго і перад захаваннем звяраюць каранёвыя адбіткі па тэлефоне або асабіста.", + "Federation needs Nextcloud 33 or later.": "Для федэрацыі патрэбны Nextcloud 33 або навейшы.", + "Your root fingerprint": "Ваш каранёвы адбітак", + "No partners yet.": "Партнёраў пакуль няма.", + "Users here may share to this partner": "Карыстальнікі тут могуць дзяліцца з гэтым партнёрам", + "This partner may share to users here": "Гэты партнёр можа дзяліцца з карыстальнікамі тут", + "Partner address": "Адрас партнёра", + "Check partner": "Праверыць партнёра", + "Partner root fingerprint": "Каранёвы адбітак партнёра", + "I compared this fingerprint with the partner's administrator": "Я звярыў гэты адбітак з адміністратарам партнёра", + "Add partner": "Дадаць партнёра", + "A secret from another organisation": "Сакрэт з іншай арганізацыі", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Карыстальнік %1$s даў вам доступ да \"%2$s\". Прыміце яго ў раздзеле Уваходныя з іншых арганізацый.", + "Incoming from other organisations": "Уваходныя з іншых арганізацый", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Людзі з партнёрскіх арганізацый могуць даць вам доступ да сакрэту. Прыміце яго, каб захоўваць копію толькі для чытання ў сваім сховішчы.", + "Nothing shared with you yet": "Вам яшчэ нічога не дадзена", + "Secrets that people in partner organisations share with you appear here.": "Тут з’яўляюцца сакрэты, да якіх людзі з партнёрскіх арганізацый даюць вам доступ.", + "From {sender}": "Ад {sender}", + "Accept": "Прыняць", + "Open in vault": "Адкрыць у сховішчы", + "The other organisation did not hand over the secret. Try again later.": "Іншая арганізацыя не перадала сакрэт. Паспрабуйце пазней.", + "Set up your vault before you accept a shared secret.": "Наладзьце сховішча, перш чым прымаць агульны сакрэт.", + "Something went wrong. Try again.": "Нешта пайшло не так. Паспрабуйце яшчэ раз.", + "Waiting for your answer": "Чакае вашага адказу", + "In your vault, read-only": "У вашым сховішчы, толькі для чытання", + "Withdrawn by the sender": "Адклікана адпраўніком", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Карыстальнік {sender} даў доступ да гэтага з іншай арганізацыі. Вы можаце яго чытаць, але не змяняць і не перадаваць.", + "Someone": "Хтосьці", + "Share with someone at another organisation": "Падзяліцца з кімсьці з іншай арганізацыі", + "Their account at the other organisation": "Уліковы запіс гэтага чалавека ў іншай арганізацыі", + "Check account": "Праверыць уліковы запіс", + "Certificate fingerprint of {account}": "Адбітак сертыфіката {account}", + "Compare it with them by phone if you want to be sure.": "Звярыце яго з гэтым чалавекам па тэлефоне, калі хочаце быць упэўненымі.", + "Shared. {account} can accept it in their own vault.": "Доступ дадзены. {account} можа прыняць сакрэт у сваім сховішчы.", + "The certificate could not be verified. Nothing was shared.": "Не ўдалося праверыць сертыфікат. Нічога не перададзена.", + "That organisation is not one of your partners.": "Гэтая арганізацыя не з’яўляецца вашым партнёрам.", + "No one with that account can receive secrets from you.": "Ніхто з гэтым уліковым запісам не можа атрымліваць ад вас сакрэты.", + "The other organisation did not answer. Try again later.": "Іншая арганізацыя не адказала. Паспрабуйце пазней.", + "This secret is already shared with that account.": "Доступ да гэтага сакрэту ўжо дадзены гэтаму ўліковаму запісу.", + "Other organisations": "Іншыя арганізацыі", + "Receive secrets from other organisations": "Атрымліваць сакрэты з іншых арганізацый", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тады людзі з партнёрскіх арганізацый змогуць знайсці ваш уліковы запіс і даваць вам доступ да сакрэтаў. Кожны з іх вы прымаеце самі.", + "Shared": "Доступ дадзены", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Прыпынена: змяніўся іх сертыфікат або партнёрства. Доступ можна адклікаць або даць зноў.", + "Their organisation did not get the last change. Revoke it or share again.": "Іх арганізацыя не атрымала апошнюю змену. Доступ можна адклікаць або даць зноў.", + "Being withdrawn": "Адклікаецца", + "Shared with another organisation": "Супольны доступ дадзены іншай арганізацыі", + "Change sent to another organisation": "Змена адпраўлена іншай арганізацыі", + "Share with another organisation revoked": "Супольны доступ для іншай арганізацыі адкліканы", + "Share with another organisation paused": "Супольны доступ для іншай арганізацыі прыпынены", + "Another organisation did not get a change": "Іншая арганізацыя не атрымала змену", + "Secret received from another organisation": "Сакрэт атрыманы з іншай арганізацыі", + "Secret from another organisation accepted": "Сакрэт з іншай арганізацыі прыняты", + "Secret from another organisation declined": "Сакрэт з іншай арганізацыі адхілены", + "Copy from another organisation updated": "Копія з іншай арганізацыі абноўлена", + "Copy from another organisation removed": "Копія з іншай арганізацыі выдалена", + "Declined: they removed their copy. Share again if they need it.": "Адхілена: атрымальнік выдаліў сваю копію. Дайце доступ зноў, калі ён патрэбны.", + "Recipient at another organisation removed their copy": "Атрымальнік з іншай арганізацыі выдаліў сваю копію", + "Removed the user from %n team folder.": "Карыстальніка выдалена з %n камандной папкі.", + "Removed the user from %n team folders.": "Карыстальніка выдалена з камандных папак: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Карыстальніка выдалена з %n камандной папкі.", + "Карыстальніка выдалена з камандных папак: %n.", + "Карыстальніка выдалена з камандных папак: %n." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Адноўленая копія паходзіць з доступу, які скончыўся. Яна застаецца толькі для чытання.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не ўдалося звязацца з арганізацыяй, якая падзялілася адноўленай копіяй. Копія застаецца толькі для чытання і не атрымлівае іх змены.", + "Recipient at another organisation restored their copy": "Атрымальнік з іншай арганізацыі аднавіў сваю копію" }, "plurals": null } diff --git a/l10n/bg.js b/l10n/bg.js index d50004eed..4269a5d75 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате.", + "Shared with groups": "Споделено с групи", + "Not shared with any group yet.": "Все още не е споделено с група.", + "Revoke the share with {group}": "Оттегляне на споделянето с {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено с {group}: {received} членове го получиха, {skipped} не, защото все още нямат настроено шифроване.", + "Search groups": "Търсене на групи", + "Failed to share": "Споделянето не бе успешно", + "Columns": "Колони", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Свържете една колона с името. Всяка тайна трябва да има име.", + "Notes": "Бележки", + "Do not import": "Не импортирай", + "Hide this value": "Скриване на тази стойност", + "Show this value": "Показване на тази стойност", + "Defaults": "По подразбиране", + "New secrets start as this type, and your secret list opens in this view.": "Новите тайни започват с този тип, а списъкът с тайни се отваря в този изглед.", + "Default item type": "Тип елемент по подразбиране", + "Cards": "Карти", + "Table": "Таблица", + "Could not save your default": "Стойността по подразбиране не можа да бъде запазена", + "Recently used": "Наскоро използвани", + "Opened": "Отворено", + "You have not opened any secrets yet": "Все още не сте отваряли тайни", + "Could not delete the item type.": "Типът елемент не можа да бъде изтрит.", + "Could not load the item types.": "Типовете елементи не можаха да бъдат заредени.", + "Could not save the item type.": "Типът елемент не можа да бъде запазен.", + "Delete item type": "Изтриване на тип елемент", + "Edit item type": "Редактиране на тип елемент", + "Fields": "Полета", + "Fields: {count}": "Полета: {count}", + "Hidden": "Скрито", + "Item types": "Типове елементи", + "Move up": "Нагоре", + "New item type": "Нов тип елемент", + "No item types defined yet.": "Все още няма дефинирани типове елементи.", + "Required": "Задължително", + "Text": "Текст", + "This field is required": "Това поле е задължително", + "Web address": "Уеб адрес", + "{label} (required)": "{label} (задължително)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Да се изтрие ли „{name}“? Тайните от този тип остават четими и стават елементи Вход.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типовете елементи, които дефинирате тук, се показват на всички в диалога Нова тайна, с полетата, които изберете.", + "Secret moved to the trash": "Тайната е преместена в кошчето", + "Secret restored from the trash": "Тайната е възстановена от кошчето", + "Secret deleted for good": "Тайната е изтрита окончателно", + "Secret archived": "Тайната е архивирана", + "Secret unarchived": "Тайната е извадена от архива", + "Unarchive": "Извади от архива", + "Could not archive the secret": "Тайната не можа да бъде архивирана", + "Could not unarchive the secret": "Тайната не можа да бъде извадена от архива", + "Archive {count} secrets": "Архивиране на тайни: {count}", + "Unarchive {count} secrets": "Изваждане от архива на тайни: {count}", + "Restore {count} secrets": "Възстановяване на тайни: {count}", + "Delete {count} secrets for good": "Окончателно изтриване на тайни: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} от {total} тайни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивираните тайни изчезват от списъка на трезора, търсенето, автоматичното попълване и доклада за състоянието. Запазват споделянията си. Ще ги намерите в Архив.", + "These secrets come back to the vault list, search and autofill.": "Тези тайни се връщат в списъка на трезора, търсенето и автоматичното попълване.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Тези тайни се връщат в списъка на трезора. Старите им споделяния не се връщат, затова ги споделете отново, където е нужно.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Това изтрива тайните заедно с прикачените файлове и историята на версиите. Действието не може да бъде отменено.", + "Delete for good": "Изтрий окончателно", + "Trash": "Кошче", + "The trash is empty": "Кошчето е празно", + "No archived secrets": "Няма архивирани тайни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Изтритите тайни чакат тук до края на срока на съхранение, след това се изтриват окончателно.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирайте тайна от панела ѝ с подробности, за да остане извън списъка на трезора, търсенето и автоматичното попълване.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничения за шифровани прикачени файлове (прилагат се на сървъра върху съхранените шифровани байтове), съхранение на историята на версиите и колко дълго изтритите тайни остават в кошчето.", + "Days a deleted secret stays in the trash (1 to 365)": "Дни, през които изтрита тайна остава в кошчето (от 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Това премества тайната в кошчето и прекратява споделянията ѝ веднага. Можете да я възстановите от кошчето до края на срока на съхранение: 30 дни, освен ако администраторът не го е променил.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Това премества тайните в кошчето ({count}) и прекратява споделянията им веднага. Можете да ги възстановите от кошчето до края на срока на съхранение.", + "Remove {name} from favourites": "Премахване на {name} от любими", + "Add {name} to favourites": "Добавяне на {name} към любими", + "Could not change the favourite": "Любимото не можа да бъде променено", + "Remove from favourites": "Премахване от любими", + "Add to favourites": "Добавяне към любими", + "Tags": "Етикети", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Етикетите не са шифровани. Администраторите на сървъра могат да ги четат, както и имената на папките.", + "Favourites": "Любими", + "Filter by tag": "Филтриране по етикет", + "All tags": "Всички етикети", + "Last used": "Последно използвано", + "Tags for {count} secrets": "Етикети за {count} тайни", + "Tag": "Етикет", + "Remove tag": "Премахване на етикет", + "Add tag": "Добавяне на етикет", + "Could not change the tags. Try again.": "Етикетите не можаха да бъдат променени. Опитайте отново.", + "Could not approve the application. It is still in the queue.": "Заявката не можа да бъде одобрена. Тя все още е в опашката.", + "Could not reject the application. It is still in the queue.": "Заявката не можа да бъде отхвърлена. Тя все още е в опашката.", + "Removed the user from {count} team folders.": "Потребителят е премахнат от {count} екипни папки.", + "Approve a share": "Одобряване на споделяне", + "This approval link is incomplete. Open it again from the notification.": "Тази връзка за одобрение е непълна. Отворете я отново от известието.", + "Deny": "Откажи", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се присъедини към група, с която споделяте тайна. Да споделите ли тайната и с него?", + "{requester} asks you to share a secret with {user}.": "{requester} ви моли да споделите тайна с {user}.", + "Shared. The recipient can now open the secret.": "Споделено. Получателят вече може да отвори тайната.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Получателят все още не е настроил Keepiq, затова нищо не е споделено. Опитайте отново, след като го направи.", + "Could not share the secret. Only its owner can approve this.": "Тайната не можа да бъде споделена. Само собственикът ѝ може да одобри това.", + "Could not share the secret. Try again.": "Тайната не можа да бъде споделена. Опитайте отново.", + "Denied. Nothing was shared.": "Отказано. Нищо не е споделено.", + "Could not deny the request. Try again.": "Заявката не можа да бъде отказана. Опитайте отново.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ви моли да споделите тайната \"%2$s\" с %3$s.", + "Expires on (optional)": "Изтича на (по избор)", + "Hand over to": "Предай на", + "Choose a recipient": "Изберете получател", + "Hand over temporarily": "Предай временно", + "Expiry rules": "Правила за изтичане", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Задайте колко дълго могат да съществуват паролите от даден тип елемент или в дадена папка и кога да получавате напомняне. Когато важат няколко дати, се взема най-ранната.", + "Delete rule": "Изтриване на правилото", + "Set by your administrator": "Зададено от администратора", + "No expiry rules yet.": "Все още няма правила за изтичане.", + "Applies to": "Прилага се за", + "Item type": "Тип елемент", + "Maximum age in days (empty for reminders only)": "Максимална възраст в дни (празно само за напомняния)", + "Remind me this many days before, comma separated": "Напомни ми толкова дни преди това, разделени със запетая", + "Save rule": "Запазване на правилото", + "An item type": "Тип елемент", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Изтича след {days} дни", + "Reminders {days} days before": "Напомняния {days} дни преди това", + "Could not save the expiry rule.": "Правилото за изтичане не можа да бъде запазено.", + "Could not delete the expiry rule.": "Правилото за изтичане не можа да бъде изтрито.", + "All statuses": "Всички статуси", + "Compromised": "Компрометиран", + "Could not load the members.": "Членовете не можаха да се заредят.", + "Emergency contact": "Контакт за спешни случаи", + "Leaving user": "Напускащ потребител", + "No": "Не", + "No users match this filter.": "Няма потребители, отговарящи на този филтър.", + "Not set up": "Не е настроен", + "Revoke suite": "Отмени пакета", + "Revoked": "Отменен", + "Search users": "Търсене на потребители", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Вижте кои потребители са настроили трезор. Започнете напускане или отменете пакет от ред.", + "Successor": "Наследник", + "Team folders": "Екипни папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Потребителят все още е в група {groups}, която е член на екипна папка. Премахнете го от групата или деактивирайте акаунта.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Потребителят все още е в групи {groups}, които са членове на екипни папки. Премахнете го от групите или деактивирайте акаунта.", + "Vault status": "Статус на трезора", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Износът в CXF НЕ Е ШИФРИРАН. Всяка парола и потребителско име ще бъдат четими като обикновен текст в изтегления файл. Пазете го на сигурно място и го изтрийте веднага след употреба.", + "Root certificate expiring soon": "Основният сертификат изтича скоро", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Основният сертификат на трезора изтича след %1$d ден(а). Подновете го преди това. Подновяването подписва отново всеки пакет за шифроване.", + "Compromise recovery aborted": "Възстановяването след компрометиране е прекратено", + "Key rotation ended by a compromise revoke": "Смяната на ключа е прекратена от отмяна поради компрометиране", + "Encryption suite revoke refused": "Отмяната на комплекта за шифроване е отказана", + "Master password proof refused": "Доказът за главната парола е отказан", + "Your current master password": "Текущата ви главна парола", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за спешни случаи имаше чакаща заявка за достъп, когато смяната на ключа го премахна. Проверете кой е поискал, преди да добавите някого отново.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контактите за спешни случаи (%n) имаха чакаща заявка за достъп, когато смяната на ключа ги премахна. Проверете кой е поискал, преди да добавите някого отново.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Тези контакти за спешни случаи не бяха прехвърлени към новия ви ключ. Спешният им достъп беше премахнат. Добавете ги отново от Спешен достъп, ако все още ги искате.", + "Renew root certificate": "Подновяване на основния сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Създават се нов основен и междинен сертификат. Всеки активен набор за шифроване се подписва отново. Това не може да бъде отменено.", + "Renew root": "Подновяване на основния", + "Root renewed. {n} encryption suites signed again.": "Основният сертификат е подновен. Наново подписани набори за шифроване: {n}.", + "Could not renew the root certificate.": "Основният сертификат не можа да бъде подновен.", + "Lease policy for this application": "Политика за наем за това приложение", + "In force now: {default} seconds by default, {max} seconds at most.": "В сила сега: {default} секунди по подразбиране, най-много {max} секунди.", + "Leases are not renewable": "Наемите не могат да се подновяват", + "Lease policy saved.": "Политиката за наем е запазена.", + "Leave a field empty to use the instance value.": "Оставете поле празно, за да използвате стойността на инстанцията.", + "Instance value: {value}": "Стойност на инстанцията: {value}", + "Renewal": "Подновяване", + "Use the instance value ({value})": "Използване на стойността на инстанцията ({value})", + "Allowed": "Разрешено", + "Not allowed": "Не е разрешено", + "Save lease policy": "Запазване на политиката за наем", + "Only an administrator can change this policy.": "Само администратор може да промени тази политика.", + "Could not save the lease policy.": "Политиката за наем не можа да бъде запазена.", + "{member} got access from {confirmer}.": "{member} получи достъп от {confirmer}.", + "Automatically confirm new team folder members": "Автоматично потвърждаване на нови членове на екипни папки", + "Gave %n new member access to a team folder.": "%n нов член получи достъп до екипна папка.", + "Gave %n new members access to a team folder.": "Нови членове (%n) получиха достъп до екипна папка.", + "Give new team folder members access without waiting for the folder owner.": "Дайте достъп на новите членове, без да чакате собственика на папката.", + "New team folder members": "Нови членове на екипни папки", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Собственикът или член с право на запис ги потвърждава от отворения си трезор. Keepiq никога не дешифрира на сървъра.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Изчаква се член с право на запис да отвори Keepiq. Можете да споделите и сега.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Част от реакцията при компрометиране се провали ({failed} стъпка(и)). Проверете сървърния дневник и отново отменете пакета, за да я завършите.", + "This also revoked suite {suite} and ended key migration {migration}.": "Това отмени и пакета {suite} и прекрати миграцията на ключове {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отмяната на втория пакет изтри %n контакт за спешен достъп.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отмяната на втория пакет изтри %n контакта за спешен достъп.", + "A suite revoked as compromised cannot be reinstated.": "Пакет, отменен като компрометиран, не може да бъде възстановен.", + "Archives to keep": "Архиви за пазене", + "Back up every vault automatically": "Автоматично архивиране на всеки трезор", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Архивирайте всеки трезор по график. Архивите съдържат само шифрован текст и се възстановяват с occ.", + "Back up now": "Архивирай сега", + "Backup public key (PEM, optional)": "Публичен ключ за архива (PEM, по избор)", + "Backup requested for the next cron run": "Архивиране е заявено за следващото изпълнение на cron", + "Encrypted": "Шифрован", + "Every (hours)": "На всеки (часа)", + "Last backup {when} failed: {error}": "Последното архивиране {when} се провали: {error}", + "Last backup {when} succeeded.": "Последното архивиране {when} е успешно.", + "No archives yet.": "Все още няма архиви.", + "Size": "Размер", + "Vault backups": "Архиви на трезора", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "С ключ всяка архива се шифрова за него. Пазете частния ключ извън този сървър: трябва ви за проверка или възстановяване.", + "Written": "Записан", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n потребител в обхвата все още няма двуфакторно влизане и не може да отвори трезора, докато това е включено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Потребители в обхвата (%n) все още нямат двуфакторно влизане и не могат да отворят трезора, докато това е включено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервните кодове не се броят. Ако потребителите ви влизат чрез доставчик на самоличност със собствен втори фактор, изключете групите им.", + "Block personal vault export": "Блокиране на износа на личния трезор", + "Keep work logins in team folders": "Пазете служебните входове в екипни папки", + "Move to a team folder": "Преместване в екипна папка", + "Not in a team folder": "Не е в екипна папка", + "Only for these groups (empty is everyone)": "Само за тези групи (празно означава всички)", + "Require two-factor login before the vault opens": "Изискване на двуфакторно влизане преди отваряне на трезора", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за всеки трезор. Всяко важи за всички или само за групите, които изберете.", + "Secret types that belong in a team folder": "Типове тайни, които принадлежат в екипна папка", + "Set up two-factor login": "Настройване на двуфакторно влизане", + "Team folder you can write to": "Екипна папка, в която можете да пишете", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Потребителите не могат да изтеглят резервно копие, CSV или файл за прехвърляне. Пакетът им с лични данни остава достъпен.", + "Users cannot save these secret types in a personal folder.": "Потребителите не могат да запазват тези типове тайни в лична папка.", + "Vault policies": "Правила на трезора", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Вашата организация не позволява износ на личния ви трезор. Пакетът ви с лични данни в настройките остава достъпен.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Вашата организация пази тези тайни в екипна папка. Преместете всяка в екипна папка.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Вашата организация пази този тип тайна в екипна папка. Изберете една от екипните си папки или такава, в която можете да пишете.", + "Your organisation requires two-factor login before you can open your vault.": "Вашата организация изисква двуфакторно влизане, преди да можете да отворите трезора си.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Потребителите избират колко дълго разширението остава отключено при неактивност. Вие задавате най-дългото време, което могат да изберат.", + "Longest idle time before the extension locks": "Най-дълго време на неактивност преди разширението да се заключи", + "1 minute": "1 минута", + "5 minutes": "5 минути", + "15 minutes": "15 минути", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД на директорията (наемател)", + "Application (client) ID": "ИД на приложението (клиент)", + "Data collection rule immutable ID": "Неизменим ИД на правилото за събиране на данни", + "Stream name": "Име на потока", + "Splunk index (optional)": "Индекс в Splunk (по избор)", + "Sourcetype (optional)": "Sourcetype (по избор)", + "Leave blank to keep the current one": "Оставете празно, за да запазите текущата", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF през syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крайна точка за събиране на данни (https URL)", + "HTTP Event Collector URL (https)": "URL на HTTP Event Collector (https)", + "Client secret (write-only)": "Тайна на клиента (само запис)", + "HEC token (write-only)": "HEC токен (само запис)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Препращайте разрешените одитни събития към Splunk, Microsoft Sentinel, syslog приемник или webhook. Съобщенията съдържат само изчистени метаданни: никаква тайна стойност, име, потребителско име или шифрован текст никога не напуска сървъра.", + "%n change waiting to sync": "%n промяна чака синхронизиране", + "%n changes waiting to sync": "%n промени чакат синхронизиране", + "Changes that could not sync": "Промени, които не можаха да се синхронизират", + "Choose a version": "Изберете версия", + "Copy value": "Копиране на стойността", + "Deleted": "Изтрито", + "Discard": "Отхвърляне", + "Keep my offline change": "Запазване на моята офлайн промяна", + "Keep the server version": "Запазване на версията от сървъра", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq е само за четене офлайн. Администраторът не е включил офлайн редактирането.", + "Let users edit secrets offline": "Разрешаване на потребителите да редактират тайни офлайн", + "Not synced yet": "Още не е синхронизирано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Офлайн промените остават на устройството, шифровани за потребителя, и се синхронизират при следващото отключване онлайн. Споделянето, папките и прикачените файлове все още изискват връзка.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Офлайн. Редакциите, преместванията и изтриванията остават на това устройство и се синхронизират, когато отново сте онлайн. Споделянето и прикачените файлове изискват връзка.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Офлайн. Промените ви остават на това устройство и се синхронизират, когато отново сте онлайн. Последна синхронизация {when}.", + "Open my changes": "Отваряне на моите промени", + "Sharing needs a connection": "Споделянето изисква връзка", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Някой е променил тази тайна на сървъра, след като е направено офлайн копието ви. Изберете коя версия да запазите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизирайте или отхвърлете офлайн промените, преди да смените ключовете си.", + "That password did not open your changes.": "Тази парола не отвори промените ви.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Офлайн снимката пази шифровани тайни (отварят се само с ключа, извлечен от главната парола на потребителя, точно както на сървъра) и шифрова имената, URL адресите и имената на папките при съхранение. Офлайн достъпът е само за четене, освен ако по-долу не разрешите офлайн редактиране. Изключете това за устройства, които никога не трябва да кешират идентификационни данни; изключването изчиства съществуващите кешове при следващото зареждане.", + "The previous vault copy is gone, so these changes cannot be opened.": "Предишното копие на трезора липсва, затова тези промени не могат да бъдат отворени.", + "The server version": "Версията от сървъра", + "This secret changed while you were offline": "Тази тайна е променена, докато бяхте офлайн", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Изтрихте тази тайна офлайн, но междувременно тя е променена на сървъра. Изберете коя версия да запазите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ключовете ви са сменени на друго устройство. Въведете предишната си главна парола, за да синхронизирате офлайн промените, или ги отхвърлете.", + "Your offline change": "Вашата офлайн промяна", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n контакт за спешни случаи имаше чакаща заявка за достъп, когато смяната на ключа го премахна. Проверете кой е поискал, преди да добавите някого отново.","Контактите за спешни случаи (%n) имаха чакаща заявка за достъп, когато смяната на ключа ги премахна. Проверете кой е поискал, преди да добавите някого отново."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n елемент не може да бъде представен в CXF и ще бъде пропуснат.","%n елемента не могат да бъдат представени в CXF и ще бъдат пропуснати."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n стара версия беше премахната, защото може да се прехвърли само скорошна история.","%n стари версии бяха премахнати, защото може да се прехвърли само скорошна история."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n копие на тайна все още трябва да бъде шифрирано и споделено.","%n копия на тайни все още трябва да бъдат шифрирани и споделени."], + "_%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 тайна не можа да бъде дешифрирана и не е включена в този експорт.","%n тайни не можаха да бъдат дешифрирани и не са включени в този експорт."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n тайна не можа да бъде дешифрирана със стария ви ключ, затова не беше прехвърлена.","%n тайни не можаха да бъдат дешифрирани със стария ви ключ, затова не бяха прехвърлени."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n тайна не беше прехвърлена.","%n тайни не бяха прехвърлени."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n тайна все още е шифрована с предишния ви ключ.","%n тайни все още са шифровани с предишния ви ключ."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n тайна беше пропусната, защото наследникът все още няма копие — добавете наследника в папката и изпълнете отново.","%n тайни бяха пропуснати, защото наследникът все още няма копие — добавете наследника в папката и изпълнете отново."], + "_%n secret_::_%n secrets_": ["%n тайна","%n тайни"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n потребител в обхвата все още няма двуфакторно влизане и не може да отвори трезора, докато това е включено.","Потребители в обхвата (%n) все още нямат двуфакторно влизане и не могат да отворят трезора, докато това е включено."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Завършване въпреки това, със загуба на достъп до %n тайна","Завършване въпреки това, със загуба на достъп до %n тайни"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n нов член получи достъп до екипна папка.","Нови членове (%n) получиха достъп до екипна папка."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ротацията на ключа завърши. %n тайна беше прешифрована с новия ви ключ.","Ротацията на ключа завърши. %n тайни бяха прешифровани с новия ви ключ."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Отмяната на втория пакет изтри %n контакт за спешен достъп.","Отмяната на втория пакет изтри %n контакта за спешен достъп."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Отменянето на този комплект премахна %n контакт за авариен достъп.","Отменянето на този комплект премахна %n контакта за авариен достъп."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["открита %n път в изтекли данни","открита %n пъти в изтекли данни"], + "_shared with %n secret_::_shared with %n secrets_": ["споделена с %n тайна","споделена с %n тайни"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Тази папка съдържа %n тайна директно.","Тази папка съдържа %n тайни директно."], + "_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._": ["Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.","Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n промяна чака синхронизиране","%n промени чакат синхронизиране"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Потребителят все още е в група {groups}, която е член на екипна папка. Премахнете го от групата или деактивирайте акаунта.","Потребителят все още е в групи {groups}, които са членове на екипни папки. Премахнете го от групите или деактивирайте акаунта."], + "Allow approval from another device": "Разрешаване на одобрение от друго устройство", + "App": "Приложение", + "Approve a new device": "Одобряване на ново устройство", + "Approve from another device": "Одобряване от друго устройство", + "Asked at": "Поискано в", + "Check that the new device shows these words:": "Проверете дали новото устройство показва тези думи:", + "Denied. If you did not ask, end your other sessions:": "Отказано. Ако не сте поискали това, прекратете другите си сесии:", + "Device": "Устройство", + "IP address": "IP адрес", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Позволява на потребителите да отключат нов браузър, като го одобрят от устройство, на което Keepiq вече е отключен.", + "New device approval": "Одобряване на нови устройства", + "Nextcloud security settings": "Настройки за сигурност на Nextcloud", + "Only approve a device you are using right now.": "Одобрявайте само устройство, което използвате в момента.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворете Keepiq на устройство, на което е отключен, и одобрете това устройство. Проверете дали показва същите думи:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Одобряващото устройство запечатва ключа за отключване за новото устройство. Сървърът само го препраща и не може да го отвори.", + "The master password is not right, or the request has ended.": "Главната парола не е вярна или заявката е приключила.", + "The request expired. Ask again or use your master password.": "Заявката изтече. Поискайте отново или използвайте главната си парола.", + "The request was denied.": "Заявката беше отказана.", + "Too many requests. Try again in an hour or use your master password.": "Твърде много заявки. Опитайте отново след час или използвайте главната си парола.", + "Unknown device": "Неизвестно устройство", + "Web app": "Уеб приложение", + "A device": "Устройство", + "A new device asks to open your vault": "Ново устройство иска да отвори трезора ви", + "%s asks to be approved. Only approve a device you are using right now.": "%s иска одобрение. Одобрявайте само устройство, което използвате в момента.", + "Access ends on (optional)": "Достъпът изтича на (по избор)", + "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.": "Приложенията на Keepiq няма да показват или копират паролата. Човек с технически умения все пак може да я прочете от своето устройство. Сменете я, когато достъпът му изтече.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Тази тайна е само за използване. Влезте чрез разширението за браузър на Keepiq.", + "Until {date}": "До {date}", + "Use only": "Само за използване", + "Use only (can sign in, cannot view or copy)": "Само за използване (може да влиза, не може да преглежда или копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Можете да влезете с този вход чрез разширението за браузър на Keepiq. Собственикът е избрал да не ви позволява да го преглеждате или копирате.", + "Your access ends on {date}": "Достъпът ви изтича на {date}", + "Your access to this secret has ended": "Достъпът ви до тази тайна е прекратен", + "Your access to \"%s\" ends tomorrow": "Достъпът ви до „%s“ изтича утре", + "Your access to \"%s\" has ended": "Достъпът ви до „%s“ е прекратен", + "%1$s no longer has access to \"%2$s\"": "%1$s вече няма достъп до „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s можеше да вижда тази парола. Сменете я, ако %1$s вече не трябва да я знае.", + "%s could not view this password in Keepiq.": "%s не можеше да види тази парола в Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} от {threshold} одобрения", + "a recovery officer": "служител по възстановяване", + "Account recovery": "Възстановяване на акаунта", + "Approvals needed": "Нужни одобрения", + "Ask {user} which words they see, by phone or in person. They must be:": "Попитайте {user} какви думи вижда, по телефона или лично. Те трябва да са:", + "Check again": "Провери отново", + "Create the recovery key": "Създай ключ за възстановяване", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Създайте ключа за възстановяване. Браузърът ви го създава и дава на всеки служител копие, което само той може да отвори.", + "Decline": "Откажи", + "Enrol in account recovery": "Запишете се за възстановяване на акаунта", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Запишете се, за да може организацията ви да ви помогне да си върнете трезора, ако забравите главната парола.", + "Every user is enrolled": "Всички потребители са записани", + "Finish the recovery in the browser you asked from.": "Завършете възстановяването в браузъра, от който поискахте.", + "Forgot your master password?": "Забравихте главната парола?", + "Hand the key over": "Предай ключа", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Позволете на потребители, забравили главната си парола, да си върнат трезора с одобрение от служители по възстановяване, които посочите.", + "New master password": "Нова главна парола", + "No one is asking to recover their account.": "Никой не иска възстановяване на акаунта си.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Все още няма ключ за възстановяване. Един от служителите го създава в настройките си на Keepiq.", + "Off": "Изключено", + "Officer {user} has no encryption set up yet.": "Служителят {user} още няма настроено шифроване.", + "Officers (user IDs, separated by commas)": "Служители (потребителски ID, разделени със запетаи)", + "Policy": "Правила", + "Publish this fingerprint internally, so users can check it before they enrol.": "Публикувайте този отпечатък вътрешно, за да могат потребителите да го проверят, преди да се запишат.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Възстановено с помощта на {officer}. Сменете ключа на трезора сега в Настройки, Сигурност: \"Главната ми парола е компрометирана\".", + "Recovery key fingerprint: {fingerprint}": "Отпечатък на ключа за възстановяване: {fingerprint}", + "Recovery officer": "Служител по възстановяване", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Премахнатите служители губят копието си сега, но може да са го отворили преди. Нека служител създаде нов ключ за възстановяване.", + "Repeat the new master password": "Повторете новата главна парола", + "Retire this recovery key": "Оттегли този ключ за възстановяване", + "Set the new master password": "Задай новата главна парола", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификатът за възстановяване не е издаден от този Keepiq. Не се записвайте и уведомете администратора си.", + "The words match, approve": "Думите съвпадат, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Този потребител е записан за възстановяване на акаунта. Възстановяването запазва тайните му; отмяната изтрива записването му.", + "Users may enrol": "Потребителите могат да се записват", + "Withdraw from account recovery": "Отпиши се от възстановяване на акаунта", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Записани сте за възстановяване на акаунта. Отпечатък на ключа за възстановяване: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Записани сте. Ако забравите главната си парола, организацията ви може да ви помогне да си върнете трезора.", + "Your key is back. Choose a new master password.": "Ключът ви е върнат. Изберете нова главна парола.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Служителите по възстановяване са уведомени. Прочетете им тези думи, когато ви се обадят или се срещнете:", + "You are now an account recovery officer": "Вече сте служител по възстановяване на акаунти", + "%s asks to recover their account. Compare the words with them before you approve.": "%s иска да възстанови акаунта си. Сравнете думите с него, преди да одобрите.", + "A user": "Потребител", + "Your account recovery request was declined": "Заявката ви за възстановяване на акаунта беше отказана", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Възстановяването на акаунта ви е готово. Отворете Keepiq в браузъра, от който поискахте.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} иска еднократно отключване на ново устройство. Главната парола остава същата.", + "Ask your organisation instead": "Вместо това попитайте организацията си", + "The request ended. Ask again or use your master password.": "Заявката приключи. Поискайте отново или използвайте главната си парола.", + "Added by {user}": "Добавено от {user}", + "Editor": "Редактор", + "Manager": "Мениджър", + "Role of {member}": "Роля на {member}", + "Team folders you manage": "Екипни папки, които управлявате", + "Viewer": "Зрител", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Нямате копие на тези тайни, затова новите членове още не са ги получили. Собственикът може да ги сподели: {names}", + "Admin areas": "Области на администриране", + "Give a group only the parts of Keepiq administration it needs.": "Дайте на група само частите от администрирането на Keepiq, които са ѝ нужни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирайте една или повече области на група на страницата с административни права. Администраторите на инстанцията имат всяка област.", + "Open administration privileges": "Отваряне на административните права", + "Policies": "Политики", + "Applications and machine access": "Приложения и машинен достъп", + "People and offboarding": "Хора и напускане", + "Audit and compliance": "Одит и съответствие", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версия, удостоверяващ орган, прикачени файлове, офлайн кеш, проверка за изтичане, типове тайни и резервни копия", + "master password, organisation password, vault policies, rotation, version history and trash": "главна парола, парола на организацията, политики на трезора, ротация, история на версиите и кошче", + "application queue, application requests and machine leases": "опашка на приложенията, заявки на приложенията и машинни наеми", + "team offboarding, encryption suites and admin handover": "напускане на екипа, шифровъчни пакети и поемане от администратор", + "audit log, compliance reports, SIEM export and honey alerts": "одитен дневник, отчети за съответствие, SIEM износ и сигнали за примамки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колко версии на дадена тайна се пазят, колко дълго и колко дълго изтритите тайни остават в кошчето.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничения за шифровани прикачени файлове, налагани на сървъра в съхранени шифровани байтове.", + "Type the suite ID again to confirm": "Въведете отново ID на пакета за потвърждение", + "This does not match the suite ID.": "Това не съвпада с ID на пакета.", + "Confirm with your master password": "Потвърдете с главната си парола", + "Confirm": "Потвърждаване", + "That master password is not right.": "Тази главна парола не е правилна.", + "You are sharing with someone new. Enter your master password to confirm.": "Споделяте с нов човек. Въведете главната си парола за потвърждение.", + "Enter your master password to confirm this share.": "Въведете главната си парола, за да потвърдите това споделяне.", + "Enter your master password to confirm this delegation.": "Въведете главната си парола, за да потвърдите това делегиране.", + "Approve {member}": "Одобряване на {member}", + "Recipient": "Получател", + "No vault yet": "Все още няма хранилище", + "No matching users": "Няма съвпадащи потребители", + "Partner organisations": "Партньорски организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обменяйте тайни с друг Keepiq. Двамата администратори се добавят взаимно и сравняват коренните отпечатъци по телефона или лично, преди да запазят.", + "Federation needs Nextcloud 33 or later.": "Федерацията изисква Nextcloud 33 или по-нов.", + "Your root fingerprint": "Вашият коренен отпечатък", + "No partners yet.": "Все още няма партньори.", + "Users here may share to this partner": "Потребителите тук могат да споделят с този партньор", + "This partner may share to users here": "Този партньор може да споделя с потребителите тук", + "Partner address": "Адрес на партньора", + "Check partner": "Провери партньора", + "Partner root fingerprint": "Коренен отпечатък на партньора", + "I compared this fingerprint with the partner's administrator": "Сравних този отпечатък с администратора на партньора", + "Add partner": "Добави партньор", + "A secret from another organisation": "Тайна от друга организация", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s сподели \"%2$s\" с вас. Приемете я в Входящи от други организации.", + "Incoming from other organisations": "Входящи от други организации", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Хора от партньорски организации могат да споделят тайна с вас. Приемете я, за да запазите копие само за четене във вашето хранилище.", + "Nothing shared with you yet": "Все още нищо не е споделено с вас", + "Secrets that people in partner organisations share with you appear here.": "Тук се показват тайните, които хора от партньорски организации споделят с вас.", + "From {sender}": "От {sender}", + "Accept": "Приемане", + "Open in vault": "Отваряне в хранилището", + "The other organisation did not hand over the secret. Try again later.": "Другата организация не предаде тайната. Опитайте отново по-късно.", + "Set up your vault before you accept a shared secret.": "Настройте хранилището си, преди да приемете споделена тайна.", + "Something went wrong. Try again.": "Нещо се обърка. Опитайте отново.", + "Waiting for your answer": "Очаква вашия отговор", + "In your vault, read-only": "Във вашето хранилище, само за четене", + "Withdrawn by the sender": "Оттеглено от подателя", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} сподели това от друга организация. Можете да го четете, но не и да го променяте или споделяте.", + "Someone": "Някой", + "Share with someone at another organisation": "Споделяне с някого от друга организация", + "Their account at the other organisation": "Профилът на човека в другата организация", + "Check account": "Проверка на профила", + "Certificate fingerprint of {account}": "Пръстов отпечатък на сертификата на {account}", + "Compare it with them by phone if you want to be sure.": "Сравнете го с човека по телефона, ако искате да сте сигурни.", + "Shared. {account} can accept it in their own vault.": "Споделено. {account} може да го приеме в собственото си хранилище.", + "The certificate could not be verified. Nothing was shared.": "Сертификатът не можа да бъде проверен. Нищо не беше споделено.", + "That organisation is not one of your partners.": "Тази организация не е сред вашите партньори.", + "No one with that account can receive secrets from you.": "Никой с този профил не може да получава тайни от вас.", + "The other organisation did not answer. Try again later.": "Другата организация не отговори. Опитайте отново по-късно.", + "This secret is already shared with that account.": "Тази тайна вече е споделена с този профил.", + "Other organisations": "Други организации", + "Receive secrets from other organisations": "Получаване на тайни от други организации", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тогава хората от партньорски организации могат да намерят профила ви и да споделят тайни с вас. Всяка от тях приемате сами.", + "Shared": "Споделено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Спряно: сертификатът им или партньорството се промени. Отнемете споделянето или споделете отново.", + "Their organisation did not get the last change. Revoke it or share again.": "Организацията им не получи последната промяна. Отнемете споделянето или споделете отново.", + "Being withdrawn": "Отнема се", + "Shared with another organisation": "Споделено с друга организация", + "Change sent to another organisation": "Промяната е изпратена до друга организация", + "Share with another organisation revoked": "Споделянето с друга организация е отменено", + "Share with another organisation paused": "Споделянето с друга организация е спряно", + "Another organisation did not get a change": "Друга организация не получи промяна", + "Secret received from another organisation": "Получена е тайна от друга организация", + "Secret from another organisation accepted": "Тайната от друга организация е приета", + "Secret from another organisation declined": "Тайната от друга организация е отказана", + "Copy from another organisation updated": "Копието от друга организация е обновено", + "Copy from another organisation removed": "Копието от друга организация е премахнато", + "Declined: they removed their copy. Share again if they need it.": "Отказано: получателят премахна своето копие. Споделете отново, ако му трябва.", + "Recipient at another organisation removed their copy": "Получател от друга организация премахна своето копие", + "Removed the user from %n team folder.": "Потребителят е премахнат от %n екипна папка.", + "Removed the user from %n team folders.": "Потребителят е премахнат от %n екипни папки.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Потребителят е премахнат от %n екипна папка.","Потребителят е премахнат от %n екипни папки."], + "A restored copy came from a share that has ended. It stays read-only.": "Възстановено копие идва от споделяне, което е приключило. То остава само за четене.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организацията, споделила възстановено копие, не може да бъде достигната. Копието остава само за четене и не следва техните промени.", + "Recipient at another organisation restored their copy": "Получател от друга организация възстанови своето копие" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/bg.json b/l10n/bg.json index c28e98156..8b78bdcde 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1181,7 +1181,567 @@ "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.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацията на ключа премахна достъпа при спешност на този контакт. Определете го отново, ако все още го искате.", + "Shared with groups": "Споделено с групи", + "Not shared with any group yet.": "Все още не е споделено с група.", + "Revoke the share with {group}": "Оттегляне на споделянето с {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено с {group}: {received} членове го получиха, {skipped} не, защото все още нямат настроено шифроване.", + "Search groups": "Търсене на групи", + "Failed to share": "Споделянето не бе успешно", + "Columns": "Колони", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Свържете една колона с името. Всяка тайна трябва да има име.", + "Notes": "Бележки", + "Do not import": "Не импортирай", + "Hide this value": "Скриване на тази стойност", + "Show this value": "Показване на тази стойност", + "Defaults": "По подразбиране", + "New secrets start as this type, and your secret list opens in this view.": "Новите тайни започват с този тип, а списъкът с тайни се отваря в този изглед.", + "Default item type": "Тип елемент по подразбиране", + "Cards": "Карти", + "Table": "Таблица", + "Could not save your default": "Стойността по подразбиране не можа да бъде запазена", + "Recently used": "Наскоро използвани", + "Opened": "Отворено", + "You have not opened any secrets yet": "Все още не сте отваряли тайни", + "Could not delete the item type.": "Типът елемент не можа да бъде изтрит.", + "Could not load the item types.": "Типовете елементи не можаха да бъдат заредени.", + "Could not save the item type.": "Типът елемент не можа да бъде запазен.", + "Delete item type": "Изтриване на тип елемент", + "Edit item type": "Редактиране на тип елемент", + "Fields": "Полета", + "Fields: {count}": "Полета: {count}", + "Hidden": "Скрито", + "Item types": "Типове елементи", + "Move up": "Нагоре", + "New item type": "Нов тип елемент", + "No item types defined yet.": "Все още няма дефинирани типове елементи.", + "Required": "Задължително", + "Text": "Текст", + "This field is required": "Това поле е задължително", + "Web address": "Уеб адрес", + "{label} (required)": "{label} (задължително)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Да се изтрие ли „{name}“? Тайните от този тип остават четими и стават елементи Вход.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типовете елементи, които дефинирате тук, се показват на всички в диалога Нова тайна, с полетата, които изберете.", + "Secret moved to the trash": "Тайната е преместена в кошчето", + "Secret restored from the trash": "Тайната е възстановена от кошчето", + "Secret deleted for good": "Тайната е изтрита окончателно", + "Secret archived": "Тайната е архивирана", + "Secret unarchived": "Тайната е извадена от архива", + "Unarchive": "Извади от архива", + "Could not archive the secret": "Тайната не можа да бъде архивирана", + "Could not unarchive the secret": "Тайната не можа да бъде извадена от архива", + "Archive {count} secrets": "Архивиране на тайни: {count}", + "Unarchive {count} secrets": "Изваждане от архива на тайни: {count}", + "Restore {count} secrets": "Възстановяване на тайни: {count}", + "Delete {count} secrets for good": "Окончателно изтриване на тайни: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} от {total} тайни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивираните тайни изчезват от списъка на трезора, търсенето, автоматичното попълване и доклада за състоянието. Запазват споделянията си. Ще ги намерите в Архив.", + "These secrets come back to the vault list, search and autofill.": "Тези тайни се връщат в списъка на трезора, търсенето и автоматичното попълване.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Тези тайни се връщат в списъка на трезора. Старите им споделяния не се връщат, затова ги споделете отново, където е нужно.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Това изтрива тайните заедно с прикачените файлове и историята на версиите. Действието не може да бъде отменено.", + "Delete for good": "Изтрий окончателно", + "Trash": "Кошче", + "The trash is empty": "Кошчето е празно", + "No archived secrets": "Няма архивирани тайни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Изтритите тайни чакат тук до края на срока на съхранение, след това се изтриват окончателно.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирайте тайна от панела ѝ с подробности, за да остане извън списъка на трезора, търсенето и автоматичното попълване.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничения за шифровани прикачени файлове (прилагат се на сървъра върху съхранените шифровани байтове), съхранение на историята на версиите и колко дълго изтритите тайни остават в кошчето.", + "Days a deleted secret stays in the trash (1 to 365)": "Дни, през които изтрита тайна остава в кошчето (от 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Това премества тайната в кошчето и прекратява споделянията ѝ веднага. Можете да я възстановите от кошчето до края на срока на съхранение: 30 дни, освен ако администраторът не го е променил.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Това премества тайните в кошчето ({count}) и прекратява споделянията им веднага. Можете да ги възстановите от кошчето до края на срока на съхранение.", + "Remove {name} from favourites": "Премахване на {name} от любими", + "Add {name} to favourites": "Добавяне на {name} към любими", + "Could not change the favourite": "Любимото не можа да бъде променено", + "Remove from favourites": "Премахване от любими", + "Add to favourites": "Добавяне към любими", + "Tags": "Етикети", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Етикетите не са шифровани. Администраторите на сървъра могат да ги четат, както и имената на папките.", + "Favourites": "Любими", + "Filter by tag": "Филтриране по етикет", + "All tags": "Всички етикети", + "Last used": "Последно използвано", + "Tags for {count} secrets": "Етикети за {count} тайни", + "Tag": "Етикет", + "Remove tag": "Премахване на етикет", + "Add tag": "Добавяне на етикет", + "Could not change the tags. Try again.": "Етикетите не можаха да бъдат променени. Опитайте отново.", + "Could not approve the application. It is still in the queue.": "Заявката не можа да бъде одобрена. Тя все още е в опашката.", + "Could not reject the application. It is still in the queue.": "Заявката не можа да бъде отхвърлена. Тя все още е в опашката.", + "Removed the user from {count} team folders.": "Потребителят е премахнат от {count} екипни папки.", + "Approve a share": "Одобряване на споделяне", + "This approval link is incomplete. Open it again from the notification.": "Тази връзка за одобрение е непълна. Отворете я отново от известието.", + "Deny": "Откажи", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се присъедини към група, с която споделяте тайна. Да споделите ли тайната и с него?", + "{requester} asks you to share a secret with {user}.": "{requester} ви моли да споделите тайна с {user}.", + "Shared. The recipient can now open the secret.": "Споделено. Получателят вече може да отвори тайната.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Получателят все още не е настроил Keepiq, затова нищо не е споделено. Опитайте отново, след като го направи.", + "Could not share the secret. Only its owner can approve this.": "Тайната не можа да бъде споделена. Само собственикът ѝ може да одобри това.", + "Could not share the secret. Try again.": "Тайната не можа да бъде споделена. Опитайте отново.", + "Denied. Nothing was shared.": "Отказано. Нищо не е споделено.", + "Could not deny the request. Try again.": "Заявката не можа да бъде отказана. Опитайте отново.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ви моли да споделите тайната \"%2$s\" с %3$s.", + "Expires on (optional)": "Изтича на (по избор)", + "Hand over to": "Предай на", + "Choose a recipient": "Изберете получател", + "Hand over temporarily": "Предай временно", + "Expiry rules": "Правила за изтичане", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Задайте колко дълго могат да съществуват паролите от даден тип елемент или в дадена папка и кога да получавате напомняне. Когато важат няколко дати, се взема най-ранната.", + "Delete rule": "Изтриване на правилото", + "Set by your administrator": "Зададено от администратора", + "No expiry rules yet.": "Все още няма правила за изтичане.", + "Applies to": "Прилага се за", + "Item type": "Тип елемент", + "Maximum age in days (empty for reminders only)": "Максимална възраст в дни (празно само за напомняния)", + "Remind me this many days before, comma separated": "Напомни ми толкова дни преди това, разделени със запетая", + "Save rule": "Запазване на правилото", + "An item type": "Тип елемент", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Изтича след {days} дни", + "Reminders {days} days before": "Напомняния {days} дни преди това", + "Could not save the expiry rule.": "Правилото за изтичане не можа да бъде запазено.", + "Could not delete the expiry rule.": "Правилото за изтичане не можа да бъде изтрито.", + "All statuses": "Всички статуси", + "Compromised": "Компрометиран", + "Could not load the members.": "Членовете не можаха да се заредят.", + "Emergency contact": "Контакт за спешни случаи", + "Leaving user": "Напускащ потребител", + "No": "Не", + "No users match this filter.": "Няма потребители, отговарящи на този филтър.", + "Not set up": "Не е настроен", + "Revoke suite": "Отмени пакета", + "Revoked": "Отменен", + "Search users": "Търсене на потребители", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Вижте кои потребители са настроили трезор. Започнете напускане или отменете пакет от ред.", + "Successor": "Наследник", + "Team folders": "Екипни папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Потребителят все още е в група {groups}, която е член на екипна папка. Премахнете го от групата или деактивирайте акаунта.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Потребителят все още е в групи {groups}, които са членове на екипни папки. Премахнете го от групите или деактивирайте акаунта.", + "Vault status": "Статус на трезора", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Износът в CXF НЕ Е ШИФРИРАН. Всяка парола и потребителско име ще бъдат четими като обикновен текст в изтегления файл. Пазете го на сигурно място и го изтрийте веднага след употреба.", + "Root certificate expiring soon": "Основният сертификат изтича скоро", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Основният сертификат на трезора изтича след %1$d ден(а). Подновете го преди това. Подновяването подписва отново всеки пакет за шифроване.", + "Compromise recovery aborted": "Възстановяването след компрометиране е прекратено", + "Key rotation ended by a compromise revoke": "Смяната на ключа е прекратена от отмяна поради компрометиране", + "Encryption suite revoke refused": "Отмяната на комплекта за шифроване е отказана", + "Master password proof refused": "Доказът за главната парола е отказан", + "Your current master password": "Текущата ви главна парола", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за спешни случаи имаше чакаща заявка за достъп, когато смяната на ключа го премахна. Проверете кой е поискал, преди да добавите някого отново.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контактите за спешни случаи (%n) имаха чакаща заявка за достъп, когато смяната на ключа ги премахна. Проверете кой е поискал, преди да добавите някого отново.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Тези контакти за спешни случаи не бяха прехвърлени към новия ви ключ. Спешният им достъп беше премахнат. Добавете ги отново от Спешен достъп, ако все още ги искате.", + "Renew root certificate": "Подновяване на основния сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Създават се нов основен и междинен сертификат. Всеки активен набор за шифроване се подписва отново. Това не може да бъде отменено.", + "Renew root": "Подновяване на основния", + "Root renewed. {n} encryption suites signed again.": "Основният сертификат е подновен. Наново подписани набори за шифроване: {n}.", + "Could not renew the root certificate.": "Основният сертификат не можа да бъде подновен.", + "Lease policy for this application": "Политика за наем за това приложение", + "In force now: {default} seconds by default, {max} seconds at most.": "В сила сега: {default} секунди по подразбиране, най-много {max} секунди.", + "Leases are not renewable": "Наемите не могат да се подновяват", + "Lease policy saved.": "Политиката за наем е запазена.", + "Leave a field empty to use the instance value.": "Оставете поле празно, за да използвате стойността на инстанцията.", + "Instance value: {value}": "Стойност на инстанцията: {value}", + "Renewal": "Подновяване", + "Use the instance value ({value})": "Използване на стойността на инстанцията ({value})", + "Allowed": "Разрешено", + "Not allowed": "Не е разрешено", + "Save lease policy": "Запазване на политиката за наем", + "Only an administrator can change this policy.": "Само администратор може да промени тази политика.", + "Could not save the lease policy.": "Политиката за наем не можа да бъде запазена.", + "{member} got access from {confirmer}.": "{member} получи достъп от {confirmer}.", + "Automatically confirm new team folder members": "Автоматично потвърждаване на нови членове на екипни папки", + "Gave %n new member access to a team folder.": "%n нов член получи достъп до екипна папка.", + "Gave %n new members access to a team folder.": "Нови членове (%n) получиха достъп до екипна папка.", + "Give new team folder members access without waiting for the folder owner.": "Дайте достъп на новите членове, без да чакате собственика на папката.", + "New team folder members": "Нови членове на екипни папки", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Собственикът или член с право на запис ги потвърждава от отворения си трезор. Keepiq никога не дешифрира на сървъра.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Изчаква се член с право на запис да отвори Keepiq. Можете да споделите и сега.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Част от реакцията при компрометиране се провали ({failed} стъпка(и)). Проверете сървърния дневник и отново отменете пакета, за да я завършите.", + "This also revoked suite {suite} and ended key migration {migration}.": "Това отмени и пакета {suite} и прекрати миграцията на ключове {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отмяната на втория пакет изтри %n контакт за спешен достъп.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отмяната на втория пакет изтри %n контакта за спешен достъп.", + "A suite revoked as compromised cannot be reinstated.": "Пакет, отменен като компрометиран, не може да бъде възстановен.", + "Archives to keep": "Архиви за пазене", + "Back up every vault automatically": "Автоматично архивиране на всеки трезор", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Архивирайте всеки трезор по график. Архивите съдържат само шифрован текст и се възстановяват с occ.", + "Back up now": "Архивирай сега", + "Backup public key (PEM, optional)": "Публичен ключ за архива (PEM, по избор)", + "Backup requested for the next cron run": "Архивиране е заявено за следващото изпълнение на cron", + "Encrypted": "Шифрован", + "Every (hours)": "На всеки (часа)", + "Last backup {when} failed: {error}": "Последното архивиране {when} се провали: {error}", + "Last backup {when} succeeded.": "Последното архивиране {when} е успешно.", + "No archives yet.": "Все още няма архиви.", + "Size": "Размер", + "Vault backups": "Архиви на трезора", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "С ключ всяка архива се шифрова за него. Пазете частния ключ извън този сървър: трябва ви за проверка или възстановяване.", + "Written": "Записан", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n потребител в обхвата все още няма двуфакторно влизане и не може да отвори трезора, докато това е включено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Потребители в обхвата (%n) все още нямат двуфакторно влизане и не могат да отворят трезора, докато това е включено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервните кодове не се броят. Ако потребителите ви влизат чрез доставчик на самоличност със собствен втори фактор, изключете групите им.", + "Block personal vault export": "Блокиране на износа на личния трезор", + "Keep work logins in team folders": "Пазете служебните входове в екипни папки", + "Move to a team folder": "Преместване в екипна папка", + "Not in a team folder": "Не е в екипна папка", + "Only for these groups (empty is everyone)": "Само за тези групи (празно означава всички)", + "Require two-factor login before the vault opens": "Изискване на двуфакторно влизане преди отваряне на трезора", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за всеки трезор. Всяко важи за всички или само за групите, които изберете.", + "Secret types that belong in a team folder": "Типове тайни, които принадлежат в екипна папка", + "Set up two-factor login": "Настройване на двуфакторно влизане", + "Team folder you can write to": "Екипна папка, в която можете да пишете", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Потребителите не могат да изтеглят резервно копие, CSV или файл за прехвърляне. Пакетът им с лични данни остава достъпен.", + "Users cannot save these secret types in a personal folder.": "Потребителите не могат да запазват тези типове тайни в лична папка.", + "Vault policies": "Правила на трезора", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Вашата организация не позволява износ на личния ви трезор. Пакетът ви с лични данни в настройките остава достъпен.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Вашата организация пази тези тайни в екипна папка. Преместете всяка в екипна папка.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Вашата организация пази този тип тайна в екипна папка. Изберете една от екипните си папки или такава, в която можете да пишете.", + "Your organisation requires two-factor login before you can open your vault.": "Вашата организация изисква двуфакторно влизане, преди да можете да отворите трезора си.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Потребителите избират колко дълго разширението остава отключено при неактивност. Вие задавате най-дългото време, което могат да изберат.", + "Longest idle time before the extension locks": "Най-дълго време на неактивност преди разширението да се заключи", + "1 minute": "1 минута", + "5 minutes": "5 минути", + "15 minutes": "15 минути", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД на директорията (наемател)", + "Application (client) ID": "ИД на приложението (клиент)", + "Data collection rule immutable ID": "Неизменим ИД на правилото за събиране на данни", + "Stream name": "Име на потока", + "Splunk index (optional)": "Индекс в Splunk (по избор)", + "Sourcetype (optional)": "Sourcetype (по избор)", + "Leave blank to keep the current one": "Оставете празно, за да запазите текущата", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF през syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крайна точка за събиране на данни (https URL)", + "HTTP Event Collector URL (https)": "URL на HTTP Event Collector (https)", + "Client secret (write-only)": "Тайна на клиента (само запис)", + "HEC token (write-only)": "HEC токен (само запис)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Препращайте разрешените одитни събития към Splunk, Microsoft Sentinel, syslog приемник или webhook. Съобщенията съдържат само изчистени метаданни: никаква тайна стойност, име, потребителско име или шифрован текст никога не напуска сървъра.", + "%n change waiting to sync": "%n промяна чака синхронизиране", + "%n changes waiting to sync": "%n промени чакат синхронизиране", + "Changes that could not sync": "Промени, които не можаха да се синхронизират", + "Choose a version": "Изберете версия", + "Copy value": "Копиране на стойността", + "Deleted": "Изтрито", + "Discard": "Отхвърляне", + "Keep my offline change": "Запазване на моята офлайн промяна", + "Keep the server version": "Запазване на версията от сървъра", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq е само за четене офлайн. Администраторът не е включил офлайн редактирането.", + "Let users edit secrets offline": "Разрешаване на потребителите да редактират тайни офлайн", + "Not synced yet": "Още не е синхронизирано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Офлайн промените остават на устройството, шифровани за потребителя, и се синхронизират при следващото отключване онлайн. Споделянето, папките и прикачените файлове все още изискват връзка.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Офлайн. Редакциите, преместванията и изтриванията остават на това устройство и се синхронизират, когато отново сте онлайн. Споделянето и прикачените файлове изискват връзка.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Офлайн. Промените ви остават на това устройство и се синхронизират, когато отново сте онлайн. Последна синхронизация {when}.", + "Open my changes": "Отваряне на моите промени", + "Sharing needs a connection": "Споделянето изисква връзка", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Някой е променил тази тайна на сървъра, след като е направено офлайн копието ви. Изберете коя версия да запазите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизирайте или отхвърлете офлайн промените, преди да смените ключовете си.", + "That password did not open your changes.": "Тази парола не отвори промените ви.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Офлайн снимката пази шифровани тайни (отварят се само с ключа, извлечен от главната парола на потребителя, точно както на сървъра) и шифрова имената, URL адресите и имената на папките при съхранение. Офлайн достъпът е само за четене, освен ако по-долу не разрешите офлайн редактиране. Изключете това за устройства, които никога не трябва да кешират идентификационни данни; изключването изчиства съществуващите кешове при следващото зареждане.", + "The previous vault copy is gone, so these changes cannot be opened.": "Предишното копие на трезора липсва, затова тези промени не могат да бъдат отворени.", + "The server version": "Версията от сървъра", + "This secret changed while you were offline": "Тази тайна е променена, докато бяхте офлайн", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Изтрихте тази тайна офлайн, но междувременно тя е променена на сървъра. Изберете коя версия да запазите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ключовете ви са сменени на друго устройство. Въведете предишната си главна парола, за да синхронизирате офлайн промените, или ги отхвърлете.", + "Your offline change": "Вашата офлайн промяна", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n контакт за спешни случаи имаше чакаща заявка за достъп, когато смяната на ключа го премахна. Проверете кой е поискал, преди да добавите някого отново.", + "Контактите за спешни случаи (%n) имаха чакаща заявка за достъп, когато смяната на ключа ги премахна. Проверете кой е поискал, преди да добавите някого отново." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n елемент не може да бъде представен в CXF и ще бъде пропуснат.", + "%n елемента не могат да бъдат представени в CXF и ще бъдат пропуснати." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n стара версия беше премахната, защото може да се прехвърли само скорошна история.", + "%n стари версии бяха премахнати, защото може да се прехвърли само скорошна история." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n копие на тайна все още трябва да бъде шифрирано и споделено.", + "%n копия на тайни все още трябва да бъдат шифрирани и споделени." + ], + "_%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 тайна не можа да бъде дешифрирана и не е включена в този експорт.", + "%n тайни не можаха да бъдат дешифрирани и не са включени в този експорт." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n тайна не можа да бъде дешифрирана със стария ви ключ, затова не беше прехвърлена.", + "%n тайни не можаха да бъдат дешифрирани със стария ви ключ, затова не бяха прехвърлени." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n тайна не беше прехвърлена.", + "%n тайни не бяха прехвърлени." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n тайна все още е шифрована с предишния ви ключ.", + "%n тайни все още са шифровани с предишния ви ключ." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n тайна беше пропусната, защото наследникът все още няма копие — добавете наследника в папката и изпълнете отново.", + "%n тайни бяха пропуснати, защото наследникът все още няма копие — добавете наследника в папката и изпълнете отново." + ], + "_%n secret_::_%n secrets_": [ + "%n тайна", + "%n тайни" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n потребител в обхвата все още няма двуфакторно влизане и не може да отвори трезора, докато това е включено.", + "Потребители в обхвата (%n) все още нямат двуфакторно влизане и не могат да отворят трезора, докато това е включено." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Завършване въпреки това, със загуба на достъп до %n тайна", + "Завършване въпреки това, със загуба на достъп до %n тайни" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n нов член получи достъп до екипна папка.", + "Нови членове (%n) получиха достъп до екипна папка." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ротацията на ключа завърши. %n тайна беше прешифрована с новия ви ключ.", + "Ротацията на ключа завърши. %n тайни бяха прешифровани с новия ви ключ." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Отмяната на втория пакет изтри %n контакт за спешен достъп.", + "Отмяната на втория пакет изтри %n контакта за спешен достъп." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Отменянето на този комплект премахна %n контакт за авариен достъп.", + "Отменянето на този комплект премахна %n контакта за авариен достъп." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "открита %n път в изтекли данни", + "открита %n пъти в изтекли данни" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "споделена с %n тайна", + "споделена с %n тайни" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Тази папка съдържа %n тайна директно.", + "Тази папка съдържа %n тайни директно." + ], + "_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._": [ + "Ротацията на ключа премахна %n контакт за спешен достъп. Проверете „Достъп при спешност“ и го добавете отново, ако все още го искате.", + "Ротацията на ключа премахна %n контакта за спешен достъп. Проверете „Достъп при спешност“ и ги добавете отново, ако все още ги искате." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n промяна чака синхронизиране", + "%n промени чакат синхронизиране" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Потребителят все още е в група {groups}, която е член на екипна папка. Премахнете го от групата или деактивирайте акаунта.", + "Потребителят все още е в групи {groups}, които са членове на екипни папки. Премахнете го от групите или деактивирайте акаунта." + ], + "Allow approval from another device": "Разрешаване на одобрение от друго устройство", + "App": "Приложение", + "Approve a new device": "Одобряване на ново устройство", + "Approve from another device": "Одобряване от друго устройство", + "Asked at": "Поискано в", + "Check that the new device shows these words:": "Проверете дали новото устройство показва тези думи:", + "Denied. If you did not ask, end your other sessions:": "Отказано. Ако не сте поискали това, прекратете другите си сесии:", + "Device": "Устройство", + "IP address": "IP адрес", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Позволява на потребителите да отключат нов браузър, като го одобрят от устройство, на което Keepiq вече е отключен.", + "New device approval": "Одобряване на нови устройства", + "Nextcloud security settings": "Настройки за сигурност на Nextcloud", + "Only approve a device you are using right now.": "Одобрявайте само устройство, което използвате в момента.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворете Keepiq на устройство, на което е отключен, и одобрете това устройство. Проверете дали показва същите думи:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Одобряващото устройство запечатва ключа за отключване за новото устройство. Сървърът само го препраща и не може да го отвори.", + "The master password is not right, or the request has ended.": "Главната парола не е вярна или заявката е приключила.", + "The request expired. Ask again or use your master password.": "Заявката изтече. Поискайте отново или използвайте главната си парола.", + "The request was denied.": "Заявката беше отказана.", + "Too many requests. Try again in an hour or use your master password.": "Твърде много заявки. Опитайте отново след час или използвайте главната си парола.", + "Unknown device": "Неизвестно устройство", + "Web app": "Уеб приложение", + "A device": "Устройство", + "A new device asks to open your vault": "Ново устройство иска да отвори трезора ви", + "%s asks to be approved. Only approve a device you are using right now.": "%s иска одобрение. Одобрявайте само устройство, което използвате в момента.", + "Access ends on (optional)": "Достъпът изтича на (по избор)", + "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.": "Приложенията на Keepiq няма да показват или копират паролата. Човек с технически умения все пак може да я прочете от своето устройство. Сменете я, когато достъпът му изтече.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Тази тайна е само за използване. Влезте чрез разширението за браузър на Keepiq.", + "Until {date}": "До {date}", + "Use only": "Само за използване", + "Use only (can sign in, cannot view or copy)": "Само за използване (може да влиза, не може да преглежда или копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Можете да влезете с този вход чрез разширението за браузър на Keepiq. Собственикът е избрал да не ви позволява да го преглеждате или копирате.", + "Your access ends on {date}": "Достъпът ви изтича на {date}", + "Your access to this secret has ended": "Достъпът ви до тази тайна е прекратен", + "Your access to \"%s\" ends tomorrow": "Достъпът ви до „%s“ изтича утре", + "Your access to \"%s\" has ended": "Достъпът ви до „%s“ е прекратен", + "%1$s no longer has access to \"%2$s\"": "%1$s вече няма достъп до „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s можеше да вижда тази парола. Сменете я, ако %1$s вече не трябва да я знае.", + "%s could not view this password in Keepiq.": "%s не можеше да види тази парола в Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} от {threshold} одобрения", + "a recovery officer": "служител по възстановяване", + "Account recovery": "Възстановяване на акаунта", + "Approvals needed": "Нужни одобрения", + "Ask {user} which words they see, by phone or in person. They must be:": "Попитайте {user} какви думи вижда, по телефона или лично. Те трябва да са:", + "Check again": "Провери отново", + "Create the recovery key": "Създай ключ за възстановяване", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Създайте ключа за възстановяване. Браузърът ви го създава и дава на всеки служител копие, което само той може да отвори.", + "Decline": "Откажи", + "Enrol in account recovery": "Запишете се за възстановяване на акаунта", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Запишете се, за да може организацията ви да ви помогне да си върнете трезора, ако забравите главната парола.", + "Every user is enrolled": "Всички потребители са записани", + "Finish the recovery in the browser you asked from.": "Завършете възстановяването в браузъра, от който поискахте.", + "Forgot your master password?": "Забравихте главната парола?", + "Hand the key over": "Предай ключа", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Позволете на потребители, забравили главната си парола, да си върнат трезора с одобрение от служители по възстановяване, които посочите.", + "New master password": "Нова главна парола", + "No one is asking to recover their account.": "Никой не иска възстановяване на акаунта си.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Все още няма ключ за възстановяване. Един от служителите го създава в настройките си на Keepiq.", + "Off": "Изключено", + "Officer {user} has no encryption set up yet.": "Служителят {user} още няма настроено шифроване.", + "Officers (user IDs, separated by commas)": "Служители (потребителски ID, разделени със запетаи)", + "Policy": "Правила", + "Publish this fingerprint internally, so users can check it before they enrol.": "Публикувайте този отпечатък вътрешно, за да могат потребителите да го проверят, преди да се запишат.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Възстановено с помощта на {officer}. Сменете ключа на трезора сега в Настройки, Сигурност: \"Главната ми парола е компрометирана\".", + "Recovery key fingerprint: {fingerprint}": "Отпечатък на ключа за възстановяване: {fingerprint}", + "Recovery officer": "Служител по възстановяване", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Премахнатите служители губят копието си сега, но може да са го отворили преди. Нека служител създаде нов ключ за възстановяване.", + "Repeat the new master password": "Повторете новата главна парола", + "Retire this recovery key": "Оттегли този ключ за възстановяване", + "Set the new master password": "Задай новата главна парола", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификатът за възстановяване не е издаден от този Keepiq. Не се записвайте и уведомете администратора си.", + "The words match, approve": "Думите съвпадат, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Този потребител е записан за възстановяване на акаунта. Възстановяването запазва тайните му; отмяната изтрива записването му.", + "Users may enrol": "Потребителите могат да се записват", + "Withdraw from account recovery": "Отпиши се от възстановяване на акаунта", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Записани сте за възстановяване на акаунта. Отпечатък на ключа за възстановяване: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Записани сте. Ако забравите главната си парола, организацията ви може да ви помогне да си върнете трезора.", + "Your key is back. Choose a new master password.": "Ключът ви е върнат. Изберете нова главна парола.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Служителите по възстановяване са уведомени. Прочетете им тези думи, когато ви се обадят или се срещнете:", + "You are now an account recovery officer": "Вече сте служител по възстановяване на акаунти", + "%s asks to recover their account. Compare the words with them before you approve.": "%s иска да възстанови акаунта си. Сравнете думите с него, преди да одобрите.", + "A user": "Потребител", + "Your account recovery request was declined": "Заявката ви за възстановяване на акаунта беше отказана", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Възстановяването на акаунта ви е готово. Отворете Keepiq в браузъра, от който поискахте.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} иска еднократно отключване на ново устройство. Главната парола остава същата.", + "Ask your organisation instead": "Вместо това попитайте организацията си", + "The request ended. Ask again or use your master password.": "Заявката приключи. Поискайте отново или използвайте главната си парола.", + "Added by {user}": "Добавено от {user}", + "Editor": "Редактор", + "Manager": "Мениджър", + "Role of {member}": "Роля на {member}", + "Team folders you manage": "Екипни папки, които управлявате", + "Viewer": "Зрител", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Нямате копие на тези тайни, затова новите членове още не са ги получили. Собственикът може да ги сподели: {names}", + "Admin areas": "Области на администриране", + "Give a group only the parts of Keepiq administration it needs.": "Дайте на група само частите от администрирането на Keepiq, които са ѝ нужни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирайте една или повече области на група на страницата с административни права. Администраторите на инстанцията имат всяка област.", + "Open administration privileges": "Отваряне на административните права", + "Policies": "Политики", + "Applications and machine access": "Приложения и машинен достъп", + "People and offboarding": "Хора и напускане", + "Audit and compliance": "Одит и съответствие", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версия, удостоверяващ орган, прикачени файлове, офлайн кеш, проверка за изтичане, типове тайни и резервни копия", + "master password, organisation password, vault policies, rotation, version history and trash": "главна парола, парола на организацията, политики на трезора, ротация, история на версиите и кошче", + "application queue, application requests and machine leases": "опашка на приложенията, заявки на приложенията и машинни наеми", + "team offboarding, encryption suites and admin handover": "напускане на екипа, шифровъчни пакети и поемане от администратор", + "audit log, compliance reports, SIEM export and honey alerts": "одитен дневник, отчети за съответствие, SIEM износ и сигнали за примамки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колко версии на дадена тайна се пазят, колко дълго и колко дълго изтритите тайни остават в кошчето.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничения за шифровани прикачени файлове, налагани на сървъра в съхранени шифровани байтове.", + "Type the suite ID again to confirm": "Въведете отново ID на пакета за потвърждение", + "This does not match the suite ID.": "Това не съвпада с ID на пакета.", + "Confirm with your master password": "Потвърдете с главната си парола", + "Confirm": "Потвърждаване", + "That master password is not right.": "Тази главна парола не е правилна.", + "You are sharing with someone new. Enter your master password to confirm.": "Споделяте с нов човек. Въведете главната си парола за потвърждение.", + "Enter your master password to confirm this share.": "Въведете главната си парола, за да потвърдите това споделяне.", + "Enter your master password to confirm this delegation.": "Въведете главната си парола, за да потвърдите това делегиране.", + "Approve {member}": "Одобряване на {member}", + "Recipient": "Получател", + "No vault yet": "Все още няма хранилище", + "No matching users": "Няма съвпадащи потребители", + "Partner organisations": "Партньорски организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обменяйте тайни с друг Keepiq. Двамата администратори се добавят взаимно и сравняват коренните отпечатъци по телефона или лично, преди да запазят.", + "Federation needs Nextcloud 33 or later.": "Федерацията изисква Nextcloud 33 или по-нов.", + "Your root fingerprint": "Вашият коренен отпечатък", + "No partners yet.": "Все още няма партньори.", + "Users here may share to this partner": "Потребителите тук могат да споделят с този партньор", + "This partner may share to users here": "Този партньор може да споделя с потребителите тук", + "Partner address": "Адрес на партньора", + "Check partner": "Провери партньора", + "Partner root fingerprint": "Коренен отпечатък на партньора", + "I compared this fingerprint with the partner's administrator": "Сравних този отпечатък с администратора на партньора", + "Add partner": "Добави партньор", + "A secret from another organisation": "Тайна от друга организация", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s сподели \"%2$s\" с вас. Приемете я в Входящи от други организации.", + "Incoming from other organisations": "Входящи от други организации", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Хора от партньорски организации могат да споделят тайна с вас. Приемете я, за да запазите копие само за четене във вашето хранилище.", + "Nothing shared with you yet": "Все още нищо не е споделено с вас", + "Secrets that people in partner organisations share with you appear here.": "Тук се показват тайните, които хора от партньорски организации споделят с вас.", + "From {sender}": "От {sender}", + "Accept": "Приемане", + "Open in vault": "Отваряне в хранилището", + "The other organisation did not hand over the secret. Try again later.": "Другата организация не предаде тайната. Опитайте отново по-късно.", + "Set up your vault before you accept a shared secret.": "Настройте хранилището си, преди да приемете споделена тайна.", + "Something went wrong. Try again.": "Нещо се обърка. Опитайте отново.", + "Waiting for your answer": "Очаква вашия отговор", + "In your vault, read-only": "Във вашето хранилище, само за четене", + "Withdrawn by the sender": "Оттеглено от подателя", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} сподели това от друга организация. Можете да го четете, но не и да го променяте или споделяте.", + "Someone": "Някой", + "Share with someone at another organisation": "Споделяне с някого от друга организация", + "Their account at the other organisation": "Профилът на човека в другата организация", + "Check account": "Проверка на профила", + "Certificate fingerprint of {account}": "Пръстов отпечатък на сертификата на {account}", + "Compare it with them by phone if you want to be sure.": "Сравнете го с човека по телефона, ако искате да сте сигурни.", + "Shared. {account} can accept it in their own vault.": "Споделено. {account} може да го приеме в собственото си хранилище.", + "The certificate could not be verified. Nothing was shared.": "Сертификатът не можа да бъде проверен. Нищо не беше споделено.", + "That organisation is not one of your partners.": "Тази организация не е сред вашите партньори.", + "No one with that account can receive secrets from you.": "Никой с този профил не може да получава тайни от вас.", + "The other organisation did not answer. Try again later.": "Другата организация не отговори. Опитайте отново по-късно.", + "This secret is already shared with that account.": "Тази тайна вече е споделена с този профил.", + "Other organisations": "Други организации", + "Receive secrets from other organisations": "Получаване на тайни от други организации", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тогава хората от партньорски организации могат да намерят профила ви и да споделят тайни с вас. Всяка от тях приемате сами.", + "Shared": "Споделено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Спряно: сертификатът им или партньорството се промени. Отнемете споделянето или споделете отново.", + "Their organisation did not get the last change. Revoke it or share again.": "Организацията им не получи последната промяна. Отнемете споделянето или споделете отново.", + "Being withdrawn": "Отнема се", + "Shared with another organisation": "Споделено с друга организация", + "Change sent to another organisation": "Промяната е изпратена до друга организация", + "Share with another organisation revoked": "Споделянето с друга организация е отменено", + "Share with another organisation paused": "Споделянето с друга организация е спряно", + "Another organisation did not get a change": "Друга организация не получи промяна", + "Secret received from another organisation": "Получена е тайна от друга организация", + "Secret from another organisation accepted": "Тайната от друга организация е приета", + "Secret from another organisation declined": "Тайната от друга организация е отказана", + "Copy from another organisation updated": "Копието от друга организация е обновено", + "Copy from another organisation removed": "Копието от друга организация е премахнато", + "Declined: they removed their copy. Share again if they need it.": "Отказано: получателят премахна своето копие. Споделете отново, ако му трябва.", + "Recipient at another organisation removed their copy": "Получател от друга организация премахна своето копие", + "Removed the user from %n team folder.": "Потребителят е премахнат от %n екипна папка.", + "Removed the user from %n team folders.": "Потребителят е премахнат от %n екипни папки.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Потребителят е премахнат от %n екипна папка.", + "Потребителят е премахнат от %n екипни папки." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Възстановено копие идва от споделяне, което е приключило. То остава само за четене.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организацията, споделила възстановено копие, не може да бъде достигната. Копието остава само за четене и не следва техните промени.", + "Recipient at another organisation restored their copy": "Получател от друга организация възстанови своето копие" }, "plurals": null } diff --git a/l10n/bs.js b/l10n/bs.js index c636188c9..843d41823 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Dijeljeno s grupama", + "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.", + "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.", + "Search groups": "Pretraži grupe", + "Failed to share": "Dijeljenje nije uspjelo", + "Columns": "Kolone", + "Column {number}": "Kolona {number}", + "Map one column to Name. Every secret needs a name.": "Povežite jednu kolonu s nazivom. Svaka tajna treba naziv.", + "Notes": "Bilješke", + "Do not import": "Ne uvozi", + "Hide this value": "Sakrij ovu vrijednost", + "Show this value": "Prikaži ovu vrijednost", + "Defaults": "Zadano", + "New secrets start as this type, and your secret list opens in this view.": "Nove tajne počinju kao ova vrsta, a tvoja lista tajni otvara se u ovom prikazu.", + "Default item type": "Zadana vrsta stavke", + "Cards": "Kartice", + "Table": "Tabela", + "Could not save your default": "Zadanu vrijednost nije moguće sačuvati", + "Recently used": "Nedavno korišteno", + "Opened": "Otvoreno", + "You have not opened any secrets yet": "Još nisi otvorio nijednu tajnu", + "Could not delete the item type.": "Vrstu stavke nije moguće izbrisati.", + "Could not load the item types.": "Vrste stavki nije moguće učitati.", + "Could not save the item type.": "Vrstu stavke nije moguće sačuvati.", + "Delete item type": "Izbriši vrstu stavke", + "Edit item type": "Uredi vrstu stavke", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skriveno", + "Item types": "Vrste stavki", + "Move up": "Pomjeri gore", + "New item type": "Nova vrsta stavke", + "No item types defined yet.": "Još nema definisanih vrsta stavki.", + "Required": "Obavezno", + "Text": "Tekst", + "This field is required": "Ovo polje je obavezno", + "Web address": "Web adresa", + "{label} (required)": "{label} (obavezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrisati „{name}”? Tajne ove vrste ostaju čitljive i postaju stavke Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste stavki koje ovdje definišeš svima se prikazuju u dijalogu Nova tajna, s poljima koja odabereš.", + "Secret moved to the trash": "Tajna premještena u smeće", + "Secret restored from the trash": "Tajna vraćena iz smeća", + "Secret deleted for good": "Tajna trajno izbrisana", + "Secret archived": "Tajna arhivirana", + "Secret unarchived": "Tajna vraćena iz arhive", + "Unarchive": "Vrati iz arhive", + "Could not archive the secret": "Tajnu nije moguće arhivirati", + "Could not unarchive the secret": "Tajnu nije moguće vratiti iz arhive", + "Archive {count} secrets": "Arhiviraj tajne: {count}", + "Unarchive {count} secrets": "Vrati iz arhive tajne: {count}", + "Restore {count} secrets": "Vrati tajne: {count}", + "Delete {count} secrets for good": "Trajno izbriši tajne: {count}", + "Done for {ok} of {total} secrets": "Gotovo za {ok} od {total} tajni", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane tajne nestaju sa liste trezora, iz pretrage, automatskog popunjavanja i izvještaja o stanju. Zadržavaju dijeljenja. Naći ćete ih u Arhivi.", + "These secrets come back to the vault list, search and autofill.": "Ove tajne se vraćaju na listu trezora, u pretragu i automatsko popunjavanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ove tajne se vraćaju na listu trezora. Stara dijeljenja se ne vraćaju, pa ih ponovo podijelite gdje je potrebno.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ovim se brišu tajne zajedno s prilozima i historijom verzija. Ovo se ne može poništiti.", + "Delete for good": "Trajno izbriši", + "Trash": "Smeće", + "The trash is empty": "Smeće je prazno", + "No archived secrets": "Nema arhiviranih tajni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane tajne čekaju ovdje do kraja perioda čuvanja, a zatim se trajno brišu.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte tajnu na njenom panelu s detaljima kako bi ostala izvan liste trezora, pretrage i automatskog popunjavanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ograničenja za šifrirane priloge (provode se na serveru u sačuvanim šifriranim bajtovima), čuvanje historije verzija i koliko dugo izbrisane tajne ostaju u smeću.", + "Days a deleted secret stays in the trash (1 to 365)": "Broj dana koliko izbrisana tajna ostaje u smeću (1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ovim se tajna premješta u smeće i njena dijeljenja odmah prestaju. Možete je vratiti iz smeća do kraja perioda čuvanja: 30 dana, osim ako je administrator to promijenio.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ovim se tajne premještaju u smeće ({count}) i njihova dijeljenja odmah prestaju. Možete ih vratiti iz smeća do kraja perioda čuvanja.", + "Remove {name} from favourites": "Ukloni {name} iz favorita", + "Add {name} to favourites": "Dodaj {name} u favorite", + "Could not change the favourite": "Favorit nije moguće promijeniti", + "Remove from favourites": "Ukloni iz favorita", + "Add to favourites": "Dodaj u favorite", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake nisu šifrovane. Administratori servera ih mogu čitati, kao i nazive foldera.", + "Favourites": "Favoriti", + "Filter by tag": "Filtriraj po oznaci", + "All tags": "Sve oznake", + "Last used": "Zadnji put korišteno", + "Tags for {count} secrets": "Oznake za {count} tajni", + "Tag": "Oznaka", + "Remove tag": "Ukloni oznaku", + "Add tag": "Dodaj oznaku", + "Could not change the tags. Try again.": "Oznake nije moguće promijeniti. Pokušajte ponovo.", + "Could not approve the application. It is still in the queue.": "Zahtjev nije moguće odobriti. Još je u redu čekanja.", + "Could not reject the application. It is still in the queue.": "Zahtjev nije moguće odbiti. Još je u redu čekanja.", + "Removed the user from {count} team folders.": "Korisnik je uklonjen iz {count} timskih foldera.", + "Approve a share": "Odobri dijeljenje", + "This approval link is incomplete. Open it again from the notification.": "Ovaj link za odobrenje je nepotpun. Otvorite ga ponovo iz obavijesti.", + "Deny": "Odbij", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se pridružio grupi s kojom dijelite tajnu. Želite li podijeliti tajnu i s njim?", + "{requester} asks you to share a secret with {user}.": "{requester} traži da podijelite tajnu s korisnikom {user}.", + "Shared. The recipient can now open the secret.": "Podijeljeno. Primalac sada može otvoriti tajnu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Primalac još nije postavio Keepiq, pa ništa nije podijeljeno. Pokušajte ponovo kada to uradi.", + "Could not share the secret. Only its owner can approve this.": "Tajnu nije moguće podijeliti. Ovo može odobriti samo njen vlasnik.", + "Could not share the secret. Try again.": "Tajnu nije moguće podijeliti. Pokušajte ponovo.", + "Denied. Nothing was shared.": "Odbijeno. Ništa nije podijeljeno.", + "Could not deny the request. Try again.": "Zahtjev nije moguće odbiti. Pokušajte ponovo.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s traži da podijelite tajnu \"%2$s\" s korisnikom %3$s.", + "Expires on (optional)": "Ističe (neobavezno)", + "Hand over to": "Predaj korisniku", + "Choose a recipient": "Odaberite primaoca", + "Hand over temporarily": "Privremeno predaj", + "Expiry rules": "Pravila isteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Postavite koliko dugo smiju važiti lozinke jedne vrste stavke ili u jednom folderu i kada želite podsjetnik. Kada važi više datuma, računa se najraniji.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Postavio vaš administrator", + "No expiry rules yet.": "Još nema pravila isteka.", + "Applies to": "Primjenjuje se na", + "Item type": "Vrsta stavke", + "Maximum age in days (empty for reminders only)": "Najveća starost u danima (prazno samo za podsjetnike)", + "Remind me this many days before, comma separated": "Podsjeti me ovoliko dana prije, odvojeno zarezima", + "Save rule": "Spremi pravilo", + "An item type": "Vrsta stavke", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Ističe nakon {days} dana", + "Reminders {days} days before": "Podsjetnici {days} dana prije", + "Could not save the expiry rule.": "Pravilo isteka nije moguće spremiti.", + "Could not delete the expiry rule.": "Pravilo isteka nije moguće izbrisati.", + "All statuses": "Svi statusi", + "Compromised": "Kompromitovan", + "Could not load the members.": "Nije moguće učitati članove.", + "Emergency contact": "Kontakt za hitne slučajeve", + "Leaving user": "Korisnik koji odlazi", + "No": "Ne", + "No users match this filter.": "Nijedan korisnik ne odgovara ovom filteru.", + "Not set up": "Nije postavljeno", + "Revoke suite": "Opozovi paket", + "Revoked": "Opozvan", + "Search users": "Pretraži korisnike", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pogledajte koji su korisnici postavili trezor. Pokrenite odjavu ili opozovite paket iz reda.", + "Successor": "Nasljednik", + "Team folders": "Timski folderi", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Korisnik je još u grupi {groups}, koja je članica timskog foldera. Uklonite ga iz grupe ili onemogućite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun.", + "Vault status": "Status trezora", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz u CXF NIJE ŠIFRIRAN. Svaka lozinka i prijava bit će čitljiva kao otvoreni tekst u preuzetoj datoteci. Čuvajte je na sigurnom i izbrišite je odmah nakon upotrebe.", + "Root certificate expiring soon": "Korijenski certifikat uskoro ističe", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korijenski certifikat trezora ističe za %1$d dan(a). Obnovite ga prije toga. Obnova ponovo potpisuje svaki paket šifrovanja.", + "Compromise recovery aborted": "Oporavak nakon kompromitacije prekinut", + "Key rotation ended by a compromise revoke": "Rotacija ključa završena opozivom zbog kompromitacije", + "Encryption suite revoke refused": "Opoziv paketa šifriranja odbijen", + "Master password proof refused": "Dokaz glavne lozinke odbijen", + "Your current master password": "Vaša trenutna glavna lozinka", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ovi kontakti za hitne slučajeve nisu preneseni na vaš novi ključ. Njihov hitni pristup je uklonjen. Ponovo ih dodajte u Hitnom pristupu ako ih još želite.", + "Renew root certificate": "Obnovi korijenski certifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ovo kreira novi korijenski i posredni certifikat. Svaki aktivni paket šifriranja ponovo se potpisuje. Ovo se ne može poništiti.", + "Renew root": "Obnovi korijen", + "Root renewed. {n} encryption suites signed again.": "Korijen obnovljen. Ponovo potpisanih paketa šifriranja: {n}.", + "Could not renew the root certificate.": "Korijenski certifikat nije moguće obnoviti.", + "Lease policy for this application": "Politika zakupa za ovu aplikaciju", + "In force now: {default} seconds by default, {max} seconds at most.": "Trenutno na snazi: {default} sekundi zadano, najviše {max} sekundi.", + "Leases are not renewable": "Zakupi se ne mogu obnavljati", + "Lease policy saved.": "Politika zakupa sačuvana.", + "Leave a field empty to use the instance value.": "Ostavite polje prazno da koristite vrijednost instance.", + "Instance value: {value}": "Vrijednost instance: {value}", + "Renewal": "Obnavljanje", + "Use the instance value ({value})": "Koristi vrijednost instance ({value})", + "Allowed": "Dozvoljeno", + "Not allowed": "Nije dozvoljeno", + "Save lease policy": "Sačuvaj politiku zakupa", + "Only an administrator can change this policy.": "Samo administrator može promijeniti ovu politiku.", + "Could not save the lease policy.": "Politiku zakupa nije moguće sačuvati.", + "{member} got access from {confirmer}.": "{member} je dobio pristup od {confirmer}.", + "Automatically confirm new team folder members": "Automatski potvrdi nove članove timskih foldera", + "Gave %n new member access to a team folder.": "%n novi član dobio je pristup timskom folderu.", + "Gave %n new members access to a team folder.": "Novi članovi (%n) dobili su pristup timskom folderu.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novim članovima pristup bez čekanja vlasnika foldera.", + "New team folder members": "Novi članovi timskih foldera", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlasnik ili član s pravom pisanja potvrđuje ih iz otvorenog trezora. Keepiq nikad ne dešifruje na serveru.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeka se da član s pravom pisanja otvori Keepiq. Možete i odmah dijeliti.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dio odgovora na kompromitaciju nije uspio ({failed} korak(a)). Provjerite zapisnik servera, a zatim ponovo opozovite paket da ga završite.", + "This also revoked suite {suite} and ended key migration {migration}.": "Time je opozvan i paket {suite} i završena migracija ključeva {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Revoking the second suite deleted %n emergency-access contacts.": "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "A suite revoked as compromised cannot be reinstated.": "Paket opozvan kao kompromitovan ne može se vratiti.", + "Archives to keep": "Arhive za čuvanje", + "Back up every vault automatically": "Automatski napravi kopiju svakog trezora", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Pravite kopiju svakog trezora prema rasporedu. Arhive sadrže samo šifrovani tekst i vraćaju se s occ.", + "Back up now": "Napravi kopiju sada", + "Backup public key (PEM, optional)": "Javni ključ kopije (PEM, neobavezno)", + "Backup requested for the next cron run": "Kopija zatražena za sljedeće pokretanje crona", + "Encrypted": "Šifrovano", + "Every (hours)": "Svakih (sati)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} nije uspjela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspjela.", + "No archives yet.": "Još nema arhiva.", + "Size": "Veličina", + "Vault backups": "Kopije trezora", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se svaka arhiva šifruje za njega. Privatni ključ čuvajte izvan ovog servera: trebate ga za provjeru ili vraćanje.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervni kodovi se ne računaju. Ako se vaši korisnici prijavljuju preko pružatelja identiteta s vlastitim drugim faktorom, izostavite njihove grupe.", + "Block personal vault export": "Blokiraj izvoz ličnog trezora", + "Keep work logins in team folders": "Čuvaj poslovne prijave u timskim folderima", + "Move to a team folder": "Premjesti u timski folder", + "Not in a team folder": "Nije u timskom folderu", + "Only for these groups (empty is everyone)": "Samo za ove grupe (prazno znači svi)", + "Require two-factor login before the vault opens": "Zahtijevaj prijavu u dva koraka prije otvaranja trezora", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za svaki trezor. Svako vrijedi za sve ili samo za grupe koje odaberete.", + "Secret types that belong in a team folder": "Vrste tajni koje pripadaju u timski folder", + "Set up two-factor login": "Postavi prijavu u dva koraka", + "Team folder you can write to": "Timski folder u koji možete pisati", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Korisnici ne mogu preuzeti sigurnosnu kopiju, CSV ni datoteku za prijenos. Njihov paket ličnih podataka ostaje dostupan.", + "Users cannot save these secret types in a personal folder.": "Korisnici ne mogu spremiti ove vrste tajni u lični folder.", + "Vault policies": "Pravila trezora", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dozvoljava izvoz vašeg ličnog trezora. Vaš paket ličnih podataka u postavkama ostaje dostupan.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija čuva ove tajne u timskom folderu. Premjestite svaku u timski folder.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija čuva ovu vrstu tajne u timskom folderu. Odaberite jedan od svojih timskih foldera ili onaj u koji možete pisati.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahtijeva prijavu u dva koraka prije nego što možete otvoriti svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Korisnici biraju koliko dugo proširenje ostaje otključano tokom neaktivnosti. Vi postavljate najduže vrijeme koje smiju odabrati.", + "Longest idle time before the extension locks": "Najduže vrijeme neaktivnosti prije zaključavanja proširenja", + "1 minute": "1 minuta", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 sat", + "4 hours": "4 sata", + "Connector": "Konektor", + "Directory (tenant) ID": "ID direktorija (zakupca)", + "Application (client) ID": "ID aplikacije (klijenta)", + "Data collection rule immutable ID": "Nepromjenjivi ID pravila prikupljanja podataka", + "Stream name": "Naziv toka", + "Splunk index (optional)": "Splunk indeks (neobavezno)", + "Sourcetype (optional)": "Sourcetype (neobavezno)", + "Leave blank to keep the current one": "Ostavite prazno da zadržite trenutnu vrijednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF preko sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Krajnja tačka prikupljanja podataka (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajna klijenta (samo pisanje)", + "HEC token (write-only)": "HEC token (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Prosljeđujte dozvoljene revizijske događaje u Splunk, Microsoft Sentinel, syslog prijemnik ili webhook. Poruke sadrže samo očišćene metapodatke: nijedna tajna vrijednost, ime, prijava ili šifrirani tekst nikada ne napušta server.", + "%n change waiting to sync": "%n promjena čeka sinhronizaciju", + "%n changes waiting to sync": "%n promjena čeka sinhronizaciju", + "Changes that could not sync": "Promjene koje se nisu mogle sinhronizovati", + "Choose a version": "Odaberite verziju", + "Copy value": "Kopiraj vrijednost", + "Deleted": "Izbrisano", + "Discard": "Odbaci", + "Keep my offline change": "Zadrži moju vanmrežnu promjenu", + "Keep the server version": "Zadrži verziju sa servera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je van mreže samo za čitanje. Administrator nije uključio uređivanje van mreže.", + "Let users edit secrets offline": "Dozvoli korisnicima uređivanje tajni van mreže", + "Not synced yet": "Još nije sinhronizovano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Vanmrežne promjene ostaju na uređaju, šifrovane za korisnika, i sinhronizuju se pri sljedećem otključavanju na mreži. Dijeljenje, folderi i prilozi i dalje trebaju vezu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Van mreže. Uređivanja, premještanja i brisanja ostaju na ovom uređaju i sinhronizuju se kad ponovo budete na mreži. Dijeljenje i prilozi trebaju vezu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Van mreže. Vaše promjene ostaju na ovom uređaju i sinhronizuju se kad ponovo budete na mreži. Zadnja sinhronizacija {when}.", + "Open my changes": "Otvori moje promjene", + "Sharing needs a connection": "Dijeljenje treba vezu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Neko je promijenio ovu tajnu na serveru nakon što je napravljena vaša vanmrežna kopija. Odaberite koju verziju zadržati.", + "Sync or discard your offline changes before you rotate your keys.": "Sinhronizujte ili odbacite vanmrežne promjene prije zamjene ključeva.", + "That password did not open your changes.": "Ta lozinka nije otvorila vaše promjene.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Vanmrežni snimak čuva šifrovane tajne (otvaraju se samo ključem izvedenim iz glavne lozinke korisnika, tačno kao na serveru) i šifruje nazive, URL-ove i nazive foldera u pohrani. Vanmrežni pristup je samo za čitanje, osim ako ispod dozvolite uređivanje van mreže. Isključite ovo za uređaje koji nikad ne smiju keširati akreditive; isključivanje briše postojeće keševe pri sljedećem učitavanju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prethodne kopije trezora više nema, pa se ove promjene ne mogu otvoriti.", + "The server version": "Verzija sa servera", + "This secret changed while you were offline": "Ova tajna se promijenila dok ste bili van mreže", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ovu ste tajnu izbrisali van mreže, ali je u međuvremenu promijenjena na serveru. Odaberite koju verziju zadržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključevi su promijenjeni na drugom uređaju. Unesite prethodnu glavnu lozinku za sinhronizaciju vanmrežnih promjena ili ih odbacite.", + "Your offline change": "Vaša vanmrežna promjena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.","Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.","Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n stavku nije moguće prikazati u CXF-u i bit će preskočena.","%n stavki nije moguće prikazati u CXF-u i bit će preskočene.","%n stavki nije moguće prikazati u CXF-u i bit će preskočene."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n starija verzija je uklonjena jer se može prenijeti samo nedavna historija.","%n starijih verzija je uklonjeno jer se može prenijeti samo nedavna historija.","%n starijih verzija je uklonjeno jer se može prenijeti samo nedavna historija."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopiju tajne još treba šifrirati i podijeliti.","%n kopija tajni još treba šifrirati i podijeliti.","%n kopija tajni još treba šifrirati i podijeliti."], + "_%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 tajna nije mogla biti dešifrovana i nije u ovom izvozu.","%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu.","%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n tajna se nije mogla dešifrovati vašim starim ključem, pa nije prenesena.","%n tajni se nije moglo dešifrovati vašim starim ključem, pa nisu prenesene.","%n tajni se nije moglo dešifrovati vašim starim ključem, pa nisu prenesene."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n tajna nije prenesena.","%n tajni nije preneseno.","%n tajni nije preneseno."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n tajna je još uvijek šifrovana vašim prethodnim ključem.","%n tajni je još uvijek šifrovano vašim prethodnim ključem.","%n tajni je još uvijek šifrovano vašim prethodnim ključem."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n tajna je preskočena jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.","%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.","%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno."], + "_%n secret_::_%n secrets_": ["%n tajna","%n tajne","%n tajne"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.","Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.","Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Ipak završi, uz gubitak pristupa %n tajni","Ipak završi, uz gubitak pristupa %n tajni","Ipak završi, uz gubitak pristupa %n tajni"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n novi član dobio je pristup timskom folderu.","Novi članovi (%n) dobili su pristup timskom folderu.","Novi članovi (%n) dobili su pristup timskom folderu."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotacija ključa je završena. %n tajna je ponovno šifrovana vašim novim ključem.","Rotacija ključa je završena. %n tajni je ponovno šifrovano vašim novim ključem.","Rotacija ključa je završena. %n tajni je ponovno šifrovano vašim novim ključem."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.","Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.","Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.","Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.","Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["zabilježeno %n put u procurjelim podacima","zabilježeno %n puta u procurjelim podacima","zabilježeno %n puta u procurjelim podacima"], + "_shared with %n secret_::_shared with %n secrets_": ["podijeljeno s %n tajnom","podijeljeno s %n tajni","podijeljeno s %n tajni"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ova mapa izravno sadrži %n tajnu.","Ova mapa izravno sadrži %n tajne.","Ova mapa izravno sadrži %n tajne."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n promjena čeka sinhronizaciju","%n promjena čeka sinhronizaciju","%n promjena čeka sinhronizaciju"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Korisnik je još u grupi {groups}, koja je članica timskog foldera. Uklonite ga iz grupe ili onemogućite račun.","Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun.","Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun."], + "Allow approval from another device": "Dozvoli odobrenje s drugog uređaja", + "App": "Aplikacija", + "Approve a new device": "Odobri novi uređaj", + "Approve from another device": "Odobri s drugog uređaja", + "Asked at": "Zatraženo u", + "Check that the new device shows these words:": "Provjerite da li novi uređaj prikazuje ove riječi:", + "Denied. If you did not ask, end your other sessions:": "Odbijeno. Ako to niste zatražili, završite ostale sesije:", + "Device": "Uređaj", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Omogući korisnicima da otključaju novi preglednik odobravanjem s uređaja na kojem je Keepiq već otključan.", + "New device approval": "Odobravanje novih uređaja", + "Nextcloud security settings": "Sigurnosne postavke Nextclouda", + "Only approve a device you are using right now.": "Odobrite samo uređaj koji upravo koristite.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorite Keepiq na uređaju na kojem je otključan i odobrite ovaj uređaj. Provjerite da li prikazuje iste riječi:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Uređaj koji odobrava zapečati ključ za otključavanje za novi uređaj. Server ga samo prosljeđuje i ne može ga otvoriti.", + "The master password is not right, or the request has ended.": "Glavna lozinka nije tačna ili je zahtjev završen.", + "The request expired. Ask again or use your master password.": "Zahtjev je istekao. Zatražite ponovo ili koristite glavnu lozinku.", + "The request was denied.": "Zahtjev je odbijen.", + "Too many requests. Try again in an hour or use your master password.": "Previše zahtjeva. Pokušajte ponovo za sat vremena ili koristite glavnu lozinku.", + "Unknown device": "Nepoznat uređaj", + "Web app": "Web aplikacija", + "A device": "Uređaj", + "A new device asks to open your vault": "Novi uređaj traži da otvori vaš trezor", + "%s asks to be approved. Only approve a device you are using right now.": "%s traži odobrenje. Odobrite samo uređaj koji upravo koristite.", + "Access ends on (optional)": "Pristup ističe (opcionalno)", + "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.": "Aplikacije Keepiq neće prikazati niti kopirati lozinku. Neko s tehničkim znanjem je i dalje može pročitati na svom uređaju. Promijenite je kada pristup istekne.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ova tajna je samo za korištenje. Prijavite se putem proširenja preglednika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo korištenje", + "Use only (can sign in, cannot view or copy)": "Samo korištenje (može se prijaviti, ne može vidjeti niti kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ovom prijavom se možete prijaviti putem proširenja preglednika Keepiq. Vlasnik je odlučio da je ne možete vidjeti niti kopirati.", + "Your access ends on {date}": "Vaš pristup ističe {date}", + "Your access to this secret has ended": "Vaš pristup ovoj tajni je istekao", + "Your access to \"%s\" ends tomorrow": "Vaš pristup stavci \"%s\" ističe sutra", + "Your access to \"%s\" has ended": "Vaš pristup stavci \"%s\" je istekao", + "%1$s no longer has access to \"%2$s\"": "%1$s više nema pristup stavci \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je mogao/la vidjeti ovu lozinku. Promijenite je ako %1$s više ne treba da je zna.", + "%s could not view this password in Keepiq.": "%s nije mogao/la vidjeti ovu lozinku u Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobrenja", + "a recovery officer": "službenik za oporavak", + "Account recovery": "Oporavak računa", + "Approvals needed": "Potrebna odobrenja", + "Ask {user} which words they see, by phone or in person. They must be:": "Pitajte {user} koje riječi vidi, telefonom ili lično. Moraju biti:", + "Check again": "Provjeri ponovo", + "Create the recovery key": "Kreiraj ključ za oporavak", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Kreirajte ključ za oporavak. Vaš preglednik ga pravi i svakom službeniku daje kopiju koju samo on može otvoriti.", + "Decline": "Odbij", + "Enrol in account recovery": "Prijavite se za oporavak računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se kako bi vam organizacija mogla pomoći da vratite trezor ako zaboravite glavnu lozinku.", + "Every user is enrolled": "Svi korisnici su prijavljeni", + "Finish the recovery in the browser you asked from.": "Završite oporavak u pregledniku iz kojeg ste tražili.", + "Forgot your master password?": "Zaboravili ste glavnu lozinku?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Omogućite korisnicima koji su zaboravili glavnu lozinku da vrate trezor, uz odobrenje službenika za oporavak koje imenujete.", + "New master password": "Nova glavna lozinka", + "No one is asking to recover their account.": "Niko ne traži oporavak računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Još nema ključa za oporavak. Jedan od službenika ga kreira u svojim Keepiq postavkama.", + "Off": "Isključeno", + "Officer {user} has no encryption set up yet.": "Službenik {user} još nema postavljeno šifriranje.", + "Officers (user IDs, separated by commas)": "Službenici (korisnički ID-ovi, odvojeni zarezima)", + "Policy": "Pravila", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ovaj otisak interno, kako bi ga korisnici mogli provjeriti prije prijave.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Oporavljeno uz pomoć {officer}. Sada promijenite ključ trezora u Postavkama, Sigurnost: \"Moja glavna lozinka je kompromitovana\".", + "Recovery key fingerprint: {fingerprint}": "Otisak ključa za oporavak: {fingerprint}", + "Recovery officer": "Službenik za oporavak", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Uklonjeni službenici sada gube svoju kopiju, ali su je možda ranije otvorili. Neka službenik kreira novi ključ za oporavak.", + "Repeat the new master password": "Ponovite novu glavnu lozinku", + "Retire this recovery key": "Povuci ovaj ključ za oporavak", + "Set the new master password": "Postavi novu glavnu lozinku", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikat za oporavak nije izdao ovaj Keepiq. Nemojte se prijaviti i obavijestite administratora.", + "The words match, approve": "Riječi se podudaraju, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ovaj korisnik je prijavljen za oporavak računa. Oporavak čuva njegove tajne; opoziv briše njegovu prijavu.", + "Users may enrol": "Korisnici se mogu prijaviti", + "Withdraw from account recovery": "Odjavi se iz oporavka računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste za oporavak računa. Otisak ključa za oporavak: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Ako zaboravite glavnu lozinku, vaša organizacija vam može pomoći da vratite trezor.", + "Your key is back. Choose a new master password.": "Ključ je vraćen. Odaberite novu glavnu lozinku.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši službenici za oporavak su obaviješteni. Pročitajte im ove riječi kada vas pozovu ili se sretnete:", + "You are now an account recovery officer": "Sada ste službenik za oporavak računa", + "%s asks to recover their account. Compare the words with them before you approve.": "%s traži oporavak računa. Uporedite riječi s njim prije nego što odobrite.", + "A user": "Korisnik", + "Your account recovery request was declined": "Vaš zahtjev za oporavak računa je odbijen", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Oporavak računa je spreman. Otvorite Keepiq u pregledniku iz kojeg ste tražili.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} traži da se novi uređaj jednom otključa. Glavna lozinka ostaje ista.", + "Ask your organisation instead": "Umjesto toga pitajte svoju organizaciju", + "The request ended. Ask again or use your master password.": "Zahtjev je završen. Pitajte ponovo ili koristite glavnu lozinku.", + "Added by {user}": "Dodao/la {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Uloga korisnika {member}", + "Team folders you manage": "Timske mape kojima upravljate", + "Viewer": "Preglednik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemate kopiju ovih tajni, pa ih novi članovi još nisu dobili. Vlasnik ih može podijeliti: {names}", + "Admin areas": "Područja administracije", + "Give a group only the parts of Keepiq administration it needs.": "Dajte grupi samo one dijelove administracije Keepiqa koji su joj potrebni.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegirajte jedno ili više područja grupi na stranici administratorskih ovlaštenja. Administratori instance imaju svako područje.", + "Open administration privileges": "Otvori administratorska ovlaštenja", + "Policies": "Pravila", + "Applications and machine access": "Aplikacije i pristup mašina", + "People and offboarding": "Ljudi i odlasci", + "Audit and compliance": "Revizija i usklađenost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzija, certifikacijsko tijelo, prilozi, offline keš, provjera curenja, tipovi tajni i rezervne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavna lozinka, lozinka organizacije, pravila trezora, rotacija, historija verzija i smeće", + "application queue, application requests and machine leases": "red aplikacija, zahtjevi aplikacija i zakupi mašina", + "team offboarding, encryption suites and admin handover": "odlasci iz tima, paketi šifriranja i preuzimanje od strane administratora", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski dnevnik, izvještaji o usklađenosti, SIEM izvoz i upozorenja mamaca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko verzija tajne se čuva, koliko dugo, i koliko dugo obrisane tajne ostaju u smeću.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ograničenja za šifrirane priloge, koja server primjenjuje na sačuvane šifrirane bajtove.", + "Type the suite ID again to confirm": "Ponovo unesite ID paketa za potvrdu", + "This does not match the suite ID.": "Ovo se ne podudara s ID-om paketa.", + "Confirm with your master password": "Potvrdite glavnom lozinkom", + "Confirm": "Potvrdi", + "That master password is not right.": "Ta glavna lozinka nije ispravna.", + "You are sharing with someone new. Enter your master password to confirm.": "Dijelite s novom osobom. Unesite glavnu lozinku za potvrdu.", + "Enter your master password to confirm this share.": "Unesite glavnu lozinku da potvrdite ovo dijeljenje.", + "Enter your master password to confirm this delegation.": "Unesite glavnu lozinku da potvrdite ovo delegiranje.", + "Approve {member}": "Odobri {member}", + "Recipient": "Primalac", + "No vault yet": "Još nema trezora", + "No matching users": "Nema odgovarajućih korisnika", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Razmjenjujte tajne s drugim Keepiqom. Oba administratora dodaju jedan drugog i prije spremanja uporede korijenske otiske telefonom ili lično.", + "Federation needs Nextcloud 33 or later.": "Federacija zahtijeva Nextcloud 33 ili noviji.", + "Your root fingerprint": "Vaš korijenski otisak", + "No partners yet.": "Još nema partnera.", + "Users here may share to this partner": "Korisnici ovdje smiju dijeliti s ovim partnerom", + "This partner may share to users here": "Ovaj partner smije dijeliti s korisnicima ovdje", + "Partner address": "Adresa partnera", + "Check partner": "Provjeri partnera", + "Partner root fingerprint": "Korijenski otisak partnera", + "I compared this fingerprint with the partner's administrator": "Uporedio sam ovaj otisak s administratorom partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Tajna iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s dijeli s vama \"%2$s\". Prihvatite je u odjeljku Dolazno iz drugih organizacija.", + "Incoming from other organisations": "Dolazno iz drugih organizacija", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osobe u partnerskim organizacijama mogu s vama dijeliti tajnu. Prihvatite je da biste zadržali kopiju samo za čitanje u svom trezoru.", + "Nothing shared with you yet": "Još ništa nije dijeljeno s vama", + "Secrets that people in partner organisations share with you appear here.": "Ovdje se prikazuju tajne koje s vama dijele osobe u partnerskim organizacijama.", + "From {sender}": "Od {sender}", + "Accept": "Prihvati", + "Open in vault": "Otvori u trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija nije predala tajnu. Pokušajte ponovo kasnije.", + "Set up your vault before you accept a shared secret.": "Postavite svoj trezor prije nego što prihvatite dijeljenu tajnu.", + "Something went wrong. Try again.": "Nešto nije u redu. Pokušajte ponovo.", + "Waiting for your answer": "Čeka vaš odgovor", + "In your vault, read-only": "U vašem trezoru, samo za čitanje", + "Withdrawn by the sender": "Pošiljalac je povukao", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} dijeli ovo iz druge organizacije. Možete to čitati, ali ne i mijenjati ili dijeliti.", + "Someone": "Neko", + "Share with someone at another organisation": "Dijeli s nekim iz druge organizacije", + "Their account at the other organisation": "Račun te osobe u drugoj organizaciji", + "Check account": "Provjeri račun", + "Certificate fingerprint of {account}": "Otisak certifikata za {account}", + "Compare it with them by phone if you want to be sure.": "Uporedite ga s tom osobom telefonom ako želite biti sigurni.", + "Shared. {account} can accept it in their own vault.": "Podijeljeno. {account} to može prihvatiti u svom trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikat nije moguće provjeriti. Ništa nije podijeljeno.", + "That organisation is not one of your partners.": "Ta organizacija nije jedan od vaših partnera.", + "No one with that account can receive secrets from you.": "Niko s tim računom ne može primati tajne od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija nije odgovorila. Pokušajte ponovo kasnije.", + "This secret is already shared with that account.": "Ova tajna je već podijeljena s tim računom.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Primanje tajni iz drugih organizacija", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osobe u partnerskim organizacijama tada mogu pronaći vaš račun i dijeliti tajne s vama. Svaku od njih prihvatate sami.", + "Shared": "Podijeljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauzirano: promijenio se njihov certifikat ili partnerstvo. Opozovite ili podijelite ponovo.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija nije dobila posljednju promjenu. Opozovite ili podijelite ponovo.", + "Being withdrawn": "Povlači se", + "Shared with another organisation": "Podijeljeno s drugom organizacijom", + "Change sent to another organisation": "Promjena poslana drugoj organizaciji", + "Share with another organisation revoked": "Dijeljenje s drugom organizacijom ukinuto", + "Share with another organisation paused": "Dijeljenje s drugom organizacijom pauzirano", + "Another organisation did not get a change": "Druga organizacija nije dobila promjenu", + "Secret received from another organisation": "Tajna primljena iz druge organizacije", + "Secret from another organisation accepted": "Tajna iz druge organizacije prihvaćena", + "Secret from another organisation declined": "Tajna iz druge organizacije odbijena", + "Copy from another organisation updated": "Kopija iz druge organizacije ažurirana", + "Copy from another organisation removed": "Kopija iz druge organizacije uklonjena", + "Declined: they removed their copy. Share again if they need it.": "Odbijeno: primalac je uklonio svoju kopiju. Podijelite ponovo ako mu treba.", + "Recipient at another organisation removed their copy": "Primalac iz druge organizacije uklonio je svoju kopiju", + "Removed the user from %n team folder.": "Korisnik je uklonjen iz %n timskog foldera.", + "Removed the user from %n team folders.": "Korisnik je uklonjen iz %n timskih foldera.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Korisnik je uklonjen iz %n timskog foldera.","Korisnik je uklonjen iz %n timskih foldera.","Korisnik je uklonjen iz %n timskih foldera."], + "A restored copy came from a share that has ended. It stays read-only.": "Vraćena kopija potječe iz dijeljenja koje je završeno. Ostaje samo za čitanje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacija koja je podijelila vraćenu kopiju nije dostupna. Kopija ostaje samo za čitanje i ne prati njihove izmjene.", + "Recipient at another organisation restored their copy": "Primalac iz druge organizacije vratio je svoju kopiju" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/bs.json b/l10n/bs.json index 288050ebe..c745b7e2d 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Dijeljeno s grupama", + "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.", + "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.", + "Search groups": "Pretraži grupe", + "Failed to share": "Dijeljenje nije uspjelo", + "Columns": "Kolone", + "Column {number}": "Kolona {number}", + "Map one column to Name. Every secret needs a name.": "Povežite jednu kolonu s nazivom. Svaka tajna treba naziv.", + "Notes": "Bilješke", + "Do not import": "Ne uvozi", + "Hide this value": "Sakrij ovu vrijednost", + "Show this value": "Prikaži ovu vrijednost", + "Defaults": "Zadano", + "New secrets start as this type, and your secret list opens in this view.": "Nove tajne počinju kao ova vrsta, a tvoja lista tajni otvara se u ovom prikazu.", + "Default item type": "Zadana vrsta stavke", + "Cards": "Kartice", + "Table": "Tabela", + "Could not save your default": "Zadanu vrijednost nije moguće sačuvati", + "Recently used": "Nedavno korišteno", + "Opened": "Otvoreno", + "You have not opened any secrets yet": "Još nisi otvorio nijednu tajnu", + "Could not delete the item type.": "Vrstu stavke nije moguće izbrisati.", + "Could not load the item types.": "Vrste stavki nije moguće učitati.", + "Could not save the item type.": "Vrstu stavke nije moguće sačuvati.", + "Delete item type": "Izbriši vrstu stavke", + "Edit item type": "Uredi vrstu stavke", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skriveno", + "Item types": "Vrste stavki", + "Move up": "Pomjeri gore", + "New item type": "Nova vrsta stavke", + "No item types defined yet.": "Još nema definisanih vrsta stavki.", + "Required": "Obavezno", + "Text": "Tekst", + "This field is required": "Ovo polje je obavezno", + "Web address": "Web adresa", + "{label} (required)": "{label} (obavezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrisati „{name}”? Tajne ove vrste ostaju čitljive i postaju stavke Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste stavki koje ovdje definišeš svima se prikazuju u dijalogu Nova tajna, s poljima koja odabereš.", + "Secret moved to the trash": "Tajna premještena u smeće", + "Secret restored from the trash": "Tajna vraćena iz smeća", + "Secret deleted for good": "Tajna trajno izbrisana", + "Secret archived": "Tajna arhivirana", + "Secret unarchived": "Tajna vraćena iz arhive", + "Unarchive": "Vrati iz arhive", + "Could not archive the secret": "Tajnu nije moguće arhivirati", + "Could not unarchive the secret": "Tajnu nije moguće vratiti iz arhive", + "Archive {count} secrets": "Arhiviraj tajne: {count}", + "Unarchive {count} secrets": "Vrati iz arhive tajne: {count}", + "Restore {count} secrets": "Vrati tajne: {count}", + "Delete {count} secrets for good": "Trajno izbriši tajne: {count}", + "Done for {ok} of {total} secrets": "Gotovo za {ok} od {total} tajni", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane tajne nestaju sa liste trezora, iz pretrage, automatskog popunjavanja i izvještaja o stanju. Zadržavaju dijeljenja. Naći ćete ih u Arhivi.", + "These secrets come back to the vault list, search and autofill.": "Ove tajne se vraćaju na listu trezora, u pretragu i automatsko popunjavanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ove tajne se vraćaju na listu trezora. Stara dijeljenja se ne vraćaju, pa ih ponovo podijelite gdje je potrebno.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ovim se brišu tajne zajedno s prilozima i historijom verzija. Ovo se ne može poništiti.", + "Delete for good": "Trajno izbriši", + "Trash": "Smeće", + "The trash is empty": "Smeće je prazno", + "No archived secrets": "Nema arhiviranih tajni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane tajne čekaju ovdje do kraja perioda čuvanja, a zatim se trajno brišu.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte tajnu na njenom panelu s detaljima kako bi ostala izvan liste trezora, pretrage i automatskog popunjavanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ograničenja za šifrirane priloge (provode se na serveru u sačuvanim šifriranim bajtovima), čuvanje historije verzija i koliko dugo izbrisane tajne ostaju u smeću.", + "Days a deleted secret stays in the trash (1 to 365)": "Broj dana koliko izbrisana tajna ostaje u smeću (1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ovim se tajna premješta u smeće i njena dijeljenja odmah prestaju. Možete je vratiti iz smeća do kraja perioda čuvanja: 30 dana, osim ako je administrator to promijenio.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ovim se tajne premještaju u smeće ({count}) i njihova dijeljenja odmah prestaju. Možete ih vratiti iz smeća do kraja perioda čuvanja.", + "Remove {name} from favourites": "Ukloni {name} iz favorita", + "Add {name} to favourites": "Dodaj {name} u favorite", + "Could not change the favourite": "Favorit nije moguće promijeniti", + "Remove from favourites": "Ukloni iz favorita", + "Add to favourites": "Dodaj u favorite", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake nisu šifrovane. Administratori servera ih mogu čitati, kao i nazive foldera.", + "Favourites": "Favoriti", + "Filter by tag": "Filtriraj po oznaci", + "All tags": "Sve oznake", + "Last used": "Zadnji put korišteno", + "Tags for {count} secrets": "Oznake za {count} tajni", + "Tag": "Oznaka", + "Remove tag": "Ukloni oznaku", + "Add tag": "Dodaj oznaku", + "Could not change the tags. Try again.": "Oznake nije moguće promijeniti. Pokušajte ponovo.", + "Could not approve the application. It is still in the queue.": "Zahtjev nije moguće odobriti. Još je u redu čekanja.", + "Could not reject the application. It is still in the queue.": "Zahtjev nije moguće odbiti. Još je u redu čekanja.", + "Removed the user from {count} team folders.": "Korisnik je uklonjen iz {count} timskih foldera.", + "Approve a share": "Odobri dijeljenje", + "This approval link is incomplete. Open it again from the notification.": "Ovaj link za odobrenje je nepotpun. Otvorite ga ponovo iz obavijesti.", + "Deny": "Odbij", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se pridružio grupi s kojom dijelite tajnu. Želite li podijeliti tajnu i s njim?", + "{requester} asks you to share a secret with {user}.": "{requester} traži da podijelite tajnu s korisnikom {user}.", + "Shared. The recipient can now open the secret.": "Podijeljeno. Primalac sada može otvoriti tajnu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Primalac još nije postavio Keepiq, pa ništa nije podijeljeno. Pokušajte ponovo kada to uradi.", + "Could not share the secret. Only its owner can approve this.": "Tajnu nije moguće podijeliti. Ovo može odobriti samo njen vlasnik.", + "Could not share the secret. Try again.": "Tajnu nije moguće podijeliti. Pokušajte ponovo.", + "Denied. Nothing was shared.": "Odbijeno. Ništa nije podijeljeno.", + "Could not deny the request. Try again.": "Zahtjev nije moguće odbiti. Pokušajte ponovo.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s traži da podijelite tajnu \"%2$s\" s korisnikom %3$s.", + "Expires on (optional)": "Ističe (neobavezno)", + "Hand over to": "Predaj korisniku", + "Choose a recipient": "Odaberite primaoca", + "Hand over temporarily": "Privremeno predaj", + "Expiry rules": "Pravila isteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Postavite koliko dugo smiju važiti lozinke jedne vrste stavke ili u jednom folderu i kada želite podsjetnik. Kada važi više datuma, računa se najraniji.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Postavio vaš administrator", + "No expiry rules yet.": "Još nema pravila isteka.", + "Applies to": "Primjenjuje se na", + "Item type": "Vrsta stavke", + "Maximum age in days (empty for reminders only)": "Najveća starost u danima (prazno samo za podsjetnike)", + "Remind me this many days before, comma separated": "Podsjeti me ovoliko dana prije, odvojeno zarezima", + "Save rule": "Spremi pravilo", + "An item type": "Vrsta stavke", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Ističe nakon {days} dana", + "Reminders {days} days before": "Podsjetnici {days} dana prije", + "Could not save the expiry rule.": "Pravilo isteka nije moguće spremiti.", + "Could not delete the expiry rule.": "Pravilo isteka nije moguće izbrisati.", + "All statuses": "Svi statusi", + "Compromised": "Kompromitovan", + "Could not load the members.": "Nije moguće učitati članove.", + "Emergency contact": "Kontakt za hitne slučajeve", + "Leaving user": "Korisnik koji odlazi", + "No": "Ne", + "No users match this filter.": "Nijedan korisnik ne odgovara ovom filteru.", + "Not set up": "Nije postavljeno", + "Revoke suite": "Opozovi paket", + "Revoked": "Opozvan", + "Search users": "Pretraži korisnike", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pogledajte koji su korisnici postavili trezor. Pokrenite odjavu ili opozovite paket iz reda.", + "Successor": "Nasljednik", + "Team folders": "Timski folderi", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Korisnik je još u grupi {groups}, koja je članica timskog foldera. Uklonite ga iz grupe ili onemogućite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun.", + "Vault status": "Status trezora", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz u CXF NIJE ŠIFRIRAN. Svaka lozinka i prijava bit će čitljiva kao otvoreni tekst u preuzetoj datoteci. Čuvajte je na sigurnom i izbrišite je odmah nakon upotrebe.", + "Root certificate expiring soon": "Korijenski certifikat uskoro ističe", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korijenski certifikat trezora ističe za %1$d dan(a). Obnovite ga prije toga. Obnova ponovo potpisuje svaki paket šifrovanja.", + "Compromise recovery aborted": "Oporavak nakon kompromitacije prekinut", + "Key rotation ended by a compromise revoke": "Rotacija ključa završena opozivom zbog kompromitacije", + "Encryption suite revoke refused": "Opoziv paketa šifriranja odbijen", + "Master password proof refused": "Dokaz glavne lozinke odbijen", + "Your current master password": "Vaša trenutna glavna lozinka", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ovi kontakti za hitne slučajeve nisu preneseni na vaš novi ključ. Njihov hitni pristup je uklonjen. Ponovo ih dodajte u Hitnom pristupu ako ih još želite.", + "Renew root certificate": "Obnovi korijenski certifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ovo kreira novi korijenski i posredni certifikat. Svaki aktivni paket šifriranja ponovo se potpisuje. Ovo se ne može poništiti.", + "Renew root": "Obnovi korijen", + "Root renewed. {n} encryption suites signed again.": "Korijen obnovljen. Ponovo potpisanih paketa šifriranja: {n}.", + "Could not renew the root certificate.": "Korijenski certifikat nije moguće obnoviti.", + "Lease policy for this application": "Politika zakupa za ovu aplikaciju", + "In force now: {default} seconds by default, {max} seconds at most.": "Trenutno na snazi: {default} sekundi zadano, najviše {max} sekundi.", + "Leases are not renewable": "Zakupi se ne mogu obnavljati", + "Lease policy saved.": "Politika zakupa sačuvana.", + "Leave a field empty to use the instance value.": "Ostavite polje prazno da koristite vrijednost instance.", + "Instance value: {value}": "Vrijednost instance: {value}", + "Renewal": "Obnavljanje", + "Use the instance value ({value})": "Koristi vrijednost instance ({value})", + "Allowed": "Dozvoljeno", + "Not allowed": "Nije dozvoljeno", + "Save lease policy": "Sačuvaj politiku zakupa", + "Only an administrator can change this policy.": "Samo administrator može promijeniti ovu politiku.", + "Could not save the lease policy.": "Politiku zakupa nije moguće sačuvati.", + "{member} got access from {confirmer}.": "{member} je dobio pristup od {confirmer}.", + "Automatically confirm new team folder members": "Automatski potvrdi nove članove timskih foldera", + "Gave %n new member access to a team folder.": "%n novi član dobio je pristup timskom folderu.", + "Gave %n new members access to a team folder.": "Novi članovi (%n) dobili su pristup timskom folderu.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novim članovima pristup bez čekanja vlasnika foldera.", + "New team folder members": "Novi članovi timskih foldera", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlasnik ili član s pravom pisanja potvrđuje ih iz otvorenog trezora. Keepiq nikad ne dešifruje na serveru.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeka se da član s pravom pisanja otvori Keepiq. Možete i odmah dijeliti.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dio odgovora na kompromitaciju nije uspio ({failed} korak(a)). Provjerite zapisnik servera, a zatim ponovo opozovite paket da ga završite.", + "This also revoked suite {suite} and ended key migration {migration}.": "Time je opozvan i paket {suite} i završena migracija ključeva {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Revoking the second suite deleted %n emergency-access contacts.": "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "A suite revoked as compromised cannot be reinstated.": "Paket opozvan kao kompromitovan ne može se vratiti.", + "Archives to keep": "Arhive za čuvanje", + "Back up every vault automatically": "Automatski napravi kopiju svakog trezora", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Pravite kopiju svakog trezora prema rasporedu. Arhive sadrže samo šifrovani tekst i vraćaju se s occ.", + "Back up now": "Napravi kopiju sada", + "Backup public key (PEM, optional)": "Javni ključ kopije (PEM, neobavezno)", + "Backup requested for the next cron run": "Kopija zatražena za sljedeće pokretanje crona", + "Encrypted": "Šifrovano", + "Every (hours)": "Svakih (sati)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} nije uspjela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspjela.", + "No archives yet.": "Još nema arhiva.", + "Size": "Veličina", + "Vault backups": "Kopije trezora", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se svaka arhiva šifruje za njega. Privatni ključ čuvajte izvan ovog servera: trebate ga za provjeru ili vraćanje.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervni kodovi se ne računaju. Ako se vaši korisnici prijavljuju preko pružatelja identiteta s vlastitim drugim faktorom, izostavite njihove grupe.", + "Block personal vault export": "Blokiraj izvoz ličnog trezora", + "Keep work logins in team folders": "Čuvaj poslovne prijave u timskim folderima", + "Move to a team folder": "Premjesti u timski folder", + "Not in a team folder": "Nije u timskom folderu", + "Only for these groups (empty is everyone)": "Samo za ove grupe (prazno znači svi)", + "Require two-factor login before the vault opens": "Zahtijevaj prijavu u dva koraka prije otvaranja trezora", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za svaki trezor. Svako vrijedi za sve ili samo za grupe koje odaberete.", + "Secret types that belong in a team folder": "Vrste tajni koje pripadaju u timski folder", + "Set up two-factor login": "Postavi prijavu u dva koraka", + "Team folder you can write to": "Timski folder u koji možete pisati", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Korisnici ne mogu preuzeti sigurnosnu kopiju, CSV ni datoteku za prijenos. Njihov paket ličnih podataka ostaje dostupan.", + "Users cannot save these secret types in a personal folder.": "Korisnici ne mogu spremiti ove vrste tajni u lični folder.", + "Vault policies": "Pravila trezora", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dozvoljava izvoz vašeg ličnog trezora. Vaš paket ličnih podataka u postavkama ostaje dostupan.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija čuva ove tajne u timskom folderu. Premjestite svaku u timski folder.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija čuva ovu vrstu tajne u timskom folderu. Odaberite jedan od svojih timskih foldera ili onaj u koji možete pisati.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahtijeva prijavu u dva koraka prije nego što možete otvoriti svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Korisnici biraju koliko dugo proširenje ostaje otključano tokom neaktivnosti. Vi postavljate najduže vrijeme koje smiju odabrati.", + "Longest idle time before the extension locks": "Najduže vrijeme neaktivnosti prije zaključavanja proširenja", + "1 minute": "1 minuta", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 sat", + "4 hours": "4 sata", + "Connector": "Konektor", + "Directory (tenant) ID": "ID direktorija (zakupca)", + "Application (client) ID": "ID aplikacije (klijenta)", + "Data collection rule immutable ID": "Nepromjenjivi ID pravila prikupljanja podataka", + "Stream name": "Naziv toka", + "Splunk index (optional)": "Splunk indeks (neobavezno)", + "Sourcetype (optional)": "Sourcetype (neobavezno)", + "Leave blank to keep the current one": "Ostavite prazno da zadržite trenutnu vrijednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF preko sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Krajnja tačka prikupljanja podataka (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajna klijenta (samo pisanje)", + "HEC token (write-only)": "HEC token (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Prosljeđujte dozvoljene revizijske događaje u Splunk, Microsoft Sentinel, syslog prijemnik ili webhook. Poruke sadrže samo očišćene metapodatke: nijedna tajna vrijednost, ime, prijava ili šifrirani tekst nikada ne napušta server.", + "%n change waiting to sync": "%n promjena čeka sinhronizaciju", + "%n changes waiting to sync": "%n promjena čeka sinhronizaciju", + "Changes that could not sync": "Promjene koje se nisu mogle sinhronizovati", + "Choose a version": "Odaberite verziju", + "Copy value": "Kopiraj vrijednost", + "Deleted": "Izbrisano", + "Discard": "Odbaci", + "Keep my offline change": "Zadrži moju vanmrežnu promjenu", + "Keep the server version": "Zadrži verziju sa servera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je van mreže samo za čitanje. Administrator nije uključio uređivanje van mreže.", + "Let users edit secrets offline": "Dozvoli korisnicima uređivanje tajni van mreže", + "Not synced yet": "Još nije sinhronizovano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Vanmrežne promjene ostaju na uređaju, šifrovane za korisnika, i sinhronizuju se pri sljedećem otključavanju na mreži. Dijeljenje, folderi i prilozi i dalje trebaju vezu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Van mreže. Uređivanja, premještanja i brisanja ostaju na ovom uređaju i sinhronizuju se kad ponovo budete na mreži. Dijeljenje i prilozi trebaju vezu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Van mreže. Vaše promjene ostaju na ovom uređaju i sinhronizuju se kad ponovo budete na mreži. Zadnja sinhronizacija {when}.", + "Open my changes": "Otvori moje promjene", + "Sharing needs a connection": "Dijeljenje treba vezu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Neko je promijenio ovu tajnu na serveru nakon što je napravljena vaša vanmrežna kopija. Odaberite koju verziju zadržati.", + "Sync or discard your offline changes before you rotate your keys.": "Sinhronizujte ili odbacite vanmrežne promjene prije zamjene ključeva.", + "That password did not open your changes.": "Ta lozinka nije otvorila vaše promjene.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Vanmrežni snimak čuva šifrovane tajne (otvaraju se samo ključem izvedenim iz glavne lozinke korisnika, tačno kao na serveru) i šifruje nazive, URL-ove i nazive foldera u pohrani. Vanmrežni pristup je samo za čitanje, osim ako ispod dozvolite uređivanje van mreže. Isključite ovo za uređaje koji nikad ne smiju keširati akreditive; isključivanje briše postojeće keševe pri sljedećem učitavanju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prethodne kopije trezora više nema, pa se ove promjene ne mogu otvoriti.", + "The server version": "Verzija sa servera", + "This secret changed while you were offline": "Ova tajna se promijenila dok ste bili van mreže", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ovu ste tajnu izbrisali van mreže, ali je u međuvremenu promijenjena na serveru. Odaberite koju verziju zadržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključevi su promijenjeni na drugom uređaju. Unesite prethodnu glavnu lozinku za sinhronizaciju vanmrežnih promjena ili ih odbacite.", + "Your offline change": "Vaša vanmrežna promjena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate.", + "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite ko je tražio prije nego što ikoga ponovo dodate." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n stavku nije moguće prikazati u CXF-u i bit će preskočena.", + "%n stavki nije moguće prikazati u CXF-u i bit će preskočene.", + "%n stavki nije moguće prikazati u CXF-u i bit će preskočene." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n starija verzija je uklonjena jer se može prenijeti samo nedavna historija.", + "%n starijih verzija je uklonjeno jer se može prenijeti samo nedavna historija.", + "%n starijih verzija je uklonjeno jer se može prenijeti samo nedavna historija." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopiju tajne još treba šifrirati i podijeliti.", + "%n kopija tajni još treba šifrirati i podijeliti.", + "%n kopija tajni još treba šifrirati i podijeliti." + ], + "_%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 tajna nije mogla biti dešifrovana i nije u ovom izvozu.", + "%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu.", + "%n tajni nije moglo biti dešifrovano i nisu u ovom izvozu." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n tajna se nije mogla dešifrovati vašim starim ključem, pa nije prenesena.", + "%n tajni se nije moglo dešifrovati vašim starim ključem, pa nisu prenesene.", + "%n tajni se nije moglo dešifrovati vašim starim ključem, pa nisu prenesene." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n tajna nije prenesena.", + "%n tajni nije preneseno.", + "%n tajni nije preneseno." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n tajna je još uvijek šifrovana vašim prethodnim ključem.", + "%n tajni je još uvijek šifrovano vašim prethodnim ključem.", + "%n tajni je još uvijek šifrovano vašim prethodnim ključem." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n tajna je preskočena jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.", + "%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.", + "%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno." + ], + "_%n secret_::_%n secrets_": [ + "%n tajna", + "%n tajne", + "%n tajne" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Ipak završi, uz gubitak pristupa %n tajni", + "Ipak završi, uz gubitak pristupa %n tajni", + "Ipak završi, uz gubitak pristupa %n tajni" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n novi član dobio je pristup timskom folderu.", + "Novi članovi (%n) dobili su pristup timskom folderu.", + "Novi članovi (%n) dobili su pristup timskom folderu." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotacija ključa je završena. %n tajna je ponovno šifrovana vašim novim ključem.", + "Rotacija ključa je završena. %n tajni je ponovno šifrovano vašim novim ključem.", + "Rotacija ključa je završena. %n tajni je ponovno šifrovano vašim novim ključem." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "zabilježeno %n put u procurjelim podacima", + "zabilježeno %n puta u procurjelim podacima", + "zabilježeno %n puta u procurjelim podacima" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "podijeljeno s %n tajnom", + "podijeljeno s %n tajni", + "podijeljeno s %n tajni" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ova mapa izravno sadrži %n tajnu.", + "Ova mapa izravno sadrži %n tajne.", + "Ova mapa izravno sadrži %n tajne." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n promjena čeka sinhronizaciju", + "%n promjena čeka sinhronizaciju", + "%n promjena čeka sinhronizaciju" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Korisnik je još u grupi {groups}, koja je članica timskog foldera. Uklonite ga iz grupe ili onemogućite račun.", + "Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun.", + "Korisnik je još u grupama {groups}, koje su članice timskih foldera. Uklonite ga iz grupa ili onemogućite račun." + ], + "Allow approval from another device": "Dozvoli odobrenje s drugog uređaja", + "App": "Aplikacija", + "Approve a new device": "Odobri novi uređaj", + "Approve from another device": "Odobri s drugog uređaja", + "Asked at": "Zatraženo u", + "Check that the new device shows these words:": "Provjerite da li novi uređaj prikazuje ove riječi:", + "Denied. If you did not ask, end your other sessions:": "Odbijeno. Ako to niste zatražili, završite ostale sesije:", + "Device": "Uređaj", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Omogući korisnicima da otključaju novi preglednik odobravanjem s uređaja na kojem je Keepiq već otključan.", + "New device approval": "Odobravanje novih uređaja", + "Nextcloud security settings": "Sigurnosne postavke Nextclouda", + "Only approve a device you are using right now.": "Odobrite samo uređaj koji upravo koristite.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorite Keepiq na uređaju na kojem je otključan i odobrite ovaj uređaj. Provjerite da li prikazuje iste riječi:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Uređaj koji odobrava zapečati ključ za otključavanje za novi uređaj. Server ga samo prosljeđuje i ne može ga otvoriti.", + "The master password is not right, or the request has ended.": "Glavna lozinka nije tačna ili je zahtjev završen.", + "The request expired. Ask again or use your master password.": "Zahtjev je istekao. Zatražite ponovo ili koristite glavnu lozinku.", + "The request was denied.": "Zahtjev je odbijen.", + "Too many requests. Try again in an hour or use your master password.": "Previše zahtjeva. Pokušajte ponovo za sat vremena ili koristite glavnu lozinku.", + "Unknown device": "Nepoznat uređaj", + "Web app": "Web aplikacija", + "A device": "Uređaj", + "A new device asks to open your vault": "Novi uređaj traži da otvori vaš trezor", + "%s asks to be approved. Only approve a device you are using right now.": "%s traži odobrenje. Odobrite samo uređaj koji upravo koristite.", + "Access ends on (optional)": "Pristup ističe (opcionalno)", + "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.": "Aplikacije Keepiq neće prikazati niti kopirati lozinku. Neko s tehničkim znanjem je i dalje može pročitati na svom uređaju. Promijenite je kada pristup istekne.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ova tajna je samo za korištenje. Prijavite se putem proširenja preglednika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo korištenje", + "Use only (can sign in, cannot view or copy)": "Samo korištenje (može se prijaviti, ne može vidjeti niti kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ovom prijavom se možete prijaviti putem proširenja preglednika Keepiq. Vlasnik je odlučio da je ne možete vidjeti niti kopirati.", + "Your access ends on {date}": "Vaš pristup ističe {date}", + "Your access to this secret has ended": "Vaš pristup ovoj tajni je istekao", + "Your access to \"%s\" ends tomorrow": "Vaš pristup stavci \"%s\" ističe sutra", + "Your access to \"%s\" has ended": "Vaš pristup stavci \"%s\" je istekao", + "%1$s no longer has access to \"%2$s\"": "%1$s više nema pristup stavci \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je mogao/la vidjeti ovu lozinku. Promijenite je ako %1$s više ne treba da je zna.", + "%s could not view this password in Keepiq.": "%s nije mogao/la vidjeti ovu lozinku u Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobrenja", + "a recovery officer": "službenik za oporavak", + "Account recovery": "Oporavak računa", + "Approvals needed": "Potrebna odobrenja", + "Ask {user} which words they see, by phone or in person. They must be:": "Pitajte {user} koje riječi vidi, telefonom ili lično. Moraju biti:", + "Check again": "Provjeri ponovo", + "Create the recovery key": "Kreiraj ključ za oporavak", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Kreirajte ključ za oporavak. Vaš preglednik ga pravi i svakom službeniku daje kopiju koju samo on može otvoriti.", + "Decline": "Odbij", + "Enrol in account recovery": "Prijavite se za oporavak računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se kako bi vam organizacija mogla pomoći da vratite trezor ako zaboravite glavnu lozinku.", + "Every user is enrolled": "Svi korisnici su prijavljeni", + "Finish the recovery in the browser you asked from.": "Završite oporavak u pregledniku iz kojeg ste tražili.", + "Forgot your master password?": "Zaboravili ste glavnu lozinku?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Omogućite korisnicima koji su zaboravili glavnu lozinku da vrate trezor, uz odobrenje službenika za oporavak koje imenujete.", + "New master password": "Nova glavna lozinka", + "No one is asking to recover their account.": "Niko ne traži oporavak računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Još nema ključa za oporavak. Jedan od službenika ga kreira u svojim Keepiq postavkama.", + "Off": "Isključeno", + "Officer {user} has no encryption set up yet.": "Službenik {user} još nema postavljeno šifriranje.", + "Officers (user IDs, separated by commas)": "Službenici (korisnički ID-ovi, odvojeni zarezima)", + "Policy": "Pravila", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ovaj otisak interno, kako bi ga korisnici mogli provjeriti prije prijave.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Oporavljeno uz pomoć {officer}. Sada promijenite ključ trezora u Postavkama, Sigurnost: \"Moja glavna lozinka je kompromitovana\".", + "Recovery key fingerprint: {fingerprint}": "Otisak ključa za oporavak: {fingerprint}", + "Recovery officer": "Službenik za oporavak", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Uklonjeni službenici sada gube svoju kopiju, ali su je možda ranije otvorili. Neka službenik kreira novi ključ za oporavak.", + "Repeat the new master password": "Ponovite novu glavnu lozinku", + "Retire this recovery key": "Povuci ovaj ključ za oporavak", + "Set the new master password": "Postavi novu glavnu lozinku", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikat za oporavak nije izdao ovaj Keepiq. Nemojte se prijaviti i obavijestite administratora.", + "The words match, approve": "Riječi se podudaraju, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ovaj korisnik je prijavljen za oporavak računa. Oporavak čuva njegove tajne; opoziv briše njegovu prijavu.", + "Users may enrol": "Korisnici se mogu prijaviti", + "Withdraw from account recovery": "Odjavi se iz oporavka računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste za oporavak računa. Otisak ključa za oporavak: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Ako zaboravite glavnu lozinku, vaša organizacija vam može pomoći da vratite trezor.", + "Your key is back. Choose a new master password.": "Ključ je vraćen. Odaberite novu glavnu lozinku.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši službenici za oporavak su obaviješteni. Pročitajte im ove riječi kada vas pozovu ili se sretnete:", + "You are now an account recovery officer": "Sada ste službenik za oporavak računa", + "%s asks to recover their account. Compare the words with them before you approve.": "%s traži oporavak računa. Uporedite riječi s njim prije nego što odobrite.", + "A user": "Korisnik", + "Your account recovery request was declined": "Vaš zahtjev za oporavak računa je odbijen", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Oporavak računa je spreman. Otvorite Keepiq u pregledniku iz kojeg ste tražili.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} traži da se novi uređaj jednom otključa. Glavna lozinka ostaje ista.", + "Ask your organisation instead": "Umjesto toga pitajte svoju organizaciju", + "The request ended. Ask again or use your master password.": "Zahtjev je završen. Pitajte ponovo ili koristite glavnu lozinku.", + "Added by {user}": "Dodao/la {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Uloga korisnika {member}", + "Team folders you manage": "Timske mape kojima upravljate", + "Viewer": "Preglednik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemate kopiju ovih tajni, pa ih novi članovi još nisu dobili. Vlasnik ih može podijeliti: {names}", + "Admin areas": "Područja administracije", + "Give a group only the parts of Keepiq administration it needs.": "Dajte grupi samo one dijelove administracije Keepiqa koji su joj potrebni.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegirajte jedno ili više područja grupi na stranici administratorskih ovlaštenja. Administratori instance imaju svako područje.", + "Open administration privileges": "Otvori administratorska ovlaštenja", + "Policies": "Pravila", + "Applications and machine access": "Aplikacije i pristup mašina", + "People and offboarding": "Ljudi i odlasci", + "Audit and compliance": "Revizija i usklađenost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzija, certifikacijsko tijelo, prilozi, offline keš, provjera curenja, tipovi tajni i rezervne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavna lozinka, lozinka organizacije, pravila trezora, rotacija, historija verzija i smeće", + "application queue, application requests and machine leases": "red aplikacija, zahtjevi aplikacija i zakupi mašina", + "team offboarding, encryption suites and admin handover": "odlasci iz tima, paketi šifriranja i preuzimanje od strane administratora", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski dnevnik, izvještaji o usklađenosti, SIEM izvoz i upozorenja mamaca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko verzija tajne se čuva, koliko dugo, i koliko dugo obrisane tajne ostaju u smeću.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ograničenja za šifrirane priloge, koja server primjenjuje na sačuvane šifrirane bajtove.", + "Type the suite ID again to confirm": "Ponovo unesite ID paketa za potvrdu", + "This does not match the suite ID.": "Ovo se ne podudara s ID-om paketa.", + "Confirm with your master password": "Potvrdite glavnom lozinkom", + "Confirm": "Potvrdi", + "That master password is not right.": "Ta glavna lozinka nije ispravna.", + "You are sharing with someone new. Enter your master password to confirm.": "Dijelite s novom osobom. Unesite glavnu lozinku za potvrdu.", + "Enter your master password to confirm this share.": "Unesite glavnu lozinku da potvrdite ovo dijeljenje.", + "Enter your master password to confirm this delegation.": "Unesite glavnu lozinku da potvrdite ovo delegiranje.", + "Approve {member}": "Odobri {member}", + "Recipient": "Primalac", + "No vault yet": "Još nema trezora", + "No matching users": "Nema odgovarajućih korisnika", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Razmjenjujte tajne s drugim Keepiqom. Oba administratora dodaju jedan drugog i prije spremanja uporede korijenske otiske telefonom ili lično.", + "Federation needs Nextcloud 33 or later.": "Federacija zahtijeva Nextcloud 33 ili noviji.", + "Your root fingerprint": "Vaš korijenski otisak", + "No partners yet.": "Još nema partnera.", + "Users here may share to this partner": "Korisnici ovdje smiju dijeliti s ovim partnerom", + "This partner may share to users here": "Ovaj partner smije dijeliti s korisnicima ovdje", + "Partner address": "Adresa partnera", + "Check partner": "Provjeri partnera", + "Partner root fingerprint": "Korijenski otisak partnera", + "I compared this fingerprint with the partner's administrator": "Uporedio sam ovaj otisak s administratorom partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Tajna iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s dijeli s vama \"%2$s\". Prihvatite je u odjeljku Dolazno iz drugih organizacija.", + "Incoming from other organisations": "Dolazno iz drugih organizacija", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osobe u partnerskim organizacijama mogu s vama dijeliti tajnu. Prihvatite je da biste zadržali kopiju samo za čitanje u svom trezoru.", + "Nothing shared with you yet": "Još ništa nije dijeljeno s vama", + "Secrets that people in partner organisations share with you appear here.": "Ovdje se prikazuju tajne koje s vama dijele osobe u partnerskim organizacijama.", + "From {sender}": "Od {sender}", + "Accept": "Prihvati", + "Open in vault": "Otvori u trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija nije predala tajnu. Pokušajte ponovo kasnije.", + "Set up your vault before you accept a shared secret.": "Postavite svoj trezor prije nego što prihvatite dijeljenu tajnu.", + "Something went wrong. Try again.": "Nešto nije u redu. Pokušajte ponovo.", + "Waiting for your answer": "Čeka vaš odgovor", + "In your vault, read-only": "U vašem trezoru, samo za čitanje", + "Withdrawn by the sender": "Pošiljalac je povukao", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} dijeli ovo iz druge organizacije. Možete to čitati, ali ne i mijenjati ili dijeliti.", + "Someone": "Neko", + "Share with someone at another organisation": "Dijeli s nekim iz druge organizacije", + "Their account at the other organisation": "Račun te osobe u drugoj organizaciji", + "Check account": "Provjeri račun", + "Certificate fingerprint of {account}": "Otisak certifikata za {account}", + "Compare it with them by phone if you want to be sure.": "Uporedite ga s tom osobom telefonom ako želite biti sigurni.", + "Shared. {account} can accept it in their own vault.": "Podijeljeno. {account} to može prihvatiti u svom trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikat nije moguće provjeriti. Ništa nije podijeljeno.", + "That organisation is not one of your partners.": "Ta organizacija nije jedan od vaših partnera.", + "No one with that account can receive secrets from you.": "Niko s tim računom ne može primati tajne od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija nije odgovorila. Pokušajte ponovo kasnije.", + "This secret is already shared with that account.": "Ova tajna je već podijeljena s tim računom.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Primanje tajni iz drugih organizacija", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osobe u partnerskim organizacijama tada mogu pronaći vaš račun i dijeliti tajne s vama. Svaku od njih prihvatate sami.", + "Shared": "Podijeljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauzirano: promijenio se njihov certifikat ili partnerstvo. Opozovite ili podijelite ponovo.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija nije dobila posljednju promjenu. Opozovite ili podijelite ponovo.", + "Being withdrawn": "Povlači se", + "Shared with another organisation": "Podijeljeno s drugom organizacijom", + "Change sent to another organisation": "Promjena poslana drugoj organizaciji", + "Share with another organisation revoked": "Dijeljenje s drugom organizacijom ukinuto", + "Share with another organisation paused": "Dijeljenje s drugom organizacijom pauzirano", + "Another organisation did not get a change": "Druga organizacija nije dobila promjenu", + "Secret received from another organisation": "Tajna primljena iz druge organizacije", + "Secret from another organisation accepted": "Tajna iz druge organizacije prihvaćena", + "Secret from another organisation declined": "Tajna iz druge organizacije odbijena", + "Copy from another organisation updated": "Kopija iz druge organizacije ažurirana", + "Copy from another organisation removed": "Kopija iz druge organizacije uklonjena", + "Declined: they removed their copy. Share again if they need it.": "Odbijeno: primalac je uklonio svoju kopiju. Podijelite ponovo ako mu treba.", + "Recipient at another organisation removed their copy": "Primalac iz druge organizacije uklonio je svoju kopiju", + "Removed the user from %n team folder.": "Korisnik je uklonjen iz %n timskog foldera.", + "Removed the user from %n team folders.": "Korisnik je uklonjen iz %n timskih foldera.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Korisnik je uklonjen iz %n timskog foldera.", + "Korisnik je uklonjen iz %n timskih foldera.", + "Korisnik je uklonjen iz %n timskih foldera." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Vraćena kopija potječe iz dijeljenja koje je završeno. Ostaje samo za čitanje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacija koja je podijelila vraćenu kopiju nije dostupna. Kopija ostaje samo za čitanje i ne prati njihove izmjene.", + "Recipient at another organisation restored their copy": "Primalac iz druge organizacije vratio je svoju kopiju" }, "plurals": null } diff --git a/l10n/ca.js b/l10n/ca.js index 729e795b4..7d5659ad5 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Compartit amb grups", + "Not shared with any group yet.": "Encara no s'ha compartit amb cap grup.", + "Revoke the share with {group}": "Revoca la compartició amb {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartit amb {group}: {received} membres l'han rebut, {skipped} no perquè encara no han configurat el xifratge.", + "Search groups": "Cerca grups", + "Failed to share": "No s'ha pogut compartir", + "Columns": "Columnes", + "Column {number}": "Columna {number}", + "Map one column to Name. Every secret needs a name.": "Assigneu una columna al nom. Cada secret necessita un nom.", + "Notes": "Notes", + "Do not import": "No importis", + "Hide this value": "Amaga aquest valor", + "Show this value": "Mostra aquest valor", + "Defaults": "Valors per defecte", + "New secrets start as this type, and your secret list opens in this view.": "Els secrets nous comencen amb aquest tipus i la teva llista de secrets s'obre en aquesta vista.", + "Default item type": "Tipus d'element per defecte", + "Cards": "Targetes", + "Table": "Taula", + "Could not save your default": "No s'ha pogut desar el valor per defecte", + "Recently used": "Utilitzats recentment", + "Opened": "Obert", + "You have not opened any secrets yet": "Encara no has obert cap secret", + "Could not delete the item type.": "No s'ha pogut suprimir el tipus d'element.", + "Could not load the item types.": "No s'han pogut carregar els tipus d'element.", + "Could not save the item type.": "No s'ha pogut desar el tipus d'element.", + "Delete item type": "Suprimeix el tipus d'element", + "Edit item type": "Edita el tipus d'element", + "Fields": "Camps", + "Fields: {count}": "Camps: {count}", + "Hidden": "Ocult", + "Item types": "Tipus d'element", + "Move up": "Mou amunt", + "New item type": "Tipus d'element nou", + "No item types defined yet.": "Encara no hi ha cap tipus d'element definit.", + "Required": "Obligatori", + "Text": "Text", + "This field is required": "Aquest camp és obligatori", + "Web address": "Adreça web", + "{label} (required)": "{label} (obligatori)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Voleu suprimir «{name}»? Els secrets d'aquest tipus continuen llegibles i passen a ser elements d'Inici de sessió.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Els tipus d'element que definiu aquí apareixen per a tothom al diàleg Secret nou, amb els camps que trieu.", + "Secret moved to the trash": "Secret mogut a la paperera", + "Secret restored from the trash": "Secret restaurat de la paperera", + "Secret deleted for good": "Secret suprimit definitivament", + "Secret archived": "Secret arxivat", + "Secret unarchived": "Secret desarxivat", + "Unarchive": "Desarxiva", + "Could not archive the secret": "No s'ha pogut arxivar el secret", + "Could not unarchive the secret": "No s'ha pogut desarxivar el secret", + "Archive {count} secrets": "Arxiva {count} secrets", + "Unarchive {count} secrets": "Desarxiva {count} secrets", + "Restore {count} secrets": "Restaura {count} secrets", + "Delete {count} secrets for good": "Suprimeix definitivament {count} secrets", + "Done for {ok} of {total} secrets": "Fet per a {ok} de {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Els secrets arxivats surten de la llista de la caixa forta, de la cerca, de l'emplenament automàtic i de l'informe de salut. Mantenen les comparticions. Els trobareu a Arxiu.", + "These secrets come back to the vault list, search and autofill.": "Aquests secrets tornen a la llista de la caixa forta, a la cerca i a l'emplenament automàtic.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Aquests secrets tornen a la llista de la caixa forta. Les comparticions antigues no tornen, així que torneu-los a compartir on calgui.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Això suprimeix els secrets amb els adjunts i l'historial de versions. No es pot desfer.", + "Delete for good": "Suprimeix definitivament", + "Trash": "Paperera", + "The trash is empty": "La paperera és buida", + "No archived secrets": "No hi ha secrets arxivats", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Els secrets suprimits esperen aquí fins que s'acaba el període de conservació; després se suprimeixen definitivament.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arxiveu un secret des del seu tauler de detalls per mantenir-lo fora de la llista de la caixa forta, de la cerca i de l'emplenament automàtic.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Límits per als adjunts xifrats (aplicats al servidor sobre els bytes xifrats desats), conservació de l'historial de versions i quant de temps es queden a la paperera els secrets suprimits.", + "Days a deleted secret stays in the trash (1 to 365)": "Dies que un secret suprimit es queda a la paperera (d'1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Això mou el secret a la paperera i acaba ara les seves comparticions. El podeu restaurar de la paperera fins que s'acabi el període de conservació: 30 dies, llevat que l'administrador l'hagi canviat.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Això mou {count} secrets a la paperera i acaba ara les seves comparticions. Els podeu restaurar de la paperera fins que s'acabi el període de conservació.", + "Remove {name} from favourites": "Treu {name} dels preferits", + "Add {name} to favourites": "Afegeix {name} als preferits", + "Could not change the favourite": "No s'ha pogut canviar el preferit", + "Remove from favourites": "Treu dels preferits", + "Add to favourites": "Afegeix als preferits", + "Tags": "Etiquetes", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Les etiquetes no estan xifrades. Els administradors del servidor les poden llegir, com els noms de carpeta.", + "Favourites": "Preferits", + "Filter by tag": "Filtra per etiqueta", + "All tags": "Totes les etiquetes", + "Last used": "Darrer ús", + "Tags for {count} secrets": "Etiquetes per a {count} secrets", + "Tag": "Etiqueta", + "Remove tag": "Treu l'etiqueta", + "Add tag": "Afegeix una etiqueta", + "Could not change the tags. Try again.": "No s'han pogut canviar les etiquetes. Torneu-ho a provar.", + "Could not approve the application. It is still in the queue.": "No s'ha pogut aprovar la sol·licitud. Encara és a la cua.", + "Could not reject the application. It is still in the queue.": "No s'ha pogut rebutjar la sol·licitud. Encara és a la cua.", + "Removed the user from {count} team folders.": "S'ha tret l'usuari de {count} carpetes d'equip.", + "Approve a share": "Aprova una compartició", + "This approval link is incomplete. Open it again from the notification.": "Aquest enllaç d'aprovació és incomplet. Torneu-lo a obrir des de la notificació.", + "Deny": "Denega", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} s'ha unit a un grup amb el qual compartiu un secret. Voleu compartir-li també el secret?", + "{requester} asks you to share a secret with {user}.": "{requester} us demana que compartiu un secret amb {user}.", + "Shared. The recipient can now open the secret.": "S'ha compartit. Ara el destinatari pot obrir el secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "El destinatari encara no ha configurat Keepiq, per tant no s'ha compartit res. Torneu-ho a provar quan ho hagi fet.", + "Could not share the secret. Only its owner can approve this.": "No s'ha pogut compartir el secret. Només el propietari ho pot aprovar.", + "Could not share the secret. Try again.": "No s'ha pogut compartir el secret. Torneu-ho a provar.", + "Denied. Nothing was shared.": "S'ha denegat. No s'ha compartit res.", + "Could not deny the request. Try again.": "No s'ha pogut denegar la sol·licitud. Torneu-ho a provar.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s us demana que compartiu el secret \"%2$s\" amb %3$s.", + "Expires on (optional)": "Caduca el (opcional)", + "Hand over to": "Cedeix a", + "Choose a recipient": "Trieu un destinatari", + "Hand over temporarily": "Cedeix temporalment", + "Expiry rules": "Regles de caducitat", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Establiu quant de temps poden durar les contrasenyes d'un tipus d'element o d'una carpeta, i quan cal recordar-ho. Quan s'apliquen diverses dates, compta la més primerenca.", + "Delete rule": "Suprimeix la regla", + "Set by your administrator": "Establert per l'administrador", + "No expiry rules yet.": "Encara no hi ha regles de caducitat.", + "Applies to": "S'aplica a", + "Item type": "Tipus d'element", + "Maximum age in days (empty for reminders only)": "Antiguitat màxima en dies (buit només per a recordatoris)", + "Remind me this many days before, comma separated": "Recorda-m'ho aquests dies abans, separats per comes", + "Save rule": "Desa la regla", + "An item type": "Un tipus d'element", + "A folder": "Una carpeta", + "Folder {name}": "Carpeta {name}", + "Type {name}": "Tipus {name}", + "Expires after {days} days": "Caduca al cap de {days} dies", + "Reminders {days} days before": "Recordatoris {days} dies abans", + "Could not save the expiry rule.": "No s'ha pogut desar la regla de caducitat.", + "Could not delete the expiry rule.": "No s'ha pogut suprimir la regla de caducitat.", + "All statuses": "Tots els estats", + "Compromised": "Compromesa", + "Could not load the members.": "No s'han pogut carregar els membres.", + "Emergency contact": "Contacte d'emergència", + "Leaving user": "Usuari que marxa", + "No": "No", + "No users match this filter.": "Cap usuari coincideix amb aquest filtre.", + "Not set up": "Sense configurar", + "Revoke suite": "Revoca la suite", + "Revoked": "Revocada", + "Search users": "Cerca usuaris", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Mireu quins usuaris han configurat una caixa forta. Inicieu la baixa o revoqueu una suite des d'una fila.", + "Successor": "Successor", + "Team folders": "Carpetes d'equip", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'usuari encara és al grup {groups}, que és membre d'una carpeta d'equip. Traieu-lo del grup o desactiveu el compte.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'usuari encara és als grups {groups}, que són membres de carpetes d'equip. Traieu-lo dels grups o desactiveu el compte.", + "Vault status": "Estat de la caixa forta", + "Yes": "Sí", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Una exportació CXF NO ESTÀ XIFRADA. Totes les contrasenyes i inicis de sessió es podran llegir com a text pla al fitxer baixat. Deseu-lo de manera segura i suprimiu-lo immediatament després d'usar-lo.", + "Root certificate expiring soon": "El certificat arrel caduca aviat", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "El certificat arrel de la caixa forta caduca d'aquí a %1$d dia(es). Renoveu-lo abans. En renovar-lo es torna a signar cada suite de xifratge.", + "Compromise recovery aborted": "Recuperació després de compromís cancel·lada", + "Key rotation ended by a compromise revoke": "Rotació de clau finalitzada per una revocació per compromís", + "Encryption suite revoke refused": "Revocació del conjunt de xifratge rebutjada", + "Master password proof refused": "Prova de la contrasenya mestra rebutjada", + "Your current master password": "La teva contrasenya mestra actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacte d'emergència tenia una sol·licitud d'accés pendent quan la rotació de clau el va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactes d'emergència tenien una sol·licitud d'accés pendent quan la rotació de clau els va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Aquests contactes d'emergència no s'han traspassat a la teva clau nova. S'ha eliminat el seu accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.", + "Renew root certificate": "Renova el certificat arrel", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Això crea un certificat arrel i un d'intermedi nous. Cada conjunt de xifratge actiu es torna a signar. No es pot desfer.", + "Renew root": "Renova l'arrel", + "Root renewed. {n} encryption suites signed again.": "Arrel renovada. Conjunts de xifratge signats de nou: {n}.", + "Could not renew the root certificate.": "No s'ha pogut renovar el certificat arrel.", + "Lease policy for this application": "Política de cessió per a aquesta aplicació", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigor ara: {default} segons per defecte, {max} segons com a màxim.", + "Leases are not renewable": "Les cessions no es poden renovar", + "Lease policy saved.": "S'ha desat la política de cessió.", + "Leave a field empty to use the instance value.": "Deixeu un camp buit per usar el valor de la instància.", + "Instance value: {value}": "Valor de la instància: {value}", + "Renewal": "Renovació", + "Use the instance value ({value})": "Usa el valor de la instància ({value})", + "Allowed": "Permès", + "Not allowed": "No permès", + "Save lease policy": "Desa la política de cessió", + "Only an administrator can change this policy.": "Només un administrador pot canviar aquesta política.", + "Could not save the lease policy.": "No s'ha pogut desar la política de cessió.", + "{member} got access from {confirmer}.": "{member} ha rebut accés de {confirmer}.", + "Automatically confirm new team folder members": "Confirma automàticament els membres nous de les carpetes d'equip", + "Gave %n new member access to a team folder.": "%n membre nou ha rebut accés a una carpeta d'equip.", + "Gave %n new members access to a team folder.": "%n membres nous han rebut accés a una carpeta d'equip.", + "Give new team folder members access without waiting for the folder owner.": "Doneu accés als membres nous sense esperar el propietari de la carpeta.", + "New team folder members": "Membres nous de les carpetes d'equip", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "El propietari o un membre amb permís d'escriptura els confirma des de la seva caixa forta oberta. Keepiq mai desxifra al servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "S'espera que un membre amb permís d'escriptura obri Keepiq. També podeu compartir ara.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Part de la resposta al compromís ha fallat ({failed} pas(sos)). Consulteu el registre del servidor i torneu a revocar la suite per acabar-la.", + "This also revoked suite {suite} and ended key migration {migration}.": "Això també ha revocat la suite {suite} i ha finalitzat la migració de claus {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocar la segona suite ha suprimit %n contacte d'accés d'emergència.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocar la segona suite ha suprimit %n contactes d'accés d'emergència.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocada com a compromesa no es pot restablir.", + "Archives to keep": "Arxius que cal conservar", + "Back up every vault automatically": "Fes còpia de cada caixa forta automàticament", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Feu còpia de cada caixa forta segons una planificació. Els arxius només contenen text xifrat i es restauren amb occ.", + "Back up now": "Fes còpia ara", + "Backup public key (PEM, optional)": "Clau pública de còpia (PEM, opcional)", + "Backup requested for the next cron run": "Còpia sol·licitada per a la propera execució de cron", + "Encrypted": "Xifrat", + "Every (hours)": "Cada (hores)", + "Last backup {when} failed: {error}": "La darrera còpia {when} ha fallat: {error}", + "Last backup {when} succeeded.": "La darrera còpia {when} ha funcionat.", + "No archives yet.": "Encara no hi ha arxius.", + "Size": "Mida", + "Vault backups": "Còpies de la caixa forta", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Amb una clau, cada arxiu es xifra per a ella. Guardeu la clau privada fora d'aquest servidor: la necessiteu per verificar o restaurar.", + "Written": "Escrit", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n usuari de l'abast encara no té inici de sessió en dos passos i no pot obrir la caixa forta mentre això estigui actiu.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n usuaris de l'abast encara no tenen inici de sessió en dos passos i no poden obrir la caixa forta mentre això estigui actiu.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Els codis de recuperació no compten. Si els usuaris inicien la sessió amb un proveïdor d'identitat amb el seu propi segon factor, excloeu-ne els grups.", + "Block personal vault export": "Bloca l'exportació de la caixa forta personal", + "Keep work logins in team folders": "Desa els inicis de sessió de feina en carpetes d'equip", + "Move to a team folder": "Mou a una carpeta d'equip", + "Not in a team folder": "No és en una carpeta d'equip", + "Only for these groups (empty is everyone)": "Només per a aquests grups (buit vol dir tothom)", + "Require two-factor login before the vault opens": "Exigeix l'inici de sessió en dos passos abans d'obrir la caixa forta", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Normes per a cada caixa forta. Cadascuna s'aplica a tothom o només als grups que trieu.", + "Secret types that belong in a team folder": "Tipus de secret que van en una carpeta d'equip", + "Set up two-factor login": "Configura l'inici de sessió en dos passos", + "Team folder you can write to": "Carpeta d'equip on podeu escriure", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Els usuaris no poden baixar una còpia de seguretat, un CSV ni un fitxer de transferència. El seu paquet de dades personals continua disponible.", + "Users cannot save these secret types in a personal folder.": "Els usuaris no poden desar aquests tipus de secret en una carpeta personal.", + "Vault policies": "Polítiques de la caixa forta", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "La vostra organització no permet exportar la vostra caixa forta personal. El vostre paquet de dades personals a la configuració continua disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "La vostra organització desa aquests secrets en una carpeta d'equip. Moveu-los un a un a una carpeta d'equip.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "La vostra organització desa aquest tipus de secret en una carpeta d'equip. Trieu una de les vostres carpetes d'equip o una on pugueu escriure.", + "Your organisation requires two-factor login before you can open your vault.": "La vostra organització exigeix l'inici de sessió en dos passos abans que pugueu obrir la caixa forta.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Els usuaris trien quant de temps l'extensió roman desbloquejada sense activitat. Vostè fixa el màxim que poden triar.", + "Longest idle time before the extension locks": "Temps màxim d'inactivitat abans que l'extensió es bloquegi", + "1 minute": "1 minut", + "5 minutes": "5 minuts", + "15 minutes": "15 minuts", + "1 hour": "1 hora", + "4 hours": "4 hores", + "Connector": "Connector", + "Directory (tenant) ID": "ID del directori (inquilí)", + "Application (client) ID": "ID de l'aplicació (client)", + "Data collection rule immutable ID": "ID immutable de la regla de recollida de dades", + "Stream name": "Nom del flux", + "Splunk index (optional)": "Índex de Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Deixeu-ho en blanc per mantenir l'actual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF per syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punt final de recollida de dades (URL https)", + "HTTP Event Collector URL (https)": "URL de l'HTTP Event Collector (https)", + "Client secret (write-only)": "Secret del client (només escriptura)", + "HEC token (write-only)": "Testimoni HEC (només escriptura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reenvia els esdeveniments d'auditoria permesos a Splunk, Microsoft Sentinel, un receptor syslog o un webhook. Els missatges només duen metadades netejades: cap valor secret, nom, inici de sessió o text xifrat surt mai del servidor.", + "%n change waiting to sync": "%n canvi pendent de sincronitzar", + "%n changes waiting to sync": "%n canvis pendents de sincronitzar", + "Changes that could not sync": "Canvis que no s'han pogut sincronitzar", + "Choose a version": "Tria una versió", + "Copy value": "Copia el valor", + "Deleted": "Suprimit", + "Discard": "Descarta", + "Keep my offline change": "Conserva el meu canvi fora de línia", + "Keep the server version": "Conserva la versió del servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq és només de lectura fora de línia. L'administrador no ha activat l'edició fora de línia.", + "Let users edit secrets offline": "Permet als usuaris editar secrets fora de línia", + "Not synced yet": "Encara no sincronitzat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Els canvis fora de línia es queden al dispositiu, xifrats per a l'usuari, i se sincronitzen en el següent desbloqueig en línia. Compartir, carpetes i adjunts encara necessiten connexió.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Fora de línia. Les edicions, els moviments i les supressions es queden en aquest dispositiu i se sincronitzen quan torneu a estar en línia. Compartir i adjunts necessiten connexió.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Fora de línia. Els vostres canvis es queden en aquest dispositiu i se sincronitzen quan torneu a estar en línia. Última sincronització {when}.", + "Open my changes": "Obre els meus canvis", + "Sharing needs a connection": "Compartir necessita connexió", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Algú ha canviat aquest secret al servidor després que es fes la vostra còpia fora de línia. Trieu quina versió conservar.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronitzeu o descarteu els canvis fora de línia abans de renovar les claus.", + "That password did not open your changes.": "Aquesta contrasenya no ha obert els vostres canvis.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La instantània fora de línia desa els secrets xifrats (només es poden obrir amb la clau derivada de la contrasenya mestra de l'usuari, igual que al servidor) i xifra en repòs els noms, els URL i els noms de carpetes. L'accés fora de línia és només de lectura, tret que permeteu a sota l'edició fora de línia. Desactiveu-ho en equips que no hagin de desar mai credencials; en desactivar-ho s'esborren les memòries cau existents en la càrrega següent.", + "The previous vault copy is gone, so these changes cannot be opened.": "La còpia anterior de la caixa forta ja no hi és, per tant aquests canvis no es poden obrir.", + "The server version": "La versió del servidor", + "This secret changed while you were offline": "Aquest secret ha canviat mentre éreu fora de línia", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vau suprimir aquest secret fora de línia, però des de llavors s'ha canviat al servidor. Trieu quina versió conservar.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Les vostres claus s'han canviat en un altre dispositiu. Introduïu la contrasenya mestra anterior per sincronitzar els canvis fora de línia, o descarteu-los.", + "Your offline change": "El vostre canvi fora de línia", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contacte d'emergència tenia una sol·licitud d'accés pendent quan la rotació de clau el va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.","%n contactes d'emergència tenien una sol·licitud d'accés pendent quan la rotació de clau els va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n element no es pot representar en CXF i s'ometrà.","%n elements no es poden representar en CXF i s'ometran."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["S'ha descartat %n versió anterior perquè només es pot traslladar l'historial recent.","S'han descartat %n versions anteriors perquè només es pot traslladar l'historial recent."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Encara cal xifrar i compartir %n còpia de secret.","Encara cal xifrar i compartir %n còpies de secret."], + "_%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 secret no s'ha pogut desxifrar i no és en aquesta exportació.","%n secrets no s'han pogut desxifrar i no són en aquesta exportació."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n secret no s'ha pogut desxifrar amb la vostra clau antiga, per això no s'ha migrat.","%n secrets no s'han pogut desxifrar amb la vostra clau antiga, per això no s'han migrat."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n secret no s'ha migrat.","%n secrets no s'han migrat."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n secret encara està xifrat amb la vostra clau anterior.","%n secrets encara estan xifrats amb la vostra clau anterior."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["S'ha omès %n secret perquè el successor encara no en té cap còpia: afegiu el successor a la carpeta i torneu-ho a executar.","S'han omès %n secrets perquè el successor encara no en té cap còpia: afegiu el successor a la carpeta i torneu-ho a executar."], + "_%n secret_::_%n secrets_": ["%n secret","%n secrets"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n usuari de l'abast encara no té inici de sessió en dos passos i no pot obrir la caixa forta mentre això estigui actiu.","%n usuaris de l'abast encara no tenen inici de sessió en dos passos i no poden obrir la caixa forta mentre això estigui actiu."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Finalitza igualment, perdent l'accés a %n secret","Finalitza igualment, perdent l'accés a %n secrets"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n membre nou ha rebut accés a una carpeta d'equip.","%n membres nous han rebut accés a una carpeta d'equip."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["La rotació de claus ha finalitzat. %n secret s'ha tornat a xifrar amb la vostra clau nova.","La rotació de claus ha finalitzat. %n secrets s'han tornat a xifrar amb la vostra clau nova."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revocar la segona suite ha suprimit %n contacte d'accés d'emergència.","Revocar la segona suite ha suprimit %n contactes d'accés d'emergència."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["La revocació d'aquesta suite ha eliminat %n contacte d'accés d'emergència.","La revocació d'aquesta suite ha eliminat %n contactes d'accés d'emergència."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["vist %n vegada en filtracions","vist %n vegades en filtracions"], + "_shared with %n secret_::_shared with %n secrets_": ["compartit amb %n secret","compartit amb %n secrets"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Aquesta carpeta conté %n secret directament.","Aquesta carpeta conté %n secrets directament."], + "_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._": ["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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n canvi pendent de sincronitzar","%n canvis pendents de sincronitzar"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["L'usuari encara és al grup {groups}, que és membre d'una carpeta d'equip. Traieu-lo del grup o desactiveu el compte.","L'usuari encara és als grups {groups}, que són membres de carpetes d'equip. Traieu-lo dels grups o desactiveu el compte."], + "Allow approval from another device": "Permet l'aprovació des d'un altre dispositiu", + "App": "Aplicació", + "Approve a new device": "Aprova un dispositiu nou", + "Approve from another device": "Aprova des d'un altre dispositiu", + "Asked at": "Sol·licitat a les", + "Check that the new device shows these words:": "Comproveu que el dispositiu nou mostra aquestes paraules:", + "Denied. If you did not ask, end your other sessions:": "Denegat. Si no ho heu sol·licitat, tanqueu les altres sessions:", + "Device": "Dispositiu", + "IP address": "Adreça IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permet als usuaris desbloquejar un navegador nou aprovant-lo des d'un dispositiu on Keepiq ja està desbloquejat.", + "New device approval": "Aprovació de dispositius nous", + "Nextcloud security settings": "Paràmetres de seguretat de Nextcloud", + "Only approve a device you are using right now.": "Aproveu només un dispositiu que estigueu fent servir ara mateix.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Obriu Keepiq en un dispositiu on estigui desbloquejat i aproveu aquest. Comproveu que mostra les mateixes paraules:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "El dispositiu que aprova segella la clau de desbloqueig per al dispositiu nou. El servidor només la transmet i no la pot obrir.", + "The master password is not right, or the request has ended.": "La contrasenya mestra no és correcta o la sol·licitud ha acabat.", + "The request expired. Ask again or use your master password.": "La sol·licitud ha caducat. Torneu-ho a demanar o feu servir la contrasenya mestra.", + "The request was denied.": "La sol·licitud s'ha denegat.", + "Too many requests. Try again in an hour or use your master password.": "Massa sol·licituds. Torneu-ho a provar d'aquí a una hora o feu servir la contrasenya mestra.", + "Unknown device": "Dispositiu desconegut", + "Web app": "Aplicació web", + "A device": "Un dispositiu", + "A new device asks to open your vault": "Un dispositiu nou demana obrir la vostra caixa forta", + "%s asks to be approved. Only approve a device you are using right now.": "%s demana ser aprovat. Aproveu només un dispositiu que estigueu fent servir ara mateix.", + "Access ends on (optional)": "L'accés acaba el (opcional)", + "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.": "Les aplicacions del Keepiq no mostraran ni copiaran la contrasenya. Algú amb coneixements tècnics encara la pot llegir des del seu dispositiu. Canvieu-la quan s'acabi el seu accés.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Aquest secret és només d'ús. Inicieu la sessió mitjançant l'extensió del navegador del Keepiq.", + "Until {date}": "Fins al {date}", + "Use only": "Només ús", + "Use only (can sign in, cannot view or copy)": "Només ús (pot iniciar la sessió, no pot veure ni copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Podeu iniciar la sessió amb aquestes credencials mitjançant l'extensió del navegador del Keepiq. El propietari ha decidit no permetre-us veure-les ni copiar-les.", + "Your access ends on {date}": "El vostre accés acaba el {date}", + "Your access to this secret has ended": "El vostre accés a aquest secret ha acabat", + "Your access to \"%s\" ends tomorrow": "El vostre accés a «%s» acaba demà", + "Your access to \"%s\" has ended": "El vostre accés a «%s» ha acabat", + "%1$s no longer has access to \"%2$s\"": "%1$s ja no té accés a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podia veure aquesta contrasenya. Canvieu-la si %1$s ja no l'ha de conèixer.", + "%s could not view this password in Keepiq.": "%s no ha pogut veure aquesta contrasenya al Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprovacions", + "a recovery officer": "un responsable de recuperació", + "Account recovery": "Recuperació del compte", + "Approvals needed": "Aprovacions necessàries", + "Ask {user} which words they see, by phone or in person. They must be:": "Pregunteu a {user} quines paraules veu, per telèfon o en persona. Han de ser:", + "Check again": "Torna a comprovar", + "Create the recovery key": "Crea la clau de recuperació", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creeu la clau de recuperació. El navegador la genera i dona a cada responsable una còpia que només ell pot obrir.", + "Decline": "Rebutja", + "Enrol in account recovery": "Inscriviu-vos a la recuperació del compte", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscriviu-vos perquè la vostra organització us pugui ajudar a recuperar la caixa forta si oblideu la contrasenya mestra.", + "Every user is enrolled": "Tots els usuaris estan inscrits", + "Finish the recovery in the browser you asked from.": "Acabeu la recuperació al navegador des d'on la vau demanar.", + "Forgot your master password?": "Heu oblidat la contrasenya mestra?", + "Hand the key over": "Lliura la clau", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permet que els usuaris que han oblidat la contrasenya mestra recuperin la caixa forta, amb l'aprovació dels responsables de recuperació que designeu.", + "New master password": "Contrasenya mestra nova", + "No one is asking to recover their account.": "Ningú no demana recuperar el seu compte.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Encara no hi ha clau de recuperació. Un dels responsables la crea a la seva configuració de Keepiq.", + "Off": "Desactivat", + "Officer {user} has no encryption set up yet.": "El responsable {user} encara no ha configurat el xifratge.", + "Officers (user IDs, separated by commas)": "Responsables (ID d'usuari, separats per comes)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiqueu aquesta empremta internament, perquè els usuaris la puguin comprovar abans d'inscriure's.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperat amb l'ajuda de {officer}. Canvieu ara la clau de la caixa forta a Configuració, Seguretat: \"La meva contrasenya mestra s'ha vist compromesa\".", + "Recovery key fingerprint: {fingerprint}": "Empremta de la clau de recuperació: {fingerprint}", + "Recovery officer": "Responsable de recuperació", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Els responsables eliminats perden ara la seva còpia, però potser ja l'havien oberta. Feu que un responsable creï una clau de recuperació nova.", + "Repeat the new master password": "Repetiu la contrasenya mestra nova", + "Retire this recovery key": "Retira aquesta clau de recuperació", + "Set the new master password": "Estableix la contrasenya mestra nova", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "El certificat de recuperació no l'ha emès aquest Keepiq. No us inscriviu i aviseu l'administrador.", + "The words match, approve": "Les paraules coincideixen, aprova", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Aquest usuari està inscrit a la recuperació del compte. Recuperar conserva els seus secrets; revocar n'elimina la inscripció.", + "Users may enrol": "Els usuaris es poden inscriure", + "Withdraw from account recovery": "Retira't de la recuperació del compte", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Esteu inscrit a la recuperació del compte. Empremta de la clau de recuperació: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Esteu inscrit. Si oblideu la contrasenya mestra, la vostra organització us pot ajudar a recuperar la caixa forta.", + "Your key is back. Choose a new master password.": "Ja teniu la clau. Trieu una contrasenya mestra nova.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "S'ha avisat els vostres responsables de recuperació. Llegiu-los aquestes paraules quan us truquin o us trobeu:", + "You are now an account recovery officer": "Ara sou responsable de recuperació de comptes", + "%s asks to recover their account. Compare the words with them before you approve.": "%s demana recuperar el seu compte. Compareu les paraules amb aquesta persona abans d'aprovar.", + "A user": "Un usuari", + "Your account recovery request was declined": "S'ha rebutjat la vostra sol·licitud de recuperació del compte", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La recuperació del compte està llesta. Obriu Keepiq al navegador des d'on la vau demanar.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} demana desbloquejar un dispositiu nou una sola vegada. Conserva la seva contrasenya mestra.", + "Ask your organisation instead": "Demana-ho a la teva organització", + "The request ended. Ask again or use your master password.": "La sol·licitud ha finalitzat. Torna-ho a demanar o fes servir la contrasenya mestra.", + "Added by {user}": "Afegit per {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Rol de {member}", + "Team folders you manage": "Carpetes d'equip que gestioneu", + "Viewer": "Lector", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "No teniu cap còpia d'aquests secrets, així que els nous membres encara no els han rebut. El propietari els pot compartir: {names}", + "Admin areas": "Àrees d'administració", + "Give a group only the parts of Keepiq administration it needs.": "Doneu a un grup només les parts de l'administració de Keepiq que necessita.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegueu una o més àrees a un grup a la pàgina de privilegis d'administració. Els administradors de la instància tenen totes les àrees.", + "Open administration privileges": "Obre els privilegis d'administració", + "Policies": "Polítiques", + "Applications and machine access": "Aplicacions i accés de màquines", + "People and offboarding": "Persones i baixes", + "Audit and compliance": "Auditoria i compliment", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versió, autoritat de certificació, adjunts, memòria cau fora de línia, comprovació de filtracions, tipus de secrets i còpies de seguretat", + "master password, organisation password, vault policies, rotation, version history and trash": "contrasenya mestra, contrasenya de l'organització, polítiques de la caixa forta, rotació, historial de versions i paperera", + "application queue, application requests and machine leases": "cua d'aplicacions, sol·licituds d'aplicacions i concessions de màquines", + "team offboarding, encryption suites and admin handover": "baixes de l'equip, conjunts de xifratge i traspàs per l'administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registre d'auditoria, informes de compliment, exportació SIEM i alertes d'esquer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantes versions d'un secret es conserven, durant quant de temps, i quant de temps resten a la paperera els secrets suprimits.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Límits per als adjunts xifrats, aplicats al servidor en bytes xifrats emmagatzemats.", + "Type the suite ID again to confirm": "Torneu a escriure l'ID del conjunt per confirmar", + "This does not match the suite ID.": "No coincideix amb l'ID del conjunt.", + "Confirm with your master password": "Confirmeu amb la contrasenya mestra", + "Confirm": "Confirma", + "That master password is not right.": "Aquesta contrasenya mestra no és correcta.", + "You are sharing with someone new. Enter your master password to confirm.": "Esteu compartint amb algú nou. Introduïu la contrasenya mestra per confirmar.", + "Enter your master password to confirm this share.": "Introduïu la contrasenya mestra per confirmar aquesta compartició.", + "Enter your master password to confirm this delegation.": "Introduïu la contrasenya mestra per confirmar aquesta delegació.", + "Approve {member}": "Aprova {member}", + "Recipient": "Destinatari", + "No vault yet": "Encara no té cap caixa forta", + "No matching users": "No hi ha cap usuari coincident", + "Partner organisations": "Organitzacions sòcies", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Intercanvia secrets amb un altre Keepiq. Tots dos administradors s’afegeixen mútuament i comparen les empremtes arrel per telèfon o en persona abans de desar.", + "Federation needs Nextcloud 33 or later.": "La federació requereix Nextcloud 33 o posterior.", + "Your root fingerprint": "La teva empremta arrel", + "No partners yet.": "Encara no hi ha cap soci.", + "Users here may share to this partner": "Els usuaris d’aquí poden compartir amb aquest soci", + "This partner may share to users here": "Aquest soci pot compartir amb els usuaris d’aquí", + "Partner address": "Adreça del soci", + "Check partner": "Comprova el soci", + "Partner root fingerprint": "Empremta arrel del soci", + "I compared this fingerprint with the partner's administrator": "He comparat aquesta empremta amb l’administrador del soci", + "Add partner": "Afegeix el soci", + "A secret from another organisation": "Un secret d’una altra organització", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha compartit \"%2$s\" amb vós. Accepteu-lo a Rebuts d’altres organitzacions.", + "Incoming from other organisations": "Rebuts d’altres organitzacions", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Les persones de les organitzacions sòcies poden compartir un secret amb vós. Accepteu-lo per conservar una còpia de només lectura a la vostra caixa forta.", + "Nothing shared with you yet": "Encara no s’ha compartit res amb vós", + "Secrets that people in partner organisations share with you appear here.": "Els secrets que les persones de les organitzacions sòcies comparteixen amb vós apareixen aquí.", + "From {sender}": "De {sender}", + "Accept": "Accepta", + "Open in vault": "Obre a la caixa forta", + "The other organisation did not hand over the secret. Try again later.": "L’altra organització no ha lliurat el secret. Torneu-ho a provar més tard.", + "Set up your vault before you accept a shared secret.": "Configureu la vostra caixa forta abans d’acceptar un secret compartit.", + "Something went wrong. Try again.": "Alguna cosa ha anat malament. Torneu-ho a provar.", + "Waiting for your answer": "Esperant la vostra resposta", + "In your vault, read-only": "A la vostra caixa forta, només lectura", + "Withdrawn by the sender": "Retirat pel remitent", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ha compartit això des d’una altra organització. Podeu llegir-ho, però no modificar-ho ni compartir-ho.", + "Someone": "Algú", + "Share with someone at another organisation": "Comparteix amb algú d’una altra organització", + "Their account at the other organisation": "El seu compte a l’altra organització", + "Check account": "Comprova el compte", + "Certificate fingerprint of {account}": "Empremta del certificat de {account}", + "Compare it with them by phone if you want to be sure.": "Compareu-la amb aquesta persona per telèfon si en voleu estar segurs.", + "Shared. {account} can accept it in their own vault.": "Compartit. {account} el pot acceptar a la seva pròpia caixa forta.", + "The certificate could not be verified. Nothing was shared.": "No s’ha pogut verificar el certificat. No s’ha compartit res.", + "That organisation is not one of your partners.": "Aquesta organització no és cap de les vostres sòcies.", + "No one with that account can receive secrets from you.": "Ningú amb aquest compte pot rebre secrets vostres.", + "The other organisation did not answer. Try again later.": "L’altra organització no ha respost. Torneu-ho a provar més tard.", + "This secret is already shared with that account.": "Aquest secret ja està compartit amb aquest compte.", + "Other organisations": "Altres organitzacions", + "Receive secrets from other organisations": "Rep secrets d’altres organitzacions", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Aleshores, les persones de les organitzacions sòcies podran trobar el vostre compte i compartir secrets amb vós. Cadascun l’accepteu vós mateix.", + "Shared": "Compartit", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: ha canviat el seu certificat o l’associació. Revoqueu-lo o torneu-lo a compartir.", + "Their organisation did not get the last change. Revoke it or share again.": "La seva organització no ha rebut l’últim canvi. Revoqueu-lo o torneu-lo a compartir.", + "Being withdrawn": "S’està retirant", + "Shared with another organisation": "Compartit amb una altra organització", + "Change sent to another organisation": "Canvi enviat a una altra organització", + "Share with another organisation revoked": "Compartició amb una altra organització revocada", + "Share with another organisation paused": "Compartició amb una altra organització en pausa", + "Another organisation did not get a change": "Una altra organització no ha rebut un canvi", + "Secret received from another organisation": "Secret rebut d’una altra organització", + "Secret from another organisation accepted": "Secret d’una altra organització acceptat", + "Secret from another organisation declined": "Secret d’una altra organització rebutjat", + "Copy from another organisation updated": "Còpia d’una altra organització actualitzada", + "Copy from another organisation removed": "Còpia d’una altra organització suprimida", + "Declined: they removed their copy. Share again if they need it.": "Rebutjat: el destinatari ha suprimit la seva còpia. Torneu-lo a compartir si la necessita.", + "Recipient at another organisation removed their copy": "Un destinatari d’una altra organització ha suprimit la seva còpia", + "Removed the user from %n team folder.": "S'ha tret l'usuari de %n carpeta d'equip.", + "Removed the user from %n team folders.": "S'ha tret l'usuari de %n carpetes d'equip.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["S'ha tret l'usuari de %n carpeta d'equip.","S'ha tret l'usuari de %n carpetes d'equip."], + "A restored copy came from a share that has ended. It stays read-only.": "Una còpia restaurada prové d’una compartició que ha acabat. Continua sent només de lectura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "No s’ha pogut contactar amb l’organització que va compartir una còpia restaurada. La còpia continua sent només de lectura i no segueix els seus canvis.", + "Recipient at another organisation restored their copy": "Un destinatari d’una altra organització ha restaurat la seva còpia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/ca.json b/l10n/ca.json index e60e36388..194439f36 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Compartit amb grups", + "Not shared with any group yet.": "Encara no s'ha compartit amb cap grup.", + "Revoke the share with {group}": "Revoca la compartició amb {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartit amb {group}: {received} membres l'han rebut, {skipped} no perquè encara no han configurat el xifratge.", + "Search groups": "Cerca grups", + "Failed to share": "No s'ha pogut compartir", + "Columns": "Columnes", + "Column {number}": "Columna {number}", + "Map one column to Name. Every secret needs a name.": "Assigneu una columna al nom. Cada secret necessita un nom.", + "Notes": "Notes", + "Do not import": "No importis", + "Hide this value": "Amaga aquest valor", + "Show this value": "Mostra aquest valor", + "Defaults": "Valors per defecte", + "New secrets start as this type, and your secret list opens in this view.": "Els secrets nous comencen amb aquest tipus i la teva llista de secrets s'obre en aquesta vista.", + "Default item type": "Tipus d'element per defecte", + "Cards": "Targetes", + "Table": "Taula", + "Could not save your default": "No s'ha pogut desar el valor per defecte", + "Recently used": "Utilitzats recentment", + "Opened": "Obert", + "You have not opened any secrets yet": "Encara no has obert cap secret", + "Could not delete the item type.": "No s'ha pogut suprimir el tipus d'element.", + "Could not load the item types.": "No s'han pogut carregar els tipus d'element.", + "Could not save the item type.": "No s'ha pogut desar el tipus d'element.", + "Delete item type": "Suprimeix el tipus d'element", + "Edit item type": "Edita el tipus d'element", + "Fields": "Camps", + "Fields: {count}": "Camps: {count}", + "Hidden": "Ocult", + "Item types": "Tipus d'element", + "Move up": "Mou amunt", + "New item type": "Tipus d'element nou", + "No item types defined yet.": "Encara no hi ha cap tipus d'element definit.", + "Required": "Obligatori", + "Text": "Text", + "This field is required": "Aquest camp és obligatori", + "Web address": "Adreça web", + "{label} (required)": "{label} (obligatori)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Voleu suprimir «{name}»? Els secrets d'aquest tipus continuen llegibles i passen a ser elements d'Inici de sessió.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Els tipus d'element que definiu aquí apareixen per a tothom al diàleg Secret nou, amb els camps que trieu.", + "Secret moved to the trash": "Secret mogut a la paperera", + "Secret restored from the trash": "Secret restaurat de la paperera", + "Secret deleted for good": "Secret suprimit definitivament", + "Secret archived": "Secret arxivat", + "Secret unarchived": "Secret desarxivat", + "Unarchive": "Desarxiva", + "Could not archive the secret": "No s'ha pogut arxivar el secret", + "Could not unarchive the secret": "No s'ha pogut desarxivar el secret", + "Archive {count} secrets": "Arxiva {count} secrets", + "Unarchive {count} secrets": "Desarxiva {count} secrets", + "Restore {count} secrets": "Restaura {count} secrets", + "Delete {count} secrets for good": "Suprimeix definitivament {count} secrets", + "Done for {ok} of {total} secrets": "Fet per a {ok} de {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Els secrets arxivats surten de la llista de la caixa forta, de la cerca, de l'emplenament automàtic i de l'informe de salut. Mantenen les comparticions. Els trobareu a Arxiu.", + "These secrets come back to the vault list, search and autofill.": "Aquests secrets tornen a la llista de la caixa forta, a la cerca i a l'emplenament automàtic.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Aquests secrets tornen a la llista de la caixa forta. Les comparticions antigues no tornen, així que torneu-los a compartir on calgui.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Això suprimeix els secrets amb els adjunts i l'historial de versions. No es pot desfer.", + "Delete for good": "Suprimeix definitivament", + "Trash": "Paperera", + "The trash is empty": "La paperera és buida", + "No archived secrets": "No hi ha secrets arxivats", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Els secrets suprimits esperen aquí fins que s'acaba el període de conservació; després se suprimeixen definitivament.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arxiveu un secret des del seu tauler de detalls per mantenir-lo fora de la llista de la caixa forta, de la cerca i de l'emplenament automàtic.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Límits per als adjunts xifrats (aplicats al servidor sobre els bytes xifrats desats), conservació de l'historial de versions i quant de temps es queden a la paperera els secrets suprimits.", + "Days a deleted secret stays in the trash (1 to 365)": "Dies que un secret suprimit es queda a la paperera (d'1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Això mou el secret a la paperera i acaba ara les seves comparticions. El podeu restaurar de la paperera fins que s'acabi el període de conservació: 30 dies, llevat que l'administrador l'hagi canviat.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Això mou {count} secrets a la paperera i acaba ara les seves comparticions. Els podeu restaurar de la paperera fins que s'acabi el període de conservació.", + "Remove {name} from favourites": "Treu {name} dels preferits", + "Add {name} to favourites": "Afegeix {name} als preferits", + "Could not change the favourite": "No s'ha pogut canviar el preferit", + "Remove from favourites": "Treu dels preferits", + "Add to favourites": "Afegeix als preferits", + "Tags": "Etiquetes", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Les etiquetes no estan xifrades. Els administradors del servidor les poden llegir, com els noms de carpeta.", + "Favourites": "Preferits", + "Filter by tag": "Filtra per etiqueta", + "All tags": "Totes les etiquetes", + "Last used": "Darrer ús", + "Tags for {count} secrets": "Etiquetes per a {count} secrets", + "Tag": "Etiqueta", + "Remove tag": "Treu l'etiqueta", + "Add tag": "Afegeix una etiqueta", + "Could not change the tags. Try again.": "No s'han pogut canviar les etiquetes. Torneu-ho a provar.", + "Could not approve the application. It is still in the queue.": "No s'ha pogut aprovar la sol·licitud. Encara és a la cua.", + "Could not reject the application. It is still in the queue.": "No s'ha pogut rebutjar la sol·licitud. Encara és a la cua.", + "Removed the user from {count} team folders.": "S'ha tret l'usuari de {count} carpetes d'equip.", + "Approve a share": "Aprova una compartició", + "This approval link is incomplete. Open it again from the notification.": "Aquest enllaç d'aprovació és incomplet. Torneu-lo a obrir des de la notificació.", + "Deny": "Denega", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} s'ha unit a un grup amb el qual compartiu un secret. Voleu compartir-li també el secret?", + "{requester} asks you to share a secret with {user}.": "{requester} us demana que compartiu un secret amb {user}.", + "Shared. The recipient can now open the secret.": "S'ha compartit. Ara el destinatari pot obrir el secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "El destinatari encara no ha configurat Keepiq, per tant no s'ha compartit res. Torneu-ho a provar quan ho hagi fet.", + "Could not share the secret. Only its owner can approve this.": "No s'ha pogut compartir el secret. Només el propietari ho pot aprovar.", + "Could not share the secret. Try again.": "No s'ha pogut compartir el secret. Torneu-ho a provar.", + "Denied. Nothing was shared.": "S'ha denegat. No s'ha compartit res.", + "Could not deny the request. Try again.": "No s'ha pogut denegar la sol·licitud. Torneu-ho a provar.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s us demana que compartiu el secret \"%2$s\" amb %3$s.", + "Expires on (optional)": "Caduca el (opcional)", + "Hand over to": "Cedeix a", + "Choose a recipient": "Trieu un destinatari", + "Hand over temporarily": "Cedeix temporalment", + "Expiry rules": "Regles de caducitat", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Establiu quant de temps poden durar les contrasenyes d'un tipus d'element o d'una carpeta, i quan cal recordar-ho. Quan s'apliquen diverses dates, compta la més primerenca.", + "Delete rule": "Suprimeix la regla", + "Set by your administrator": "Establert per l'administrador", + "No expiry rules yet.": "Encara no hi ha regles de caducitat.", + "Applies to": "S'aplica a", + "Item type": "Tipus d'element", + "Maximum age in days (empty for reminders only)": "Antiguitat màxima en dies (buit només per a recordatoris)", + "Remind me this many days before, comma separated": "Recorda-m'ho aquests dies abans, separats per comes", + "Save rule": "Desa la regla", + "An item type": "Un tipus d'element", + "A folder": "Una carpeta", + "Folder {name}": "Carpeta {name}", + "Type {name}": "Tipus {name}", + "Expires after {days} days": "Caduca al cap de {days} dies", + "Reminders {days} days before": "Recordatoris {days} dies abans", + "Could not save the expiry rule.": "No s'ha pogut desar la regla de caducitat.", + "Could not delete the expiry rule.": "No s'ha pogut suprimir la regla de caducitat.", + "All statuses": "Tots els estats", + "Compromised": "Compromesa", + "Could not load the members.": "No s'han pogut carregar els membres.", + "Emergency contact": "Contacte d'emergència", + "Leaving user": "Usuari que marxa", + "No": "No", + "No users match this filter.": "Cap usuari coincideix amb aquest filtre.", + "Not set up": "Sense configurar", + "Revoke suite": "Revoca la suite", + "Revoked": "Revocada", + "Search users": "Cerca usuaris", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Mireu quins usuaris han configurat una caixa forta. Inicieu la baixa o revoqueu una suite des d'una fila.", + "Successor": "Successor", + "Team folders": "Carpetes d'equip", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'usuari encara és al grup {groups}, que és membre d'una carpeta d'equip. Traieu-lo del grup o desactiveu el compte.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'usuari encara és als grups {groups}, que són membres de carpetes d'equip. Traieu-lo dels grups o desactiveu el compte.", + "Vault status": "Estat de la caixa forta", + "Yes": "Sí", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Una exportació CXF NO ESTÀ XIFRADA. Totes les contrasenyes i inicis de sessió es podran llegir com a text pla al fitxer baixat. Deseu-lo de manera segura i suprimiu-lo immediatament després d'usar-lo.", + "Root certificate expiring soon": "El certificat arrel caduca aviat", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "El certificat arrel de la caixa forta caduca d'aquí a %1$d dia(es). Renoveu-lo abans. En renovar-lo es torna a signar cada suite de xifratge.", + "Compromise recovery aborted": "Recuperació després de compromís cancel·lada", + "Key rotation ended by a compromise revoke": "Rotació de clau finalitzada per una revocació per compromís", + "Encryption suite revoke refused": "Revocació del conjunt de xifratge rebutjada", + "Master password proof refused": "Prova de la contrasenya mestra rebutjada", + "Your current master password": "La teva contrasenya mestra actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacte d'emergència tenia una sol·licitud d'accés pendent quan la rotació de clau el va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactes d'emergència tenien una sol·licitud d'accés pendent quan la rotació de clau els va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Aquests contactes d'emergència no s'han traspassat a la teva clau nova. S'ha eliminat el seu accés d'emergència. Torna'ls a afegir des d'Accés d'emergència si encara els vols.", + "Renew root certificate": "Renova el certificat arrel", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Això crea un certificat arrel i un d'intermedi nous. Cada conjunt de xifratge actiu es torna a signar. No es pot desfer.", + "Renew root": "Renova l'arrel", + "Root renewed. {n} encryption suites signed again.": "Arrel renovada. Conjunts de xifratge signats de nou: {n}.", + "Could not renew the root certificate.": "No s'ha pogut renovar el certificat arrel.", + "Lease policy for this application": "Política de cessió per a aquesta aplicació", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigor ara: {default} segons per defecte, {max} segons com a màxim.", + "Leases are not renewable": "Les cessions no es poden renovar", + "Lease policy saved.": "S'ha desat la política de cessió.", + "Leave a field empty to use the instance value.": "Deixeu un camp buit per usar el valor de la instància.", + "Instance value: {value}": "Valor de la instància: {value}", + "Renewal": "Renovació", + "Use the instance value ({value})": "Usa el valor de la instància ({value})", + "Allowed": "Permès", + "Not allowed": "No permès", + "Save lease policy": "Desa la política de cessió", + "Only an administrator can change this policy.": "Només un administrador pot canviar aquesta política.", + "Could not save the lease policy.": "No s'ha pogut desar la política de cessió.", + "{member} got access from {confirmer}.": "{member} ha rebut accés de {confirmer}.", + "Automatically confirm new team folder members": "Confirma automàticament els membres nous de les carpetes d'equip", + "Gave %n new member access to a team folder.": "%n membre nou ha rebut accés a una carpeta d'equip.", + "Gave %n new members access to a team folder.": "%n membres nous han rebut accés a una carpeta d'equip.", + "Give new team folder members access without waiting for the folder owner.": "Doneu accés als membres nous sense esperar el propietari de la carpeta.", + "New team folder members": "Membres nous de les carpetes d'equip", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "El propietari o un membre amb permís d'escriptura els confirma des de la seva caixa forta oberta. Keepiq mai desxifra al servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "S'espera que un membre amb permís d'escriptura obri Keepiq. També podeu compartir ara.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Part de la resposta al compromís ha fallat ({failed} pas(sos)). Consulteu el registre del servidor i torneu a revocar la suite per acabar-la.", + "This also revoked suite {suite} and ended key migration {migration}.": "Això també ha revocat la suite {suite} i ha finalitzat la migració de claus {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocar la segona suite ha suprimit %n contacte d'accés d'emergència.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocar la segona suite ha suprimit %n contactes d'accés d'emergència.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocada com a compromesa no es pot restablir.", + "Archives to keep": "Arxius que cal conservar", + "Back up every vault automatically": "Fes còpia de cada caixa forta automàticament", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Feu còpia de cada caixa forta segons una planificació. Els arxius només contenen text xifrat i es restauren amb occ.", + "Back up now": "Fes còpia ara", + "Backup public key (PEM, optional)": "Clau pública de còpia (PEM, opcional)", + "Backup requested for the next cron run": "Còpia sol·licitada per a la propera execució de cron", + "Encrypted": "Xifrat", + "Every (hours)": "Cada (hores)", + "Last backup {when} failed: {error}": "La darrera còpia {when} ha fallat: {error}", + "Last backup {when} succeeded.": "La darrera còpia {when} ha funcionat.", + "No archives yet.": "Encara no hi ha arxius.", + "Size": "Mida", + "Vault backups": "Còpies de la caixa forta", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Amb una clau, cada arxiu es xifra per a ella. Guardeu la clau privada fora d'aquest servidor: la necessiteu per verificar o restaurar.", + "Written": "Escrit", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n usuari de l'abast encara no té inici de sessió en dos passos i no pot obrir la caixa forta mentre això estigui actiu.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n usuaris de l'abast encara no tenen inici de sessió en dos passos i no poden obrir la caixa forta mentre això estigui actiu.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Els codis de recuperació no compten. Si els usuaris inicien la sessió amb un proveïdor d'identitat amb el seu propi segon factor, excloeu-ne els grups.", + "Block personal vault export": "Bloca l'exportació de la caixa forta personal", + "Keep work logins in team folders": "Desa els inicis de sessió de feina en carpetes d'equip", + "Move to a team folder": "Mou a una carpeta d'equip", + "Not in a team folder": "No és en una carpeta d'equip", + "Only for these groups (empty is everyone)": "Només per a aquests grups (buit vol dir tothom)", + "Require two-factor login before the vault opens": "Exigeix l'inici de sessió en dos passos abans d'obrir la caixa forta", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Normes per a cada caixa forta. Cadascuna s'aplica a tothom o només als grups que trieu.", + "Secret types that belong in a team folder": "Tipus de secret que van en una carpeta d'equip", + "Set up two-factor login": "Configura l'inici de sessió en dos passos", + "Team folder you can write to": "Carpeta d'equip on podeu escriure", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Els usuaris no poden baixar una còpia de seguretat, un CSV ni un fitxer de transferència. El seu paquet de dades personals continua disponible.", + "Users cannot save these secret types in a personal folder.": "Els usuaris no poden desar aquests tipus de secret en una carpeta personal.", + "Vault policies": "Polítiques de la caixa forta", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "La vostra organització no permet exportar la vostra caixa forta personal. El vostre paquet de dades personals a la configuració continua disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "La vostra organització desa aquests secrets en una carpeta d'equip. Moveu-los un a un a una carpeta d'equip.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "La vostra organització desa aquest tipus de secret en una carpeta d'equip. Trieu una de les vostres carpetes d'equip o una on pugueu escriure.", + "Your organisation requires two-factor login before you can open your vault.": "La vostra organització exigeix l'inici de sessió en dos passos abans que pugueu obrir la caixa forta.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Els usuaris trien quant de temps l'extensió roman desbloquejada sense activitat. Vostè fixa el màxim que poden triar.", + "Longest idle time before the extension locks": "Temps màxim d'inactivitat abans que l'extensió es bloquegi", + "1 minute": "1 minut", + "5 minutes": "5 minuts", + "15 minutes": "15 minuts", + "1 hour": "1 hora", + "4 hours": "4 hores", + "Connector": "Connector", + "Directory (tenant) ID": "ID del directori (inquilí)", + "Application (client) ID": "ID de l'aplicació (client)", + "Data collection rule immutable ID": "ID immutable de la regla de recollida de dades", + "Stream name": "Nom del flux", + "Splunk index (optional)": "Índex de Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Deixeu-ho en blanc per mantenir l'actual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF per syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punt final de recollida de dades (URL https)", + "HTTP Event Collector URL (https)": "URL de l'HTTP Event Collector (https)", + "Client secret (write-only)": "Secret del client (només escriptura)", + "HEC token (write-only)": "Testimoni HEC (només escriptura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reenvia els esdeveniments d'auditoria permesos a Splunk, Microsoft Sentinel, un receptor syslog o un webhook. Els missatges només duen metadades netejades: cap valor secret, nom, inici de sessió o text xifrat surt mai del servidor.", + "%n change waiting to sync": "%n canvi pendent de sincronitzar", + "%n changes waiting to sync": "%n canvis pendents de sincronitzar", + "Changes that could not sync": "Canvis que no s'han pogut sincronitzar", + "Choose a version": "Tria una versió", + "Copy value": "Copia el valor", + "Deleted": "Suprimit", + "Discard": "Descarta", + "Keep my offline change": "Conserva el meu canvi fora de línia", + "Keep the server version": "Conserva la versió del servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq és només de lectura fora de línia. L'administrador no ha activat l'edició fora de línia.", + "Let users edit secrets offline": "Permet als usuaris editar secrets fora de línia", + "Not synced yet": "Encara no sincronitzat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Els canvis fora de línia es queden al dispositiu, xifrats per a l'usuari, i se sincronitzen en el següent desbloqueig en línia. Compartir, carpetes i adjunts encara necessiten connexió.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Fora de línia. Les edicions, els moviments i les supressions es queden en aquest dispositiu i se sincronitzen quan torneu a estar en línia. Compartir i adjunts necessiten connexió.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Fora de línia. Els vostres canvis es queden en aquest dispositiu i se sincronitzen quan torneu a estar en línia. Última sincronització {when}.", + "Open my changes": "Obre els meus canvis", + "Sharing needs a connection": "Compartir necessita connexió", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Algú ha canviat aquest secret al servidor després que es fes la vostra còpia fora de línia. Trieu quina versió conservar.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronitzeu o descarteu els canvis fora de línia abans de renovar les claus.", + "That password did not open your changes.": "Aquesta contrasenya no ha obert els vostres canvis.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La instantània fora de línia desa els secrets xifrats (només es poden obrir amb la clau derivada de la contrasenya mestra de l'usuari, igual que al servidor) i xifra en repòs els noms, els URL i els noms de carpetes. L'accés fora de línia és només de lectura, tret que permeteu a sota l'edició fora de línia. Desactiveu-ho en equips que no hagin de desar mai credencials; en desactivar-ho s'esborren les memòries cau existents en la càrrega següent.", + "The previous vault copy is gone, so these changes cannot be opened.": "La còpia anterior de la caixa forta ja no hi és, per tant aquests canvis no es poden obrir.", + "The server version": "La versió del servidor", + "This secret changed while you were offline": "Aquest secret ha canviat mentre éreu fora de línia", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vau suprimir aquest secret fora de línia, però des de llavors s'ha canviat al servidor. Trieu quina versió conservar.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Les vostres claus s'han canviat en un altre dispositiu. Introduïu la contrasenya mestra anterior per sincronitzar els canvis fora de línia, o descarteu-los.", + "Your offline change": "El vostre canvi fora de línia", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contacte d'emergència tenia una sol·licitud d'accés pendent quan la rotació de clau el va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú.", + "%n contactes d'emergència tenien una sol·licitud d'accés pendent quan la rotació de clau els va eliminar. Comprova qui la va demanar abans de tornar a afegir ningú." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n element no es pot representar en CXF i s'ometrà.", + "%n elements no es poden representar en CXF i s'ometran." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "S'ha descartat %n versió anterior perquè només es pot traslladar l'historial recent.", + "S'han descartat %n versions anteriors perquè només es pot traslladar l'historial recent." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Encara cal xifrar i compartir %n còpia de secret.", + "Encara cal xifrar i compartir %n còpies de secret." + ], + "_%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 secret no s'ha pogut desxifrar i no és en aquesta exportació.", + "%n secrets no s'han pogut desxifrar i no són en aquesta exportació." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n secret no s'ha pogut desxifrar amb la vostra clau antiga, per això no s'ha migrat.", + "%n secrets no s'han pogut desxifrar amb la vostra clau antiga, per això no s'han migrat." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n secret no s'ha migrat.", + "%n secrets no s'han migrat." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n secret encara està xifrat amb la vostra clau anterior.", + "%n secrets encara estan xifrats amb la vostra clau anterior." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "S'ha omès %n secret perquè el successor encara no en té cap còpia: afegiu el successor a la carpeta i torneu-ho a executar.", + "S'han omès %n secrets perquè el successor encara no en té cap còpia: afegiu el successor a la carpeta i torneu-ho a executar." + ], + "_%n secret_::_%n secrets_": [ + "%n secret", + "%n secrets" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n usuari de l'abast encara no té inici de sessió en dos passos i no pot obrir la caixa forta mentre això estigui actiu.", + "%n usuaris de l'abast encara no tenen inici de sessió en dos passos i no poden obrir la caixa forta mentre això estigui actiu." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Finalitza igualment, perdent l'accés a %n secret", + "Finalitza igualment, perdent l'accés a %n secrets" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n membre nou ha rebut accés a una carpeta d'equip.", + "%n membres nous han rebut accés a una carpeta d'equip." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "La rotació de claus ha finalitzat. %n secret s'ha tornat a xifrar amb la vostra clau nova.", + "La rotació de claus ha finalitzat. %n secrets s'han tornat a xifrar amb la vostra clau nova." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revocar la segona suite ha suprimit %n contacte d'accés d'emergència.", + "Revocar la segona suite ha suprimit %n contactes d'accés d'emergència." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "La revocació d'aquesta suite ha eliminat %n contacte d'accés d'emergència.", + "La revocació d'aquesta suite ha eliminat %n contactes d'accés d'emergència." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "vist %n vegada en filtracions", + "vist %n vegades en filtracions" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "compartit amb %n secret", + "compartit amb %n secrets" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Aquesta carpeta conté %n secret directament.", + "Aquesta carpeta conté %n secrets directament." + ], + "_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._": [ + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n canvi pendent de sincronitzar", + "%n canvis pendents de sincronitzar" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "L'usuari encara és al grup {groups}, que és membre d'una carpeta d'equip. Traieu-lo del grup o desactiveu el compte.", + "L'usuari encara és als grups {groups}, que són membres de carpetes d'equip. Traieu-lo dels grups o desactiveu el compte." + ], + "Allow approval from another device": "Permet l'aprovació des d'un altre dispositiu", + "App": "Aplicació", + "Approve a new device": "Aprova un dispositiu nou", + "Approve from another device": "Aprova des d'un altre dispositiu", + "Asked at": "Sol·licitat a les", + "Check that the new device shows these words:": "Comproveu que el dispositiu nou mostra aquestes paraules:", + "Denied. If you did not ask, end your other sessions:": "Denegat. Si no ho heu sol·licitat, tanqueu les altres sessions:", + "Device": "Dispositiu", + "IP address": "Adreça IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permet als usuaris desbloquejar un navegador nou aprovant-lo des d'un dispositiu on Keepiq ja està desbloquejat.", + "New device approval": "Aprovació de dispositius nous", + "Nextcloud security settings": "Paràmetres de seguretat de Nextcloud", + "Only approve a device you are using right now.": "Aproveu només un dispositiu que estigueu fent servir ara mateix.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Obriu Keepiq en un dispositiu on estigui desbloquejat i aproveu aquest. Comproveu que mostra les mateixes paraules:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "El dispositiu que aprova segella la clau de desbloqueig per al dispositiu nou. El servidor només la transmet i no la pot obrir.", + "The master password is not right, or the request has ended.": "La contrasenya mestra no és correcta o la sol·licitud ha acabat.", + "The request expired. Ask again or use your master password.": "La sol·licitud ha caducat. Torneu-ho a demanar o feu servir la contrasenya mestra.", + "The request was denied.": "La sol·licitud s'ha denegat.", + "Too many requests. Try again in an hour or use your master password.": "Massa sol·licituds. Torneu-ho a provar d'aquí a una hora o feu servir la contrasenya mestra.", + "Unknown device": "Dispositiu desconegut", + "Web app": "Aplicació web", + "A device": "Un dispositiu", + "A new device asks to open your vault": "Un dispositiu nou demana obrir la vostra caixa forta", + "%s asks to be approved. Only approve a device you are using right now.": "%s demana ser aprovat. Aproveu només un dispositiu que estigueu fent servir ara mateix.", + "Access ends on (optional)": "L'accés acaba el (opcional)", + "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.": "Les aplicacions del Keepiq no mostraran ni copiaran la contrasenya. Algú amb coneixements tècnics encara la pot llegir des del seu dispositiu. Canvieu-la quan s'acabi el seu accés.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Aquest secret és només d'ús. Inicieu la sessió mitjançant l'extensió del navegador del Keepiq.", + "Until {date}": "Fins al {date}", + "Use only": "Només ús", + "Use only (can sign in, cannot view or copy)": "Només ús (pot iniciar la sessió, no pot veure ni copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Podeu iniciar la sessió amb aquestes credencials mitjançant l'extensió del navegador del Keepiq. El propietari ha decidit no permetre-us veure-les ni copiar-les.", + "Your access ends on {date}": "El vostre accés acaba el {date}", + "Your access to this secret has ended": "El vostre accés a aquest secret ha acabat", + "Your access to \"%s\" ends tomorrow": "El vostre accés a «%s» acaba demà", + "Your access to \"%s\" has ended": "El vostre accés a «%s» ha acabat", + "%1$s no longer has access to \"%2$s\"": "%1$s ja no té accés a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podia veure aquesta contrasenya. Canvieu-la si %1$s ja no l'ha de conèixer.", + "%s could not view this password in Keepiq.": "%s no ha pogut veure aquesta contrasenya al Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprovacions", + "a recovery officer": "un responsable de recuperació", + "Account recovery": "Recuperació del compte", + "Approvals needed": "Aprovacions necessàries", + "Ask {user} which words they see, by phone or in person. They must be:": "Pregunteu a {user} quines paraules veu, per telèfon o en persona. Han de ser:", + "Check again": "Torna a comprovar", + "Create the recovery key": "Crea la clau de recuperació", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creeu la clau de recuperació. El navegador la genera i dona a cada responsable una còpia que només ell pot obrir.", + "Decline": "Rebutja", + "Enrol in account recovery": "Inscriviu-vos a la recuperació del compte", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscriviu-vos perquè la vostra organització us pugui ajudar a recuperar la caixa forta si oblideu la contrasenya mestra.", + "Every user is enrolled": "Tots els usuaris estan inscrits", + "Finish the recovery in the browser you asked from.": "Acabeu la recuperació al navegador des d'on la vau demanar.", + "Forgot your master password?": "Heu oblidat la contrasenya mestra?", + "Hand the key over": "Lliura la clau", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permet que els usuaris que han oblidat la contrasenya mestra recuperin la caixa forta, amb l'aprovació dels responsables de recuperació que designeu.", + "New master password": "Contrasenya mestra nova", + "No one is asking to recover their account.": "Ningú no demana recuperar el seu compte.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Encara no hi ha clau de recuperació. Un dels responsables la crea a la seva configuració de Keepiq.", + "Off": "Desactivat", + "Officer {user} has no encryption set up yet.": "El responsable {user} encara no ha configurat el xifratge.", + "Officers (user IDs, separated by commas)": "Responsables (ID d'usuari, separats per comes)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiqueu aquesta empremta internament, perquè els usuaris la puguin comprovar abans d'inscriure's.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperat amb l'ajuda de {officer}. Canvieu ara la clau de la caixa forta a Configuració, Seguretat: \"La meva contrasenya mestra s'ha vist compromesa\".", + "Recovery key fingerprint: {fingerprint}": "Empremta de la clau de recuperació: {fingerprint}", + "Recovery officer": "Responsable de recuperació", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Els responsables eliminats perden ara la seva còpia, però potser ja l'havien oberta. Feu que un responsable creï una clau de recuperació nova.", + "Repeat the new master password": "Repetiu la contrasenya mestra nova", + "Retire this recovery key": "Retira aquesta clau de recuperació", + "Set the new master password": "Estableix la contrasenya mestra nova", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "El certificat de recuperació no l'ha emès aquest Keepiq. No us inscriviu i aviseu l'administrador.", + "The words match, approve": "Les paraules coincideixen, aprova", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Aquest usuari està inscrit a la recuperació del compte. Recuperar conserva els seus secrets; revocar n'elimina la inscripció.", + "Users may enrol": "Els usuaris es poden inscriure", + "Withdraw from account recovery": "Retira't de la recuperació del compte", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Esteu inscrit a la recuperació del compte. Empremta de la clau de recuperació: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Esteu inscrit. Si oblideu la contrasenya mestra, la vostra organització us pot ajudar a recuperar la caixa forta.", + "Your key is back. Choose a new master password.": "Ja teniu la clau. Trieu una contrasenya mestra nova.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "S'ha avisat els vostres responsables de recuperació. Llegiu-los aquestes paraules quan us truquin o us trobeu:", + "You are now an account recovery officer": "Ara sou responsable de recuperació de comptes", + "%s asks to recover their account. Compare the words with them before you approve.": "%s demana recuperar el seu compte. Compareu les paraules amb aquesta persona abans d'aprovar.", + "A user": "Un usuari", + "Your account recovery request was declined": "S'ha rebutjat la vostra sol·licitud de recuperació del compte", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La recuperació del compte està llesta. Obriu Keepiq al navegador des d'on la vau demanar.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} demana desbloquejar un dispositiu nou una sola vegada. Conserva la seva contrasenya mestra.", + "Ask your organisation instead": "Demana-ho a la teva organització", + "The request ended. Ask again or use your master password.": "La sol·licitud ha finalitzat. Torna-ho a demanar o fes servir la contrasenya mestra.", + "Added by {user}": "Afegit per {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Rol de {member}", + "Team folders you manage": "Carpetes d'equip que gestioneu", + "Viewer": "Lector", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "No teniu cap còpia d'aquests secrets, així que els nous membres encara no els han rebut. El propietari els pot compartir: {names}", + "Admin areas": "Àrees d'administració", + "Give a group only the parts of Keepiq administration it needs.": "Doneu a un grup només les parts de l'administració de Keepiq que necessita.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegueu una o més àrees a un grup a la pàgina de privilegis d'administració. Els administradors de la instància tenen totes les àrees.", + "Open administration privileges": "Obre els privilegis d'administració", + "Policies": "Polítiques", + "Applications and machine access": "Aplicacions i accés de màquines", + "People and offboarding": "Persones i baixes", + "Audit and compliance": "Auditoria i compliment", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versió, autoritat de certificació, adjunts, memòria cau fora de línia, comprovació de filtracions, tipus de secrets i còpies de seguretat", + "master password, organisation password, vault policies, rotation, version history and trash": "contrasenya mestra, contrasenya de l'organització, polítiques de la caixa forta, rotació, historial de versions i paperera", + "application queue, application requests and machine leases": "cua d'aplicacions, sol·licituds d'aplicacions i concessions de màquines", + "team offboarding, encryption suites and admin handover": "baixes de l'equip, conjunts de xifratge i traspàs per l'administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registre d'auditoria, informes de compliment, exportació SIEM i alertes d'esquer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantes versions d'un secret es conserven, durant quant de temps, i quant de temps resten a la paperera els secrets suprimits.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Límits per als adjunts xifrats, aplicats al servidor en bytes xifrats emmagatzemats.", + "Type the suite ID again to confirm": "Torneu a escriure l'ID del conjunt per confirmar", + "This does not match the suite ID.": "No coincideix amb l'ID del conjunt.", + "Confirm with your master password": "Confirmeu amb la contrasenya mestra", + "Confirm": "Confirma", + "That master password is not right.": "Aquesta contrasenya mestra no és correcta.", + "You are sharing with someone new. Enter your master password to confirm.": "Esteu compartint amb algú nou. Introduïu la contrasenya mestra per confirmar.", + "Enter your master password to confirm this share.": "Introduïu la contrasenya mestra per confirmar aquesta compartició.", + "Enter your master password to confirm this delegation.": "Introduïu la contrasenya mestra per confirmar aquesta delegació.", + "Approve {member}": "Aprova {member}", + "Recipient": "Destinatari", + "No vault yet": "Encara no té cap caixa forta", + "No matching users": "No hi ha cap usuari coincident", + "Partner organisations": "Organitzacions sòcies", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Intercanvia secrets amb un altre Keepiq. Tots dos administradors s’afegeixen mútuament i comparen les empremtes arrel per telèfon o en persona abans de desar.", + "Federation needs Nextcloud 33 or later.": "La federació requereix Nextcloud 33 o posterior.", + "Your root fingerprint": "La teva empremta arrel", + "No partners yet.": "Encara no hi ha cap soci.", + "Users here may share to this partner": "Els usuaris d’aquí poden compartir amb aquest soci", + "This partner may share to users here": "Aquest soci pot compartir amb els usuaris d’aquí", + "Partner address": "Adreça del soci", + "Check partner": "Comprova el soci", + "Partner root fingerprint": "Empremta arrel del soci", + "I compared this fingerprint with the partner's administrator": "He comparat aquesta empremta amb l’administrador del soci", + "Add partner": "Afegeix el soci", + "A secret from another organisation": "Un secret d’una altra organització", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha compartit \"%2$s\" amb vós. Accepteu-lo a Rebuts d’altres organitzacions.", + "Incoming from other organisations": "Rebuts d’altres organitzacions", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Les persones de les organitzacions sòcies poden compartir un secret amb vós. Accepteu-lo per conservar una còpia de només lectura a la vostra caixa forta.", + "Nothing shared with you yet": "Encara no s’ha compartit res amb vós", + "Secrets that people in partner organisations share with you appear here.": "Els secrets que les persones de les organitzacions sòcies comparteixen amb vós apareixen aquí.", + "From {sender}": "De {sender}", + "Accept": "Accepta", + "Open in vault": "Obre a la caixa forta", + "The other organisation did not hand over the secret. Try again later.": "L’altra organització no ha lliurat el secret. Torneu-ho a provar més tard.", + "Set up your vault before you accept a shared secret.": "Configureu la vostra caixa forta abans d’acceptar un secret compartit.", + "Something went wrong. Try again.": "Alguna cosa ha anat malament. Torneu-ho a provar.", + "Waiting for your answer": "Esperant la vostra resposta", + "In your vault, read-only": "A la vostra caixa forta, només lectura", + "Withdrawn by the sender": "Retirat pel remitent", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ha compartit això des d’una altra organització. Podeu llegir-ho, però no modificar-ho ni compartir-ho.", + "Someone": "Algú", + "Share with someone at another organisation": "Comparteix amb algú d’una altra organització", + "Their account at the other organisation": "El seu compte a l’altra organització", + "Check account": "Comprova el compte", + "Certificate fingerprint of {account}": "Empremta del certificat de {account}", + "Compare it with them by phone if you want to be sure.": "Compareu-la amb aquesta persona per telèfon si en voleu estar segurs.", + "Shared. {account} can accept it in their own vault.": "Compartit. {account} el pot acceptar a la seva pròpia caixa forta.", + "The certificate could not be verified. Nothing was shared.": "No s’ha pogut verificar el certificat. No s’ha compartit res.", + "That organisation is not one of your partners.": "Aquesta organització no és cap de les vostres sòcies.", + "No one with that account can receive secrets from you.": "Ningú amb aquest compte pot rebre secrets vostres.", + "The other organisation did not answer. Try again later.": "L’altra organització no ha respost. Torneu-ho a provar més tard.", + "This secret is already shared with that account.": "Aquest secret ja està compartit amb aquest compte.", + "Other organisations": "Altres organitzacions", + "Receive secrets from other organisations": "Rep secrets d’altres organitzacions", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Aleshores, les persones de les organitzacions sòcies podran trobar el vostre compte i compartir secrets amb vós. Cadascun l’accepteu vós mateix.", + "Shared": "Compartit", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: ha canviat el seu certificat o l’associació. Revoqueu-lo o torneu-lo a compartir.", + "Their organisation did not get the last change. Revoke it or share again.": "La seva organització no ha rebut l’últim canvi. Revoqueu-lo o torneu-lo a compartir.", + "Being withdrawn": "S’està retirant", + "Shared with another organisation": "Compartit amb una altra organització", + "Change sent to another organisation": "Canvi enviat a una altra organització", + "Share with another organisation revoked": "Compartició amb una altra organització revocada", + "Share with another organisation paused": "Compartició amb una altra organització en pausa", + "Another organisation did not get a change": "Una altra organització no ha rebut un canvi", + "Secret received from another organisation": "Secret rebut d’una altra organització", + "Secret from another organisation accepted": "Secret d’una altra organització acceptat", + "Secret from another organisation declined": "Secret d’una altra organització rebutjat", + "Copy from another organisation updated": "Còpia d’una altra organització actualitzada", + "Copy from another organisation removed": "Còpia d’una altra organització suprimida", + "Declined: they removed their copy. Share again if they need it.": "Rebutjat: el destinatari ha suprimit la seva còpia. Torneu-lo a compartir si la necessita.", + "Recipient at another organisation removed their copy": "Un destinatari d’una altra organització ha suprimit la seva còpia", + "Removed the user from %n team folder.": "S'ha tret l'usuari de %n carpeta d'equip.", + "Removed the user from %n team folders.": "S'ha tret l'usuari de %n carpetes d'equip.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "S'ha tret l'usuari de %n carpeta d'equip.", + "S'ha tret l'usuari de %n carpetes d'equip." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Una còpia restaurada prové d’una compartició que ha acabat. Continua sent només de lectura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "No s’ha pogut contactar amb l’organització que va compartir una còpia restaurada. La còpia continua sent només de lectura i no segueix els seus canvis.", + "Recipient at another organisation restored their copy": "Un destinatari d’una altra organització ha restaurat la seva còpia" }, "plurals": null } diff --git a/l10n/cs.js b/l10n/cs.js index 3ee623003..d30c9e567 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Sdíleno se skupinami", + "Not shared with any group yet.": "Zatím nesdíleno s žádnou skupinou.", + "Revoke the share with {group}": "Zrušit sdílení se skupinou {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Sdíleno se skupinou {group}: {received} členů to obdrželo, {skipped} ne, protože ještě nemají nastavené šifrování.", + "Search groups": "Hledat skupiny", + "Failed to share": "Sdílení se nezdařilo", + "Columns": "Sloupce", + "Column {number}": "Sloupec {number}", + "Map one column to Name. Every secret needs a name.": "Přiřaďte jeden sloupec k názvu. Každé tajemství potřebuje název.", + "Notes": "Poznámky", + "Do not import": "Neimportovat", + "Hide this value": "Skrýt tuto hodnotu", + "Show this value": "Zobrazit tuto hodnotu", + "Defaults": "Výchozí", + "New secrets start as this type, and your secret list opens in this view.": "Nová tajemství začínají tímto typem a seznam tajemství se otevře v tomto zobrazení.", + "Default item type": "Výchozí typ položky", + "Cards": "Karty", + "Table": "Tabulka", + "Could not save your default": "Výchozí hodnotu se nepodařilo uložit", + "Recently used": "Nedávno použité", + "Opened": "Otevřeno", + "You have not opened any secrets yet": "Zatím jste neotevřeli žádné tajemství", + "Could not delete the item type.": "Typ položky se nepodařilo smazat.", + "Could not load the item types.": "Typy položek se nepodařilo načíst.", + "Could not save the item type.": "Typ položky se nepodařilo uložit.", + "Delete item type": "Smazat typ položky", + "Edit item type": "Upravit typ položky", + "Fields": "Pole", + "Fields: {count}": "Pole: {count}", + "Hidden": "Skryté", + "Item types": "Typy položek", + "Move up": "Posunout nahoru", + "New item type": "Nový typ položky", + "No item types defined yet.": "Zatím nejsou definovány žádné typy položek.", + "Required": "Povinné", + "Text": "Text", + "This field is required": "Toto pole je povinné", + "Web address": "Webová adresa", + "{label} (required)": "{label} (povinné)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Smazat „{name}“? Tajemství tohoto typu zůstanou čitelná a stanou se položkami Přihlášení.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy položek, které zde definujete, se všem zobrazí v dialogu Nové tajemství se zvolenými poli.", + "Secret moved to the trash": "Tajemství přesunuto do koše", + "Secret restored from the trash": "Tajemství obnoveno z koše", + "Secret deleted for good": "Tajemství trvale smazáno", + "Secret archived": "Tajemství archivováno", + "Secret unarchived": "Tajemství vráceno z archivu", + "Unarchive": "Vrátit z archivu", + "Could not archive the secret": "Tajemství se nepodařilo archivovat", + "Could not unarchive the secret": "Tajemství se nepodařilo vrátit z archivu", + "Archive {count} secrets": "Archivovat tajemství: {count}", + "Unarchive {count} secrets": "Vrátit z archivu tajemství: {count}", + "Restore {count} secrets": "Obnovit tajemství: {count}", + "Delete {count} secrets for good": "Trvale smazat tajemství: {count}", + "Done for {ok} of {total} secrets": "Hotovo pro {ok} z {total} tajemství", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivovaná tajemství zmizí ze seznamu trezoru, z vyhledávání, automatického vyplňování a zprávy o stavu. Zůstanou sdílená. Najdete je v Archivu.", + "These secrets come back to the vault list, search and autofill.": "Tato tajemství se vrátí do seznamu trezoru, do vyhledávání a automatického vyplňování.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tato tajemství se vrátí do seznamu trezoru. Jejich původní sdílení se nevrátí, proto je podle potřeby sdílejte znovu.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tímto se smažou tajemství včetně příloh a historie verzí. Tuto akci nelze vrátit.", + "Delete for good": "Trvale smazat", + "Trash": "Koš", + "The trash is empty": "Koš je prázdný", + "No archived secrets": "Žádná archivovaná tajemství", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Smazaná tajemství tu čekají do konce doby uchování, pak se trvale smažou.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivujte tajemství v jeho panelu podrobností, aby nebylo v seznamu trezoru, ve vyhledávání ani v automatickém vyplňování.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity pro šifrované přílohy (vynucované na serveru v uložených šifrovaných bajtech), uchovávání historie verzí a jak dlouho zůstávají smazaná tajemství v koši.", + "Days a deleted secret stays in the trash (1 to 365)": "Počet dní, kdy smazané tajemství zůstává v koši (1 až 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tímto se tajemství přesune do koše a jeho sdílení hned skončí. Z koše ho můžete obnovit do konce doby uchování: 30 dní, pokud to správce nezměnil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tímto se tajemství přesunou do koše ({count}) a jejich sdílení hned skončí. Z koše je můžete obnovit do konce doby uchování.", + "Remove {name} from favourites": "Odebrat {name} z oblíbených", + "Add {name} to favourites": "Přidat {name} do oblíbených", + "Could not change the favourite": "Oblíbenou položku nelze změnit", + "Remove from favourites": "Odebrat z oblíbených", + "Add to favourites": "Přidat do oblíbených", + "Tags": "Štítky", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Štítky nejsou šifrované. Správci serveru je mohou číst, stejně jako názvy složek.", + "Favourites": "Oblíbené", + "Filter by tag": "Filtrovat podle štítku", + "All tags": "Všechny štítky", + "Last used": "Naposledy použito", + "Tags for {count} secrets": "Štítky pro {count} tajemství", + "Tag": "Štítek", + "Remove tag": "Odebrat štítek", + "Add tag": "Přidat štítek", + "Could not change the tags. Try again.": "Štítky nelze změnit. Zkuste to znovu.", + "Could not approve the application. It is still in the queue.": "Žádost se nepodařilo schválit. Stále je ve frontě.", + "Could not reject the application. It is still in the queue.": "Žádost se nepodařilo zamítnout. Stále je ve frontě.", + "Removed the user from {count} team folders.": "Uživatel byl odebrán z {count} týmových složek.", + "Approve a share": "Schválit sdílení", + "This approval link is incomplete. Open it again from the notification.": "Tento odkaz pro schválení je neúplný. Otevřete ho znovu z oznámení.", + "Deny": "Zamítnout", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se připojil(a) ke skupině, se kterou sdílíte tajemství. Sdílet tajemství i s touto osobou?", + "{requester} asks you to share a secret with {user}.": "{requester} vás žádá o sdílení tajemství s {user}.", + "Shared. The recipient can now open the secret.": "Sdíleno. Příjemce nyní může tajemství otevřít.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Příjemce si ještě nenastavil Keepiq, proto nebylo nic sdíleno. Zkuste to znovu, až to udělá.", + "Could not share the secret. Only its owner can approve this.": "Tajemství se nepodařilo sdílet. Schválit to může pouze jeho vlastník.", + "Could not share the secret. Try again.": "Tajemství se nepodařilo sdílet. Zkuste to znovu.", + "Denied. Nothing was shared.": "Zamítnuto. Nic nebylo sdíleno.", + "Could not deny the request. Try again.": "Požadavek se nepodařilo zamítnout. Zkuste to znovu.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vás žádá o sdílení tajemství \"%2$s\" s %3$s.", + "Expires on (optional)": "Platnost do (volitelné)", + "Hand over to": "Předat", + "Choose a recipient": "Vyberte příjemce", + "Hand over temporarily": "Dočasně předat", + "Expiry rules": "Pravidla vypršení platnosti", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavte, jak dlouho mohou platit hesla jednoho typu položky nebo v jedné složce a kdy dostanete připomenutí. Pokud platí více dat, rozhoduje nejdřívější.", + "Delete rule": "Smazat pravidlo", + "Set by your administrator": "Nastaveno vaším správcem", + "No expiry rules yet.": "Zatím žádná pravidla vypršení platnosti.", + "Applies to": "Platí pro", + "Item type": "Typ položky", + "Maximum age in days (empty for reminders only)": "Maximální stáří ve dnech (prázdné jen pro připomenutí)", + "Remind me this many days before, comma separated": "Připomenout tolik dní předem, oddělte čárkami", + "Save rule": "Uložit pravidlo", + "An item type": "Typ položky", + "A folder": "Složka", + "Folder {name}": "Složka {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Vyprší po {days} dnech", + "Reminders {days} days before": "Připomenutí {days} dní předem", + "Could not save the expiry rule.": "Pravidlo vypršení platnosti se nepodařilo uložit.", + "Could not delete the expiry rule.": "Pravidlo vypršení platnosti se nepodařilo smazat.", + "All statuses": "Všechny stavy", + "Compromised": "Kompromitovaná", + "Could not load the members.": "Členy se nepodařilo načíst.", + "Emergency contact": "Nouzový kontakt", + "Leaving user": "Odcházející uživatel", + "No": "Ne", + "No users match this filter.": "Tomuto filtru neodpovídá žádný uživatel.", + "Not set up": "Nenastaveno", + "Revoke suite": "Odvolat sadu", + "Revoked": "Odvolaná", + "Search users": "Hledat uživatele", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Podívejte se, kteří uživatelé si nastavili trezor. Spusťte offboarding nebo odvolejte sadu z řádku.", + "Successor": "Nástupce", + "Team folders": "Týmové složky", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Uživatel je stále ve skupině {groups}, která je členem týmové složky. Odeberte ho ze skupiny nebo zakažte účet.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet.", + "Vault status": "Stav trezoru", + "Yes": "Ano", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Export do CXF NENÍ ZAŠIFROVANÝ. Každé heslo a přihlašovací jméno bude ve stažené souboru čitelné jako otevřený text. Uložte jej bezpečně a hned po použití smažte.", + "Root certificate expiring soon": "Kořenový certifikát brzy vyprší", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Kořenový certifikát trezoru vyprší za %1$d dní. Obnovte ho předtím. Obnovení znovu podepíše každou šifrovací sadu.", + "Compromise recovery aborted": "Obnova po kompromitaci přerušena", + "Key rotation ended by a compromise revoke": "Rotace klíče ukončena odvoláním kvůli kompromitaci", + "Encryption suite revoke refused": "Odvolání šifrovací sady zamítnuto", + "Master password proof refused": "Důkaz hlavního hesla zamítnut", + "Your current master password": "Vaše současné hlavní heslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nouzový kontakt měl nevyřízenou žádost o přístup, když ho rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tyto nouzové kontakty nebyly převedeny na váš nový klíč. Jejich nouzový přístup byl odebrán. Pokud je stále chcete, přidejte je znovu v Nouzovém přístupu.", + "Renew root certificate": "Obnovit kořenový certifikát", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Vytvoří se nový kořenový a zprostředkující certifikát. Každá aktivní šifrovací sada se znovu podepíše. Tuto akci nelze vrátit.", + "Renew root": "Obnovit kořen", + "Root renewed. {n} encryption suites signed again.": "Kořen obnoven. Znovu podepsaných šifrovacích sad: {n}.", + "Could not renew the root certificate.": "Kořenový certifikát se nepodařilo obnovit.", + "Lease policy for this application": "Zásady zápůjček pro tuto aplikaci", + "In force now: {default} seconds by default, {max} seconds at most.": "Nyní platí: výchozí {default} sekund, nejvýše {max} sekund.", + "Leases are not renewable": "Zápůjčky nelze obnovovat", + "Lease policy saved.": "Zásady zápůjček uloženy.", + "Leave a field empty to use the instance value.": "Ponechte pole prázdné pro použití hodnoty instance.", + "Instance value: {value}": "Hodnota instance: {value}", + "Renewal": "Obnovení", + "Use the instance value ({value})": "Použít hodnotu instance ({value})", + "Allowed": "Povoleno", + "Not allowed": "Nepovoleno", + "Save lease policy": "Uložit zásady zápůjček", + "Only an administrator can change this policy.": "Tyto zásady může změnit pouze správce.", + "Could not save the lease policy.": "Zásady zápůjček se nepodařilo uložit.", + "{member} got access from {confirmer}.": "{member} získal přístup od {confirmer}.", + "Automatically confirm new team folder members": "Automaticky potvrzovat nové členy týmových složek", + "Gave %n new member access to a team folder.": "%n nový člen získal přístup k týmové složce.", + "Gave %n new members access to a team folder.": "Noví členové (%n) získali přístup k týmové složce.", + "Give new team folder members access without waiting for the folder owner.": "Dejte novým členům přístup bez čekání na vlastníka složky.", + "New team folder members": "Noví členové týmových složek", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlastník nebo člen s právem zápisu je potvrdí ze svého otevřeného trezoru. Keepiq nikdy nedešifruje na serveru.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeká se, až člen s právem zápisu otevře Keepiq. Můžete také sdílet hned.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Část reakce na kompromitaci selhala ({failed} krok(ů)). Zkontrolujte protokol serveru a poté sadu znovu odvolejte, abyste ji dokončili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tím byla odvolána také sada {suite} a ukončena migrace klíčů {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Odvolání druhé sady smazalo %n kontakt nouzového přístupu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Odvolání druhé sady smazalo %n kontaktů nouzového přístupu.", + "A suite revoked as compromised cannot be reinstated.": "Sadu odvolanou jako kompromitovanou nelze obnovit.", + "Archives to keep": "Archivy k uchování", + "Back up every vault automatically": "Automaticky zálohovat každý trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Zálohujte každý trezor podle plánu. Archivy obsahují jen šifrovaný text a obnovují se přes occ.", + "Back up now": "Zálohovat nyní", + "Backup public key (PEM, optional)": "Veřejný klíč zálohy (PEM, volitelný)", + "Backup requested for the next cron run": "Záloha vyžádána pro příští běh cronu", + "Encrypted": "Šifrováno", + "Every (hours)": "Každých (hodin)", + "Last backup {when} failed: {error}": "Poslední záloha {when} selhala: {error}", + "Last backup {when} succeeded.": "Poslední záloha {when} proběhla.", + "No archives yet.": "Zatím žádné archivy.", + "Size": "Velikost", + "Vault backups": "Zálohy trezoru", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S klíčem se každý archiv šifruje pro něj. Soukromý klíč uchovávejte mimo tento server: potřebujete ho k ověření nebo obnovení.", + "Written": "Zapsáno", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n uživatel v rozsahu zatím nemá dvoufázové přihlášení a nemůže otevřít trezor, dokud je toto zapnuté.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Záložní kódy se nepočítají. Pokud se vaši uživatelé přihlašují přes poskytovatele identity s vlastním druhým faktorem, jejich skupiny vynechte.", + "Block personal vault export": "Blokovat export osobního trezoru", + "Keep work logins in team folders": "Uchovávat pracovní přihlašovací údaje v týmových složkách", + "Move to a team folder": "Přesunout do týmové složky", + "Not in a team folder": "Není v týmové složce", + "Only for these groups (empty is everyone)": "Jen pro tyto skupiny (prázdné znamená všechny)", + "Require two-factor login before the vault opens": "Vyžadovat dvoufázové přihlášení před otevřením trezoru", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravidla pro každý trezor. Každé platí pro všechny, nebo jen pro skupiny, které zvolíte.", + "Secret types that belong in a team folder": "Typy tajemství, které patří do týmové složky", + "Set up two-factor login": "Nastavit dvoufázové přihlášení", + "Team folder you can write to": "Týmová složka, do které můžete zapisovat", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Uživatelé nemohou stáhnout zálohu, CSV ani přenosový soubor. Jejich balíček osobních údajů zůstává dostupný.", + "Users cannot save these secret types in a personal folder.": "Uživatelé nemohou ukládat tyto typy tajemství do osobní složky.", + "Vault policies": "Zásady trezoru", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaše organizace nepovoluje export vašeho osobního trezoru. Váš balíček osobních údajů v nastavení zůstává dostupný.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaše organizace uchovává tato tajemství v týmové složce. Přesuňte každé do týmové složky.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaše organizace uchovává tento typ tajemství v týmové složce. Vyberte jednu ze svých týmových složek nebo takovou, do které můžete zapisovat.", + "Your organisation requires two-factor login before you can open your vault.": "Vaše organizace vyžaduje dvoufázové přihlášení, než budete moci otevřít svůj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Uživatelé volí, jak dlouho zůstane rozšíření při nečinnosti odemčené. Vy nastavujete nejdelší dobu, kterou mohou zvolit.", + "Longest idle time before the extension locks": "Nejdelší doba nečinnosti před uzamčením rozšíření", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 hodina", + "4 hours": "4 hodiny", + "Connector": "Konektor", + "Directory (tenant) ID": "ID adresáře (tenanta)", + "Application (client) ID": "ID aplikace (klienta)", + "Data collection rule immutable ID": "Neměnné ID pravidla shromažďování dat", + "Stream name": "Název streamu", + "Splunk index (optional)": "Index Splunk (volitelné)", + "Sourcetype (optional)": "Sourcetype (volitelné)", + "Leave blank to keep the current one": "Ponechte prázdné pro zachování současné hodnoty", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF přes syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Koncový bod shromažďování dat (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectoru (https)", + "Client secret (write-only)": "Tajný klíč klienta (pouze zápis)", + "HEC token (write-only)": "Token HEC (pouze zápis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Přeposílejte povolené auditní události do Splunk, Microsoft Sentinel, přijímače syslog nebo webhooku. Zprávy obsahují pouze očištěná metadata: žádná tajná hodnota, jméno, přihlašovací jméno ani šifrovaný text nikdy neopustí server.", + "%n change waiting to sync": "%n změna čeká na synchronizaci", + "%n changes waiting to sync": "%n změn čeká na synchronizaci", + "Changes that could not sync": "Změny, které se nepodařilo synchronizovat", + "Choose a version": "Zvolit verzi", + "Copy value": "Kopírovat hodnotu", + "Deleted": "Smazáno", + "Discard": "Zahodit", + "Keep my offline change": "Ponechat moji offline změnu", + "Keep the server version": "Ponechat verzi ze serveru", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je offline jen pro čtení. Správce nezapnul úpravy offline.", + "Let users edit secrets offline": "Povolit uživatelům upravovat tajemství offline", + "Not synced yet": "Zatím nesynchronizováno", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline změny zůstávají v zařízení, šifrované pro uživatele, a synchronizují se při dalším odemčení online. Sdílení, složky a přílohy stále potřebují připojení.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Úpravy, přesuny a smazání zůstávají v tomto zařízení a synchronizují se, až budete znovu online. Sdílení a přílohy potřebují připojení.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vaše změny zůstávají v tomto zařízení a synchronizují se, až budete znovu online. Naposledy synchronizováno {when}.", + "Open my changes": "Otevřít moje změny", + "Sharing needs a connection": "Sdílení potřebuje připojení", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Někdo změnil toto tajemství na serveru poté, co vznikla vaše offline kopie. Zvolte, kterou verzi ponechat.", + "Sync or discard your offline changes before you rotate your keys.": "Před výměnou klíčů synchronizujte nebo zahoďte offline změny.", + "That password did not open your changes.": "Toto heslo neotevřelo vaše změny.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline snímek ukládá šifrovaná tajemství (otevřít je lze jen klíčem odvozeným z hlavního hesla uživatele, přesně jako na serveru) a šifruje názvy, URL a názvy složek v úložišti. Offline přístup je jen pro čtení, pokud níže nepovolíte úpravy offline. Vypněte to pro zařízení, která nikdy nesmí ukládat přihlašovací údaje; vypnutí vymaže stávající mezipaměti při příštím načtení.", + "The previous vault copy is gone, so these changes cannot be opened.": "Předchozí kopie trezoru zmizela, takže tyto změny nelze otevřít.", + "The server version": "Verze ze serveru", + "This secret changed while you were offline": "Toto tajemství se změnilo, když jste byli offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Toto tajemství jste offline smazali, ale mezitím bylo na serveru změněno. Zvolte, kterou verzi ponechat.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaše klíče byly změněny na jiném zařízení. Zadejte předchozí hlavní heslo pro synchronizaci offline změn, nebo je zahoďte.", + "Your offline change": "Vaše offline změna", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n nouzový kontakt měl nevyřízenou žádost o přístup, když ho rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.","Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.","Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n položku nelze reprezentovat ve formátu CXF a bude přeskočena.","%n položek nelze reprezentovat ve formátu CXF a budou přeskočeny.","%n položek nelze reprezentovat ve formátu CXF a budou přeskočeny."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n starší verze byla zahozena, protože přenést lze jen nedávnou historii.","%n starších verzí bylo zahozeno, protože přenést lze jen nedávnou historii.","%n starších verzí bylo zahozeno, protože přenést lze jen nedávnou historii."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopii tajemství je stále potřeba zašifrovat a nasdílet.","%n kopií tajemství je stále potřeba zašifrovat a nasdílet.","%n kopií tajemství je stále potřeba zašifrovat a nasdílet."], + "_%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 tajemství nebylo možné dešifrovat a není v tomto exportu.","%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu.","%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebylo migrováno.","%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebyla migrována.","%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebyla migrována."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n tajemství nebylo migrováno.","%n tajemství nebylo migrováno.","%n tajemství nebylo migrováno."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n tajemství je stále zašifrováno vaším předchozím klíčem.","%n tajemství je stále zašifrováno vaším předchozím klíčem.","%n tajemství je stále zašifrováno vaším předchozím klíčem."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu.","%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu.","%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu."], + "_%n secret_::_%n secrets_": ["%n tajemství","%n tajemství","%n tajemství"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n uživatel v rozsahu zatím nemá dvoufázové přihlášení a nemůže otevřít trezor, dokud je toto zapnuté.","Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté.","Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Přesto dokončit a ztratit přístup k %n tajemství","Přesto dokončit a ztratit přístup k %n tajemstvím","Přesto dokončit a ztratit přístup k %n tajemstvím"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nový člen získal přístup k týmové složce.","Noví členové (%n) získali přístup k týmové složce.","Noví členové (%n) získali přístup k týmové složce."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem.","Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem.","Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Odvolání druhé sady smazalo %n kontakt nouzového přístupu.","Odvolání druhé sady smazalo %n kontaktů nouzového přístupu.","Odvolání druhé sady smazalo %n kontaktů nouzového přístupu."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Odvolání této sady odstranilo %n kontakt nouzového přístupu.","Odvolání této sady odstranilo %n kontaktů nouzového přístupu.","Odvolání této sady odstranilo %n kontaktů nouzového přístupu."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["nalezeno v únicích %n krát","nalezeno v únicích %n krát","nalezeno v únicích %n krát"], + "_shared with %n secret_::_shared with %n secrets_": ["sdíleno s %n tajemstvím","sdíleno s %n tajemstvími","sdíleno s %n tajemstvími"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Tato složka obsahuje přímo %n tajemství.","Tato složka obsahuje přímo %n tajemství.","Tato složka obsahuje přímo %n tajemství."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n změna čeká na synchronizaci","%n změn čeká na synchronizaci","%n změn čeká na synchronizaci"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Uživatel je stále ve skupině {groups}, která je členem týmové složky. Odeberte ho ze skupiny nebo zakažte účet.","Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet.","Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet."], + "Allow approval from another device": "Povolit schválení z jiného zařízení", + "App": "Aplikace", + "Approve a new device": "Schválit nové zařízení", + "Approve from another device": "Schválit z jiného zařízení", + "Asked at": "Vyžádáno v", + "Check that the new device shows these words:": "Zkontrolujte, že nové zařízení zobrazuje tato slova:", + "Denied. If you did not ask, end your other sessions:": "Zamítnuto. Pokud jste o to nežádali, ukončete své ostatní relace:", + "Device": "Zařízení", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Umožnit uživatelům odemknout nový prohlížeč jeho schválením ze zařízení, kde je Keepiq už odemčený.", + "New device approval": "Schvalování nových zařízení", + "Nextcloud security settings": "Nastavení zabezpečení Nextcloud", + "Only approve a device you are using right now.": "Schvalujte jen zařízení, které právě používáte.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otevřete Keepiq na zařízení, kde je odemčený, a schvalte toto zařízení. Zkontrolujte, že zobrazuje stejná slova:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Schvalující zařízení zapečetí odemykací klíč pro nové zařízení. Server ho jen předá dál a nemůže ho otevřít.", + "The master password is not right, or the request has ended.": "Hlavní heslo není správné, nebo žádost skončila.", + "The request expired. Ask again or use your master password.": "Platnost žádosti vypršela. Požádejte znovu nebo použijte hlavní heslo.", + "The request was denied.": "Žádost byla zamítnuta.", + "Too many requests. Try again in an hour or use your master password.": "Příliš mnoho žádostí. Zkuste to znovu za hodinu nebo použijte hlavní heslo.", + "Unknown device": "Neznámé zařízení", + "Web app": "Webová aplikace", + "A device": "Zařízení", + "A new device asks to open your vault": "Nové zařízení žádá o otevření vašeho trezoru", + "%s asks to be approved. Only approve a device you are using right now.": "%s žádá o schválení. Schvalujte jen zařízení, které právě používáte.", + "Access ends on (optional)": "Přístup končí (volitelné)", + "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.": "Aplikace Keepiq heslo nezobrazí ani nezkopírují. Někdo s technickými znalostmi jej přesto může přečíst ze svého zařízení. Až přístup skončí, heslo změňte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Toto tajemství je pouze k použití. Přihlaste se přes rozšíření prohlížeče Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Pouze použití", + "Use only (can sign in, cannot view or copy)": "Pouze použití (může se přihlásit, nemůže zobrazit ani zkopírovat)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S tímto přihlášením se můžete přihlásit přes rozšíření prohlížeče Keepiq. Vlastník se rozhodl, že ho nemůžete zobrazit ani zkopírovat.", + "Your access ends on {date}": "Váš přístup končí {date}", + "Your access to this secret has ended": "Váš přístup k tomuto tajemství skončil", + "Your access to \"%s\" ends tomorrow": "Váš přístup k „%s“ končí zítra", + "Your access to \"%s\" has ended": "Váš přístup k „%s“ skončil", + "%1$s no longer has access to \"%2$s\"": "%1$s už nemá přístup k „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mohl(a) vidět toto heslo. Změňte ho, pokud by ho %1$s už neměl(a) znát.", + "%s could not view this password in Keepiq.": "%s nemohl(a) toto heslo v Keepiq zobrazit.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} schválení", + "a recovery officer": "pověřenec pro obnovu", + "Account recovery": "Obnova účtu", + "Approvals needed": "Potřebná schválení", + "Ask {user} which words they see, by phone or in person. They must be:": "Zeptejte se uživatele {user}, jaká slova vidí, telefonicky nebo osobně. Musí to být:", + "Check again": "Zkontrolovat znovu", + "Create the recovery key": "Vytvořit klíč pro obnovu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Vytvořte klíč pro obnovu. Váš prohlížeč ho vytvoří a každému pověřenci dá kopii, kterou otevře jen on.", + "Decline": "Odmítnout", + "Enrol in account recovery": "Přihlásit se k obnově účtu", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Přihlaste se, aby vám organizace mohla pomoci získat trezor zpět, pokud zapomenete hlavní heslo.", + "Every user is enrolled": "Všichni uživatelé jsou přihlášeni", + "Finish the recovery in the browser you asked from.": "Dokončete obnovu v prohlížeči, ze kterého jste žádali.", + "Forgot your master password?": "Zapomněli jste hlavní heslo?", + "Hand the key over": "Předat klíč", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Umožněte uživatelům, kteří zapomněli hlavní heslo, získat trezor zpět se schválením pověřenců pro obnovu, které určíte.", + "New master password": "Nové hlavní heslo", + "No one is asking to recover their account.": "Nikdo nežádá o obnovu účtu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Klíč pro obnovu zatím neexistuje. Jeden z pověřenců ho vytvoří ve svém nastavení Keepiq.", + "Off": "Vypnuto", + "Officer {user} has no encryption set up yet.": "Pověřenec {user} ještě nemá nastavené šifrování.", + "Officers (user IDs, separated by commas)": "Pověřenci (ID uživatelů oddělená čárkami)", + "Policy": "Zásady", + "Publish this fingerprint internally, so users can check it before they enrol.": "Zveřejněte tento otisk interně, aby si ho uživatelé mohli ověřit před přihlášením.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnoveno s pomocí {officer}. Nyní změňte klíč trezoru v Nastavení, Zabezpečení: \"Moje hlavní heslo bylo prozrazeno\".", + "Recovery key fingerprint: {fingerprint}": "Otisk klíče pro obnovu: {fingerprint}", + "Recovery officer": "Pověřenec pro obnovu", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odebraní pověřenci nyní přijdou o svou kopii, ale mohli ji dříve otevřít. Nechte pověřence vytvořit nový klíč pro obnovu.", + "Repeat the new master password": "Zopakujte nové hlavní heslo", + "Retire this recovery key": "Vyřadit tento klíč pro obnovu", + "Set the new master password": "Nastavit nové hlavní heslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikát pro obnovu nevydal tento Keepiq. Nepřihlašujte se a informujte správce.", + "The words match, approve": "Slova se shodují, schválit", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tento uživatel je přihlášen k obnově účtu. Obnova zachová jeho tajemství; odvolání smaže jeho přihlášení.", + "Users may enrol": "Uživatelé se mohou přihlásit", + "Withdraw from account recovery": "Odhlásit se z obnovy účtu", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jste přihlášeni k obnově účtu. Otisk klíče pro obnovu: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jste přihlášeni. Pokud zapomenete hlavní heslo, organizace vám může pomoci získat trezor zpět.", + "Your key is back. Choose a new master password.": "Klíč je zpět. Zvolte nové hlavní heslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši pověřenci pro obnovu byli informováni. Přečtěte jim tato slova, až vám zavolají nebo se s vámi setkají:", + "You are now an account recovery officer": "Nyní jste pověřencem pro obnovu účtů", + "%s asks to recover their account. Compare the words with them before you approve.": "%s žádá o obnovu účtu. Před schválením s ním porovnejte slova.", + "A user": "Uživatel", + "Your account recovery request was declined": "Vaše žádost o obnovu účtu byla zamítnuta", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnova účtu je připravena. Otevřete Keepiq v prohlížeči, ze kterého jste žádali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} žádá o jednorázové odemčení nového zařízení. Hlavní heslo zůstává stejné.", + "Ask your organisation instead": "Raději požádat svou organizaci", + "The request ended. Ask again or use your master password.": "Žádost skončila. Požádejte znovu nebo použijte hlavní heslo.", + "Added by {user}": "Přidal(a) {user}", + "Editor": "Editor", + "Manager": "Správce", + "Role of {member}": "Role uživatele {member}", + "Team folders you manage": "Týmové složky, které spravujete", + "Viewer": "Čtenář", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemáte kopii těchto tajemství, takže je noví členové zatím nedostali. Vlastník je může sdílet: {names}", + "Admin areas": "Oblasti správy", + "Give a group only the parts of Keepiq administration it needs.": "Dejte skupině jen ty části správy Keepiq, které potřebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegujte jednu nebo více oblastí na skupinu na stránce oprávnění ke správě. Správci instance mají každou oblast.", + "Open administration privileges": "Otevřít oprávnění ke správě", + "Policies": "Zásady", + "Applications and machine access": "Aplikace a přístup strojů", + "People and offboarding": "Lidé a odchody", + "Audit and compliance": "Audit a shoda", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verze, certifikační autorita, přílohy, offline mezipaměť, kontrola úniků, typy tajemství a zálohy", + "master password, organisation password, vault policies, rotation, version history and trash": "hlavní heslo, heslo organizace, zásady trezoru, rotace, historie verzí a koš", + "application queue, application requests and machine leases": "fronta aplikací, požadavky aplikací a pronájmy strojů", + "team offboarding, encryption suites and admin handover": "odchody z týmu, šifrovací sady a převzetí správcem", + "audit log, compliance reports, SIEM export and honey alerts": "auditní protokol, zprávy o shodě, export SIEM a návnadová upozornění", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kolik verzí tajemství se uchovává, jak dlouho a jak dlouho zůstávají smazaná tajemství v koši.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity šifrovaných příloh, vynucované na serveru v uložených šifrovaných bajtech.", + "Type the suite ID again to confirm": "Pro potvrzení zadejte ID sady znovu", + "This does not match the suite ID.": "Neshoduje se s ID sady.", + "Confirm with your master password": "Potvrďte hlavním heslem", + "Confirm": "Potvrdit", + "That master password is not right.": "Toto hlavní heslo není správné.", + "You are sharing with someone new. Enter your master password to confirm.": "Sdílíte s někým novým. Pro potvrzení zadejte hlavní heslo.", + "Enter your master password to confirm this share.": "Pro potvrzení tohoto sdílení zadejte hlavní heslo.", + "Enter your master password to confirm this delegation.": "Pro potvrzení tohoto delegování zadejte hlavní heslo.", + "Approve {member}": "Schválit {member}", + "Recipient": "Příjemce", + "No vault yet": "Zatím nemá trezor", + "No matching users": "Žádní odpovídající uživatelé", + "Partner organisations": "Partnerské organizace", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vyměňujte tajemství s jiným Keepiq. Oba správci se navzájem přidají a před uložením porovnají kořenové otisky po telefonu nebo osobně.", + "Federation needs Nextcloud 33 or later.": "Federace vyžaduje Nextcloud 33 nebo novější.", + "Your root fingerprint": "Váš kořenový otisk", + "No partners yet.": "Zatím žádní partneři.", + "Users here may share to this partner": "Uživatelé zde mohou sdílet s tímto partnerem", + "This partner may share to users here": "Tento partner může sdílet s uživateli zde", + "Partner address": "Adresa partnera", + "Check partner": "Ověřit partnera", + "Partner root fingerprint": "Kořenový otisk partnera", + "I compared this fingerprint with the partner's administrator": "Porovnal jsem tento otisk se správcem partnera", + "Add partner": "Přidat partnera", + "A secret from another organisation": "Tajemství z jiné organizace", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Uživatel %1$s s vámi sdílí \"%2$s\". Přijměte ho v sekci Příchozí z jiných organizací.", + "Incoming from other organisations": "Příchozí z jiných organizací", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Lidé v partnerských organizacích s vámi mohou sdílet tajemství. Přijměte ho, abyste si ve svém trezoru ponechali kopii jen pro čtení.", + "Nothing shared with you yet": "Zatím s vámi nebylo nic sdíleno", + "Secrets that people in partner organisations share with you appear here.": "Zde se zobrazí tajemství, která s vámi sdílejí lidé v partnerských organizacích.", + "From {sender}": "Od {sender}", + "Accept": "Přijmout", + "Open in vault": "Otevřít v trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druhá organizace tajemství nepředala. Zkuste to později znovu.", + "Set up your vault before you accept a shared secret.": "Než přijmete sdílené tajemství, nastavte si trezor.", + "Something went wrong. Try again.": "Něco se pokazilo. Zkuste to znovu.", + "Waiting for your answer": "Čeká na vaši odpověď", + "In your vault, read-only": "Ve vašem trezoru, jen pro čtení", + "Withdrawn by the sender": "Staženo odesílatelem", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} toto sdílí z jiné organizace. Můžete to číst, ale nemůžete to měnit ani sdílet.", + "Someone": "Někdo", + "Share with someone at another organisation": "Sdílet s někým z jiné organizace", + "Their account at the other organisation": "Účet dané osoby v druhé organizaci", + "Check account": "Zkontrolovat účet", + "Certificate fingerprint of {account}": "Otisk certifikátu účtu {account}", + "Compare it with them by phone if you want to be sure.": "Pokud si chcete být jisti, porovnejte ho s danou osobou po telefonu.", + "Shared. {account} can accept it in their own vault.": "Sdíleno. {account} to může přijmout ve svém vlastním trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikát se nepodařilo ověřit. Nic nebylo sdíleno.", + "That organisation is not one of your partners.": "Tato organizace není mezi vašimi partnery.", + "No one with that account can receive secrets from you.": "Nikdo s tímto účtem od vás nemůže přijímat tajemství.", + "The other organisation did not answer. Try again later.": "Druhá organizace neodpověděla. Zkuste to později znovu.", + "This secret is already shared with that account.": "Toto tajemství je s tímto účtem již sdíleno.", + "Other organisations": "Jiné organizace", + "Receive secrets from other organisations": "Přijímat tajemství z jiných organizací", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Lidé v partnerských organizacích pak mohou najít váš účet a sdílet s vámi tajemství. Každé z nich přijímáte sami.", + "Shared": "Sdíleno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pozastaveno: změnil se jejich certifikát nebo partnerství. Sdílení můžete odvolat nebo sdílet znovu.", + "Their organisation did not get the last change. Revoke it or share again.": "Jejich organizace nedostala poslední změnu. Sdílení můžete odvolat nebo sdílet znovu.", + "Being withdrawn": "Odvolává se", + "Shared with another organisation": "Sdíleno s jinou organizací", + "Change sent to another organisation": "Změna odeslána jiné organizaci", + "Share with another organisation revoked": "Sdílení s jinou organizací odvoláno", + "Share with another organisation paused": "Sdílení s jinou organizací pozastaveno", + "Another organisation did not get a change": "Jiná organizace nedostala změnu", + "Secret received from another organisation": "Tajemství přijato z jiné organizace", + "Secret from another organisation accepted": "Tajemství z jiné organizace přijato", + "Secret from another organisation declined": "Tajemství z jiné organizace odmítnuto", + "Copy from another organisation updated": "Kopie z jiné organizace aktualizována", + "Copy from another organisation removed": "Kopie z jiné organizace odstraněna", + "Declined: they removed their copy. Share again if they need it.": "Odmítnuto: příjemce odstranil svou kopii. Pokud ji potřebuje, sdílejte znovu.", + "Recipient at another organisation removed their copy": "Příjemce z jiné organizace odstranil svou kopii", + "Removed the user from %n team folder.": "Uživatel byl odebrán z %n týmové složky.", + "Removed the user from %n team folders.": "Uživatel byl odebrán z %n týmových složek.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Uživatel byl odebrán z %n týmové složky.","Uživatel byl odebrán z %n týmových složek.","Uživatel byl odebrán z %n týmových složek."], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovená kopie pochází ze sdílení, které skončilo. Zůstává jen pro čtení.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizaci, která sdílela obnovenou kopii, se nepodařilo zastihnout. Kopie zůstává jen pro čtení a nesleduje jejich změny.", + "Recipient at another organisation restored their copy": "Příjemce z jiné organizace obnovil svou kopii" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1 ? 0 : (n>=2 && n<=4) ? 1 : 2);" ) diff --git a/l10n/cs.json b/l10n/cs.json index 2102e8a3a..9979657e4 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Sdíleno se skupinami", + "Not shared with any group yet.": "Zatím nesdíleno s žádnou skupinou.", + "Revoke the share with {group}": "Zrušit sdílení se skupinou {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Sdíleno se skupinou {group}: {received} členů to obdrželo, {skipped} ne, protože ještě nemají nastavené šifrování.", + "Search groups": "Hledat skupiny", + "Failed to share": "Sdílení se nezdařilo", + "Columns": "Sloupce", + "Column {number}": "Sloupec {number}", + "Map one column to Name. Every secret needs a name.": "Přiřaďte jeden sloupec k názvu. Každé tajemství potřebuje název.", + "Notes": "Poznámky", + "Do not import": "Neimportovat", + "Hide this value": "Skrýt tuto hodnotu", + "Show this value": "Zobrazit tuto hodnotu", + "Defaults": "Výchozí", + "New secrets start as this type, and your secret list opens in this view.": "Nová tajemství začínají tímto typem a seznam tajemství se otevře v tomto zobrazení.", + "Default item type": "Výchozí typ položky", + "Cards": "Karty", + "Table": "Tabulka", + "Could not save your default": "Výchozí hodnotu se nepodařilo uložit", + "Recently used": "Nedávno použité", + "Opened": "Otevřeno", + "You have not opened any secrets yet": "Zatím jste neotevřeli žádné tajemství", + "Could not delete the item type.": "Typ položky se nepodařilo smazat.", + "Could not load the item types.": "Typy položek se nepodařilo načíst.", + "Could not save the item type.": "Typ položky se nepodařilo uložit.", + "Delete item type": "Smazat typ položky", + "Edit item type": "Upravit typ položky", + "Fields": "Pole", + "Fields: {count}": "Pole: {count}", + "Hidden": "Skryté", + "Item types": "Typy položek", + "Move up": "Posunout nahoru", + "New item type": "Nový typ položky", + "No item types defined yet.": "Zatím nejsou definovány žádné typy položek.", + "Required": "Povinné", + "Text": "Text", + "This field is required": "Toto pole je povinné", + "Web address": "Webová adresa", + "{label} (required)": "{label} (povinné)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Smazat „{name}“? Tajemství tohoto typu zůstanou čitelná a stanou se položkami Přihlášení.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy položek, které zde definujete, se všem zobrazí v dialogu Nové tajemství se zvolenými poli.", + "Secret moved to the trash": "Tajemství přesunuto do koše", + "Secret restored from the trash": "Tajemství obnoveno z koše", + "Secret deleted for good": "Tajemství trvale smazáno", + "Secret archived": "Tajemství archivováno", + "Secret unarchived": "Tajemství vráceno z archivu", + "Unarchive": "Vrátit z archivu", + "Could not archive the secret": "Tajemství se nepodařilo archivovat", + "Could not unarchive the secret": "Tajemství se nepodařilo vrátit z archivu", + "Archive {count} secrets": "Archivovat tajemství: {count}", + "Unarchive {count} secrets": "Vrátit z archivu tajemství: {count}", + "Restore {count} secrets": "Obnovit tajemství: {count}", + "Delete {count} secrets for good": "Trvale smazat tajemství: {count}", + "Done for {ok} of {total} secrets": "Hotovo pro {ok} z {total} tajemství", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivovaná tajemství zmizí ze seznamu trezoru, z vyhledávání, automatického vyplňování a zprávy o stavu. Zůstanou sdílená. Najdete je v Archivu.", + "These secrets come back to the vault list, search and autofill.": "Tato tajemství se vrátí do seznamu trezoru, do vyhledávání a automatického vyplňování.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tato tajemství se vrátí do seznamu trezoru. Jejich původní sdílení se nevrátí, proto je podle potřeby sdílejte znovu.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tímto se smažou tajemství včetně příloh a historie verzí. Tuto akci nelze vrátit.", + "Delete for good": "Trvale smazat", + "Trash": "Koš", + "The trash is empty": "Koš je prázdný", + "No archived secrets": "Žádná archivovaná tajemství", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Smazaná tajemství tu čekají do konce doby uchování, pak se trvale smažou.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivujte tajemství v jeho panelu podrobností, aby nebylo v seznamu trezoru, ve vyhledávání ani v automatickém vyplňování.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity pro šifrované přílohy (vynucované na serveru v uložených šifrovaných bajtech), uchovávání historie verzí a jak dlouho zůstávají smazaná tajemství v koši.", + "Days a deleted secret stays in the trash (1 to 365)": "Počet dní, kdy smazané tajemství zůstává v koši (1 až 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tímto se tajemství přesune do koše a jeho sdílení hned skončí. Z koše ho můžete obnovit do konce doby uchování: 30 dní, pokud to správce nezměnil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tímto se tajemství přesunou do koše ({count}) a jejich sdílení hned skončí. Z koše je můžete obnovit do konce doby uchování.", + "Remove {name} from favourites": "Odebrat {name} z oblíbených", + "Add {name} to favourites": "Přidat {name} do oblíbených", + "Could not change the favourite": "Oblíbenou položku nelze změnit", + "Remove from favourites": "Odebrat z oblíbených", + "Add to favourites": "Přidat do oblíbených", + "Tags": "Štítky", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Štítky nejsou šifrované. Správci serveru je mohou číst, stejně jako názvy složek.", + "Favourites": "Oblíbené", + "Filter by tag": "Filtrovat podle štítku", + "All tags": "Všechny štítky", + "Last used": "Naposledy použito", + "Tags for {count} secrets": "Štítky pro {count} tajemství", + "Tag": "Štítek", + "Remove tag": "Odebrat štítek", + "Add tag": "Přidat štítek", + "Could not change the tags. Try again.": "Štítky nelze změnit. Zkuste to znovu.", + "Could not approve the application. It is still in the queue.": "Žádost se nepodařilo schválit. Stále je ve frontě.", + "Could not reject the application. It is still in the queue.": "Žádost se nepodařilo zamítnout. Stále je ve frontě.", + "Removed the user from {count} team folders.": "Uživatel byl odebrán z {count} týmových složek.", + "Approve a share": "Schválit sdílení", + "This approval link is incomplete. Open it again from the notification.": "Tento odkaz pro schválení je neúplný. Otevřete ho znovu z oznámení.", + "Deny": "Zamítnout", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se připojil(a) ke skupině, se kterou sdílíte tajemství. Sdílet tajemství i s touto osobou?", + "{requester} asks you to share a secret with {user}.": "{requester} vás žádá o sdílení tajemství s {user}.", + "Shared. The recipient can now open the secret.": "Sdíleno. Příjemce nyní může tajemství otevřít.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Příjemce si ještě nenastavil Keepiq, proto nebylo nic sdíleno. Zkuste to znovu, až to udělá.", + "Could not share the secret. Only its owner can approve this.": "Tajemství se nepodařilo sdílet. Schválit to může pouze jeho vlastník.", + "Could not share the secret. Try again.": "Tajemství se nepodařilo sdílet. Zkuste to znovu.", + "Denied. Nothing was shared.": "Zamítnuto. Nic nebylo sdíleno.", + "Could not deny the request. Try again.": "Požadavek se nepodařilo zamítnout. Zkuste to znovu.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vás žádá o sdílení tajemství \"%2$s\" s %3$s.", + "Expires on (optional)": "Platnost do (volitelné)", + "Hand over to": "Předat", + "Choose a recipient": "Vyberte příjemce", + "Hand over temporarily": "Dočasně předat", + "Expiry rules": "Pravidla vypršení platnosti", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavte, jak dlouho mohou platit hesla jednoho typu položky nebo v jedné složce a kdy dostanete připomenutí. Pokud platí více dat, rozhoduje nejdřívější.", + "Delete rule": "Smazat pravidlo", + "Set by your administrator": "Nastaveno vaším správcem", + "No expiry rules yet.": "Zatím žádná pravidla vypršení platnosti.", + "Applies to": "Platí pro", + "Item type": "Typ položky", + "Maximum age in days (empty for reminders only)": "Maximální stáří ve dnech (prázdné jen pro připomenutí)", + "Remind me this many days before, comma separated": "Připomenout tolik dní předem, oddělte čárkami", + "Save rule": "Uložit pravidlo", + "An item type": "Typ položky", + "A folder": "Složka", + "Folder {name}": "Složka {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Vyprší po {days} dnech", + "Reminders {days} days before": "Připomenutí {days} dní předem", + "Could not save the expiry rule.": "Pravidlo vypršení platnosti se nepodařilo uložit.", + "Could not delete the expiry rule.": "Pravidlo vypršení platnosti se nepodařilo smazat.", + "All statuses": "Všechny stavy", + "Compromised": "Kompromitovaná", + "Could not load the members.": "Členy se nepodařilo načíst.", + "Emergency contact": "Nouzový kontakt", + "Leaving user": "Odcházející uživatel", + "No": "Ne", + "No users match this filter.": "Tomuto filtru neodpovídá žádný uživatel.", + "Not set up": "Nenastaveno", + "Revoke suite": "Odvolat sadu", + "Revoked": "Odvolaná", + "Search users": "Hledat uživatele", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Podívejte se, kteří uživatelé si nastavili trezor. Spusťte offboarding nebo odvolejte sadu z řádku.", + "Successor": "Nástupce", + "Team folders": "Týmové složky", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Uživatel je stále ve skupině {groups}, která je členem týmové složky. Odeberte ho ze skupiny nebo zakažte účet.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet.", + "Vault status": "Stav trezoru", + "Yes": "Ano", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Export do CXF NENÍ ZAŠIFROVANÝ. Každé heslo a přihlašovací jméno bude ve stažené souboru čitelné jako otevřený text. Uložte jej bezpečně a hned po použití smažte.", + "Root certificate expiring soon": "Kořenový certifikát brzy vyprší", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Kořenový certifikát trezoru vyprší za %1$d dní. Obnovte ho předtím. Obnovení znovu podepíše každou šifrovací sadu.", + "Compromise recovery aborted": "Obnova po kompromitaci přerušena", + "Key rotation ended by a compromise revoke": "Rotace klíče ukončena odvoláním kvůli kompromitaci", + "Encryption suite revoke refused": "Odvolání šifrovací sady zamítnuto", + "Master password proof refused": "Důkaz hlavního hesla zamítnut", + "Your current master password": "Vaše současné hlavní heslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nouzový kontakt měl nevyřízenou žádost o přístup, když ho rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tyto nouzové kontakty nebyly převedeny na váš nový klíč. Jejich nouzový přístup byl odebrán. Pokud je stále chcete, přidejte je znovu v Nouzovém přístupu.", + "Renew root certificate": "Obnovit kořenový certifikát", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Vytvoří se nový kořenový a zprostředkující certifikát. Každá aktivní šifrovací sada se znovu podepíše. Tuto akci nelze vrátit.", + "Renew root": "Obnovit kořen", + "Root renewed. {n} encryption suites signed again.": "Kořen obnoven. Znovu podepsaných šifrovacích sad: {n}.", + "Could not renew the root certificate.": "Kořenový certifikát se nepodařilo obnovit.", + "Lease policy for this application": "Zásady zápůjček pro tuto aplikaci", + "In force now: {default} seconds by default, {max} seconds at most.": "Nyní platí: výchozí {default} sekund, nejvýše {max} sekund.", + "Leases are not renewable": "Zápůjčky nelze obnovovat", + "Lease policy saved.": "Zásady zápůjček uloženy.", + "Leave a field empty to use the instance value.": "Ponechte pole prázdné pro použití hodnoty instance.", + "Instance value: {value}": "Hodnota instance: {value}", + "Renewal": "Obnovení", + "Use the instance value ({value})": "Použít hodnotu instance ({value})", + "Allowed": "Povoleno", + "Not allowed": "Nepovoleno", + "Save lease policy": "Uložit zásady zápůjček", + "Only an administrator can change this policy.": "Tyto zásady může změnit pouze správce.", + "Could not save the lease policy.": "Zásady zápůjček se nepodařilo uložit.", + "{member} got access from {confirmer}.": "{member} získal přístup od {confirmer}.", + "Automatically confirm new team folder members": "Automaticky potvrzovat nové členy týmových složek", + "Gave %n new member access to a team folder.": "%n nový člen získal přístup k týmové složce.", + "Gave %n new members access to a team folder.": "Noví členové (%n) získali přístup k týmové složce.", + "Give new team folder members access without waiting for the folder owner.": "Dejte novým členům přístup bez čekání na vlastníka složky.", + "New team folder members": "Noví členové týmových složek", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlastník nebo člen s právem zápisu je potvrdí ze svého otevřeného trezoru. Keepiq nikdy nedešifruje na serveru.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeká se, až člen s právem zápisu otevře Keepiq. Můžete také sdílet hned.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Část reakce na kompromitaci selhala ({failed} krok(ů)). Zkontrolujte protokol serveru a poté sadu znovu odvolejte, abyste ji dokončili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tím byla odvolána také sada {suite} a ukončena migrace klíčů {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Odvolání druhé sady smazalo %n kontakt nouzového přístupu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Odvolání druhé sady smazalo %n kontaktů nouzového přístupu.", + "A suite revoked as compromised cannot be reinstated.": "Sadu odvolanou jako kompromitovanou nelze obnovit.", + "Archives to keep": "Archivy k uchování", + "Back up every vault automatically": "Automaticky zálohovat každý trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Zálohujte každý trezor podle plánu. Archivy obsahují jen šifrovaný text a obnovují se přes occ.", + "Back up now": "Zálohovat nyní", + "Backup public key (PEM, optional)": "Veřejný klíč zálohy (PEM, volitelný)", + "Backup requested for the next cron run": "Záloha vyžádána pro příští běh cronu", + "Encrypted": "Šifrováno", + "Every (hours)": "Každých (hodin)", + "Last backup {when} failed: {error}": "Poslední záloha {when} selhala: {error}", + "Last backup {when} succeeded.": "Poslední záloha {when} proběhla.", + "No archives yet.": "Zatím žádné archivy.", + "Size": "Velikost", + "Vault backups": "Zálohy trezoru", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S klíčem se každý archiv šifruje pro něj. Soukromý klíč uchovávejte mimo tento server: potřebujete ho k ověření nebo obnovení.", + "Written": "Zapsáno", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n uživatel v rozsahu zatím nemá dvoufázové přihlášení a nemůže otevřít trezor, dokud je toto zapnuté.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Záložní kódy se nepočítají. Pokud se vaši uživatelé přihlašují přes poskytovatele identity s vlastním druhým faktorem, jejich skupiny vynechte.", + "Block personal vault export": "Blokovat export osobního trezoru", + "Keep work logins in team folders": "Uchovávat pracovní přihlašovací údaje v týmových složkách", + "Move to a team folder": "Přesunout do týmové složky", + "Not in a team folder": "Není v týmové složce", + "Only for these groups (empty is everyone)": "Jen pro tyto skupiny (prázdné znamená všechny)", + "Require two-factor login before the vault opens": "Vyžadovat dvoufázové přihlášení před otevřením trezoru", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravidla pro každý trezor. Každé platí pro všechny, nebo jen pro skupiny, které zvolíte.", + "Secret types that belong in a team folder": "Typy tajemství, které patří do týmové složky", + "Set up two-factor login": "Nastavit dvoufázové přihlášení", + "Team folder you can write to": "Týmová složka, do které můžete zapisovat", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Uživatelé nemohou stáhnout zálohu, CSV ani přenosový soubor. Jejich balíček osobních údajů zůstává dostupný.", + "Users cannot save these secret types in a personal folder.": "Uživatelé nemohou ukládat tyto typy tajemství do osobní složky.", + "Vault policies": "Zásady trezoru", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaše organizace nepovoluje export vašeho osobního trezoru. Váš balíček osobních údajů v nastavení zůstává dostupný.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaše organizace uchovává tato tajemství v týmové složce. Přesuňte každé do týmové složky.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaše organizace uchovává tento typ tajemství v týmové složce. Vyberte jednu ze svých týmových složek nebo takovou, do které můžete zapisovat.", + "Your organisation requires two-factor login before you can open your vault.": "Vaše organizace vyžaduje dvoufázové přihlášení, než budete moci otevřít svůj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Uživatelé volí, jak dlouho zůstane rozšíření při nečinnosti odemčené. Vy nastavujete nejdelší dobu, kterou mohou zvolit.", + "Longest idle time before the extension locks": "Nejdelší doba nečinnosti před uzamčením rozšíření", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 hodina", + "4 hours": "4 hodiny", + "Connector": "Konektor", + "Directory (tenant) ID": "ID adresáře (tenanta)", + "Application (client) ID": "ID aplikace (klienta)", + "Data collection rule immutable ID": "Neměnné ID pravidla shromažďování dat", + "Stream name": "Název streamu", + "Splunk index (optional)": "Index Splunk (volitelné)", + "Sourcetype (optional)": "Sourcetype (volitelné)", + "Leave blank to keep the current one": "Ponechte prázdné pro zachování současné hodnoty", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF přes syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Koncový bod shromažďování dat (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectoru (https)", + "Client secret (write-only)": "Tajný klíč klienta (pouze zápis)", + "HEC token (write-only)": "Token HEC (pouze zápis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Přeposílejte povolené auditní události do Splunk, Microsoft Sentinel, přijímače syslog nebo webhooku. Zprávy obsahují pouze očištěná metadata: žádná tajná hodnota, jméno, přihlašovací jméno ani šifrovaný text nikdy neopustí server.", + "%n change waiting to sync": "%n změna čeká na synchronizaci", + "%n changes waiting to sync": "%n změn čeká na synchronizaci", + "Changes that could not sync": "Změny, které se nepodařilo synchronizovat", + "Choose a version": "Zvolit verzi", + "Copy value": "Kopírovat hodnotu", + "Deleted": "Smazáno", + "Discard": "Zahodit", + "Keep my offline change": "Ponechat moji offline změnu", + "Keep the server version": "Ponechat verzi ze serveru", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je offline jen pro čtení. Správce nezapnul úpravy offline.", + "Let users edit secrets offline": "Povolit uživatelům upravovat tajemství offline", + "Not synced yet": "Zatím nesynchronizováno", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline změny zůstávají v zařízení, šifrované pro uživatele, a synchronizují se při dalším odemčení online. Sdílení, složky a přílohy stále potřebují připojení.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Úpravy, přesuny a smazání zůstávají v tomto zařízení a synchronizují se, až budete znovu online. Sdílení a přílohy potřebují připojení.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vaše změny zůstávají v tomto zařízení a synchronizují se, až budete znovu online. Naposledy synchronizováno {when}.", + "Open my changes": "Otevřít moje změny", + "Sharing needs a connection": "Sdílení potřebuje připojení", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Někdo změnil toto tajemství na serveru poté, co vznikla vaše offline kopie. Zvolte, kterou verzi ponechat.", + "Sync or discard your offline changes before you rotate your keys.": "Před výměnou klíčů synchronizujte nebo zahoďte offline změny.", + "That password did not open your changes.": "Toto heslo neotevřelo vaše změny.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline snímek ukládá šifrovaná tajemství (otevřít je lze jen klíčem odvozeným z hlavního hesla uživatele, přesně jako na serveru) a šifruje názvy, URL a názvy složek v úložišti. Offline přístup je jen pro čtení, pokud níže nepovolíte úpravy offline. Vypněte to pro zařízení, která nikdy nesmí ukládat přihlašovací údaje; vypnutí vymaže stávající mezipaměti při příštím načtení.", + "The previous vault copy is gone, so these changes cannot be opened.": "Předchozí kopie trezoru zmizela, takže tyto změny nelze otevřít.", + "The server version": "Verze ze serveru", + "This secret changed while you were offline": "Toto tajemství se změnilo, když jste byli offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Toto tajemství jste offline smazali, ale mezitím bylo na serveru změněno. Zvolte, kterou verzi ponechat.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaše klíče byly změněny na jiném zařízení. Zadejte předchozí hlavní heslo pro synchronizaci offline změn, nebo je zahoďte.", + "Your offline change": "Vaše offline změna", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n nouzový kontakt měl nevyřízenou žádost o přístup, když ho rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal.", + "Nouzové kontakty (%n) měly nevyřízenou žádost o přístup, když je rotace klíče odstranila. Než kohokoli znovu přidáte, ověřte, kdo žádal." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n položku nelze reprezentovat ve formátu CXF a bude přeskočena.", + "%n položek nelze reprezentovat ve formátu CXF a budou přeskočeny.", + "%n položek nelze reprezentovat ve formátu CXF a budou přeskočeny." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n starší verze byla zahozena, protože přenést lze jen nedávnou historii.", + "%n starších verzí bylo zahozeno, protože přenést lze jen nedávnou historii.", + "%n starších verzí bylo zahozeno, protože přenést lze jen nedávnou historii." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopii tajemství je stále potřeba zašifrovat a nasdílet.", + "%n kopií tajemství je stále potřeba zašifrovat a nasdílet.", + "%n kopií tajemství je stále potřeba zašifrovat a nasdílet." + ], + "_%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 tajemství nebylo možné dešifrovat a není v tomto exportu.", + "%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu.", + "%n tajemství nebylo možné dešifrovat a nejsou v tomto exportu." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebylo migrováno.", + "%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebyla migrována.", + "%n tajemství se nepodařilo dešifrovat vaším starým klíčem, takže nebyla migrována." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n tajemství nebylo migrováno.", + "%n tajemství nebylo migrováno.", + "%n tajemství nebylo migrováno." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n tajemství je stále zašifrováno vaším předchozím klíčem.", + "%n tajemství je stále zašifrováno vaším předchozím klíčem.", + "%n tajemství je stále zašifrováno vaším předchozím klíčem." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu.", + "%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu.", + "%n tajemství bylo přeskočeno, protože nástupce dosud nemá kopii — přidejte nástupce do složky a spusťte akci znovu." + ], + "_%n secret_::_%n secrets_": [ + "%n tajemství", + "%n tajemství", + "%n tajemství" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n uživatel v rozsahu zatím nemá dvoufázové přihlášení a nemůže otevřít trezor, dokud je toto zapnuté.", + "Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté.", + "Uživatelé v rozsahu (%n) zatím nemají dvoufázové přihlášení a nemohou otevřít trezor, dokud je toto zapnuté." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Přesto dokončit a ztratit přístup k %n tajemství", + "Přesto dokončit a ztratit přístup k %n tajemstvím", + "Přesto dokončit a ztratit přístup k %n tajemstvím" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nový člen získal přístup k týmové složce.", + "Noví členové (%n) získali přístup k týmové složce.", + "Noví členové (%n) získali přístup k týmové složce." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem.", + "Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem.", + "Rotace klíče dokončena. %n tajemství bylo znovu zašifrováno vaším novým klíčem." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Odvolání druhé sady smazalo %n kontakt nouzového přístupu.", + "Odvolání druhé sady smazalo %n kontaktů nouzového přístupu.", + "Odvolání druhé sady smazalo %n kontaktů nouzového přístupu." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Odvolání této sady odstranilo %n kontakt nouzového přístupu.", + "Odvolání této sady odstranilo %n kontaktů nouzového přístupu.", + "Odvolání této sady odstranilo %n kontaktů nouzového přístupu." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "nalezeno v únicích %n krát", + "nalezeno v únicích %n krát", + "nalezeno v únicích %n krát" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "sdíleno s %n tajemstvím", + "sdíleno s %n tajemstvími", + "sdíleno s %n tajemstvími" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Tato složka obsahuje přímo %n tajemství.", + "Tato složka obsahuje přímo %n tajemství.", + "Tato složka obsahuje přímo %n tajemství." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n změna čeká na synchronizaci", + "%n změn čeká na synchronizaci", + "%n změn čeká na synchronizaci" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Uživatel je stále ve skupině {groups}, která je členem týmové složky. Odeberte ho ze skupiny nebo zakažte účet.", + "Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet.", + "Uživatel je stále ve skupinách {groups}, které jsou členy týmových složek. Odeberte ho ze skupin nebo zakažte účet." + ], + "Allow approval from another device": "Povolit schválení z jiného zařízení", + "App": "Aplikace", + "Approve a new device": "Schválit nové zařízení", + "Approve from another device": "Schválit z jiného zařízení", + "Asked at": "Vyžádáno v", + "Check that the new device shows these words:": "Zkontrolujte, že nové zařízení zobrazuje tato slova:", + "Denied. If you did not ask, end your other sessions:": "Zamítnuto. Pokud jste o to nežádali, ukončete své ostatní relace:", + "Device": "Zařízení", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Umožnit uživatelům odemknout nový prohlížeč jeho schválením ze zařízení, kde je Keepiq už odemčený.", + "New device approval": "Schvalování nových zařízení", + "Nextcloud security settings": "Nastavení zabezpečení Nextcloud", + "Only approve a device you are using right now.": "Schvalujte jen zařízení, které právě používáte.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otevřete Keepiq na zařízení, kde je odemčený, a schvalte toto zařízení. Zkontrolujte, že zobrazuje stejná slova:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Schvalující zařízení zapečetí odemykací klíč pro nové zařízení. Server ho jen předá dál a nemůže ho otevřít.", + "The master password is not right, or the request has ended.": "Hlavní heslo není správné, nebo žádost skončila.", + "The request expired. Ask again or use your master password.": "Platnost žádosti vypršela. Požádejte znovu nebo použijte hlavní heslo.", + "The request was denied.": "Žádost byla zamítnuta.", + "Too many requests. Try again in an hour or use your master password.": "Příliš mnoho žádostí. Zkuste to znovu za hodinu nebo použijte hlavní heslo.", + "Unknown device": "Neznámé zařízení", + "Web app": "Webová aplikace", + "A device": "Zařízení", + "A new device asks to open your vault": "Nové zařízení žádá o otevření vašeho trezoru", + "%s asks to be approved. Only approve a device you are using right now.": "%s žádá o schválení. Schvalujte jen zařízení, které právě používáte.", + "Access ends on (optional)": "Přístup končí (volitelné)", + "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.": "Aplikace Keepiq heslo nezobrazí ani nezkopírují. Někdo s technickými znalostmi jej přesto může přečíst ze svého zařízení. Až přístup skončí, heslo změňte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Toto tajemství je pouze k použití. Přihlaste se přes rozšíření prohlížeče Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Pouze použití", + "Use only (can sign in, cannot view or copy)": "Pouze použití (může se přihlásit, nemůže zobrazit ani zkopírovat)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S tímto přihlášením se můžete přihlásit přes rozšíření prohlížeče Keepiq. Vlastník se rozhodl, že ho nemůžete zobrazit ani zkopírovat.", + "Your access ends on {date}": "Váš přístup končí {date}", + "Your access to this secret has ended": "Váš přístup k tomuto tajemství skončil", + "Your access to \"%s\" ends tomorrow": "Váš přístup k „%s“ končí zítra", + "Your access to \"%s\" has ended": "Váš přístup k „%s“ skončil", + "%1$s no longer has access to \"%2$s\"": "%1$s už nemá přístup k „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mohl(a) vidět toto heslo. Změňte ho, pokud by ho %1$s už neměl(a) znát.", + "%s could not view this password in Keepiq.": "%s nemohl(a) toto heslo v Keepiq zobrazit.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} schválení", + "a recovery officer": "pověřenec pro obnovu", + "Account recovery": "Obnova účtu", + "Approvals needed": "Potřebná schválení", + "Ask {user} which words they see, by phone or in person. They must be:": "Zeptejte se uživatele {user}, jaká slova vidí, telefonicky nebo osobně. Musí to být:", + "Check again": "Zkontrolovat znovu", + "Create the recovery key": "Vytvořit klíč pro obnovu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Vytvořte klíč pro obnovu. Váš prohlížeč ho vytvoří a každému pověřenci dá kopii, kterou otevře jen on.", + "Decline": "Odmítnout", + "Enrol in account recovery": "Přihlásit se k obnově účtu", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Přihlaste se, aby vám organizace mohla pomoci získat trezor zpět, pokud zapomenete hlavní heslo.", + "Every user is enrolled": "Všichni uživatelé jsou přihlášeni", + "Finish the recovery in the browser you asked from.": "Dokončete obnovu v prohlížeči, ze kterého jste žádali.", + "Forgot your master password?": "Zapomněli jste hlavní heslo?", + "Hand the key over": "Předat klíč", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Umožněte uživatelům, kteří zapomněli hlavní heslo, získat trezor zpět se schválením pověřenců pro obnovu, které určíte.", + "New master password": "Nové hlavní heslo", + "No one is asking to recover their account.": "Nikdo nežádá o obnovu účtu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Klíč pro obnovu zatím neexistuje. Jeden z pověřenců ho vytvoří ve svém nastavení Keepiq.", + "Off": "Vypnuto", + "Officer {user} has no encryption set up yet.": "Pověřenec {user} ještě nemá nastavené šifrování.", + "Officers (user IDs, separated by commas)": "Pověřenci (ID uživatelů oddělená čárkami)", + "Policy": "Zásady", + "Publish this fingerprint internally, so users can check it before they enrol.": "Zveřejněte tento otisk interně, aby si ho uživatelé mohli ověřit před přihlášením.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnoveno s pomocí {officer}. Nyní změňte klíč trezoru v Nastavení, Zabezpečení: \"Moje hlavní heslo bylo prozrazeno\".", + "Recovery key fingerprint: {fingerprint}": "Otisk klíče pro obnovu: {fingerprint}", + "Recovery officer": "Pověřenec pro obnovu", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odebraní pověřenci nyní přijdou o svou kopii, ale mohli ji dříve otevřít. Nechte pověřence vytvořit nový klíč pro obnovu.", + "Repeat the new master password": "Zopakujte nové hlavní heslo", + "Retire this recovery key": "Vyřadit tento klíč pro obnovu", + "Set the new master password": "Nastavit nové hlavní heslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikát pro obnovu nevydal tento Keepiq. Nepřihlašujte se a informujte správce.", + "The words match, approve": "Slova se shodují, schválit", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tento uživatel je přihlášen k obnově účtu. Obnova zachová jeho tajemství; odvolání smaže jeho přihlášení.", + "Users may enrol": "Uživatelé se mohou přihlásit", + "Withdraw from account recovery": "Odhlásit se z obnovy účtu", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jste přihlášeni k obnově účtu. Otisk klíče pro obnovu: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jste přihlášeni. Pokud zapomenete hlavní heslo, organizace vám může pomoci získat trezor zpět.", + "Your key is back. Choose a new master password.": "Klíč je zpět. Zvolte nové hlavní heslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši pověřenci pro obnovu byli informováni. Přečtěte jim tato slova, až vám zavolají nebo se s vámi setkají:", + "You are now an account recovery officer": "Nyní jste pověřencem pro obnovu účtů", + "%s asks to recover their account. Compare the words with them before you approve.": "%s žádá o obnovu účtu. Před schválením s ním porovnejte slova.", + "A user": "Uživatel", + "Your account recovery request was declined": "Vaše žádost o obnovu účtu byla zamítnuta", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnova účtu je připravena. Otevřete Keepiq v prohlížeči, ze kterého jste žádali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} žádá o jednorázové odemčení nového zařízení. Hlavní heslo zůstává stejné.", + "Ask your organisation instead": "Raději požádat svou organizaci", + "The request ended. Ask again or use your master password.": "Žádost skončila. Požádejte znovu nebo použijte hlavní heslo.", + "Added by {user}": "Přidal(a) {user}", + "Editor": "Editor", + "Manager": "Správce", + "Role of {member}": "Role uživatele {member}", + "Team folders you manage": "Týmové složky, které spravujete", + "Viewer": "Čtenář", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemáte kopii těchto tajemství, takže je noví členové zatím nedostali. Vlastník je může sdílet: {names}", + "Admin areas": "Oblasti správy", + "Give a group only the parts of Keepiq administration it needs.": "Dejte skupině jen ty části správy Keepiq, které potřebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegujte jednu nebo více oblastí na skupinu na stránce oprávnění ke správě. Správci instance mají každou oblast.", + "Open administration privileges": "Otevřít oprávnění ke správě", + "Policies": "Zásady", + "Applications and machine access": "Aplikace a přístup strojů", + "People and offboarding": "Lidé a odchody", + "Audit and compliance": "Audit a shoda", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verze, certifikační autorita, přílohy, offline mezipaměť, kontrola úniků, typy tajemství a zálohy", + "master password, organisation password, vault policies, rotation, version history and trash": "hlavní heslo, heslo organizace, zásady trezoru, rotace, historie verzí a koš", + "application queue, application requests and machine leases": "fronta aplikací, požadavky aplikací a pronájmy strojů", + "team offboarding, encryption suites and admin handover": "odchody z týmu, šifrovací sady a převzetí správcem", + "audit log, compliance reports, SIEM export and honey alerts": "auditní protokol, zprávy o shodě, export SIEM a návnadová upozornění", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kolik verzí tajemství se uchovává, jak dlouho a jak dlouho zůstávají smazaná tajemství v koši.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity šifrovaných příloh, vynucované na serveru v uložených šifrovaných bajtech.", + "Type the suite ID again to confirm": "Pro potvrzení zadejte ID sady znovu", + "This does not match the suite ID.": "Neshoduje se s ID sady.", + "Confirm with your master password": "Potvrďte hlavním heslem", + "Confirm": "Potvrdit", + "That master password is not right.": "Toto hlavní heslo není správné.", + "You are sharing with someone new. Enter your master password to confirm.": "Sdílíte s někým novým. Pro potvrzení zadejte hlavní heslo.", + "Enter your master password to confirm this share.": "Pro potvrzení tohoto sdílení zadejte hlavní heslo.", + "Enter your master password to confirm this delegation.": "Pro potvrzení tohoto delegování zadejte hlavní heslo.", + "Approve {member}": "Schválit {member}", + "Recipient": "Příjemce", + "No vault yet": "Zatím nemá trezor", + "No matching users": "Žádní odpovídající uživatelé", + "Partner organisations": "Partnerské organizace", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vyměňujte tajemství s jiným Keepiq. Oba správci se navzájem přidají a před uložením porovnají kořenové otisky po telefonu nebo osobně.", + "Federation needs Nextcloud 33 or later.": "Federace vyžaduje Nextcloud 33 nebo novější.", + "Your root fingerprint": "Váš kořenový otisk", + "No partners yet.": "Zatím žádní partneři.", + "Users here may share to this partner": "Uživatelé zde mohou sdílet s tímto partnerem", + "This partner may share to users here": "Tento partner může sdílet s uživateli zde", + "Partner address": "Adresa partnera", + "Check partner": "Ověřit partnera", + "Partner root fingerprint": "Kořenový otisk partnera", + "I compared this fingerprint with the partner's administrator": "Porovnal jsem tento otisk se správcem partnera", + "Add partner": "Přidat partnera", + "A secret from another organisation": "Tajemství z jiné organizace", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Uživatel %1$s s vámi sdílí \"%2$s\". Přijměte ho v sekci Příchozí z jiných organizací.", + "Incoming from other organisations": "Příchozí z jiných organizací", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Lidé v partnerských organizacích s vámi mohou sdílet tajemství. Přijměte ho, abyste si ve svém trezoru ponechali kopii jen pro čtení.", + "Nothing shared with you yet": "Zatím s vámi nebylo nic sdíleno", + "Secrets that people in partner organisations share with you appear here.": "Zde se zobrazí tajemství, která s vámi sdílejí lidé v partnerských organizacích.", + "From {sender}": "Od {sender}", + "Accept": "Přijmout", + "Open in vault": "Otevřít v trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druhá organizace tajemství nepředala. Zkuste to později znovu.", + "Set up your vault before you accept a shared secret.": "Než přijmete sdílené tajemství, nastavte si trezor.", + "Something went wrong. Try again.": "Něco se pokazilo. Zkuste to znovu.", + "Waiting for your answer": "Čeká na vaši odpověď", + "In your vault, read-only": "Ve vašem trezoru, jen pro čtení", + "Withdrawn by the sender": "Staženo odesílatelem", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} toto sdílí z jiné organizace. Můžete to číst, ale nemůžete to měnit ani sdílet.", + "Someone": "Někdo", + "Share with someone at another organisation": "Sdílet s někým z jiné organizace", + "Their account at the other organisation": "Účet dané osoby v druhé organizaci", + "Check account": "Zkontrolovat účet", + "Certificate fingerprint of {account}": "Otisk certifikátu účtu {account}", + "Compare it with them by phone if you want to be sure.": "Pokud si chcete být jisti, porovnejte ho s danou osobou po telefonu.", + "Shared. {account} can accept it in their own vault.": "Sdíleno. {account} to může přijmout ve svém vlastním trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikát se nepodařilo ověřit. Nic nebylo sdíleno.", + "That organisation is not one of your partners.": "Tato organizace není mezi vašimi partnery.", + "No one with that account can receive secrets from you.": "Nikdo s tímto účtem od vás nemůže přijímat tajemství.", + "The other organisation did not answer. Try again later.": "Druhá organizace neodpověděla. Zkuste to později znovu.", + "This secret is already shared with that account.": "Toto tajemství je s tímto účtem již sdíleno.", + "Other organisations": "Jiné organizace", + "Receive secrets from other organisations": "Přijímat tajemství z jiných organizací", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Lidé v partnerských organizacích pak mohou najít váš účet a sdílet s vámi tajemství. Každé z nich přijímáte sami.", + "Shared": "Sdíleno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pozastaveno: změnil se jejich certifikát nebo partnerství. Sdílení můžete odvolat nebo sdílet znovu.", + "Their organisation did not get the last change. Revoke it or share again.": "Jejich organizace nedostala poslední změnu. Sdílení můžete odvolat nebo sdílet znovu.", + "Being withdrawn": "Odvolává se", + "Shared with another organisation": "Sdíleno s jinou organizací", + "Change sent to another organisation": "Změna odeslána jiné organizaci", + "Share with another organisation revoked": "Sdílení s jinou organizací odvoláno", + "Share with another organisation paused": "Sdílení s jinou organizací pozastaveno", + "Another organisation did not get a change": "Jiná organizace nedostala změnu", + "Secret received from another organisation": "Tajemství přijato z jiné organizace", + "Secret from another organisation accepted": "Tajemství z jiné organizace přijato", + "Secret from another organisation declined": "Tajemství z jiné organizace odmítnuto", + "Copy from another organisation updated": "Kopie z jiné organizace aktualizována", + "Copy from another organisation removed": "Kopie z jiné organizace odstraněna", + "Declined: they removed their copy. Share again if they need it.": "Odmítnuto: příjemce odstranil svou kopii. Pokud ji potřebuje, sdílejte znovu.", + "Recipient at another organisation removed their copy": "Příjemce z jiné organizace odstranil svou kopii", + "Removed the user from %n team folder.": "Uživatel byl odebrán z %n týmové složky.", + "Removed the user from %n team folders.": "Uživatel byl odebrán z %n týmových složek.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Uživatel byl odebrán z %n týmové složky.", + "Uživatel byl odebrán z %n týmových složek.", + "Uživatel byl odebrán z %n týmových složek." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovená kopie pochází ze sdílení, které skončilo. Zůstává jen pro čtení.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizaci, která sdílela obnovenou kopii, se nepodařilo zastihnout. Kopie zůstává jen pro čtení a nesleduje jejich změny.", + "Recipient at another organisation restored their copy": "Příjemce z jiné organizace obnovil svou kopii" }, "plurals": null } diff --git a/l10n/da.js b/l10n/da.js index e01e89365..9f3086dd8 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Delt med grupper", + "Not shared with any group yet.": "Endnu ikke delt med nogen gruppe.", + "Revoke the share with {group}": "Tilbagekald deling med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer modtog den, {skipped} gjorde ikke, fordi de endnu ikke har opsat kryptering.", + "Search groups": "Søg efter grupper", + "Failed to share": "Deling mislykkedes", + "Columns": "Kolonner", + "Column {number}": "Kolonne {number}", + "Map one column to Name. Every secret needs a name.": "Knyt én kolonne til Navn. Hver hemmelighed skal have et navn.", + "Notes": "Noter", + "Do not import": "Importer ikke", + "Hide this value": "Skjul denne værdi", + "Show this value": "Vis denne værdi", + "Defaults": "Standarder", + "New secrets start as this type, and your secret list opens in this view.": "Nye hemmeligheder starter som denne type, og din hemmelighedsliste åbner i denne visning.", + "Default item type": "Standardelementtype", + "Cards": "Kort", + "Table": "Tabel", + "Could not save your default": "Din standard kunne ikke gemmes", + "Recently used": "Senest brugt", + "Opened": "Åbnet", + "You have not opened any secrets yet": "Du har ikke åbnet nogen hemmeligheder endnu", + "Could not delete the item type.": "Elementtypen kunne ikke slettes.", + "Could not load the item types.": "Elementtyperne kunne ikke indlæses.", + "Could not save the item type.": "Elementtypen kunne ikke gemmes.", + "Delete item type": "Slet elementtype", + "Edit item type": "Rediger elementtype", + "Fields": "Felter", + "Fields: {count}": "Felter: {count}", + "Hidden": "Skjult", + "Item types": "Elementtyper", + "Move up": "Flyt op", + "New item type": "Ny elementtype", + "No item types defined yet.": "Der er ikke defineret nogen elementtyper endnu.", + "Required": "Påkrævet", + "Text": "Tekst", + "This field is required": "Dette felt er påkrævet", + "Web address": "Webadresse", + "{label} (required)": "{label} (påkrævet)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Slet “{name}”? Hemmeligheder af denne type kan stadig læses og bliver til Log ind-elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtyper, du definerer her, vises for alle i dialogen Ny hemmelighed med de felter, du vælger.", + "Secret moved to the trash": "Hemmelighed flyttet til papirkurven", + "Secret restored from the trash": "Hemmelighed gendannet fra papirkurven", + "Secret deleted for good": "Hemmelighed slettet endeligt", + "Secret archived": "Hemmelighed arkiveret", + "Secret unarchived": "Hemmelighed hentet ud af arkivet", + "Unarchive": "Hent ud af arkivet", + "Could not archive the secret": "Hemmeligheden kunne ikke arkiveres", + "Could not unarchive the secret": "Hemmeligheden kunne ikke hentes ud af arkivet", + "Archive {count} secrets": "Arkivér {count} hemmeligheder", + "Unarchive {count} secrets": "Hent {count} hemmeligheder ud af arkivet", + "Restore {count} secrets": "Gendan {count} hemmeligheder", + "Delete {count} secrets for good": "Slet {count} hemmeligheder endeligt", + "Done for {ok} of {total} secrets": "Færdig for {ok} af {total} hemmeligheder", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverede hemmeligheder forsvinder fra boksens liste, søgning, autoudfyldning og sundhedsrapporten. De beholder deres delinger. Du finder dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "Disse hemmeligheder kommer tilbage på boksens liste, i søgning og autoudfyldning.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Disse hemmeligheder kommer tilbage på boksens liste. Deres gamle delinger kommer ikke tilbage, så del dem igen, hvor det er nødvendigt.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dette sletter hemmelighederne med deres vedhæftninger og versionshistorik. Det kan ikke fortrydes.", + "Delete for good": "Slet endeligt", + "Trash": "Papirkurv", + "The trash is empty": "Papirkurven er tom", + "No archived secrets": "Ingen arkiverede hemmeligheder", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Slettede hemmeligheder venter her, til opbevaringsperioden udløber, derefter slettes de endeligt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivér en hemmelighed fra dens detaljepanel for at holde den ude af boksens liste, søgning og autoudfyldning.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grænser for krypterede vedhæftede filer (håndhævet på serveren i gemte krypterede bytes), opbevaring af versionshistorik og hvor længe slettede hemmeligheder bliver i papirkurven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dage en slettet hemmelighed bliver i papirkurven (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dette flytter hemmeligheden til papirkurven og afslutter dens delinger nu. Du kan gendanne den fra papirkurven, indtil opbevaringsperioden udløber: 30 dage, medmindre din administrator har ændret det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dette flytter {count} hemmeligheder til papirkurven og afslutter deres delinger nu. Du kan gendanne dem fra papirkurven, indtil opbevaringsperioden udløber.", + "Remove {name} from favourites": "Fjern {name} fra favoritter", + "Add {name} to favourites": "Føj {name} til favoritter", + "Could not change the favourite": "Favoritten kunne ikke ændres", + "Remove from favourites": "Fjern fra favoritter", + "Add to favourites": "Føj til favoritter", + "Tags": "Mærker", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Mærker er ikke krypteret. Serveradministratorer kan læse dem, ligesom mappenavne.", + "Favourites": "Favoritter", + "Filter by tag": "Filtrer efter mærke", + "All tags": "Alle mærker", + "Last used": "Sidst brugt", + "Tags for {count} secrets": "Mærker for {count} hemmeligheder", + "Tag": "Mærke", + "Remove tag": "Fjern mærke", + "Add tag": "Tilføj mærke", + "Could not change the tags. Try again.": "Mærkerne kunne ikke ændres. Prøv igen.", + "Could not approve the application. It is still in the queue.": "Ansøgningen kunne ikke godkendes. Den er stadig i køen.", + "Could not reject the application. It is still in the queue.": "Ansøgningen kunne ikke afvises. Den er stadig i køen.", + "Removed the user from {count} team folders.": "Brugeren blev fjernet fra {count} teammapper.", + "Approve a share": "Godkend en deling", + "This approval link is incomplete. Open it again from the notification.": "Dette godkendelseslink er ufuldstændigt. Åbn det igen fra notifikationen.", + "Deny": "Afvis", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} er blevet medlem af en gruppe, du deler en hemmelighed med. Vil du også dele hemmeligheden med vedkommende?", + "{requester} asks you to share a secret with {user}.": "{requester} beder dig dele en hemmelighed med {user}.", + "Shared. The recipient can now open the secret.": "Delt. Modtageren kan nu åbne hemmeligheden.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Modtageren har ikke konfigureret Keepiq endnu, så intet blev delt. Prøv igen, når det er gjort.", + "Could not share the secret. Only its owner can approve this.": "Hemmeligheden kunne ikke deles. Kun ejeren kan godkende dette.", + "Could not share the secret. Try again.": "Hemmeligheden kunne ikke deles. Prøv igen.", + "Denied. Nothing was shared.": "Afvist. Intet blev delt.", + "Could not deny the request. Try again.": "Anmodningen kunne ikke afvises. Prøv igen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s beder dig dele hemmeligheden \"%2$s\" med %3$s.", + "Expires on (optional)": "Udløber den (valgfrit)", + "Hand over to": "Overdrag til", + "Choose a recipient": "Vælg en modtager", + "Hand over temporarily": "Overdrag midlertidigt", + "Expiry rules": "Udløbsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Angiv, hvor længe adgangskoder af én elementtype eller i én mappe må leve, og hvornår du skal mindes om det. Når flere datoer gælder, tæller den tidligste.", + "Delete rule": "Slet regel", + "Set by your administrator": "Angivet af din administrator", + "No expiry rules yet.": "Ingen udløbsregler endnu.", + "Applies to": "Gælder for", + "Item type": "Elementtype", + "Maximum age in days (empty for reminders only)": "Maksimal alder i dage (tom for kun påmindelser)", + "Remind me this many days before, comma separated": "Mind mig om det så mange dage før, kommasepareret", + "Save rule": "Gem regel", + "An item type": "En elementtype", + "A folder": "En mappe", + "Folder {name}": "Mappe {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Udløber efter {days} dage", + "Reminders {days} days before": "Påmindelser {days} dage før", + "Could not save the expiry rule.": "Udløbsreglen kunne ikke gemmes.", + "Could not delete the expiry rule.": "Udløbsreglen kunne ikke slettes.", + "All statuses": "Alle statusser", + "Compromised": "Kompromitteret", + "Could not load the members.": "Medlemmerne kunne ikke indlæses.", + "Emergency contact": "Nødkontakt", + "Leaving user": "Fratrædende bruger", + "No": "Nej", + "No users match this filter.": "Ingen brugere matcher dette filter.", + "Not set up": "Ikke oprettet", + "Revoke suite": "Tilbagekald suite", + "Revoked": "Tilbagekaldt", + "Search users": "Søg efter brugere", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se hvilke brugere der har oprettet en boks. Start offboarding eller tilbagekald en suite fra en række.", + "Successor": "Efterfølger", + "Team folders": "Teammapper", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Brugeren er stadig i gruppen {groups}, som er medlem af en teammappe. Fjern brugeren fra gruppen eller deaktiver kontoen.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Brugeren er stadig i grupperne {groups}, som er medlemmer af teammapper. Fjern brugeren fra grupperne eller deaktiver kontoen.", + "Vault status": "Boksstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-eksport er UKRYPTERET. Alle adgangskoder og logins vil kunne læses som klartekst i den hentede fil. Opbevar den sikkert, og slet den umiddelbart efter brug.", + "Root certificate expiring soon": "Rodcertifikatet udløber snart", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Boksens rodcertifikat udløber om %1$d dag(e). Forny det inden da. Fornyelse signerer hver krypteringssuite igen.", + "Compromise recovery aborted": "Gendannelse efter kompromittering afbrudt", + "Key rotation ended by a compromise revoke": "Nøglerotation afsluttet af en tilbagekaldelse pga. kompromittering", + "Encryption suite revoke refused": "Tilbagekaldelse af krypteringssuite afvist", + "Master password proof refused": "Bevis for hovedadgangskode afvist", + "Your current master password": "Din nuværende hovedadgangskode", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nødkontakt havde en ventende adgangsanmodning, da din nøglerotation fjernede den. Tjek, hvem der spurgte, før du tilføjer nogen igen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nødkontakter havde en ventende adgangsanmodning, da din nøglerotation fjernede dem. Tjek, hvem der spurgte, før du tilføjer nogen igen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Disse nødkontakter blev ikke overført til din nye nøgle. Deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.", + "Renew root certificate": "Forny rodcertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dette opretter et nyt rod- og mellemcertifikat. Hver aktiv krypteringspakke signeres igen. Det kan ikke fortrydes.", + "Renew root": "Forny rod", + "Root renewed. {n} encryption suites signed again.": "Rod fornyet. {n} krypteringspakker signeret igen.", + "Could not renew the root certificate.": "Rodcertifikatet kunne ikke fornyes.", + "Lease policy for this application": "Lejepolitik for denne applikation", + "In force now: {default} seconds by default, {max} seconds at most.": "Gælder nu: {default} sekunder som standard, højst {max} sekunder.", + "Leases are not renewable": "Lejemål kan ikke fornyes", + "Lease policy saved.": "Lejepolitik gemt.", + "Leave a field empty to use the instance value.": "Lad et felt stå tomt for at bruge instansens værdi.", + "Instance value: {value}": "Instansens værdi: {value}", + "Renewal": "Fornyelse", + "Use the instance value ({value})": "Brug instansens værdi ({value})", + "Allowed": "Tilladt", + "Not allowed": "Ikke tilladt", + "Save lease policy": "Gem lejepolitik", + "Only an administrator can change this policy.": "Kun en administrator kan ændre denne politik.", + "Could not save the lease policy.": "Lejepolitikken kunne ikke gemmes.", + "{member} got access from {confirmer}.": "{member} fik adgang fra {confirmer}.", + "Automatically confirm new team folder members": "Bekræft nye teammappemedlemmer automatisk", + "Gave %n new member access to a team folder.": "%n nyt medlem fik adgang til en teammappe.", + "Gave %n new members access to a team folder.": "%n nye medlemmer fik adgang til en teammappe.", + "Give new team folder members access without waiting for the folder owner.": "Giv nye teammappemedlemmer adgang uden at vente på mappens ejer.", + "New team folder members": "Nye teammappemedlemmer", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ejeren eller et medlem med skriveadgang bekræfter dem fra sin åbne boks. Keepiq dekrypterer aldrig på serveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Venter på at et medlem med skriveadgang åbner Keepiq. Du kan også dele nu.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del af kompromitteringsreaktionen mislykkedes ({failed} trin). Tjek serverloggen, og tilbagekald derefter suiten igen for at afslutte.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dette tilbagekaldte også suite {suite} og afsluttede nøglemigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakter.", + "A suite revoked as compromised cannot be reinstated.": "En suite, der er tilbagekaldt som kompromitteret, kan ikke genindsættes.", + "Archives to keep": "Arkiver der skal gemmes", + "Back up every vault automatically": "Sikkerhedskopiér hver boks automatisk", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sikkerhedskopiér hver boks efter en plan. Arkiver indeholder kun krypteret tekst og gendannes med occ.", + "Back up now": "Sikkerhedskopiér nu", + "Backup public key (PEM, optional)": "Offentlig backupnøgle (PEM, valgfri)", + "Backup requested for the next cron run": "Backup anmodet til næste cron-kørsel", + "Encrypted": "Krypteret", + "Every (hours)": "Hver (timer)", + "Last backup {when} failed: {error}": "Seneste backup {when} mislykkedes: {error}", + "Last backup {when} succeeded.": "Seneste backup {when} lykkedes.", + "No archives yet.": "Ingen arkiver endnu.", + "Size": "Størrelse", + "Vault backups": "Boksbackups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nøgle krypteres hvert arkiv til den. Opbevar den private nøgle uden for denne server: du skal bruge den til at kontrollere eller gendanne.", + "Written": "Skrevet", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n bruger i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n brugere i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backupkoder tæller ikke. Hvis dine brugere logger ind via en identitetsudbyder med sin egen anden faktor, så udelad deres grupper.", + "Block personal vault export": "Bloker eksport af personlig boks", + "Keep work logins in team folders": "Opbevar arbejdslogins i teammapper", + "Move to a team folder": "Flyt til en teammappe", + "Not in a team folder": "Ikke i en teammappe", + "Only for these groups (empty is everyone)": "Kun for disse grupper (tom betyder alle)", + "Require two-factor login before the vault opens": "Kræv totrinslogin, før boksen åbner", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler for hver boks. Hver regel gælder for alle eller kun for de grupper, du vælger.", + "Secret types that belong in a team folder": "Hemmelighedstyper, der hører hjemme i en teammappe", + "Set up two-factor login": "Opsæt totrinslogin", + "Team folder you can write to": "Teammappe, du kan skrive i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Brugere kan ikke downloade en backup-, CSV- eller overførselsfil. Deres personlige datapakke er stadig tilgængelig.", + "Users cannot save these secret types in a personal folder.": "Brugere kan ikke gemme disse hemmelighedstyper i en personlig mappe.", + "Vault policies": "Boksregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Din organisation tillader ikke eksport af din personlige boks. Din personlige datapakke i dine indstillinger er stadig tilgængelig.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Din organisation opbevarer disse hemmeligheder i en teammappe. Flyt hver enkelt til en teammappe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Din organisation opbevarer denne type hemmelighed i en teammappe. Vælg en af dine teammapper eller en, du kan skrive i.", + "Your organisation requires two-factor login before you can open your vault.": "Din organisation kræver totrinslogin, før du kan åbne din boks.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Brugerne vælger, hvor længe udvidelsen forbliver låst op ved inaktivitet. Du angiver det længste, de må vælge.", + "Longest idle time before the extension locks": "Længste inaktive tid, før udvidelsen låser", + "1 minute": "1 minut", + "5 minutes": "5 minutter", + "15 minutes": "15 minutter", + "1 hour": "1 time", + "4 hours": "4 timer", + "Connector": "Connector", + "Directory (tenant) ID": "Mappe-id (tenant)", + "Application (client) ID": "Program-id (klient)", + "Data collection rule immutable ID": "Uforanderligt id for dataindsamlingsreglen", + "Stream name": "Streamnavn", + "Splunk index (optional)": "Splunk-indeks (valgfrit)", + "Sourcetype (optional)": "Sourcetype (valgfrit)", + "Leave blank to keep the current one": "Lad stå tomt for at beholde den nuværende", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Slutpunkt for dataindsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemmelighed (kun skrivning)", + "HEC token (write-only)": "HEC-token (kun skrivning)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Videresend tilladte revisionshændelser til Splunk, Microsoft Sentinel, en syslog-modtager eller en webhook. Beskeder indeholder kun rensede metadata: ingen hemmelig værdi, navn, login eller krypteret tekst forlader nogensinde serveren.", + "%n change waiting to sync": "%n ændring venter på synkronisering", + "%n changes waiting to sync": "%n ændringer venter på synkronisering", + "Changes that could not sync": "Ændringer, der ikke kunne synkroniseres", + "Choose a version": "Vælg en version", + "Copy value": "Kopiér værdi", + "Deleted": "Slettet", + "Discard": "Kassér", + "Keep my offline change": "Behold min offlineændring", + "Keep the server version": "Behold serverversionen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er skrivebeskyttet offline. Din administrator har ikke slået offlineredigering til.", + "Let users edit secrets offline": "Lad brugere redigere hemmeligheder offline", + "Not synced yet": "Ikke synkroniseret endnu", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offlineændringer gemmes på enheden, krypteret til brugeren, og synkroniseres ved næste onlineoplåsning. Deling, mapper og vedhæftninger kræver stadig forbindelse.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Redigeringer, flytninger og sletninger bliver på denne enhed og synkroniseres, når du er online igen. Deling og vedhæftninger kræver forbindelse.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Dine ændringer bliver på denne enhed og synkroniseres, når du er online igen. Sidst synkroniseret {when}.", + "Open my changes": "Åbn mine ændringer", + "Sharing needs a connection": "Deling kræver forbindelse", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Nogen ændrede denne hemmelighed på serveren, efter din offlinekopi blev lavet. Vælg, hvilken version du vil beholde.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronisér eller kassér dine offlineændringer, før du udskifter dine nøgler.", + "That password did not open your changes.": "Den adgangskode åbnede ikke dine ændringer.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offlinesnapshottet gemmer krypterede hemmeligheder (kan kun åbnes med nøglen afledt af brugerens hovedadgangskode, præcis som på serveren) og krypterer navne, URL'er og mappenavne i hvile. Offlineadgang er skrivebeskyttet, medmindre du tillader offlineredigering nedenfor. Slå det fra for enheder, der aldrig må cache loginoplysninger; når det slås fra, ryddes eksisterende cache ved næste indlæsning.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den tidligere boks-kopi er væk, så disse ændringer kan ikke åbnes.", + "The server version": "Serverversionen", + "This secret changed while you were offline": "Denne hemmelighed blev ændret, mens du var offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du slettede denne hemmelighed offline, men den er siden blevet ændret på serveren. Vælg, hvilken version du vil beholde.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Dine nøgler blev ændret på en anden enhed. Indtast din tidligere hovedadgangskode for at synkronisere dine offlineændringer, eller kassér dem.", + "Your offline change": "Din offlineændring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n nødkontakt havde en ventende adgangsanmodning, da din nøglerotation fjernede den. Tjek, hvem der spurgte, før du tilføjer nogen igen.","%n nødkontakter havde en ventende adgangsanmodning, da din nøglerotation fjernede dem. Tjek, hvem der spurgte, før du tilføjer nogen igen."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n element kan ikke gengives i CXF og springes over.","%n elementer kan ikke gengives i CXF og springes over."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n ældre version blev fjernet, fordi kun den nyeste historik kan overføres.","%n ældre versioner blev fjernet, fordi kun den nyeste historik kan overføres."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopi af en hemmelighed skal stadig krypteres og deles.","%n kopier af hemmeligheder skal stadig krypteres og deles."], + "_%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 hemmelighed kunne ikke dekrypteres og er ikke med i denne eksport.","%n hemmeligheder kunne ikke dekrypteres og er ikke med i denne eksport."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n hemmelighed kunne ikke dekrypteres med din gamle nøgle, så den blev ikke migreret.","%n hemmeligheder kunne ikke dekrypteres med din gamle nøgle, så de blev ikke migreret."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n hemmelighed blev ikke migreret.","%n hemmeligheder blev ikke migreret."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n hemmelighed er stadig krypteret med din tidligere nøgle.","%n hemmeligheder er stadig krypteret med din tidligere nøgle."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n hemmelighed blev sprunget over, fordi efterfølgeren endnu ikke har en kopi — tilføj efterfølgeren til mappen, og kør igen.","%n hemmeligheder blev sprunget over, fordi efterfølgeren endnu ikke har en kopi — tilføj efterfølgeren til mappen, og kør igen."], + "_%n secret_::_%n secrets_": ["%n hemmelighed","%n hemmeligheder"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n bruger i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.","%n brugere i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Afslut alligevel og mist adgangen til %n hemmelighed","Afslut alligevel og mist adgangen til %n hemmeligheder"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nyt medlem fik adgang til en teammappe.","%n nye medlemmer fik adgang til en teammappe."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Nøglerotation færdig. %n hemmelighed blev krypteret igen med din nye nøgle.","Nøglerotation færdig. %n hemmeligheder blev krypteret igen med din nye nøgle."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Tilbagekaldelse af den anden suite slettede %n nødadgangskontakt.","Tilbagekaldelse af den anden suite slettede %n nødadgangskontakter."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakt.","Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakter."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["set %n gang i lækager","set %n gange i lækager"], + "_shared with %n secret_::_shared with %n secrets_": ["delt med %n hemmelighed","delt med %n hemmeligheder"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Denne mappe indeholder %n hemmelighed direkte.","Denne mappe indeholder %n hemmeligheder direkte."], + "_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._": ["Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.","Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n ændring venter på synkronisering","%n ændringer venter på synkronisering"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Brugeren er stadig i gruppen {groups}, som er medlem af en teammappe. Fjern brugeren fra gruppen eller deaktiver kontoen.","Brugeren er stadig i grupperne {groups}, som er medlemmer af teammapper. Fjern brugeren fra grupperne eller deaktiver kontoen."], + "Allow approval from another device": "Tillad godkendelse fra en anden enhed", + "App": "App", + "Approve a new device": "Godkend en ny enhed", + "Approve from another device": "Godkend fra en anden enhed", + "Asked at": "Anmodet kl.", + "Check that the new device shows these words:": "Kontrollér, at den nye enhed viser disse ord:", + "Denied. If you did not ask, end your other sessions:": "Afvist. Hvis du ikke har anmodet om det, så afslut dine andre sessioner:", + "Device": "Enhed", + "IP address": "IP-adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lad brugere låse en ny browser op ved at godkende den fra en enhed, hvor Keepiq allerede er låst op.", + "New device approval": "Godkendelse af nye enheder", + "Nextcloud security settings": "Nextcloud-sikkerhedsindstillinger", + "Only approve a device you are using right now.": "Godkend kun en enhed, du bruger lige nu.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Åbn Keepiq på en enhed, hvor den er låst op, og godkend denne. Kontrollér, at den viser de samme ord:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den godkendende enhed forsegler oplåsningsnøglen til den nye enhed. Serveren sender den kun videre og kan ikke åbne den.", + "The master password is not right, or the request has ended.": "Hovedadgangskoden er forkert, eller anmodningen er afsluttet.", + "The request expired. Ask again or use your master password.": "Anmodningen er udløbet. Anmod igen, eller brug din hovedadgangskode.", + "The request was denied.": "Anmodningen blev afvist.", + "Too many requests. Try again in an hour or use your master password.": "For mange anmodninger. Prøv igen om en time, eller brug din hovedadgangskode.", + "Unknown device": "Ukendt enhed", + "Web app": "Webapp", + "A device": "En enhed", + "A new device asks to open your vault": "En ny enhed beder om at åbne din boks", + "%s asks to be approved. Only approve a device you are using right now.": "%s beder om godkendelse. Godkend kun en enhed, du bruger lige nu.", + "Access ends on (optional)": "Adgang ophører den (valgfrit)", + "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.": "Keepiqs apps viser eller kopierer ikke adgangskoden. En person med tekniske færdigheder kan stadig læse den fra sin egen enhed. Skift den, når adgangen ophører.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Denne hemmelighed er kun til brug. Log ind via Keepiq-browserudvidelsen.", + "Until {date}": "Indtil {date}", + "Use only": "Kun brug", + "Use only (can sign in, cannot view or copy)": "Kun brug (kan logge ind, kan ikke se eller kopiere)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logge ind med dette login via Keepiq-browserudvidelsen. Ejeren har valgt ikke at lade dig se eller kopiere det.", + "Your access ends on {date}": "Din adgang ophører den {date}", + "Your access to this secret has ended": "Din adgang til denne hemmelighed er ophørt", + "Your access to \"%s\" ends tomorrow": "Din adgang til \"%s\" ophører i morgen", + "Your access to \"%s\" has ended": "Din adgang til \"%s\" er ophørt", + "%1$s no longer has access to \"%2$s\"": "%1$s har ikke længere adgang til \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunne se denne adgangskode. Skift den, hvis %1$s ikke længere skal kende den.", + "%s could not view this password in Keepiq.": "%s kunne ikke se denne adgangskode i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} af {threshold} godkendelser", + "a recovery officer": "en gendannelsesansvarlig", + "Account recovery": "Kontogendannelse", + "Approvals needed": "Godkendelser krævet", + "Ask {user} which words they see, by phone or in person. They must be:": "Spørg {user}, hvilke ord de ser, over telefonen eller personligt. De skal være:", + "Check again": "Tjek igen", + "Create the recovery key": "Opret gendannelsesnøglen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Opret gendannelsesnøglen. Din browser laver den og giver hver ansvarlig en kopi, som kun de kan åbne.", + "Decline": "Afvis", + "Enrol in account recovery": "Tilmeld dig kontogendannelse", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Tilmeld dig, så din organisation kan hjælpe dig med at få din boks tilbage, hvis du glemmer din hovedadgangskode.", + "Every user is enrolled": "Alle brugere er tilmeldt", + "Finish the recovery in the browser you asked from.": "Afslut gendannelsen i den browser, du spurgte fra.", + "Forgot your master password?": "Glemt din hovedadgangskode?", + "Hand the key over": "Overdrag nøglen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lad brugere, der har glemt deres hovedadgangskode, få deres boks tilbage, godkendt af gendannelsesansvarlige, du udpeger.", + "New master password": "Ny hovedadgangskode", + "No one is asking to recover their account.": "Ingen beder om at gendanne deres konto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen gendannelsesnøgle endnu. En af de ansvarlige opretter den i sine Keepiq-indstillinger.", + "Off": "Fra", + "Officer {user} has no encryption set up yet.": "Den ansvarlige {user} har ikke opsat kryptering endnu.", + "Officers (user IDs, separated by commas)": "Ansvarlige (bruger-id'er, adskilt med komma)", + "Policy": "Politik", + "Publish this fingerprint internally, so users can check it before they enrol.": "Offentliggør dette fingeraftryk internt, så brugerne kan tjekke det, før de tilmelder sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Gendannet med hjælp fra {officer}. Skift din boksnøgle nu under Indstillinger, Sikkerhed: \"Min hovedadgangskode er kompromitteret\".", + "Recovery key fingerprint: {fingerprint}": "Gendannelsesnøglens fingeraftryk: {fingerprint}", + "Recovery officer": "Gendannelsesansvarlig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fjernede ansvarlige mister deres kopi nu, men kan have åbnet den før. Få en ansvarlig til at oprette en ny gendannelsesnøgle.", + "Repeat the new master password": "Gentag den nye hovedadgangskode", + "Retire this recovery key": "Udfas denne gendannelsesnøgle", + "Set the new master password": "Angiv den nye hovedadgangskode", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Gendannelsescertifikatet er ikke udstedt af denne Keepiq. Tilmeld dig ikke, og giv din administrator besked.", + "The words match, approve": "Ordene passer, godkend", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denne bruger er tilmeldt kontogendannelse. Gendannelse bevarer deres hemmeligheder; tilbagekaldelse sletter deres tilmelding.", + "Users may enrol": "Brugere må tilmelde sig", + "Withdraw from account recovery": "Afmeld dig kontogendannelse", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du er tilmeldt kontogendannelse. Gendannelsesnøglens fingeraftryk: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du er tilmeldt. Hvis du glemmer din hovedadgangskode, kan din organisation hjælpe dig med at få din boks tilbage.", + "Your key is back. Choose a new master password.": "Din nøgle er tilbage. Vælg en ny hovedadgangskode.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Dine gendannelsesansvarlige har fået besked. Læs disse ord op for dem, når de ringer eller mødes med dig:", + "You are now an account recovery officer": "Du er nu ansvarlig for kontogendannelse", + "%s asks to recover their account. Compare the words with them before you approve.": "%s beder om at gendanne sin konto. Sammenlign ordene med personen, før du godkender.", + "A user": "En bruger", + "Your account recovery request was declined": "Din anmodning om kontogendannelse blev afvist", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Din kontogendannelse er klar. Åbn Keepiq i den browser, du spurgte fra.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} beder om at låse en ny enhed op én gang. Hovedadgangskoden forbliver den samme.", + "Ask your organisation instead": "Spørg din organisation i stedet", + "The request ended. Ask again or use your master password.": "Anmodningen er afsluttet. Spørg igen, eller brug din hovedadgangskode.", + "Added by {user}": "Tilføjet af {user}", + "Editor": "Redaktør", + "Manager": "Administrator", + "Role of {member}": "Rolle for {member}", + "Team folders you manage": "Teammapper, du administrerer", + "Viewer": "Læser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopi af disse hemmeligheder, så de nye medlemmer har ikke fået dem endnu. Ejeren kan dele dem: {names}", + "Admin areas": "Administrationsområder", + "Give a group only the parts of Keepiq administration it needs.": "Giv en gruppe kun de dele af Keepiq-administrationen, den har brug for.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Uddeleger et eller flere områder til en gruppe på siden med administrationsrettigheder. Instansadministratorer har alle områder.", + "Open administration privileges": "Åbn administrationsrettigheder", + "Policies": "Politikker", + "Applications and machine access": "Applikationer og maskinadgang", + "People and offboarding": "Personer og fratrædelse", + "Audit and compliance": "Revision og overholdelse", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certifikatautoritet, vedhæftninger, offline-cache, lækagekontrol, hemmelighedstyper og sikkerhedskopier", + "master password, organisation password, vault policies, rotation, version history and trash": "hovedadgangskode, organisationsadgangskode, boksens politikker, rotation, versionshistorik og papirkurv", + "application queue, application requests and machine leases": "applikationskø, applikationsanmodninger og maskinlejemål", + "team offboarding, encryption suites and admin handover": "teamfratrædelse, krypteringssuiter og overtagelse ved administrator", + "audit log, compliance reports, SIEM export and honey alerts": "revisionslog, overholdelsesrapporter, SIEM-eksport og lokkealarmer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hvor mange versioner af en hemmelighed der gemmes, hvor længe, og hvor længe slettede hemmeligheder bliver i papirkurven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grænser for krypterede vedhæftede filer, håndhævet på serveren i gemte krypterede bytes.", + "Type the suite ID again to confirm": "Skriv pakke-ID'et igen for at bekræfte", + "This does not match the suite ID.": "Dette matcher ikke pakke-ID'et.", + "Confirm with your master password": "Bekræft med din masteradgangskode", + "Confirm": "Bekræft", + "That master password is not right.": "Den masteradgangskode er ikke korrekt.", + "You are sharing with someone new. Enter your master password to confirm.": "Du deler med en ny person. Indtast din masteradgangskode for at bekræfte.", + "Enter your master password to confirm this share.": "Indtast din masteradgangskode for at bekræfte denne deling.", + "Enter your master password to confirm this delegation.": "Indtast din masteradgangskode for at bekræfte denne delegering.", + "Approve {member}": "Godkend {member}", + "Recipient": "Modtager", + "No vault yet": "Har endnu ingen boks", + "No matching users": "Ingen matchende brugere", + "Partner organisations": "Partnerorganisationer", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Udveksl hemmeligheder med en anden Keepiq. Begge administratorer tilføjer hinanden og sammenligner rodfingeraftrykkene i telefonen eller ansigt til ansigt, før de gemmer.", + "Federation needs Nextcloud 33 or later.": "Føderation kræver Nextcloud 33 eller nyere.", + "Your root fingerprint": "Dit rodfingeraftryk", + "No partners yet.": "Ingen partnere endnu.", + "Users here may share to this partner": "Brugere her må dele med denne partner", + "This partner may share to users here": "Denne partner må dele med brugere her", + "Partner address": "Partnerens adresse", + "Check partner": "Tjek partner", + "Partner root fingerprint": "Partnerens rodfingeraftryk", + "I compared this fingerprint with the partner's administrator": "Jeg har sammenlignet dette fingeraftryk med partnerens administrator", + "Add partner": "Tilføj partner", + "A secret from another organisation": "En hemmelighed fra en anden organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delt \"%2$s\" med dig. Accepter den under Modtaget fra andre organisationer.", + "Incoming from other organisations": "Modtaget fra andre organisationer", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisationer kan dele en hemmelighed med dig. Accepter den for at beholde en skrivebeskyttet kopi i din boks.", + "Nothing shared with you yet": "Intet delt med dig endnu", + "Secrets that people in partner organisations share with you appear here.": "Hemmeligheder, som personer i partnerorganisationer deler med dig, vises her.", + "From {sender}": "Fra {sender}", + "Accept": "Accepter", + "Open in vault": "Åbn i boks", + "The other organisation did not hand over the secret. Try again later.": "Den anden organisation overdrog ikke hemmeligheden. Prøv igen senere.", + "Set up your vault before you accept a shared secret.": "Opsæt din boks, før du accepterer en delt hemmelighed.", + "Something went wrong. Try again.": "Noget gik galt. Prøv igen.", + "Waiting for your answer": "Venter på dit svar", + "In your vault, read-only": "I din boks, skrivebeskyttet", + "Withdrawn by the sender": "Trukket tilbage af afsenderen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delte dette fra en anden organisation. Du kan læse det, men ikke ændre eller dele det.", + "Someone": "Nogen", + "Share with someone at another organisation": "Del med en person i en anden organisation", + "Their account at the other organisation": "Personens konto i den anden organisation", + "Check account": "Tjek konto", + "Certificate fingerprint of {account}": "Certifikatets fingeraftryk for {account}", + "Compare it with them by phone if you want to be sure.": "Sammenlign det med personen over telefonen, hvis du vil være sikker.", + "Shared. {account} can accept it in their own vault.": "Delt. {account} kan acceptere den i sin egen boks.", + "The certificate could not be verified. Nothing was shared.": "Certifikatet kunne ikke bekræftes. Intet blev delt.", + "That organisation is not one of your partners.": "Den organisation er ikke en af dine partnere.", + "No one with that account can receive secrets from you.": "Ingen med den konto kan modtage hemmeligheder fra dig.", + "The other organisation did not answer. Try again later.": "Den anden organisation svarede ikke. Prøv igen senere.", + "This secret is already shared with that account.": "Denne hemmelighed er allerede delt med den konto.", + "Other organisations": "Andre organisationer", + "Receive secrets from other organisations": "Modtag hemmeligheder fra andre organisationer", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisationer kan så finde din konto og dele hemmeligheder med dig. Du accepterer hver enkelt selv.", + "Shared": "Delt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Sat på pause: modtagerens certifikat eller partnerskabet er ændret. Tilbagekald den, eller del igen.", + "Their organisation did not get the last change. Revoke it or share again.": "Modtagerens organisation fik ikke den seneste ændring. Tilbagekald den, eller del igen.", + "Being withdrawn": "Trækkes tilbage", + "Shared with another organisation": "Delt med en anden organisation", + "Change sent to another organisation": "Ændring sendt til en anden organisation", + "Share with another organisation revoked": "Deling med en anden organisation tilbagekaldt", + "Share with another organisation paused": "Deling med en anden organisation sat på pause", + "Another organisation did not get a change": "En anden organisation fik ikke en ændring", + "Secret received from another organisation": "Hemmelighed modtaget fra en anden organisation", + "Secret from another organisation accepted": "Hemmelighed fra en anden organisation accepteret", + "Secret from another organisation declined": "Hemmelighed fra en anden organisation afvist", + "Copy from another organisation updated": "Kopi fra en anden organisation opdateret", + "Copy from another organisation removed": "Kopi fra en anden organisation fjernet", + "Declined: they removed their copy. Share again if they need it.": "Afvist: modtageren har fjernet sin kopi. Del igen, hvis de har brug for den.", + "Recipient at another organisation removed their copy": "Modtager i en anden organisation har fjernet sin kopi", + "Removed the user from %n team folder.": "Brugeren blev fjernet fra %n teammappe.", + "Removed the user from %n team folders.": "Brugeren blev fjernet fra %n teammapper.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Brugeren blev fjernet fra %n teammappe.","Brugeren blev fjernet fra %n teammapper."], + "A restored copy came from a share that has ended. It stays read-only.": "En gendannet kopi kom fra en deling, der er ophørt. Den forbliver skrivebeskyttet.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisationen, der delte en gendannet kopi, kunne ikke nås. Kopien forbliver skrivebeskyttet og følger ikke deres ændringer.", + "Recipient at another organisation restored their copy": "Modtager i en anden organisation har gendannet sin kopi" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/da.json b/l10n/da.json index 5c843470c..3490d19ab 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Delt med grupper", + "Not shared with any group yet.": "Endnu ikke delt med nogen gruppe.", + "Revoke the share with {group}": "Tilbagekald deling med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer modtog den, {skipped} gjorde ikke, fordi de endnu ikke har opsat kryptering.", + "Search groups": "Søg efter grupper", + "Failed to share": "Deling mislykkedes", + "Columns": "Kolonner", + "Column {number}": "Kolonne {number}", + "Map one column to Name. Every secret needs a name.": "Knyt én kolonne til Navn. Hver hemmelighed skal have et navn.", + "Notes": "Noter", + "Do not import": "Importer ikke", + "Hide this value": "Skjul denne værdi", + "Show this value": "Vis denne værdi", + "Defaults": "Standarder", + "New secrets start as this type, and your secret list opens in this view.": "Nye hemmeligheder starter som denne type, og din hemmelighedsliste åbner i denne visning.", + "Default item type": "Standardelementtype", + "Cards": "Kort", + "Table": "Tabel", + "Could not save your default": "Din standard kunne ikke gemmes", + "Recently used": "Senest brugt", + "Opened": "Åbnet", + "You have not opened any secrets yet": "Du har ikke åbnet nogen hemmeligheder endnu", + "Could not delete the item type.": "Elementtypen kunne ikke slettes.", + "Could not load the item types.": "Elementtyperne kunne ikke indlæses.", + "Could not save the item type.": "Elementtypen kunne ikke gemmes.", + "Delete item type": "Slet elementtype", + "Edit item type": "Rediger elementtype", + "Fields": "Felter", + "Fields: {count}": "Felter: {count}", + "Hidden": "Skjult", + "Item types": "Elementtyper", + "Move up": "Flyt op", + "New item type": "Ny elementtype", + "No item types defined yet.": "Der er ikke defineret nogen elementtyper endnu.", + "Required": "Påkrævet", + "Text": "Tekst", + "This field is required": "Dette felt er påkrævet", + "Web address": "Webadresse", + "{label} (required)": "{label} (påkrævet)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Slet “{name}”? Hemmeligheder af denne type kan stadig læses og bliver til Log ind-elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtyper, du definerer her, vises for alle i dialogen Ny hemmelighed med de felter, du vælger.", + "Secret moved to the trash": "Hemmelighed flyttet til papirkurven", + "Secret restored from the trash": "Hemmelighed gendannet fra papirkurven", + "Secret deleted for good": "Hemmelighed slettet endeligt", + "Secret archived": "Hemmelighed arkiveret", + "Secret unarchived": "Hemmelighed hentet ud af arkivet", + "Unarchive": "Hent ud af arkivet", + "Could not archive the secret": "Hemmeligheden kunne ikke arkiveres", + "Could not unarchive the secret": "Hemmeligheden kunne ikke hentes ud af arkivet", + "Archive {count} secrets": "Arkivér {count} hemmeligheder", + "Unarchive {count} secrets": "Hent {count} hemmeligheder ud af arkivet", + "Restore {count} secrets": "Gendan {count} hemmeligheder", + "Delete {count} secrets for good": "Slet {count} hemmeligheder endeligt", + "Done for {ok} of {total} secrets": "Færdig for {ok} af {total} hemmeligheder", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverede hemmeligheder forsvinder fra boksens liste, søgning, autoudfyldning og sundhedsrapporten. De beholder deres delinger. Du finder dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "Disse hemmeligheder kommer tilbage på boksens liste, i søgning og autoudfyldning.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Disse hemmeligheder kommer tilbage på boksens liste. Deres gamle delinger kommer ikke tilbage, så del dem igen, hvor det er nødvendigt.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dette sletter hemmelighederne med deres vedhæftninger og versionshistorik. Det kan ikke fortrydes.", + "Delete for good": "Slet endeligt", + "Trash": "Papirkurv", + "The trash is empty": "Papirkurven er tom", + "No archived secrets": "Ingen arkiverede hemmeligheder", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Slettede hemmeligheder venter her, til opbevaringsperioden udløber, derefter slettes de endeligt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivér en hemmelighed fra dens detaljepanel for at holde den ude af boksens liste, søgning og autoudfyldning.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grænser for krypterede vedhæftede filer (håndhævet på serveren i gemte krypterede bytes), opbevaring af versionshistorik og hvor længe slettede hemmeligheder bliver i papirkurven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dage en slettet hemmelighed bliver i papirkurven (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dette flytter hemmeligheden til papirkurven og afslutter dens delinger nu. Du kan gendanne den fra papirkurven, indtil opbevaringsperioden udløber: 30 dage, medmindre din administrator har ændret det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dette flytter {count} hemmeligheder til papirkurven og afslutter deres delinger nu. Du kan gendanne dem fra papirkurven, indtil opbevaringsperioden udløber.", + "Remove {name} from favourites": "Fjern {name} fra favoritter", + "Add {name} to favourites": "Føj {name} til favoritter", + "Could not change the favourite": "Favoritten kunne ikke ændres", + "Remove from favourites": "Fjern fra favoritter", + "Add to favourites": "Føj til favoritter", + "Tags": "Mærker", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Mærker er ikke krypteret. Serveradministratorer kan læse dem, ligesom mappenavne.", + "Favourites": "Favoritter", + "Filter by tag": "Filtrer efter mærke", + "All tags": "Alle mærker", + "Last used": "Sidst brugt", + "Tags for {count} secrets": "Mærker for {count} hemmeligheder", + "Tag": "Mærke", + "Remove tag": "Fjern mærke", + "Add tag": "Tilføj mærke", + "Could not change the tags. Try again.": "Mærkerne kunne ikke ændres. Prøv igen.", + "Could not approve the application. It is still in the queue.": "Ansøgningen kunne ikke godkendes. Den er stadig i køen.", + "Could not reject the application. It is still in the queue.": "Ansøgningen kunne ikke afvises. Den er stadig i køen.", + "Removed the user from {count} team folders.": "Brugeren blev fjernet fra {count} teammapper.", + "Approve a share": "Godkend en deling", + "This approval link is incomplete. Open it again from the notification.": "Dette godkendelseslink er ufuldstændigt. Åbn det igen fra notifikationen.", + "Deny": "Afvis", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} er blevet medlem af en gruppe, du deler en hemmelighed med. Vil du også dele hemmeligheden med vedkommende?", + "{requester} asks you to share a secret with {user}.": "{requester} beder dig dele en hemmelighed med {user}.", + "Shared. The recipient can now open the secret.": "Delt. Modtageren kan nu åbne hemmeligheden.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Modtageren har ikke konfigureret Keepiq endnu, så intet blev delt. Prøv igen, når det er gjort.", + "Could not share the secret. Only its owner can approve this.": "Hemmeligheden kunne ikke deles. Kun ejeren kan godkende dette.", + "Could not share the secret. Try again.": "Hemmeligheden kunne ikke deles. Prøv igen.", + "Denied. Nothing was shared.": "Afvist. Intet blev delt.", + "Could not deny the request. Try again.": "Anmodningen kunne ikke afvises. Prøv igen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s beder dig dele hemmeligheden \"%2$s\" med %3$s.", + "Expires on (optional)": "Udløber den (valgfrit)", + "Hand over to": "Overdrag til", + "Choose a recipient": "Vælg en modtager", + "Hand over temporarily": "Overdrag midlertidigt", + "Expiry rules": "Udløbsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Angiv, hvor længe adgangskoder af én elementtype eller i én mappe må leve, og hvornår du skal mindes om det. Når flere datoer gælder, tæller den tidligste.", + "Delete rule": "Slet regel", + "Set by your administrator": "Angivet af din administrator", + "No expiry rules yet.": "Ingen udløbsregler endnu.", + "Applies to": "Gælder for", + "Item type": "Elementtype", + "Maximum age in days (empty for reminders only)": "Maksimal alder i dage (tom for kun påmindelser)", + "Remind me this many days before, comma separated": "Mind mig om det så mange dage før, kommasepareret", + "Save rule": "Gem regel", + "An item type": "En elementtype", + "A folder": "En mappe", + "Folder {name}": "Mappe {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Udløber efter {days} dage", + "Reminders {days} days before": "Påmindelser {days} dage før", + "Could not save the expiry rule.": "Udløbsreglen kunne ikke gemmes.", + "Could not delete the expiry rule.": "Udløbsreglen kunne ikke slettes.", + "All statuses": "Alle statusser", + "Compromised": "Kompromitteret", + "Could not load the members.": "Medlemmerne kunne ikke indlæses.", + "Emergency contact": "Nødkontakt", + "Leaving user": "Fratrædende bruger", + "No": "Nej", + "No users match this filter.": "Ingen brugere matcher dette filter.", + "Not set up": "Ikke oprettet", + "Revoke suite": "Tilbagekald suite", + "Revoked": "Tilbagekaldt", + "Search users": "Søg efter brugere", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se hvilke brugere der har oprettet en boks. Start offboarding eller tilbagekald en suite fra en række.", + "Successor": "Efterfølger", + "Team folders": "Teammapper", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Brugeren er stadig i gruppen {groups}, som er medlem af en teammappe. Fjern brugeren fra gruppen eller deaktiver kontoen.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Brugeren er stadig i grupperne {groups}, som er medlemmer af teammapper. Fjern brugeren fra grupperne eller deaktiver kontoen.", + "Vault status": "Boksstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-eksport er UKRYPTERET. Alle adgangskoder og logins vil kunne læses som klartekst i den hentede fil. Opbevar den sikkert, og slet den umiddelbart efter brug.", + "Root certificate expiring soon": "Rodcertifikatet udløber snart", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Boksens rodcertifikat udløber om %1$d dag(e). Forny det inden da. Fornyelse signerer hver krypteringssuite igen.", + "Compromise recovery aborted": "Gendannelse efter kompromittering afbrudt", + "Key rotation ended by a compromise revoke": "Nøglerotation afsluttet af en tilbagekaldelse pga. kompromittering", + "Encryption suite revoke refused": "Tilbagekaldelse af krypteringssuite afvist", + "Master password proof refused": "Bevis for hovedadgangskode afvist", + "Your current master password": "Din nuværende hovedadgangskode", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nødkontakt havde en ventende adgangsanmodning, da din nøglerotation fjernede den. Tjek, hvem der spurgte, før du tilføjer nogen igen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nødkontakter havde en ventende adgangsanmodning, da din nøglerotation fjernede dem. Tjek, hvem der spurgte, før du tilføjer nogen igen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Disse nødkontakter blev ikke overført til din nye nøgle. Deres nødadgang blev fjernet. Tilføj dem igen under Nødadgang, hvis du stadig ønsker dem.", + "Renew root certificate": "Forny rodcertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dette opretter et nyt rod- og mellemcertifikat. Hver aktiv krypteringspakke signeres igen. Det kan ikke fortrydes.", + "Renew root": "Forny rod", + "Root renewed. {n} encryption suites signed again.": "Rod fornyet. {n} krypteringspakker signeret igen.", + "Could not renew the root certificate.": "Rodcertifikatet kunne ikke fornyes.", + "Lease policy for this application": "Lejepolitik for denne applikation", + "In force now: {default} seconds by default, {max} seconds at most.": "Gælder nu: {default} sekunder som standard, højst {max} sekunder.", + "Leases are not renewable": "Lejemål kan ikke fornyes", + "Lease policy saved.": "Lejepolitik gemt.", + "Leave a field empty to use the instance value.": "Lad et felt stå tomt for at bruge instansens værdi.", + "Instance value: {value}": "Instansens værdi: {value}", + "Renewal": "Fornyelse", + "Use the instance value ({value})": "Brug instansens værdi ({value})", + "Allowed": "Tilladt", + "Not allowed": "Ikke tilladt", + "Save lease policy": "Gem lejepolitik", + "Only an administrator can change this policy.": "Kun en administrator kan ændre denne politik.", + "Could not save the lease policy.": "Lejepolitikken kunne ikke gemmes.", + "{member} got access from {confirmer}.": "{member} fik adgang fra {confirmer}.", + "Automatically confirm new team folder members": "Bekræft nye teammappemedlemmer automatisk", + "Gave %n new member access to a team folder.": "%n nyt medlem fik adgang til en teammappe.", + "Gave %n new members access to a team folder.": "%n nye medlemmer fik adgang til en teammappe.", + "Give new team folder members access without waiting for the folder owner.": "Giv nye teammappemedlemmer adgang uden at vente på mappens ejer.", + "New team folder members": "Nye teammappemedlemmer", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ejeren eller et medlem med skriveadgang bekræfter dem fra sin åbne boks. Keepiq dekrypterer aldrig på serveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Venter på at et medlem med skriveadgang åbner Keepiq. Du kan også dele nu.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del af kompromitteringsreaktionen mislykkedes ({failed} trin). Tjek serverloggen, og tilbagekald derefter suiten igen for at afslutte.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dette tilbagekaldte også suite {suite} og afsluttede nøglemigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakter.", + "A suite revoked as compromised cannot be reinstated.": "En suite, der er tilbagekaldt som kompromitteret, kan ikke genindsættes.", + "Archives to keep": "Arkiver der skal gemmes", + "Back up every vault automatically": "Sikkerhedskopiér hver boks automatisk", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sikkerhedskopiér hver boks efter en plan. Arkiver indeholder kun krypteret tekst og gendannes med occ.", + "Back up now": "Sikkerhedskopiér nu", + "Backup public key (PEM, optional)": "Offentlig backupnøgle (PEM, valgfri)", + "Backup requested for the next cron run": "Backup anmodet til næste cron-kørsel", + "Encrypted": "Krypteret", + "Every (hours)": "Hver (timer)", + "Last backup {when} failed: {error}": "Seneste backup {when} mislykkedes: {error}", + "Last backup {when} succeeded.": "Seneste backup {when} lykkedes.", + "No archives yet.": "Ingen arkiver endnu.", + "Size": "Størrelse", + "Vault backups": "Boksbackups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nøgle krypteres hvert arkiv til den. Opbevar den private nøgle uden for denne server: du skal bruge den til at kontrollere eller gendanne.", + "Written": "Skrevet", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n bruger i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n brugere i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backupkoder tæller ikke. Hvis dine brugere logger ind via en identitetsudbyder med sin egen anden faktor, så udelad deres grupper.", + "Block personal vault export": "Bloker eksport af personlig boks", + "Keep work logins in team folders": "Opbevar arbejdslogins i teammapper", + "Move to a team folder": "Flyt til en teammappe", + "Not in a team folder": "Ikke i en teammappe", + "Only for these groups (empty is everyone)": "Kun for disse grupper (tom betyder alle)", + "Require two-factor login before the vault opens": "Kræv totrinslogin, før boksen åbner", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler for hver boks. Hver regel gælder for alle eller kun for de grupper, du vælger.", + "Secret types that belong in a team folder": "Hemmelighedstyper, der hører hjemme i en teammappe", + "Set up two-factor login": "Opsæt totrinslogin", + "Team folder you can write to": "Teammappe, du kan skrive i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Brugere kan ikke downloade en backup-, CSV- eller overførselsfil. Deres personlige datapakke er stadig tilgængelig.", + "Users cannot save these secret types in a personal folder.": "Brugere kan ikke gemme disse hemmelighedstyper i en personlig mappe.", + "Vault policies": "Boksregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Din organisation tillader ikke eksport af din personlige boks. Din personlige datapakke i dine indstillinger er stadig tilgængelig.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Din organisation opbevarer disse hemmeligheder i en teammappe. Flyt hver enkelt til en teammappe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Din organisation opbevarer denne type hemmelighed i en teammappe. Vælg en af dine teammapper eller en, du kan skrive i.", + "Your organisation requires two-factor login before you can open your vault.": "Din organisation kræver totrinslogin, før du kan åbne din boks.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Brugerne vælger, hvor længe udvidelsen forbliver låst op ved inaktivitet. Du angiver det længste, de må vælge.", + "Longest idle time before the extension locks": "Længste inaktive tid, før udvidelsen låser", + "1 minute": "1 minut", + "5 minutes": "5 minutter", + "15 minutes": "15 minutter", + "1 hour": "1 time", + "4 hours": "4 timer", + "Connector": "Connector", + "Directory (tenant) ID": "Mappe-id (tenant)", + "Application (client) ID": "Program-id (klient)", + "Data collection rule immutable ID": "Uforanderligt id for dataindsamlingsreglen", + "Stream name": "Streamnavn", + "Splunk index (optional)": "Splunk-indeks (valgfrit)", + "Sourcetype (optional)": "Sourcetype (valgfrit)", + "Leave blank to keep the current one": "Lad stå tomt for at beholde den nuværende", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Slutpunkt for dataindsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemmelighed (kun skrivning)", + "HEC token (write-only)": "HEC-token (kun skrivning)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Videresend tilladte revisionshændelser til Splunk, Microsoft Sentinel, en syslog-modtager eller en webhook. Beskeder indeholder kun rensede metadata: ingen hemmelig værdi, navn, login eller krypteret tekst forlader nogensinde serveren.", + "%n change waiting to sync": "%n ændring venter på synkronisering", + "%n changes waiting to sync": "%n ændringer venter på synkronisering", + "Changes that could not sync": "Ændringer, der ikke kunne synkroniseres", + "Choose a version": "Vælg en version", + "Copy value": "Kopiér værdi", + "Deleted": "Slettet", + "Discard": "Kassér", + "Keep my offline change": "Behold min offlineændring", + "Keep the server version": "Behold serverversionen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er skrivebeskyttet offline. Din administrator har ikke slået offlineredigering til.", + "Let users edit secrets offline": "Lad brugere redigere hemmeligheder offline", + "Not synced yet": "Ikke synkroniseret endnu", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offlineændringer gemmes på enheden, krypteret til brugeren, og synkroniseres ved næste onlineoplåsning. Deling, mapper og vedhæftninger kræver stadig forbindelse.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Redigeringer, flytninger og sletninger bliver på denne enhed og synkroniseres, når du er online igen. Deling og vedhæftninger kræver forbindelse.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Dine ændringer bliver på denne enhed og synkroniseres, når du er online igen. Sidst synkroniseret {when}.", + "Open my changes": "Åbn mine ændringer", + "Sharing needs a connection": "Deling kræver forbindelse", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Nogen ændrede denne hemmelighed på serveren, efter din offlinekopi blev lavet. Vælg, hvilken version du vil beholde.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronisér eller kassér dine offlineændringer, før du udskifter dine nøgler.", + "That password did not open your changes.": "Den adgangskode åbnede ikke dine ændringer.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offlinesnapshottet gemmer krypterede hemmeligheder (kan kun åbnes med nøglen afledt af brugerens hovedadgangskode, præcis som på serveren) og krypterer navne, URL'er og mappenavne i hvile. Offlineadgang er skrivebeskyttet, medmindre du tillader offlineredigering nedenfor. Slå det fra for enheder, der aldrig må cache loginoplysninger; når det slås fra, ryddes eksisterende cache ved næste indlæsning.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den tidligere boks-kopi er væk, så disse ændringer kan ikke åbnes.", + "The server version": "Serverversionen", + "This secret changed while you were offline": "Denne hemmelighed blev ændret, mens du var offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du slettede denne hemmelighed offline, men den er siden blevet ændret på serveren. Vælg, hvilken version du vil beholde.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Dine nøgler blev ændret på en anden enhed. Indtast din tidligere hovedadgangskode for at synkronisere dine offlineændringer, eller kassér dem.", + "Your offline change": "Din offlineændring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n nødkontakt havde en ventende adgangsanmodning, da din nøglerotation fjernede den. Tjek, hvem der spurgte, før du tilføjer nogen igen.", + "%n nødkontakter havde en ventende adgangsanmodning, da din nøglerotation fjernede dem. Tjek, hvem der spurgte, før du tilføjer nogen igen." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n element kan ikke gengives i CXF og springes over.", + "%n elementer kan ikke gengives i CXF og springes over." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n ældre version blev fjernet, fordi kun den nyeste historik kan overføres.", + "%n ældre versioner blev fjernet, fordi kun den nyeste historik kan overføres." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopi af en hemmelighed skal stadig krypteres og deles.", + "%n kopier af hemmeligheder skal stadig krypteres og deles." + ], + "_%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 hemmelighed kunne ikke dekrypteres og er ikke med i denne eksport.", + "%n hemmeligheder kunne ikke dekrypteres og er ikke med i denne eksport." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n hemmelighed kunne ikke dekrypteres med din gamle nøgle, så den blev ikke migreret.", + "%n hemmeligheder kunne ikke dekrypteres med din gamle nøgle, så de blev ikke migreret." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n hemmelighed blev ikke migreret.", + "%n hemmeligheder blev ikke migreret." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n hemmelighed er stadig krypteret med din tidligere nøgle.", + "%n hemmeligheder er stadig krypteret med din tidligere nøgle." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n hemmelighed blev sprunget over, fordi efterfølgeren endnu ikke har en kopi — tilføj efterfølgeren til mappen, og kør igen.", + "%n hemmeligheder blev sprunget over, fordi efterfølgeren endnu ikke har en kopi — tilføj efterfølgeren til mappen, og kør igen." + ], + "_%n secret_::_%n secrets_": [ + "%n hemmelighed", + "%n hemmeligheder" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n bruger i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til.", + "%n brugere i omfanget har endnu ikke totrinslogin og kan ikke åbne boksen, mens dette er slået til." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Afslut alligevel og mist adgangen til %n hemmelighed", + "Afslut alligevel og mist adgangen til %n hemmeligheder" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nyt medlem fik adgang til en teammappe.", + "%n nye medlemmer fik adgang til en teammappe." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Nøglerotation færdig. %n hemmelighed blev krypteret igen med din nye nøgle.", + "Nøglerotation færdig. %n hemmeligheder blev krypteret igen med din nye nøgle." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakt.", + "Tilbagekaldelse af den anden suite slettede %n nødadgangskontakter." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakt.", + "Tilbagekaldelsen af denne suite fjernede %n nødadgangskontakter." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "set %n gang i lækager", + "set %n gange i lækager" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "delt med %n hemmelighed", + "delt med %n hemmeligheder" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Denne mappe indeholder %n hemmelighed direkte.", + "Denne mappe indeholder %n hemmeligheder direkte." + ], + "_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._": [ + "Din nøglerotation fjernede %n nødkontakt. Tjek Nødadgang, og tilføj den igen, hvis du stadig ønsker den.", + "Din nøglerotation fjernede %n nødkontakter. Tjek Nødadgang, og tilføj dem igen, hvis du stadig ønsker dem." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n ændring venter på synkronisering", + "%n ændringer venter på synkronisering" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Brugeren er stadig i gruppen {groups}, som er medlem af en teammappe. Fjern brugeren fra gruppen eller deaktiver kontoen.", + "Brugeren er stadig i grupperne {groups}, som er medlemmer af teammapper. Fjern brugeren fra grupperne eller deaktiver kontoen." + ], + "Allow approval from another device": "Tillad godkendelse fra en anden enhed", + "App": "App", + "Approve a new device": "Godkend en ny enhed", + "Approve from another device": "Godkend fra en anden enhed", + "Asked at": "Anmodet kl.", + "Check that the new device shows these words:": "Kontrollér, at den nye enhed viser disse ord:", + "Denied. If you did not ask, end your other sessions:": "Afvist. Hvis du ikke har anmodet om det, så afslut dine andre sessioner:", + "Device": "Enhed", + "IP address": "IP-adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lad brugere låse en ny browser op ved at godkende den fra en enhed, hvor Keepiq allerede er låst op.", + "New device approval": "Godkendelse af nye enheder", + "Nextcloud security settings": "Nextcloud-sikkerhedsindstillinger", + "Only approve a device you are using right now.": "Godkend kun en enhed, du bruger lige nu.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Åbn Keepiq på en enhed, hvor den er låst op, og godkend denne. Kontrollér, at den viser de samme ord:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den godkendende enhed forsegler oplåsningsnøglen til den nye enhed. Serveren sender den kun videre og kan ikke åbne den.", + "The master password is not right, or the request has ended.": "Hovedadgangskoden er forkert, eller anmodningen er afsluttet.", + "The request expired. Ask again or use your master password.": "Anmodningen er udløbet. Anmod igen, eller brug din hovedadgangskode.", + "The request was denied.": "Anmodningen blev afvist.", + "Too many requests. Try again in an hour or use your master password.": "For mange anmodninger. Prøv igen om en time, eller brug din hovedadgangskode.", + "Unknown device": "Ukendt enhed", + "Web app": "Webapp", + "A device": "En enhed", + "A new device asks to open your vault": "En ny enhed beder om at åbne din boks", + "%s asks to be approved. Only approve a device you are using right now.": "%s beder om godkendelse. Godkend kun en enhed, du bruger lige nu.", + "Access ends on (optional)": "Adgang ophører den (valgfrit)", + "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.": "Keepiqs apps viser eller kopierer ikke adgangskoden. En person med tekniske færdigheder kan stadig læse den fra sin egen enhed. Skift den, når adgangen ophører.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Denne hemmelighed er kun til brug. Log ind via Keepiq-browserudvidelsen.", + "Until {date}": "Indtil {date}", + "Use only": "Kun brug", + "Use only (can sign in, cannot view or copy)": "Kun brug (kan logge ind, kan ikke se eller kopiere)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logge ind med dette login via Keepiq-browserudvidelsen. Ejeren har valgt ikke at lade dig se eller kopiere det.", + "Your access ends on {date}": "Din adgang ophører den {date}", + "Your access to this secret has ended": "Din adgang til denne hemmelighed er ophørt", + "Your access to \"%s\" ends tomorrow": "Din adgang til \"%s\" ophører i morgen", + "Your access to \"%s\" has ended": "Din adgang til \"%s\" er ophørt", + "%1$s no longer has access to \"%2$s\"": "%1$s har ikke længere adgang til \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunne se denne adgangskode. Skift den, hvis %1$s ikke længere skal kende den.", + "%s could not view this password in Keepiq.": "%s kunne ikke se denne adgangskode i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} af {threshold} godkendelser", + "a recovery officer": "en gendannelsesansvarlig", + "Account recovery": "Kontogendannelse", + "Approvals needed": "Godkendelser krævet", + "Ask {user} which words they see, by phone or in person. They must be:": "Spørg {user}, hvilke ord de ser, over telefonen eller personligt. De skal være:", + "Check again": "Tjek igen", + "Create the recovery key": "Opret gendannelsesnøglen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Opret gendannelsesnøglen. Din browser laver den og giver hver ansvarlig en kopi, som kun de kan åbne.", + "Decline": "Afvis", + "Enrol in account recovery": "Tilmeld dig kontogendannelse", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Tilmeld dig, så din organisation kan hjælpe dig med at få din boks tilbage, hvis du glemmer din hovedadgangskode.", + "Every user is enrolled": "Alle brugere er tilmeldt", + "Finish the recovery in the browser you asked from.": "Afslut gendannelsen i den browser, du spurgte fra.", + "Forgot your master password?": "Glemt din hovedadgangskode?", + "Hand the key over": "Overdrag nøglen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lad brugere, der har glemt deres hovedadgangskode, få deres boks tilbage, godkendt af gendannelsesansvarlige, du udpeger.", + "New master password": "Ny hovedadgangskode", + "No one is asking to recover their account.": "Ingen beder om at gendanne deres konto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen gendannelsesnøgle endnu. En af de ansvarlige opretter den i sine Keepiq-indstillinger.", + "Off": "Fra", + "Officer {user} has no encryption set up yet.": "Den ansvarlige {user} har ikke opsat kryptering endnu.", + "Officers (user IDs, separated by commas)": "Ansvarlige (bruger-id'er, adskilt med komma)", + "Policy": "Politik", + "Publish this fingerprint internally, so users can check it before they enrol.": "Offentliggør dette fingeraftryk internt, så brugerne kan tjekke det, før de tilmelder sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Gendannet med hjælp fra {officer}. Skift din boksnøgle nu under Indstillinger, Sikkerhed: \"Min hovedadgangskode er kompromitteret\".", + "Recovery key fingerprint: {fingerprint}": "Gendannelsesnøglens fingeraftryk: {fingerprint}", + "Recovery officer": "Gendannelsesansvarlig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fjernede ansvarlige mister deres kopi nu, men kan have åbnet den før. Få en ansvarlig til at oprette en ny gendannelsesnøgle.", + "Repeat the new master password": "Gentag den nye hovedadgangskode", + "Retire this recovery key": "Udfas denne gendannelsesnøgle", + "Set the new master password": "Angiv den nye hovedadgangskode", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Gendannelsescertifikatet er ikke udstedt af denne Keepiq. Tilmeld dig ikke, og giv din administrator besked.", + "The words match, approve": "Ordene passer, godkend", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denne bruger er tilmeldt kontogendannelse. Gendannelse bevarer deres hemmeligheder; tilbagekaldelse sletter deres tilmelding.", + "Users may enrol": "Brugere må tilmelde sig", + "Withdraw from account recovery": "Afmeld dig kontogendannelse", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du er tilmeldt kontogendannelse. Gendannelsesnøglens fingeraftryk: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du er tilmeldt. Hvis du glemmer din hovedadgangskode, kan din organisation hjælpe dig med at få din boks tilbage.", + "Your key is back. Choose a new master password.": "Din nøgle er tilbage. Vælg en ny hovedadgangskode.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Dine gendannelsesansvarlige har fået besked. Læs disse ord op for dem, når de ringer eller mødes med dig:", + "You are now an account recovery officer": "Du er nu ansvarlig for kontogendannelse", + "%s asks to recover their account. Compare the words with them before you approve.": "%s beder om at gendanne sin konto. Sammenlign ordene med personen, før du godkender.", + "A user": "En bruger", + "Your account recovery request was declined": "Din anmodning om kontogendannelse blev afvist", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Din kontogendannelse er klar. Åbn Keepiq i den browser, du spurgte fra.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} beder om at låse en ny enhed op én gang. Hovedadgangskoden forbliver den samme.", + "Ask your organisation instead": "Spørg din organisation i stedet", + "The request ended. Ask again or use your master password.": "Anmodningen er afsluttet. Spørg igen, eller brug din hovedadgangskode.", + "Added by {user}": "Tilføjet af {user}", + "Editor": "Redaktør", + "Manager": "Administrator", + "Role of {member}": "Rolle for {member}", + "Team folders you manage": "Teammapper, du administrerer", + "Viewer": "Læser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopi af disse hemmeligheder, så de nye medlemmer har ikke fået dem endnu. Ejeren kan dele dem: {names}", + "Admin areas": "Administrationsområder", + "Give a group only the parts of Keepiq administration it needs.": "Giv en gruppe kun de dele af Keepiq-administrationen, den har brug for.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Uddeleger et eller flere områder til en gruppe på siden med administrationsrettigheder. Instansadministratorer har alle områder.", + "Open administration privileges": "Åbn administrationsrettigheder", + "Policies": "Politikker", + "Applications and machine access": "Applikationer og maskinadgang", + "People and offboarding": "Personer og fratrædelse", + "Audit and compliance": "Revision og overholdelse", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certifikatautoritet, vedhæftninger, offline-cache, lækagekontrol, hemmelighedstyper og sikkerhedskopier", + "master password, organisation password, vault policies, rotation, version history and trash": "hovedadgangskode, organisationsadgangskode, boksens politikker, rotation, versionshistorik og papirkurv", + "application queue, application requests and machine leases": "applikationskø, applikationsanmodninger og maskinlejemål", + "team offboarding, encryption suites and admin handover": "teamfratrædelse, krypteringssuiter og overtagelse ved administrator", + "audit log, compliance reports, SIEM export and honey alerts": "revisionslog, overholdelsesrapporter, SIEM-eksport og lokkealarmer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hvor mange versioner af en hemmelighed der gemmes, hvor længe, og hvor længe slettede hemmeligheder bliver i papirkurven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grænser for krypterede vedhæftede filer, håndhævet på serveren i gemte krypterede bytes.", + "Type the suite ID again to confirm": "Skriv pakke-ID'et igen for at bekræfte", + "This does not match the suite ID.": "Dette matcher ikke pakke-ID'et.", + "Confirm with your master password": "Bekræft med din masteradgangskode", + "Confirm": "Bekræft", + "That master password is not right.": "Den masteradgangskode er ikke korrekt.", + "You are sharing with someone new. Enter your master password to confirm.": "Du deler med en ny person. Indtast din masteradgangskode for at bekræfte.", + "Enter your master password to confirm this share.": "Indtast din masteradgangskode for at bekræfte denne deling.", + "Enter your master password to confirm this delegation.": "Indtast din masteradgangskode for at bekræfte denne delegering.", + "Approve {member}": "Godkend {member}", + "Recipient": "Modtager", + "No vault yet": "Har endnu ingen boks", + "No matching users": "Ingen matchende brugere", + "Partner organisations": "Partnerorganisationer", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Udveksl hemmeligheder med en anden Keepiq. Begge administratorer tilføjer hinanden og sammenligner rodfingeraftrykkene i telefonen eller ansigt til ansigt, før de gemmer.", + "Federation needs Nextcloud 33 or later.": "Føderation kræver Nextcloud 33 eller nyere.", + "Your root fingerprint": "Dit rodfingeraftryk", + "No partners yet.": "Ingen partnere endnu.", + "Users here may share to this partner": "Brugere her må dele med denne partner", + "This partner may share to users here": "Denne partner må dele med brugere her", + "Partner address": "Partnerens adresse", + "Check partner": "Tjek partner", + "Partner root fingerprint": "Partnerens rodfingeraftryk", + "I compared this fingerprint with the partner's administrator": "Jeg har sammenlignet dette fingeraftryk med partnerens administrator", + "Add partner": "Tilføj partner", + "A secret from another organisation": "En hemmelighed fra en anden organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delt \"%2$s\" med dig. Accepter den under Modtaget fra andre organisationer.", + "Incoming from other organisations": "Modtaget fra andre organisationer", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisationer kan dele en hemmelighed med dig. Accepter den for at beholde en skrivebeskyttet kopi i din boks.", + "Nothing shared with you yet": "Intet delt med dig endnu", + "Secrets that people in partner organisations share with you appear here.": "Hemmeligheder, som personer i partnerorganisationer deler med dig, vises her.", + "From {sender}": "Fra {sender}", + "Accept": "Accepter", + "Open in vault": "Åbn i boks", + "The other organisation did not hand over the secret. Try again later.": "Den anden organisation overdrog ikke hemmeligheden. Prøv igen senere.", + "Set up your vault before you accept a shared secret.": "Opsæt din boks, før du accepterer en delt hemmelighed.", + "Something went wrong. Try again.": "Noget gik galt. Prøv igen.", + "Waiting for your answer": "Venter på dit svar", + "In your vault, read-only": "I din boks, skrivebeskyttet", + "Withdrawn by the sender": "Trukket tilbage af afsenderen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delte dette fra en anden organisation. Du kan læse det, men ikke ændre eller dele det.", + "Someone": "Nogen", + "Share with someone at another organisation": "Del med en person i en anden organisation", + "Their account at the other organisation": "Personens konto i den anden organisation", + "Check account": "Tjek konto", + "Certificate fingerprint of {account}": "Certifikatets fingeraftryk for {account}", + "Compare it with them by phone if you want to be sure.": "Sammenlign det med personen over telefonen, hvis du vil være sikker.", + "Shared. {account} can accept it in their own vault.": "Delt. {account} kan acceptere den i sin egen boks.", + "The certificate could not be verified. Nothing was shared.": "Certifikatet kunne ikke bekræftes. Intet blev delt.", + "That organisation is not one of your partners.": "Den organisation er ikke en af dine partnere.", + "No one with that account can receive secrets from you.": "Ingen med den konto kan modtage hemmeligheder fra dig.", + "The other organisation did not answer. Try again later.": "Den anden organisation svarede ikke. Prøv igen senere.", + "This secret is already shared with that account.": "Denne hemmelighed er allerede delt med den konto.", + "Other organisations": "Andre organisationer", + "Receive secrets from other organisations": "Modtag hemmeligheder fra andre organisationer", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisationer kan så finde din konto og dele hemmeligheder med dig. Du accepterer hver enkelt selv.", + "Shared": "Delt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Sat på pause: modtagerens certifikat eller partnerskabet er ændret. Tilbagekald den, eller del igen.", + "Their organisation did not get the last change. Revoke it or share again.": "Modtagerens organisation fik ikke den seneste ændring. Tilbagekald den, eller del igen.", + "Being withdrawn": "Trækkes tilbage", + "Shared with another organisation": "Delt med en anden organisation", + "Change sent to another organisation": "Ændring sendt til en anden organisation", + "Share with another organisation revoked": "Deling med en anden organisation tilbagekaldt", + "Share with another organisation paused": "Deling med en anden organisation sat på pause", + "Another organisation did not get a change": "En anden organisation fik ikke en ændring", + "Secret received from another organisation": "Hemmelighed modtaget fra en anden organisation", + "Secret from another organisation accepted": "Hemmelighed fra en anden organisation accepteret", + "Secret from another organisation declined": "Hemmelighed fra en anden organisation afvist", + "Copy from another organisation updated": "Kopi fra en anden organisation opdateret", + "Copy from another organisation removed": "Kopi fra en anden organisation fjernet", + "Declined: they removed their copy. Share again if they need it.": "Afvist: modtageren har fjernet sin kopi. Del igen, hvis de har brug for den.", + "Recipient at another organisation removed their copy": "Modtager i en anden organisation har fjernet sin kopi", + "Removed the user from %n team folder.": "Brugeren blev fjernet fra %n teammappe.", + "Removed the user from %n team folders.": "Brugeren blev fjernet fra %n teammapper.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Brugeren blev fjernet fra %n teammappe.", + "Brugeren blev fjernet fra %n teammapper." + ], + "A restored copy came from a share that has ended. It stays read-only.": "En gendannet kopi kom fra en deling, der er ophørt. Den forbliver skrivebeskyttet.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisationen, der delte en gendannet kopi, kunne ikke nås. Kopien forbliver skrivebeskyttet og følger ikke deres ændringer.", + "Recipient at another organisation restored their copy": "Modtager i en anden organisation har gendannet sin kopi" }, "plurals": null } diff --git a/l10n/de.js b/l10n/de.js index 5e7448c14..105b33e6d 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Mit Gruppen geteilt", + "Not shared with any group yet.": "Noch mit keiner Gruppe geteilt.", + "Revoke the share with {group}": "Freigabe für {group} widerrufen", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mit {group} geteilt: {received} Mitglieder haben es erhalten, {skipped} nicht, weil sie noch keine Verschlüsselung eingerichtet haben.", + "Search groups": "Gruppen suchen", + "Failed to share": "Teilen fehlgeschlagen", + "Columns": "Spalten", + "Column {number}": "Spalte {number}", + "Map one column to Name. Every secret needs a name.": "Ordnen Sie eine Spalte dem Namen zu. Jedes Geheimnis braucht einen Namen.", + "Notes": "Notizen", + "Do not import": "Nicht importieren", + "Hide this value": "Diesen Wert verbergen", + "Show this value": "Diesen Wert anzeigen", + "Defaults": "Standardwerte", + "New secrets start as this type, and your secret list opens in this view.": "Neue Geheimnisse beginnen mit diesem Typ, und deine Geheimnisliste öffnet sich in dieser Ansicht.", + "Default item type": "Standard-Elementtyp", + "Cards": "Karten", + "Table": "Tabelle", + "Could not save your default": "Dein Standardwert konnte nicht gespeichert werden", + "Recently used": "Zuletzt verwendet", + "Opened": "Geöffnet", + "You have not opened any secrets yet": "Du hast noch keine Geheimnisse geöffnet", + "Could not delete the item type.": "Der Elementtyp konnte nicht gelöscht werden.", + "Could not load the item types.": "Die Elementtypen konnten nicht geladen werden.", + "Could not save the item type.": "Der Elementtyp konnte nicht gespeichert werden.", + "Delete item type": "Elementtyp löschen", + "Edit item type": "Elementtyp bearbeiten", + "Fields": "Felder", + "Fields: {count}": "Felder: {count}", + "Hidden": "Verborgen", + "Item types": "Elementtypen", + "Move up": "Nach oben", + "New item type": "Neuer Elementtyp", + "No item types defined yet.": "Noch keine Elementtypen angelegt.", + "Required": "Pflichtfeld", + "Text": "Text", + "This field is required": "Dieses Feld ist erforderlich", + "Web address": "Webadresse", + "{label} (required)": "{label} (erforderlich)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "„{name}“ löschen? Geheimnisse dieses Typs bleiben lesbar und werden zu Anmeldung-Elementen.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtypen, die du hier anlegst, erscheinen bei allen im Dialog Neues Geheimnis, mit den Feldern, die du wählst.", + "Secret moved to the trash": "Geheimnis in den Papierkorb verschoben", + "Secret restored from the trash": "Geheimnis aus dem Papierkorb wiederhergestellt", + "Secret deleted for good": "Geheimnis endgültig gelöscht", + "Secret archived": "Geheimnis archiviert", + "Secret unarchived": "Geheimnis aus dem Archiv geholt", + "Unarchive": "Aus dem Archiv holen", + "Could not archive the secret": "Das Geheimnis konnte nicht archiviert werden", + "Could not unarchive the secret": "Das Geheimnis konnte nicht aus dem Archiv geholt werden", + "Archive {count} secrets": "{count} Geheimnisse archivieren", + "Unarchive {count} secrets": "{count} Geheimnisse aus dem Archiv holen", + "Restore {count} secrets": "{count} Geheimnisse wiederherstellen", + "Delete {count} secrets for good": "{count} Geheimnisse endgültig löschen", + "Done for {ok} of {total} secrets": "Erledigt für {ok} von {total} Geheimnissen", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivierte Geheimnisse verschwinden aus der Tresorliste, der Suche, dem automatischen Ausfüllen und dem Sicherheitsbericht. Ihre Freigaben bleiben. Du findest sie unter Archiv.", + "These secrets come back to the vault list, search and autofill.": "Diese Geheimnisse kommen zurück in die Tresorliste, die Suche und das automatische Ausfüllen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Diese Geheimnisse kommen zurück in die Tresorliste. Ihre alten Freigaben kommen nicht zurück, teile sie also bei Bedarf erneut.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dies löscht die Geheimnisse mit ihren Anhängen und ihrem Versionsverlauf. Das kann nicht rückgängig gemacht werden.", + "Delete for good": "Endgültig löschen", + "Trash": "Papierkorb", + "The trash is empty": "Der Papierkorb ist leer", + "No archived secrets": "Keine archivierten Geheimnisse", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Gelöschte Geheimnisse warten hier, bis die Aufbewahrungsfrist endet, danach werden sie endgültig gelöscht.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiviere ein Geheimnis in seinem Detailbereich, um es aus der Tresorliste, der Suche und dem automatischen Ausfüllen herauszuhalten.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenzen für verschlüsselte Dateianhänge (serverseitig in gespeicherten Chiffretext-Bytes durchgesetzt), Aufbewahrung des Versionsverlaufs und wie lange gelöschte Geheimnisse im Papierkorb bleiben.", + "Days a deleted secret stays in the trash (1 to 365)": "Tage, die ein gelöschtes Geheimnis im Papierkorb bleibt (1 bis 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dies verschiebt das Geheimnis in den Papierkorb und beendet seine Freigaben jetzt. Du kannst es aus dem Papierkorb wiederherstellen, bis die Aufbewahrungsfrist endet: 30 Tage, sofern deine Administration das nicht geändert hat.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dies verschiebt {count} Geheimnisse in den Papierkorb und beendet ihre Freigaben jetzt. Du kannst sie aus dem Papierkorb wiederherstellen, bis die Aufbewahrungsfrist endet.", + "Remove {name} from favourites": "{name} aus den Favoriten entfernen", + "Add {name} to favourites": "{name} zu den Favoriten hinzufügen", + "Could not change the favourite": "Favorit konnte nicht geändert werden", + "Remove from favourites": "Aus den Favoriten entfernen", + "Add to favourites": "Zu den Favoriten hinzufügen", + "Tags": "Schlagwörter", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Schlagwörter sind nicht verschlüsselt. Serveradministratoren können sie lesen, wie Ordnernamen.", + "Favourites": "Favoriten", + "Filter by tag": "Nach Schlagwort filtern", + "All tags": "Alle Schlagwörter", + "Last used": "Zuletzt verwendet", + "Tags for {count} secrets": "Schlagwörter für {count} Geheimnisse", + "Tag": "Schlagwort", + "Remove tag": "Schlagwort entfernen", + "Add tag": "Schlagwort hinzufügen", + "Could not change the tags. Try again.": "Schlagwörter konnten nicht geändert werden. Bitte erneut versuchen.", + "Could not approve the application. It is still in the queue.": "Der Antrag konnte nicht genehmigt werden. Er befindet sich noch in der Warteschlange.", + "Could not reject the application. It is still in the queue.": "Der Antrag konnte nicht abgelehnt werden. Er befindet sich noch in der Warteschlange.", + "Removed the user from {count} team folders.": "Benutzer aus {count} Teamordnern entfernt.", + "Approve a share": "Eine Freigabe genehmigen", + "This approval link is incomplete. Open it again from the notification.": "Dieser Genehmigungslink ist unvollständig. Öffne ihn erneut über die Benachrichtigung.", + "Deny": "Ablehnen", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ist einer Gruppe beigetreten, mit der du ein Geheimnis teilst. Das Geheimnis auch mit dieser Person teilen?", + "{requester} asks you to share a secret with {user}.": "{requester} bittet dich, ein Geheimnis mit {user} zu teilen.", + "Shared. The recipient can now open the secret.": "Geteilt. Der Empfänger kann das Geheimnis jetzt öffnen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Der Empfänger hat Keepiq noch nicht eingerichtet, daher wurde nichts geteilt. Versuche es erneut, sobald dies geschehen ist.", + "Could not share the secret. Only its owner can approve this.": "Das Geheimnis konnte nicht geteilt werden. Nur der Eigentümer kann dies genehmigen.", + "Could not share the secret. Try again.": "Das Geheimnis konnte nicht geteilt werden. Versuche es erneut.", + "Denied. Nothing was shared.": "Abgelehnt. Es wurde nichts geteilt.", + "Could not deny the request. Try again.": "Die Anfrage konnte nicht abgelehnt werden. Versuche es erneut.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s bittet dich, das Geheimnis \"%2$s\" mit %3$s zu teilen.", + "Expires on (optional)": "Läuft ab am (optional)", + "Hand over to": "Übergeben an", + "Choose a recipient": "Empfänger auswählen", + "Hand over temporarily": "Vorübergehend übergeben", + "Expiry rules": "Ablaufregeln", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Lege fest, wie lange Passwörter eines Elementtyps oder in einem Ordner gültig sein dürfen und wann du erinnert wirst. Wenn mehrere Daten gelten, zählt das früheste.", + "Delete rule": "Regel löschen", + "Set by your administrator": "Von deiner Administration festgelegt", + "No expiry rules yet.": "Noch keine Ablaufregeln.", + "Applies to": "Gilt für", + "Item type": "Elementtyp", + "Maximum age in days (empty for reminders only)": "Maximales Alter in Tagen (leer für reine Erinnerungen)", + "Remind me this many days before, comma separated": "So viele Tage vorher erinnern, durch Kommas getrennt", + "Save rule": "Regel speichern", + "An item type": "Ein Elementtyp", + "A folder": "Ein Ordner", + "Folder {name}": "Ordner {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Läuft nach {days} Tagen ab", + "Reminders {days} days before": "Erinnerungen {days} Tage vorher", + "Could not save the expiry rule.": "Die Ablaufregel konnte nicht gespeichert werden.", + "Could not delete the expiry rule.": "Die Ablaufregel konnte nicht gelöscht werden.", + "All statuses": "Alle Status", + "Compromised": "Kompromittiert", + "Could not load the members.": "Die Mitglieder konnten nicht geladen werden.", + "Emergency contact": "Notfallkontakt", + "Leaving user": "Ausscheidender Benutzer", + "No": "Nein", + "No users match this filter.": "Keine Benutzer für diesen Filter.", + "Not set up": "Nicht eingerichtet", + "Revoke suite": "Suite widerrufen", + "Revoked": "Widerrufen", + "Search users": "Benutzer suchen", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Sehen Sie, welche Benutzer einen Tresor eingerichtet haben. Starten Sie das Offboarding oder widerrufen Sie eine Suite aus einer Zeile.", + "Successor": "Nachfolger", + "Team folders": "Teamordner", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Der Benutzer ist noch in der Gruppe {groups}, die Mitglied eines Teamordners ist. Entfernen Sie ihn aus der Gruppe oder deaktivieren Sie das Konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Der Benutzer ist noch in den Gruppen {groups}, die Mitglieder von Teamordnern sind. Entfernen Sie ihn aus den Gruppen oder deaktivieren Sie das Konto.", + "Vault status": "Tresorstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Ein CXF-Export ist UNVERSCHLÜSSELT. Jedes Passwort und jeder Login ist in der heruntergeladenen Datei als Klartext lesbar. Bewahren Sie sie sicher auf und löschen Sie sie unmittelbar nach der Verwendung.", + "Root certificate expiring soon": "Stammzertifikat läuft bald ab", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Das Stammzertifikat des Tresors läuft in %1$d Tag(en) ab. Erneuern Sie es vorher. Beim Erneuern wird jede Verschlüsselungssuite neu signiert.", + "Compromise recovery aborted": "Kompromittierungs-Wiederherstellung abgebrochen", + "Key rotation ended by a compromise revoke": "Schlüsselrotation durch einen Kompromittierungs-Widerruf beendet", + "Encryption suite revoke refused": "Widerruf der Verschlüsselungssuite abgelehnt", + "Master password proof refused": "Nachweis des Master-Passworts abgelehnt", + "Your current master password": "Ihr aktuelles Master-Passwort", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n Notfallkontakt hatte eine offene Zugriffsanfrage, als Ihre Schlüsselrotation ihn entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n Notfallkontakte hatten eine offene Zugriffsanfrage, als Ihre Schlüsselrotation sie entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Diese Notfallkontakte wurden nicht zu Ihrem neuen Schlüssel übernommen. Ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.", + "Renew root certificate": "Stammzertifikat erneuern", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dadurch werden ein neues Stamm- und Zwischenzertifikat erstellt. Jede aktive Verschlüsselungssuite wird neu signiert. Das lässt sich nicht rückgängig machen.", + "Renew root": "Stamm erneuern", + "Root renewed. {n} encryption suites signed again.": "Stamm erneuert. {n} Verschlüsselungssuiten neu signiert.", + "Could not renew the root certificate.": "Das Stammzertifikat konnte nicht erneuert werden.", + "Lease policy for this application": "Lease-Richtlinie für diese Anwendung", + "In force now: {default} seconds by default, {max} seconds at most.": "Jetzt gültig: standardmäßig {default} Sekunden, höchstens {max} Sekunden.", + "Leases are not renewable": "Leases sind nicht verlängerbar", + "Lease policy saved.": "Lease-Richtlinie gespeichert.", + "Leave a field empty to use the instance value.": "Lass ein Feld leer, um den Wert der Instanz zu verwenden.", + "Instance value: {value}": "Wert der Instanz: {value}", + "Renewal": "Verlängerung", + "Use the instance value ({value})": "Wert der Instanz verwenden ({value})", + "Allowed": "Erlaubt", + "Not allowed": "Nicht erlaubt", + "Save lease policy": "Lease-Richtlinie speichern", + "Only an administrator can change this policy.": "Nur eine Administratorin oder ein Administrator kann diese Richtlinie ändern.", + "Could not save the lease policy.": "Die Lease-Richtlinie konnte nicht gespeichert werden.", + "{member} got access from {confirmer}.": "{member} hat Zugriff von {confirmer} erhalten.", + "Automatically confirm new team folder members": "Neue Teamordner-Mitglieder automatisch bestätigen", + "Gave %n new member access to a team folder.": "%n neues Mitglied hat Zugriff auf einen Teamordner erhalten.", + "Gave %n new members access to a team folder.": "%n neue Mitglieder haben Zugriff auf einen Teamordner erhalten.", + "Give new team folder members access without waiting for the folder owner.": "Geben Sie neuen Teamordner-Mitgliedern Zugriff, ohne auf den Ordnereigentümer zu warten.", + "New team folder members": "Neue Teamordner-Mitglieder", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Der Eigentümer oder ein Mitglied mit Schreibrechten bestätigt sie aus dem geöffneten Tresor. Keepiq entschlüsselt nie auf dem Server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Warten auf ein Mitglied mit Schreibrechten, das Keepiq öffnet. Sie können auch jetzt teilen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Ein Teil der Kompromittierungsreaktion ist fehlgeschlagen ({failed} Schritt(e)). Prüfen Sie das Serverprotokoll und widerrufen Sie die Suite dann erneut, um sie abzuschließen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dadurch wurde auch die Suite {suite} widerrufen und die Schlüsselmigration {migration} beendet.", + "Revoking the second suite deleted %n emergency-access contact.": "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakt gelöscht.", + "Revoking the second suite deleted %n emergency-access contacts.": "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakte gelöscht.", + "A suite revoked as compromised cannot be reinstated.": "Eine als kompromittiert widerrufene Suite kann nicht wiederhergestellt werden.", + "Archives to keep": "Aufzubewahrende Archive", + "Back up every vault automatically": "Jeden Tresor automatisch sichern", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sichern Sie jeden Tresor nach Zeitplan. Archive enthalten nur Chiffretext und werden mit occ wiederhergestellt.", + "Back up now": "Jetzt sichern", + "Backup public key (PEM, optional)": "Öffentlicher Sicherungsschlüssel (PEM, optional)", + "Backup requested for the next cron run": "Sicherung für den nächsten Cron-Lauf angefordert", + "Encrypted": "Verschlüsselt", + "Every (hours)": "Alle (Stunden)", + "Last backup {when} failed: {error}": "Letzte Sicherung {when} fehlgeschlagen: {error}", + "Last backup {when} succeeded.": "Letzte Sicherung {when} erfolgreich.", + "No archives yet.": "Noch keine Archive.", + "Size": "Größe", + "Vault backups": "Tresorsicherungen", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Mit einem Schlüssel wird jedes Archiv damit verschlüsselt. Bewahren Sie den privaten Schlüssel außerhalb dieses Servers auf: Sie brauchen ihn zum Prüfen oder Wiederherstellen.", + "Written": "Geschrieben", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n Benutzer im Geltungsbereich hat noch keine Zwei-Faktor-Anmeldung und kann den Tresor nicht öffnen, solange dies aktiv ist.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n Benutzer im Geltungsbereich haben noch keine Zwei-Faktor-Anmeldung und können den Tresor nicht öffnen, solange dies aktiv ist.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup-Codes zählen nicht. Wenn sich Ihre Benutzer über einen Identitätsanbieter mit eigenem zweiten Faktor anmelden, lassen Sie deren Gruppen weg.", + "Block personal vault export": "Export des persönlichen Tresors sperren", + "Keep work logins in team folders": "Arbeitszugänge in Teamordnern aufbewahren", + "Move to a team folder": "In einen Teamordner verschieben", + "Not in a team folder": "Nicht in einem Teamordner", + "Only for these groups (empty is everyone)": "Nur für diese Gruppen (leer bedeutet alle)", + "Require two-factor login before the vault opens": "Zwei-Faktor-Anmeldung verlangen, bevor sich der Tresor öffnet", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regeln für jeden Tresor. Jede gilt für alle oder nur für die von Ihnen gewählten Gruppen.", + "Secret types that belong in a team folder": "Geheimnistypen, die in einen Teamordner gehören", + "Set up two-factor login": "Zwei-Faktor-Anmeldung einrichten", + "Team folder you can write to": "Teamordner, in den Sie schreiben können", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Benutzer können keine Sicherung, CSV- oder Übertragungsdatei herunterladen. Ihr persönliches Datenpaket bleibt verfügbar.", + "Users cannot save these secret types in a personal folder.": "Benutzer können diese Geheimnistypen nicht in einem persönlichen Ordner speichern.", + "Vault policies": "Tresorrichtlinien", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ihre Organisation erlaubt keinen Export Ihres persönlichen Tresors. Ihr persönliches Datenpaket in den Einstellungen bleibt verfügbar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ihre Organisation bewahrt diese Geheimnisse in einem Teamordner auf. Verschieben Sie jedes in einen Teamordner.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ihre Organisation bewahrt diesen Geheimnistyp in einem Teamordner auf. Wählen Sie einen Ihrer Teamordner oder einen, in den Sie schreiben können.", + "Your organisation requires two-factor login before you can open your vault.": "Ihre Organisation verlangt eine Zwei-Faktor-Anmeldung, bevor Sie Ihren Tresor öffnen können.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Benutzer wählen, wie lange die Erweiterung bei Inaktivität entsperrt bleibt. Sie legen die längste wählbare Zeit fest.", + "Longest idle time before the extension locks": "Längste Inaktivitätszeit, bevor die Erweiterung sperrt", + "1 minute": "1 Minute", + "5 minutes": "5 Minuten", + "15 minutes": "15 Minuten", + "1 hour": "1 Stunde", + "4 hours": "4 Stunden", + "Connector": "Konnektor", + "Directory (tenant) ID": "Verzeichnis-ID (Mandant)", + "Application (client) ID": "Anwendungs-ID (Client)", + "Data collection rule immutable ID": "Unveränderliche ID der Datensammlungsregel", + "Stream name": "Streamname", + "Splunk index (optional)": "Splunk-Index (optional)", + "Sourcetype (optional)": "Sourcetype (optional)", + "Leave blank to keep the current one": "Leer lassen, um den aktuellen Wert zu behalten", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF über Syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datensammlungsendpunkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP-Event-Collector-URL (https)", + "Client secret (write-only)": "Clientgeheimnis (nur schreiben)", + "HEC token (write-only)": "HEC-Token (nur schreiben)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Erlaubte Audit-Ereignisse an Splunk, Microsoft Sentinel, einen Syslog-Empfänger oder einen Webhook weiterleiten. Nachrichten enthalten nur bereinigte Metadaten: Kein geheimer Wert, Name, Login oder Geheimtext verlässt je den Server.", + "%n change waiting to sync": "%n Änderung wartet auf Synchronisierung", + "%n changes waiting to sync": "%n Änderungen warten auf Synchronisierung", + "Changes that could not sync": "Änderungen, die nicht synchronisiert werden konnten", + "Choose a version": "Version wählen", + "Copy value": "Wert kopieren", + "Deleted": "Gelöscht", + "Discard": "Verwerfen", + "Keep my offline change": "Meine Offline-Änderung behalten", + "Keep the server version": "Die Serverversion behalten", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq ist offline schreibgeschützt. Ihre Administration hat Offline-Bearbeitung nicht eingeschaltet.", + "Let users edit secrets offline": "Benutzern erlauben, Geheimnisse offline zu bearbeiten", + "Not synced yet": "Noch nicht synchronisiert", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-Änderungen bleiben auf dem Gerät, für den Benutzer verschlüsselt, und werden beim nächsten Online-Entsperren synchronisiert. Teilen, Ordner und Anhänge brauchen weiterhin eine Verbindung.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Bearbeitungen, Verschiebungen und Löschungen bleiben auf diesem Gerät und werden synchronisiert, sobald Sie wieder online sind. Teilen und Anhänge brauchen eine Verbindung.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Ihre Änderungen bleiben auf diesem Gerät und werden synchronisiert, sobald Sie wieder online sind. Zuletzt synchronisiert {when}.", + "Open my changes": "Meine Änderungen öffnen", + "Sharing needs a connection": "Teilen braucht eine Verbindung", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Jemand hat dieses Geheimnis auf dem Server geändert, nachdem Ihre Offline-Kopie erstellt wurde. Wählen Sie, welche Version Sie behalten.", + "Sync or discard your offline changes before you rotate your keys.": "Synchronisieren oder verwerfen Sie Ihre Offline-Änderungen, bevor Sie Ihre Schlüssel erneuern.", + "That password did not open your changes.": "Mit diesem Passwort ließen sich Ihre Änderungen nicht öffnen.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Der Offline-Schnappschuss speichert verschlüsselte Geheimnisse (nur mit dem aus dem Master-Passwort des Benutzers abgeleiteten Schlüssel zu öffnen, genau wie auf dem Server) und verschlüsselt Namen, URLs und Ordnernamen im Ruhezustand. Offline-Zugriff ist schreibgeschützt, außer Sie erlauben unten Offline-Bearbeitung. Schalten Sie dies für Geräte aus, die niemals Zugangsdaten zwischenspeichern dürfen; das Ausschalten löscht vorhandene Caches beim nächsten Laden.", + "The previous vault copy is gone, so these changes cannot be opened.": "Die vorherige Tresorkopie ist weg, daher können diese Änderungen nicht geöffnet werden.", + "The server version": "Die Serverversion", + "This secret changed while you were offline": "Dieses Geheimnis wurde geändert, während Sie offline waren", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Sie haben dieses Geheimnis offline gelöscht, aber es wurde seitdem auf dem Server geändert. Wählen Sie, welche Version Sie behalten.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ihre Schlüssel wurden auf einem anderen Gerät geändert. Geben Sie Ihr vorheriges Master-Passwort ein, um Ihre Offline-Änderungen zu synchronisieren, oder verwerfen Sie sie.", + "Your offline change": "Ihre Offline-Änderung", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n Notfallkontakt hatte eine offene Zugriffsanfrage, als Ihre Schlüsselrotation ihn entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.","%n Notfallkontakte hatten eine offene Zugriffsanfrage, als Ihre Schlüsselrotation sie entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n Element kann nicht in CXF dargestellt werden und wird übersprungen.","%n Elemente können nicht in CXF dargestellt werden und werden übersprungen."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n ältere Version wurde verworfen, da nur die neuere Historie übernommen werden kann.","%n ältere Versionen wurden verworfen, da nur die neuere Historie übernommen werden kann."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n Geheimniskopie muss noch verschlüsselt und geteilt werden.","%n Geheimniskopien müssen noch verschlüsselt und geteilt werden."], + "_%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 Geheimnis konnte nicht entschlüsselt werden und ist nicht in diesem Export enthalten.","%n Geheimnisse konnten nicht entschlüsselt werden und sind nicht in diesem Export enthalten."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n Geheimnis konnte mit Ihrem alten Schlüssel nicht entschlüsselt werden und wurde daher nicht migriert.","%n Geheimnisse konnten mit Ihrem alten Schlüssel nicht entschlüsselt werden und wurden daher nicht migriert."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n Geheimnis wurde nicht migriert.","%n Geheimnisse wurden nicht migriert."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n Geheimnis ist noch mit Ihrem vorherigen Schlüssel verschlüsselt.","%n Geheimnisse sind noch mit Ihrem vorherigen Schlüssel verschlüsselt."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n Geheimnis wurde übersprungen, weil der Nachfolger noch keine Kopie besitzt — fügen Sie den Nachfolger dem Ordner hinzu und führen Sie den Vorgang erneut aus.","%n Geheimnisse wurden übersprungen, weil der Nachfolger noch keine Kopie besitzt — fügen Sie den Nachfolger dem Ordner hinzu und führen Sie den Vorgang erneut aus."], + "_%n secret_::_%n secrets_": ["%n Geheimnis","%n Geheimnisse"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n Benutzer im Geltungsbereich hat noch keine Zwei-Faktor-Anmeldung und kann den Tresor nicht öffnen, solange dies aktiv ist.","%n Benutzer im Geltungsbereich haben noch keine Zwei-Faktor-Anmeldung und können den Tresor nicht öffnen, solange dies aktiv ist."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Trotzdem abschließen und den Zugriff auf %n Geheimnis verlieren","Trotzdem abschließen und den Zugriff auf %n Geheimnisse verlieren"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n neues Mitglied hat Zugriff auf einen Teamordner erhalten.","%n neue Mitglieder haben Zugriff auf einen Teamordner erhalten."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Schlüsselrotation abgeschlossen. %n Geheimnis wurde mit Ihrem neuen Schlüssel neu verschlüsselt.","Schlüsselrotation abgeschlossen. %n Geheimnisse wurden mit Ihrem neuen Schlüssel neu verschlüsselt."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Der Widerruf der zweiten Suite hat %n Notfallzugangskontakt gelöscht.","Der Widerruf der zweiten Suite hat %n Notfallzugangskontakte gelöscht."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Der Widerruf dieser Suite hat %n Notfallzugangskontakt gelöscht.","Der Widerruf dieser Suite hat %n Notfallzugangskontakte gelöscht."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["%n Mal in Leaks gefunden","%n Mal in Leaks gefunden"], + "_shared with %n secret_::_shared with %n secrets_": ["mit %n Geheimnis geteilt","mit %n Geheimnissen geteilt"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Dieser Ordner enthält direkt %n Geheimnis.","Dieser Ordner enthält direkt %n Geheimnisse."], + "_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._": ["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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n Änderung wartet auf Synchronisierung","%n Änderungen warten auf Synchronisierung"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Der Benutzer ist noch in der Gruppe {groups}, die Mitglied eines Teamordners ist. Entfernen Sie ihn aus der Gruppe oder deaktivieren Sie das Konto.","Der Benutzer ist noch in den Gruppen {groups}, die Mitglieder von Teamordnern sind. Entfernen Sie ihn aus den Gruppen oder deaktivieren Sie das Konto."], + "Allow approval from another device": "Freigabe von einem anderen Gerät erlauben", + "App": "App", + "Approve a new device": "Neues Gerät freigeben", + "Approve from another device": "Von einem anderen Gerät freigeben", + "Asked at": "Angefragt um", + "Check that the new device shows these words:": "Prüfe, ob das neue Gerät diese Wörter anzeigt:", + "Denied. If you did not ask, end your other sessions:": "Abgelehnt. Wenn du das nicht angefragt hast, beende deine anderen Sitzungen:", + "Device": "Gerät", + "IP address": "IP-Adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Nutzer können einen neuen Browser entsperren, indem sie ihn auf einem Gerät freigeben, auf dem Keepiq bereits entsperrt ist.", + "New device approval": "Freigabe neuer Geräte", + "Nextcloud security settings": "Nextcloud-Sicherheitseinstellungen", + "Only approve a device you are using right now.": "Gib nur ein Gerät frei, das du gerade selbst benutzt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Öffne Keepiq auf einem Gerät, auf dem es entsperrt ist, und gib dieses Gerät frei. Prüfe, ob dort dieselben Wörter angezeigt werden:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Das freigebende Gerät verschlüsselt den Entsperrschlüssel für das neue Gerät. Der Server leitet ihn nur weiter und kann ihn nicht öffnen.", + "The master password is not right, or the request has ended.": "Das Master-Passwort ist falsch oder die Anfrage ist beendet.", + "The request expired. Ask again or use your master password.": "Die Anfrage ist abgelaufen. Frage erneut an oder nutze dein Master-Passwort.", + "The request was denied.": "Die Anfrage wurde abgelehnt.", + "Too many requests. Try again in an hour or use your master password.": "Zu viele Anfragen. Versuche es in einer Stunde erneut oder nutze dein Master-Passwort.", + "Unknown device": "Unbekanntes Gerät", + "Web app": "Web-App", + "A device": "Ein Gerät", + "A new device asks to open your vault": "Ein neues Gerät möchte deinen Tresor öffnen", + "%s asks to be approved. Only approve a device you are using right now.": "%s bittet um Freigabe. Gib nur ein Gerät frei, das du gerade selbst benutzt.", + "Access ends on (optional)": "Zugriff endet am (optional)", + "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.": "Die Apps von Keepiq zeigen oder kopieren das Passwort nicht. Jemand mit technischen Kenntnissen kann es trotzdem auf dem eigenen Gerät auslesen. Ändere es, wenn der Zugriff endet.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dieses Geheimnis ist nur zur Nutzung freigegeben. Melde dich über die Keepiq-Browsererweiterung an.", + "Until {date}": "Bis {date}", + "Use only": "Nur verwenden", + "Use only (can sign in, cannot view or copy)": "Nur verwenden (anmelden möglich, ansehen oder kopieren nicht)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kannst dich mit diesen Zugangsdaten über die Keepiq-Browsererweiterung anmelden. Der Eigentümer hat entschieden, dass du sie nicht ansehen oder kopieren darfst.", + "Your access ends on {date}": "Dein Zugriff endet am {date}", + "Your access to this secret has ended": "Dein Zugriff auf dieses Geheimnis ist beendet", + "Your access to \"%s\" ends tomorrow": "Dein Zugriff auf „%s“ endet morgen", + "Your access to \"%s\" has ended": "Dein Zugriff auf „%s“ ist beendet", + "%1$s no longer has access to \"%2$s\"": "%1$s hat keinen Zugriff mehr auf „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s konnte dieses Passwort sehen. Ändere es, wenn %1$s es nicht mehr kennen soll.", + "%s could not view this password in Keepiq.": "%s konnte dieses Passwort in Keepiq nicht ansehen.", + "{approvals} of {threshold} approvals": "{approvals} von {threshold} Freigaben", + "a recovery officer": "eine Wiederherstellungsbeauftragte Person", + "Account recovery": "Kontowiederherstellung", + "Approvals needed": "Benötigte Freigaben", + "Ask {user} which words they see, by phone or in person. They must be:": "Fragen Sie {user} am Telefon oder persönlich, welche Wörter angezeigt werden. Es müssen diese sein:", + "Check again": "Erneut prüfen", + "Create the recovery key": "Wiederherstellungsschlüssel erstellen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Erstellen Sie den Wiederherstellungsschlüssel. Ihr Browser erzeugt ihn und gibt jeder beauftragten Person eine Kopie, die nur sie öffnen kann.", + "Decline": "Ablehnen", + "Enrol in account recovery": "Für die Kontowiederherstellung anmelden", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Melden Sie sich an, damit Ihre Organisation Ihnen helfen kann, Ihren Tresor zurückzubekommen, wenn Sie Ihr Master-Passwort vergessen.", + "Every user is enrolled": "Alle Benutzer sind angemeldet", + "Finish the recovery in the browser you asked from.": "Schließen Sie die Wiederherstellung in dem Browser ab, in dem Sie sie angefragt haben.", + "Forgot your master password?": "Master-Passwort vergessen?", + "Hand the key over": "Schlüssel übergeben", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Benutzer, die ihr Master-Passwort vergessen haben, bekommen ihren Tresor zurück, freigegeben von Wiederherstellungsbeauftragten, die Sie benennen.", + "New master password": "Neues Master-Passwort", + "No one is asking to recover their account.": "Niemand bittet um die Wiederherstellung seines Kontos.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Noch kein Wiederherstellungsschlüssel. Eine der beauftragten Personen erstellt ihn in ihren Keepiq-Einstellungen.", + "Off": "Aus", + "Officer {user} has no encryption set up yet.": "Die beauftragte Person {user} hat noch keine Verschlüsselung eingerichtet.", + "Officers (user IDs, separated by commas)": "Beauftragte (Benutzer-IDs, durch Kommas getrennt)", + "Policy": "Richtlinie", + "Publish this fingerprint internally, so users can check it before they enrol.": "Veröffentlichen Sie diesen Fingerabdruck intern, damit Benutzer ihn vor der Anmeldung prüfen können.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Mit Hilfe von {officer} wiederhergestellt. Ändern Sie jetzt Ihren Tresorschlüssel unter Einstellungen, Sicherheit: \"Mein Master-Passwort wurde kompromittiert\".", + "Recovery key fingerprint: {fingerprint}": "Fingerabdruck des Wiederherstellungsschlüssels: {fingerprint}", + "Recovery officer": "Wiederherstellungsbeauftragte Person", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Entfernte Beauftragte verlieren ihre Kopie jetzt, könnten sie aber vorher geöffnet haben. Lassen Sie eine beauftragte Person einen neuen Wiederherstellungsschlüssel erstellen.", + "Repeat the new master password": "Neues Master-Passwort wiederholen", + "Retire this recovery key": "Diesen Wiederherstellungsschlüssel stilllegen", + "Set the new master password": "Neues Master-Passwort festlegen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Das Wiederherstellungszertifikat wurde nicht von diesem Keepiq ausgestellt. Melden Sie sich nicht an und informieren Sie Ihre Administration.", + "The words match, approve": "Die Wörter stimmen, freigeben", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dieser Benutzer ist für die Kontowiederherstellung angemeldet. Wiederherstellen behält seine Geheimnisse; Widerrufen löscht seine Anmeldung.", + "Users may enrol": "Benutzer dürfen sich anmelden", + "Withdraw from account recovery": "Von der Kontowiederherstellung abmelden", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sie sind für die Kontowiederherstellung angemeldet. Fingerabdruck des Wiederherstellungsschlüssels: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sie sind angemeldet. Wenn Sie Ihr Master-Passwort vergessen, kann Ihre Organisation Ihnen helfen, Ihren Tresor zurückzubekommen.", + "Your key is back. Choose a new master password.": "Ihr Schlüssel ist zurück. Wählen Sie ein neues Master-Passwort.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ihre Wiederherstellungsbeauftragten wurden benachrichtigt. Lesen Sie ihnen diese Wörter vor, wenn sie anrufen oder Sie treffen:", + "You are now an account recovery officer": "Sie sind jetzt für die Kontowiederherstellung beauftragt", + "%s asks to recover their account. Compare the words with them before you approve.": "%s bittet um die Wiederherstellung des Kontos. Vergleichen Sie die Wörter mit der Person, bevor Sie freigeben.", + "A user": "Ein Benutzer", + "Your account recovery request was declined": "Ihre Anfrage zur Kontowiederherstellung wurde abgelehnt", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Ihre Kontowiederherstellung ist bereit. Öffnen Sie Keepiq in dem Browser, in dem Sie sie angefragt haben.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} bittet darum, ein neues Gerät einmalig zu entsperren. Das Master-Passwort bleibt gleich.", + "Ask your organisation instead": "Stattdessen deine Organisation fragen", + "The request ended. Ask again or use your master password.": "Die Anfrage ist beendet. Frage erneut oder verwende dein Master-Passwort.", + "Added by {user}": "Hinzugefügt von {user}", + "Editor": "Bearbeiter", + "Manager": "Verwalter", + "Role of {member}": "Rolle von {member}", + "Team folders you manage": "Teamordner, die Sie verwalten", + "Viewer": "Betrachter", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sie haben keine Kopie dieser Geheimnisse, daher haben die neuen Mitglieder sie noch nicht erhalten. Der Eigentümer kann sie teilen: {names}", + "Admin areas": "Verwaltungsbereiche", + "Give a group only the parts of Keepiq administration it needs.": "Geben Sie einer Gruppe nur die Teile der Keepiq-Verwaltung, die sie braucht.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegieren Sie einen oder mehrere Bereiche auf der Seite Verwaltungsrechte an eine Gruppe. Instanzadministratoren haben jeden Bereich.", + "Open administration privileges": "Verwaltungsrechte öffnen", + "Policies": "Richtlinien", + "Applications and machine access": "Anwendungen und Maschinenzugriff", + "People and offboarding": "Personen und Austritt", + "Audit and compliance": "Audit und Compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "Version, Zertifizierungsstelle, Anhänge, Offline-Cache, Leckprüfung, Geheimnistypen und Sicherungen", + "master password, organisation password, vault policies, rotation, version history and trash": "Master-Passwort, Organisationspasswort, Tresorrichtlinien, Rotation, Versionsverlauf und Papierkorb", + "application queue, application requests and machine leases": "Anwendungswarteschlange, Anwendungsanfragen und Maschinen-Leases", + "team offboarding, encryption suites and admin handover": "Team-Austritt, Verschlüsselungssuiten und Administratorübernahme", + "audit log, compliance reports, SIEM export and honey alerts": "Auditprotokoll, Compliance-Berichte, SIEM-Export und Honigalarme", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Wie viele Versionen eines Geheimnisses wie lange aufbewahrt werden und wie lange gelöschte Geheimnisse im Papierkorb bleiben.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenzen für verschlüsselte Dateianhänge, auf dem Server in gespeicherten verschlüsselten Bytes durchgesetzt.", + "Type the suite ID again to confirm": "Geben Sie die Suite-ID zur Bestätigung erneut ein", + "This does not match the suite ID.": "Dies stimmt nicht mit der Suite-ID überein.", + "Confirm with your master password": "Mit Ihrem Masterpasswort bestätigen", + "Confirm": "Bestätigen", + "That master password is not right.": "Dieses Masterpasswort ist nicht richtig.", + "You are sharing with someone new. Enter your master password to confirm.": "Sie teilen mit einer neuen Person. Geben Sie zur Bestätigung Ihr Masterpasswort ein.", + "Enter your master password to confirm this share.": "Geben Sie Ihr Masterpasswort ein, um diese Freigabe zu bestätigen.", + "Enter your master password to confirm this delegation.": "Geben Sie Ihr Masterpasswort ein, um diese Delegierung zu bestätigen.", + "Approve {member}": "{member} genehmigen", + "Recipient": "Empfänger", + "No vault yet": "Noch kein Tresor", + "No matching users": "Keine passenden Benutzer", + "Partner organisations": "Partnerorganisationen", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Tauschen Sie Geheimnisse mit einem anderen Keepiq aus. Beide Administratoren fügen sich gegenseitig hinzu und vergleichen die Root-Fingerabdrücke telefonisch oder persönlich, bevor sie speichern.", + "Federation needs Nextcloud 33 or later.": "Föderation erfordert Nextcloud 33 oder neuer.", + "Your root fingerprint": "Ihr Root-Fingerabdruck", + "No partners yet.": "Noch keine Partner.", + "Users here may share to this partner": "Benutzer hier dürfen mit diesem Partner teilen", + "This partner may share to users here": "Dieser Partner darf mit Benutzern hier teilen", + "Partner address": "Adresse des Partners", + "Check partner": "Partner prüfen", + "Partner root fingerprint": "Root-Fingerabdruck des Partners", + "I compared this fingerprint with the partner's administrator": "Ich habe diesen Fingerabdruck mit dem Administrator des Partners verglichen", + "Add partner": "Partner hinzufügen", + "A secret from another organisation": "Ein Geheimnis von einer anderen Organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s hat \"%2$s\" mit Ihnen geteilt. Nehmen Sie es unter Eingang von anderen Organisationen an.", + "Incoming from other organisations": "Eingang von anderen Organisationen", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personen in Partnerorganisationen können ein Geheimnis mit Ihnen teilen. Nehmen Sie es an, um eine schreibgeschützte Kopie in Ihrem Tresor zu behalten.", + "Nothing shared with you yet": "Noch nichts mit Ihnen geteilt", + "Secrets that people in partner organisations share with you appear here.": "Geheimnisse, die Personen in Partnerorganisationen mit Ihnen teilen, erscheinen hier.", + "From {sender}": "Von {sender}", + "Accept": "Annehmen", + "Open in vault": "Im Tresor öffnen", + "The other organisation did not hand over the secret. Try again later.": "Die andere Organisation hat das Geheimnis nicht übergeben. Versuchen Sie es später erneut.", + "Set up your vault before you accept a shared secret.": "Richten Sie Ihren Tresor ein, bevor Sie ein geteiltes Geheimnis annehmen.", + "Something went wrong. Try again.": "Etwas ist schiefgelaufen. Versuchen Sie es erneut.", + "Waiting for your answer": "Wartet auf Ihre Antwort", + "In your vault, read-only": "In Ihrem Tresor, schreibgeschützt", + "Withdrawn by the sender": "Vom Absender zurückgezogen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} hat dies aus einer anderen Organisation geteilt. Sie können es lesen, aber nicht ändern oder teilen.", + "Someone": "Jemand", + "Share with someone at another organisation": "Mit jemandem in einer anderen Organisation teilen", + "Their account at the other organisation": "Konto der Person bei der anderen Organisation", + "Check account": "Konto prüfen", + "Certificate fingerprint of {account}": "Fingerabdruck des Zertifikats von {account}", + "Compare it with them by phone if you want to be sure.": "Vergleichen Sie ihn telefonisch mit der Person, wenn Sie sichergehen möchten.", + "Shared. {account} can accept it in their own vault.": "Geteilt. {account} kann es im eigenen Tresor annehmen.", + "The certificate could not be verified. Nothing was shared.": "Das Zertifikat konnte nicht überprüft werden. Es wurde nichts geteilt.", + "That organisation is not one of your partners.": "Diese Organisation gehört nicht zu Ihren Partnern.", + "No one with that account can receive secrets from you.": "Niemand mit diesem Konto kann Geheimnisse von Ihnen empfangen.", + "The other organisation did not answer. Try again later.": "Die andere Organisation hat nicht geantwortet. Versuchen Sie es später erneut.", + "This secret is already shared with that account.": "Dieses Geheimnis ist bereits mit diesem Konto geteilt.", + "Other organisations": "Andere Organisationen", + "Receive secrets from other organisations": "Geheimnisse von anderen Organisationen empfangen", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personen in Partnerorganisationen können dann Ihr Konto finden und Geheimnisse mit Ihnen teilen. Sie nehmen jedes davon selbst an.", + "Shared": "Geteilt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pausiert: Das Zertifikat des Empfängers oder die Partnerschaft hat sich geändert. Widerrufen Sie die Freigabe oder teilen Sie erneut.", + "Their organisation did not get the last change. Revoke it or share again.": "Die Organisation des Empfängers hat die letzte Änderung nicht erhalten. Widerrufen Sie die Freigabe oder teilen Sie erneut.", + "Being withdrawn": "Wird zurückgezogen", + "Shared with another organisation": "Mit einer anderen Organisation geteilt", + "Change sent to another organisation": "Änderung an eine andere Organisation gesendet", + "Share with another organisation revoked": "Freigabe für eine andere Organisation widerrufen", + "Share with another organisation paused": "Freigabe für eine andere Organisation pausiert", + "Another organisation did not get a change": "Eine andere Organisation hat eine Änderung nicht erhalten", + "Secret received from another organisation": "Geheimnis von einer anderen Organisation erhalten", + "Secret from another organisation accepted": "Geheimnis von einer anderen Organisation angenommen", + "Secret from another organisation declined": "Geheimnis von einer anderen Organisation abgelehnt", + "Copy from another organisation updated": "Kopie von einer anderen Organisation aktualisiert", + "Copy from another organisation removed": "Kopie von einer anderen Organisation entfernt", + "Declined: they removed their copy. Share again if they need it.": "Abgelehnt: Der Empfänger hat seine Kopie entfernt. Teilen Sie erneut, wenn er sie braucht.", + "Recipient at another organisation removed their copy": "Empfänger in einer anderen Organisation hat seine Kopie entfernt", + "Removed the user from %n team folder.": "Benutzer aus %n Teamordner entfernt.", + "Removed the user from %n team folders.": "Benutzer aus %n Teamordnern entfernt.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Benutzer aus %n Teamordner entfernt.","Benutzer aus %n Teamordnern entfernt."], + "A restored copy came from a share that has ended. It stays read-only.": "Eine wiederhergestellte Kopie stammt aus einer beendeten Freigabe. Sie bleibt schreibgeschützt.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Die Organisation, die eine wiederhergestellte Kopie geteilt hat, ist nicht erreichbar. Die Kopie bleibt schreibgeschützt und folgt ihren Änderungen nicht.", + "Recipient at another organisation restored their copy": "Empfänger in einer anderen Organisation hat seine Kopie wiederhergestellt" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/de.json b/l10n/de.json index 0fe008b04..1637bd2c9 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Mit Gruppen geteilt", + "Not shared with any group yet.": "Noch mit keiner Gruppe geteilt.", + "Revoke the share with {group}": "Freigabe für {group} widerrufen", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mit {group} geteilt: {received} Mitglieder haben es erhalten, {skipped} nicht, weil sie noch keine Verschlüsselung eingerichtet haben.", + "Search groups": "Gruppen suchen", + "Failed to share": "Teilen fehlgeschlagen", + "Columns": "Spalten", + "Column {number}": "Spalte {number}", + "Map one column to Name. Every secret needs a name.": "Ordnen Sie eine Spalte dem Namen zu. Jedes Geheimnis braucht einen Namen.", + "Notes": "Notizen", + "Do not import": "Nicht importieren", + "Hide this value": "Diesen Wert verbergen", + "Show this value": "Diesen Wert anzeigen", + "Defaults": "Standardwerte", + "New secrets start as this type, and your secret list opens in this view.": "Neue Geheimnisse beginnen mit diesem Typ, und deine Geheimnisliste öffnet sich in dieser Ansicht.", + "Default item type": "Standard-Elementtyp", + "Cards": "Karten", + "Table": "Tabelle", + "Could not save your default": "Dein Standardwert konnte nicht gespeichert werden", + "Recently used": "Zuletzt verwendet", + "Opened": "Geöffnet", + "You have not opened any secrets yet": "Du hast noch keine Geheimnisse geöffnet", + "Could not delete the item type.": "Der Elementtyp konnte nicht gelöscht werden.", + "Could not load the item types.": "Die Elementtypen konnten nicht geladen werden.", + "Could not save the item type.": "Der Elementtyp konnte nicht gespeichert werden.", + "Delete item type": "Elementtyp löschen", + "Edit item type": "Elementtyp bearbeiten", + "Fields": "Felder", + "Fields: {count}": "Felder: {count}", + "Hidden": "Verborgen", + "Item types": "Elementtypen", + "Move up": "Nach oben", + "New item type": "Neuer Elementtyp", + "No item types defined yet.": "Noch keine Elementtypen angelegt.", + "Required": "Pflichtfeld", + "Text": "Text", + "This field is required": "Dieses Feld ist erforderlich", + "Web address": "Webadresse", + "{label} (required)": "{label} (erforderlich)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "„{name}“ löschen? Geheimnisse dieses Typs bleiben lesbar und werden zu Anmeldung-Elementen.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtypen, die du hier anlegst, erscheinen bei allen im Dialog Neues Geheimnis, mit den Feldern, die du wählst.", + "Secret moved to the trash": "Geheimnis in den Papierkorb verschoben", + "Secret restored from the trash": "Geheimnis aus dem Papierkorb wiederhergestellt", + "Secret deleted for good": "Geheimnis endgültig gelöscht", + "Secret archived": "Geheimnis archiviert", + "Secret unarchived": "Geheimnis aus dem Archiv geholt", + "Unarchive": "Aus dem Archiv holen", + "Could not archive the secret": "Das Geheimnis konnte nicht archiviert werden", + "Could not unarchive the secret": "Das Geheimnis konnte nicht aus dem Archiv geholt werden", + "Archive {count} secrets": "{count} Geheimnisse archivieren", + "Unarchive {count} secrets": "{count} Geheimnisse aus dem Archiv holen", + "Restore {count} secrets": "{count} Geheimnisse wiederherstellen", + "Delete {count} secrets for good": "{count} Geheimnisse endgültig löschen", + "Done for {ok} of {total} secrets": "Erledigt für {ok} von {total} Geheimnissen", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivierte Geheimnisse verschwinden aus der Tresorliste, der Suche, dem automatischen Ausfüllen und dem Sicherheitsbericht. Ihre Freigaben bleiben. Du findest sie unter Archiv.", + "These secrets come back to the vault list, search and autofill.": "Diese Geheimnisse kommen zurück in die Tresorliste, die Suche und das automatische Ausfüllen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Diese Geheimnisse kommen zurück in die Tresorliste. Ihre alten Freigaben kommen nicht zurück, teile sie also bei Bedarf erneut.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dies löscht die Geheimnisse mit ihren Anhängen und ihrem Versionsverlauf. Das kann nicht rückgängig gemacht werden.", + "Delete for good": "Endgültig löschen", + "Trash": "Papierkorb", + "The trash is empty": "Der Papierkorb ist leer", + "No archived secrets": "Keine archivierten Geheimnisse", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Gelöschte Geheimnisse warten hier, bis die Aufbewahrungsfrist endet, danach werden sie endgültig gelöscht.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiviere ein Geheimnis in seinem Detailbereich, um es aus der Tresorliste, der Suche und dem automatischen Ausfüllen herauszuhalten.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenzen für verschlüsselte Dateianhänge (serverseitig in gespeicherten Chiffretext-Bytes durchgesetzt), Aufbewahrung des Versionsverlaufs und wie lange gelöschte Geheimnisse im Papierkorb bleiben.", + "Days a deleted secret stays in the trash (1 to 365)": "Tage, die ein gelöschtes Geheimnis im Papierkorb bleibt (1 bis 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dies verschiebt das Geheimnis in den Papierkorb und beendet seine Freigaben jetzt. Du kannst es aus dem Papierkorb wiederherstellen, bis die Aufbewahrungsfrist endet: 30 Tage, sofern deine Administration das nicht geändert hat.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dies verschiebt {count} Geheimnisse in den Papierkorb und beendet ihre Freigaben jetzt. Du kannst sie aus dem Papierkorb wiederherstellen, bis die Aufbewahrungsfrist endet.", + "Remove {name} from favourites": "{name} aus den Favoriten entfernen", + "Add {name} to favourites": "{name} zu den Favoriten hinzufügen", + "Could not change the favourite": "Favorit konnte nicht geändert werden", + "Remove from favourites": "Aus den Favoriten entfernen", + "Add to favourites": "Zu den Favoriten hinzufügen", + "Tags": "Schlagwörter", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Schlagwörter sind nicht verschlüsselt. Serveradministratoren können sie lesen, wie Ordnernamen.", + "Favourites": "Favoriten", + "Filter by tag": "Nach Schlagwort filtern", + "All tags": "Alle Schlagwörter", + "Last used": "Zuletzt verwendet", + "Tags for {count} secrets": "Schlagwörter für {count} Geheimnisse", + "Tag": "Schlagwort", + "Remove tag": "Schlagwort entfernen", + "Add tag": "Schlagwort hinzufügen", + "Could not change the tags. Try again.": "Schlagwörter konnten nicht geändert werden. Bitte erneut versuchen.", + "Could not approve the application. It is still in the queue.": "Der Antrag konnte nicht genehmigt werden. Er befindet sich noch in der Warteschlange.", + "Could not reject the application. It is still in the queue.": "Der Antrag konnte nicht abgelehnt werden. Er befindet sich noch in der Warteschlange.", + "Removed the user from {count} team folders.": "Benutzer aus {count} Teamordnern entfernt.", + "Approve a share": "Eine Freigabe genehmigen", + "This approval link is incomplete. Open it again from the notification.": "Dieser Genehmigungslink ist unvollständig. Öffne ihn erneut über die Benachrichtigung.", + "Deny": "Ablehnen", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ist einer Gruppe beigetreten, mit der du ein Geheimnis teilst. Das Geheimnis auch mit dieser Person teilen?", + "{requester} asks you to share a secret with {user}.": "{requester} bittet dich, ein Geheimnis mit {user} zu teilen.", + "Shared. The recipient can now open the secret.": "Geteilt. Der Empfänger kann das Geheimnis jetzt öffnen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Der Empfänger hat Keepiq noch nicht eingerichtet, daher wurde nichts geteilt. Versuche es erneut, sobald dies geschehen ist.", + "Could not share the secret. Only its owner can approve this.": "Das Geheimnis konnte nicht geteilt werden. Nur der Eigentümer kann dies genehmigen.", + "Could not share the secret. Try again.": "Das Geheimnis konnte nicht geteilt werden. Versuche es erneut.", + "Denied. Nothing was shared.": "Abgelehnt. Es wurde nichts geteilt.", + "Could not deny the request. Try again.": "Die Anfrage konnte nicht abgelehnt werden. Versuche es erneut.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s bittet dich, das Geheimnis \"%2$s\" mit %3$s zu teilen.", + "Expires on (optional)": "Läuft ab am (optional)", + "Hand over to": "Übergeben an", + "Choose a recipient": "Empfänger auswählen", + "Hand over temporarily": "Vorübergehend übergeben", + "Expiry rules": "Ablaufregeln", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Lege fest, wie lange Passwörter eines Elementtyps oder in einem Ordner gültig sein dürfen und wann du erinnert wirst. Wenn mehrere Daten gelten, zählt das früheste.", + "Delete rule": "Regel löschen", + "Set by your administrator": "Von deiner Administration festgelegt", + "No expiry rules yet.": "Noch keine Ablaufregeln.", + "Applies to": "Gilt für", + "Item type": "Elementtyp", + "Maximum age in days (empty for reminders only)": "Maximales Alter in Tagen (leer für reine Erinnerungen)", + "Remind me this many days before, comma separated": "So viele Tage vorher erinnern, durch Kommas getrennt", + "Save rule": "Regel speichern", + "An item type": "Ein Elementtyp", + "A folder": "Ein Ordner", + "Folder {name}": "Ordner {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Läuft nach {days} Tagen ab", + "Reminders {days} days before": "Erinnerungen {days} Tage vorher", + "Could not save the expiry rule.": "Die Ablaufregel konnte nicht gespeichert werden.", + "Could not delete the expiry rule.": "Die Ablaufregel konnte nicht gelöscht werden.", + "All statuses": "Alle Status", + "Compromised": "Kompromittiert", + "Could not load the members.": "Die Mitglieder konnten nicht geladen werden.", + "Emergency contact": "Notfallkontakt", + "Leaving user": "Ausscheidender Benutzer", + "No": "Nein", + "No users match this filter.": "Keine Benutzer für diesen Filter.", + "Not set up": "Nicht eingerichtet", + "Revoke suite": "Suite widerrufen", + "Revoked": "Widerrufen", + "Search users": "Benutzer suchen", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Sehen Sie, welche Benutzer einen Tresor eingerichtet haben. Starten Sie das Offboarding oder widerrufen Sie eine Suite aus einer Zeile.", + "Successor": "Nachfolger", + "Team folders": "Teamordner", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Der Benutzer ist noch in der Gruppe {groups}, die Mitglied eines Teamordners ist. Entfernen Sie ihn aus der Gruppe oder deaktivieren Sie das Konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Der Benutzer ist noch in den Gruppen {groups}, die Mitglieder von Teamordnern sind. Entfernen Sie ihn aus den Gruppen oder deaktivieren Sie das Konto.", + "Vault status": "Tresorstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Ein CXF-Export ist UNVERSCHLÜSSELT. Jedes Passwort und jeder Login ist in der heruntergeladenen Datei als Klartext lesbar. Bewahren Sie sie sicher auf und löschen Sie sie unmittelbar nach der Verwendung.", + "Root certificate expiring soon": "Stammzertifikat läuft bald ab", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Das Stammzertifikat des Tresors läuft in %1$d Tag(en) ab. Erneuern Sie es vorher. Beim Erneuern wird jede Verschlüsselungssuite neu signiert.", + "Compromise recovery aborted": "Kompromittierungs-Wiederherstellung abgebrochen", + "Key rotation ended by a compromise revoke": "Schlüsselrotation durch einen Kompromittierungs-Widerruf beendet", + "Encryption suite revoke refused": "Widerruf der Verschlüsselungssuite abgelehnt", + "Master password proof refused": "Nachweis des Master-Passworts abgelehnt", + "Your current master password": "Ihr aktuelles Master-Passwort", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n Notfallkontakt hatte eine offene Zugriffsanfrage, als Ihre Schlüsselrotation ihn entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n Notfallkontakte hatten eine offene Zugriffsanfrage, als Ihre Schlüsselrotation sie entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Diese Notfallkontakte wurden nicht zu Ihrem neuen Schlüssel übernommen. Ihr Notfallzugriff wurde entfernt. Fügen Sie sie unter Notfallzugriff erneut hinzu, wenn Sie sie noch möchten.", + "Renew root certificate": "Stammzertifikat erneuern", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dadurch werden ein neues Stamm- und Zwischenzertifikat erstellt. Jede aktive Verschlüsselungssuite wird neu signiert. Das lässt sich nicht rückgängig machen.", + "Renew root": "Stamm erneuern", + "Root renewed. {n} encryption suites signed again.": "Stamm erneuert. {n} Verschlüsselungssuiten neu signiert.", + "Could not renew the root certificate.": "Das Stammzertifikat konnte nicht erneuert werden.", + "Lease policy for this application": "Lease-Richtlinie für diese Anwendung", + "In force now: {default} seconds by default, {max} seconds at most.": "Jetzt gültig: standardmäßig {default} Sekunden, höchstens {max} Sekunden.", + "Leases are not renewable": "Leases sind nicht verlängerbar", + "Lease policy saved.": "Lease-Richtlinie gespeichert.", + "Leave a field empty to use the instance value.": "Lass ein Feld leer, um den Wert der Instanz zu verwenden.", + "Instance value: {value}": "Wert der Instanz: {value}", + "Renewal": "Verlängerung", + "Use the instance value ({value})": "Wert der Instanz verwenden ({value})", + "Allowed": "Erlaubt", + "Not allowed": "Nicht erlaubt", + "Save lease policy": "Lease-Richtlinie speichern", + "Only an administrator can change this policy.": "Nur eine Administratorin oder ein Administrator kann diese Richtlinie ändern.", + "Could not save the lease policy.": "Die Lease-Richtlinie konnte nicht gespeichert werden.", + "{member} got access from {confirmer}.": "{member} hat Zugriff von {confirmer} erhalten.", + "Automatically confirm new team folder members": "Neue Teamordner-Mitglieder automatisch bestätigen", + "Gave %n new member access to a team folder.": "%n neues Mitglied hat Zugriff auf einen Teamordner erhalten.", + "Gave %n new members access to a team folder.": "%n neue Mitglieder haben Zugriff auf einen Teamordner erhalten.", + "Give new team folder members access without waiting for the folder owner.": "Geben Sie neuen Teamordner-Mitgliedern Zugriff, ohne auf den Ordnereigentümer zu warten.", + "New team folder members": "Neue Teamordner-Mitglieder", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Der Eigentümer oder ein Mitglied mit Schreibrechten bestätigt sie aus dem geöffneten Tresor. Keepiq entschlüsselt nie auf dem Server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Warten auf ein Mitglied mit Schreibrechten, das Keepiq öffnet. Sie können auch jetzt teilen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Ein Teil der Kompromittierungsreaktion ist fehlgeschlagen ({failed} Schritt(e)). Prüfen Sie das Serverprotokoll und widerrufen Sie die Suite dann erneut, um sie abzuschließen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dadurch wurde auch die Suite {suite} widerrufen und die Schlüsselmigration {migration} beendet.", + "Revoking the second suite deleted %n emergency-access contact.": "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakt gelöscht.", + "Revoking the second suite deleted %n emergency-access contacts.": "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakte gelöscht.", + "A suite revoked as compromised cannot be reinstated.": "Eine als kompromittiert widerrufene Suite kann nicht wiederhergestellt werden.", + "Archives to keep": "Aufzubewahrende Archive", + "Back up every vault automatically": "Jeden Tresor automatisch sichern", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sichern Sie jeden Tresor nach Zeitplan. Archive enthalten nur Chiffretext und werden mit occ wiederhergestellt.", + "Back up now": "Jetzt sichern", + "Backup public key (PEM, optional)": "Öffentlicher Sicherungsschlüssel (PEM, optional)", + "Backup requested for the next cron run": "Sicherung für den nächsten Cron-Lauf angefordert", + "Encrypted": "Verschlüsselt", + "Every (hours)": "Alle (Stunden)", + "Last backup {when} failed: {error}": "Letzte Sicherung {when} fehlgeschlagen: {error}", + "Last backup {when} succeeded.": "Letzte Sicherung {when} erfolgreich.", + "No archives yet.": "Noch keine Archive.", + "Size": "Größe", + "Vault backups": "Tresorsicherungen", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Mit einem Schlüssel wird jedes Archiv damit verschlüsselt. Bewahren Sie den privaten Schlüssel außerhalb dieses Servers auf: Sie brauchen ihn zum Prüfen oder Wiederherstellen.", + "Written": "Geschrieben", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n Benutzer im Geltungsbereich hat noch keine Zwei-Faktor-Anmeldung und kann den Tresor nicht öffnen, solange dies aktiv ist.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n Benutzer im Geltungsbereich haben noch keine Zwei-Faktor-Anmeldung und können den Tresor nicht öffnen, solange dies aktiv ist.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup-Codes zählen nicht. Wenn sich Ihre Benutzer über einen Identitätsanbieter mit eigenem zweiten Faktor anmelden, lassen Sie deren Gruppen weg.", + "Block personal vault export": "Export des persönlichen Tresors sperren", + "Keep work logins in team folders": "Arbeitszugänge in Teamordnern aufbewahren", + "Move to a team folder": "In einen Teamordner verschieben", + "Not in a team folder": "Nicht in einem Teamordner", + "Only for these groups (empty is everyone)": "Nur für diese Gruppen (leer bedeutet alle)", + "Require two-factor login before the vault opens": "Zwei-Faktor-Anmeldung verlangen, bevor sich der Tresor öffnet", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regeln für jeden Tresor. Jede gilt für alle oder nur für die von Ihnen gewählten Gruppen.", + "Secret types that belong in a team folder": "Geheimnistypen, die in einen Teamordner gehören", + "Set up two-factor login": "Zwei-Faktor-Anmeldung einrichten", + "Team folder you can write to": "Teamordner, in den Sie schreiben können", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Benutzer können keine Sicherung, CSV- oder Übertragungsdatei herunterladen. Ihr persönliches Datenpaket bleibt verfügbar.", + "Users cannot save these secret types in a personal folder.": "Benutzer können diese Geheimnistypen nicht in einem persönlichen Ordner speichern.", + "Vault policies": "Tresorrichtlinien", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ihre Organisation erlaubt keinen Export Ihres persönlichen Tresors. Ihr persönliches Datenpaket in den Einstellungen bleibt verfügbar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ihre Organisation bewahrt diese Geheimnisse in einem Teamordner auf. Verschieben Sie jedes in einen Teamordner.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ihre Organisation bewahrt diesen Geheimnistyp in einem Teamordner auf. Wählen Sie einen Ihrer Teamordner oder einen, in den Sie schreiben können.", + "Your organisation requires two-factor login before you can open your vault.": "Ihre Organisation verlangt eine Zwei-Faktor-Anmeldung, bevor Sie Ihren Tresor öffnen können.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Benutzer wählen, wie lange die Erweiterung bei Inaktivität entsperrt bleibt. Sie legen die längste wählbare Zeit fest.", + "Longest idle time before the extension locks": "Längste Inaktivitätszeit, bevor die Erweiterung sperrt", + "1 minute": "1 Minute", + "5 minutes": "5 Minuten", + "15 minutes": "15 Minuten", + "1 hour": "1 Stunde", + "4 hours": "4 Stunden", + "Connector": "Konnektor", + "Directory (tenant) ID": "Verzeichnis-ID (Mandant)", + "Application (client) ID": "Anwendungs-ID (Client)", + "Data collection rule immutable ID": "Unveränderliche ID der Datensammlungsregel", + "Stream name": "Streamname", + "Splunk index (optional)": "Splunk-Index (optional)", + "Sourcetype (optional)": "Sourcetype (optional)", + "Leave blank to keep the current one": "Leer lassen, um den aktuellen Wert zu behalten", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF über Syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datensammlungsendpunkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP-Event-Collector-URL (https)", + "Client secret (write-only)": "Clientgeheimnis (nur schreiben)", + "HEC token (write-only)": "HEC-Token (nur schreiben)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Erlaubte Audit-Ereignisse an Splunk, Microsoft Sentinel, einen Syslog-Empfänger oder einen Webhook weiterleiten. Nachrichten enthalten nur bereinigte Metadaten: Kein geheimer Wert, Name, Login oder Geheimtext verlässt je den Server.", + "%n change waiting to sync": "%n Änderung wartet auf Synchronisierung", + "%n changes waiting to sync": "%n Änderungen warten auf Synchronisierung", + "Changes that could not sync": "Änderungen, die nicht synchronisiert werden konnten", + "Choose a version": "Version wählen", + "Copy value": "Wert kopieren", + "Deleted": "Gelöscht", + "Discard": "Verwerfen", + "Keep my offline change": "Meine Offline-Änderung behalten", + "Keep the server version": "Die Serverversion behalten", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq ist offline schreibgeschützt. Ihre Administration hat Offline-Bearbeitung nicht eingeschaltet.", + "Let users edit secrets offline": "Benutzern erlauben, Geheimnisse offline zu bearbeiten", + "Not synced yet": "Noch nicht synchronisiert", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-Änderungen bleiben auf dem Gerät, für den Benutzer verschlüsselt, und werden beim nächsten Online-Entsperren synchronisiert. Teilen, Ordner und Anhänge brauchen weiterhin eine Verbindung.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Bearbeitungen, Verschiebungen und Löschungen bleiben auf diesem Gerät und werden synchronisiert, sobald Sie wieder online sind. Teilen und Anhänge brauchen eine Verbindung.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Ihre Änderungen bleiben auf diesem Gerät und werden synchronisiert, sobald Sie wieder online sind. Zuletzt synchronisiert {when}.", + "Open my changes": "Meine Änderungen öffnen", + "Sharing needs a connection": "Teilen braucht eine Verbindung", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Jemand hat dieses Geheimnis auf dem Server geändert, nachdem Ihre Offline-Kopie erstellt wurde. Wählen Sie, welche Version Sie behalten.", + "Sync or discard your offline changes before you rotate your keys.": "Synchronisieren oder verwerfen Sie Ihre Offline-Änderungen, bevor Sie Ihre Schlüssel erneuern.", + "That password did not open your changes.": "Mit diesem Passwort ließen sich Ihre Änderungen nicht öffnen.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Der Offline-Schnappschuss speichert verschlüsselte Geheimnisse (nur mit dem aus dem Master-Passwort des Benutzers abgeleiteten Schlüssel zu öffnen, genau wie auf dem Server) und verschlüsselt Namen, URLs und Ordnernamen im Ruhezustand. Offline-Zugriff ist schreibgeschützt, außer Sie erlauben unten Offline-Bearbeitung. Schalten Sie dies für Geräte aus, die niemals Zugangsdaten zwischenspeichern dürfen; das Ausschalten löscht vorhandene Caches beim nächsten Laden.", + "The previous vault copy is gone, so these changes cannot be opened.": "Die vorherige Tresorkopie ist weg, daher können diese Änderungen nicht geöffnet werden.", + "The server version": "Die Serverversion", + "This secret changed while you were offline": "Dieses Geheimnis wurde geändert, während Sie offline waren", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Sie haben dieses Geheimnis offline gelöscht, aber es wurde seitdem auf dem Server geändert. Wählen Sie, welche Version Sie behalten.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ihre Schlüssel wurden auf einem anderen Gerät geändert. Geben Sie Ihr vorheriges Master-Passwort ein, um Ihre Offline-Änderungen zu synchronisieren, oder verwerfen Sie sie.", + "Your offline change": "Ihre Offline-Änderung", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n Notfallkontakt hatte eine offene Zugriffsanfrage, als Ihre Schlüsselrotation ihn entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen.", + "%n Notfallkontakte hatten eine offene Zugriffsanfrage, als Ihre Schlüsselrotation sie entfernte. Prüfen Sie, wer angefragt hat, bevor Sie jemanden erneut hinzufügen." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n Element kann nicht in CXF dargestellt werden und wird übersprungen.", + "%n Elemente können nicht in CXF dargestellt werden und werden übersprungen." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n ältere Version wurde verworfen, da nur die neuere Historie übernommen werden kann.", + "%n ältere Versionen wurden verworfen, da nur die neuere Historie übernommen werden kann." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n Geheimniskopie muss noch verschlüsselt und geteilt werden.", + "%n Geheimniskopien müssen noch verschlüsselt und geteilt werden." + ], + "_%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 Geheimnis konnte nicht entschlüsselt werden und ist nicht in diesem Export enthalten.", + "%n Geheimnisse konnten nicht entschlüsselt werden und sind nicht in diesem Export enthalten." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n Geheimnis konnte mit Ihrem alten Schlüssel nicht entschlüsselt werden und wurde daher nicht migriert.", + "%n Geheimnisse konnten mit Ihrem alten Schlüssel nicht entschlüsselt werden und wurden daher nicht migriert." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n Geheimnis wurde nicht migriert.", + "%n Geheimnisse wurden nicht migriert." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n Geheimnis ist noch mit Ihrem vorherigen Schlüssel verschlüsselt.", + "%n Geheimnisse sind noch mit Ihrem vorherigen Schlüssel verschlüsselt." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n Geheimnis wurde übersprungen, weil der Nachfolger noch keine Kopie besitzt — fügen Sie den Nachfolger dem Ordner hinzu und führen Sie den Vorgang erneut aus.", + "%n Geheimnisse wurden übersprungen, weil der Nachfolger noch keine Kopie besitzt — fügen Sie den Nachfolger dem Ordner hinzu und führen Sie den Vorgang erneut aus." + ], + "_%n secret_::_%n secrets_": [ + "%n Geheimnis", + "%n Geheimnisse" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n Benutzer im Geltungsbereich hat noch keine Zwei-Faktor-Anmeldung und kann den Tresor nicht öffnen, solange dies aktiv ist.", + "%n Benutzer im Geltungsbereich haben noch keine Zwei-Faktor-Anmeldung und können den Tresor nicht öffnen, solange dies aktiv ist." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Trotzdem abschließen und den Zugriff auf %n Geheimnis verlieren", + "Trotzdem abschließen und den Zugriff auf %n Geheimnisse verlieren" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n neues Mitglied hat Zugriff auf einen Teamordner erhalten.", + "%n neue Mitglieder haben Zugriff auf einen Teamordner erhalten." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Schlüsselrotation abgeschlossen. %n Geheimnis wurde mit Ihrem neuen Schlüssel neu verschlüsselt.", + "Schlüsselrotation abgeschlossen. %n Geheimnisse wurden mit Ihrem neuen Schlüssel neu verschlüsselt." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakt gelöscht.", + "Der Widerruf der zweiten Suite hat %n Notfallzugangskontakte gelöscht." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Der Widerruf dieser Suite hat %n Notfallzugangskontakt gelöscht.", + "Der Widerruf dieser Suite hat %n Notfallzugangskontakte gelöscht." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "%n Mal in Leaks gefunden", + "%n Mal in Leaks gefunden" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "mit %n Geheimnis geteilt", + "mit %n Geheimnissen geteilt" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Dieser Ordner enthält direkt %n Geheimnis.", + "Dieser Ordner enthält direkt %n Geheimnisse." + ], + "_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._": [ + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n Änderung wartet auf Synchronisierung", + "%n Änderungen warten auf Synchronisierung" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Der Benutzer ist noch in der Gruppe {groups}, die Mitglied eines Teamordners ist. Entfernen Sie ihn aus der Gruppe oder deaktivieren Sie das Konto.", + "Der Benutzer ist noch in den Gruppen {groups}, die Mitglieder von Teamordnern sind. Entfernen Sie ihn aus den Gruppen oder deaktivieren Sie das Konto." + ], + "Allow approval from another device": "Freigabe von einem anderen Gerät erlauben", + "App": "App", + "Approve a new device": "Neues Gerät freigeben", + "Approve from another device": "Von einem anderen Gerät freigeben", + "Asked at": "Angefragt um", + "Check that the new device shows these words:": "Prüfe, ob das neue Gerät diese Wörter anzeigt:", + "Denied. If you did not ask, end your other sessions:": "Abgelehnt. Wenn du das nicht angefragt hast, beende deine anderen Sitzungen:", + "Device": "Gerät", + "IP address": "IP-Adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Nutzer können einen neuen Browser entsperren, indem sie ihn auf einem Gerät freigeben, auf dem Keepiq bereits entsperrt ist.", + "New device approval": "Freigabe neuer Geräte", + "Nextcloud security settings": "Nextcloud-Sicherheitseinstellungen", + "Only approve a device you are using right now.": "Gib nur ein Gerät frei, das du gerade selbst benutzt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Öffne Keepiq auf einem Gerät, auf dem es entsperrt ist, und gib dieses Gerät frei. Prüfe, ob dort dieselben Wörter angezeigt werden:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Das freigebende Gerät verschlüsselt den Entsperrschlüssel für das neue Gerät. Der Server leitet ihn nur weiter und kann ihn nicht öffnen.", + "The master password is not right, or the request has ended.": "Das Master-Passwort ist falsch oder die Anfrage ist beendet.", + "The request expired. Ask again or use your master password.": "Die Anfrage ist abgelaufen. Frage erneut an oder nutze dein Master-Passwort.", + "The request was denied.": "Die Anfrage wurde abgelehnt.", + "Too many requests. Try again in an hour or use your master password.": "Zu viele Anfragen. Versuche es in einer Stunde erneut oder nutze dein Master-Passwort.", + "Unknown device": "Unbekanntes Gerät", + "Web app": "Web-App", + "A device": "Ein Gerät", + "A new device asks to open your vault": "Ein neues Gerät möchte deinen Tresor öffnen", + "%s asks to be approved. Only approve a device you are using right now.": "%s bittet um Freigabe. Gib nur ein Gerät frei, das du gerade selbst benutzt.", + "Access ends on (optional)": "Zugriff endet am (optional)", + "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.": "Die Apps von Keepiq zeigen oder kopieren das Passwort nicht. Jemand mit technischen Kenntnissen kann es trotzdem auf dem eigenen Gerät auslesen. Ändere es, wenn der Zugriff endet.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dieses Geheimnis ist nur zur Nutzung freigegeben. Melde dich über die Keepiq-Browsererweiterung an.", + "Until {date}": "Bis {date}", + "Use only": "Nur verwenden", + "Use only (can sign in, cannot view or copy)": "Nur verwenden (anmelden möglich, ansehen oder kopieren nicht)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kannst dich mit diesen Zugangsdaten über die Keepiq-Browsererweiterung anmelden. Der Eigentümer hat entschieden, dass du sie nicht ansehen oder kopieren darfst.", + "Your access ends on {date}": "Dein Zugriff endet am {date}", + "Your access to this secret has ended": "Dein Zugriff auf dieses Geheimnis ist beendet", + "Your access to \"%s\" ends tomorrow": "Dein Zugriff auf „%s“ endet morgen", + "Your access to \"%s\" has ended": "Dein Zugriff auf „%s“ ist beendet", + "%1$s no longer has access to \"%2$s\"": "%1$s hat keinen Zugriff mehr auf „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s konnte dieses Passwort sehen. Ändere es, wenn %1$s es nicht mehr kennen soll.", + "%s could not view this password in Keepiq.": "%s konnte dieses Passwort in Keepiq nicht ansehen.", + "{approvals} of {threshold} approvals": "{approvals} von {threshold} Freigaben", + "a recovery officer": "eine Wiederherstellungsbeauftragte Person", + "Account recovery": "Kontowiederherstellung", + "Approvals needed": "Benötigte Freigaben", + "Ask {user} which words they see, by phone or in person. They must be:": "Fragen Sie {user} am Telefon oder persönlich, welche Wörter angezeigt werden. Es müssen diese sein:", + "Check again": "Erneut prüfen", + "Create the recovery key": "Wiederherstellungsschlüssel erstellen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Erstellen Sie den Wiederherstellungsschlüssel. Ihr Browser erzeugt ihn und gibt jeder beauftragten Person eine Kopie, die nur sie öffnen kann.", + "Decline": "Ablehnen", + "Enrol in account recovery": "Für die Kontowiederherstellung anmelden", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Melden Sie sich an, damit Ihre Organisation Ihnen helfen kann, Ihren Tresor zurückzubekommen, wenn Sie Ihr Master-Passwort vergessen.", + "Every user is enrolled": "Alle Benutzer sind angemeldet", + "Finish the recovery in the browser you asked from.": "Schließen Sie die Wiederherstellung in dem Browser ab, in dem Sie sie angefragt haben.", + "Forgot your master password?": "Master-Passwort vergessen?", + "Hand the key over": "Schlüssel übergeben", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Benutzer, die ihr Master-Passwort vergessen haben, bekommen ihren Tresor zurück, freigegeben von Wiederherstellungsbeauftragten, die Sie benennen.", + "New master password": "Neues Master-Passwort", + "No one is asking to recover their account.": "Niemand bittet um die Wiederherstellung seines Kontos.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Noch kein Wiederherstellungsschlüssel. Eine der beauftragten Personen erstellt ihn in ihren Keepiq-Einstellungen.", + "Off": "Aus", + "Officer {user} has no encryption set up yet.": "Die beauftragte Person {user} hat noch keine Verschlüsselung eingerichtet.", + "Officers (user IDs, separated by commas)": "Beauftragte (Benutzer-IDs, durch Kommas getrennt)", + "Policy": "Richtlinie", + "Publish this fingerprint internally, so users can check it before they enrol.": "Veröffentlichen Sie diesen Fingerabdruck intern, damit Benutzer ihn vor der Anmeldung prüfen können.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Mit Hilfe von {officer} wiederhergestellt. Ändern Sie jetzt Ihren Tresorschlüssel unter Einstellungen, Sicherheit: \"Mein Master-Passwort wurde kompromittiert\".", + "Recovery key fingerprint: {fingerprint}": "Fingerabdruck des Wiederherstellungsschlüssels: {fingerprint}", + "Recovery officer": "Wiederherstellungsbeauftragte Person", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Entfernte Beauftragte verlieren ihre Kopie jetzt, könnten sie aber vorher geöffnet haben. Lassen Sie eine beauftragte Person einen neuen Wiederherstellungsschlüssel erstellen.", + "Repeat the new master password": "Neues Master-Passwort wiederholen", + "Retire this recovery key": "Diesen Wiederherstellungsschlüssel stilllegen", + "Set the new master password": "Neues Master-Passwort festlegen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Das Wiederherstellungszertifikat wurde nicht von diesem Keepiq ausgestellt. Melden Sie sich nicht an und informieren Sie Ihre Administration.", + "The words match, approve": "Die Wörter stimmen, freigeben", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dieser Benutzer ist für die Kontowiederherstellung angemeldet. Wiederherstellen behält seine Geheimnisse; Widerrufen löscht seine Anmeldung.", + "Users may enrol": "Benutzer dürfen sich anmelden", + "Withdraw from account recovery": "Von der Kontowiederherstellung abmelden", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sie sind für die Kontowiederherstellung angemeldet. Fingerabdruck des Wiederherstellungsschlüssels: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sie sind angemeldet. Wenn Sie Ihr Master-Passwort vergessen, kann Ihre Organisation Ihnen helfen, Ihren Tresor zurückzubekommen.", + "Your key is back. Choose a new master password.": "Ihr Schlüssel ist zurück. Wählen Sie ein neues Master-Passwort.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ihre Wiederherstellungsbeauftragten wurden benachrichtigt. Lesen Sie ihnen diese Wörter vor, wenn sie anrufen oder Sie treffen:", + "You are now an account recovery officer": "Sie sind jetzt für die Kontowiederherstellung beauftragt", + "%s asks to recover their account. Compare the words with them before you approve.": "%s bittet um die Wiederherstellung des Kontos. Vergleichen Sie die Wörter mit der Person, bevor Sie freigeben.", + "A user": "Ein Benutzer", + "Your account recovery request was declined": "Ihre Anfrage zur Kontowiederherstellung wurde abgelehnt", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Ihre Kontowiederherstellung ist bereit. Öffnen Sie Keepiq in dem Browser, in dem Sie sie angefragt haben.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} bittet darum, ein neues Gerät einmalig zu entsperren. Das Master-Passwort bleibt gleich.", + "Ask your organisation instead": "Stattdessen deine Organisation fragen", + "The request ended. Ask again or use your master password.": "Die Anfrage ist beendet. Frage erneut oder verwende dein Master-Passwort.", + "Added by {user}": "Hinzugefügt von {user}", + "Editor": "Bearbeiter", + "Manager": "Verwalter", + "Role of {member}": "Rolle von {member}", + "Team folders you manage": "Teamordner, die Sie verwalten", + "Viewer": "Betrachter", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sie haben keine Kopie dieser Geheimnisse, daher haben die neuen Mitglieder sie noch nicht erhalten. Der Eigentümer kann sie teilen: {names}", + "Admin areas": "Verwaltungsbereiche", + "Give a group only the parts of Keepiq administration it needs.": "Geben Sie einer Gruppe nur die Teile der Keepiq-Verwaltung, die sie braucht.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegieren Sie einen oder mehrere Bereiche auf der Seite Verwaltungsrechte an eine Gruppe. Instanzadministratoren haben jeden Bereich.", + "Open administration privileges": "Verwaltungsrechte öffnen", + "Policies": "Richtlinien", + "Applications and machine access": "Anwendungen und Maschinenzugriff", + "People and offboarding": "Personen und Austritt", + "Audit and compliance": "Audit und Compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "Version, Zertifizierungsstelle, Anhänge, Offline-Cache, Leckprüfung, Geheimnistypen und Sicherungen", + "master password, organisation password, vault policies, rotation, version history and trash": "Master-Passwort, Organisationspasswort, Tresorrichtlinien, Rotation, Versionsverlauf und Papierkorb", + "application queue, application requests and machine leases": "Anwendungswarteschlange, Anwendungsanfragen und Maschinen-Leases", + "team offboarding, encryption suites and admin handover": "Team-Austritt, Verschlüsselungssuiten und Administratorübernahme", + "audit log, compliance reports, SIEM export and honey alerts": "Auditprotokoll, Compliance-Berichte, SIEM-Export und Honigalarme", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Wie viele Versionen eines Geheimnisses wie lange aufbewahrt werden und wie lange gelöschte Geheimnisse im Papierkorb bleiben.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenzen für verschlüsselte Dateianhänge, auf dem Server in gespeicherten verschlüsselten Bytes durchgesetzt.", + "Type the suite ID again to confirm": "Geben Sie die Suite-ID zur Bestätigung erneut ein", + "This does not match the suite ID.": "Dies stimmt nicht mit der Suite-ID überein.", + "Confirm with your master password": "Mit Ihrem Masterpasswort bestätigen", + "Confirm": "Bestätigen", + "That master password is not right.": "Dieses Masterpasswort ist nicht richtig.", + "You are sharing with someone new. Enter your master password to confirm.": "Sie teilen mit einer neuen Person. Geben Sie zur Bestätigung Ihr Masterpasswort ein.", + "Enter your master password to confirm this share.": "Geben Sie Ihr Masterpasswort ein, um diese Freigabe zu bestätigen.", + "Enter your master password to confirm this delegation.": "Geben Sie Ihr Masterpasswort ein, um diese Delegierung zu bestätigen.", + "Approve {member}": "{member} genehmigen", + "Recipient": "Empfänger", + "No vault yet": "Noch kein Tresor", + "No matching users": "Keine passenden Benutzer", + "Partner organisations": "Partnerorganisationen", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Tauschen Sie Geheimnisse mit einem anderen Keepiq aus. Beide Administratoren fügen sich gegenseitig hinzu und vergleichen die Root-Fingerabdrücke telefonisch oder persönlich, bevor sie speichern.", + "Federation needs Nextcloud 33 or later.": "Föderation erfordert Nextcloud 33 oder neuer.", + "Your root fingerprint": "Ihr Root-Fingerabdruck", + "No partners yet.": "Noch keine Partner.", + "Users here may share to this partner": "Benutzer hier dürfen mit diesem Partner teilen", + "This partner may share to users here": "Dieser Partner darf mit Benutzern hier teilen", + "Partner address": "Adresse des Partners", + "Check partner": "Partner prüfen", + "Partner root fingerprint": "Root-Fingerabdruck des Partners", + "I compared this fingerprint with the partner's administrator": "Ich habe diesen Fingerabdruck mit dem Administrator des Partners verglichen", + "Add partner": "Partner hinzufügen", + "A secret from another organisation": "Ein Geheimnis von einer anderen Organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s hat \"%2$s\" mit Ihnen geteilt. Nehmen Sie es unter Eingang von anderen Organisationen an.", + "Incoming from other organisations": "Eingang von anderen Organisationen", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personen in Partnerorganisationen können ein Geheimnis mit Ihnen teilen. Nehmen Sie es an, um eine schreibgeschützte Kopie in Ihrem Tresor zu behalten.", + "Nothing shared with you yet": "Noch nichts mit Ihnen geteilt", + "Secrets that people in partner organisations share with you appear here.": "Geheimnisse, die Personen in Partnerorganisationen mit Ihnen teilen, erscheinen hier.", + "From {sender}": "Von {sender}", + "Accept": "Annehmen", + "Open in vault": "Im Tresor öffnen", + "The other organisation did not hand over the secret. Try again later.": "Die andere Organisation hat das Geheimnis nicht übergeben. Versuchen Sie es später erneut.", + "Set up your vault before you accept a shared secret.": "Richten Sie Ihren Tresor ein, bevor Sie ein geteiltes Geheimnis annehmen.", + "Something went wrong. Try again.": "Etwas ist schiefgelaufen. Versuchen Sie es erneut.", + "Waiting for your answer": "Wartet auf Ihre Antwort", + "In your vault, read-only": "In Ihrem Tresor, schreibgeschützt", + "Withdrawn by the sender": "Vom Absender zurückgezogen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} hat dies aus einer anderen Organisation geteilt. Sie können es lesen, aber nicht ändern oder teilen.", + "Someone": "Jemand", + "Share with someone at another organisation": "Mit jemandem in einer anderen Organisation teilen", + "Their account at the other organisation": "Konto der Person bei der anderen Organisation", + "Check account": "Konto prüfen", + "Certificate fingerprint of {account}": "Fingerabdruck des Zertifikats von {account}", + "Compare it with them by phone if you want to be sure.": "Vergleichen Sie ihn telefonisch mit der Person, wenn Sie sichergehen möchten.", + "Shared. {account} can accept it in their own vault.": "Geteilt. {account} kann es im eigenen Tresor annehmen.", + "The certificate could not be verified. Nothing was shared.": "Das Zertifikat konnte nicht überprüft werden. Es wurde nichts geteilt.", + "That organisation is not one of your partners.": "Diese Organisation gehört nicht zu Ihren Partnern.", + "No one with that account can receive secrets from you.": "Niemand mit diesem Konto kann Geheimnisse von Ihnen empfangen.", + "The other organisation did not answer. Try again later.": "Die andere Organisation hat nicht geantwortet. Versuchen Sie es später erneut.", + "This secret is already shared with that account.": "Dieses Geheimnis ist bereits mit diesem Konto geteilt.", + "Other organisations": "Andere Organisationen", + "Receive secrets from other organisations": "Geheimnisse von anderen Organisationen empfangen", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personen in Partnerorganisationen können dann Ihr Konto finden und Geheimnisse mit Ihnen teilen. Sie nehmen jedes davon selbst an.", + "Shared": "Geteilt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pausiert: Das Zertifikat des Empfängers oder die Partnerschaft hat sich geändert. Widerrufen Sie die Freigabe oder teilen Sie erneut.", + "Their organisation did not get the last change. Revoke it or share again.": "Die Organisation des Empfängers hat die letzte Änderung nicht erhalten. Widerrufen Sie die Freigabe oder teilen Sie erneut.", + "Being withdrawn": "Wird zurückgezogen", + "Shared with another organisation": "Mit einer anderen Organisation geteilt", + "Change sent to another organisation": "Änderung an eine andere Organisation gesendet", + "Share with another organisation revoked": "Freigabe für eine andere Organisation widerrufen", + "Share with another organisation paused": "Freigabe für eine andere Organisation pausiert", + "Another organisation did not get a change": "Eine andere Organisation hat eine Änderung nicht erhalten", + "Secret received from another organisation": "Geheimnis von einer anderen Organisation erhalten", + "Secret from another organisation accepted": "Geheimnis von einer anderen Organisation angenommen", + "Secret from another organisation declined": "Geheimnis von einer anderen Organisation abgelehnt", + "Copy from another organisation updated": "Kopie von einer anderen Organisation aktualisiert", + "Copy from another organisation removed": "Kopie von einer anderen Organisation entfernt", + "Declined: they removed their copy. Share again if they need it.": "Abgelehnt: Der Empfänger hat seine Kopie entfernt. Teilen Sie erneut, wenn er sie braucht.", + "Recipient at another organisation removed their copy": "Empfänger in einer anderen Organisation hat seine Kopie entfernt", + "Removed the user from %n team folder.": "Benutzer aus %n Teamordner entfernt.", + "Removed the user from %n team folders.": "Benutzer aus %n Teamordnern entfernt.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Benutzer aus %n Teamordner entfernt.", + "Benutzer aus %n Teamordnern entfernt." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Eine wiederhergestellte Kopie stammt aus einer beendeten Freigabe. Sie bleibt schreibgeschützt.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Die Organisation, die eine wiederhergestellte Kopie geteilt hat, ist nicht erreichbar. Die Kopie bleibt schreibgeschützt und folgt ihren Änderungen nicht.", + "Recipient at another organisation restored their copy": "Empfänger in einer anderen Organisation hat seine Kopie wiederhergestellt" }, "plurals": null } diff --git a/l10n/el.js b/l10n/el.js index 5b19d8f67..247b7a862 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα.", + "Shared with groups": "Κοινοποιήθηκε σε ομάδες", + "Not shared with any group yet.": "Δεν έχει κοινοποιηθεί ακόμη σε καμία ομάδα.", + "Revoke the share with {group}": "Ανάκληση της κοινοποίησης στην ομάδα {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Κοινοποιήθηκε στην ομάδα {group}: {received} μέλη το έλαβαν, {skipped} όχι, επειδή δεν έχουν ρυθμίσει ακόμη κρυπτογράφηση.", + "Search groups": "Αναζήτηση ομάδων", + "Failed to share": "Η κοινοποίηση απέτυχε", + "Columns": "Στήλες", + "Column {number}": "Στήλη {number}", + "Map one column to Name. Every secret needs a name.": "Αντιστοιχίστε μία στήλη στο όνομα. Κάθε μυστικό χρειάζεται όνομα.", + "Notes": "Σημειώσεις", + "Do not import": "Να μην εισαχθεί", + "Hide this value": "Απόκρυψη αυτής της τιμής", + "Show this value": "Εμφάνιση αυτής της τιμής", + "Defaults": "Προεπιλογές", + "New secrets start as this type, and your secret list opens in this view.": "Τα νέα μυστικά ξεκινούν με αυτόν τον τύπο και η λίστα μυστικών ανοίγει σε αυτή την προβολή.", + "Default item type": "Προεπιλεγμένος τύπος στοιχείου", + "Cards": "Κάρτες", + "Table": "Πίνακας", + "Could not save your default": "Δεν ήταν δυνατή η αποθήκευση της προεπιλογής σας", + "Recently used": "Πρόσφατα χρησιμοποιημένα", + "Opened": "Άνοιξε", + "You have not opened any secrets yet": "Δεν έχετε ανοίξει ακόμη κανένα μυστικό", + "Could not delete the item type.": "Δεν ήταν δυνατή η διαγραφή του τύπου στοιχείου.", + "Could not load the item types.": "Δεν ήταν δυνατή η φόρτωση των τύπων στοιχείων.", + "Could not save the item type.": "Δεν ήταν δυνατή η αποθήκευση του τύπου στοιχείου.", + "Delete item type": "Διαγραφή τύπου στοιχείου", + "Edit item type": "Επεξεργασία τύπου στοιχείου", + "Fields": "Πεδία", + "Fields: {count}": "Πεδία: {count}", + "Hidden": "Κρυφό", + "Item types": "Τύποι στοιχείων", + "Move up": "Μετακίνηση πάνω", + "New item type": "Νέος τύπος στοιχείου", + "No item types defined yet.": "Δεν έχουν οριστεί ακόμη τύποι στοιχείων.", + "Required": "Υποχρεωτικό", + "Text": "Κείμενο", + "This field is required": "Αυτό το πεδίο είναι υποχρεωτικό", + "Web address": "Διεύθυνση ιστού", + "{label} (required)": "{label} (υποχρεωτικό)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Διαγραφή του «{name}»; Τα μυστικά αυτού του τύπου παραμένουν αναγνώσιμα και γίνονται στοιχεία Σύνδεσης.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Οι τύποι στοιχείων που ορίζετε εδώ εμφανίζονται σε όλους στο παράθυρο Νέο μυστικό, με τα πεδία που επιλέγετε.", + "Secret moved to the trash": "Το μυστικό μεταφέρθηκε στον κάδο", + "Secret restored from the trash": "Το μυστικό επαναφέρθηκε από τον κάδο", + "Secret deleted for good": "Το μυστικό διαγράφηκε οριστικά", + "Secret archived": "Το μυστικό αρχειοθετήθηκε", + "Secret unarchived": "Το μυστικό βγήκε από την αρχειοθήκη", + "Unarchive": "Έξοδος από την αρχειοθήκη", + "Could not archive the secret": "Δεν ήταν δυνατή η αρχειοθέτηση του μυστικού", + "Could not unarchive the secret": "Δεν ήταν δυνατή η έξοδος του μυστικού από την αρχειοθήκη", + "Archive {count} secrets": "Αρχειοθέτηση μυστικών: {count}", + "Unarchive {count} secrets": "Έξοδος από την αρχειοθήκη μυστικών: {count}", + "Restore {count} secrets": "Επαναφορά μυστικών: {count}", + "Delete {count} secrets for good": "Οριστική διαγραφή μυστικών: {count}", + "Done for {ok} of {total} secrets": "Ολοκληρώθηκε για {ok} από {total} μυστικά", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Τα αρχειοθετημένα μυστικά φεύγουν από τη λίστα του θησαυροφυλακίου, την αναζήτηση, την αυτόματη συμπλήρωση και την αναφορά υγείας. Διατηρούν τις κοινοποιήσεις τους. Θα τα βρείτε στην Αρχειοθήκη.", + "These secrets come back to the vault list, search and autofill.": "Αυτά τα μυστικά επιστρέφουν στη λίστα του θησαυροφυλακίου, την αναζήτηση και την αυτόματη συμπλήρωση.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Αυτά τα μυστικά επιστρέφουν στη λίστα του θησαυροφυλακίου. Οι παλιές κοινοποιήσεις δεν επιστρέφουν, οπότε κοινοποιήστε τα ξανά όπου χρειάζεται.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Αυτό διαγράφει τα μυστικά μαζί με τα συνημμένα και το ιστορικό εκδόσεων. Δεν μπορεί να αναιρεθεί.", + "Delete for good": "Οριστική διαγραφή", + "Trash": "Κάδος", + "The trash is empty": "Ο κάδος είναι άδειος", + "No archived secrets": "Δεν υπάρχουν αρχειοθετημένα μυστικά", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Τα διαγραμμένα μυστικά περιμένουν εδώ μέχρι να λήξει η περίοδος διατήρησης και έπειτα διαγράφονται οριστικά.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Αρχειοθετήστε ένα μυστικό από το πλαίσιο λεπτομερειών του για να μείνει έξω από τη λίστα του θησαυροφυλακίου, την αναζήτηση και την αυτόματη συμπλήρωση.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Όρια για κρυπτογραφημένα συνημμένα (επιβάλλονται στον διακομιστή στα αποθηκευμένα κρυπτογραφημένα byte), διατήρηση ιστορικού εκδόσεων και πόσο μένουν στον κάδο τα διαγραμμένα μυστικά.", + "Days a deleted secret stays in the trash (1 to 365)": "Ημέρες που ένα διαγραμμένο μυστικό μένει στον κάδο (1 έως 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Αυτό μεταφέρει το μυστικό στον κάδο και τερματίζει τώρα τις κοινοποιήσεις του. Μπορείτε να το επαναφέρετε από τον κάδο μέχρι να λήξει η περίοδος διατήρησης: 30 ημέρες, εκτός αν την άλλαξε ο διαχειριστής σας.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Αυτό μεταφέρει μυστικά στον κάδο ({count}) και τερματίζει τώρα τις κοινοποιήσεις τους. Μπορείτε να τα επαναφέρετε από τον κάδο μέχρι να λήξει η περίοδος διατήρησης.", + "Remove {name} from favourites": "Αφαίρεση του {name} από τα αγαπημένα", + "Add {name} to favourites": "Προσθήκη του {name} στα αγαπημένα", + "Could not change the favourite": "Δεν ήταν δυνατή η αλλαγή του αγαπημένου", + "Remove from favourites": "Αφαίρεση από τα αγαπημένα", + "Add to favourites": "Προσθήκη στα αγαπημένα", + "Tags": "Ετικέτες", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Οι ετικέτες δεν είναι κρυπτογραφημένες. Οι διαχειριστές του διακομιστή μπορούν να τις διαβάσουν, όπως τα ονόματα φακέλων.", + "Favourites": "Αγαπημένα", + "Filter by tag": "Φιλτράρισμα κατά ετικέτα", + "All tags": "Όλες οι ετικέτες", + "Last used": "Τελευταία χρήση", + "Tags for {count} secrets": "Ετικέτες για {count} μυστικά", + "Tag": "Ετικέτα", + "Remove tag": "Αφαίρεση ετικέτας", + "Add tag": "Προσθήκη ετικέτας", + "Could not change the tags. Try again.": "Δεν ήταν δυνατή η αλλαγή των ετικετών. Δοκιμάστε ξανά.", + "Could not approve the application. It is still in the queue.": "Δεν ήταν δυνατή η έγκριση της αίτησης. Βρίσκεται ακόμη στην ουρά.", + "Could not reject the application. It is still in the queue.": "Δεν ήταν δυνατή η απόρριψη της αίτησης. Βρίσκεται ακόμη στην ουρά.", + "Removed the user from {count} team folders.": "Ο χρήστης αφαιρέθηκε από {count} φακέλους ομάδας.", + "Approve a share": "Έγκριση κοινής χρήσης", + "This approval link is incomplete. Open it again from the notification.": "Αυτός ο σύνδεσμος έγκρισης είναι ελλιπής. Ανοίξτε τον ξανά από την ειδοποίηση.", + "Deny": "Άρνηση", + "{user} joined a group you share a secret with. Share the secret with them too?": "Ο χρήστης {user} εντάχθηκε σε μια ομάδα με την οποία μοιράζεστε ένα μυστικό. Να μοιραστεί το μυστικό και μαζί του;", + "{requester} asks you to share a secret with {user}.": "Ο χρήστης {requester} σας ζητά να μοιραστείτε ένα μυστικό με τον χρήστη {user}.", + "Shared. The recipient can now open the secret.": "Κοινοποιήθηκε. Ο παραλήπτης μπορεί πλέον να ανοίξει το μυστικό.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Ο παραλήπτης δεν έχει ρυθμίσει ακόμη το Keepiq, οπότε δεν κοινοποιήθηκε τίποτα. Δοκιμάστε ξανά όταν το κάνει.", + "Could not share the secret. Only its owner can approve this.": "Δεν ήταν δυνατή η κοινή χρήση του μυστικού. Μόνο ο κάτοχός του μπορεί να το εγκρίνει.", + "Could not share the secret. Try again.": "Δεν ήταν δυνατή η κοινή χρήση του μυστικού. Δοκιμάστε ξανά.", + "Denied. Nothing was shared.": "Απορρίφθηκε. Δεν κοινοποιήθηκε τίποτα.", + "Could not deny the request. Try again.": "Δεν ήταν δυνατή η άρνηση του αιτήματος. Δοκιμάστε ξανά.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "Ο χρήστης %1$s σας ζητά να μοιραστείτε το μυστικό \"%2$s\" με τον χρήστη %3$s.", + "Expires on (optional)": "Λήγει στις (προαιρετικό)", + "Hand over to": "Παράδοση σε", + "Choose a recipient": "Επιλέξτε παραλήπτη", + "Hand over temporarily": "Προσωρινή παράδοση", + "Expiry rules": "Κανόνες λήξης", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ορίστε πόσο καιρό μπορούν να ισχύουν οι κωδικοί ενός τύπου στοιχείου ή ενός φακέλου και πότε θα λαμβάνετε υπενθύμιση. Όταν ισχύουν πολλές ημερομηνίες, μετράει η νωρίτερη.", + "Delete rule": "Διαγραφή κανόνα", + "Set by your administrator": "Ορίστηκε από τον διαχειριστή σας", + "No expiry rules yet.": "Δεν υπάρχουν ακόμη κανόνες λήξης.", + "Applies to": "Ισχύει για", + "Item type": "Τύπος στοιχείου", + "Maximum age in days (empty for reminders only)": "Μέγιστη ηλικία σε ημέρες (κενό μόνο για υπενθυμίσεις)", + "Remind me this many days before, comma separated": "Υπενθύμιση τόσες ημέρες πριν, χωρισμένες με κόμμα", + "Save rule": "Αποθήκευση κανόνα", + "An item type": "Ένας τύπος στοιχείου", + "A folder": "Ένας φάκελος", + "Folder {name}": "Φάκελος {name}", + "Type {name}": "Τύπος {name}", + "Expires after {days} days": "Λήγει μετά από {days} ημέρες", + "Reminders {days} days before": "Υπενθυμίσεις {days} ημέρες πριν", + "Could not save the expiry rule.": "Δεν ήταν δυνατή η αποθήκευση του κανόνα λήξης.", + "Could not delete the expiry rule.": "Δεν ήταν δυνατή η διαγραφή του κανόνα λήξης.", + "All statuses": "Όλες οι καταστάσεις", + "Compromised": "Παραβιασμένη", + "Could not load the members.": "Δεν ήταν δυνατή η φόρτωση των μελών.", + "Emergency contact": "Επαφή έκτακτης ανάγκης", + "Leaving user": "Αποχωρών χρήστης", + "No": "Όχι", + "No users match this filter.": "Κανένας χρήστης δεν ταιριάζει σε αυτό το φίλτρο.", + "Not set up": "Δεν έχει ρυθμιστεί", + "Revoke suite": "Ανάκληση σουίτας", + "Revoked": "Ανακλήθηκε", + "Search users": "Αναζήτηση χρηστών", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Δείτε ποιοι χρήστες έχουν ρυθμίσει θησαυροφυλάκιο. Ξεκινήστε την αποχώρηση ή ανακαλέστε μια σουίτα από μια γραμμή.", + "Successor": "Διάδοχος", + "Team folders": "Φάκελοι ομάδας", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Ο χρήστης είναι ακόμη στην ομάδα {groups}, που είναι μέλος φακέλου ομάδας. Αφαιρέστε τον από την ομάδα ή απενεργοποιήστε τον λογαριασμό.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Ο χρήστης είναι ακόμη στις ομάδες {groups}, που είναι μέλη φακέλων ομάδας. Αφαιρέστε τον από τις ομάδες ή απενεργοποιήστε τον λογαριασμό.", + "Vault status": "Κατάσταση θησαυροφυλακίου", + "Yes": "Ναι", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Μια εξαγωγή CXF ΔΕΝ ΕΙΝΑΙ ΚΡΥΠΤΟΓΡΑΦΗΜΕΝΗ. Κάθε κωδικός πρόσβασης και σύνδεση θα είναι αναγνώσιμα ως απλό κείμενο στο αρχείο που κατεβάζετε. Αποθηκεύστε το με ασφάλεια και διαγράψτε το αμέσως μετά τη χρήση.", + "Root certificate expiring soon": "Το πιστοποιητικό ρίζας λήγει σύντομα", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Το πιστοποιητικό ρίζας του θησαυροφυλακίου λήγει σε %1$d ημέρα(ες). Ανανεώστε το πριν από τότε. Η ανανέωση υπογράφει ξανά κάθε σουίτα κρυπτογράφησης.", + "Compromise recovery aborted": "Η ανάκτηση μετά από παραβίαση ακυρώθηκε", + "Key rotation ended by a compromise revoke": "Η εναλλαγή κλειδιού τερματίστηκε από ανάκληση λόγω παραβίασης", + "Encryption suite revoke refused": "Η ανάκληση της σουίτας κρυπτογράφησης απορρίφθηκε", + "Master password proof refused": "Η απόδειξη του κύριου κωδικού απορρίφθηκε", + "Your current master password": "Ο τρέχων κύριος κωδικός σας", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n επαφή έκτακτης ανάγκης είχε εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού την αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n επαφές έκτακτης ανάγκης είχαν εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού τις αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Αυτές οι επαφές έκτακτης ανάγκης δεν μεταφέρθηκαν στο νέο σας κλειδί. Η πρόσβαση έκτακτης ανάγκης τους αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης αν τις θέλετε ακόμα.", + "Renew root certificate": "Ανανέωση πιστοποιητικού ρίζας", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Δημιουργείται νέο πιστοποιητικό ρίζας και ενδιάμεσο. Κάθε ενεργή σουίτα κρυπτογράφησης υπογράφεται ξανά. Δεν μπορεί να αναιρεθεί.", + "Renew root": "Ανανέωση ρίζας", + "Root renewed. {n} encryption suites signed again.": "Η ρίζα ανανεώθηκε. Σουίτες κρυπτογράφησης που υπογράφηκαν ξανά: {n}.", + "Could not renew the root certificate.": "Δεν ήταν δυνατή η ανανέωση του πιστοποιητικού ρίζας.", + "Lease policy for this application": "Πολιτική μίσθωσης για αυτή την εφαρμογή", + "In force now: {default} seconds by default, {max} seconds at most.": "Ισχύει τώρα: {default} δευτερόλεπτα από προεπιλογή, το πολύ {max} δευτερόλεπτα.", + "Leases are not renewable": "Οι μισθώσεις δεν ανανεώνονται", + "Lease policy saved.": "Η πολιτική μίσθωσης αποθηκεύτηκε.", + "Leave a field empty to use the instance value.": "Αφήστε ένα πεδίο κενό για να χρησιμοποιηθεί η τιμή της εγκατάστασης.", + "Instance value: {value}": "Τιμή εγκατάστασης: {value}", + "Renewal": "Ανανέωση", + "Use the instance value ({value})": "Χρήση της τιμής εγκατάστασης ({value})", + "Allowed": "Επιτρέπεται", + "Not allowed": "Δεν επιτρέπεται", + "Save lease policy": "Αποθήκευση πολιτικής μίσθωσης", + "Only an administrator can change this policy.": "Μόνο ένας διαχειριστής μπορεί να αλλάξει αυτή την πολιτική.", + "Could not save the lease policy.": "Δεν ήταν δυνατή η αποθήκευση της πολιτικής μίσθωσης.", + "{member} got access from {confirmer}.": "Ο/Η {member} πήρε πρόσβαση από τον/την {confirmer}.", + "Automatically confirm new team folder members": "Αυτόματη επιβεβαίωση νέων μελών φακέλων ομάδας", + "Gave %n new member access to a team folder.": "%n νέο μέλος πήρε πρόσβαση σε φάκελο ομάδας.", + "Gave %n new members access to a team folder.": "%n νέα μέλη πήραν πρόσβαση σε φάκελο ομάδας.", + "Give new team folder members access without waiting for the folder owner.": "Δώστε πρόσβαση στα νέα μέλη χωρίς να περιμένετε τον κάτοχο του φακέλου.", + "New team folder members": "Νέα μέλη φακέλων ομάδας", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ο κάτοχος ή ένα μέλος με δικαίωμα εγγραφής τα επιβεβαιώνει από το ανοιχτό θησαυροφυλάκιο. Το Keepiq δεν αποκρυπτογραφεί ποτέ στον διακομιστή.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Αναμονή για μέλος με δικαίωμα εγγραφής να ανοίξει το Keepiq. Μπορείτε επίσης να μοιραστείτε τώρα.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Μέρος της απόκρισης στην παραβίαση απέτυχε ({failed} βήμα(τα)). Ελέγξτε το αρχείο καταγραφής του διακομιστή και ανακαλέστε ξανά τη σουίτα για να ολοκληρωθεί.", + "This also revoked suite {suite} and ended key migration {migration}.": "Αυτό ανακάλεσε επίσης τη σουίτα {suite} και τερμάτισε τη μετάβαση κλειδιών {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Revoking the second suite deleted %n emergency-access contacts.": "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης.", + "A suite revoked as compromised cannot be reinstated.": "Μια σουίτα που ανακλήθηκε ως παραβιασμένη δεν μπορεί να αποκατασταθεί.", + "Archives to keep": "Αρχεία προς διατήρηση", + "Back up every vault automatically": "Αυτόματο αντίγραφο κάθε θησαυροφυλακίου", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Δημιουργήστε αντίγραφο κάθε θησαυροφυλακίου βάσει προγράμματος. Τα αρχεία περιέχουν μόνο κρυπτοκείμενο και επαναφέρονται με occ.", + "Back up now": "Αντίγραφο τώρα", + "Backup public key (PEM, optional)": "Δημόσιο κλειδί αντιγράφου (PEM, προαιρετικό)", + "Backup requested for the next cron run": "Ζητήθηκε αντίγραφο για την επόμενη εκτέλεση cron", + "Encrypted": "Κρυπτογραφημένο", + "Every (hours)": "Κάθε (ώρες)", + "Last backup {when} failed: {error}": "Το τελευταίο αντίγραφο {when} απέτυχε: {error}", + "Last backup {when} succeeded.": "Το τελευταίο αντίγραφο {when} ολοκληρώθηκε.", + "No archives yet.": "Δεν υπάρχουν ακόμη αρχεία.", + "Size": "Μέγεθος", + "Vault backups": "Αντίγραφα θησαυροφυλακίου", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Με κλειδί, κάθε αρχείο κρυπτογραφείται για αυτό. Κρατήστε το ιδιωτικό κλειδί εκτός αυτού του διακομιστή: το χρειάζεστε για έλεγχο ή επαναφορά.", + "Written": "Γράφτηκε", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n χρήστης στο πεδίο εφαρμογής δεν έχει ακόμη σύνδεση δύο παραγόντων και δεν μπορεί να ανοίξει το θησαυροφυλάκιο όσο αυτό είναι ενεργό.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n χρήστες στο πεδίο εφαρμογής δεν έχουν ακόμη σύνδεση δύο παραγόντων και δεν μπορούν να ανοίξουν το θησαυροφυλάκιο όσο αυτό είναι ενεργό.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Οι κωδικοί ανάκτησης δεν μετρούν. Αν οι χρήστες σας συνδέονται μέσω παρόχου ταυτότητας με δικό του δεύτερο παράγοντα, εξαιρέστε τις ομάδες τους.", + "Block personal vault export": "Αποκλεισμός εξαγωγής προσωπικού θησαυροφυλακίου", + "Keep work logins in team folders": "Φύλαξη εταιρικών στοιχείων σύνδεσης σε φακέλους ομάδας", + "Move to a team folder": "Μετακίνηση σε φάκελο ομάδας", + "Not in a team folder": "Όχι σε φάκελο ομάδας", + "Only for these groups (empty is everyone)": "Μόνο για αυτές τις ομάδες (κενό σημαίνει όλοι)", + "Require two-factor login before the vault opens": "Απαίτηση σύνδεσης δύο παραγόντων πριν ανοίξει το θησαυροφυλάκιο", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Κανόνες για κάθε θησαυροφυλάκιο. Ο καθένας ισχύει για όλους ή μόνο για τις ομάδες που επιλέγετε.", + "Secret types that belong in a team folder": "Τύποι μυστικών που ανήκουν σε φάκελο ομάδας", + "Set up two-factor login": "Ρύθμιση σύνδεσης δύο παραγόντων", + "Team folder you can write to": "Φάκελος ομάδας όπου μπορείτε να γράψετε", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Οι χρήστες δεν μπορούν να κατεβάσουν αντίγραφο ασφαλείας, CSV ή αρχείο μεταφοράς. Το πακέτο προσωπικών δεδομένων τους παραμένει διαθέσιμο.", + "Users cannot save these secret types in a personal folder.": "Οι χρήστες δεν μπορούν να αποθηκεύσουν αυτούς τους τύπους μυστικών σε προσωπικό φάκελο.", + "Vault policies": "Πολιτικές θησαυροφυλακίου", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ο οργανισμός σας δεν επιτρέπει την εξαγωγή του προσωπικού σας θησαυροφυλακίου. Το πακέτο προσωπικών δεδομένων σας στις ρυθμίσεις παραμένει διαθέσιμο.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ο οργανισμός σας φυλάει αυτά τα μυστικά σε φάκελο ομάδας. Μετακινήστε το καθένα σε φάκελο ομάδας.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ο οργανισμός σας φυλάει αυτόν τον τύπο μυστικού σε φάκελο ομάδας. Επιλέξτε έναν από τους φακέλους ομάδας σας ή έναν όπου μπορείτε να γράψετε.", + "Your organisation requires two-factor login before you can open your vault.": "Ο οργανισμός σας απαιτεί σύνδεση δύο παραγόντων πριν μπορέσετε να ανοίξετε το θησαυροφυλάκιό σας.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Οι χρήστες επιλέγουν πόσο χρόνο η επέκταση μένει ξεκλείδωτη όταν δεν χρησιμοποιείται. Εσείς ορίζετε τον μέγιστο χρόνο που μπορούν να επιλέξουν.", + "Longest idle time before the extension locks": "Μέγιστος χρόνος αδράνειας πριν κλειδώσει η επέκταση", + "1 minute": "1 λεπτό", + "5 minutes": "5 λεπτά", + "15 minutes": "15 λεπτά", + "1 hour": "1 ώρα", + "4 hours": "4 ώρες", + "Connector": "Σύνδεσμος", + "Directory (tenant) ID": "Αναγνωριστικό καταλόγου (μισθωτή)", + "Application (client) ID": "Αναγνωριστικό εφαρμογής (πελάτη)", + "Data collection rule immutable ID": "Αμετάβλητο αναγνωριστικό κανόνα συλλογής δεδομένων", + "Stream name": "Όνομα ροής", + "Splunk index (optional)": "Ευρετήριο Splunk (προαιρετικό)", + "Sourcetype (optional)": "Sourcetype (προαιρετικό)", + "Leave blank to keep the current one": "Αφήστε κενό για να διατηρηθεί το τρέχον", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF μέσω syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Τελικό σημείο συλλογής δεδομένων (https URL)", + "HTTP Event Collector URL (https)": "URL του HTTP Event Collector (https)", + "Client secret (write-only)": "Μυστικό πελάτη (μόνο εγγραφή)", + "HEC token (write-only)": "Διακριτικό HEC (μόνο εγγραφή)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Προωθήστε τα επιτρεπόμενα συμβάντα ελέγχου στο Splunk, στο Microsoft Sentinel, σε δέκτη syslog ή σε webhook. Τα μηνύματα περιέχουν μόνο καθαρισμένα μεταδεδομένα: καμία μυστική τιμή, όνομα, σύνδεση ή κρυπτογραφημένο κείμενο δεν φεύγει ποτέ από τον διακομιστή.", + "%n change waiting to sync": "%n αλλαγή περιμένει συγχρονισμό", + "%n changes waiting to sync": "%n αλλαγές περιμένουν συγχρονισμό", + "Changes that could not sync": "Αλλαγές που δεν μπόρεσαν να συγχρονιστούν", + "Choose a version": "Επιλέξτε έκδοση", + "Copy value": "Αντιγραφή τιμής", + "Deleted": "Διαγράφηκε", + "Discard": "Απόρριψη", + "Keep my offline change": "Διατήρηση της αλλαγής μου εκτός σύνδεσης", + "Keep the server version": "Διατήρηση της έκδοσης του διακομιστή", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Το Keepiq είναι μόνο για ανάγνωση εκτός σύνδεσης. Ο διαχειριστής δεν έχει ενεργοποιήσει την επεξεργασία εκτός σύνδεσης.", + "Let users edit secrets offline": "Να επιτρέπεται στους χρήστες να επεξεργάζονται μυστικά εκτός σύνδεσης", + "Not synced yet": "Δεν έχει συγχρονιστεί ακόμα", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Οι αλλαγές εκτός σύνδεσης μένουν στη συσκευή, κρυπτογραφημένες για τον χρήστη, και συγχρονίζονται στο επόμενο ξεκλείδωμα με σύνδεση. Η κοινή χρήση, οι φάκελοι και τα συνημμένα χρειάζονται ακόμα σύνδεση.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Εκτός σύνδεσης. Οι επεξεργασίες, οι μετακινήσεις και οι διαγραφές μένουν σε αυτή τη συσκευή και συγχρονίζονται όταν συνδεθείτε ξανά. Η κοινή χρήση και τα συνημμένα χρειάζονται σύνδεση.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Εκτός σύνδεσης. Οι αλλαγές σας μένουν σε αυτή τη συσκευή και συγχρονίζονται όταν συνδεθείτε ξανά. Τελευταίος συγχρονισμός {when}.", + "Open my changes": "Άνοιγμα των αλλαγών μου", + "Sharing needs a connection": "Η κοινή χρήση χρειάζεται σύνδεση", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Κάποιος άλλαξε αυτό το μυστικό στον διακομιστή αφού δημιουργήθηκε το αντίγραφό σας εκτός σύνδεσης. Επιλέξτε ποια έκδοση θα κρατήσετε.", + "Sync or discard your offline changes before you rotate your keys.": "Συγχρονίστε ή απορρίψτε τις αλλαγές εκτός σύνδεσης πριν αλλάξετε τα κλειδιά σας.", + "That password did not open your changes.": "Αυτός ο κωδικός δεν άνοιξε τις αλλαγές σας.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Το στιγμιότυπο εκτός σύνδεσης αποθηκεύει κρυπτογραφημένα μυστικά (ανοίγουν μόνο με το κλειδί που προκύπτει από τον κύριο κωδικό του χρήστη, ακριβώς όπως στον διακομιστή) και κρυπτογραφεί ονόματα, URL και ονόματα φακέλων κατά την αποθήκευση. Η πρόσβαση εκτός σύνδεσης είναι μόνο για ανάγνωση, εκτός αν επιτρέψετε παρακάτω την επεξεργασία εκτός σύνδεσης. Απενεργοποιήστε το για συσκευές που δεν πρέπει ποτέ να αποθηκεύουν διαπιστευτήρια· η απενεργοποίηση καθαρίζει τις υπάρχουσες κρυφές μνήμες στην επόμενη φόρτωση.", + "The previous vault copy is gone, so these changes cannot be opened.": "Το προηγούμενο αντίγραφο του θησαυροφυλακίου δεν υπάρχει πια, οπότε αυτές οι αλλαγές δεν μπορούν να ανοίξουν.", + "The server version": "Η έκδοση του διακομιστή", + "This secret changed while you were offline": "Αυτό το μυστικό άλλαξε ενώ ήσασταν εκτός σύνδεσης", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Διαγράψατε αυτό το μυστικό εκτός σύνδεσης, αλλά στο μεταξύ άλλαξε στον διακομιστή. Επιλέξτε ποια έκδοση θα κρατήσετε.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Τα κλειδιά σας άλλαξαν σε άλλη συσκευή. Εισαγάγετε τον προηγούμενο κύριο κωδικό για να συγχρονίσετε τις αλλαγές εκτός σύνδεσης, ή απορρίψτε τις.", + "Your offline change": "Η αλλαγή σας εκτός σύνδεσης", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n επαφή έκτακτης ανάγκης είχε εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού την αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.","%n επαφές έκτακτης ανάγκης είχαν εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού τις αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n στοιχείο δεν μπορεί να αναπαρασταθεί σε CXF και θα παραλειφθεί.","%n στοιχεία δεν μπορούν να αναπαρασταθούν σε CXF και θα παραλειφθούν."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n παλαιότερη έκδοση απορρίφθηκε, επειδή μόνο το πρόσφατο ιστορικό μπορεί να μεταφερθεί.","%n παλαιότερες εκδόσεις απορρίφθηκαν, επειδή μόνο το πρόσφατο ιστορικό μπορεί να μεταφερθεί."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n αντίγραφο μυστικού πρέπει ακόμη να κρυπτογραφηθεί και να κοινοποιηθεί.","%n αντίγραφα μυστικών πρέπει ακόμη να κρυπτογραφηθούν και να κοινοποιηθούν."], + "_%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 μυστικό δεν ήταν δυνατό να αποκρυπτογραφηθεί και δεν περιλαμβάνεται σε αυτή την εξαγωγή.","%n μυστικά δεν ήταν δυνατό να αποκρυπτογραφηθούν και δεν περιλαμβάνονται σε αυτή την εξαγωγή."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n μυστικό δεν μπόρεσε να αποκρυπτογραφηθεί με το παλιό σας κλειδί, γι' αυτό δεν μεταφέρθηκε.","%n μυστικά δεν μπόρεσαν να αποκρυπτογραφηθούν με το παλιό σας κλειδί, γι' αυτό δεν μεταφέρθηκαν."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n μυστικό δεν μεταφέρθηκε.","%n μυστικά δεν μεταφέρθηκαν."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n μυστικό είναι ακόμη κρυπτογραφημένο με το προηγούμενο κλειδί σας.","%n μυστικά είναι ακόμη κρυπτογραφημένα με το προηγούμενο κλειδί σας."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n μυστικό παραλείφθηκε επειδή ο διάδοχος δεν έχει ακόμη αντίγραφο — προσθέστε τον διάδοχο στον φάκελο και εκτελέστε ξανά.","%n μυστικά παραλείφθηκαν επειδή ο διάδοχος δεν έχει ακόμη αντίγραφο — προσθέστε τον διάδοχο στον φάκελο και εκτελέστε ξανά."], + "_%n secret_::_%n secrets_": ["%n μυστικό","%n μυστικά"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n χρήστης στο πεδίο εφαρμογής δεν έχει ακόμη σύνδεση δύο παραγόντων και δεν μπορεί να ανοίξει το θησαυροφυλάκιο όσο αυτό είναι ενεργό.","%n χρήστες στο πεδίο εφαρμογής δεν έχουν ακόμη σύνδεση δύο παραγόντων και δεν μπορούν να ανοίξουν το θησαυροφυλάκιο όσο αυτό είναι ενεργό."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Ολοκλήρωση παρ' όλα αυτά, με απώλεια πρόσβασης σε %n μυστικό","Ολοκλήρωση παρ' όλα αυτά, με απώλεια πρόσβασης σε %n μυστικά"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n νέο μέλος πήρε πρόσβαση σε φάκελο ομάδας.","%n νέα μέλη πήραν πρόσβαση σε φάκελο ομάδας."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Η εναλλαγή κλειδιού ολοκληρώθηκε. %n μυστικό επανακρυπτογραφήθηκε με το νέο σας κλειδί.","Η εναλλαγή κλειδιού ολοκληρώθηκε. %n μυστικά επανακρυπτογραφήθηκαν με το νέο σας κλειδί."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.","Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Η ανάκληση αυτής της σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.","Η ανάκληση αυτής της σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["εμφανίστηκε %n φορά σε παραβιάσεις","εμφανίστηκε %n φορές σε παραβιάσεις"], + "_shared with %n secret_::_shared with %n secrets_": ["κοινοποιημένο σε %n μυστικό","κοινοποιημένο σε %n μυστικά"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Αυτός ο φάκελος περιέχει %n μυστικό άμεσα.","Αυτός ο φάκελος περιέχει %n μυστικά άμεσα."], + "_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._": ["Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.","Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n αλλαγή περιμένει συγχρονισμό","%n αλλαγές περιμένουν συγχρονισμό"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Ο χρήστης είναι ακόμη στην ομάδα {groups}, που είναι μέλος φακέλου ομάδας. Αφαιρέστε τον από την ομάδα ή απενεργοποιήστε τον λογαριασμό.","Ο χρήστης είναι ακόμη στις ομάδες {groups}, που είναι μέλη φακέλων ομάδας. Αφαιρέστε τον από τις ομάδες ή απενεργοποιήστε τον λογαριασμό."], + "Allow approval from another device": "Να επιτρέπεται η έγκριση από άλλη συσκευή", + "App": "Εφαρμογή", + "Approve a new device": "Έγκριση νέας συσκευής", + "Approve from another device": "Έγκριση από άλλη συσκευή", + "Asked at": "Ζητήθηκε στις", + "Check that the new device shows these words:": "Ελέγξτε ότι η νέα συσκευή εμφανίζει αυτές τις λέξεις:", + "Denied. If you did not ask, end your other sessions:": "Απορρίφθηκε. Αν δεν το ζητήσατε εσείς, τερματίστε τις άλλες συνεδρίες σας:", + "Device": "Συσκευή", + "IP address": "Διεύθυνση IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Επιτρέψτε στους χρήστες να ξεκλειδώνουν ένα νέο πρόγραμμα περιήγησης εγκρίνοντάς το από μια συσκευή όπου το Keepiq είναι ήδη ξεκλείδωτο.", + "New device approval": "Έγκριση νέων συσκευών", + "Nextcloud security settings": "Ρυθμίσεις ασφαλείας Nextcloud", + "Only approve a device you are using right now.": "Εγκρίνετε μόνο μια συσκευή που χρησιμοποιείτε αυτή τη στιγμή.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ανοίξτε το Keepiq σε μια συσκευή όπου είναι ξεκλείδωτο και εγκρίνετε αυτή τη συσκευή. Ελέγξτε ότι εμφανίζει τις ίδιες λέξεις:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Η συσκευή που εγκρίνει σφραγίζει το κλειδί ξεκλειδώματος για τη νέα συσκευή. Ο διακομιστής απλώς το μεταβιβάζει και δεν μπορεί να το ανοίξει.", + "The master password is not right, or the request has ended.": "Ο κύριος κωδικός πρόσβασης δεν είναι σωστός ή το αίτημα έχει λήξει.", + "The request expired. Ask again or use your master password.": "Το αίτημα έληξε. Ζητήστε ξανά ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "The request was denied.": "Το αίτημα απορρίφθηκε.", + "Too many requests. Try again in an hour or use your master password.": "Πάρα πολλά αιτήματα. Δοκιμάστε ξανά σε μία ώρα ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "Unknown device": "Άγνωστη συσκευή", + "Web app": "Εφαρμογή ιστού", + "A device": "Μια συσκευή", + "A new device asks to open your vault": "Μια νέα συσκευή ζητά να ανοίξει το θησαυροφυλάκιό σας", + "%s asks to be approved. Only approve a device you are using right now.": "%s ζητά έγκριση. Εγκρίνετε μόνο μια συσκευή που χρησιμοποιείτε αυτή τη στιγμή.", + "Access ends on (optional)": "Η πρόσβαση λήγει στις (προαιρετικό)", + "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.": "Οι εφαρμογές του Keepiq δεν θα εμφανίσουν ούτε θα αντιγράψουν τον κωδικό πρόσβασης. Κάποιος με τεχνικές γνώσεις μπορεί ακόμη να τον διαβάσει από τη δική του συσκευή. Αλλάξτε τον όταν λήξει η πρόσβασή του.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Αυτό το μυστικό είναι μόνο για χρήση. Συνδεθείτε μέσω της επέκτασης προγράμματος περιήγησης του Keepiq.", + "Until {date}": "Έως {date}", + "Use only": "Μόνο χρήση", + "Use only (can sign in, cannot view or copy)": "Μόνο χρήση (μπορεί να συνδεθεί, δεν μπορεί να δει ή να αντιγράψει)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Μπορείτε να συνδεθείτε με αυτά τα στοιχεία μέσω της επέκτασης προγράμματος περιήγησης του Keepiq. Ο κάτοχος επέλεξε να μην σας επιτρέπει να τα δείτε ή να τα αντιγράψετε.", + "Your access ends on {date}": "Η πρόσβασή σας λήγει στις {date}", + "Your access to this secret has ended": "Η πρόσβασή σας σε αυτό το μυστικό έληξε", + "Your access to \"%s\" ends tomorrow": "Η πρόσβασή σας στο «%s» λήγει αύριο", + "Your access to \"%s\" has ended": "Η πρόσβασή σας στο «%s» έληξε", + "%1$s no longer has access to \"%2$s\"": "Ο χρήστης %1$s δεν έχει πλέον πρόσβαση στο «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "Ο χρήστης %1$s μπορούσε να δει αυτόν τον κωδικό. Αλλάξτε τον αν ο χρήστης %1$s δεν πρέπει πλέον να τον γνωρίζει.", + "%s could not view this password in Keepiq.": "Ο χρήστης %s δεν μπορούσε να δει αυτόν τον κωδικό στο Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} από {threshold} εγκρίσεις", + "a recovery officer": "ένας υπεύθυνος ανάκτησης", + "Account recovery": "Ανάκτηση λογαριασμού", + "Approvals needed": "Απαιτούμενες εγκρίσεις", + "Ask {user} which words they see, by phone or in person. They must be:": "Ρωτήστε τον/την {user} ποιες λέξεις βλέπει, τηλεφωνικά ή αυτοπροσώπως. Πρέπει να είναι:", + "Check again": "Έλεγχος ξανά", + "Create the recovery key": "Δημιουργία κλειδιού ανάκτησης", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Δημιουργήστε το κλειδί ανάκτησης. Το πρόγραμμα περιήγησης το δημιουργεί και δίνει σε κάθε υπεύθυνο ένα αντίγραφο που μόνο εκείνος μπορεί να ανοίξει.", + "Decline": "Απόρριψη", + "Enrol in account recovery": "Εγγραφή στην ανάκτηση λογαριασμού", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Εγγραφείτε ώστε ο οργανισμός σας να σας βοηθήσει να ανακτήσετε το θησαυροφυλάκιό σας αν ξεχάσετε τον κύριο κωδικό.", + "Every user is enrolled": "Όλοι οι χρήστες είναι εγγεγραμμένοι", + "Finish the recovery in the browser you asked from.": "Ολοκληρώστε την ανάκτηση στο πρόγραμμα περιήγησης από το οποίο την ζητήσατε.", + "Forgot your master password?": "Ξεχάσατε τον κύριο κωδικό;", + "Hand the key over": "Παράδοση του κλειδιού", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Επιτρέψτε σε χρήστες που ξέχασαν τον κύριο κωδικό τους να ανακτήσουν το θησαυροφυλάκιο, με έγκριση από υπεύθυνους ανάκτησης που ορίζετε.", + "New master password": "Νέος κύριος κωδικός", + "No one is asking to recover their account.": "Κανείς δεν ζητά ανάκτηση λογαριασμού.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Δεν υπάρχει ακόμη κλειδί ανάκτησης. Ένας από τους υπεύθυνους το δημιουργεί στις ρυθμίσεις Keepiq του.", + "Off": "Ανενεργό", + "Officer {user} has no encryption set up yet.": "Ο υπεύθυνος {user} δεν έχει ρυθμίσει ακόμη κρυπτογράφηση.", + "Officers (user IDs, separated by commas)": "Υπεύθυνοι (ID χρηστών, χωρισμένα με κόμμα)", + "Policy": "Πολιτική", + "Publish this fingerprint internally, so users can check it before they enrol.": "Δημοσιεύστε αυτό το αποτύπωμα εσωτερικά, ώστε οι χρήστες να το ελέγχουν πριν εγγραφούν.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Ανακτήθηκε με τη βοήθεια του/της {officer}. Αλλάξτε τώρα το κλειδί του θησαυροφυλακίου στις Ρυθμίσεις, Ασφάλεια: \"Ο κύριος κωδικός μου διέρρευσε\".", + "Recovery key fingerprint: {fingerprint}": "Αποτύπωμα κλειδιού ανάκτησης: {fingerprint}", + "Recovery officer": "Υπεύθυνος ανάκτησης", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Οι υπεύθυνοι που αφαιρέθηκαν χάνουν τώρα το αντίγραφό τους, αλλά ίσως το άνοιξαν νωρίτερα. Ζητήστε από έναν υπεύθυνο να δημιουργήσει νέο κλειδί ανάκτησης.", + "Repeat the new master password": "Επαναλάβετε τον νέο κύριο κωδικό", + "Retire this recovery key": "Απόσυρση αυτού του κλειδιού ανάκτησης", + "Set the new master password": "Ορισμός νέου κύριου κωδικού", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Το πιστοποιητικό ανάκτησης δεν εκδόθηκε από αυτό το Keepiq. Μην εγγραφείτε και ενημερώστε τον διαχειριστή σας.", + "The words match, approve": "Οι λέξεις ταιριάζουν, έγκριση", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Αυτός ο χρήστης είναι εγγεγραμμένος στην ανάκτηση λογαριασμού. Η ανάκτηση διατηρεί τα μυστικά του· η ανάκληση διαγράφει την εγγραφή του.", + "Users may enrol": "Οι χρήστες μπορούν να εγγραφούν", + "Withdraw from account recovery": "Αποχώρηση από την ανάκτηση λογαριασμού", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Είστε εγγεγραμμένοι στην ανάκτηση λογαριασμού. Αποτύπωμα κλειδιού ανάκτησης: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Είστε εγγεγραμμένοι. Αν ξεχάσετε τον κύριο κωδικό, ο οργανισμός σας μπορεί να σας βοηθήσει να ανακτήσετε το θησαυροφυλάκιο.", + "Your key is back. Choose a new master password.": "Το κλειδί σας επέστρεψε. Επιλέξτε νέο κύριο κωδικό.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Οι υπεύθυνοι ανάκτησης ενημερώθηκαν. Διαβάστε τους αυτές τις λέξεις όταν σας καλέσουν ή σας συναντήσουν:", + "You are now an account recovery officer": "Είστε πλέον υπεύθυνος ανάκτησης λογαριασμών", + "%s asks to recover their account. Compare the words with them before you approve.": "Ο/Η %s ζητά ανάκτηση του λογαριασμού. Συγκρίνετε τις λέξεις μαζί του πριν εγκρίνετε.", + "A user": "Ένας χρήστης", + "Your account recovery request was declined": "Το αίτημα ανάκτησης λογαριασμού απορρίφθηκε", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Η ανάκτηση του λογαριασμού σας είναι έτοιμη. Ανοίξτε το Keepiq στο πρόγραμμα περιήγησης από το οποίο την ζητήσατε.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ζητά εφάπαξ ξεκλείδωμα μιας νέας συσκευής. Ο κύριος κωδικός πρόσβασης παραμένει ίδιος.", + "Ask your organisation instead": "Ρωτήστε καλύτερα τον οργανισμό σας", + "The request ended. Ask again or use your master password.": "Το αίτημα έληξε. Ζητήστε ξανά ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "Added by {user}": "Προστέθηκε από {user}", + "Editor": "Συντάκτης", + "Manager": "Διαχειριστής", + "Role of {member}": "Ρόλος του {member}", + "Team folders you manage": "Ομαδικοί φάκελοι που διαχειρίζεστε", + "Viewer": "Θεατής", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Δεν έχετε αντίγραφο αυτών των μυστικών, οπότε τα νέα μέλη δεν τα έχουν λάβει ακόμα. Ο κάτοχος μπορεί να τα κοινοποιήσει: {names}", + "Admin areas": "Περιοχές διαχείρισης", + "Give a group only the parts of Keepiq administration it needs.": "Δώστε σε μια ομάδα μόνο τα μέρη της διαχείρισης του Keepiq που χρειάζεται.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Αναθέστε μία ή περισσότερες περιοχές σε μια ομάδα στη σελίδα δικαιωμάτων διαχείρισης. Οι διαχειριστές της εγκατάστασης έχουν κάθε περιοχή.", + "Open administration privileges": "Άνοιγμα δικαιωμάτων διαχείρισης", + "Policies": "Πολιτικές", + "Applications and machine access": "Εφαρμογές και πρόσβαση μηχανών", + "People and offboarding": "Άτομα και αποχωρήσεις", + "Audit and compliance": "Έλεγχος και συμμόρφωση", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "έκδοση, αρχή πιστοποίησης, συνημμένα, κρυφή μνήμη εκτός σύνδεσης, έλεγχος διαρροών, τύποι μυστικών και αντίγραφα ασφαλείας", + "master password, organisation password, vault policies, rotation, version history and trash": "κύριος κωδικός, κωδικός οργανισμού, πολιτικές θησαυροφυλακίου, εναλλαγή, ιστορικό εκδόσεων και κάδος", + "application queue, application requests and machine leases": "ουρά εφαρμογών, αιτήματα εφαρμογών και μισθώσεις μηχανών", + "team offboarding, encryption suites and admin handover": "αποχωρήσεις από την ομάδα, σουίτες κρυπτογράφησης και ανάληψη από διαχειριστή", + "audit log, compliance reports, SIEM export and honey alerts": "αρχείο ελέγχου, αναφορές συμμόρφωσης, εξαγωγή SIEM και ειδοποιήσεις δολωμάτων", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Πόσες εκδόσεις ενός μυστικού διατηρούνται, για πόσο, και για πόσο μένουν στον κάδο τα διαγραμμένα μυστικά.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Όρια για κρυπτογραφημένα συνημμένα, που επιβάλλει ο διακομιστής στα αποθηκευμένα κρυπτογραφημένα byte.", + "Type the suite ID again to confirm": "Πληκτρολογήστε ξανά το αναγνωριστικό σουίτας για επιβεβαίωση", + "This does not match the suite ID.": "Δεν ταιριάζει με το αναγνωριστικό σουίτας.", + "Confirm with your master password": "Επιβεβαίωση με τον κύριο κωδικό πρόσβασης", + "Confirm": "Επιβεβαίωση", + "That master password is not right.": "Αυτός ο κύριος κωδικός πρόσβασης δεν είναι σωστός.", + "You are sharing with someone new. Enter your master password to confirm.": "Μοιράζεστε με κάποιο νέο άτομο. Εισαγάγετε τον κύριο κωδικό πρόσβασης για επιβεβαίωση.", + "Enter your master password to confirm this share.": "Εισαγάγετε τον κύριο κωδικό πρόσβασης για να επιβεβαιώσετε αυτή την κοινή χρήση.", + "Enter your master password to confirm this delegation.": "Εισαγάγετε τον κύριο κωδικό πρόσβασης για να επιβεβαιώσετε αυτή την ανάθεση.", + "Approve {member}": "Έγκριση {member}", + "Recipient": "Παραλήπτης", + "No vault yet": "Δεν έχει ακόμη θησαυροφυλάκιο", + "No matching users": "Δεν βρέθηκαν χρήστες", + "Partner organisations": "Συνεργαζόμενοι οργανισμοί", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Ανταλλάξτε μυστικά με άλλο Keepiq. Και οι δύο διαχειριστές προσθέτουν ο ένας τον άλλον και συγκρίνουν τα ριζικά αποτυπώματα τηλεφωνικά ή αυτοπροσώπως πριν την αποθήκευση.", + "Federation needs Nextcloud 33 or later.": "Η ομοσπονδία απαιτεί Nextcloud 33 ή νεότερο.", + "Your root fingerprint": "Το ριζικό σας αποτύπωμα", + "No partners yet.": "Δεν υπάρχουν ακόμη συνεργάτες.", + "Users here may share to this partner": "Οι χρήστες εδώ μπορούν να μοιράζονται με αυτόν τον συνεργάτη", + "This partner may share to users here": "Αυτός ο συνεργάτης μπορεί να μοιράζεται με τους χρήστες εδώ", + "Partner address": "Διεύθυνση συνεργάτη", + "Check partner": "Έλεγχος συνεργάτη", + "Partner root fingerprint": "Ριζικό αποτύπωμα συνεργάτη", + "I compared this fingerprint with the partner's administrator": "Σύγκρινα αυτό το αποτύπωμα με τον διαχειριστή του συνεργάτη", + "Add partner": "Προσθήκη συνεργάτη", + "A secret from another organisation": "Ένα μυστικό από άλλον οργανισμό", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Ο χρήστης %1$s κοινοποίησε το \"%2$s\" σε εσάς. Αποδεχτείτε το στα Εισερχόμενα από άλλους οργανισμούς.", + "Incoming from other organisations": "Εισερχόμενα από άλλους οργανισμούς", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Άτομα σε συνεργαζόμενους οργανισμούς μπορούν να κοινοποιήσουν ένα μυστικό σε εσάς. Αποδεχτείτε το για να κρατήσετε ένα αντίγραφο μόνο για ανάγνωση στο θησαυροφυλάκιό σας.", + "Nothing shared with you yet": "Δεν έχει κοινοποιηθεί τίποτα σε εσάς ακόμα", + "Secrets that people in partner organisations share with you appear here.": "Τα μυστικά που σας κοινοποιούν άτομα σε συνεργαζόμενους οργανισμούς εμφανίζονται εδώ.", + "From {sender}": "Από {sender}", + "Accept": "Αποδοχή", + "Open in vault": "Άνοιγμα στο θησαυροφυλάκιο", + "The other organisation did not hand over the secret. Try again later.": "Ο άλλος οργανισμός δεν παρέδωσε το μυστικό. Δοκιμάστε ξανά αργότερα.", + "Set up your vault before you accept a shared secret.": "Ρυθμίστε το θησαυροφυλάκιό σας πριν αποδεχτείτε ένα κοινόχρηστο μυστικό.", + "Something went wrong. Try again.": "Κάτι πήγε στραβά. Δοκιμάστε ξανά.", + "Waiting for your answer": "Αναμένει την απάντησή σας", + "In your vault, read-only": "Στο θησαυροφυλάκιό σας, μόνο για ανάγνωση", + "Withdrawn by the sender": "Αποσύρθηκε από τον αποστολέα", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Ο χρήστης {sender} το κοινοποίησε από άλλον οργανισμό. Μπορείτε να το διαβάσετε, αλλά όχι να το αλλάξετε ή να το κοινοποιήσετε.", + "Someone": "Κάποιος", + "Share with someone at another organisation": "Κοινοποίηση σε κάποιον από άλλον οργανισμό", + "Their account at the other organisation": "Ο λογαριασμός του ατόμου στον άλλον οργανισμό", + "Check account": "Έλεγχος λογαριασμού", + "Certificate fingerprint of {account}": "Αποτύπωμα πιστοποιητικού του λογαριασμού {account}", + "Compare it with them by phone if you want to be sure.": "Συγκρίνετέ το με το άτομο τηλεφωνικά, αν θέλετε να είστε σίγουροι.", + "Shared. {account} can accept it in their own vault.": "Κοινοποιήθηκε. Ο λογαριασμός {account} μπορεί να το αποδεχτεί στο δικό του θησαυροφυλάκιο.", + "The certificate could not be verified. Nothing was shared.": "Δεν ήταν δυνατή η επαλήθευση του πιστοποιητικού. Δεν κοινοποιήθηκε τίποτα.", + "That organisation is not one of your partners.": "Αυτός ο οργανισμός δεν είναι ένας από τους συνεργάτες σας.", + "No one with that account can receive secrets from you.": "Κανείς με αυτόν τον λογαριασμό δεν μπορεί να λάβει μυστικά από εσάς.", + "The other organisation did not answer. Try again later.": "Ο άλλος οργανισμός δεν απάντησε. Δοκιμάστε ξανά αργότερα.", + "This secret is already shared with that account.": "Αυτό το μυστικό έχει ήδη κοινοποιηθεί σε αυτόν τον λογαριασμό.", + "Other organisations": "Άλλοι οργανισμοί", + "Receive secrets from other organisations": "Λήψη μυστικών από άλλους οργανισμούς", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Τα άτομα σε συνεργαζόμενους οργανισμούς μπορούν τότε να βρουν τον λογαριασμό σας και να κοινοποιούν μυστικά σε εσάς. Αποδέχεστε κάθε ένα μόνοι σας.", + "Shared": "Κοινοποιήθηκε", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Σε παύση: άλλαξε το πιστοποιητικό του παραλήπτη ή η συνεργασία. Κάντε ανάκληση ή κοινοποιήστε ξανά.", + "Their organisation did not get the last change. Revoke it or share again.": "Ο οργανισμός του παραλήπτη δεν έλαβε την τελευταία αλλαγή. Κάντε ανάκληση ή κοινοποιήστε ξανά.", + "Being withdrawn": "Ανακαλείται", + "Shared with another organisation": "Κοινοποιήθηκε σε άλλον οργανισμό", + "Change sent to another organisation": "Η αλλαγή στάλθηκε σε άλλον οργανισμό", + "Share with another organisation revoked": "Η κοινοποίηση σε άλλον οργανισμό ανακλήθηκε", + "Share with another organisation paused": "Η κοινοποίηση σε άλλον οργανισμό τέθηκε σε παύση", + "Another organisation did not get a change": "Άλλος οργανισμός δεν έλαβε μια αλλαγή", + "Secret received from another organisation": "Ελήφθη μυστικό από άλλον οργανισμό", + "Secret from another organisation accepted": "Το μυστικό από άλλον οργανισμό έγινε αποδεκτό", + "Secret from another organisation declined": "Το μυστικό από άλλον οργανισμό απορρίφθηκε", + "Copy from another organisation updated": "Το αντίγραφο από άλλον οργανισμό ενημερώθηκε", + "Copy from another organisation removed": "Το αντίγραφο από άλλον οργανισμό αφαιρέθηκε", + "Declined: they removed their copy. Share again if they need it.": "Απορρίφθηκε: ο παραλήπτης αφαίρεσε το αντίγραφό του. Κοινοποιήστε ξανά αν το χρειάζεται.", + "Recipient at another organisation removed their copy": "Παραλήπτης σε άλλον οργανισμό αφαίρεσε το αντίγραφό του", + "Removed the user from %n team folder.": "Ο χρήστης αφαιρέθηκε από %n φάκελο ομάδας.", + "Removed the user from %n team folders.": "Ο χρήστης αφαιρέθηκε από %n φακέλους ομάδας.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Ο χρήστης αφαιρέθηκε από %n φάκελο ομάδας.","Ο χρήστης αφαιρέθηκε από %n φακέλους ομάδας."], + "A restored copy came from a share that has ended. It stays read-only.": "Ένα αντίγραφο που επαναφέρθηκε προέρχεται από κοινοποίηση που έληξε. Παραμένει μόνο για ανάγνωση.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Ο οργανισμός που κοινοποίησε ένα αντίγραφο που επαναφέρθηκε δεν ήταν προσβάσιμος. Το αντίγραφο παραμένει μόνο για ανάγνωση και δεν ακολουθεί τις αλλαγές τους.", + "Recipient at another organisation restored their copy": "Παραλήπτης σε άλλον οργανισμό επανέφερε το αντίγραφό του" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/el.json b/l10n/el.json index c92d68429..bc6359053 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1181,7 +1181,567 @@ "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.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Η εναλλαγή κλειδιού αφαίρεσε την πρόσβαση έκτακτης ανάγκης αυτής της επαφής. Ορίστε την ξανά, αν τη θέλετε ακόμα.", + "Shared with groups": "Κοινοποιήθηκε σε ομάδες", + "Not shared with any group yet.": "Δεν έχει κοινοποιηθεί ακόμη σε καμία ομάδα.", + "Revoke the share with {group}": "Ανάκληση της κοινοποίησης στην ομάδα {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Κοινοποιήθηκε στην ομάδα {group}: {received} μέλη το έλαβαν, {skipped} όχι, επειδή δεν έχουν ρυθμίσει ακόμη κρυπτογράφηση.", + "Search groups": "Αναζήτηση ομάδων", + "Failed to share": "Η κοινοποίηση απέτυχε", + "Columns": "Στήλες", + "Column {number}": "Στήλη {number}", + "Map one column to Name. Every secret needs a name.": "Αντιστοιχίστε μία στήλη στο όνομα. Κάθε μυστικό χρειάζεται όνομα.", + "Notes": "Σημειώσεις", + "Do not import": "Να μην εισαχθεί", + "Hide this value": "Απόκρυψη αυτής της τιμής", + "Show this value": "Εμφάνιση αυτής της τιμής", + "Defaults": "Προεπιλογές", + "New secrets start as this type, and your secret list opens in this view.": "Τα νέα μυστικά ξεκινούν με αυτόν τον τύπο και η λίστα μυστικών ανοίγει σε αυτή την προβολή.", + "Default item type": "Προεπιλεγμένος τύπος στοιχείου", + "Cards": "Κάρτες", + "Table": "Πίνακας", + "Could not save your default": "Δεν ήταν δυνατή η αποθήκευση της προεπιλογής σας", + "Recently used": "Πρόσφατα χρησιμοποιημένα", + "Opened": "Άνοιξε", + "You have not opened any secrets yet": "Δεν έχετε ανοίξει ακόμη κανένα μυστικό", + "Could not delete the item type.": "Δεν ήταν δυνατή η διαγραφή του τύπου στοιχείου.", + "Could not load the item types.": "Δεν ήταν δυνατή η φόρτωση των τύπων στοιχείων.", + "Could not save the item type.": "Δεν ήταν δυνατή η αποθήκευση του τύπου στοιχείου.", + "Delete item type": "Διαγραφή τύπου στοιχείου", + "Edit item type": "Επεξεργασία τύπου στοιχείου", + "Fields": "Πεδία", + "Fields: {count}": "Πεδία: {count}", + "Hidden": "Κρυφό", + "Item types": "Τύποι στοιχείων", + "Move up": "Μετακίνηση πάνω", + "New item type": "Νέος τύπος στοιχείου", + "No item types defined yet.": "Δεν έχουν οριστεί ακόμη τύποι στοιχείων.", + "Required": "Υποχρεωτικό", + "Text": "Κείμενο", + "This field is required": "Αυτό το πεδίο είναι υποχρεωτικό", + "Web address": "Διεύθυνση ιστού", + "{label} (required)": "{label} (υποχρεωτικό)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Διαγραφή του «{name}»; Τα μυστικά αυτού του τύπου παραμένουν αναγνώσιμα και γίνονται στοιχεία Σύνδεσης.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Οι τύποι στοιχείων που ορίζετε εδώ εμφανίζονται σε όλους στο παράθυρο Νέο μυστικό, με τα πεδία που επιλέγετε.", + "Secret moved to the trash": "Το μυστικό μεταφέρθηκε στον κάδο", + "Secret restored from the trash": "Το μυστικό επαναφέρθηκε από τον κάδο", + "Secret deleted for good": "Το μυστικό διαγράφηκε οριστικά", + "Secret archived": "Το μυστικό αρχειοθετήθηκε", + "Secret unarchived": "Το μυστικό βγήκε από την αρχειοθήκη", + "Unarchive": "Έξοδος από την αρχειοθήκη", + "Could not archive the secret": "Δεν ήταν δυνατή η αρχειοθέτηση του μυστικού", + "Could not unarchive the secret": "Δεν ήταν δυνατή η έξοδος του μυστικού από την αρχειοθήκη", + "Archive {count} secrets": "Αρχειοθέτηση μυστικών: {count}", + "Unarchive {count} secrets": "Έξοδος από την αρχειοθήκη μυστικών: {count}", + "Restore {count} secrets": "Επαναφορά μυστικών: {count}", + "Delete {count} secrets for good": "Οριστική διαγραφή μυστικών: {count}", + "Done for {ok} of {total} secrets": "Ολοκληρώθηκε για {ok} από {total} μυστικά", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Τα αρχειοθετημένα μυστικά φεύγουν από τη λίστα του θησαυροφυλακίου, την αναζήτηση, την αυτόματη συμπλήρωση και την αναφορά υγείας. Διατηρούν τις κοινοποιήσεις τους. Θα τα βρείτε στην Αρχειοθήκη.", + "These secrets come back to the vault list, search and autofill.": "Αυτά τα μυστικά επιστρέφουν στη λίστα του θησαυροφυλακίου, την αναζήτηση και την αυτόματη συμπλήρωση.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Αυτά τα μυστικά επιστρέφουν στη λίστα του θησαυροφυλακίου. Οι παλιές κοινοποιήσεις δεν επιστρέφουν, οπότε κοινοποιήστε τα ξανά όπου χρειάζεται.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Αυτό διαγράφει τα μυστικά μαζί με τα συνημμένα και το ιστορικό εκδόσεων. Δεν μπορεί να αναιρεθεί.", + "Delete for good": "Οριστική διαγραφή", + "Trash": "Κάδος", + "The trash is empty": "Ο κάδος είναι άδειος", + "No archived secrets": "Δεν υπάρχουν αρχειοθετημένα μυστικά", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Τα διαγραμμένα μυστικά περιμένουν εδώ μέχρι να λήξει η περίοδος διατήρησης και έπειτα διαγράφονται οριστικά.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Αρχειοθετήστε ένα μυστικό από το πλαίσιο λεπτομερειών του για να μείνει έξω από τη λίστα του θησαυροφυλακίου, την αναζήτηση και την αυτόματη συμπλήρωση.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Όρια για κρυπτογραφημένα συνημμένα (επιβάλλονται στον διακομιστή στα αποθηκευμένα κρυπτογραφημένα byte), διατήρηση ιστορικού εκδόσεων και πόσο μένουν στον κάδο τα διαγραμμένα μυστικά.", + "Days a deleted secret stays in the trash (1 to 365)": "Ημέρες που ένα διαγραμμένο μυστικό μένει στον κάδο (1 έως 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Αυτό μεταφέρει το μυστικό στον κάδο και τερματίζει τώρα τις κοινοποιήσεις του. Μπορείτε να το επαναφέρετε από τον κάδο μέχρι να λήξει η περίοδος διατήρησης: 30 ημέρες, εκτός αν την άλλαξε ο διαχειριστής σας.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Αυτό μεταφέρει μυστικά στον κάδο ({count}) και τερματίζει τώρα τις κοινοποιήσεις τους. Μπορείτε να τα επαναφέρετε από τον κάδο μέχρι να λήξει η περίοδος διατήρησης.", + "Remove {name} from favourites": "Αφαίρεση του {name} από τα αγαπημένα", + "Add {name} to favourites": "Προσθήκη του {name} στα αγαπημένα", + "Could not change the favourite": "Δεν ήταν δυνατή η αλλαγή του αγαπημένου", + "Remove from favourites": "Αφαίρεση από τα αγαπημένα", + "Add to favourites": "Προσθήκη στα αγαπημένα", + "Tags": "Ετικέτες", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Οι ετικέτες δεν είναι κρυπτογραφημένες. Οι διαχειριστές του διακομιστή μπορούν να τις διαβάσουν, όπως τα ονόματα φακέλων.", + "Favourites": "Αγαπημένα", + "Filter by tag": "Φιλτράρισμα κατά ετικέτα", + "All tags": "Όλες οι ετικέτες", + "Last used": "Τελευταία χρήση", + "Tags for {count} secrets": "Ετικέτες για {count} μυστικά", + "Tag": "Ετικέτα", + "Remove tag": "Αφαίρεση ετικέτας", + "Add tag": "Προσθήκη ετικέτας", + "Could not change the tags. Try again.": "Δεν ήταν δυνατή η αλλαγή των ετικετών. Δοκιμάστε ξανά.", + "Could not approve the application. It is still in the queue.": "Δεν ήταν δυνατή η έγκριση της αίτησης. Βρίσκεται ακόμη στην ουρά.", + "Could not reject the application. It is still in the queue.": "Δεν ήταν δυνατή η απόρριψη της αίτησης. Βρίσκεται ακόμη στην ουρά.", + "Removed the user from {count} team folders.": "Ο χρήστης αφαιρέθηκε από {count} φακέλους ομάδας.", + "Approve a share": "Έγκριση κοινής χρήσης", + "This approval link is incomplete. Open it again from the notification.": "Αυτός ο σύνδεσμος έγκρισης είναι ελλιπής. Ανοίξτε τον ξανά από την ειδοποίηση.", + "Deny": "Άρνηση", + "{user} joined a group you share a secret with. Share the secret with them too?": "Ο χρήστης {user} εντάχθηκε σε μια ομάδα με την οποία μοιράζεστε ένα μυστικό. Να μοιραστεί το μυστικό και μαζί του;", + "{requester} asks you to share a secret with {user}.": "Ο χρήστης {requester} σας ζητά να μοιραστείτε ένα μυστικό με τον χρήστη {user}.", + "Shared. The recipient can now open the secret.": "Κοινοποιήθηκε. Ο παραλήπτης μπορεί πλέον να ανοίξει το μυστικό.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Ο παραλήπτης δεν έχει ρυθμίσει ακόμη το Keepiq, οπότε δεν κοινοποιήθηκε τίποτα. Δοκιμάστε ξανά όταν το κάνει.", + "Could not share the secret. Only its owner can approve this.": "Δεν ήταν δυνατή η κοινή χρήση του μυστικού. Μόνο ο κάτοχός του μπορεί να το εγκρίνει.", + "Could not share the secret. Try again.": "Δεν ήταν δυνατή η κοινή χρήση του μυστικού. Δοκιμάστε ξανά.", + "Denied. Nothing was shared.": "Απορρίφθηκε. Δεν κοινοποιήθηκε τίποτα.", + "Could not deny the request. Try again.": "Δεν ήταν δυνατή η άρνηση του αιτήματος. Δοκιμάστε ξανά.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "Ο χρήστης %1$s σας ζητά να μοιραστείτε το μυστικό \"%2$s\" με τον χρήστη %3$s.", + "Expires on (optional)": "Λήγει στις (προαιρετικό)", + "Hand over to": "Παράδοση σε", + "Choose a recipient": "Επιλέξτε παραλήπτη", + "Hand over temporarily": "Προσωρινή παράδοση", + "Expiry rules": "Κανόνες λήξης", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ορίστε πόσο καιρό μπορούν να ισχύουν οι κωδικοί ενός τύπου στοιχείου ή ενός φακέλου και πότε θα λαμβάνετε υπενθύμιση. Όταν ισχύουν πολλές ημερομηνίες, μετράει η νωρίτερη.", + "Delete rule": "Διαγραφή κανόνα", + "Set by your administrator": "Ορίστηκε από τον διαχειριστή σας", + "No expiry rules yet.": "Δεν υπάρχουν ακόμη κανόνες λήξης.", + "Applies to": "Ισχύει για", + "Item type": "Τύπος στοιχείου", + "Maximum age in days (empty for reminders only)": "Μέγιστη ηλικία σε ημέρες (κενό μόνο για υπενθυμίσεις)", + "Remind me this many days before, comma separated": "Υπενθύμιση τόσες ημέρες πριν, χωρισμένες με κόμμα", + "Save rule": "Αποθήκευση κανόνα", + "An item type": "Ένας τύπος στοιχείου", + "A folder": "Ένας φάκελος", + "Folder {name}": "Φάκελος {name}", + "Type {name}": "Τύπος {name}", + "Expires after {days} days": "Λήγει μετά από {days} ημέρες", + "Reminders {days} days before": "Υπενθυμίσεις {days} ημέρες πριν", + "Could not save the expiry rule.": "Δεν ήταν δυνατή η αποθήκευση του κανόνα λήξης.", + "Could not delete the expiry rule.": "Δεν ήταν δυνατή η διαγραφή του κανόνα λήξης.", + "All statuses": "Όλες οι καταστάσεις", + "Compromised": "Παραβιασμένη", + "Could not load the members.": "Δεν ήταν δυνατή η φόρτωση των μελών.", + "Emergency contact": "Επαφή έκτακτης ανάγκης", + "Leaving user": "Αποχωρών χρήστης", + "No": "Όχι", + "No users match this filter.": "Κανένας χρήστης δεν ταιριάζει σε αυτό το φίλτρο.", + "Not set up": "Δεν έχει ρυθμιστεί", + "Revoke suite": "Ανάκληση σουίτας", + "Revoked": "Ανακλήθηκε", + "Search users": "Αναζήτηση χρηστών", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Δείτε ποιοι χρήστες έχουν ρυθμίσει θησαυροφυλάκιο. Ξεκινήστε την αποχώρηση ή ανακαλέστε μια σουίτα από μια γραμμή.", + "Successor": "Διάδοχος", + "Team folders": "Φάκελοι ομάδας", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Ο χρήστης είναι ακόμη στην ομάδα {groups}, που είναι μέλος φακέλου ομάδας. Αφαιρέστε τον από την ομάδα ή απενεργοποιήστε τον λογαριασμό.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Ο χρήστης είναι ακόμη στις ομάδες {groups}, που είναι μέλη φακέλων ομάδας. Αφαιρέστε τον από τις ομάδες ή απενεργοποιήστε τον λογαριασμό.", + "Vault status": "Κατάσταση θησαυροφυλακίου", + "Yes": "Ναι", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Μια εξαγωγή CXF ΔΕΝ ΕΙΝΑΙ ΚΡΥΠΤΟΓΡΑΦΗΜΕΝΗ. Κάθε κωδικός πρόσβασης και σύνδεση θα είναι αναγνώσιμα ως απλό κείμενο στο αρχείο που κατεβάζετε. Αποθηκεύστε το με ασφάλεια και διαγράψτε το αμέσως μετά τη χρήση.", + "Root certificate expiring soon": "Το πιστοποιητικό ρίζας λήγει σύντομα", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Το πιστοποιητικό ρίζας του θησαυροφυλακίου λήγει σε %1$d ημέρα(ες). Ανανεώστε το πριν από τότε. Η ανανέωση υπογράφει ξανά κάθε σουίτα κρυπτογράφησης.", + "Compromise recovery aborted": "Η ανάκτηση μετά από παραβίαση ακυρώθηκε", + "Key rotation ended by a compromise revoke": "Η εναλλαγή κλειδιού τερματίστηκε από ανάκληση λόγω παραβίασης", + "Encryption suite revoke refused": "Η ανάκληση της σουίτας κρυπτογράφησης απορρίφθηκε", + "Master password proof refused": "Η απόδειξη του κύριου κωδικού απορρίφθηκε", + "Your current master password": "Ο τρέχων κύριος κωδικός σας", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n επαφή έκτακτης ανάγκης είχε εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού την αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n επαφές έκτακτης ανάγκης είχαν εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού τις αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Αυτές οι επαφές έκτακτης ανάγκης δεν μεταφέρθηκαν στο νέο σας κλειδί. Η πρόσβαση έκτακτης ανάγκης τους αφαιρέθηκε. Προσθέστε τις ξανά από την Πρόσβαση έκτακτης ανάγκης αν τις θέλετε ακόμα.", + "Renew root certificate": "Ανανέωση πιστοποιητικού ρίζας", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Δημιουργείται νέο πιστοποιητικό ρίζας και ενδιάμεσο. Κάθε ενεργή σουίτα κρυπτογράφησης υπογράφεται ξανά. Δεν μπορεί να αναιρεθεί.", + "Renew root": "Ανανέωση ρίζας", + "Root renewed. {n} encryption suites signed again.": "Η ρίζα ανανεώθηκε. Σουίτες κρυπτογράφησης που υπογράφηκαν ξανά: {n}.", + "Could not renew the root certificate.": "Δεν ήταν δυνατή η ανανέωση του πιστοποιητικού ρίζας.", + "Lease policy for this application": "Πολιτική μίσθωσης για αυτή την εφαρμογή", + "In force now: {default} seconds by default, {max} seconds at most.": "Ισχύει τώρα: {default} δευτερόλεπτα από προεπιλογή, το πολύ {max} δευτερόλεπτα.", + "Leases are not renewable": "Οι μισθώσεις δεν ανανεώνονται", + "Lease policy saved.": "Η πολιτική μίσθωσης αποθηκεύτηκε.", + "Leave a field empty to use the instance value.": "Αφήστε ένα πεδίο κενό για να χρησιμοποιηθεί η τιμή της εγκατάστασης.", + "Instance value: {value}": "Τιμή εγκατάστασης: {value}", + "Renewal": "Ανανέωση", + "Use the instance value ({value})": "Χρήση της τιμής εγκατάστασης ({value})", + "Allowed": "Επιτρέπεται", + "Not allowed": "Δεν επιτρέπεται", + "Save lease policy": "Αποθήκευση πολιτικής μίσθωσης", + "Only an administrator can change this policy.": "Μόνο ένας διαχειριστής μπορεί να αλλάξει αυτή την πολιτική.", + "Could not save the lease policy.": "Δεν ήταν δυνατή η αποθήκευση της πολιτικής μίσθωσης.", + "{member} got access from {confirmer}.": "Ο/Η {member} πήρε πρόσβαση από τον/την {confirmer}.", + "Automatically confirm new team folder members": "Αυτόματη επιβεβαίωση νέων μελών φακέλων ομάδας", + "Gave %n new member access to a team folder.": "%n νέο μέλος πήρε πρόσβαση σε φάκελο ομάδας.", + "Gave %n new members access to a team folder.": "%n νέα μέλη πήραν πρόσβαση σε φάκελο ομάδας.", + "Give new team folder members access without waiting for the folder owner.": "Δώστε πρόσβαση στα νέα μέλη χωρίς να περιμένετε τον κάτοχο του φακέλου.", + "New team folder members": "Νέα μέλη φακέλων ομάδας", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ο κάτοχος ή ένα μέλος με δικαίωμα εγγραφής τα επιβεβαιώνει από το ανοιχτό θησαυροφυλάκιο. Το Keepiq δεν αποκρυπτογραφεί ποτέ στον διακομιστή.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Αναμονή για μέλος με δικαίωμα εγγραφής να ανοίξει το Keepiq. Μπορείτε επίσης να μοιραστείτε τώρα.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Μέρος της απόκρισης στην παραβίαση απέτυχε ({failed} βήμα(τα)). Ελέγξτε το αρχείο καταγραφής του διακομιστή και ανακαλέστε ξανά τη σουίτα για να ολοκληρωθεί.", + "This also revoked suite {suite} and ended key migration {migration}.": "Αυτό ανακάλεσε επίσης τη σουίτα {suite} και τερμάτισε τη μετάβαση κλειδιών {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Revoking the second suite deleted %n emergency-access contacts.": "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης.", + "A suite revoked as compromised cannot be reinstated.": "Μια σουίτα που ανακλήθηκε ως παραβιασμένη δεν μπορεί να αποκατασταθεί.", + "Archives to keep": "Αρχεία προς διατήρηση", + "Back up every vault automatically": "Αυτόματο αντίγραφο κάθε θησαυροφυλακίου", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Δημιουργήστε αντίγραφο κάθε θησαυροφυλακίου βάσει προγράμματος. Τα αρχεία περιέχουν μόνο κρυπτοκείμενο και επαναφέρονται με occ.", + "Back up now": "Αντίγραφο τώρα", + "Backup public key (PEM, optional)": "Δημόσιο κλειδί αντιγράφου (PEM, προαιρετικό)", + "Backup requested for the next cron run": "Ζητήθηκε αντίγραφο για την επόμενη εκτέλεση cron", + "Encrypted": "Κρυπτογραφημένο", + "Every (hours)": "Κάθε (ώρες)", + "Last backup {when} failed: {error}": "Το τελευταίο αντίγραφο {when} απέτυχε: {error}", + "Last backup {when} succeeded.": "Το τελευταίο αντίγραφο {when} ολοκληρώθηκε.", + "No archives yet.": "Δεν υπάρχουν ακόμη αρχεία.", + "Size": "Μέγεθος", + "Vault backups": "Αντίγραφα θησαυροφυλακίου", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Με κλειδί, κάθε αρχείο κρυπτογραφείται για αυτό. Κρατήστε το ιδιωτικό κλειδί εκτός αυτού του διακομιστή: το χρειάζεστε για έλεγχο ή επαναφορά.", + "Written": "Γράφτηκε", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n χρήστης στο πεδίο εφαρμογής δεν έχει ακόμη σύνδεση δύο παραγόντων και δεν μπορεί να ανοίξει το θησαυροφυλάκιο όσο αυτό είναι ενεργό.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n χρήστες στο πεδίο εφαρμογής δεν έχουν ακόμη σύνδεση δύο παραγόντων και δεν μπορούν να ανοίξουν το θησαυροφυλάκιο όσο αυτό είναι ενεργό.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Οι κωδικοί ανάκτησης δεν μετρούν. Αν οι χρήστες σας συνδέονται μέσω παρόχου ταυτότητας με δικό του δεύτερο παράγοντα, εξαιρέστε τις ομάδες τους.", + "Block personal vault export": "Αποκλεισμός εξαγωγής προσωπικού θησαυροφυλακίου", + "Keep work logins in team folders": "Φύλαξη εταιρικών στοιχείων σύνδεσης σε φακέλους ομάδας", + "Move to a team folder": "Μετακίνηση σε φάκελο ομάδας", + "Not in a team folder": "Όχι σε φάκελο ομάδας", + "Only for these groups (empty is everyone)": "Μόνο για αυτές τις ομάδες (κενό σημαίνει όλοι)", + "Require two-factor login before the vault opens": "Απαίτηση σύνδεσης δύο παραγόντων πριν ανοίξει το θησαυροφυλάκιο", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Κανόνες για κάθε θησαυροφυλάκιο. Ο καθένας ισχύει για όλους ή μόνο για τις ομάδες που επιλέγετε.", + "Secret types that belong in a team folder": "Τύποι μυστικών που ανήκουν σε φάκελο ομάδας", + "Set up two-factor login": "Ρύθμιση σύνδεσης δύο παραγόντων", + "Team folder you can write to": "Φάκελος ομάδας όπου μπορείτε να γράψετε", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Οι χρήστες δεν μπορούν να κατεβάσουν αντίγραφο ασφαλείας, CSV ή αρχείο μεταφοράς. Το πακέτο προσωπικών δεδομένων τους παραμένει διαθέσιμο.", + "Users cannot save these secret types in a personal folder.": "Οι χρήστες δεν μπορούν να αποθηκεύσουν αυτούς τους τύπους μυστικών σε προσωπικό φάκελο.", + "Vault policies": "Πολιτικές θησαυροφυλακίου", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ο οργανισμός σας δεν επιτρέπει την εξαγωγή του προσωπικού σας θησαυροφυλακίου. Το πακέτο προσωπικών δεδομένων σας στις ρυθμίσεις παραμένει διαθέσιμο.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ο οργανισμός σας φυλάει αυτά τα μυστικά σε φάκελο ομάδας. Μετακινήστε το καθένα σε φάκελο ομάδας.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ο οργανισμός σας φυλάει αυτόν τον τύπο μυστικού σε φάκελο ομάδας. Επιλέξτε έναν από τους φακέλους ομάδας σας ή έναν όπου μπορείτε να γράψετε.", + "Your organisation requires two-factor login before you can open your vault.": "Ο οργανισμός σας απαιτεί σύνδεση δύο παραγόντων πριν μπορέσετε να ανοίξετε το θησαυροφυλάκιό σας.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Οι χρήστες επιλέγουν πόσο χρόνο η επέκταση μένει ξεκλείδωτη όταν δεν χρησιμοποιείται. Εσείς ορίζετε τον μέγιστο χρόνο που μπορούν να επιλέξουν.", + "Longest idle time before the extension locks": "Μέγιστος χρόνος αδράνειας πριν κλειδώσει η επέκταση", + "1 minute": "1 λεπτό", + "5 minutes": "5 λεπτά", + "15 minutes": "15 λεπτά", + "1 hour": "1 ώρα", + "4 hours": "4 ώρες", + "Connector": "Σύνδεσμος", + "Directory (tenant) ID": "Αναγνωριστικό καταλόγου (μισθωτή)", + "Application (client) ID": "Αναγνωριστικό εφαρμογής (πελάτη)", + "Data collection rule immutable ID": "Αμετάβλητο αναγνωριστικό κανόνα συλλογής δεδομένων", + "Stream name": "Όνομα ροής", + "Splunk index (optional)": "Ευρετήριο Splunk (προαιρετικό)", + "Sourcetype (optional)": "Sourcetype (προαιρετικό)", + "Leave blank to keep the current one": "Αφήστε κενό για να διατηρηθεί το τρέχον", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF μέσω syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Τελικό σημείο συλλογής δεδομένων (https URL)", + "HTTP Event Collector URL (https)": "URL του HTTP Event Collector (https)", + "Client secret (write-only)": "Μυστικό πελάτη (μόνο εγγραφή)", + "HEC token (write-only)": "Διακριτικό HEC (μόνο εγγραφή)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Προωθήστε τα επιτρεπόμενα συμβάντα ελέγχου στο Splunk, στο Microsoft Sentinel, σε δέκτη syslog ή σε webhook. Τα μηνύματα περιέχουν μόνο καθαρισμένα μεταδεδομένα: καμία μυστική τιμή, όνομα, σύνδεση ή κρυπτογραφημένο κείμενο δεν φεύγει ποτέ από τον διακομιστή.", + "%n change waiting to sync": "%n αλλαγή περιμένει συγχρονισμό", + "%n changes waiting to sync": "%n αλλαγές περιμένουν συγχρονισμό", + "Changes that could not sync": "Αλλαγές που δεν μπόρεσαν να συγχρονιστούν", + "Choose a version": "Επιλέξτε έκδοση", + "Copy value": "Αντιγραφή τιμής", + "Deleted": "Διαγράφηκε", + "Discard": "Απόρριψη", + "Keep my offline change": "Διατήρηση της αλλαγής μου εκτός σύνδεσης", + "Keep the server version": "Διατήρηση της έκδοσης του διακομιστή", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Το Keepiq είναι μόνο για ανάγνωση εκτός σύνδεσης. Ο διαχειριστής δεν έχει ενεργοποιήσει την επεξεργασία εκτός σύνδεσης.", + "Let users edit secrets offline": "Να επιτρέπεται στους χρήστες να επεξεργάζονται μυστικά εκτός σύνδεσης", + "Not synced yet": "Δεν έχει συγχρονιστεί ακόμα", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Οι αλλαγές εκτός σύνδεσης μένουν στη συσκευή, κρυπτογραφημένες για τον χρήστη, και συγχρονίζονται στο επόμενο ξεκλείδωμα με σύνδεση. Η κοινή χρήση, οι φάκελοι και τα συνημμένα χρειάζονται ακόμα σύνδεση.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Εκτός σύνδεσης. Οι επεξεργασίες, οι μετακινήσεις και οι διαγραφές μένουν σε αυτή τη συσκευή και συγχρονίζονται όταν συνδεθείτε ξανά. Η κοινή χρήση και τα συνημμένα χρειάζονται σύνδεση.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Εκτός σύνδεσης. Οι αλλαγές σας μένουν σε αυτή τη συσκευή και συγχρονίζονται όταν συνδεθείτε ξανά. Τελευταίος συγχρονισμός {when}.", + "Open my changes": "Άνοιγμα των αλλαγών μου", + "Sharing needs a connection": "Η κοινή χρήση χρειάζεται σύνδεση", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Κάποιος άλλαξε αυτό το μυστικό στον διακομιστή αφού δημιουργήθηκε το αντίγραφό σας εκτός σύνδεσης. Επιλέξτε ποια έκδοση θα κρατήσετε.", + "Sync or discard your offline changes before you rotate your keys.": "Συγχρονίστε ή απορρίψτε τις αλλαγές εκτός σύνδεσης πριν αλλάξετε τα κλειδιά σας.", + "That password did not open your changes.": "Αυτός ο κωδικός δεν άνοιξε τις αλλαγές σας.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Το στιγμιότυπο εκτός σύνδεσης αποθηκεύει κρυπτογραφημένα μυστικά (ανοίγουν μόνο με το κλειδί που προκύπτει από τον κύριο κωδικό του χρήστη, ακριβώς όπως στον διακομιστή) και κρυπτογραφεί ονόματα, URL και ονόματα φακέλων κατά την αποθήκευση. Η πρόσβαση εκτός σύνδεσης είναι μόνο για ανάγνωση, εκτός αν επιτρέψετε παρακάτω την επεξεργασία εκτός σύνδεσης. Απενεργοποιήστε το για συσκευές που δεν πρέπει ποτέ να αποθηκεύουν διαπιστευτήρια· η απενεργοποίηση καθαρίζει τις υπάρχουσες κρυφές μνήμες στην επόμενη φόρτωση.", + "The previous vault copy is gone, so these changes cannot be opened.": "Το προηγούμενο αντίγραφο του θησαυροφυλακίου δεν υπάρχει πια, οπότε αυτές οι αλλαγές δεν μπορούν να ανοίξουν.", + "The server version": "Η έκδοση του διακομιστή", + "This secret changed while you were offline": "Αυτό το μυστικό άλλαξε ενώ ήσασταν εκτός σύνδεσης", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Διαγράψατε αυτό το μυστικό εκτός σύνδεσης, αλλά στο μεταξύ άλλαξε στον διακομιστή. Επιλέξτε ποια έκδοση θα κρατήσετε.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Τα κλειδιά σας άλλαξαν σε άλλη συσκευή. Εισαγάγετε τον προηγούμενο κύριο κωδικό για να συγχρονίσετε τις αλλαγές εκτός σύνδεσης, ή απορρίψτε τις.", + "Your offline change": "Η αλλαγή σας εκτός σύνδεσης", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n επαφή έκτακτης ανάγκης είχε εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού την αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον.", + "%n επαφές έκτακτης ανάγκης είχαν εκκρεμές αίτημα πρόσβασης όταν η εναλλαγή κλειδιού τις αφαίρεσε. Ελέγξτε ποιος το ζήτησε πριν προσθέσετε ξανά κάποιον." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n στοιχείο δεν μπορεί να αναπαρασταθεί σε CXF και θα παραλειφθεί.", + "%n στοιχεία δεν μπορούν να αναπαρασταθούν σε CXF και θα παραλειφθούν." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n παλαιότερη έκδοση απορρίφθηκε, επειδή μόνο το πρόσφατο ιστορικό μπορεί να μεταφερθεί.", + "%n παλαιότερες εκδόσεις απορρίφθηκαν, επειδή μόνο το πρόσφατο ιστορικό μπορεί να μεταφερθεί." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n αντίγραφο μυστικού πρέπει ακόμη να κρυπτογραφηθεί και να κοινοποιηθεί.", + "%n αντίγραφα μυστικών πρέπει ακόμη να κρυπτογραφηθούν και να κοινοποιηθούν." + ], + "_%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 μυστικό δεν ήταν δυνατό να αποκρυπτογραφηθεί και δεν περιλαμβάνεται σε αυτή την εξαγωγή.", + "%n μυστικά δεν ήταν δυνατό να αποκρυπτογραφηθούν και δεν περιλαμβάνονται σε αυτή την εξαγωγή." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n μυστικό δεν μπόρεσε να αποκρυπτογραφηθεί με το παλιό σας κλειδί, γι' αυτό δεν μεταφέρθηκε.", + "%n μυστικά δεν μπόρεσαν να αποκρυπτογραφηθούν με το παλιό σας κλειδί, γι' αυτό δεν μεταφέρθηκαν." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n μυστικό δεν μεταφέρθηκε.", + "%n μυστικά δεν μεταφέρθηκαν." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n μυστικό είναι ακόμη κρυπτογραφημένο με το προηγούμενο κλειδί σας.", + "%n μυστικά είναι ακόμη κρυπτογραφημένα με το προηγούμενο κλειδί σας." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n μυστικό παραλείφθηκε επειδή ο διάδοχος δεν έχει ακόμη αντίγραφο — προσθέστε τον διάδοχο στον φάκελο και εκτελέστε ξανά.", + "%n μυστικά παραλείφθηκαν επειδή ο διάδοχος δεν έχει ακόμη αντίγραφο — προσθέστε τον διάδοχο στον φάκελο και εκτελέστε ξανά." + ], + "_%n secret_::_%n secrets_": [ + "%n μυστικό", + "%n μυστικά" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n χρήστης στο πεδίο εφαρμογής δεν έχει ακόμη σύνδεση δύο παραγόντων και δεν μπορεί να ανοίξει το θησαυροφυλάκιο όσο αυτό είναι ενεργό.", + "%n χρήστες στο πεδίο εφαρμογής δεν έχουν ακόμη σύνδεση δύο παραγόντων και δεν μπορούν να ανοίξουν το θησαυροφυλάκιο όσο αυτό είναι ενεργό." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Ολοκλήρωση παρ' όλα αυτά, με απώλεια πρόσβασης σε %n μυστικό", + "Ολοκλήρωση παρ' όλα αυτά, με απώλεια πρόσβασης σε %n μυστικά" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n νέο μέλος πήρε πρόσβαση σε φάκελο ομάδας.", + "%n νέα μέλη πήραν πρόσβαση σε φάκελο ομάδας." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Η εναλλαγή κλειδιού ολοκληρώθηκε. %n μυστικό επανακρυπτογραφήθηκε με το νέο σας κλειδί.", + "Η εναλλαγή κλειδιού ολοκληρώθηκε. %n μυστικά επανακρυπτογραφήθηκαν με το νέο σας κλειδί." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Η ανάκληση της δεύτερης σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφή πρόσβασης έκτακτης ανάγκης.", + "Η ανάκληση αυτής της σουίτας διέγραψε %n επαφές πρόσβασης έκτακτης ανάγκης." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "εμφανίστηκε %n φορά σε παραβιάσεις", + "εμφανίστηκε %n φορές σε παραβιάσεις" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "κοινοποιημένο σε %n μυστικό", + "κοινοποιημένο σε %n μυστικά" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Αυτός ο φάκελος περιέχει %n μυστικό άμεσα.", + "Αυτός ο φάκελος περιέχει %n μυστικά άμεσα." + ], + "_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._": [ + "Η εναλλαγή κλειδιού αφαίρεσε %n επαφή έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε την ξανά, αν τη θέλετε ακόμα.", + "Η εναλλαγή κλειδιού αφαίρεσε %n επαφές έκτακτης ανάγκης. Ελέγξτε την Πρόσβαση έκτακτης ανάγκης και προσθέστε τις ξανά, αν τις θέλετε ακόμα." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n αλλαγή περιμένει συγχρονισμό", + "%n αλλαγές περιμένουν συγχρονισμό" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Ο χρήστης είναι ακόμη στην ομάδα {groups}, που είναι μέλος φακέλου ομάδας. Αφαιρέστε τον από την ομάδα ή απενεργοποιήστε τον λογαριασμό.", + "Ο χρήστης είναι ακόμη στις ομάδες {groups}, που είναι μέλη φακέλων ομάδας. Αφαιρέστε τον από τις ομάδες ή απενεργοποιήστε τον λογαριασμό." + ], + "Allow approval from another device": "Να επιτρέπεται η έγκριση από άλλη συσκευή", + "App": "Εφαρμογή", + "Approve a new device": "Έγκριση νέας συσκευής", + "Approve from another device": "Έγκριση από άλλη συσκευή", + "Asked at": "Ζητήθηκε στις", + "Check that the new device shows these words:": "Ελέγξτε ότι η νέα συσκευή εμφανίζει αυτές τις λέξεις:", + "Denied. If you did not ask, end your other sessions:": "Απορρίφθηκε. Αν δεν το ζητήσατε εσείς, τερματίστε τις άλλες συνεδρίες σας:", + "Device": "Συσκευή", + "IP address": "Διεύθυνση IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Επιτρέψτε στους χρήστες να ξεκλειδώνουν ένα νέο πρόγραμμα περιήγησης εγκρίνοντάς το από μια συσκευή όπου το Keepiq είναι ήδη ξεκλείδωτο.", + "New device approval": "Έγκριση νέων συσκευών", + "Nextcloud security settings": "Ρυθμίσεις ασφαλείας Nextcloud", + "Only approve a device you are using right now.": "Εγκρίνετε μόνο μια συσκευή που χρησιμοποιείτε αυτή τη στιγμή.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ανοίξτε το Keepiq σε μια συσκευή όπου είναι ξεκλείδωτο και εγκρίνετε αυτή τη συσκευή. Ελέγξτε ότι εμφανίζει τις ίδιες λέξεις:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Η συσκευή που εγκρίνει σφραγίζει το κλειδί ξεκλειδώματος για τη νέα συσκευή. Ο διακομιστής απλώς το μεταβιβάζει και δεν μπορεί να το ανοίξει.", + "The master password is not right, or the request has ended.": "Ο κύριος κωδικός πρόσβασης δεν είναι σωστός ή το αίτημα έχει λήξει.", + "The request expired. Ask again or use your master password.": "Το αίτημα έληξε. Ζητήστε ξανά ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "The request was denied.": "Το αίτημα απορρίφθηκε.", + "Too many requests. Try again in an hour or use your master password.": "Πάρα πολλά αιτήματα. Δοκιμάστε ξανά σε μία ώρα ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "Unknown device": "Άγνωστη συσκευή", + "Web app": "Εφαρμογή ιστού", + "A device": "Μια συσκευή", + "A new device asks to open your vault": "Μια νέα συσκευή ζητά να ανοίξει το θησαυροφυλάκιό σας", + "%s asks to be approved. Only approve a device you are using right now.": "%s ζητά έγκριση. Εγκρίνετε μόνο μια συσκευή που χρησιμοποιείτε αυτή τη στιγμή.", + "Access ends on (optional)": "Η πρόσβαση λήγει στις (προαιρετικό)", + "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.": "Οι εφαρμογές του Keepiq δεν θα εμφανίσουν ούτε θα αντιγράψουν τον κωδικό πρόσβασης. Κάποιος με τεχνικές γνώσεις μπορεί ακόμη να τον διαβάσει από τη δική του συσκευή. Αλλάξτε τον όταν λήξει η πρόσβασή του.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Αυτό το μυστικό είναι μόνο για χρήση. Συνδεθείτε μέσω της επέκτασης προγράμματος περιήγησης του Keepiq.", + "Until {date}": "Έως {date}", + "Use only": "Μόνο χρήση", + "Use only (can sign in, cannot view or copy)": "Μόνο χρήση (μπορεί να συνδεθεί, δεν μπορεί να δει ή να αντιγράψει)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Μπορείτε να συνδεθείτε με αυτά τα στοιχεία μέσω της επέκτασης προγράμματος περιήγησης του Keepiq. Ο κάτοχος επέλεξε να μην σας επιτρέπει να τα δείτε ή να τα αντιγράψετε.", + "Your access ends on {date}": "Η πρόσβασή σας λήγει στις {date}", + "Your access to this secret has ended": "Η πρόσβασή σας σε αυτό το μυστικό έληξε", + "Your access to \"%s\" ends tomorrow": "Η πρόσβασή σας στο «%s» λήγει αύριο", + "Your access to \"%s\" has ended": "Η πρόσβασή σας στο «%s» έληξε", + "%1$s no longer has access to \"%2$s\"": "Ο χρήστης %1$s δεν έχει πλέον πρόσβαση στο «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "Ο χρήστης %1$s μπορούσε να δει αυτόν τον κωδικό. Αλλάξτε τον αν ο χρήστης %1$s δεν πρέπει πλέον να τον γνωρίζει.", + "%s could not view this password in Keepiq.": "Ο χρήστης %s δεν μπορούσε να δει αυτόν τον κωδικό στο Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} από {threshold} εγκρίσεις", + "a recovery officer": "ένας υπεύθυνος ανάκτησης", + "Account recovery": "Ανάκτηση λογαριασμού", + "Approvals needed": "Απαιτούμενες εγκρίσεις", + "Ask {user} which words they see, by phone or in person. They must be:": "Ρωτήστε τον/την {user} ποιες λέξεις βλέπει, τηλεφωνικά ή αυτοπροσώπως. Πρέπει να είναι:", + "Check again": "Έλεγχος ξανά", + "Create the recovery key": "Δημιουργία κλειδιού ανάκτησης", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Δημιουργήστε το κλειδί ανάκτησης. Το πρόγραμμα περιήγησης το δημιουργεί και δίνει σε κάθε υπεύθυνο ένα αντίγραφο που μόνο εκείνος μπορεί να ανοίξει.", + "Decline": "Απόρριψη", + "Enrol in account recovery": "Εγγραφή στην ανάκτηση λογαριασμού", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Εγγραφείτε ώστε ο οργανισμός σας να σας βοηθήσει να ανακτήσετε το θησαυροφυλάκιό σας αν ξεχάσετε τον κύριο κωδικό.", + "Every user is enrolled": "Όλοι οι χρήστες είναι εγγεγραμμένοι", + "Finish the recovery in the browser you asked from.": "Ολοκληρώστε την ανάκτηση στο πρόγραμμα περιήγησης από το οποίο την ζητήσατε.", + "Forgot your master password?": "Ξεχάσατε τον κύριο κωδικό;", + "Hand the key over": "Παράδοση του κλειδιού", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Επιτρέψτε σε χρήστες που ξέχασαν τον κύριο κωδικό τους να ανακτήσουν το θησαυροφυλάκιο, με έγκριση από υπεύθυνους ανάκτησης που ορίζετε.", + "New master password": "Νέος κύριος κωδικός", + "No one is asking to recover their account.": "Κανείς δεν ζητά ανάκτηση λογαριασμού.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Δεν υπάρχει ακόμη κλειδί ανάκτησης. Ένας από τους υπεύθυνους το δημιουργεί στις ρυθμίσεις Keepiq του.", + "Off": "Ανενεργό", + "Officer {user} has no encryption set up yet.": "Ο υπεύθυνος {user} δεν έχει ρυθμίσει ακόμη κρυπτογράφηση.", + "Officers (user IDs, separated by commas)": "Υπεύθυνοι (ID χρηστών, χωρισμένα με κόμμα)", + "Policy": "Πολιτική", + "Publish this fingerprint internally, so users can check it before they enrol.": "Δημοσιεύστε αυτό το αποτύπωμα εσωτερικά, ώστε οι χρήστες να το ελέγχουν πριν εγγραφούν.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Ανακτήθηκε με τη βοήθεια του/της {officer}. Αλλάξτε τώρα το κλειδί του θησαυροφυλακίου στις Ρυθμίσεις, Ασφάλεια: \"Ο κύριος κωδικός μου διέρρευσε\".", + "Recovery key fingerprint: {fingerprint}": "Αποτύπωμα κλειδιού ανάκτησης: {fingerprint}", + "Recovery officer": "Υπεύθυνος ανάκτησης", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Οι υπεύθυνοι που αφαιρέθηκαν χάνουν τώρα το αντίγραφό τους, αλλά ίσως το άνοιξαν νωρίτερα. Ζητήστε από έναν υπεύθυνο να δημιουργήσει νέο κλειδί ανάκτησης.", + "Repeat the new master password": "Επαναλάβετε τον νέο κύριο κωδικό", + "Retire this recovery key": "Απόσυρση αυτού του κλειδιού ανάκτησης", + "Set the new master password": "Ορισμός νέου κύριου κωδικού", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Το πιστοποιητικό ανάκτησης δεν εκδόθηκε από αυτό το Keepiq. Μην εγγραφείτε και ενημερώστε τον διαχειριστή σας.", + "The words match, approve": "Οι λέξεις ταιριάζουν, έγκριση", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Αυτός ο χρήστης είναι εγγεγραμμένος στην ανάκτηση λογαριασμού. Η ανάκτηση διατηρεί τα μυστικά του· η ανάκληση διαγράφει την εγγραφή του.", + "Users may enrol": "Οι χρήστες μπορούν να εγγραφούν", + "Withdraw from account recovery": "Αποχώρηση από την ανάκτηση λογαριασμού", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Είστε εγγεγραμμένοι στην ανάκτηση λογαριασμού. Αποτύπωμα κλειδιού ανάκτησης: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Είστε εγγεγραμμένοι. Αν ξεχάσετε τον κύριο κωδικό, ο οργανισμός σας μπορεί να σας βοηθήσει να ανακτήσετε το θησαυροφυλάκιο.", + "Your key is back. Choose a new master password.": "Το κλειδί σας επέστρεψε. Επιλέξτε νέο κύριο κωδικό.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Οι υπεύθυνοι ανάκτησης ενημερώθηκαν. Διαβάστε τους αυτές τις λέξεις όταν σας καλέσουν ή σας συναντήσουν:", + "You are now an account recovery officer": "Είστε πλέον υπεύθυνος ανάκτησης λογαριασμών", + "%s asks to recover their account. Compare the words with them before you approve.": "Ο/Η %s ζητά ανάκτηση του λογαριασμού. Συγκρίνετε τις λέξεις μαζί του πριν εγκρίνετε.", + "A user": "Ένας χρήστης", + "Your account recovery request was declined": "Το αίτημα ανάκτησης λογαριασμού απορρίφθηκε", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Η ανάκτηση του λογαριασμού σας είναι έτοιμη. Ανοίξτε το Keepiq στο πρόγραμμα περιήγησης από το οποίο την ζητήσατε.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ζητά εφάπαξ ξεκλείδωμα μιας νέας συσκευής. Ο κύριος κωδικός πρόσβασης παραμένει ίδιος.", + "Ask your organisation instead": "Ρωτήστε καλύτερα τον οργανισμό σας", + "The request ended. Ask again or use your master password.": "Το αίτημα έληξε. Ζητήστε ξανά ή χρησιμοποιήστε τον κύριο κωδικό πρόσβασης.", + "Added by {user}": "Προστέθηκε από {user}", + "Editor": "Συντάκτης", + "Manager": "Διαχειριστής", + "Role of {member}": "Ρόλος του {member}", + "Team folders you manage": "Ομαδικοί φάκελοι που διαχειρίζεστε", + "Viewer": "Θεατής", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Δεν έχετε αντίγραφο αυτών των μυστικών, οπότε τα νέα μέλη δεν τα έχουν λάβει ακόμα. Ο κάτοχος μπορεί να τα κοινοποιήσει: {names}", + "Admin areas": "Περιοχές διαχείρισης", + "Give a group only the parts of Keepiq administration it needs.": "Δώστε σε μια ομάδα μόνο τα μέρη της διαχείρισης του Keepiq που χρειάζεται.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Αναθέστε μία ή περισσότερες περιοχές σε μια ομάδα στη σελίδα δικαιωμάτων διαχείρισης. Οι διαχειριστές της εγκατάστασης έχουν κάθε περιοχή.", + "Open administration privileges": "Άνοιγμα δικαιωμάτων διαχείρισης", + "Policies": "Πολιτικές", + "Applications and machine access": "Εφαρμογές και πρόσβαση μηχανών", + "People and offboarding": "Άτομα και αποχωρήσεις", + "Audit and compliance": "Έλεγχος και συμμόρφωση", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "έκδοση, αρχή πιστοποίησης, συνημμένα, κρυφή μνήμη εκτός σύνδεσης, έλεγχος διαρροών, τύποι μυστικών και αντίγραφα ασφαλείας", + "master password, organisation password, vault policies, rotation, version history and trash": "κύριος κωδικός, κωδικός οργανισμού, πολιτικές θησαυροφυλακίου, εναλλαγή, ιστορικό εκδόσεων και κάδος", + "application queue, application requests and machine leases": "ουρά εφαρμογών, αιτήματα εφαρμογών και μισθώσεις μηχανών", + "team offboarding, encryption suites and admin handover": "αποχωρήσεις από την ομάδα, σουίτες κρυπτογράφησης και ανάληψη από διαχειριστή", + "audit log, compliance reports, SIEM export and honey alerts": "αρχείο ελέγχου, αναφορές συμμόρφωσης, εξαγωγή SIEM και ειδοποιήσεις δολωμάτων", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Πόσες εκδόσεις ενός μυστικού διατηρούνται, για πόσο, και για πόσο μένουν στον κάδο τα διαγραμμένα μυστικά.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Όρια για κρυπτογραφημένα συνημμένα, που επιβάλλει ο διακομιστής στα αποθηκευμένα κρυπτογραφημένα byte.", + "Type the suite ID again to confirm": "Πληκτρολογήστε ξανά το αναγνωριστικό σουίτας για επιβεβαίωση", + "This does not match the suite ID.": "Δεν ταιριάζει με το αναγνωριστικό σουίτας.", + "Confirm with your master password": "Επιβεβαίωση με τον κύριο κωδικό πρόσβασης", + "Confirm": "Επιβεβαίωση", + "That master password is not right.": "Αυτός ο κύριος κωδικός πρόσβασης δεν είναι σωστός.", + "You are sharing with someone new. Enter your master password to confirm.": "Μοιράζεστε με κάποιο νέο άτομο. Εισαγάγετε τον κύριο κωδικό πρόσβασης για επιβεβαίωση.", + "Enter your master password to confirm this share.": "Εισαγάγετε τον κύριο κωδικό πρόσβασης για να επιβεβαιώσετε αυτή την κοινή χρήση.", + "Enter your master password to confirm this delegation.": "Εισαγάγετε τον κύριο κωδικό πρόσβασης για να επιβεβαιώσετε αυτή την ανάθεση.", + "Approve {member}": "Έγκριση {member}", + "Recipient": "Παραλήπτης", + "No vault yet": "Δεν έχει ακόμη θησαυροφυλάκιο", + "No matching users": "Δεν βρέθηκαν χρήστες", + "Partner organisations": "Συνεργαζόμενοι οργανισμοί", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Ανταλλάξτε μυστικά με άλλο Keepiq. Και οι δύο διαχειριστές προσθέτουν ο ένας τον άλλον και συγκρίνουν τα ριζικά αποτυπώματα τηλεφωνικά ή αυτοπροσώπως πριν την αποθήκευση.", + "Federation needs Nextcloud 33 or later.": "Η ομοσπονδία απαιτεί Nextcloud 33 ή νεότερο.", + "Your root fingerprint": "Το ριζικό σας αποτύπωμα", + "No partners yet.": "Δεν υπάρχουν ακόμη συνεργάτες.", + "Users here may share to this partner": "Οι χρήστες εδώ μπορούν να μοιράζονται με αυτόν τον συνεργάτη", + "This partner may share to users here": "Αυτός ο συνεργάτης μπορεί να μοιράζεται με τους χρήστες εδώ", + "Partner address": "Διεύθυνση συνεργάτη", + "Check partner": "Έλεγχος συνεργάτη", + "Partner root fingerprint": "Ριζικό αποτύπωμα συνεργάτη", + "I compared this fingerprint with the partner's administrator": "Σύγκρινα αυτό το αποτύπωμα με τον διαχειριστή του συνεργάτη", + "Add partner": "Προσθήκη συνεργάτη", + "A secret from another organisation": "Ένα μυστικό από άλλον οργανισμό", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Ο χρήστης %1$s κοινοποίησε το \"%2$s\" σε εσάς. Αποδεχτείτε το στα Εισερχόμενα από άλλους οργανισμούς.", + "Incoming from other organisations": "Εισερχόμενα από άλλους οργανισμούς", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Άτομα σε συνεργαζόμενους οργανισμούς μπορούν να κοινοποιήσουν ένα μυστικό σε εσάς. Αποδεχτείτε το για να κρατήσετε ένα αντίγραφο μόνο για ανάγνωση στο θησαυροφυλάκιό σας.", + "Nothing shared with you yet": "Δεν έχει κοινοποιηθεί τίποτα σε εσάς ακόμα", + "Secrets that people in partner organisations share with you appear here.": "Τα μυστικά που σας κοινοποιούν άτομα σε συνεργαζόμενους οργανισμούς εμφανίζονται εδώ.", + "From {sender}": "Από {sender}", + "Accept": "Αποδοχή", + "Open in vault": "Άνοιγμα στο θησαυροφυλάκιο", + "The other organisation did not hand over the secret. Try again later.": "Ο άλλος οργανισμός δεν παρέδωσε το μυστικό. Δοκιμάστε ξανά αργότερα.", + "Set up your vault before you accept a shared secret.": "Ρυθμίστε το θησαυροφυλάκιό σας πριν αποδεχτείτε ένα κοινόχρηστο μυστικό.", + "Something went wrong. Try again.": "Κάτι πήγε στραβά. Δοκιμάστε ξανά.", + "Waiting for your answer": "Αναμένει την απάντησή σας", + "In your vault, read-only": "Στο θησαυροφυλάκιό σας, μόνο για ανάγνωση", + "Withdrawn by the sender": "Αποσύρθηκε από τον αποστολέα", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Ο χρήστης {sender} το κοινοποίησε από άλλον οργανισμό. Μπορείτε να το διαβάσετε, αλλά όχι να το αλλάξετε ή να το κοινοποιήσετε.", + "Someone": "Κάποιος", + "Share with someone at another organisation": "Κοινοποίηση σε κάποιον από άλλον οργανισμό", + "Their account at the other organisation": "Ο λογαριασμός του ατόμου στον άλλον οργανισμό", + "Check account": "Έλεγχος λογαριασμού", + "Certificate fingerprint of {account}": "Αποτύπωμα πιστοποιητικού του λογαριασμού {account}", + "Compare it with them by phone if you want to be sure.": "Συγκρίνετέ το με το άτομο τηλεφωνικά, αν θέλετε να είστε σίγουροι.", + "Shared. {account} can accept it in their own vault.": "Κοινοποιήθηκε. Ο λογαριασμός {account} μπορεί να το αποδεχτεί στο δικό του θησαυροφυλάκιο.", + "The certificate could not be verified. Nothing was shared.": "Δεν ήταν δυνατή η επαλήθευση του πιστοποιητικού. Δεν κοινοποιήθηκε τίποτα.", + "That organisation is not one of your partners.": "Αυτός ο οργανισμός δεν είναι ένας από τους συνεργάτες σας.", + "No one with that account can receive secrets from you.": "Κανείς με αυτόν τον λογαριασμό δεν μπορεί να λάβει μυστικά από εσάς.", + "The other organisation did not answer. Try again later.": "Ο άλλος οργανισμός δεν απάντησε. Δοκιμάστε ξανά αργότερα.", + "This secret is already shared with that account.": "Αυτό το μυστικό έχει ήδη κοινοποιηθεί σε αυτόν τον λογαριασμό.", + "Other organisations": "Άλλοι οργανισμοί", + "Receive secrets from other organisations": "Λήψη μυστικών από άλλους οργανισμούς", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Τα άτομα σε συνεργαζόμενους οργανισμούς μπορούν τότε να βρουν τον λογαριασμό σας και να κοινοποιούν μυστικά σε εσάς. Αποδέχεστε κάθε ένα μόνοι σας.", + "Shared": "Κοινοποιήθηκε", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Σε παύση: άλλαξε το πιστοποιητικό του παραλήπτη ή η συνεργασία. Κάντε ανάκληση ή κοινοποιήστε ξανά.", + "Their organisation did not get the last change. Revoke it or share again.": "Ο οργανισμός του παραλήπτη δεν έλαβε την τελευταία αλλαγή. Κάντε ανάκληση ή κοινοποιήστε ξανά.", + "Being withdrawn": "Ανακαλείται", + "Shared with another organisation": "Κοινοποιήθηκε σε άλλον οργανισμό", + "Change sent to another organisation": "Η αλλαγή στάλθηκε σε άλλον οργανισμό", + "Share with another organisation revoked": "Η κοινοποίηση σε άλλον οργανισμό ανακλήθηκε", + "Share with another organisation paused": "Η κοινοποίηση σε άλλον οργανισμό τέθηκε σε παύση", + "Another organisation did not get a change": "Άλλος οργανισμός δεν έλαβε μια αλλαγή", + "Secret received from another organisation": "Ελήφθη μυστικό από άλλον οργανισμό", + "Secret from another organisation accepted": "Το μυστικό από άλλον οργανισμό έγινε αποδεκτό", + "Secret from another organisation declined": "Το μυστικό από άλλον οργανισμό απορρίφθηκε", + "Copy from another organisation updated": "Το αντίγραφο από άλλον οργανισμό ενημερώθηκε", + "Copy from another organisation removed": "Το αντίγραφο από άλλον οργανισμό αφαιρέθηκε", + "Declined: they removed their copy. Share again if they need it.": "Απορρίφθηκε: ο παραλήπτης αφαίρεσε το αντίγραφό του. Κοινοποιήστε ξανά αν το χρειάζεται.", + "Recipient at another organisation removed their copy": "Παραλήπτης σε άλλον οργανισμό αφαίρεσε το αντίγραφό του", + "Removed the user from %n team folder.": "Ο χρήστης αφαιρέθηκε από %n φάκελο ομάδας.", + "Removed the user from %n team folders.": "Ο χρήστης αφαιρέθηκε από %n φακέλους ομάδας.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Ο χρήστης αφαιρέθηκε από %n φάκελο ομάδας.", + "Ο χρήστης αφαιρέθηκε από %n φακέλους ομάδας." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Ένα αντίγραφο που επαναφέρθηκε προέρχεται από κοινοποίηση που έληξε. Παραμένει μόνο για ανάγνωση.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Ο οργανισμός που κοινοποίησε ένα αντίγραφο που επαναφέρθηκε δεν ήταν προσβάσιμος. Το αντίγραφο παραμένει μόνο για ανάγνωση και δεν ακολουθεί τις αλλαγές τους.", + "Recipient at another organisation restored their copy": "Παραλήπτης σε άλλον οργανισμό επανέφερε το αντίγραφό του" }, "plurals": null } diff --git a/l10n/en.js b/l10n/en.js index 0d86028c6..d91617406 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Shared with groups", + "Not shared with any group yet.": "Not shared with any group yet.", + "Revoke the share with {group}": "Revoke the share with {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.", + "Search groups": "Search groups", + "Failed to share": "Failed to share", + "Columns": "Columns", + "Column {number}": "Column {number}", + "Map one column to Name. Every secret needs a name.": "Map one column to Name. Every secret needs a name.", + "Notes": "Notes", + "Do not import": "Do not import", + "Hide this value": "Hide this value", + "Show this value": "Show this value", + "Defaults": "Defaults", + "New secrets start as this type, and your secret list opens in this view.": "New secrets start as this type, and your secret list opens in this view.", + "Default item type": "Default item type", + "Cards": "Cards", + "Table": "Table", + "Could not save your default": "Could not save your default", + "Recently used": "Recently used", + "Opened": "Opened", + "You have not opened any secrets yet": "You have not opened any secrets yet", + "Could not delete the item type.": "Could not delete the item type.", + "Could not load the item types.": "Could not load the item types.", + "Could not save the item type.": "Could not save the item type.", + "Delete item type": "Delete item type", + "Edit item type": "Edit item type", + "Fields": "Fields", + "Fields: {count}": "Fields: {count}", + "Hidden": "Hidden", + "Item types": "Item types", + "Move up": "Move up", + "New item type": "New item type", + "No item types defined yet.": "No item types defined yet.", + "Required": "Required", + "Text": "Text", + "This field is required": "This field is required", + "Web address": "Web address", + "{label} (required)": "{label} (required)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Delete “{name}”? Secrets of this type stay readable and become Login items.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.", + "Secret moved to the trash": "Secret moved to the trash", + "Secret restored from the trash": "Secret restored from the trash", + "Secret deleted for good": "Secret deleted for good", + "Secret archived": "Secret archived", + "Secret unarchived": "Secret unarchived", + "Unarchive": "Unarchive", + "Could not archive the secret": "Could not archive the secret", + "Could not unarchive the secret": "Could not unarchive the secret", + "Archive {count} secrets": "Archive {count} secrets", + "Unarchive {count} secrets": "Unarchive {count} secrets", + "Restore {count} secrets": "Restore {count} secrets", + "Delete {count} secrets for good": "Delete {count} secrets for good", + "Done for {ok} of {total} secrets": "Done for {ok} of {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.", + "These secrets come back to the vault list, search and autofill.": "These secrets come back to the vault list, search and autofill.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "This deletes the secrets with their attachments and version history. This cannot be undone.", + "Delete for good": "Delete for good", + "Trash": "Trash", + "The trash is empty": "The trash is empty", + "No archived secrets": "No archived secrets", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Deleted secrets wait here until the retention period ends, then they are deleted for good.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.", + "Days a deleted secret stays in the trash (1 to 365)": "Days a deleted secret stays in the trash (1 to 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.", + "Remove {name} from favourites": "Remove {name} from favourites", + "Add {name} to favourites": "Add {name} to favourites", + "Could not change the favourite": "Could not change the favourite", + "Remove from favourites": "Remove from favourites", + "Add to favourites": "Add to favourites", + "Tags": "Tags", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tags are not encrypted. Server administrators can read them, as they can folder names.", + "Favourites": "Favourites", + "Filter by tag": "Filter by tag", + "All tags": "All tags", + "Last used": "Last used", + "Tags for {count} secrets": "Tags for {count} secrets", + "Tag": "Tag", + "Remove tag": "Remove tag", + "Add tag": "Add tag", + "Could not change the tags. Try again.": "Could not change the tags. Try again.", + "Could not approve the application. It is still in the queue.": "Could not approve the application. It is still in the queue.", + "Could not reject the application. It is still in the queue.": "Could not reject the application. It is still in the queue.", + "Removed the user from {count} team folders.": "Removed the user from {count} team folders.", + "Approve a share": "Approve a share", + "This approval link is incomplete. Open it again from the notification.": "This approval link is incomplete. Open it again from the notification.", + "Deny": "Deny", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} joined a group you share a secret with. Share the secret with them too?", + "{requester} asks you to share a secret with {user}.": "{requester} asks you to share a secret with {user}.", + "Shared. The recipient can now open the secret.": "Shared. The recipient can now open the secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.", + "Could not share the secret. Only its owner can approve this.": "Could not share the secret. Only its owner can approve this.", + "Could not share the secret. Try again.": "Could not share the secret. Try again.", + "Denied. Nothing was shared.": "Denied. Nothing was shared.", + "Could not deny the request. Try again.": "Could not deny the request. Try again.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s asks you to share the secret \"%2$s\" with %3$s.", + "Expires on (optional)": "Expires on (optional)", + "Hand over to": "Hand over to", + "Choose a recipient": "Choose a recipient", + "Hand over temporarily": "Hand over temporarily", + "Expiry rules": "Expiry rules", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.", + "Delete rule": "Delete rule", + "Set by your administrator": "Set by your administrator", + "No expiry rules yet.": "No expiry rules yet.", + "Applies to": "Applies to", + "Item type": "Item type", + "Maximum age in days (empty for reminders only)": "Maximum age in days (empty for reminders only)", + "Remind me this many days before, comma separated": "Remind me this many days before, comma separated", + "Save rule": "Save rule", + "An item type": "An item type", + "A folder": "A folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Expires after {days} days", + "Reminders {days} days before": "Reminders {days} days before", + "Could not save the expiry rule.": "Could not save the expiry rule.", + "Could not delete the expiry rule.": "Could not delete the expiry rule.", + "All statuses": "All statuses", + "Compromised": "Compromised", + "Could not load the members.": "Could not load the members.", + "Emergency contact": "Emergency contact", + "Leaving user": "Leaving user", + "No": "No", + "No users match this filter.": "No users match this filter.", + "Not set up": "Not set up", + "Revoke suite": "Revoke suite", + "Revoked": "Revoked", + "Search users": "Search users", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "See which users have set up a vault. Start offboarding or revoke a suite from a row.", + "Successor": "Successor", + "Team folders": "Team folders", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.", + "Vault status": "Vault status", + "Yes": "Yes", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.", + "Root certificate expiring soon": "Root certificate expiring soon", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.", + "Compromise recovery aborted": "Compromise recovery aborted", + "Key rotation ended by a compromise revoke": "Key rotation ended by a compromise revoke", + "Encryption suite revoke refused": "Encryption suite revoke refused", + "Master password proof refused": "Master password proof refused", + "Your current master password": "Your current master password", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.", + "Renew root certificate": "Renew root certificate", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.", + "Renew root": "Renew root", + "Root renewed. {n} encryption suites signed again.": "Root renewed. {n} encryption suites signed again.", + "Could not renew the root certificate.": "Could not renew the root certificate.", + "Lease policy for this application": "Lease policy for this application", + "In force now: {default} seconds by default, {max} seconds at most.": "In force now: {default} seconds by default, {max} seconds at most.", + "Leases are not renewable": "Leases are not renewable", + "Lease policy saved.": "Lease policy saved.", + "Leave a field empty to use the instance value.": "Leave a field empty to use the instance value.", + "Instance value: {value}": "Instance value: {value}", + "Renewal": "Renewal", + "Use the instance value ({value})": "Use the instance value ({value})", + "Allowed": "Allowed", + "Not allowed": "Not allowed", + "Save lease policy": "Save lease policy", + "Only an administrator can change this policy.": "Only an administrator can change this policy.", + "Could not save the lease policy.": "Could not save the lease policy.", + "{member} got access from {confirmer}.": "{member} got access from {confirmer}.", + "Automatically confirm new team folder members": "Automatically confirm new team folder members", + "Gave %n new member access to a team folder.": "Gave %n new member access to a team folder.", + "Gave %n new members access to a team folder.": "Gave %n new members access to a team folder.", + "Give new team folder members access without waiting for the folder owner.": "Give new team folder members access without waiting for the folder owner.", + "New team folder members": "New team folder members", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Waiting for a member with write access to open Keepiq. You can also share now.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.", + "This also revoked suite {suite} and ended key migration {migration}.": "This also revoked suite {suite} and ended key migration {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revoking the second suite deleted %n emergency-access contact.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revoking the second suite deleted %n emergency-access contacts.", + "A suite revoked as compromised cannot be reinstated.": "A suite revoked as compromised cannot be reinstated.", + "Archives to keep": "Archives to keep", + "Back up every vault automatically": "Back up every vault automatically", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.", + "Back up now": "Back up now", + "Backup public key (PEM, optional)": "Backup public key (PEM, optional)", + "Backup requested for the next cron run": "Backup requested for the next cron run", + "Encrypted": "Encrypted", + "Every (hours)": "Every (hours)", + "Last backup {when} failed: {error}": "Last backup {when} failed: {error}", + "Last backup {when} succeeded.": "Last backup {when} succeeded.", + "No archives yet.": "No archives yet.", + "Size": "Size", + "Vault backups": "Vault backups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.", + "Written": "Written", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n user in scope has no two-factor login yet and cannot open the vault while this is on.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n users in scope have no two-factor login yet and cannot open the vault while this is on.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.", + "Block personal vault export": "Block personal vault export", + "Keep work logins in team folders": "Keep work logins in team folders", + "Move to a team folder": "Move to a team folder", + "Not in a team folder": "Not in a team folder", + "Only for these groups (empty is everyone)": "Only for these groups (empty is everyone)", + "Require two-factor login before the vault opens": "Require two-factor login before the vault opens", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rules for every vault. Each applies to everyone, or only to the groups you choose.", + "Secret types that belong in a team folder": "Secret types that belong in a team folder", + "Set up two-factor login": "Set up two-factor login", + "Team folder you can write to": "Team folder you can write to", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.", + "Users cannot save these secret types in a personal folder.": "Users cannot save these secret types in a personal folder.", + "Vault policies": "Vault policies", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Your organisation keeps these secrets in a team folder. Move each one into a team folder.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.", + "Your organisation requires two-factor login before you can open your vault.": "Your organisation requires two-factor login before you can open your vault.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.", + "Longest idle time before the extension locks": "Longest idle time before the extension locks", + "1 minute": "1 minute", + "5 minutes": "5 minutes", + "15 minutes": "15 minutes", + "1 hour": "1 hour", + "4 hours": "4 hours", + "Connector": "Connector", + "Directory (tenant) ID": "Directory (tenant) ID", + "Application (client) ID": "Application (client) ID", + "Data collection rule immutable ID": "Data collection rule immutable ID", + "Stream name": "Stream name", + "Splunk index (optional)": "Splunk index (optional)", + "Sourcetype (optional)": "Sourcetype (optional)", + "Leave blank to keep the current one": "Leave blank to keep the current one", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Data collection endpoint (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Client secret (write-only)", + "HEC token (write-only)": "HEC token (write-only)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.", + "%n change waiting to sync": "%n change waiting to sync", + "%n changes waiting to sync": "%n changes waiting to sync", + "Changes that could not sync": "Changes that could not sync", + "Choose a version": "Choose a version", + "Copy value": "Copy value", + "Deleted": "Deleted", + "Discard": "Discard", + "Keep my offline change": "Keep my offline change", + "Keep the server version": "Keep the server version", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq is read-only offline. Your administrator has not turned on offline edits.", + "Let users edit secrets offline": "Let users edit secrets offline", + "Not synced yet": "Not synced yet", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.", + "Open my changes": "Open my changes", + "Sharing needs a connection": "Sharing needs a connection", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.", + "Sync or discard your offline changes before you rotate your keys.": "Sync or discard your offline changes before you rotate your keys.", + "That password did not open your changes.": "That password did not open your changes.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.", + "The previous vault copy is gone, so these changes cannot be opened.": "The previous vault copy is gone, so these changes cannot be opened.", + "The server version": "The server version", + "This secret changed while you were offline": "This secret changed while you were offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.", + "Your offline change": "Your offline change", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.","%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n item cannot be represented in CXF and will be skipped.","%n items cannot be represented in CXF and will be skipped."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n older version was dropped because only recent history can be carried across.","%n older versions were dropped because only recent history can be carried across."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n secret copy still needs to be encrypted and shared.","%n secret copies still need to be encrypted and shared."], + "_%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 secret could not be decrypted and is not in this export.","%n secrets could not be decrypted and are not in this export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n secret could not be decrypted with your old key, so it did not migrate.","%n secrets could not be decrypted with your old key, so they did not migrate."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n secret did not migrate.","%n secrets did not migrate."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n secret is still encrypted under your previous key.","%n secrets are still encrypted under your previous key."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run.","%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run."], + "_%n secret_::_%n secrets_": ["%n secret","%n secrets"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n user in scope has no two-factor login yet and cannot open the vault while this is on.","%n users in scope have no two-factor login yet and cannot open the vault while this is on."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Finish anyway, losing access to %n secret","Finish anyway, losing access to %n secrets"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["Gave %n new member access to a team folder.","Gave %n new members access to a team folder."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Key rotation finished. %n secret was re-encrypted under your new key.","Key rotation finished. %n secrets were re-encrypted under your new key."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revoking the second suite deleted %n emergency-access contact.","Revoking the second suite deleted %n emergency-access contacts."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revoking this suite deleted %n emergency-access contact.","Revoking this suite deleted %n emergency-access contacts."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["seen %n time in breaches","seen %n times in breaches"], + "_shared with %n secret_::_shared with %n secrets_": ["shared with %n secret","shared with %n secrets"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["This folder contains %n secret directly.","This folder contains %n secrets directly."], + "_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 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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n change waiting to sync","%n changes waiting to sync"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.","The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account."], + "Allow approval from another device": "Allow approval from another device", + "App": "App", + "Approve a new device": "Approve a new device", + "Approve from another device": "Approve from another device", + "Asked at": "Asked at", + "Check that the new device shows these words:": "Check that the new device shows these words:", + "Denied. If you did not ask, end your other sessions:": "Denied. If you did not ask, end your other sessions:", + "Device": "Device", + "IP address": "IP address", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.", + "New device approval": "New device approval", + "Nextcloud security settings": "Nextcloud security settings", + "Only approve a device you are using right now.": "Only approve a device you are using right now.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.", + "The master password is not right, or the request has ended.": "The master password is not right, or the request has ended.", + "The request expired. Ask again or use your master password.": "The request expired. Ask again or use your master password.", + "The request was denied.": "The request was denied.", + "Too many requests. Try again in an hour or use your master password.": "Too many requests. Try again in an hour or use your master password.", + "Unknown device": "Unknown device", + "Web app": "Web app", + "A device": "A device", + "A new device asks to open your vault": "A new device asks to open your vault", + "%s asks to be approved. Only approve a device you are using right now.": "%s asks to be approved. Only approve a device you are using right now.", + "Access ends on (optional)": "Access ends on (optional)", + "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.": "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.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "This secret is use-only. Sign in through the Keepiq browser extension.", + "Until {date}": "Until {date}", + "Use only": "Use only", + "Use only (can sign in, cannot view or copy)": "Use only (can sign in, cannot view or copy)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.", + "Your access ends on {date}": "Your access ends on {date}", + "Your access to this secret has ended": "Your access to this secret has ended", + "Your access to \"%s\" ends tomorrow": "Your access to \"%s\" ends tomorrow", + "Your access to \"%s\" has ended": "Your access to \"%s\" has ended", + "%1$s no longer has access to \"%2$s\"": "%1$s no longer has access to \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s could see this password. Rotate it if %1$s should no longer know it.", + "%s could not view this password in Keepiq.": "%s could not view this password in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} of {threshold} approvals", + "a recovery officer": "a recovery officer", + "Account recovery": "Account recovery", + "Approvals needed": "Approvals needed", + "Ask {user} which words they see, by phone or in person. They must be:": "Ask {user} which words they see, by phone or in person. They must be:", + "Check again": "Check again", + "Create the recovery key": "Create the recovery key", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.", + "Decline": "Decline", + "Enrol in account recovery": "Enrol in account recovery", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Enrol so your organisation can help you get your vault back if you forget your master password.", + "Every user is enrolled": "Every user is enrolled", + "Finish the recovery in the browser you asked from.": "Finish the recovery in the browser you asked from.", + "Forgot your master password?": "Forgot your master password?", + "Hand the key over": "Hand the key over", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Let users who forgot their master password get their vault back, approved by recovery officers you name.", + "New master password": "New master password", + "No one is asking to recover their account.": "No one is asking to recover their account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "No recovery key yet. One of the officers creates it in their Keepiq settings.", + "Off": "Off", + "Officer {user} has no encryption set up yet.": "Officer {user} has no encryption set up yet.", + "Officers (user IDs, separated by commas)": "Officers (user IDs, separated by commas)", + "Policy": "Policy", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publish this fingerprint internally, so users can check it before they enrol.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".", + "Recovery key fingerprint: {fingerprint}": "Recovery key fingerprint: {fingerprint}", + "Recovery officer": "Recovery officer", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.", + "Repeat the new master password": "Repeat the new master password", + "Retire this recovery key": "Retire this recovery key", + "Set the new master password": "Set the new master password", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.", + "The words match, approve": "The words match, approve", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.", + "Users may enrol": "Users may enrol", + "Withdraw from account recovery": "Withdraw from account recovery", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "You are enrolled. If you forget your master password, your organisation can help you get your vault back.", + "Your key is back. Choose a new master password.": "Your key is back. Choose a new master password.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Your recovery officers have been told. Read them these words when they call or meet you:", + "You are now an account recovery officer": "You are now an account recovery officer", + "%s asks to recover their account. Compare the words with them before you approve.": "%s asks to recover their account. Compare the words with them before you approve.", + "A user": "A user", + "Your account recovery request was declined": "Your account recovery request was declined", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Your account recovery is ready. Open Keepiq in the browser you asked from.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} asks to unlock a new device once. They keep their master password.", + "Ask your organisation instead": "Ask your organisation instead", + "The request ended. Ask again or use your master password.": "The request ended. Ask again or use your master password.", + "Added by {user}": "Added by {user}", + "Editor": "Editor", + "Manager": "Manager", + "Role of {member}": "Role of {member}", + "Team folders you manage": "Team folders you manage", + "Viewer": "Viewer", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}", + "Admin areas": "Admin areas", + "Give a group only the parts of Keepiq administration it needs.": "Give a group only the parts of Keepiq administration it needs.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.", + "Open administration privileges": "Open administration privileges", + "Policies": "Policies", + "Applications and machine access": "Applications and machine access", + "People and offboarding": "People and offboarding", + "Audit and compliance": "Audit and compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certificate authority, attachments, offline cache, breach check, secret types and backups", + "master password, organisation password, vault policies, rotation, version history and trash": "master password, organisation password, vault policies, rotation, version history and trash", + "application queue, application requests and machine leases": "application queue, application requests and machine leases", + "team offboarding, encryption suites and admin handover": "team offboarding, encryption suites and admin handover", + "audit log, compliance reports, SIEM export and honey alerts": "audit log, compliance reports, SIEM export and honey alerts", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.", + "Type the suite ID again to confirm": "Type the suite ID again to confirm", + "This does not match the suite ID.": "This does not match the suite ID.", + "Confirm with your master password": "Confirm with your master password", + "Confirm": "Confirm", + "That master password is not right.": "That master password is not right.", + "You are sharing with someone new. Enter your master password to confirm.": "You are sharing with someone new. Enter your master password to confirm.", + "Enter your master password to confirm this share.": "Enter your master password to confirm this share.", + "Enter your master password to confirm this delegation.": "Enter your master password to confirm this delegation.", + "Approve {member}": "Approve {member}", + "Recipient": "Recipient", + "No vault yet": "No vault yet", + "No matching users": "No matching users", + "Partner organisations": "Partner organisations", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.", + "Federation needs Nextcloud 33 or later.": "Federation needs Nextcloud 33 or later.", + "Your root fingerprint": "Your root fingerprint", + "No partners yet.": "No partners yet.", + "Users here may share to this partner": "Users here may share to this partner", + "This partner may share to users here": "This partner may share to users here", + "Partner address": "Partner address", + "Check partner": "Check partner", + "Partner root fingerprint": "Partner root fingerprint", + "I compared this fingerprint with the partner's administrator": "I compared this fingerprint with the partner's administrator", + "Add partner": "Add partner", + "A secret from another organisation": "A secret from another organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.", + "Incoming from other organisations": "Incoming from other organisations", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.", + "Nothing shared with you yet": "Nothing shared with you yet", + "Secrets that people in partner organisations share with you appear here.": "Secrets that people in partner organisations share with you appear here.", + "From {sender}": "From {sender}", + "Accept": "Accept", + "Open in vault": "Open in vault", + "The other organisation did not hand over the secret. Try again later.": "The other organisation did not hand over the secret. Try again later.", + "Set up your vault before you accept a shared secret.": "Set up your vault before you accept a shared secret.", + "Something went wrong. Try again.": "Something went wrong. Try again.", + "Waiting for your answer": "Waiting for your answer", + "In your vault, read-only": "In your vault, read-only", + "Withdrawn by the sender": "Withdrawn by the sender", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} shared this from another organisation. You can read it, but not change or share it.", + "Someone": "Someone", + "Share with someone at another organisation": "Share with someone at another organisation", + "Their account at the other organisation": "Their account at the other organisation", + "Check account": "Check account", + "Certificate fingerprint of {account}": "Certificate fingerprint of {account}", + "Compare it with them by phone if you want to be sure.": "Compare it with them by phone if you want to be sure.", + "Shared. {account} can accept it in their own vault.": "Shared. {account} can accept it in their own vault.", + "The certificate could not be verified. Nothing was shared.": "The certificate could not be verified. Nothing was shared.", + "That organisation is not one of your partners.": "That organisation is not one of your partners.", + "No one with that account can receive secrets from you.": "No one with that account can receive secrets from you.", + "The other organisation did not answer. Try again later.": "The other organisation did not answer. Try again later.", + "This secret is already shared with that account.": "This secret is already shared with that account.", + "Other organisations": "Other organisations", + "Receive secrets from other organisations": "Receive secrets from other organisations", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.", + "Shared": "Shared", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Paused: their certificate or the partnership changed. Revoke it or share again.", + "Their organisation did not get the last change. Revoke it or share again.": "Their organisation did not get the last change. Revoke it or share again.", + "Being withdrawn": "Being withdrawn", + "Shared with another organisation": "Shared with another organisation", + "Change sent to another organisation": "Change sent to another organisation", + "Share with another organisation revoked": "Share with another organisation revoked", + "Share with another organisation paused": "Share with another organisation paused", + "Another organisation did not get a change": "Another organisation did not get a change", + "Secret received from another organisation": "Secret received from another organisation", + "Secret from another organisation accepted": "Secret from another organisation accepted", + "Secret from another organisation declined": "Secret from another organisation declined", + "Copy from another organisation updated": "Copy from another organisation updated", + "Copy from another organisation removed": "Copy from another organisation removed", + "Declined: they removed their copy. Share again if they need it.": "Declined: they removed their copy. Share again if they need it.", + "Recipient at another organisation removed their copy": "Recipient at another organisation removed their copy", + "Removed the user from %n team folder.": "Removed the user from %n team folder.", + "Removed the user from %n team folders.": "Removed the user from %n team folders.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Removed the user from %n team folder.","Removed the user from %n team folders."], + "A restored copy came from a share that has ended. It stays read-only.": "A restored copy came from a share that has ended. It stays read-only.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.", + "Recipient at another organisation restored their copy": "Recipient at another organisation restored their copy" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 80c5bbbf7..9ad0a1cac 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Shared with groups", + "Not shared with any group yet.": "Not shared with any group yet.", + "Revoke the share with {group}": "Revoke the share with {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.", + "Search groups": "Search groups", + "Failed to share": "Failed to share", + "Columns": "Columns", + "Column {number}": "Column {number}", + "Map one column to Name. Every secret needs a name.": "Map one column to Name. Every secret needs a name.", + "Notes": "Notes", + "Do not import": "Do not import", + "Hide this value": "Hide this value", + "Show this value": "Show this value", + "Defaults": "Defaults", + "New secrets start as this type, and your secret list opens in this view.": "New secrets start as this type, and your secret list opens in this view.", + "Default item type": "Default item type", + "Cards": "Cards", + "Table": "Table", + "Could not save your default": "Could not save your default", + "Recently used": "Recently used", + "Opened": "Opened", + "You have not opened any secrets yet": "You have not opened any secrets yet", + "Could not delete the item type.": "Could not delete the item type.", + "Could not load the item types.": "Could not load the item types.", + "Could not save the item type.": "Could not save the item type.", + "Delete item type": "Delete item type", + "Edit item type": "Edit item type", + "Fields": "Fields", + "Fields: {count}": "Fields: {count}", + "Hidden": "Hidden", + "Item types": "Item types", + "Move up": "Move up", + "New item type": "New item type", + "No item types defined yet.": "No item types defined yet.", + "Required": "Required", + "Text": "Text", + "This field is required": "This field is required", + "Web address": "Web address", + "{label} (required)": "{label} (required)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Delete “{name}”? Secrets of this type stay readable and become Login items.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.", + "Secret moved to the trash": "Secret moved to the trash", + "Secret restored from the trash": "Secret restored from the trash", + "Secret deleted for good": "Secret deleted for good", + "Secret archived": "Secret archived", + "Secret unarchived": "Secret unarchived", + "Unarchive": "Unarchive", + "Could not archive the secret": "Could not archive the secret", + "Could not unarchive the secret": "Could not unarchive the secret", + "Archive {count} secrets": "Archive {count} secrets", + "Unarchive {count} secrets": "Unarchive {count} secrets", + "Restore {count} secrets": "Restore {count} secrets", + "Delete {count} secrets for good": "Delete {count} secrets for good", + "Done for {ok} of {total} secrets": "Done for {ok} of {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.", + "These secrets come back to the vault list, search and autofill.": "These secrets come back to the vault list, search and autofill.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "This deletes the secrets with their attachments and version history. This cannot be undone.", + "Delete for good": "Delete for good", + "Trash": "Trash", + "The trash is empty": "The trash is empty", + "No archived secrets": "No archived secrets", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Deleted secrets wait here until the retention period ends, then they are deleted for good.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.", + "Days a deleted secret stays in the trash (1 to 365)": "Days a deleted secret stays in the trash (1 to 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.", + "Remove {name} from favourites": "Remove {name} from favourites", + "Add {name} to favourites": "Add {name} to favourites", + "Could not change the favourite": "Could not change the favourite", + "Remove from favourites": "Remove from favourites", + "Add to favourites": "Add to favourites", + "Tags": "Tags", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tags are not encrypted. Server administrators can read them, as they can folder names.", + "Favourites": "Favourites", + "Filter by tag": "Filter by tag", + "All tags": "All tags", + "Last used": "Last used", + "Tags for {count} secrets": "Tags for {count} secrets", + "Tag": "Tag", + "Remove tag": "Remove tag", + "Add tag": "Add tag", + "Could not change the tags. Try again.": "Could not change the tags. Try again.", + "Could not approve the application. It is still in the queue.": "Could not approve the application. It is still in the queue.", + "Could not reject the application. It is still in the queue.": "Could not reject the application. It is still in the queue.", + "Removed the user from {count} team folders.": "Removed the user from {count} team folders.", + "Approve a share": "Approve a share", + "This approval link is incomplete. Open it again from the notification.": "This approval link is incomplete. Open it again from the notification.", + "Deny": "Deny", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} joined a group you share a secret with. Share the secret with them too?", + "{requester} asks you to share a secret with {user}.": "{requester} asks you to share a secret with {user}.", + "Shared. The recipient can now open the secret.": "Shared. The recipient can now open the secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.", + "Could not share the secret. Only its owner can approve this.": "Could not share the secret. Only its owner can approve this.", + "Could not share the secret. Try again.": "Could not share the secret. Try again.", + "Denied. Nothing was shared.": "Denied. Nothing was shared.", + "Could not deny the request. Try again.": "Could not deny the request. Try again.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s asks you to share the secret \"%2$s\" with %3$s.", + "Expires on (optional)": "Expires on (optional)", + "Hand over to": "Hand over to", + "Choose a recipient": "Choose a recipient", + "Hand over temporarily": "Hand over temporarily", + "Expiry rules": "Expiry rules", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.", + "Delete rule": "Delete rule", + "Set by your administrator": "Set by your administrator", + "No expiry rules yet.": "No expiry rules yet.", + "Applies to": "Applies to", + "Item type": "Item type", + "Maximum age in days (empty for reminders only)": "Maximum age in days (empty for reminders only)", + "Remind me this many days before, comma separated": "Remind me this many days before, comma separated", + "Save rule": "Save rule", + "An item type": "An item type", + "A folder": "A folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Expires after {days} days", + "Reminders {days} days before": "Reminders {days} days before", + "Could not save the expiry rule.": "Could not save the expiry rule.", + "Could not delete the expiry rule.": "Could not delete the expiry rule.", + "All statuses": "All statuses", + "Compromised": "Compromised", + "Could not load the members.": "Could not load the members.", + "Emergency contact": "Emergency contact", + "Leaving user": "Leaving user", + "No": "No", + "No users match this filter.": "No users match this filter.", + "Not set up": "Not set up", + "Revoke suite": "Revoke suite", + "Revoked": "Revoked", + "Search users": "Search users", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "See which users have set up a vault. Start offboarding or revoke a suite from a row.", + "Successor": "Successor", + "Team folders": "Team folders", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.", + "Vault status": "Vault status", + "Yes": "Yes", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.", + "Root certificate expiring soon": "Root certificate expiring soon", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.", + "Compromise recovery aborted": "Compromise recovery aborted", + "Key rotation ended by a compromise revoke": "Key rotation ended by a compromise revoke", + "Encryption suite revoke refused": "Encryption suite revoke refused", + "Master password proof refused": "Master password proof refused", + "Your current master password": "Your current master password", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.", + "Renew root certificate": "Renew root certificate", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.", + "Renew root": "Renew root", + "Root renewed. {n} encryption suites signed again.": "Root renewed. {n} encryption suites signed again.", + "Could not renew the root certificate.": "Could not renew the root certificate.", + "Lease policy for this application": "Lease policy for this application", + "In force now: {default} seconds by default, {max} seconds at most.": "In force now: {default} seconds by default, {max} seconds at most.", + "Leases are not renewable": "Leases are not renewable", + "Lease policy saved.": "Lease policy saved.", + "Leave a field empty to use the instance value.": "Leave a field empty to use the instance value.", + "Instance value: {value}": "Instance value: {value}", + "Renewal": "Renewal", + "Use the instance value ({value})": "Use the instance value ({value})", + "Allowed": "Allowed", + "Not allowed": "Not allowed", + "Save lease policy": "Save lease policy", + "Only an administrator can change this policy.": "Only an administrator can change this policy.", + "Could not save the lease policy.": "Could not save the lease policy.", + "{member} got access from {confirmer}.": "{member} got access from {confirmer}.", + "Automatically confirm new team folder members": "Automatically confirm new team folder members", + "Gave %n new member access to a team folder.": "Gave %n new member access to a team folder.", + "Gave %n new members access to a team folder.": "Gave %n new members access to a team folder.", + "Give new team folder members access without waiting for the folder owner.": "Give new team folder members access without waiting for the folder owner.", + "New team folder members": "New team folder members", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Waiting for a member with write access to open Keepiq. You can also share now.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.", + "This also revoked suite {suite} and ended key migration {migration}.": "This also revoked suite {suite} and ended key migration {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revoking the second suite deleted %n emergency-access contact.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revoking the second suite deleted %n emergency-access contacts.", + "A suite revoked as compromised cannot be reinstated.": "A suite revoked as compromised cannot be reinstated.", + "Archives to keep": "Archives to keep", + "Back up every vault automatically": "Back up every vault automatically", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.", + "Back up now": "Back up now", + "Backup public key (PEM, optional)": "Backup public key (PEM, optional)", + "Backup requested for the next cron run": "Backup requested for the next cron run", + "Encrypted": "Encrypted", + "Every (hours)": "Every (hours)", + "Last backup {when} failed: {error}": "Last backup {when} failed: {error}", + "Last backup {when} succeeded.": "Last backup {when} succeeded.", + "No archives yet.": "No archives yet.", + "Size": "Size", + "Vault backups": "Vault backups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.", + "Written": "Written", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n user in scope has no two-factor login yet and cannot open the vault while this is on.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n users in scope have no two-factor login yet and cannot open the vault while this is on.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.", + "Block personal vault export": "Block personal vault export", + "Keep work logins in team folders": "Keep work logins in team folders", + "Move to a team folder": "Move to a team folder", + "Not in a team folder": "Not in a team folder", + "Only for these groups (empty is everyone)": "Only for these groups (empty is everyone)", + "Require two-factor login before the vault opens": "Require two-factor login before the vault opens", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rules for every vault. Each applies to everyone, or only to the groups you choose.", + "Secret types that belong in a team folder": "Secret types that belong in a team folder", + "Set up two-factor login": "Set up two-factor login", + "Team folder you can write to": "Team folder you can write to", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.", + "Users cannot save these secret types in a personal folder.": "Users cannot save these secret types in a personal folder.", + "Vault policies": "Vault policies", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Your organisation keeps these secrets in a team folder. Move each one into a team folder.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.", + "Your organisation requires two-factor login before you can open your vault.": "Your organisation requires two-factor login before you can open your vault.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.", + "Longest idle time before the extension locks": "Longest idle time before the extension locks", + "1 minute": "1 minute", + "5 minutes": "5 minutes", + "15 minutes": "15 minutes", + "1 hour": "1 hour", + "4 hours": "4 hours", + "Connector": "Connector", + "Directory (tenant) ID": "Directory (tenant) ID", + "Application (client) ID": "Application (client) ID", + "Data collection rule immutable ID": "Data collection rule immutable ID", + "Stream name": "Stream name", + "Splunk index (optional)": "Splunk index (optional)", + "Sourcetype (optional)": "Sourcetype (optional)", + "Leave blank to keep the current one": "Leave blank to keep the current one", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Data collection endpoint (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Client secret (write-only)", + "HEC token (write-only)": "HEC token (write-only)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.", + "%n change waiting to sync": "%n change waiting to sync", + "%n changes waiting to sync": "%n changes waiting to sync", + "Changes that could not sync": "Changes that could not sync", + "Choose a version": "Choose a version", + "Copy value": "Copy value", + "Deleted": "Deleted", + "Discard": "Discard", + "Keep my offline change": "Keep my offline change", + "Keep the server version": "Keep the server version", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq is read-only offline. Your administrator has not turned on offline edits.", + "Let users edit secrets offline": "Let users edit secrets offline", + "Not synced yet": "Not synced yet", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.", + "Open my changes": "Open my changes", + "Sharing needs a connection": "Sharing needs a connection", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.", + "Sync or discard your offline changes before you rotate your keys.": "Sync or discard your offline changes before you rotate your keys.", + "That password did not open your changes.": "That password did not open your changes.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.", + "The previous vault copy is gone, so these changes cannot be opened.": "The previous vault copy is gone, so these changes cannot be opened.", + "The server version": "The server version", + "This secret changed while you were offline": "This secret changed while you were offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.", + "Your offline change": "Your offline change", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n item cannot be represented in CXF and will be skipped.", + "%n items cannot be represented in CXF and will be skipped." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n older version was dropped because only recent history can be carried across.", + "%n older versions were dropped because only recent history can be carried across." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n secret copy still needs to be encrypted and shared.", + "%n secret copies still need to be encrypted and shared." + ], + "_%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 secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n secret could not be decrypted with your old key, so it did not migrate.", + "%n secrets could not be decrypted with your old key, so they did not migrate." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n secret did not migrate.", + "%n secrets did not migrate." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n secret is still encrypted under your previous key.", + "%n secrets are still encrypted under your previous key." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run.", + "%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run." + ], + "_%n secret_::_%n secrets_": [ + "%n secret", + "%n secrets" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Finish anyway, losing access to %n secret", + "Finish anyway, losing access to %n secrets" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "Gave %n new member access to a team folder.", + "Gave %n new members access to a team folder." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Key rotation finished. %n secret was re-encrypted under your new key.", + "Key rotation finished. %n secrets were re-encrypted under your new key." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revoking the second suite deleted %n emergency-access contact.", + "Revoking the second suite deleted %n emergency-access contacts." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "seen %n time in breaches", + "seen %n times in breaches" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "shared with %n secret", + "shared with %n secrets" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "This folder contains %n secret directly.", + "This folder contains %n secrets directly." + ], + "_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 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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n change waiting to sync", + "%n changes waiting to sync" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account." + ], + "Allow approval from another device": "Allow approval from another device", + "App": "App", + "Approve a new device": "Approve a new device", + "Approve from another device": "Approve from another device", + "Asked at": "Asked at", + "Check that the new device shows these words:": "Check that the new device shows these words:", + "Denied. If you did not ask, end your other sessions:": "Denied. If you did not ask, end your other sessions:", + "Device": "Device", + "IP address": "IP address", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.", + "New device approval": "New device approval", + "Nextcloud security settings": "Nextcloud security settings", + "Only approve a device you are using right now.": "Only approve a device you are using right now.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.", + "The master password is not right, or the request has ended.": "The master password is not right, or the request has ended.", + "The request expired. Ask again or use your master password.": "The request expired. Ask again or use your master password.", + "The request was denied.": "The request was denied.", + "Too many requests. Try again in an hour or use your master password.": "Too many requests. Try again in an hour or use your master password.", + "Unknown device": "Unknown device", + "Web app": "Web app", + "A device": "A device", + "A new device asks to open your vault": "A new device asks to open your vault", + "%s asks to be approved. Only approve a device you are using right now.": "%s asks to be approved. Only approve a device you are using right now.", + "Access ends on (optional)": "Access ends on (optional)", + "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.": "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.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "This secret is use-only. Sign in through the Keepiq browser extension.", + "Until {date}": "Until {date}", + "Use only": "Use only", + "Use only (can sign in, cannot view or copy)": "Use only (can sign in, cannot view or copy)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.", + "Your access ends on {date}": "Your access ends on {date}", + "Your access to this secret has ended": "Your access to this secret has ended", + "Your access to \"%s\" ends tomorrow": "Your access to \"%s\" ends tomorrow", + "Your access to \"%s\" has ended": "Your access to \"%s\" has ended", + "%1$s no longer has access to \"%2$s\"": "%1$s no longer has access to \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s could see this password. Rotate it if %1$s should no longer know it.", + "%s could not view this password in Keepiq.": "%s could not view this password in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} of {threshold} approvals", + "a recovery officer": "a recovery officer", + "Account recovery": "Account recovery", + "Approvals needed": "Approvals needed", + "Ask {user} which words they see, by phone or in person. They must be:": "Ask {user} which words they see, by phone or in person. They must be:", + "Check again": "Check again", + "Create the recovery key": "Create the recovery key", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.", + "Decline": "Decline", + "Enrol in account recovery": "Enrol in account recovery", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Enrol so your organisation can help you get your vault back if you forget your master password.", + "Every user is enrolled": "Every user is enrolled", + "Finish the recovery in the browser you asked from.": "Finish the recovery in the browser you asked from.", + "Forgot your master password?": "Forgot your master password?", + "Hand the key over": "Hand the key over", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Let users who forgot their master password get their vault back, approved by recovery officers you name.", + "New master password": "New master password", + "No one is asking to recover their account.": "No one is asking to recover their account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "No recovery key yet. One of the officers creates it in their Keepiq settings.", + "Off": "Off", + "Officer {user} has no encryption set up yet.": "Officer {user} has no encryption set up yet.", + "Officers (user IDs, separated by commas)": "Officers (user IDs, separated by commas)", + "Policy": "Policy", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publish this fingerprint internally, so users can check it before they enrol.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".", + "Recovery key fingerprint: {fingerprint}": "Recovery key fingerprint: {fingerprint}", + "Recovery officer": "Recovery officer", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.", + "Repeat the new master password": "Repeat the new master password", + "Retire this recovery key": "Retire this recovery key", + "Set the new master password": "Set the new master password", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.", + "The words match, approve": "The words match, approve", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.", + "Users may enrol": "Users may enrol", + "Withdraw from account recovery": "Withdraw from account recovery", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "You are enrolled. If you forget your master password, your organisation can help you get your vault back.", + "Your key is back. Choose a new master password.": "Your key is back. Choose a new master password.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Your recovery officers have been told. Read them these words when they call or meet you:", + "You are now an account recovery officer": "You are now an account recovery officer", + "%s asks to recover their account. Compare the words with them before you approve.": "%s asks to recover their account. Compare the words with them before you approve.", + "A user": "A user", + "Your account recovery request was declined": "Your account recovery request was declined", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Your account recovery is ready. Open Keepiq in the browser you asked from.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} asks to unlock a new device once. They keep their master password.", + "Ask your organisation instead": "Ask your organisation instead", + "The request ended. Ask again or use your master password.": "The request ended. Ask again or use your master password.", + "Added by {user}": "Added by {user}", + "Editor": "Editor", + "Manager": "Manager", + "Role of {member}": "Role of {member}", + "Team folders you manage": "Team folders you manage", + "Viewer": "Viewer", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}", + "Admin areas": "Admin areas", + "Give a group only the parts of Keepiq administration it needs.": "Give a group only the parts of Keepiq administration it needs.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.", + "Open administration privileges": "Open administration privileges", + "Policies": "Policies", + "Applications and machine access": "Applications and machine access", + "People and offboarding": "People and offboarding", + "Audit and compliance": "Audit and compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certificate authority, attachments, offline cache, breach check, secret types and backups", + "master password, organisation password, vault policies, rotation, version history and trash": "master password, organisation password, vault policies, rotation, version history and trash", + "application queue, application requests and machine leases": "application queue, application requests and machine leases", + "team offboarding, encryption suites and admin handover": "team offboarding, encryption suites and admin handover", + "audit log, compliance reports, SIEM export and honey alerts": "audit log, compliance reports, SIEM export and honey alerts", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.", + "Type the suite ID again to confirm": "Type the suite ID again to confirm", + "This does not match the suite ID.": "This does not match the suite ID.", + "Confirm with your master password": "Confirm with your master password", + "Confirm": "Confirm", + "That master password is not right.": "That master password is not right.", + "You are sharing with someone new. Enter your master password to confirm.": "You are sharing with someone new. Enter your master password to confirm.", + "Enter your master password to confirm this share.": "Enter your master password to confirm this share.", + "Enter your master password to confirm this delegation.": "Enter your master password to confirm this delegation.", + "Approve {member}": "Approve {member}", + "Recipient": "Recipient", + "No vault yet": "No vault yet", + "No matching users": "No matching users", + "Partner organisations": "Partner organisations", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.", + "Federation needs Nextcloud 33 or later.": "Federation needs Nextcloud 33 or later.", + "Your root fingerprint": "Your root fingerprint", + "No partners yet.": "No partners yet.", + "Users here may share to this partner": "Users here may share to this partner", + "This partner may share to users here": "This partner may share to users here", + "Partner address": "Partner address", + "Check partner": "Check partner", + "Partner root fingerprint": "Partner root fingerprint", + "I compared this fingerprint with the partner's administrator": "I compared this fingerprint with the partner's administrator", + "Add partner": "Add partner", + "A secret from another organisation": "A secret from another organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.", + "Incoming from other organisations": "Incoming from other organisations", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.", + "Nothing shared with you yet": "Nothing shared with you yet", + "Secrets that people in partner organisations share with you appear here.": "Secrets that people in partner organisations share with you appear here.", + "From {sender}": "From {sender}", + "Accept": "Accept", + "Open in vault": "Open in vault", + "The other organisation did not hand over the secret. Try again later.": "The other organisation did not hand over the secret. Try again later.", + "Set up your vault before you accept a shared secret.": "Set up your vault before you accept a shared secret.", + "Something went wrong. Try again.": "Something went wrong. Try again.", + "Waiting for your answer": "Waiting for your answer", + "In your vault, read-only": "In your vault, read-only", + "Withdrawn by the sender": "Withdrawn by the sender", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} shared this from another organisation. You can read it, but not change or share it.", + "Someone": "Someone", + "Share with someone at another organisation": "Share with someone at another organisation", + "Their account at the other organisation": "Their account at the other organisation", + "Check account": "Check account", + "Certificate fingerprint of {account}": "Certificate fingerprint of {account}", + "Compare it with them by phone if you want to be sure.": "Compare it with them by phone if you want to be sure.", + "Shared. {account} can accept it in their own vault.": "Shared. {account} can accept it in their own vault.", + "The certificate could not be verified. Nothing was shared.": "The certificate could not be verified. Nothing was shared.", + "That organisation is not one of your partners.": "That organisation is not one of your partners.", + "No one with that account can receive secrets from you.": "No one with that account can receive secrets from you.", + "The other organisation did not answer. Try again later.": "The other organisation did not answer. Try again later.", + "This secret is already shared with that account.": "This secret is already shared with that account.", + "Other organisations": "Other organisations", + "Receive secrets from other organisations": "Receive secrets from other organisations", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.", + "Shared": "Shared", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Paused: their certificate or the partnership changed. Revoke it or share again.", + "Their organisation did not get the last change. Revoke it or share again.": "Their organisation did not get the last change. Revoke it or share again.", + "Being withdrawn": "Being withdrawn", + "Shared with another organisation": "Shared with another organisation", + "Change sent to another organisation": "Change sent to another organisation", + "Share with another organisation revoked": "Share with another organisation revoked", + "Share with another organisation paused": "Share with another organisation paused", + "Another organisation did not get a change": "Another organisation did not get a change", + "Secret received from another organisation": "Secret received from another organisation", + "Secret from another organisation accepted": "Secret from another organisation accepted", + "Secret from another organisation declined": "Secret from another organisation declined", + "Copy from another organisation updated": "Copy from another organisation updated", + "Copy from another organisation removed": "Copy from another organisation removed", + "Declined: they removed their copy. Share again if they need it.": "Declined: they removed their copy. Share again if they need it.", + "Recipient at another organisation removed their copy": "Recipient at another organisation removed their copy", + "Removed the user from %n team folder.": "Removed the user from %n team folder.", + "Removed the user from %n team folders.": "Removed the user from %n team folders.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Removed the user from %n team folder.", + "Removed the user from %n team folders." + ], + "A restored copy came from a share that has ended. It stays read-only.": "A restored copy came from a share that has ended. It stays read-only.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.", + "Recipient at another organisation restored their copy": "Recipient at another organisation restored their copy" }, "plurals": "", "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/es.js b/l10n/es.js index ceb54d2bb..0eb21c82a 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Compartido con grupos", + "Not shared with any group yet.": "Aún no se ha compartido con ningún grupo.", + "Revoke the share with {group}": "Revocar el uso compartido con {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartido con {group}: {received} miembros lo recibieron, {skipped} no porque aún no han configurado el cifrado.", + "Search groups": "Buscar grupos", + "Failed to share": "No se pudo compartir", + "Columns": "Columnas", + "Column {number}": "Columna {number}", + "Map one column to Name. Every secret needs a name.": "Asigne una columna al nombre. Cada secreto necesita un nombre.", + "Notes": "Notas", + "Do not import": "No importar", + "Hide this value": "Ocultar este valor", + "Show this value": "Mostrar este valor", + "Defaults": "Valores predeterminados", + "New secrets start as this type, and your secret list opens in this view.": "Los secretos nuevos empiezan con este tipo y tu lista de secretos se abre en esta vista.", + "Default item type": "Tipo de elemento predeterminado", + "Cards": "Tarjetas", + "Table": "Tabla", + "Could not save your default": "No se pudo guardar tu valor predeterminado", + "Recently used": "Usados recientemente", + "Opened": "Abierto", + "You have not opened any secrets yet": "Aún no has abierto ningún secreto", + "Could not delete the item type.": "No se pudo eliminar el tipo de elemento.", + "Could not load the item types.": "No se pudieron cargar los tipos de elemento.", + "Could not save the item type.": "No se pudo guardar el tipo de elemento.", + "Delete item type": "Eliminar tipo de elemento", + "Edit item type": "Editar tipo de elemento", + "Fields": "Campos", + "Fields: {count}": "Campos: {count}", + "Hidden": "Oculto", + "Item types": "Tipos de elemento", + "Move up": "Subir", + "New item type": "Nuevo tipo de elemento", + "No item types defined yet.": "Aún no hay tipos de elemento definidos.", + "Required": "Obligatorio", + "Text": "Texto", + "This field is required": "Este campo es obligatorio", + "Web address": "Dirección web", + "{label} (required)": "{label} (obligatorio)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "¿Eliminar «{name}»? Los secretos de este tipo siguen siendo legibles y pasan a ser elementos de Inicio de sesión.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Los tipos de elemento que definas aquí aparecen para todos en el diálogo Nuevo secreto, con los campos que elijas.", + "Secret moved to the trash": "Secreto movido a la papelera", + "Secret restored from the trash": "Secreto restaurado desde la papelera", + "Secret deleted for good": "Secreto eliminado definitivamente", + "Secret archived": "Secreto archivado", + "Secret unarchived": "Secreto desarchivado", + "Unarchive": "Desarchivar", + "Could not archive the secret": "No se pudo archivar el secreto", + "Could not unarchive the secret": "No se pudo desarchivar el secreto", + "Archive {count} secrets": "Archivar {count} secretos", + "Unarchive {count} secrets": "Desarchivar {count} secretos", + "Restore {count} secrets": "Restaurar {count} secretos", + "Delete {count} secrets for good": "Eliminar definitivamente {count} secretos", + "Done for {ok} of {total} secrets": "Hecho para {ok} de {total} secretos", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Los secretos archivados salen de la lista de la bóveda, la búsqueda, el autorrelleno y el informe de salud. Conservan sus comparticiones. Los encontrarás en Archivo.", + "These secrets come back to the vault list, search and autofill.": "Estos secretos vuelven a la lista de la bóveda, la búsqueda y el autorrelleno.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Estos secretos vuelven a la lista de la bóveda. Sus comparticiones anteriores no vuelven, así que compártelos de nuevo donde haga falta.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Esto elimina los secretos con sus adjuntos y su historial de versiones. No se puede deshacer.", + "Delete for good": "Eliminar definitivamente", + "Trash": "Papelera", + "The trash is empty": "La papelera está vacía", + "No archived secrets": "No hay secretos archivados", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Los secretos eliminados esperan aquí hasta que termina el periodo de conservación; después se eliminan definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiva un secreto desde su panel de detalles para mantenerlo fuera de la lista de la bóveda, la búsqueda y el autorrelleno.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Límites para los adjuntos cifrados (aplicados en el servidor sobre los bytes cifrados almacenados), conservación del historial de versiones y cuánto tiempo permanecen en la papelera los secretos eliminados.", + "Days a deleted secret stays in the trash (1 to 365)": "Días que un secreto eliminado permanece en la papelera (1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Esto mueve el secreto a la papelera y termina ahora sus comparticiones. Puedes restaurarlo desde la papelera hasta que termine el periodo de conservación: 30 días, salvo que tu administrador lo haya cambiado.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Esto mueve {count} secretos a la papelera y termina ahora sus comparticiones. Puedes restaurarlos desde la papelera hasta que termine el periodo de conservación.", + "Remove {name} from favourites": "Quitar {name} de favoritos", + "Add {name} to favourites": "Añadir {name} a favoritos", + "Could not change the favourite": "No se pudo cambiar el favorito", + "Remove from favourites": "Quitar de favoritos", + "Add to favourites": "Añadir a favoritos", + "Tags": "Etiquetas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Las etiquetas no están cifradas. Los administradores del servidor pueden leerlas, como los nombres de carpeta.", + "Favourites": "Favoritos", + "Filter by tag": "Filtrar por etiqueta", + "All tags": "Todas las etiquetas", + "Last used": "Último uso", + "Tags for {count} secrets": "Etiquetas para {count} secretos", + "Tag": "Etiqueta", + "Remove tag": "Quitar etiqueta", + "Add tag": "Añadir etiqueta", + "Could not change the tags. Try again.": "No se pudieron cambiar las etiquetas. Inténtelo de nuevo.", + "Could not approve the application. It is still in the queue.": "No se pudo aprobar la solicitud. Sigue en la cola.", + "Could not reject the application. It is still in the queue.": "No se pudo rechazar la solicitud. Sigue en la cola.", + "Removed the user from {count} team folders.": "Se eliminó al usuario de {count} carpetas de equipo.", + "Approve a share": "Aprobar un uso compartido", + "This approval link is incomplete. Open it again from the notification.": "Este enlace de aprobación está incompleto. Ábrelo de nuevo desde la notificación.", + "Deny": "Denegar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se unió a un grupo con el que compartes un secreto. ¿Compartir también el secreto con esta persona?", + "{requester} asks you to share a secret with {user}.": "{requester} te pide que compartas un secreto con {user}.", + "Shared. The recipient can now open the secret.": "Compartido. El destinatario ya puede abrir el secreto.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "El destinatario aún no ha configurado Keepiq, así que no se compartió nada. Inténtalo de nuevo cuando lo haya hecho.", + "Could not share the secret. Only its owner can approve this.": "No se pudo compartir el secreto. Solo su propietario puede aprobarlo.", + "Could not share the secret. Try again.": "No se pudo compartir el secreto. Inténtalo de nuevo.", + "Denied. Nothing was shared.": "Denegado. No se compartió nada.", + "Could not deny the request. Try again.": "No se pudo denegar la solicitud. Inténtalo de nuevo.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s te pide que compartas el secreto \"%2$s\" con %3$s.", + "Expires on (optional)": "Caduca el (opcional)", + "Hand over to": "Entregar a", + "Choose a recipient": "Elige un destinatario", + "Hand over temporarily": "Entregar temporalmente", + "Expiry rules": "Reglas de caducidad", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Define cuánto tiempo pueden durar las contraseñas de un tipo de elemento o de una carpeta, y cuándo recibir un recordatorio. Si se aplican varias fechas, cuenta la más temprana.", + "Delete rule": "Eliminar regla", + "Set by your administrator": "Establecido por tu administrador", + "No expiry rules yet.": "Aún no hay reglas de caducidad.", + "Applies to": "Se aplica a", + "Item type": "Tipo de elemento", + "Maximum age in days (empty for reminders only)": "Antigüedad máxima en días (vacío solo para recordatorios)", + "Remind me this many days before, comma separated": "Recordarme con estos días de antelación, separados por comas", + "Save rule": "Guardar regla", + "An item type": "Un tipo de elemento", + "A folder": "Una carpeta", + "Folder {name}": "Carpeta {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Caduca tras {days} días", + "Reminders {days} days before": "Recordatorios {days} días antes", + "Could not save the expiry rule.": "No se pudo guardar la regla de caducidad.", + "Could not delete the expiry rule.": "No se pudo eliminar la regla de caducidad.", + "All statuses": "Todos los estados", + "Compromised": "Comprometida", + "Could not load the members.": "No se pudieron cargar los miembros.", + "Emergency contact": "Contacto de emergencia", + "Leaving user": "Usuario saliente", + "No": "No", + "No users match this filter.": "Ningún usuario coincide con este filtro.", + "Not set up": "Sin configurar", + "Revoke suite": "Revocar suite", + "Revoked": "Revocada", + "Search users": "Buscar usuarios", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vea qué usuarios han configurado una bóveda. Inicie la baja o revoque una suite desde una fila.", + "Successor": "Sucesor", + "Team folders": "Carpetas de equipo", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "El usuario sigue en el grupo {groups}, que es miembro de una carpeta de equipo. Quítelo del grupo o desactive la cuenta.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "El usuario sigue en los grupos {groups}, que son miembros de carpetas de equipo. Quítelo de los grupos o desactive la cuenta.", + "Vault status": "Estado de la bóveda", + "Yes": "Sí", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Una exportación CXF NO ESTÁ CIFRADA. Todas las contraseñas e inicios de sesión serán legibles como texto plano en el archivo descargado. Guárdelo de forma segura y elimínelo inmediatamente después de usarlo.", + "Root certificate expiring soon": "El certificado raíz caduca pronto", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "El certificado raíz de la bóveda caduca en %1$d día(s). Renuévelo antes. Al renovarlo se vuelve a firmar cada suite de cifrado.", + "Compromise recovery aborted": "Recuperación tras compromiso cancelada", + "Key rotation ended by a compromise revoke": "Rotación de clave finalizada por una revocación por compromiso", + "Encryption suite revoke refused": "Revocación de la suite de cifrado rechazada", + "Master password proof refused": "Prueba de la contraseña maestra rechazada", + "Your current master password": "Tu contraseña maestra actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacto de emergencia tenía una solicitud de acceso pendiente cuando tu rotación de clave lo eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactos de emergencia tenían una solicitud de acceso pendiente cuando tu rotación de clave los eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Estos contactos de emergencia no se trasladaron a tu nueva clave. Su acceso de emergencia se eliminó. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.", + "Renew root certificate": "Renovar el certificado raíz", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Esto crea un certificado raíz y uno intermedio nuevos. Cada conjunto de cifrado activo se vuelve a firmar. No se puede deshacer.", + "Renew root": "Renovar raíz", + "Root renewed. {n} encryption suites signed again.": "Raíz renovada. Conjuntos de cifrado firmados de nuevo: {n}.", + "Could not renew the root certificate.": "No se pudo renovar el certificado raíz.", + "Lease policy for this application": "Política de concesión para esta aplicación", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigor ahora: {default} segundos por defecto, {max} segundos como máximo.", + "Leases are not renewable": "Las concesiones no se pueden renovar", + "Lease policy saved.": "Política de concesión guardada.", + "Leave a field empty to use the instance value.": "Deja un campo vacío para usar el valor de la instancia.", + "Instance value: {value}": "Valor de la instancia: {value}", + "Renewal": "Renovación", + "Use the instance value ({value})": "Usar el valor de la instancia ({value})", + "Allowed": "Permitido", + "Not allowed": "No permitido", + "Save lease policy": "Guardar política de concesión", + "Only an administrator can change this policy.": "Solo un administrador puede cambiar esta política.", + "Could not save the lease policy.": "No se pudo guardar la política de concesión.", + "{member} got access from {confirmer}.": "{member} recibió acceso de {confirmer}.", + "Automatically confirm new team folder members": "Confirmar automáticamente los nuevos miembros de carpetas de equipo", + "Gave %n new member access to a team folder.": "%n nuevo miembro recibió acceso a una carpeta de equipo.", + "Gave %n new members access to a team folder.": "%n nuevos miembros recibieron acceso a una carpeta de equipo.", + "Give new team folder members access without waiting for the folder owner.": "Dé acceso a los nuevos miembros sin esperar al propietario de la carpeta.", + "New team folder members": "Nuevos miembros de carpetas de equipo", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "El propietario o un miembro con permiso de escritura los confirma desde su bóveda abierta. Keepiq nunca descifra en el servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Esperando a que un miembro con permiso de escritura abra Keepiq. También puede compartir ahora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte de la respuesta al compromiso falló ({failed} paso(s)). Revise el registro del servidor y vuelva a revocar la suite para terminarla.", + "This also revoked suite {suite} and ended key migration {migration}.": "Esto también revocó la suite {suite} y finalizó la migración de claves {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocar la segunda suite eliminó %n contacto de acceso de emergencia.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocar la segunda suite eliminó %n contactos de acceso de emergencia.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocada como comprometida no se puede restablecer.", + "Archives to keep": "Archivos a conservar", + "Back up every vault automatically": "Hacer copia de cada bóveda automáticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Haga copia de cada bóveda según un calendario. Los archivos solo contienen texto cifrado y se restauran con occ.", + "Back up now": "Hacer copia ahora", + "Backup public key (PEM, optional)": "Clave pública de copia (PEM, opcional)", + "Backup requested for the next cron run": "Copia solicitada para la próxima ejecución de cron", + "Encrypted": "Cifrado", + "Every (hours)": "Cada (horas)", + "Last backup {when} failed: {error}": "Última copia {when} fallida: {error}", + "Last backup {when} succeeded.": "Última copia {when} correcta.", + "No archives yet.": "Aún no hay archivos.", + "Size": "Tamaño", + "Vault backups": "Copias de la bóveda", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Con una clave, cada archivo se cifra para ella. Guarde la clave privada fuera de este servidor: la necesita para verificar o restaurar.", + "Written": "Escrito", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n usuario en el ámbito aún no tiene inicio de sesión en dos pasos y no puede abrir la bóveda mientras esto esté activo.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n usuarios en el ámbito aún no tienen inicio de sesión en dos pasos y no pueden abrir la bóveda mientras esto esté activo.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Los códigos de respaldo no cuentan. Si sus usuarios inician sesión con un proveedor de identidad con su propio segundo factor, excluya sus grupos.", + "Block personal vault export": "Bloquear la exportación de la bóveda personal", + "Keep work logins in team folders": "Guardar los inicios de sesión de trabajo en carpetas de equipo", + "Move to a team folder": "Mover a una carpeta de equipo", + "Not in a team folder": "No está en una carpeta de equipo", + "Only for these groups (empty is everyone)": "Solo para estos grupos (vacío es todos)", + "Require two-factor login before the vault opens": "Exigir inicio de sesión en dos pasos antes de abrir la bóveda", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglas para cada bóveda. Cada una se aplica a todos, o solo a los grupos que elija.", + "Secret types that belong in a team folder": "Tipos de secreto que van en una carpeta de equipo", + "Set up two-factor login": "Configurar el inicio de sesión en dos pasos", + "Team folder you can write to": "Carpeta de equipo en la que puede escribir", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Los usuarios no pueden descargar una copia de seguridad, un CSV ni un archivo de transferencia. Su paquete de datos personales sigue disponible.", + "Users cannot save these secret types in a personal folder.": "Los usuarios no pueden guardar estos tipos de secreto en una carpeta personal.", + "Vault policies": "Políticas de la bóveda", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Su organización no permite exportar su bóveda personal. Su paquete de datos personales en la configuración sigue disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Su organización guarda estos secretos en una carpeta de equipo. Mueva cada uno a una carpeta de equipo.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Su organización guarda este tipo de secreto en una carpeta de equipo. Elija una de sus carpetas de equipo, o una en la que pueda escribir.", + "Your organisation requires two-factor login before you can open your vault.": "Su organización exige el inicio de sesión en dos pasos antes de que pueda abrir su bóveda.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Los usuarios eligen cuánto tiempo permanece desbloqueada la extensión sin actividad. Usted fija el máximo que pueden elegir.", + "Longest idle time before the extension locks": "Tiempo de inactividad máximo antes de que la extensión se bloquee", + "1 minute": "1 minuto", + "5 minutes": "5 minutos", + "15 minutes": "15 minutos", + "1 hour": "1 hora", + "4 hours": "4 horas", + "Connector": "Conector", + "Directory (tenant) ID": "ID de directorio (inquilino)", + "Application (client) ID": "ID de aplicación (cliente)", + "Data collection rule immutable ID": "ID inmutable de la regla de recopilación de datos", + "Stream name": "Nombre del flujo", + "Splunk index (optional)": "Índice de Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Déjelo en blanco para conservar el actual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF sobre syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punto de conexión de recopilación de datos (URL https)", + "HTTP Event Collector URL (https)": "URL del HTTP Event Collector (https)", + "Client secret (write-only)": "Secreto de cliente (solo escritura)", + "HEC token (write-only)": "Token HEC (solo escritura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reenvíe los eventos de auditoría permitidos a Splunk, Microsoft Sentinel, un receptor syslog o un webhook. Los mensajes solo llevan metadatos depurados: ningún valor secreto, nombre, inicio de sesión o texto cifrado sale nunca del servidor.", + "%n change waiting to sync": "%n cambio pendiente de sincronizar", + "%n changes waiting to sync": "%n cambios pendientes de sincronizar", + "Changes that could not sync": "Cambios que no se pudieron sincronizar", + "Choose a version": "Elegir una versión", + "Copy value": "Copiar valor", + "Deleted": "Eliminado", + "Discard": "Descartar", + "Keep my offline change": "Conservar mi cambio sin conexión", + "Keep the server version": "Conservar la versión del servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq es de solo lectura sin conexión. Su administrador no ha activado la edición sin conexión.", + "Let users edit secrets offline": "Permitir a los usuarios editar secretos sin conexión", + "Not synced yet": "Aún sin sincronizar", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Los cambios sin conexión se guardan en el dispositivo, cifrados para el usuario, y se sincronizan en el siguiente desbloqueo en línea. Compartir, carpetas y adjuntos siguen necesitando conexión.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Sin conexión. Las ediciones, movimientos y eliminaciones se quedan en este dispositivo y se sincronizan cuando vuelva a estar en línea. Compartir y adjuntos necesitan conexión.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Sin conexión. Sus cambios se quedan en este dispositivo y se sincronizan cuando vuelva a estar en línea. Última sincronización {when}.", + "Open my changes": "Abrir mis cambios", + "Sharing needs a connection": "Compartir necesita conexión", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Alguien cambió este secreto en el servidor después de crear su copia sin conexión. Elija qué versión conservar.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronice o descarte sus cambios sin conexión antes de renovar sus claves.", + "That password did not open your changes.": "Esa contraseña no abrió sus cambios.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La instantánea sin conexión guarda secretos cifrados (solo se abren con la clave derivada de la contraseña maestra del usuario, igual que en el servidor) y cifra en reposo los nombres, URL y nombres de carpetas. El acceso sin conexión es de solo lectura, salvo que permita abajo la edición sin conexión. Desactívelo en equipos que nunca deban guardar credenciales; al desactivarlo se borran las cachés existentes en la siguiente carga.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia anterior de la bóveda ya no está, así que estos cambios no se pueden abrir.", + "The server version": "La versión del servidor", + "This secret changed while you were offline": "Este secreto cambió mientras estaba sin conexión", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Eliminó este secreto sin conexión, pero desde entonces se cambió en el servidor. Elija qué versión conservar.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Sus claves se cambiaron en otro dispositivo. Introduzca su contraseña maestra anterior para sincronizar los cambios sin conexión, o descártelos.", + "Your offline change": "Su cambio sin conexión", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contacto de emergencia tenía una solicitud de acceso pendiente cuando tu rotación de clave lo eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.","%n contactos de emergencia tenían una solicitud de acceso pendiente cuando tu rotación de clave los eliminó. Comprueba quién la pidió antes de volver a añadir a nadie."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n elemento no se puede representar en CXF y se omitirá.","%n elementos no se pueden representar en CXF y se omitirán."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["Se descartó %n versión anterior porque solo se puede trasladar el historial reciente.","Se descartaron %n versiones anteriores porque solo se puede trasladar el historial reciente."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Todavía hay que cifrar y compartir %n copia del secreto.","Todavía hay que cifrar y compartir %n copias del secreto."], + "_%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 secreto no se pudo descifrar y no está en esta exportación.","%n secretos no se pudieron descifrar y no están en esta exportación."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n secreto no se pudo descifrar con su clave antigua, por lo que no se migró.","%n secretos no se pudieron descifrar con su clave antigua, por lo que no se migraron."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n secreto no se migró.","%n secretos no se migraron."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n secreto sigue cifrado con su clave anterior.","%n secretos siguen cifrados con su clave anterior."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["Se omitió %n secreto porque el sucesor aún no tiene una copia: añada el sucesor a la carpeta y vuelva a ejecutarlo.","Se omitieron %n secretos porque el sucesor aún no tiene una copia: añada el sucesor a la carpeta y vuelva a ejecutarlo."], + "_%n secret_::_%n secrets_": ["%n secreto","%n secretos"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n usuario en el ámbito aún no tiene inicio de sesión en dos pasos y no puede abrir la bóveda mientras esto esté activo.","%n usuarios en el ámbito aún no tienen inicio de sesión en dos pasos y no pueden abrir la bóveda mientras esto esté activo."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Finalizar de todos modos, perdiendo el acceso a %n secreto","Finalizar de todos modos, perdiendo el acceso a %n secretos"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nuevo miembro recibió acceso a una carpeta de equipo.","%n nuevos miembros recibieron acceso a una carpeta de equipo."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotación de clave finalizada. %n secreto se volvió a cifrar con su nueva clave.","Rotación de clave finalizada. %n secretos se volvieron a cifrar con su nueva clave."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revocar la segunda suite eliminó %n contacto de acceso de emergencia.","Revocar la segunda suite eliminó %n contactos de acceso de emergencia."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revocar esta suite eliminó %n contacto de acceso de emergencia.","Revocar esta suite eliminó %n contactos de acceso de emergencia."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["visto %n vez en filtraciones","visto %n veces en filtraciones"], + "_shared with %n secret_::_shared with %n secrets_": ["compartido con %n secreto","compartido con %n secretos"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Esta carpeta contiene directamente %n secreto.","Esta carpeta contiene directamente %n secretos."], + "_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._": ["Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.","Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n cambio pendiente de sincronizar","%n cambios pendientes de sincronizar"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["El usuario sigue en el grupo {groups}, que es miembro de una carpeta de equipo. Quítelo del grupo o desactive la cuenta.","El usuario sigue en los grupos {groups}, que son miembros de carpetas de equipo. Quítelo de los grupos o desactive la cuenta."], + "Allow approval from another device": "Permitir la aprobación desde otro dispositivo", + "App": "Aplicación", + "Approve a new device": "Aprobar un dispositivo nuevo", + "Approve from another device": "Aprobar desde otro dispositivo", + "Asked at": "Solicitado a las", + "Check that the new device shows these words:": "Comprueba que el dispositivo nuevo muestra estas palabras:", + "Denied. If you did not ask, end your other sessions:": "Denegado. Si no lo solicitaste, cierra tus otras sesiones:", + "Device": "Dispositivo", + "IP address": "Dirección IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permite a los usuarios desbloquear un navegador nuevo aprobándolo desde un dispositivo donde Keepiq ya está desbloqueado.", + "New device approval": "Aprobación de dispositivos nuevos", + "Nextcloud security settings": "Ajustes de seguridad de Nextcloud", + "Only approve a device you are using right now.": "Aprueba solo un dispositivo que estés usando ahora mismo.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Abre Keepiq en un dispositivo donde esté desbloqueado y aprueba este. Comprueba que muestra las mismas palabras:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "El dispositivo que aprueba sella la clave de desbloqueo para el dispositivo nuevo. El servidor solo la transmite y no puede abrirla.", + "The master password is not right, or the request has ended.": "La contraseña maestra no es correcta o la solicitud ha terminado.", + "The request expired. Ask again or use your master password.": "La solicitud ha caducado. Vuelve a solicitarlo o usa tu contraseña maestra.", + "The request was denied.": "La solicitud fue denegada.", + "Too many requests. Try again in an hour or use your master password.": "Demasiadas solicitudes. Inténtalo de nuevo en una hora o usa tu contraseña maestra.", + "Unknown device": "Dispositivo desconocido", + "Web app": "Aplicación web", + "A device": "Un dispositivo", + "A new device asks to open your vault": "Un dispositivo nuevo pide abrir tu bóveda", + "%s asks to be approved. Only approve a device you are using right now.": "%s pide ser aprobado. Aprueba solo un dispositivo que estés usando ahora mismo.", + "Access ends on (optional)": "El acceso termina el (opcional)", + "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.": "Las apps de Keepiq no mostrarán ni copiarán la contraseña. Alguien con conocimientos técnicos aún puede leerla desde su propio dispositivo. Cámbiala cuando termine su acceso.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Este secreto es solo de uso. Inicia sesión mediante la extensión de navegador de Keepiq.", + "Until {date}": "Hasta el {date}", + "Use only": "Solo uso", + "Use only (can sign in, cannot view or copy)": "Solo uso (puede iniciar sesión, no puede ver ni copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Puedes iniciar sesión con estas credenciales mediante la extensión de navegador de Keepiq. Su propietario decidió no permitirte verlas ni copiarlas.", + "Your access ends on {date}": "Tu acceso termina el {date}", + "Your access to this secret has ended": "Tu acceso a este secreto ha terminado", + "Your access to \"%s\" ends tomorrow": "Tu acceso a «%s» termina mañana", + "Your access to \"%s\" has ended": "Tu acceso a «%s» ha terminado", + "%1$s no longer has access to \"%2$s\"": "%1$s ya no tiene acceso a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podía ver esta contraseña. Cámbiala si %1$s ya no debe conocerla.", + "%s could not view this password in Keepiq.": "%s no pudo ver esta contraseña en Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprobaciones", + "a recovery officer": "un responsable de recuperación", + "Account recovery": "Recuperación de cuenta", + "Approvals needed": "Aprobaciones necesarias", + "Ask {user} which words they see, by phone or in person. They must be:": "Pregunte a {user} qué palabras ve, por teléfono o en persona. Deben ser:", + "Check again": "Comprobar de nuevo", + "Create the recovery key": "Crear la clave de recuperación", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Cree la clave de recuperación. Su navegador la genera y da a cada responsable una copia que solo él puede abrir.", + "Decline": "Rechazar", + "Enrol in account recovery": "Inscribirse en la recuperación de cuenta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscríbase para que su organización pueda ayudarle a recuperar su bóveda si olvida su contraseña maestra.", + "Every user is enrolled": "Todos los usuarios están inscritos", + "Finish the recovery in the browser you asked from.": "Termine la recuperación en el navegador desde el que la pidió.", + "Forgot your master password?": "¿Ha olvidado su contraseña maestra?", + "Hand the key over": "Entregar la clave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permita que los usuarios que olvidaron su contraseña maestra recuperen su bóveda, con la aprobación de los responsables de recuperación que designe.", + "New master password": "Nueva contraseña maestra", + "No one is asking to recover their account.": "Nadie está pidiendo recuperar su cuenta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Todavía no hay clave de recuperación. Uno de los responsables la crea en su configuración de Keepiq.", + "Off": "Desactivado", + "Officer {user} has no encryption set up yet.": "El responsable {user} aún no ha configurado el cifrado.", + "Officers (user IDs, separated by commas)": "Responsables (ID de usuario, separados por comas)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publique esta huella internamente para que los usuarios puedan comprobarla antes de inscribirse.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperado con ayuda de {officer}. Cambie ahora la clave de su bóveda en Configuración, Seguridad: \"Mi contraseña maestra se ha visto comprometida\".", + "Recovery key fingerprint: {fingerprint}": "Huella de la clave de recuperación: {fingerprint}", + "Recovery officer": "Responsable de recuperación", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Los responsables eliminados pierden ahora su copia, pero pueden haberla abierto antes. Pida a un responsable que cree una nueva clave de recuperación.", + "Repeat the new master password": "Repita la nueva contraseña maestra", + "Retire this recovery key": "Retirar esta clave de recuperación", + "Set the new master password": "Establecer la nueva contraseña maestra", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "El certificado de recuperación no lo ha emitido este Keepiq. No se inscriba y avise a su administrador.", + "The words match, approve": "Las palabras coinciden, aprobar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Este usuario está inscrito en la recuperación de cuenta. Recuperar conserva sus secretos; revocar elimina su inscripción.", + "Users may enrol": "Los usuarios pueden inscribirse", + "Withdraw from account recovery": "Salir de la recuperación de cuenta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Está inscrito en la recuperación de cuenta. Huella de la clave de recuperación: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Está inscrito. Si olvida su contraseña maestra, su organización puede ayudarle a recuperar su bóveda.", + "Your key is back. Choose a new master password.": "Ha recuperado su clave. Elija una nueva contraseña maestra.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Se ha avisado a sus responsables de recuperación. Léales estas palabras cuando le llamen o se reúnan con usted:", + "You are now an account recovery officer": "Ahora es responsable de recuperación de cuentas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pide recuperar su cuenta. Compare las palabras con esa persona antes de aprobar.", + "A user": "Un usuario", + "Your account recovery request was declined": "Su solicitud de recuperación de cuenta ha sido rechazada", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La recuperación de su cuenta está lista. Abra Keepiq en el navegador desde el que la pidió.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pide desbloquear un dispositivo nuevo una sola vez. Conserva su contraseña maestra.", + "Ask your organisation instead": "Pídeselo a tu organización", + "The request ended. Ask again or use your master password.": "La solicitud ha terminado. Vuelve a pedirlo o usa tu contraseña maestra.", + "Added by {user}": "Añadido por {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Rol de {member}", + "Team folders you manage": "Carpetas de equipo que gestionas", + "Viewer": "Lector", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "No tienes ninguna copia de estos secretos, así que los nuevos miembros aún no los han recibido. El propietario puede compartirlos: {names}", + "Admin areas": "Áreas de administración", + "Give a group only the parts of Keepiq administration it needs.": "Dé a un grupo solo las partes de la administración de Keepiq que necesita.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegue una o más áreas a un grupo en la página de privilegios de administración. Los administradores de la instancia tienen todas las áreas.", + "Open administration privileges": "Abrir privilegios de administración", + "Policies": "Políticas", + "Applications and machine access": "Aplicaciones y acceso de máquinas", + "People and offboarding": "Personas y bajas", + "Audit and compliance": "Auditoría y cumplimiento", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versión, autoridad de certificación, adjuntos, caché sin conexión, comprobación de filtraciones, tipos de secretos y copias de seguridad", + "master password, organisation password, vault policies, rotation, version history and trash": "contraseña maestra, contraseña de la organización, políticas de la bóveda, rotación, historial de versiones y papelera", + "application queue, application requests and machine leases": "cola de aplicaciones, solicitudes de aplicaciones y concesiones de máquinas", + "team offboarding, encryption suites and admin handover": "bajas de equipo, suites de cifrado y traspaso por el administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registro de auditoría, informes de cumplimiento, exportación SIEM y alertas señuelo", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cuántas versiones de un secreto se guardan, durante cuánto tiempo y cuánto tiempo permanecen en la papelera los secretos eliminados.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Límites para adjuntos cifrados, aplicados en el servidor en bytes cifrados almacenados.", + "Type the suite ID again to confirm": "Vuelva a escribir el ID del conjunto para confirmar", + "This does not match the suite ID.": "No coincide con el ID del conjunto.", + "Confirm with your master password": "Confirme con su contraseña maestra", + "Confirm": "Confirmar", + "That master password is not right.": "Esa contraseña maestra no es correcta.", + "You are sharing with someone new. Enter your master password to confirm.": "Está compartiendo con alguien nuevo. Introduzca su contraseña maestra para confirmar.", + "Enter your master password to confirm this share.": "Introduzca su contraseña maestra para confirmar este uso compartido.", + "Enter your master password to confirm this delegation.": "Introduzca su contraseña maestra para confirmar esta delegación.", + "Approve {member}": "Aprobar a {member}", + "Recipient": "Destinatario", + "No vault yet": "Todavía sin bóveda", + "No matching users": "No hay usuarios que coincidan", + "Partner organisations": "Organizaciones asociadas", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Intercambia secretos con otro Keepiq. Ambos administradores se añaden mutuamente y comparan las huellas raíz por teléfono o en persona antes de guardar.", + "Federation needs Nextcloud 33 or later.": "La federación requiere Nextcloud 33 o posterior.", + "Your root fingerprint": "Tu huella raíz", + "No partners yet.": "Todavía no hay socios.", + "Users here may share to this partner": "Los usuarios de aquí pueden compartir con este socio", + "This partner may share to users here": "Este socio puede compartir con los usuarios de aquí", + "Partner address": "Dirección del socio", + "Check partner": "Comprobar socio", + "Partner root fingerprint": "Huella raíz del socio", + "I compared this fingerprint with the partner's administrator": "He comparado esta huella con el administrador del socio", + "Add partner": "Añadir socio", + "A secret from another organisation": "Un secreto de otra organización", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha compartido \"%2$s\" con usted. Acéptelo en Recibidos de otras organizaciones.", + "Incoming from other organisations": "Recibidos de otras organizaciones", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Las personas de organizaciones asociadas pueden compartir un secreto con usted. Acéptelo para guardar una copia de solo lectura en su bóveda.", + "Nothing shared with you yet": "Todavía no se ha compartido nada con usted", + "Secrets that people in partner organisations share with you appear here.": "Los secretos que las personas de organizaciones asociadas comparten con usted aparecen aquí.", + "From {sender}": "De {sender}", + "Accept": "Aceptar", + "Open in vault": "Abrir en la bóveda", + "The other organisation did not hand over the secret. Try again later.": "La otra organización no entregó el secreto. Inténtelo de nuevo más tarde.", + "Set up your vault before you accept a shared secret.": "Configure su bóveda antes de aceptar un secreto compartido.", + "Something went wrong. Try again.": "Algo salió mal. Inténtelo de nuevo.", + "Waiting for your answer": "Esperando su respuesta", + "In your vault, read-only": "En su bóveda, solo lectura", + "Withdrawn by the sender": "Retirado por el remitente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} compartió esto desde otra organización. Puede leerlo, pero no modificarlo ni compartirlo.", + "Someone": "Alguien", + "Share with someone at another organisation": "Compartir con alguien de otra organización", + "Their account at the other organisation": "Su cuenta en la otra organización", + "Check account": "Comprobar cuenta", + "Certificate fingerprint of {account}": "Huella digital del certificado de {account}", + "Compare it with them by phone if you want to be sure.": "Compárela con esa persona por teléfono si quiere estar seguro.", + "Shared. {account} can accept it in their own vault.": "Compartido. {account} puede aceptarlo en su propia bóveda.", + "The certificate could not be verified. Nothing was shared.": "No se pudo verificar el certificado. No se ha compartido nada.", + "That organisation is not one of your partners.": "Esa organización no es una de sus organizaciones asociadas.", + "No one with that account can receive secrets from you.": "Nadie con esa cuenta puede recibir secretos de usted.", + "The other organisation did not answer. Try again later.": "La otra organización no respondió. Inténtelo de nuevo más tarde.", + "This secret is already shared with that account.": "Este secreto ya está compartido con esa cuenta.", + "Other organisations": "Otras organizaciones", + "Receive secrets from other organisations": "Recibir secretos de otras organizaciones", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Las personas de organizaciones asociadas podrán encontrar su cuenta y compartir secretos con usted. Usted acepta cada uno personalmente.", + "Shared": "Compartido", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: ha cambiado el certificado del destinatario o la asociación. Revóquelo o vuelva a compartirlo.", + "Their organisation did not get the last change. Revoke it or share again.": "La organización del destinatario no recibió el último cambio. Revóquelo o vuelva a compartirlo.", + "Being withdrawn": "Retirándose", + "Shared with another organisation": "Compartido con otra organización", + "Change sent to another organisation": "Cambio enviado a otra organización", + "Share with another organisation revoked": "Acceso compartido con otra organización revocado", + "Share with another organisation paused": "Acceso compartido con otra organización en pausa", + "Another organisation did not get a change": "Otra organización no recibió un cambio", + "Secret received from another organisation": "Secreto recibido de otra organización", + "Secret from another organisation accepted": "Secreto de otra organización aceptado", + "Secret from another organisation declined": "Secreto de otra organización rechazado", + "Copy from another organisation updated": "Copia de otra organización actualizada", + "Copy from another organisation removed": "Copia de otra organización eliminada", + "Declined: they removed their copy. Share again if they need it.": "Rechazado: el destinatario eliminó su copia. Vuelva a compartirlo si la necesita.", + "Recipient at another organisation removed their copy": "Un destinatario de otra organización eliminó su copia", + "Removed the user from %n team folder.": "Se eliminó al usuario de %n carpeta de equipo.", + "Removed the user from %n team folders.": "Se eliminó al usuario de %n carpetas de equipo.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Se eliminó al usuario de %n carpeta de equipo.","Se eliminó al usuario de %n carpetas de equipo."], + "A restored copy came from a share that has ended. It stays read-only.": "Una copia restaurada procede de un uso compartido que ha terminado. Sigue siendo de solo lectura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "No se pudo contactar con la organización que compartió una copia restaurada. La copia sigue siendo de solo lectura y no sigue sus cambios.", + "Recipient at another organisation restored their copy": "Un destinatario de otra organización restauró su copia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/es.json b/l10n/es.json index a89a7df7a..e06a3e8dd 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Compartido con grupos", + "Not shared with any group yet.": "Aún no se ha compartido con ningún grupo.", + "Revoke the share with {group}": "Revocar el uso compartido con {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Compartido con {group}: {received} miembros lo recibieron, {skipped} no porque aún no han configurado el cifrado.", + "Search groups": "Buscar grupos", + "Failed to share": "No se pudo compartir", + "Columns": "Columnas", + "Column {number}": "Columna {number}", + "Map one column to Name. Every secret needs a name.": "Asigne una columna al nombre. Cada secreto necesita un nombre.", + "Notes": "Notas", + "Do not import": "No importar", + "Hide this value": "Ocultar este valor", + "Show this value": "Mostrar este valor", + "Defaults": "Valores predeterminados", + "New secrets start as this type, and your secret list opens in this view.": "Los secretos nuevos empiezan con este tipo y tu lista de secretos se abre en esta vista.", + "Default item type": "Tipo de elemento predeterminado", + "Cards": "Tarjetas", + "Table": "Tabla", + "Could not save your default": "No se pudo guardar tu valor predeterminado", + "Recently used": "Usados recientemente", + "Opened": "Abierto", + "You have not opened any secrets yet": "Aún no has abierto ningún secreto", + "Could not delete the item type.": "No se pudo eliminar el tipo de elemento.", + "Could not load the item types.": "No se pudieron cargar los tipos de elemento.", + "Could not save the item type.": "No se pudo guardar el tipo de elemento.", + "Delete item type": "Eliminar tipo de elemento", + "Edit item type": "Editar tipo de elemento", + "Fields": "Campos", + "Fields: {count}": "Campos: {count}", + "Hidden": "Oculto", + "Item types": "Tipos de elemento", + "Move up": "Subir", + "New item type": "Nuevo tipo de elemento", + "No item types defined yet.": "Aún no hay tipos de elemento definidos.", + "Required": "Obligatorio", + "Text": "Texto", + "This field is required": "Este campo es obligatorio", + "Web address": "Dirección web", + "{label} (required)": "{label} (obligatorio)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "¿Eliminar «{name}»? Los secretos de este tipo siguen siendo legibles y pasan a ser elementos de Inicio de sesión.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Los tipos de elemento que definas aquí aparecen para todos en el diálogo Nuevo secreto, con los campos que elijas.", + "Secret moved to the trash": "Secreto movido a la papelera", + "Secret restored from the trash": "Secreto restaurado desde la papelera", + "Secret deleted for good": "Secreto eliminado definitivamente", + "Secret archived": "Secreto archivado", + "Secret unarchived": "Secreto desarchivado", + "Unarchive": "Desarchivar", + "Could not archive the secret": "No se pudo archivar el secreto", + "Could not unarchive the secret": "No se pudo desarchivar el secreto", + "Archive {count} secrets": "Archivar {count} secretos", + "Unarchive {count} secrets": "Desarchivar {count} secretos", + "Restore {count} secrets": "Restaurar {count} secretos", + "Delete {count} secrets for good": "Eliminar definitivamente {count} secretos", + "Done for {ok} of {total} secrets": "Hecho para {ok} de {total} secretos", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Los secretos archivados salen de la lista de la bóveda, la búsqueda, el autorrelleno y el informe de salud. Conservan sus comparticiones. Los encontrarás en Archivo.", + "These secrets come back to the vault list, search and autofill.": "Estos secretos vuelven a la lista de la bóveda, la búsqueda y el autorrelleno.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Estos secretos vuelven a la lista de la bóveda. Sus comparticiones anteriores no vuelven, así que compártelos de nuevo donde haga falta.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Esto elimina los secretos con sus adjuntos y su historial de versiones. No se puede deshacer.", + "Delete for good": "Eliminar definitivamente", + "Trash": "Papelera", + "The trash is empty": "La papelera está vacía", + "No archived secrets": "No hay secretos archivados", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Los secretos eliminados esperan aquí hasta que termina el periodo de conservación; después se eliminan definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiva un secreto desde su panel de detalles para mantenerlo fuera de la lista de la bóveda, la búsqueda y el autorrelleno.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Límites para los adjuntos cifrados (aplicados en el servidor sobre los bytes cifrados almacenados), conservación del historial de versiones y cuánto tiempo permanecen en la papelera los secretos eliminados.", + "Days a deleted secret stays in the trash (1 to 365)": "Días que un secreto eliminado permanece en la papelera (1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Esto mueve el secreto a la papelera y termina ahora sus comparticiones. Puedes restaurarlo desde la papelera hasta que termine el periodo de conservación: 30 días, salvo que tu administrador lo haya cambiado.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Esto mueve {count} secretos a la papelera y termina ahora sus comparticiones. Puedes restaurarlos desde la papelera hasta que termine el periodo de conservación.", + "Remove {name} from favourites": "Quitar {name} de favoritos", + "Add {name} to favourites": "Añadir {name} a favoritos", + "Could not change the favourite": "No se pudo cambiar el favorito", + "Remove from favourites": "Quitar de favoritos", + "Add to favourites": "Añadir a favoritos", + "Tags": "Etiquetas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Las etiquetas no están cifradas. Los administradores del servidor pueden leerlas, como los nombres de carpeta.", + "Favourites": "Favoritos", + "Filter by tag": "Filtrar por etiqueta", + "All tags": "Todas las etiquetas", + "Last used": "Último uso", + "Tags for {count} secrets": "Etiquetas para {count} secretos", + "Tag": "Etiqueta", + "Remove tag": "Quitar etiqueta", + "Add tag": "Añadir etiqueta", + "Could not change the tags. Try again.": "No se pudieron cambiar las etiquetas. Inténtelo de nuevo.", + "Could not approve the application. It is still in the queue.": "No se pudo aprobar la solicitud. Sigue en la cola.", + "Could not reject the application. It is still in the queue.": "No se pudo rechazar la solicitud. Sigue en la cola.", + "Removed the user from {count} team folders.": "Se eliminó al usuario de {count} carpetas de equipo.", + "Approve a share": "Aprobar un uso compartido", + "This approval link is incomplete. Open it again from the notification.": "Este enlace de aprobación está incompleto. Ábrelo de nuevo desde la notificación.", + "Deny": "Denegar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se unió a un grupo con el que compartes un secreto. ¿Compartir también el secreto con esta persona?", + "{requester} asks you to share a secret with {user}.": "{requester} te pide que compartas un secreto con {user}.", + "Shared. The recipient can now open the secret.": "Compartido. El destinatario ya puede abrir el secreto.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "El destinatario aún no ha configurado Keepiq, así que no se compartió nada. Inténtalo de nuevo cuando lo haya hecho.", + "Could not share the secret. Only its owner can approve this.": "No se pudo compartir el secreto. Solo su propietario puede aprobarlo.", + "Could not share the secret. Try again.": "No se pudo compartir el secreto. Inténtalo de nuevo.", + "Denied. Nothing was shared.": "Denegado. No se compartió nada.", + "Could not deny the request. Try again.": "No se pudo denegar la solicitud. Inténtalo de nuevo.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s te pide que compartas el secreto \"%2$s\" con %3$s.", + "Expires on (optional)": "Caduca el (opcional)", + "Hand over to": "Entregar a", + "Choose a recipient": "Elige un destinatario", + "Hand over temporarily": "Entregar temporalmente", + "Expiry rules": "Reglas de caducidad", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Define cuánto tiempo pueden durar las contraseñas de un tipo de elemento o de una carpeta, y cuándo recibir un recordatorio. Si se aplican varias fechas, cuenta la más temprana.", + "Delete rule": "Eliminar regla", + "Set by your administrator": "Establecido por tu administrador", + "No expiry rules yet.": "Aún no hay reglas de caducidad.", + "Applies to": "Se aplica a", + "Item type": "Tipo de elemento", + "Maximum age in days (empty for reminders only)": "Antigüedad máxima en días (vacío solo para recordatorios)", + "Remind me this many days before, comma separated": "Recordarme con estos días de antelación, separados por comas", + "Save rule": "Guardar regla", + "An item type": "Un tipo de elemento", + "A folder": "Una carpeta", + "Folder {name}": "Carpeta {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Caduca tras {days} días", + "Reminders {days} days before": "Recordatorios {days} días antes", + "Could not save the expiry rule.": "No se pudo guardar la regla de caducidad.", + "Could not delete the expiry rule.": "No se pudo eliminar la regla de caducidad.", + "All statuses": "Todos los estados", + "Compromised": "Comprometida", + "Could not load the members.": "No se pudieron cargar los miembros.", + "Emergency contact": "Contacto de emergencia", + "Leaving user": "Usuario saliente", + "No": "No", + "No users match this filter.": "Ningún usuario coincide con este filtro.", + "Not set up": "Sin configurar", + "Revoke suite": "Revocar suite", + "Revoked": "Revocada", + "Search users": "Buscar usuarios", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vea qué usuarios han configurado una bóveda. Inicie la baja o revoque una suite desde una fila.", + "Successor": "Sucesor", + "Team folders": "Carpetas de equipo", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "El usuario sigue en el grupo {groups}, que es miembro de una carpeta de equipo. Quítelo del grupo o desactive la cuenta.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "El usuario sigue en los grupos {groups}, que son miembros de carpetas de equipo. Quítelo de los grupos o desactive la cuenta.", + "Vault status": "Estado de la bóveda", + "Yes": "Sí", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Una exportación CXF NO ESTÁ CIFRADA. Todas las contraseñas e inicios de sesión serán legibles como texto plano en el archivo descargado. Guárdelo de forma segura y elimínelo inmediatamente después de usarlo.", + "Root certificate expiring soon": "El certificado raíz caduca pronto", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "El certificado raíz de la bóveda caduca en %1$d día(s). Renuévelo antes. Al renovarlo se vuelve a firmar cada suite de cifrado.", + "Compromise recovery aborted": "Recuperación tras compromiso cancelada", + "Key rotation ended by a compromise revoke": "Rotación de clave finalizada por una revocación por compromiso", + "Encryption suite revoke refused": "Revocación de la suite de cifrado rechazada", + "Master password proof refused": "Prueba de la contraseña maestra rechazada", + "Your current master password": "Tu contraseña maestra actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacto de emergencia tenía una solicitud de acceso pendiente cuando tu rotación de clave lo eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactos de emergencia tenían una solicitud de acceso pendiente cuando tu rotación de clave los eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Estos contactos de emergencia no se trasladaron a tu nueva clave. Su acceso de emergencia se eliminó. Vuelve a añadirlos desde Acceso de emergencia si aún los quieres.", + "Renew root certificate": "Renovar el certificado raíz", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Esto crea un certificado raíz y uno intermedio nuevos. Cada conjunto de cifrado activo se vuelve a firmar. No se puede deshacer.", + "Renew root": "Renovar raíz", + "Root renewed. {n} encryption suites signed again.": "Raíz renovada. Conjuntos de cifrado firmados de nuevo: {n}.", + "Could not renew the root certificate.": "No se pudo renovar el certificado raíz.", + "Lease policy for this application": "Política de concesión para esta aplicación", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigor ahora: {default} segundos por defecto, {max} segundos como máximo.", + "Leases are not renewable": "Las concesiones no se pueden renovar", + "Lease policy saved.": "Política de concesión guardada.", + "Leave a field empty to use the instance value.": "Deja un campo vacío para usar el valor de la instancia.", + "Instance value: {value}": "Valor de la instancia: {value}", + "Renewal": "Renovación", + "Use the instance value ({value})": "Usar el valor de la instancia ({value})", + "Allowed": "Permitido", + "Not allowed": "No permitido", + "Save lease policy": "Guardar política de concesión", + "Only an administrator can change this policy.": "Solo un administrador puede cambiar esta política.", + "Could not save the lease policy.": "No se pudo guardar la política de concesión.", + "{member} got access from {confirmer}.": "{member} recibió acceso de {confirmer}.", + "Automatically confirm new team folder members": "Confirmar automáticamente los nuevos miembros de carpetas de equipo", + "Gave %n new member access to a team folder.": "%n nuevo miembro recibió acceso a una carpeta de equipo.", + "Gave %n new members access to a team folder.": "%n nuevos miembros recibieron acceso a una carpeta de equipo.", + "Give new team folder members access without waiting for the folder owner.": "Dé acceso a los nuevos miembros sin esperar al propietario de la carpeta.", + "New team folder members": "Nuevos miembros de carpetas de equipo", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "El propietario o un miembro con permiso de escritura los confirma desde su bóveda abierta. Keepiq nunca descifra en el servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Esperando a que un miembro con permiso de escritura abra Keepiq. También puede compartir ahora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte de la respuesta al compromiso falló ({failed} paso(s)). Revise el registro del servidor y vuelva a revocar la suite para terminarla.", + "This also revoked suite {suite} and ended key migration {migration}.": "Esto también revocó la suite {suite} y finalizó la migración de claves {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocar la segunda suite eliminó %n contacto de acceso de emergencia.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocar la segunda suite eliminó %n contactos de acceso de emergencia.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocada como comprometida no se puede restablecer.", + "Archives to keep": "Archivos a conservar", + "Back up every vault automatically": "Hacer copia de cada bóveda automáticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Haga copia de cada bóveda según un calendario. Los archivos solo contienen texto cifrado y se restauran con occ.", + "Back up now": "Hacer copia ahora", + "Backup public key (PEM, optional)": "Clave pública de copia (PEM, opcional)", + "Backup requested for the next cron run": "Copia solicitada para la próxima ejecución de cron", + "Encrypted": "Cifrado", + "Every (hours)": "Cada (horas)", + "Last backup {when} failed: {error}": "Última copia {when} fallida: {error}", + "Last backup {when} succeeded.": "Última copia {when} correcta.", + "No archives yet.": "Aún no hay archivos.", + "Size": "Tamaño", + "Vault backups": "Copias de la bóveda", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Con una clave, cada archivo se cifra para ella. Guarde la clave privada fuera de este servidor: la necesita para verificar o restaurar.", + "Written": "Escrito", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n usuario en el ámbito aún no tiene inicio de sesión en dos pasos y no puede abrir la bóveda mientras esto esté activo.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n usuarios en el ámbito aún no tienen inicio de sesión en dos pasos y no pueden abrir la bóveda mientras esto esté activo.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Los códigos de respaldo no cuentan. Si sus usuarios inician sesión con un proveedor de identidad con su propio segundo factor, excluya sus grupos.", + "Block personal vault export": "Bloquear la exportación de la bóveda personal", + "Keep work logins in team folders": "Guardar los inicios de sesión de trabajo en carpetas de equipo", + "Move to a team folder": "Mover a una carpeta de equipo", + "Not in a team folder": "No está en una carpeta de equipo", + "Only for these groups (empty is everyone)": "Solo para estos grupos (vacío es todos)", + "Require two-factor login before the vault opens": "Exigir inicio de sesión en dos pasos antes de abrir la bóveda", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglas para cada bóveda. Cada una se aplica a todos, o solo a los grupos que elija.", + "Secret types that belong in a team folder": "Tipos de secreto que van en una carpeta de equipo", + "Set up two-factor login": "Configurar el inicio de sesión en dos pasos", + "Team folder you can write to": "Carpeta de equipo en la que puede escribir", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Los usuarios no pueden descargar una copia de seguridad, un CSV ni un archivo de transferencia. Su paquete de datos personales sigue disponible.", + "Users cannot save these secret types in a personal folder.": "Los usuarios no pueden guardar estos tipos de secreto en una carpeta personal.", + "Vault policies": "Políticas de la bóveda", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Su organización no permite exportar su bóveda personal. Su paquete de datos personales en la configuración sigue disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Su organización guarda estos secretos en una carpeta de equipo. Mueva cada uno a una carpeta de equipo.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Su organización guarda este tipo de secreto en una carpeta de equipo. Elija una de sus carpetas de equipo, o una en la que pueda escribir.", + "Your organisation requires two-factor login before you can open your vault.": "Su organización exige el inicio de sesión en dos pasos antes de que pueda abrir su bóveda.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Los usuarios eligen cuánto tiempo permanece desbloqueada la extensión sin actividad. Usted fija el máximo que pueden elegir.", + "Longest idle time before the extension locks": "Tiempo de inactividad máximo antes de que la extensión se bloquee", + "1 minute": "1 minuto", + "5 minutes": "5 minutos", + "15 minutes": "15 minutos", + "1 hour": "1 hora", + "4 hours": "4 horas", + "Connector": "Conector", + "Directory (tenant) ID": "ID de directorio (inquilino)", + "Application (client) ID": "ID de aplicación (cliente)", + "Data collection rule immutable ID": "ID inmutable de la regla de recopilación de datos", + "Stream name": "Nombre del flujo", + "Splunk index (optional)": "Índice de Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Déjelo en blanco para conservar el actual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF sobre syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punto de conexión de recopilación de datos (URL https)", + "HTTP Event Collector URL (https)": "URL del HTTP Event Collector (https)", + "Client secret (write-only)": "Secreto de cliente (solo escritura)", + "HEC token (write-only)": "Token HEC (solo escritura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reenvíe los eventos de auditoría permitidos a Splunk, Microsoft Sentinel, un receptor syslog o un webhook. Los mensajes solo llevan metadatos depurados: ningún valor secreto, nombre, inicio de sesión o texto cifrado sale nunca del servidor.", + "%n change waiting to sync": "%n cambio pendiente de sincronizar", + "%n changes waiting to sync": "%n cambios pendientes de sincronizar", + "Changes that could not sync": "Cambios que no se pudieron sincronizar", + "Choose a version": "Elegir una versión", + "Copy value": "Copiar valor", + "Deleted": "Eliminado", + "Discard": "Descartar", + "Keep my offline change": "Conservar mi cambio sin conexión", + "Keep the server version": "Conservar la versión del servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq es de solo lectura sin conexión. Su administrador no ha activado la edición sin conexión.", + "Let users edit secrets offline": "Permitir a los usuarios editar secretos sin conexión", + "Not synced yet": "Aún sin sincronizar", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Los cambios sin conexión se guardan en el dispositivo, cifrados para el usuario, y se sincronizan en el siguiente desbloqueo en línea. Compartir, carpetas y adjuntos siguen necesitando conexión.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Sin conexión. Las ediciones, movimientos y eliminaciones se quedan en este dispositivo y se sincronizan cuando vuelva a estar en línea. Compartir y adjuntos necesitan conexión.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Sin conexión. Sus cambios se quedan en este dispositivo y se sincronizan cuando vuelva a estar en línea. Última sincronización {when}.", + "Open my changes": "Abrir mis cambios", + "Sharing needs a connection": "Compartir necesita conexión", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Alguien cambió este secreto en el servidor después de crear su copia sin conexión. Elija qué versión conservar.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronice o descarte sus cambios sin conexión antes de renovar sus claves.", + "That password did not open your changes.": "Esa contraseña no abrió sus cambios.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La instantánea sin conexión guarda secretos cifrados (solo se abren con la clave derivada de la contraseña maestra del usuario, igual que en el servidor) y cifra en reposo los nombres, URL y nombres de carpetas. El acceso sin conexión es de solo lectura, salvo que permita abajo la edición sin conexión. Desactívelo en equipos que nunca deban guardar credenciales; al desactivarlo se borran las cachés existentes en la siguiente carga.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia anterior de la bóveda ya no está, así que estos cambios no se pueden abrir.", + "The server version": "La versión del servidor", + "This secret changed while you were offline": "Este secreto cambió mientras estaba sin conexión", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Eliminó este secreto sin conexión, pero desde entonces se cambió en el servidor. Elija qué versión conservar.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Sus claves se cambiaron en otro dispositivo. Introduzca su contraseña maestra anterior para sincronizar los cambios sin conexión, o descártelos.", + "Your offline change": "Su cambio sin conexión", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contacto de emergencia tenía una solicitud de acceso pendiente cuando tu rotación de clave lo eliminó. Comprueba quién la pidió antes de volver a añadir a nadie.", + "%n contactos de emergencia tenían una solicitud de acceso pendiente cuando tu rotación de clave los eliminó. Comprueba quién la pidió antes de volver a añadir a nadie." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n elemento no se puede representar en CXF y se omitirá.", + "%n elementos no se pueden representar en CXF y se omitirán." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "Se descartó %n versión anterior porque solo se puede trasladar el historial reciente.", + "Se descartaron %n versiones anteriores porque solo se puede trasladar el historial reciente." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Todavía hay que cifrar y compartir %n copia del secreto.", + "Todavía hay que cifrar y compartir %n copias del secreto." + ], + "_%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 secreto no se pudo descifrar y no está en esta exportación.", + "%n secretos no se pudieron descifrar y no están en esta exportación." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n secreto no se pudo descifrar con su clave antigua, por lo que no se migró.", + "%n secretos no se pudieron descifrar con su clave antigua, por lo que no se migraron." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n secreto no se migró.", + "%n secretos no se migraron." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n secreto sigue cifrado con su clave anterior.", + "%n secretos siguen cifrados con su clave anterior." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "Se omitió %n secreto porque el sucesor aún no tiene una copia: añada el sucesor a la carpeta y vuelva a ejecutarlo.", + "Se omitieron %n secretos porque el sucesor aún no tiene una copia: añada el sucesor a la carpeta y vuelva a ejecutarlo." + ], + "_%n secret_::_%n secrets_": [ + "%n secreto", + "%n secretos" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n usuario en el ámbito aún no tiene inicio de sesión en dos pasos y no puede abrir la bóveda mientras esto esté activo.", + "%n usuarios en el ámbito aún no tienen inicio de sesión en dos pasos y no pueden abrir la bóveda mientras esto esté activo." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Finalizar de todos modos, perdiendo el acceso a %n secreto", + "Finalizar de todos modos, perdiendo el acceso a %n secretos" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nuevo miembro recibió acceso a una carpeta de equipo.", + "%n nuevos miembros recibieron acceso a una carpeta de equipo." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotación de clave finalizada. %n secreto se volvió a cifrar con su nueva clave.", + "Rotación de clave finalizada. %n secretos se volvieron a cifrar con su nueva clave." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revocar la segunda suite eliminó %n contacto de acceso de emergencia.", + "Revocar la segunda suite eliminó %n contactos de acceso de emergencia." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revocar esta suite eliminó %n contacto de acceso de emergencia.", + "Revocar esta suite eliminó %n contactos de acceso de emergencia." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "visto %n vez en filtraciones", + "visto %n veces en filtraciones" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "compartido con %n secreto", + "compartido con %n secretos" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Esta carpeta contiene directamente %n secreto.", + "Esta carpeta contiene directamente %n secretos." + ], + "_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._": [ + "Tu rotación de clave retiró %n contacto de emergencia. Revisa Acceso de emergencia y vuelve a añadirlo si aún lo quieres.", + "Tu rotación de clave retiró %n contactos de emergencia. Revisa Acceso de emergencia y vuelve a añadirlos si aún los quieres." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n cambio pendiente de sincronizar", + "%n cambios pendientes de sincronizar" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "El usuario sigue en el grupo {groups}, que es miembro de una carpeta de equipo. Quítelo del grupo o desactive la cuenta.", + "El usuario sigue en los grupos {groups}, que son miembros de carpetas de equipo. Quítelo de los grupos o desactive la cuenta." + ], + "Allow approval from another device": "Permitir la aprobación desde otro dispositivo", + "App": "Aplicación", + "Approve a new device": "Aprobar un dispositivo nuevo", + "Approve from another device": "Aprobar desde otro dispositivo", + "Asked at": "Solicitado a las", + "Check that the new device shows these words:": "Comprueba que el dispositivo nuevo muestra estas palabras:", + "Denied. If you did not ask, end your other sessions:": "Denegado. Si no lo solicitaste, cierra tus otras sesiones:", + "Device": "Dispositivo", + "IP address": "Dirección IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permite a los usuarios desbloquear un navegador nuevo aprobándolo desde un dispositivo donde Keepiq ya está desbloqueado.", + "New device approval": "Aprobación de dispositivos nuevos", + "Nextcloud security settings": "Ajustes de seguridad de Nextcloud", + "Only approve a device you are using right now.": "Aprueba solo un dispositivo que estés usando ahora mismo.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Abre Keepiq en un dispositivo donde esté desbloqueado y aprueba este. Comprueba que muestra las mismas palabras:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "El dispositivo que aprueba sella la clave de desbloqueo para el dispositivo nuevo. El servidor solo la transmite y no puede abrirla.", + "The master password is not right, or the request has ended.": "La contraseña maestra no es correcta o la solicitud ha terminado.", + "The request expired. Ask again or use your master password.": "La solicitud ha caducado. Vuelve a solicitarlo o usa tu contraseña maestra.", + "The request was denied.": "La solicitud fue denegada.", + "Too many requests. Try again in an hour or use your master password.": "Demasiadas solicitudes. Inténtalo de nuevo en una hora o usa tu contraseña maestra.", + "Unknown device": "Dispositivo desconocido", + "Web app": "Aplicación web", + "A device": "Un dispositivo", + "A new device asks to open your vault": "Un dispositivo nuevo pide abrir tu bóveda", + "%s asks to be approved. Only approve a device you are using right now.": "%s pide ser aprobado. Aprueba solo un dispositivo que estés usando ahora mismo.", + "Access ends on (optional)": "El acceso termina el (opcional)", + "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.": "Las apps de Keepiq no mostrarán ni copiarán la contraseña. Alguien con conocimientos técnicos aún puede leerla desde su propio dispositivo. Cámbiala cuando termine su acceso.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Este secreto es solo de uso. Inicia sesión mediante la extensión de navegador de Keepiq.", + "Until {date}": "Hasta el {date}", + "Use only": "Solo uso", + "Use only (can sign in, cannot view or copy)": "Solo uso (puede iniciar sesión, no puede ver ni copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Puedes iniciar sesión con estas credenciales mediante la extensión de navegador de Keepiq. Su propietario decidió no permitirte verlas ni copiarlas.", + "Your access ends on {date}": "Tu acceso termina el {date}", + "Your access to this secret has ended": "Tu acceso a este secreto ha terminado", + "Your access to \"%s\" ends tomorrow": "Tu acceso a «%s» termina mañana", + "Your access to \"%s\" has ended": "Tu acceso a «%s» ha terminado", + "%1$s no longer has access to \"%2$s\"": "%1$s ya no tiene acceso a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podía ver esta contraseña. Cámbiala si %1$s ya no debe conocerla.", + "%s could not view this password in Keepiq.": "%s no pudo ver esta contraseña en Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprobaciones", + "a recovery officer": "un responsable de recuperación", + "Account recovery": "Recuperación de cuenta", + "Approvals needed": "Aprobaciones necesarias", + "Ask {user} which words they see, by phone or in person. They must be:": "Pregunte a {user} qué palabras ve, por teléfono o en persona. Deben ser:", + "Check again": "Comprobar de nuevo", + "Create the recovery key": "Crear la clave de recuperación", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Cree la clave de recuperación. Su navegador la genera y da a cada responsable una copia que solo él puede abrir.", + "Decline": "Rechazar", + "Enrol in account recovery": "Inscribirse en la recuperación de cuenta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscríbase para que su organización pueda ayudarle a recuperar su bóveda si olvida su contraseña maestra.", + "Every user is enrolled": "Todos los usuarios están inscritos", + "Finish the recovery in the browser you asked from.": "Termine la recuperación en el navegador desde el que la pidió.", + "Forgot your master password?": "¿Ha olvidado su contraseña maestra?", + "Hand the key over": "Entregar la clave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permita que los usuarios que olvidaron su contraseña maestra recuperen su bóveda, con la aprobación de los responsables de recuperación que designe.", + "New master password": "Nueva contraseña maestra", + "No one is asking to recover their account.": "Nadie está pidiendo recuperar su cuenta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Todavía no hay clave de recuperación. Uno de los responsables la crea en su configuración de Keepiq.", + "Off": "Desactivado", + "Officer {user} has no encryption set up yet.": "El responsable {user} aún no ha configurado el cifrado.", + "Officers (user IDs, separated by commas)": "Responsables (ID de usuario, separados por comas)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publique esta huella internamente para que los usuarios puedan comprobarla antes de inscribirse.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperado con ayuda de {officer}. Cambie ahora la clave de su bóveda en Configuración, Seguridad: \"Mi contraseña maestra se ha visto comprometida\".", + "Recovery key fingerprint: {fingerprint}": "Huella de la clave de recuperación: {fingerprint}", + "Recovery officer": "Responsable de recuperación", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Los responsables eliminados pierden ahora su copia, pero pueden haberla abierto antes. Pida a un responsable que cree una nueva clave de recuperación.", + "Repeat the new master password": "Repita la nueva contraseña maestra", + "Retire this recovery key": "Retirar esta clave de recuperación", + "Set the new master password": "Establecer la nueva contraseña maestra", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "El certificado de recuperación no lo ha emitido este Keepiq. No se inscriba y avise a su administrador.", + "The words match, approve": "Las palabras coinciden, aprobar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Este usuario está inscrito en la recuperación de cuenta. Recuperar conserva sus secretos; revocar elimina su inscripción.", + "Users may enrol": "Los usuarios pueden inscribirse", + "Withdraw from account recovery": "Salir de la recuperación de cuenta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Está inscrito en la recuperación de cuenta. Huella de la clave de recuperación: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Está inscrito. Si olvida su contraseña maestra, su organización puede ayudarle a recuperar su bóveda.", + "Your key is back. Choose a new master password.": "Ha recuperado su clave. Elija una nueva contraseña maestra.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Se ha avisado a sus responsables de recuperación. Léales estas palabras cuando le llamen o se reúnan con usted:", + "You are now an account recovery officer": "Ahora es responsable de recuperación de cuentas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pide recuperar su cuenta. Compare las palabras con esa persona antes de aprobar.", + "A user": "Un usuario", + "Your account recovery request was declined": "Su solicitud de recuperación de cuenta ha sido rechazada", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La recuperación de su cuenta está lista. Abra Keepiq en el navegador desde el que la pidió.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pide desbloquear un dispositivo nuevo una sola vez. Conserva su contraseña maestra.", + "Ask your organisation instead": "Pídeselo a tu organización", + "The request ended. Ask again or use your master password.": "La solicitud ha terminado. Vuelve a pedirlo o usa tu contraseña maestra.", + "Added by {user}": "Añadido por {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Rol de {member}", + "Team folders you manage": "Carpetas de equipo que gestionas", + "Viewer": "Lector", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "No tienes ninguna copia de estos secretos, así que los nuevos miembros aún no los han recibido. El propietario puede compartirlos: {names}", + "Admin areas": "Áreas de administración", + "Give a group only the parts of Keepiq administration it needs.": "Dé a un grupo solo las partes de la administración de Keepiq que necesita.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegue una o más áreas a un grupo en la página de privilegios de administración. Los administradores de la instancia tienen todas las áreas.", + "Open administration privileges": "Abrir privilegios de administración", + "Policies": "Políticas", + "Applications and machine access": "Aplicaciones y acceso de máquinas", + "People and offboarding": "Personas y bajas", + "Audit and compliance": "Auditoría y cumplimiento", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versión, autoridad de certificación, adjuntos, caché sin conexión, comprobación de filtraciones, tipos de secretos y copias de seguridad", + "master password, organisation password, vault policies, rotation, version history and trash": "contraseña maestra, contraseña de la organización, políticas de la bóveda, rotación, historial de versiones y papelera", + "application queue, application requests and machine leases": "cola de aplicaciones, solicitudes de aplicaciones y concesiones de máquinas", + "team offboarding, encryption suites and admin handover": "bajas de equipo, suites de cifrado y traspaso por el administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registro de auditoría, informes de cumplimiento, exportación SIEM y alertas señuelo", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cuántas versiones de un secreto se guardan, durante cuánto tiempo y cuánto tiempo permanecen en la papelera los secretos eliminados.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Límites para adjuntos cifrados, aplicados en el servidor en bytes cifrados almacenados.", + "Type the suite ID again to confirm": "Vuelva a escribir el ID del conjunto para confirmar", + "This does not match the suite ID.": "No coincide con el ID del conjunto.", + "Confirm with your master password": "Confirme con su contraseña maestra", + "Confirm": "Confirmar", + "That master password is not right.": "Esa contraseña maestra no es correcta.", + "You are sharing with someone new. Enter your master password to confirm.": "Está compartiendo con alguien nuevo. Introduzca su contraseña maestra para confirmar.", + "Enter your master password to confirm this share.": "Introduzca su contraseña maestra para confirmar este uso compartido.", + "Enter your master password to confirm this delegation.": "Introduzca su contraseña maestra para confirmar esta delegación.", + "Approve {member}": "Aprobar a {member}", + "Recipient": "Destinatario", + "No vault yet": "Todavía sin bóveda", + "No matching users": "No hay usuarios que coincidan", + "Partner organisations": "Organizaciones asociadas", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Intercambia secretos con otro Keepiq. Ambos administradores se añaden mutuamente y comparan las huellas raíz por teléfono o en persona antes de guardar.", + "Federation needs Nextcloud 33 or later.": "La federación requiere Nextcloud 33 o posterior.", + "Your root fingerprint": "Tu huella raíz", + "No partners yet.": "Todavía no hay socios.", + "Users here may share to this partner": "Los usuarios de aquí pueden compartir con este socio", + "This partner may share to users here": "Este socio puede compartir con los usuarios de aquí", + "Partner address": "Dirección del socio", + "Check partner": "Comprobar socio", + "Partner root fingerprint": "Huella raíz del socio", + "I compared this fingerprint with the partner's administrator": "He comparado esta huella con el administrador del socio", + "Add partner": "Añadir socio", + "A secret from another organisation": "Un secreto de otra organización", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha compartido \"%2$s\" con usted. Acéptelo en Recibidos de otras organizaciones.", + "Incoming from other organisations": "Recibidos de otras organizaciones", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Las personas de organizaciones asociadas pueden compartir un secreto con usted. Acéptelo para guardar una copia de solo lectura en su bóveda.", + "Nothing shared with you yet": "Todavía no se ha compartido nada con usted", + "Secrets that people in partner organisations share with you appear here.": "Los secretos que las personas de organizaciones asociadas comparten con usted aparecen aquí.", + "From {sender}": "De {sender}", + "Accept": "Aceptar", + "Open in vault": "Abrir en la bóveda", + "The other organisation did not hand over the secret. Try again later.": "La otra organización no entregó el secreto. Inténtelo de nuevo más tarde.", + "Set up your vault before you accept a shared secret.": "Configure su bóveda antes de aceptar un secreto compartido.", + "Something went wrong. Try again.": "Algo salió mal. Inténtelo de nuevo.", + "Waiting for your answer": "Esperando su respuesta", + "In your vault, read-only": "En su bóveda, solo lectura", + "Withdrawn by the sender": "Retirado por el remitente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} compartió esto desde otra organización. Puede leerlo, pero no modificarlo ni compartirlo.", + "Someone": "Alguien", + "Share with someone at another organisation": "Compartir con alguien de otra organización", + "Their account at the other organisation": "Su cuenta en la otra organización", + "Check account": "Comprobar cuenta", + "Certificate fingerprint of {account}": "Huella digital del certificado de {account}", + "Compare it with them by phone if you want to be sure.": "Compárela con esa persona por teléfono si quiere estar seguro.", + "Shared. {account} can accept it in their own vault.": "Compartido. {account} puede aceptarlo en su propia bóveda.", + "The certificate could not be verified. Nothing was shared.": "No se pudo verificar el certificado. No se ha compartido nada.", + "That organisation is not one of your partners.": "Esa organización no es una de sus organizaciones asociadas.", + "No one with that account can receive secrets from you.": "Nadie con esa cuenta puede recibir secretos de usted.", + "The other organisation did not answer. Try again later.": "La otra organización no respondió. Inténtelo de nuevo más tarde.", + "This secret is already shared with that account.": "Este secreto ya está compartido con esa cuenta.", + "Other organisations": "Otras organizaciones", + "Receive secrets from other organisations": "Recibir secretos de otras organizaciones", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Las personas de organizaciones asociadas podrán encontrar su cuenta y compartir secretos con usted. Usted acepta cada uno personalmente.", + "Shared": "Compartido", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: ha cambiado el certificado del destinatario o la asociación. Revóquelo o vuelva a compartirlo.", + "Their organisation did not get the last change. Revoke it or share again.": "La organización del destinatario no recibió el último cambio. Revóquelo o vuelva a compartirlo.", + "Being withdrawn": "Retirándose", + "Shared with another organisation": "Compartido con otra organización", + "Change sent to another organisation": "Cambio enviado a otra organización", + "Share with another organisation revoked": "Acceso compartido con otra organización revocado", + "Share with another organisation paused": "Acceso compartido con otra organización en pausa", + "Another organisation did not get a change": "Otra organización no recibió un cambio", + "Secret received from another organisation": "Secreto recibido de otra organización", + "Secret from another organisation accepted": "Secreto de otra organización aceptado", + "Secret from another organisation declined": "Secreto de otra organización rechazado", + "Copy from another organisation updated": "Copia de otra organización actualizada", + "Copy from another organisation removed": "Copia de otra organización eliminada", + "Declined: they removed their copy. Share again if they need it.": "Rechazado: el destinatario eliminó su copia. Vuelva a compartirlo si la necesita.", + "Recipient at another organisation removed their copy": "Un destinatario de otra organización eliminó su copia", + "Removed the user from %n team folder.": "Se eliminó al usuario de %n carpeta de equipo.", + "Removed the user from %n team folders.": "Se eliminó al usuario de %n carpetas de equipo.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Se eliminó al usuario de %n carpeta de equipo.", + "Se eliminó al usuario de %n carpetas de equipo." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Una copia restaurada procede de un uso compartido que ha terminado. Sigue siendo de solo lectura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "No se pudo contactar con la organización que compartió una copia restaurada. La copia sigue siendo de solo lectura y no sigue sus cambios.", + "Recipient at another organisation restored their copy": "Un destinatario de otra organización restauró su copia" }, "plurals": null } diff --git a/l10n/et.js b/l10n/et.js index c78b1de8c..a3921cda9 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Jagatud gruppidega", + "Not shared with any group yet.": "Pole veel ühegi grupiga jagatud.", + "Revoke the share with {group}": "Tühista jagamine grupiga {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jagatud grupiga {group}: {received} liiget said selle kätte, {skipped} mitte, sest neil pole veel krüpteerimist seadistatud.", + "Search groups": "Otsi gruppe", + "Failed to share": "Jagamine ebaõnnestus", + "Columns": "Veerud", + "Column {number}": "Veerg {number}", + "Map one column to Name. Every secret needs a name.": "Seo üks veerg nimega. Igal saladusel peab olema nimi.", + "Notes": "Märkmed", + "Do not import": "Ära impordi", + "Hide this value": "Peida see väärtus", + "Show this value": "Näita seda väärtust", + "Defaults": "Vaikeväärtused", + "New secrets start as this type, and your secret list opens in this view.": "Uued saladused algavad selle tüübiga ja sinu saladuste loend avaneb selles vaates.", + "Default item type": "Vaikimisi kirje tüüp", + "Cards": "Kaardid", + "Table": "Tabel", + "Could not save your default": "Vaikeväärtust ei õnnestunud salvestada", + "Recently used": "Hiljuti kasutatud", + "Opened": "Avatud", + "You have not opened any secrets yet": "Sa pole veel ühtegi saladust avanud", + "Could not delete the item type.": "Kirje tüüpi ei õnnestunud kustutada.", + "Could not load the item types.": "Kirje tüüpe ei õnnestunud laadida.", + "Could not save the item type.": "Kirje tüüpi ei õnnestunud salvestada.", + "Delete item type": "Kustuta kirje tüüp", + "Edit item type": "Muuda kirje tüüpi", + "Fields": "Väljad", + "Fields: {count}": "Väljad: {count}", + "Hidden": "Peidetud", + "Item types": "Kirje tüübid", + "Move up": "Liiguta üles", + "New item type": "Uus kirje tüüp", + "No item types defined yet.": "Kirje tüüpe pole veel määratud.", + "Required": "Kohustuslik", + "Text": "Tekst", + "This field is required": "See väli on kohustuslik", + "Web address": "Veebiaadress", + "{label} (required)": "{label} (kohustuslik)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Kas kustutada „{name}“? Selle tüübi saladused jäävad loetavaks ja muutuvad Sisselogimise kirjeteks.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Siin määratud kirje tüübid ilmuvad kõigile aknas Uus saladus, sinu valitud väljadega.", + "Secret moved to the trash": "Saladus viidi prügikasti", + "Secret restored from the trash": "Saladus taastati prügikastist", + "Secret deleted for good": "Saladus kustutati jäädavalt", + "Secret archived": "Saladus arhiveeriti", + "Secret unarchived": "Saladus toodi arhiivist välja", + "Unarchive": "Too arhiivist välja", + "Could not archive the secret": "Saladust ei õnnestunud arhiveerida", + "Could not unarchive the secret": "Saladust ei õnnestunud arhiivist välja tuua", + "Archive {count} secrets": "Arhiveeri saladused: {count}", + "Unarchive {count} secrets": "Too arhiivist välja saladused: {count}", + "Restore {count} secrets": "Taasta saladused: {count}", + "Delete {count} secrets for good": "Kustuta jäädavalt saladused: {count}", + "Done for {ok} of {total} secrets": "Valmis {ok} saladusel {total}-st", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhiveeritud saladused kaovad hoidla loendist, otsingust, automaattäitest ja tervisearuandest. Nende jagamised jäävad alles. Leiad need jaotisest Arhiiv.", + "These secrets come back to the vault list, search and autofill.": "Need saladused tulevad tagasi hoidla loendisse, otsingusse ja automaattäitesse.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Need saladused tulevad tagasi hoidla loendisse. Nende vanad jagamised tagasi ei tule, nii et jaga neid vajadusel uuesti.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "See kustutab saladused koos manuste ja versiooniajalooga. Seda ei saa tagasi võtta.", + "Delete for good": "Kustuta jäädavalt", + "Trash": "Prügikast", + "The trash is empty": "Prügikast on tühi", + "No archived secrets": "Arhiveeritud saladusi pole", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Kustutatud saladused ootavad siin säilitusaja lõpuni, seejärel kustutatakse need jäädavalt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhiveeri saladus selle üksikasjade paneelilt, et hoida see eemal hoidla loendist, otsingust ja automaattäitest.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Krüpteeritud manuste piirangud (serveris jõustatud salvestatud krüpteeritud baitides), versiooniajaloo säilitamine ja kui kaua kustutatud saladused prügikastis püsivad.", + "Days a deleted secret stays in the trash (1 to 365)": "Päevad, mil kustutatud saladus püsib prügikastis (1 kuni 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "See viib saladuse prügikasti ja lõpetab selle jagamised kohe. Saad selle prügikastist taastada säilitusaja lõpuni: 30 päeva, kui administraator pole seda muutnud.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "See viib saladused prügikasti ({count}) ja lõpetab nende jagamised kohe. Saad need prügikastist taastada säilitusaja lõpuni.", + "Remove {name} from favourites": "Eemalda {name} lemmikutest", + "Add {name} to favourites": "Lisa {name} lemmikutesse", + "Could not change the favourite": "Lemmikut ei õnnestunud muuta", + "Remove from favourites": "Eemalda lemmikutest", + "Add to favourites": "Lisa lemmikutesse", + "Tags": "Sildid", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Sildid ei ole krüpteeritud. Serveri administraatorid saavad neid lugeda, nagu kaustade nimesid.", + "Favourites": "Lemmikud", + "Filter by tag": "Filtreeri sildi järgi", + "All tags": "Kõik sildid", + "Last used": "Viimati kasutatud", + "Tags for {count} secrets": "Sildid {count} saladusele", + "Tag": "Silt", + "Remove tag": "Eemalda silt", + "Add tag": "Lisa silt", + "Could not change the tags. Try again.": "Silte ei õnnestunud muuta. Proovi uuesti.", + "Could not approve the application. It is still in the queue.": "Taotlust ei õnnestunud kinnitada. See on endiselt järjekorras.", + "Could not reject the application. It is still in the queue.": "Taotlust ei õnnestunud tagasi lükata. See on endiselt järjekorras.", + "Removed the user from {count} team folders.": "Kasutaja eemaldati {count} meeskonnakaustast.", + "Approve a share": "Kinnita jagamine", + "This approval link is incomplete. Open it again from the notification.": "See kinnituslink on puudulik. Ava see uuesti teavitusest.", + "Deny": "Keeldu", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} liitus grupiga, kellega sa jagad saladust. Kas jagada saladust ka temaga?", + "{requester} asks you to share a secret with {user}.": "{requester} palub sul jagada saladust kasutajaga {user}.", + "Shared. The recipient can now open the secret.": "Jagatud. Saaja saab nüüd saladuse avada.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Saaja pole Keepiqi veel seadistanud, seega midagi ei jagatud. Proovi uuesti, kui ta on seda teinud.", + "Could not share the secret. Only its owner can approve this.": "Saladust ei õnnestunud jagada. Seda saab kinnitada ainult selle omanik.", + "Could not share the secret. Try again.": "Saladust ei õnnestunud jagada. Proovi uuesti.", + "Denied. Nothing was shared.": "Keeldutud. Midagi ei jagatud.", + "Could not deny the request. Try again.": "Taotlusest ei õnnestunud keelduda. Proovi uuesti.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s palub sul jagada saladust \"%2$s\" kasutajaga %3$s.", + "Expires on (optional)": "Aegub (valikuline)", + "Hand over to": "Anna üle kasutajale", + "Choose a recipient": "Vali saaja", + "Hand over temporarily": "Anna ajutiselt üle", + "Expiry rules": "Aegumisreeglid", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Määra, kui kaua ühe kirje tüübi või ühe kausta paroolid võivad kehtida ja millal sulle meelde tuletatakse. Kui kehtib mitu kuupäeva, arvestatakse kõige varasemat.", + "Delete rule": "Kustuta reegel", + "Set by your administrator": "Määratud sinu administraatori poolt", + "No expiry rules yet.": "Aegumisreegleid veel pole.", + "Applies to": "Kehtib", + "Item type": "Kirje tüüp", + "Maximum age in days (empty for reminders only)": "Maksimaalne vanus päevades (tühi ainult meeldetuletuste jaoks)", + "Remind me this many days before, comma separated": "Tuleta meelde nii mitu päeva enne, komadega eraldatult", + "Save rule": "Salvesta reegel", + "An item type": "Kirje tüüp", + "A folder": "Kaust", + "Folder {name}": "Kaust {name}", + "Type {name}": "Tüüp {name}", + "Expires after {days} days": "Aegub {days} päeva pärast", + "Reminders {days} days before": "Meeldetuletused {days} päeva enne", + "Could not save the expiry rule.": "Aegumisreeglit ei õnnestunud salvestada.", + "Could not delete the expiry rule.": "Aegumisreeglit ei õnnestunud kustutada.", + "All statuses": "Kõik olekud", + "Compromised": "Ohustatud", + "Could not load the members.": "Liikmete laadimine ebaõnnestus.", + "Emergency contact": "Hädaabikontakt", + "Leaving user": "Lahkuv kasutaja", + "No": "Ei", + "No users match this filter.": "Ükski kasutaja ei vasta sellele filtrile.", + "Not set up": "Seadistamata", + "Revoke suite": "Tühista komplekt", + "Revoked": "Tühistatud", + "Search users": "Otsi kasutajaid", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vaadake, millised kasutajad on hoidla seadistanud. Alustage lahkumist või tühistage komplekt realt.", + "Successor": "Järglane", + "Team folders": "Meeskonnakaustad", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Kasutaja on endiselt grupis {groups}, mis on meeskonnakausta liige. Eemaldage ta grupist või keelake konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Kasutaja on endiselt gruppides {groups}, mis on meeskonnakaustade liikmed. Eemaldage ta gruppidest või keelake konto.", + "Vault status": "Hoidla olek", + "Yes": "Jah", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-eksport on KRÜPTIMATA. Iga parool ja kasutajanimi on allalaaditud failis loetavad lihttekstina. Hoia seda turvaliselt ja kustuta kohe pärast kasutamist.", + "Root certificate expiring soon": "Juursertifikaat aegub peagi", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Hoidla juursertifikaat aegub %1$d päeva pärast. Uuenda see enne seda. Uuendamine allkirjastab iga krüpteerimiskomplekti uuesti.", + "Compromise recovery aborted": "Kompromiteerimisjärgne taastamine katkestatud", + "Key rotation ended by a compromise revoke": "Võtmevahetus lõpetati kompromiteerimise tõttu tehtud tühistamisega", + "Encryption suite revoke refused": "Krüpteerimiskomplekti tühistamine keelatud", + "Master password proof refused": "Põhiparooli tõend keelatud", + "Your current master password": "Sinu praegune põhiparool", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus ta eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus nad eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Neid hädaabikontakte ei viidud üle sinu uuele võtmele. Nende hädajuurdepääs eemaldati. Lisa nad uuesti jaotises Hädajuurdepääs, kui soovid neid endiselt.", + "Renew root certificate": "Uuenda juursertifikaati", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "See loob uue juur- ja vahesertifikaadi. Iga aktiivne krüpteerimiskomplekt allkirjastatakse uuesti. Seda ei saa tagasi võtta.", + "Renew root": "Uuenda juurt", + "Root renewed. {n} encryption suites signed again.": "Juur uuendatud. Uuesti allkirjastatud krüpteerimiskomplekte: {n}.", + "Could not renew the root certificate.": "Juursertifikaati ei õnnestunud uuendada.", + "Lease policy for this application": "Selle rakenduse rendipoliitika", + "In force now: {default} seconds by default, {max} seconds at most.": "Praegu kehtib: vaikimisi {default} sekundit, kuni {max} sekundit.", + "Leases are not renewable": "Rente ei saa pikendada", + "Lease policy saved.": "Rendipoliitika salvestatud.", + "Leave a field empty to use the instance value.": "Jäta väli tühjaks, et kasutada eksemplari väärtust.", + "Instance value: {value}": "Eksemplari väärtus: {value}", + "Renewal": "Pikendamine", + "Use the instance value ({value})": "Kasuta eksemplari väärtust ({value})", + "Allowed": "Lubatud", + "Not allowed": "Pole lubatud", + "Save lease policy": "Salvesta rendipoliitika", + "Only an administrator can change this policy.": "Seda poliitikat saab muuta ainult administraator.", + "Could not save the lease policy.": "Rendipoliitikat ei õnnestunud salvestada.", + "{member} got access from {confirmer}.": "{member} sai juurdepääsu kasutajalt {confirmer}.", + "Automatically confirm new team folder members": "Kinnita uued meeskonnakaustade liikmed automaatselt", + "Gave %n new member access to a team folder.": "%n uus liige sai juurdepääsu meeskonnakaustale.", + "Gave %n new members access to a team folder.": "%n uut liiget said juurdepääsu meeskonnakaustale.", + "Give new team folder members access without waiting for the folder owner.": "Andke uutele liikmetele juurdepääs kausta omanikku ootamata.", + "New team folder members": "Uued meeskonnakaustade liikmed", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Omanik või kirjutusõigusega liige kinnitab nad oma avatud hoidlast. Keepiq ei dekrüpteeri kunagi serveris.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ootame, et kirjutusõigusega liige avaks Keepiqi. Saate ka kohe jagada.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Osa ohustumisele reageerimisest ebaõnnestus ({failed} samm(u)). Kontrollige serveri logi ja tühistage seejärel komplekt uuesti, et see lõpetada.", + "This also revoked suite {suite} and ended key migration {migration}.": "See tühistas ka komplekti {suite} ja lõpetas võtmete migratsiooni {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.", + "Revoking the second suite deleted %n emergency-access contacts.": "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.", + "A suite revoked as compromised cannot be reinstated.": "Ohustatuna tühistatud komplekti ei saa taastada.", + "Archives to keep": "Säilitatavad arhiivid", + "Back up every vault automatically": "Varunda iga hoidla automaatselt", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varundage iga hoidla ajakava järgi. Arhiivid sisaldavad ainult krüpteeritud teksti ja taastatakse occ-ga.", + "Back up now": "Varunda kohe", + "Backup public key (PEM, optional)": "Varunduse avalik võti (PEM, valikuline)", + "Backup requested for the next cron run": "Varundus taotletud järgmiseks croni käivituseks", + "Encrypted": "Krüpteeritud", + "Every (hours)": "Iga (tunni järel)", + "Last backup {when} failed: {error}": "Viimane varundus {when} ebaõnnestus: {error}", + "Last backup {when} succeeded.": "Viimane varundus {when} õnnestus.", + "No archives yet.": "Arhiive veel pole.", + "Size": "Suurus", + "Vault backups": "Hoidla varundused", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Võtmega krüpteeritakse iga arhiiv selle jaoks. Hoidke privaatvõtit sellest serverist väljas: seda on vaja kontrollimiseks või taastamiseks.", + "Written": "Kirjutatud", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa ta hoidlat avada, kuni see on sees.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa nad hoidlat avada, kuni see on sees.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varukoodid ei loe. Kui kasutajad logivad sisse identiteedipakkuja kaudu, millel on oma teine tegur, jätke nende grupid välja.", + "Block personal vault export": "Blokeeri isikliku hoidla eksport", + "Keep work logins in team folders": "Hoia töökontode andmeid meeskonnakaustades", + "Move to a team folder": "Teisalda meeskonnakausta", + "Not in a team folder": "Pole meeskonnakaustas", + "Only for these groups (empty is everyone)": "Ainult nendele gruppidele (tühi tähendab kõiki)", + "Require two-factor login before the vault opens": "Nõua enne hoidla avamist kaheastmelist sisselogimist", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reeglid igale hoidlale. Iga reegel kehtib kõigile või ainult valitud gruppidele.", + "Secret types that belong in a team folder": "Saladuste tüübid, mis kuuluvad meeskonnakausta", + "Set up two-factor login": "Seadista kaheastmeline sisselogimine", + "Team folder you can write to": "Meeskonnakaust, kuhu saate kirjutada", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Kasutajad ei saa alla laadida varukoopiat, CSV-d ega ülekandefaili. Nende isikuandmete pakett jääb kättesaadavaks.", + "Users cannot save these secret types in a personal folder.": "Kasutajad ei saa neid saladuste tüüpe isiklikku kausta salvestada.", + "Vault policies": "Hoidla reeglid", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Teie organisatsioon ei luba teie isikliku hoidla eksporti. Teie isikuandmete pakett seadetes jääb kättesaadavaks.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Teie organisatsioon hoiab neid saladusi meeskonnakaustas. Teisaldage igaüks meeskonnakausta.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Teie organisatsioon hoiab seda tüüpi saladust meeskonnakaustas. Valige üks oma meeskonnakaustadest või selline, kuhu saate kirjutada.", + "Your organisation requires two-factor login before you can open your vault.": "Teie organisatsioon nõuab kaheastmelist sisselogimist, enne kui saate oma hoidla avada.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Kasutajad valivad, kui kaua laiendus jõudeolekus lukustamata jääb. Teie määrate pikima aja, mida nad valida saavad.", + "Longest idle time before the extension locks": "Pikim jõudeaeg enne laienduse lukustumist", + "1 minute": "1 minut", + "5 minutes": "5 minutit", + "15 minutes": "15 minutit", + "1 hour": "1 tund", + "4 hours": "4 tundi", + "Connector": "Konnektor", + "Directory (tenant) ID": "Kataloogi (rentniku) ID", + "Application (client) ID": "Rakenduse (kliendi) ID", + "Data collection rule immutable ID": "Andmekogumisreegli muutumatu ID", + "Stream name": "Voo nimi", + "Splunk index (optional)": "Splunki indeks (valikuline)", + "Sourcetype (optional)": "Sourcetype (valikuline)", + "Leave blank to keep the current one": "Jäta tühjaks, et praegune alles jätta", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogi kaudu", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Andmekogumise lõpp-punkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collectori URL (https)", + "Client secret (write-only)": "Kliendi saladus (ainult kirjutamiseks)", + "HEC token (write-only)": "HEC-luba (ainult kirjutamiseks)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Edasta lubatud auditisündmused Splunki, Microsoft Sentinelisse, syslogi vastuvõtjale või veebihaagile. Sõnumid sisaldavad ainult puhastatud metaandmeid: ükski salajane väärtus, nimi, kasutajanimi ega krüpteeritud tekst ei lahku kunagi serverist.", + "%n change waiting to sync": "%n muudatus ootab sünkroonimist", + "%n changes waiting to sync": "%n muudatust ootab sünkroonimist", + "Changes that could not sync": "Muudatused, mida ei saanud sünkroonida", + "Choose a version": "Vali versioon", + "Copy value": "Kopeeri väärtus", + "Deleted": "Kustutatud", + "Discard": "Loobu", + "Keep my offline change": "Hoia minu võrguühenduseta muudatus", + "Keep the server version": "Hoia serveri versioon", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq on võrguühenduseta ainult lugemiseks. Administraator ei ole võrguühenduseta muutmist sisse lülitanud.", + "Let users edit secrets offline": "Luba kasutajatel saladusi võrguühenduseta muuta", + "Not synced yet": "Veel sünkroonimata", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Võrguühenduseta muudatused jäävad seadmesse, kasutaja jaoks krüpteerituna, ja sünkroonitakse järgmisel võrgus avamisel. Jagamine, kaustad ja manused vajavad endiselt ühendust.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Võrguühenduseta. Muudatused, teisaldused ja kustutused jäävad sellesse seadmesse ja sünkroonitakse, kui oled taas võrgus. Jagamine ja manused vajavad ühendust.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Võrguühenduseta. Sinu muudatused jäävad sellesse seadmesse ja sünkroonitakse, kui oled taas võrgus. Viimati sünkroonitud {when}.", + "Open my changes": "Ava minu muudatused", + "Sharing needs a connection": "Jagamine vajab ühendust", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Keegi muutis seda saladust serveris pärast sinu võrguühenduseta koopia loomist. Vali, millist versiooni hoida.", + "Sync or discard your offline changes before you rotate your keys.": "Sünkrooni või loobu võrguühenduseta muudatustest enne võtmete vahetamist.", + "That password did not open your changes.": "See parool ei avanud sinu muudatusi.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Võrguühenduseta hetktõmmis salvestab krüpteeritud saladused (avatavad ainult kasutaja ülemparoolist tuletatud võtmega, täpselt nagu serveris) ja krüpteerib nimed, URL-id ja kaustade nimed salvestatuna. Võrguühenduseta juurdepääs on ainult lugemiseks, kui sa allpool võrguühenduseta muutmist ei luba. Lülita see välja seadmetes, mis ei tohi kunagi mandaate puhverdada; väljalülitamine tühjendab olemasolevad puhvrid järgmisel laadimisel.", + "The previous vault copy is gone, so these changes cannot be opened.": "Hoidla eelmine koopia on kadunud, seega neid muudatusi ei saa avada.", + "The server version": "Serveri versioon", + "This secret changed while you were offline": "Seda saladust muudeti, kui olid võrguühenduseta", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Kustutasid selle saladuse võrguühenduseta, kuid seda on vahepeal serveris muudetud. Vali, millist versiooni hoida.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Sinu võtmeid muudeti teises seadmes. Sisesta oma eelmine ülemparool, et võrguühenduseta muudatused sünkroonida, või loobu neist.", + "Your offline change": "Sinu võrguühenduseta muudatus", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus ta eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.","%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus nad eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n üksust ei saa CXF-vormingus esitada ja see jäetakse vahele.","%n üksust ei saa CXF-vormingus esitada ja need jäetakse vahele."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n vanem versioon eemaldati, kuna üle saab kanda ainult hiljutise ajaloo.","%n vanemat versiooni eemaldati, kuna üle saab kanda ainult hiljutise ajaloo."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n saladuse koopiat tuleb veel krüptida ja jagada.","%n saladuse koopiat tuleb veel krüptida ja jagada."], + "_%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 saladust ei õnnestunud dekrüpteerida ja see ei ole selles ekspordis.","%n saladust ei õnnestunud dekrüpteerida ja need ei ole selles ekspordis."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n saladust ei õnnestunud teie vana võtmega dekrüpteerida, seetõttu seda ei migreeritud.","%n saladust ei õnnestunud teie vana võtmega dekrüpteerida, seetõttu neid ei migreeritud."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n saladust ei migreeritud.","%n saladust ei migreeritud."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n saladus on endiselt krüpteeritud teie eelmise võtmega.","%n saladust on endiselt krüpteeritud teie eelmise võtmega."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n saladus jäeti vahele, sest järglasel pole veel koopiat — lisa järglane kausta ja käivita uuesti.","%n saladust jäeti vahele, sest järglasel pole veel koopiat — lisa järglane kausta ja käivita uuesti."], + "_%n secret_::_%n secrets_": ["%n saladus","%n saladust"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa ta hoidlat avada, kuni see on sees.","%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa nad hoidlat avada, kuni see on sees."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Lõpeta siiski, kaotades ligipääsu %n saladusele","Lõpeta siiski, kaotades ligipääsu %n saladusele"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n uus liige sai juurdepääsu meeskonnakaustale.","%n uut liiget said juurdepääsu meeskonnakaustale."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Võtme rotatsioon lõpetatud. %n saladus krüpteeriti uuesti teie uue võtmega.","Võtme rotatsioon lõpetatud. %n saladust krüpteeriti uuesti teie uue võtmega."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.","Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.","Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["esines lekete hulgas %n korda","esines lekete hulgas %n korda"], + "_shared with %n secret_::_shared with %n secrets_": ["jagatud %n saladusega","jagatud %n saladusega"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["See kaust sisaldab otseselt %n saladust.","See kaust sisaldab otseselt %n saladust."], + "_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._": ["Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.","Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n muudatus ootab sünkroonimist","%n muudatust ootab sünkroonimist"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Kasutaja on endiselt grupis {groups}, mis on meeskonnakausta liige. Eemaldage ta grupist või keelake konto.","Kasutaja on endiselt gruppides {groups}, mis on meeskonnakaustade liikmed. Eemaldage ta gruppidest või keelake konto."], + "Allow approval from another device": "Luba kinnitamine teisest seadmest", + "App": "Rakendus", + "Approve a new device": "Kinnita uus seade", + "Approve from another device": "Kinnita teisest seadmest", + "Asked at": "Küsitud", + "Check that the new device shows these words:": "Kontrolli, et uus seade näitab neid sõnu:", + "Denied. If you did not ask, end your other sessions:": "Keelatud. Kui sa seda ei küsinud, lõpeta oma teised seansid:", + "Device": "Seade", + "IP address": "IP-aadress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Luba kasutajatel avada uus brauser, kinnitades selle seadmest, kus Keepiq on juba avatud.", + "New device approval": "Uue seadme kinnitamine", + "Nextcloud security settings": "Nextcloudi turvaseaded", + "Only approve a device you are using right now.": "Kinnita ainult seade, mida sa praegu kasutad.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ava Keepiq seadmes, kus see on avatud, ja kinnita see seade. Kontrolli, et see näitab samu sõnu:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Kinnitav seade pitseerib avamisvõtme uuele seadmele. Server ainult edastab selle ega saa seda avada.", + "The master password is not right, or the request has ended.": "Ülemparool on vale või päring on lõppenud.", + "The request expired. Ask again or use your master password.": "Päring aegus. Küsi uuesti või kasuta oma ülemparooli.", + "The request was denied.": "Päring keelati.", + "Too many requests. Try again in an hour or use your master password.": "Liiga palju päringuid. Proovi tunni aja pärast uuesti või kasuta oma ülemparooli.", + "Unknown device": "Tundmatu seade", + "Web app": "Veebirakendus", + "A device": "Seade", + "A new device asks to open your vault": "Uus seade soovib avada sinu hoidlat", + "%s asks to be approved. Only approve a device you are using right now.": "%s soovib kinnitust. Kinnita ainult seade, mida sa praegu kasutad.", + "Access ends on (optional)": "Juurdepääs lõpeb (valikuline)", + "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.": "Keepiqi rakendused ei näita ega kopeeri parooli. Tehniliste oskustega inimene saab selle siiski oma seadmest välja lugeda. Vaheta see, kui juurdepääs lõpeb.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Seda saladust saab ainult kasutada. Logi sisse Keepiqi brauserilaienduse kaudu.", + "Until {date}": "Kuni {date}", + "Use only": "Ainult kasutamiseks", + "Use only (can sign in, cannot view or copy)": "Ainult kasutamiseks (saab sisse logida, ei saa vaadata ega kopeerida)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Saad selle kontoga sisse logida Keepiqi brauserilaienduse kaudu. Omanik otsustas, et sa ei saa seda vaadata ega kopeerida.", + "Your access ends on {date}": "Sinu juurdepääs lõpeb {date}", + "Your access to this secret has ended": "Sinu juurdepääs sellele saladusele on lõppenud", + "Your access to \"%s\" ends tomorrow": "Sinu juurdepääs üksusele „%s“ lõpeb homme", + "Your access to \"%s\" has ended": "Sinu juurdepääs üksusele „%s“ on lõppenud", + "%1$s no longer has access to \"%2$s\"": "Kasutajal %1$s pole enam juurdepääsu üksusele „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s nägi seda parooli. Vaheta see, kui %1$s ei tohiks seda enam teada.", + "%s could not view this password in Keepiq.": "%s ei saanud seda parooli Keepiqis vaadata.", + "{approvals} of {threshold} approvals": "{approvals} / {threshold} kinnitust", + "a recovery officer": "taasteametnik", + "Account recovery": "Konto taastamine", + "Approvals needed": "Vajalikud kinnitused", + "Ask {user} which words they see, by phone or in person. They must be:": "Küsige kasutajalt {user} telefoni teel või isiklikult, milliseid sõnu ta näeb. Need peavad olema:", + "Check again": "Kontrolli uuesti", + "Create the recovery key": "Loo taastevõti", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Looge taastevõti. Teie brauser loob selle ja annab igale ametnikule koopia, mida ainult tema saab avada.", + "Decline": "Keeldu", + "Enrol in account recovery": "Liitu konto taastamisega", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Liituge, et teie organisatsioon saaks aidata teil hoidla tagasi saada, kui unustate ülemparooli.", + "Every user is enrolled": "Kõik kasutajad on liitunud", + "Finish the recovery in the browser you asked from.": "Lõpetage taastamine brauseris, kust selle küsisite.", + "Forgot your master password?": "Unustasite ülemparooli?", + "Hand the key over": "Anna võti üle", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lubage ülemparooli unustanud kasutajatel hoidla tagasi saada, kui teie määratud taasteametnikud selle kinnitavad.", + "New master password": "Uus ülemparool", + "No one is asking to recover their account.": "Keegi ei palu oma kontot taastada.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Taastevõtit veel pole. Üks ametnikest loob selle oma Keepiqi seadetes.", + "Off": "Väljas", + "Officer {user} has no encryption set up yet.": "Ametnik {user} pole veel krüpteerimist seadistanud.", + "Officers (user IDs, separated by commas)": "Ametnikud (kasutaja ID-d, komadega eraldatud)", + "Policy": "Reeglid", + "Publish this fingerprint internally, so users can check it before they enrol.": "Avaldage see sõrmejälg organisatsioonisiseselt, et kasutajad saaksid seda enne liitumist kontrollida.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Taastatud kasutaja {officer} abiga. Vahetage nüüd hoidla võti: Seaded, Turvalisus: \"Minu ülemparool on lekkinud\".", + "Recovery key fingerprint: {fingerprint}": "Taastevõtme sõrmejälg: {fingerprint}", + "Recovery officer": "Taasteametnik", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Eemaldatud ametnikud kaotavad nüüd oma koopia, kuid võisid selle varem avada. Laske ametnikul luua uus taastevõti.", + "Repeat the new master password": "Korrake uut ülemparooli", + "Retire this recovery key": "Võta see taastevõti kasutusest maha", + "Set the new master password": "Määra uus ülemparool", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Taastesertifikaati ei ole väljastanud see Keepiq. Ärge liituge ja teavitage administraatorit.", + "The words match, approve": "Sõnad klapivad, kinnita", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "See kasutaja on liitunud konto taastamisega. Taastamine säilitab tema saladused; tühistamine kustutab tema liitumise.", + "Users may enrol": "Kasutajad võivad liituda", + "Withdraw from account recovery": "Lahku konto taastamisest", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Olete liitunud konto taastamisega. Taastevõtme sõrmejälg: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Olete liitunud. Kui unustate ülemparooli, saab teie organisatsioon aidata teil hoidla tagasi saada.", + "Your key is back. Choose a new master password.": "Teie võti on tagasi. Valige uus ülemparool.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Teie taasteametnikke on teavitatud. Lugege neile need sõnad ette, kui nad helistavad või teiega kohtuvad:", + "You are now an account recovery officer": "Olete nüüd konto taastamise ametnik", + "%s asks to recover their account. Compare the words with them before you approve.": "%s palub oma kontot taastada. Võrrelge enne kinnitamist temaga sõnu.", + "A user": "Kasutaja", + "Your account recovery request was declined": "Teie konto taastamise taotlus lükati tagasi", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Teie konto taastamine on valmis. Avage Keepiq brauseris, kust selle küsisite.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} palub uue seadme ühekordset avamist. Ülemparool jääb samaks.", + "Ask your organisation instead": "Küsi hoopis oma organisatsioonilt", + "The request ended. Ask again or use your master password.": "Taotlus lõppes. Küsi uuesti või kasuta oma ülemparooli.", + "Added by {user}": "Lisas {user}", + "Editor": "Muutja", + "Manager": "Haldur", + "Role of {member}": "Kasutaja {member} roll", + "Team folders you manage": "Meeskonnakaustad, mida haldad", + "Viewer": "Vaataja", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sul pole nende saladuste koopiat, seega uued liikmed pole neid veel saanud. Omanik saab neid jagada: {names}", + "Admin areas": "Halduse alad", + "Give a group only the parts of Keepiq administration it needs.": "Andke rühmale ainult need Keepiqi halduse osad, mida see vajab.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegeerige üks või mitu ala rühmale halduse õiguste lehel. Eksemplari administraatoritel on kõik alad.", + "Open administration privileges": "Ava halduse õigused", + "Policies": "Reeglid", + "Applications and machine access": "Rakendused ja masinate juurdepääs", + "People and offboarding": "Inimesed ja lahkumine", + "Audit and compliance": "Audit ja vastavus", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versioon, sertifitseerimisasutus, manused, võrguühenduseta vahemälu, lekete kontroll, saladuste tüübid ja varukoopiad", + "master password, organisation password, vault policies, rotation, version history and trash": "ülemparool, organisatsiooni parool, hoidla reeglid, roteerimine, versiooniajalugu ja prügikast", + "application queue, application requests and machine leases": "rakenduste järjekord, rakenduste päringud ja masinate rendid", + "team offboarding, encryption suites and admin handover": "meeskonnast lahkumine, krüpteerimiskomplektid ja administraatori ülevõtmine", + "audit log, compliance reports, SIEM export and honey alerts": "auditilogi, vastavusaruanded, SIEM-eksport ja peibutushoiatused", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Mitu saladuse versiooni säilitatakse, kui kaua, ja kui kaua kustutatud saladused prügikastis püsivad.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Krüpteeritud manuste piirangud, mida server jõustab salvestatud krüpteeritud baitides.", + "Type the suite ID again to confirm": "Kinnitamiseks sisestage komplekti ID uuesti", + "This does not match the suite ID.": "See ei ühti komplekti ID-ga.", + "Confirm with your master password": "Kinnitage põhiparooliga", + "Confirm": "Kinnita", + "That master password is not right.": "See põhiparool ei ole õige.", + "You are sharing with someone new. Enter your master password to confirm.": "Jagate uue inimesega. Kinnitamiseks sisestage põhiparool.", + "Enter your master password to confirm this share.": "Selle jagamise kinnitamiseks sisestage põhiparool.", + "Enter your master password to confirm this delegation.": "Selle delegeerimise kinnitamiseks sisestage põhiparool.", + "Approve {member}": "Kinnita {member}", + "Recipient": "Adressaat", + "No vault yet": "Seifi veel pole", + "No matching users": "Sobivaid kasutajaid pole", + "Partner organisations": "Partnerorganisatsioonid", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vaheta saladusi teise Keepiqiga. Mõlemad administraatorid lisavad teineteise ja võrdlevad juursõrmejälgi telefoni teel või silmast silma enne salvestamist.", + "Federation needs Nextcloud 33 or later.": "Föderatsioon vajab Nextcloud 33 või uuemat.", + "Your root fingerprint": "Sinu juursõrmejälg", + "No partners yet.": "Partnereid veel pole.", + "Users here may share to this partner": "Siinsed kasutajad võivad selle partneriga jagada", + "This partner may share to users here": "See partner võib siinsete kasutajatega jagada", + "Partner address": "Partneri aadress", + "Check partner": "Kontrolli partnerit", + "Partner root fingerprint": "Partneri juursõrmejälg", + "I compared this fingerprint with the partner's administrator": "Võrdlesin seda sõrmejälge partneri administraatoriga", + "Add partner": "Lisa partner", + "A secret from another organisation": "Saladus teisest organisatsioonist", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s jagas teiega kirjet \"%2$s\". Võtke see vastu lehel Saabunud teistest organisatsioonidest.", + "Incoming from other organisations": "Saabunud teistest organisatsioonidest", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partnerorganisatsioonide inimesed saavad teiega saladust jagada. Võtke see vastu, et hoida oma seifis kirjutuskaitstud koopiat.", + "Nothing shared with you yet": "Teiega pole veel midagi jagatud", + "Secrets that people in partner organisations share with you appear here.": "Saladused, mida partnerorganisatsioonide inimesed teiega jagavad, kuvatakse siin.", + "From {sender}": "Saatja: {sender}", + "Accept": "Võta vastu", + "Open in vault": "Ava seifis", + "The other organisation did not hand over the secret. Try again later.": "Teine organisatsioon ei andnud saladust üle. Proovige hiljem uuesti.", + "Set up your vault before you accept a shared secret.": "Seadistage oma seif enne jagatud saladuse vastuvõtmist.", + "Something went wrong. Try again.": "Midagi läks valesti. Proovige uuesti.", + "Waiting for your answer": "Ootab teie vastust", + "In your vault, read-only": "Teie seifis, kirjutuskaitstud", + "Withdrawn by the sender": "Saatja võttis tagasi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} jagas seda teisest organisatsioonist. Saate seda lugeda, kuid mitte muuta ega jagada.", + "Someone": "Keegi", + "Share with someone at another organisation": "Jaga kellegagi teisest organisatsioonist", + "Their account at the other organisation": "Selle inimese konto teises organisatsioonis", + "Check account": "Kontrolli kontot", + "Certificate fingerprint of {account}": "Konto {account} sertifikaadi sõrmejälg", + "Compare it with them by phone if you want to be sure.": "Kui soovite kindel olla, võrrelge seda selle inimesega telefoni teel.", + "Shared. {account} can accept it in their own vault.": "Jagatud. {account} saab selle oma seifis vastu võtta.", + "The certificate could not be verified. Nothing was shared.": "Sertifikaati ei õnnestunud kontrollida. Midagi ei jagatud.", + "That organisation is not one of your partners.": "See organisatsioon ei ole teie partner.", + "No one with that account can receive secrets from you.": "Keegi selle kontoga ei saa teilt saladusi vastu võtta.", + "The other organisation did not answer. Try again later.": "Teine organisatsioon ei vastanud. Proovige hiljem uuesti.", + "This secret is already shared with that account.": "See saladus on selle kontoga juba jagatud.", + "Other organisations": "Teised organisatsioonid", + "Receive secrets from other organisations": "Võta vastu saladusi teistest organisatsioonidest", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Partnerorganisatsioonide inimesed saavad siis teie konto leida ja teiega saladusi jagada. Iga saladuse võtate vastu ise.", + "Shared": "Jagatud", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Peatatud: nende sertifikaat või partnerlus muutus. Tühistage jagamine või jagage uuesti.", + "Their organisation did not get the last change. Revoke it or share again.": "Nende organisatsioon ei saanud viimast muudatust. Tühistage jagamine või jagage uuesti.", + "Being withdrawn": "Tühistatakse", + "Shared with another organisation": "Jagatud teise organisatsiooniga", + "Change sent to another organisation": "Muudatus saadetud teisele organisatsioonile", + "Share with another organisation revoked": "Jagamine teise organisatsiooniga tühistatud", + "Share with another organisation paused": "Jagamine teise organisatsiooniga peatatud", + "Another organisation did not get a change": "Teine organisatsioon ei saanud muudatust", + "Secret received from another organisation": "Saladus saadud teisest organisatsioonist", + "Secret from another organisation accepted": "Saladus teisest organisatsioonist vastu võetud", + "Secret from another organisation declined": "Saladus teisest organisatsioonist tagasi lükatud", + "Copy from another organisation updated": "Koopia teisest organisatsioonist uuendatud", + "Copy from another organisation removed": "Koopia teisest organisatsioonist eemaldatud", + "Declined: they removed their copy. Share again if they need it.": "Tagasi lükatud: saaja eemaldas oma koopia. Jagage uuesti, kui tal seda vaja on.", + "Recipient at another organisation removed their copy": "Teise organisatsiooni saaja eemaldas oma koopia", + "Removed the user from %n team folder.": "Kasutaja eemaldati %n meeskonnakaustast.", + "Removed the user from %n team folders.": "Kasutaja eemaldati %n meeskonnakaustast.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Kasutaja eemaldati %n meeskonnakaustast.","Kasutaja eemaldati %n meeskonnakaustast."], + "A restored copy came from a share that has ended. It stays read-only.": "Taastatud koopia pärineb lõppenud jagamisest. See jääb kirjutuskaitstuks.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Taastatud koopia jaganud organisatsiooniga ei saadud ühendust. Koopia jääb kirjutuskaitstuks ega järgi nende muudatusi.", + "Recipient at another organisation restored their copy": "Teise organisatsiooni saaja taastas oma koopia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/et.json b/l10n/et.json index afece1dfd..279cd2485 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Jagatud gruppidega", + "Not shared with any group yet.": "Pole veel ühegi grupiga jagatud.", + "Revoke the share with {group}": "Tühista jagamine grupiga {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jagatud grupiga {group}: {received} liiget said selle kätte, {skipped} mitte, sest neil pole veel krüpteerimist seadistatud.", + "Search groups": "Otsi gruppe", + "Failed to share": "Jagamine ebaõnnestus", + "Columns": "Veerud", + "Column {number}": "Veerg {number}", + "Map one column to Name. Every secret needs a name.": "Seo üks veerg nimega. Igal saladusel peab olema nimi.", + "Notes": "Märkmed", + "Do not import": "Ära impordi", + "Hide this value": "Peida see väärtus", + "Show this value": "Näita seda väärtust", + "Defaults": "Vaikeväärtused", + "New secrets start as this type, and your secret list opens in this view.": "Uued saladused algavad selle tüübiga ja sinu saladuste loend avaneb selles vaates.", + "Default item type": "Vaikimisi kirje tüüp", + "Cards": "Kaardid", + "Table": "Tabel", + "Could not save your default": "Vaikeväärtust ei õnnestunud salvestada", + "Recently used": "Hiljuti kasutatud", + "Opened": "Avatud", + "You have not opened any secrets yet": "Sa pole veel ühtegi saladust avanud", + "Could not delete the item type.": "Kirje tüüpi ei õnnestunud kustutada.", + "Could not load the item types.": "Kirje tüüpe ei õnnestunud laadida.", + "Could not save the item type.": "Kirje tüüpi ei õnnestunud salvestada.", + "Delete item type": "Kustuta kirje tüüp", + "Edit item type": "Muuda kirje tüüpi", + "Fields": "Väljad", + "Fields: {count}": "Väljad: {count}", + "Hidden": "Peidetud", + "Item types": "Kirje tüübid", + "Move up": "Liiguta üles", + "New item type": "Uus kirje tüüp", + "No item types defined yet.": "Kirje tüüpe pole veel määratud.", + "Required": "Kohustuslik", + "Text": "Tekst", + "This field is required": "See väli on kohustuslik", + "Web address": "Veebiaadress", + "{label} (required)": "{label} (kohustuslik)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Kas kustutada „{name}“? Selle tüübi saladused jäävad loetavaks ja muutuvad Sisselogimise kirjeteks.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Siin määratud kirje tüübid ilmuvad kõigile aknas Uus saladus, sinu valitud väljadega.", + "Secret moved to the trash": "Saladus viidi prügikasti", + "Secret restored from the trash": "Saladus taastati prügikastist", + "Secret deleted for good": "Saladus kustutati jäädavalt", + "Secret archived": "Saladus arhiveeriti", + "Secret unarchived": "Saladus toodi arhiivist välja", + "Unarchive": "Too arhiivist välja", + "Could not archive the secret": "Saladust ei õnnestunud arhiveerida", + "Could not unarchive the secret": "Saladust ei õnnestunud arhiivist välja tuua", + "Archive {count} secrets": "Arhiveeri saladused: {count}", + "Unarchive {count} secrets": "Too arhiivist välja saladused: {count}", + "Restore {count} secrets": "Taasta saladused: {count}", + "Delete {count} secrets for good": "Kustuta jäädavalt saladused: {count}", + "Done for {ok} of {total} secrets": "Valmis {ok} saladusel {total}-st", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhiveeritud saladused kaovad hoidla loendist, otsingust, automaattäitest ja tervisearuandest. Nende jagamised jäävad alles. Leiad need jaotisest Arhiiv.", + "These secrets come back to the vault list, search and autofill.": "Need saladused tulevad tagasi hoidla loendisse, otsingusse ja automaattäitesse.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Need saladused tulevad tagasi hoidla loendisse. Nende vanad jagamised tagasi ei tule, nii et jaga neid vajadusel uuesti.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "See kustutab saladused koos manuste ja versiooniajalooga. Seda ei saa tagasi võtta.", + "Delete for good": "Kustuta jäädavalt", + "Trash": "Prügikast", + "The trash is empty": "Prügikast on tühi", + "No archived secrets": "Arhiveeritud saladusi pole", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Kustutatud saladused ootavad siin säilitusaja lõpuni, seejärel kustutatakse need jäädavalt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhiveeri saladus selle üksikasjade paneelilt, et hoida see eemal hoidla loendist, otsingust ja automaattäitest.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Krüpteeritud manuste piirangud (serveris jõustatud salvestatud krüpteeritud baitides), versiooniajaloo säilitamine ja kui kaua kustutatud saladused prügikastis püsivad.", + "Days a deleted secret stays in the trash (1 to 365)": "Päevad, mil kustutatud saladus püsib prügikastis (1 kuni 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "See viib saladuse prügikasti ja lõpetab selle jagamised kohe. Saad selle prügikastist taastada säilitusaja lõpuni: 30 päeva, kui administraator pole seda muutnud.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "See viib saladused prügikasti ({count}) ja lõpetab nende jagamised kohe. Saad need prügikastist taastada säilitusaja lõpuni.", + "Remove {name} from favourites": "Eemalda {name} lemmikutest", + "Add {name} to favourites": "Lisa {name} lemmikutesse", + "Could not change the favourite": "Lemmikut ei õnnestunud muuta", + "Remove from favourites": "Eemalda lemmikutest", + "Add to favourites": "Lisa lemmikutesse", + "Tags": "Sildid", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Sildid ei ole krüpteeritud. Serveri administraatorid saavad neid lugeda, nagu kaustade nimesid.", + "Favourites": "Lemmikud", + "Filter by tag": "Filtreeri sildi järgi", + "All tags": "Kõik sildid", + "Last used": "Viimati kasutatud", + "Tags for {count} secrets": "Sildid {count} saladusele", + "Tag": "Silt", + "Remove tag": "Eemalda silt", + "Add tag": "Lisa silt", + "Could not change the tags. Try again.": "Silte ei õnnestunud muuta. Proovi uuesti.", + "Could not approve the application. It is still in the queue.": "Taotlust ei õnnestunud kinnitada. See on endiselt järjekorras.", + "Could not reject the application. It is still in the queue.": "Taotlust ei õnnestunud tagasi lükata. See on endiselt järjekorras.", + "Removed the user from {count} team folders.": "Kasutaja eemaldati {count} meeskonnakaustast.", + "Approve a share": "Kinnita jagamine", + "This approval link is incomplete. Open it again from the notification.": "See kinnituslink on puudulik. Ava see uuesti teavitusest.", + "Deny": "Keeldu", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} liitus grupiga, kellega sa jagad saladust. Kas jagada saladust ka temaga?", + "{requester} asks you to share a secret with {user}.": "{requester} palub sul jagada saladust kasutajaga {user}.", + "Shared. The recipient can now open the secret.": "Jagatud. Saaja saab nüüd saladuse avada.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Saaja pole Keepiqi veel seadistanud, seega midagi ei jagatud. Proovi uuesti, kui ta on seda teinud.", + "Could not share the secret. Only its owner can approve this.": "Saladust ei õnnestunud jagada. Seda saab kinnitada ainult selle omanik.", + "Could not share the secret. Try again.": "Saladust ei õnnestunud jagada. Proovi uuesti.", + "Denied. Nothing was shared.": "Keeldutud. Midagi ei jagatud.", + "Could not deny the request. Try again.": "Taotlusest ei õnnestunud keelduda. Proovi uuesti.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s palub sul jagada saladust \"%2$s\" kasutajaga %3$s.", + "Expires on (optional)": "Aegub (valikuline)", + "Hand over to": "Anna üle kasutajale", + "Choose a recipient": "Vali saaja", + "Hand over temporarily": "Anna ajutiselt üle", + "Expiry rules": "Aegumisreeglid", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Määra, kui kaua ühe kirje tüübi või ühe kausta paroolid võivad kehtida ja millal sulle meelde tuletatakse. Kui kehtib mitu kuupäeva, arvestatakse kõige varasemat.", + "Delete rule": "Kustuta reegel", + "Set by your administrator": "Määratud sinu administraatori poolt", + "No expiry rules yet.": "Aegumisreegleid veel pole.", + "Applies to": "Kehtib", + "Item type": "Kirje tüüp", + "Maximum age in days (empty for reminders only)": "Maksimaalne vanus päevades (tühi ainult meeldetuletuste jaoks)", + "Remind me this many days before, comma separated": "Tuleta meelde nii mitu päeva enne, komadega eraldatult", + "Save rule": "Salvesta reegel", + "An item type": "Kirje tüüp", + "A folder": "Kaust", + "Folder {name}": "Kaust {name}", + "Type {name}": "Tüüp {name}", + "Expires after {days} days": "Aegub {days} päeva pärast", + "Reminders {days} days before": "Meeldetuletused {days} päeva enne", + "Could not save the expiry rule.": "Aegumisreeglit ei õnnestunud salvestada.", + "Could not delete the expiry rule.": "Aegumisreeglit ei õnnestunud kustutada.", + "All statuses": "Kõik olekud", + "Compromised": "Ohustatud", + "Could not load the members.": "Liikmete laadimine ebaõnnestus.", + "Emergency contact": "Hädaabikontakt", + "Leaving user": "Lahkuv kasutaja", + "No": "Ei", + "No users match this filter.": "Ükski kasutaja ei vasta sellele filtrile.", + "Not set up": "Seadistamata", + "Revoke suite": "Tühista komplekt", + "Revoked": "Tühistatud", + "Search users": "Otsi kasutajaid", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vaadake, millised kasutajad on hoidla seadistanud. Alustage lahkumist või tühistage komplekt realt.", + "Successor": "Järglane", + "Team folders": "Meeskonnakaustad", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Kasutaja on endiselt grupis {groups}, mis on meeskonnakausta liige. Eemaldage ta grupist või keelake konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Kasutaja on endiselt gruppides {groups}, mis on meeskonnakaustade liikmed. Eemaldage ta gruppidest või keelake konto.", + "Vault status": "Hoidla olek", + "Yes": "Jah", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-eksport on KRÜPTIMATA. Iga parool ja kasutajanimi on allalaaditud failis loetavad lihttekstina. Hoia seda turvaliselt ja kustuta kohe pärast kasutamist.", + "Root certificate expiring soon": "Juursertifikaat aegub peagi", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Hoidla juursertifikaat aegub %1$d päeva pärast. Uuenda see enne seda. Uuendamine allkirjastab iga krüpteerimiskomplekti uuesti.", + "Compromise recovery aborted": "Kompromiteerimisjärgne taastamine katkestatud", + "Key rotation ended by a compromise revoke": "Võtmevahetus lõpetati kompromiteerimise tõttu tehtud tühistamisega", + "Encryption suite revoke refused": "Krüpteerimiskomplekti tühistamine keelatud", + "Master password proof refused": "Põhiparooli tõend keelatud", + "Your current master password": "Sinu praegune põhiparool", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus ta eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus nad eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Neid hädaabikontakte ei viidud üle sinu uuele võtmele. Nende hädajuurdepääs eemaldati. Lisa nad uuesti jaotises Hädajuurdepääs, kui soovid neid endiselt.", + "Renew root certificate": "Uuenda juursertifikaati", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "See loob uue juur- ja vahesertifikaadi. Iga aktiivne krüpteerimiskomplekt allkirjastatakse uuesti. Seda ei saa tagasi võtta.", + "Renew root": "Uuenda juurt", + "Root renewed. {n} encryption suites signed again.": "Juur uuendatud. Uuesti allkirjastatud krüpteerimiskomplekte: {n}.", + "Could not renew the root certificate.": "Juursertifikaati ei õnnestunud uuendada.", + "Lease policy for this application": "Selle rakenduse rendipoliitika", + "In force now: {default} seconds by default, {max} seconds at most.": "Praegu kehtib: vaikimisi {default} sekundit, kuni {max} sekundit.", + "Leases are not renewable": "Rente ei saa pikendada", + "Lease policy saved.": "Rendipoliitika salvestatud.", + "Leave a field empty to use the instance value.": "Jäta väli tühjaks, et kasutada eksemplari väärtust.", + "Instance value: {value}": "Eksemplari väärtus: {value}", + "Renewal": "Pikendamine", + "Use the instance value ({value})": "Kasuta eksemplari väärtust ({value})", + "Allowed": "Lubatud", + "Not allowed": "Pole lubatud", + "Save lease policy": "Salvesta rendipoliitika", + "Only an administrator can change this policy.": "Seda poliitikat saab muuta ainult administraator.", + "Could not save the lease policy.": "Rendipoliitikat ei õnnestunud salvestada.", + "{member} got access from {confirmer}.": "{member} sai juurdepääsu kasutajalt {confirmer}.", + "Automatically confirm new team folder members": "Kinnita uued meeskonnakaustade liikmed automaatselt", + "Gave %n new member access to a team folder.": "%n uus liige sai juurdepääsu meeskonnakaustale.", + "Gave %n new members access to a team folder.": "%n uut liiget said juurdepääsu meeskonnakaustale.", + "Give new team folder members access without waiting for the folder owner.": "Andke uutele liikmetele juurdepääs kausta omanikku ootamata.", + "New team folder members": "Uued meeskonnakaustade liikmed", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Omanik või kirjutusõigusega liige kinnitab nad oma avatud hoidlast. Keepiq ei dekrüpteeri kunagi serveris.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ootame, et kirjutusõigusega liige avaks Keepiqi. Saate ka kohe jagada.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Osa ohustumisele reageerimisest ebaõnnestus ({failed} samm(u)). Kontrollige serveri logi ja tühistage seejärel komplekt uuesti, et see lõpetada.", + "This also revoked suite {suite} and ended key migration {migration}.": "See tühistas ka komplekti {suite} ja lõpetas võtmete migratsiooni {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.", + "Revoking the second suite deleted %n emergency-access contacts.": "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.", + "A suite revoked as compromised cannot be reinstated.": "Ohustatuna tühistatud komplekti ei saa taastada.", + "Archives to keep": "Säilitatavad arhiivid", + "Back up every vault automatically": "Varunda iga hoidla automaatselt", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varundage iga hoidla ajakava järgi. Arhiivid sisaldavad ainult krüpteeritud teksti ja taastatakse occ-ga.", + "Back up now": "Varunda kohe", + "Backup public key (PEM, optional)": "Varunduse avalik võti (PEM, valikuline)", + "Backup requested for the next cron run": "Varundus taotletud järgmiseks croni käivituseks", + "Encrypted": "Krüpteeritud", + "Every (hours)": "Iga (tunni järel)", + "Last backup {when} failed: {error}": "Viimane varundus {when} ebaõnnestus: {error}", + "Last backup {when} succeeded.": "Viimane varundus {when} õnnestus.", + "No archives yet.": "Arhiive veel pole.", + "Size": "Suurus", + "Vault backups": "Hoidla varundused", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Võtmega krüpteeritakse iga arhiiv selle jaoks. Hoidke privaatvõtit sellest serverist väljas: seda on vaja kontrollimiseks või taastamiseks.", + "Written": "Kirjutatud", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa ta hoidlat avada, kuni see on sees.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa nad hoidlat avada, kuni see on sees.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varukoodid ei loe. Kui kasutajad logivad sisse identiteedipakkuja kaudu, millel on oma teine tegur, jätke nende grupid välja.", + "Block personal vault export": "Blokeeri isikliku hoidla eksport", + "Keep work logins in team folders": "Hoia töökontode andmeid meeskonnakaustades", + "Move to a team folder": "Teisalda meeskonnakausta", + "Not in a team folder": "Pole meeskonnakaustas", + "Only for these groups (empty is everyone)": "Ainult nendele gruppidele (tühi tähendab kõiki)", + "Require two-factor login before the vault opens": "Nõua enne hoidla avamist kaheastmelist sisselogimist", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reeglid igale hoidlale. Iga reegel kehtib kõigile või ainult valitud gruppidele.", + "Secret types that belong in a team folder": "Saladuste tüübid, mis kuuluvad meeskonnakausta", + "Set up two-factor login": "Seadista kaheastmeline sisselogimine", + "Team folder you can write to": "Meeskonnakaust, kuhu saate kirjutada", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Kasutajad ei saa alla laadida varukoopiat, CSV-d ega ülekandefaili. Nende isikuandmete pakett jääb kättesaadavaks.", + "Users cannot save these secret types in a personal folder.": "Kasutajad ei saa neid saladuste tüüpe isiklikku kausta salvestada.", + "Vault policies": "Hoidla reeglid", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Teie organisatsioon ei luba teie isikliku hoidla eksporti. Teie isikuandmete pakett seadetes jääb kättesaadavaks.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Teie organisatsioon hoiab neid saladusi meeskonnakaustas. Teisaldage igaüks meeskonnakausta.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Teie organisatsioon hoiab seda tüüpi saladust meeskonnakaustas. Valige üks oma meeskonnakaustadest või selline, kuhu saate kirjutada.", + "Your organisation requires two-factor login before you can open your vault.": "Teie organisatsioon nõuab kaheastmelist sisselogimist, enne kui saate oma hoidla avada.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Kasutajad valivad, kui kaua laiendus jõudeolekus lukustamata jääb. Teie määrate pikima aja, mida nad valida saavad.", + "Longest idle time before the extension locks": "Pikim jõudeaeg enne laienduse lukustumist", + "1 minute": "1 minut", + "5 minutes": "5 minutit", + "15 minutes": "15 minutit", + "1 hour": "1 tund", + "4 hours": "4 tundi", + "Connector": "Konnektor", + "Directory (tenant) ID": "Kataloogi (rentniku) ID", + "Application (client) ID": "Rakenduse (kliendi) ID", + "Data collection rule immutable ID": "Andmekogumisreegli muutumatu ID", + "Stream name": "Voo nimi", + "Splunk index (optional)": "Splunki indeks (valikuline)", + "Sourcetype (optional)": "Sourcetype (valikuline)", + "Leave blank to keep the current one": "Jäta tühjaks, et praegune alles jätta", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogi kaudu", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Andmekogumise lõpp-punkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collectori URL (https)", + "Client secret (write-only)": "Kliendi saladus (ainult kirjutamiseks)", + "HEC token (write-only)": "HEC-luba (ainult kirjutamiseks)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Edasta lubatud auditisündmused Splunki, Microsoft Sentinelisse, syslogi vastuvõtjale või veebihaagile. Sõnumid sisaldavad ainult puhastatud metaandmeid: ükski salajane väärtus, nimi, kasutajanimi ega krüpteeritud tekst ei lahku kunagi serverist.", + "%n change waiting to sync": "%n muudatus ootab sünkroonimist", + "%n changes waiting to sync": "%n muudatust ootab sünkroonimist", + "Changes that could not sync": "Muudatused, mida ei saanud sünkroonida", + "Choose a version": "Vali versioon", + "Copy value": "Kopeeri väärtus", + "Deleted": "Kustutatud", + "Discard": "Loobu", + "Keep my offline change": "Hoia minu võrguühenduseta muudatus", + "Keep the server version": "Hoia serveri versioon", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq on võrguühenduseta ainult lugemiseks. Administraator ei ole võrguühenduseta muutmist sisse lülitanud.", + "Let users edit secrets offline": "Luba kasutajatel saladusi võrguühenduseta muuta", + "Not synced yet": "Veel sünkroonimata", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Võrguühenduseta muudatused jäävad seadmesse, kasutaja jaoks krüpteerituna, ja sünkroonitakse järgmisel võrgus avamisel. Jagamine, kaustad ja manused vajavad endiselt ühendust.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Võrguühenduseta. Muudatused, teisaldused ja kustutused jäävad sellesse seadmesse ja sünkroonitakse, kui oled taas võrgus. Jagamine ja manused vajavad ühendust.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Võrguühenduseta. Sinu muudatused jäävad sellesse seadmesse ja sünkroonitakse, kui oled taas võrgus. Viimati sünkroonitud {when}.", + "Open my changes": "Ava minu muudatused", + "Sharing needs a connection": "Jagamine vajab ühendust", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Keegi muutis seda saladust serveris pärast sinu võrguühenduseta koopia loomist. Vali, millist versiooni hoida.", + "Sync or discard your offline changes before you rotate your keys.": "Sünkrooni või loobu võrguühenduseta muudatustest enne võtmete vahetamist.", + "That password did not open your changes.": "See parool ei avanud sinu muudatusi.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Võrguühenduseta hetktõmmis salvestab krüpteeritud saladused (avatavad ainult kasutaja ülemparoolist tuletatud võtmega, täpselt nagu serveris) ja krüpteerib nimed, URL-id ja kaustade nimed salvestatuna. Võrguühenduseta juurdepääs on ainult lugemiseks, kui sa allpool võrguühenduseta muutmist ei luba. Lülita see välja seadmetes, mis ei tohi kunagi mandaate puhverdada; väljalülitamine tühjendab olemasolevad puhvrid järgmisel laadimisel.", + "The previous vault copy is gone, so these changes cannot be opened.": "Hoidla eelmine koopia on kadunud, seega neid muudatusi ei saa avada.", + "The server version": "Serveri versioon", + "This secret changed while you were offline": "Seda saladust muudeti, kui olid võrguühenduseta", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Kustutasid selle saladuse võrguühenduseta, kuid seda on vahepeal serveris muudetud. Vali, millist versiooni hoida.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Sinu võtmeid muudeti teises seadmes. Sisesta oma eelmine ülemparool, et võrguühenduseta muudatused sünkroonida, või loobu neist.", + "Your offline change": "Sinu võrguühenduseta muudatus", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus ta eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad.", + "%n hädaabikontaktil oli ootel juurdepääsutaotlus, kui võtmevahetus nad eemaldas. Kontrolli, kes küsis, enne kui kedagi uuesti lisad." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n üksust ei saa CXF-vormingus esitada ja see jäetakse vahele.", + "%n üksust ei saa CXF-vormingus esitada ja need jäetakse vahele." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n vanem versioon eemaldati, kuna üle saab kanda ainult hiljutise ajaloo.", + "%n vanemat versiooni eemaldati, kuna üle saab kanda ainult hiljutise ajaloo." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n saladuse koopiat tuleb veel krüptida ja jagada.", + "%n saladuse koopiat tuleb veel krüptida ja jagada." + ], + "_%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 saladust ei õnnestunud dekrüpteerida ja see ei ole selles ekspordis.", + "%n saladust ei õnnestunud dekrüpteerida ja need ei ole selles ekspordis." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n saladust ei õnnestunud teie vana võtmega dekrüpteerida, seetõttu seda ei migreeritud.", + "%n saladust ei õnnestunud teie vana võtmega dekrüpteerida, seetõttu neid ei migreeritud." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n saladust ei migreeritud.", + "%n saladust ei migreeritud." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n saladus on endiselt krüpteeritud teie eelmise võtmega.", + "%n saladust on endiselt krüpteeritud teie eelmise võtmega." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n saladus jäeti vahele, sest järglasel pole veel koopiat — lisa järglane kausta ja käivita uuesti.", + "%n saladust jäeti vahele, sest järglasel pole veel koopiat — lisa järglane kausta ja käivita uuesti." + ], + "_%n secret_::_%n secrets_": [ + "%n saladus", + "%n saladust" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa ta hoidlat avada, kuni see on sees.", + "%n kasutajal ulatuses pole veel kaheastmelist sisselogimist ega saa nad hoidlat avada, kuni see on sees." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Lõpeta siiski, kaotades ligipääsu %n saladusele", + "Lõpeta siiski, kaotades ligipääsu %n saladusele" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n uus liige sai juurdepääsu meeskonnakaustale.", + "%n uut liiget said juurdepääsu meeskonnakaustale." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Võtme rotatsioon lõpetatud. %n saladus krüpteeriti uuesti teie uue võtmega.", + "Võtme rotatsioon lõpetatud. %n saladust krüpteeriti uuesti teie uue võtmega." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti.", + "Teise komplekti tühistamine kustutas %n hädaabijuurdepääsu kontakti." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti.", + "Selle komplekti tühistamine kustutas %n hädajuurdepääsu kontakti." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "esines lekete hulgas %n korda", + "esines lekete hulgas %n korda" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "jagatud %n saladusega", + "jagatud %n saladusega" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "See kaust sisaldab otseselt %n saladust.", + "See kaust sisaldab otseselt %n saladust." + ], + "_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._": [ + "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa see uuesti, kui soovid seda endiselt.", + "Sinu võtme rotatsioon eemaldas %n hädaolukorra kontakti. Vaata üle Hädaolukorra ligipääs ja lisa need uuesti, kui soovid neid endiselt." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n muudatus ootab sünkroonimist", + "%n muudatust ootab sünkroonimist" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Kasutaja on endiselt grupis {groups}, mis on meeskonnakausta liige. Eemaldage ta grupist või keelake konto.", + "Kasutaja on endiselt gruppides {groups}, mis on meeskonnakaustade liikmed. Eemaldage ta gruppidest või keelake konto." + ], + "Allow approval from another device": "Luba kinnitamine teisest seadmest", + "App": "Rakendus", + "Approve a new device": "Kinnita uus seade", + "Approve from another device": "Kinnita teisest seadmest", + "Asked at": "Küsitud", + "Check that the new device shows these words:": "Kontrolli, et uus seade näitab neid sõnu:", + "Denied. If you did not ask, end your other sessions:": "Keelatud. Kui sa seda ei küsinud, lõpeta oma teised seansid:", + "Device": "Seade", + "IP address": "IP-aadress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Luba kasutajatel avada uus brauser, kinnitades selle seadmest, kus Keepiq on juba avatud.", + "New device approval": "Uue seadme kinnitamine", + "Nextcloud security settings": "Nextcloudi turvaseaded", + "Only approve a device you are using right now.": "Kinnita ainult seade, mida sa praegu kasutad.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ava Keepiq seadmes, kus see on avatud, ja kinnita see seade. Kontrolli, et see näitab samu sõnu:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Kinnitav seade pitseerib avamisvõtme uuele seadmele. Server ainult edastab selle ega saa seda avada.", + "The master password is not right, or the request has ended.": "Ülemparool on vale või päring on lõppenud.", + "The request expired. Ask again or use your master password.": "Päring aegus. Küsi uuesti või kasuta oma ülemparooli.", + "The request was denied.": "Päring keelati.", + "Too many requests. Try again in an hour or use your master password.": "Liiga palju päringuid. Proovi tunni aja pärast uuesti või kasuta oma ülemparooli.", + "Unknown device": "Tundmatu seade", + "Web app": "Veebirakendus", + "A device": "Seade", + "A new device asks to open your vault": "Uus seade soovib avada sinu hoidlat", + "%s asks to be approved. Only approve a device you are using right now.": "%s soovib kinnitust. Kinnita ainult seade, mida sa praegu kasutad.", + "Access ends on (optional)": "Juurdepääs lõpeb (valikuline)", + "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.": "Keepiqi rakendused ei näita ega kopeeri parooli. Tehniliste oskustega inimene saab selle siiski oma seadmest välja lugeda. Vaheta see, kui juurdepääs lõpeb.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Seda saladust saab ainult kasutada. Logi sisse Keepiqi brauserilaienduse kaudu.", + "Until {date}": "Kuni {date}", + "Use only": "Ainult kasutamiseks", + "Use only (can sign in, cannot view or copy)": "Ainult kasutamiseks (saab sisse logida, ei saa vaadata ega kopeerida)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Saad selle kontoga sisse logida Keepiqi brauserilaienduse kaudu. Omanik otsustas, et sa ei saa seda vaadata ega kopeerida.", + "Your access ends on {date}": "Sinu juurdepääs lõpeb {date}", + "Your access to this secret has ended": "Sinu juurdepääs sellele saladusele on lõppenud", + "Your access to \"%s\" ends tomorrow": "Sinu juurdepääs üksusele „%s“ lõpeb homme", + "Your access to \"%s\" has ended": "Sinu juurdepääs üksusele „%s“ on lõppenud", + "%1$s no longer has access to \"%2$s\"": "Kasutajal %1$s pole enam juurdepääsu üksusele „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s nägi seda parooli. Vaheta see, kui %1$s ei tohiks seda enam teada.", + "%s could not view this password in Keepiq.": "%s ei saanud seda parooli Keepiqis vaadata.", + "{approvals} of {threshold} approvals": "{approvals} / {threshold} kinnitust", + "a recovery officer": "taasteametnik", + "Account recovery": "Konto taastamine", + "Approvals needed": "Vajalikud kinnitused", + "Ask {user} which words they see, by phone or in person. They must be:": "Küsige kasutajalt {user} telefoni teel või isiklikult, milliseid sõnu ta näeb. Need peavad olema:", + "Check again": "Kontrolli uuesti", + "Create the recovery key": "Loo taastevõti", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Looge taastevõti. Teie brauser loob selle ja annab igale ametnikule koopia, mida ainult tema saab avada.", + "Decline": "Keeldu", + "Enrol in account recovery": "Liitu konto taastamisega", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Liituge, et teie organisatsioon saaks aidata teil hoidla tagasi saada, kui unustate ülemparooli.", + "Every user is enrolled": "Kõik kasutajad on liitunud", + "Finish the recovery in the browser you asked from.": "Lõpetage taastamine brauseris, kust selle küsisite.", + "Forgot your master password?": "Unustasite ülemparooli?", + "Hand the key over": "Anna võti üle", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lubage ülemparooli unustanud kasutajatel hoidla tagasi saada, kui teie määratud taasteametnikud selle kinnitavad.", + "New master password": "Uus ülemparool", + "No one is asking to recover their account.": "Keegi ei palu oma kontot taastada.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Taastevõtit veel pole. Üks ametnikest loob selle oma Keepiqi seadetes.", + "Off": "Väljas", + "Officer {user} has no encryption set up yet.": "Ametnik {user} pole veel krüpteerimist seadistanud.", + "Officers (user IDs, separated by commas)": "Ametnikud (kasutaja ID-d, komadega eraldatud)", + "Policy": "Reeglid", + "Publish this fingerprint internally, so users can check it before they enrol.": "Avaldage see sõrmejälg organisatsioonisiseselt, et kasutajad saaksid seda enne liitumist kontrollida.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Taastatud kasutaja {officer} abiga. Vahetage nüüd hoidla võti: Seaded, Turvalisus: \"Minu ülemparool on lekkinud\".", + "Recovery key fingerprint: {fingerprint}": "Taastevõtme sõrmejälg: {fingerprint}", + "Recovery officer": "Taasteametnik", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Eemaldatud ametnikud kaotavad nüüd oma koopia, kuid võisid selle varem avada. Laske ametnikul luua uus taastevõti.", + "Repeat the new master password": "Korrake uut ülemparooli", + "Retire this recovery key": "Võta see taastevõti kasutusest maha", + "Set the new master password": "Määra uus ülemparool", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Taastesertifikaati ei ole väljastanud see Keepiq. Ärge liituge ja teavitage administraatorit.", + "The words match, approve": "Sõnad klapivad, kinnita", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "See kasutaja on liitunud konto taastamisega. Taastamine säilitab tema saladused; tühistamine kustutab tema liitumise.", + "Users may enrol": "Kasutajad võivad liituda", + "Withdraw from account recovery": "Lahku konto taastamisest", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Olete liitunud konto taastamisega. Taastevõtme sõrmejälg: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Olete liitunud. Kui unustate ülemparooli, saab teie organisatsioon aidata teil hoidla tagasi saada.", + "Your key is back. Choose a new master password.": "Teie võti on tagasi. Valige uus ülemparool.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Teie taasteametnikke on teavitatud. Lugege neile need sõnad ette, kui nad helistavad või teiega kohtuvad:", + "You are now an account recovery officer": "Olete nüüd konto taastamise ametnik", + "%s asks to recover their account. Compare the words with them before you approve.": "%s palub oma kontot taastada. Võrrelge enne kinnitamist temaga sõnu.", + "A user": "Kasutaja", + "Your account recovery request was declined": "Teie konto taastamise taotlus lükati tagasi", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Teie konto taastamine on valmis. Avage Keepiq brauseris, kust selle küsisite.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} palub uue seadme ühekordset avamist. Ülemparool jääb samaks.", + "Ask your organisation instead": "Küsi hoopis oma organisatsioonilt", + "The request ended. Ask again or use your master password.": "Taotlus lõppes. Küsi uuesti või kasuta oma ülemparooli.", + "Added by {user}": "Lisas {user}", + "Editor": "Muutja", + "Manager": "Haldur", + "Role of {member}": "Kasutaja {member} roll", + "Team folders you manage": "Meeskonnakaustad, mida haldad", + "Viewer": "Vaataja", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sul pole nende saladuste koopiat, seega uued liikmed pole neid veel saanud. Omanik saab neid jagada: {names}", + "Admin areas": "Halduse alad", + "Give a group only the parts of Keepiq administration it needs.": "Andke rühmale ainult need Keepiqi halduse osad, mida see vajab.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegeerige üks või mitu ala rühmale halduse õiguste lehel. Eksemplari administraatoritel on kõik alad.", + "Open administration privileges": "Ava halduse õigused", + "Policies": "Reeglid", + "Applications and machine access": "Rakendused ja masinate juurdepääs", + "People and offboarding": "Inimesed ja lahkumine", + "Audit and compliance": "Audit ja vastavus", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versioon, sertifitseerimisasutus, manused, võrguühenduseta vahemälu, lekete kontroll, saladuste tüübid ja varukoopiad", + "master password, organisation password, vault policies, rotation, version history and trash": "ülemparool, organisatsiooni parool, hoidla reeglid, roteerimine, versiooniajalugu ja prügikast", + "application queue, application requests and machine leases": "rakenduste järjekord, rakenduste päringud ja masinate rendid", + "team offboarding, encryption suites and admin handover": "meeskonnast lahkumine, krüpteerimiskomplektid ja administraatori ülevõtmine", + "audit log, compliance reports, SIEM export and honey alerts": "auditilogi, vastavusaruanded, SIEM-eksport ja peibutushoiatused", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Mitu saladuse versiooni säilitatakse, kui kaua, ja kui kaua kustutatud saladused prügikastis püsivad.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Krüpteeritud manuste piirangud, mida server jõustab salvestatud krüpteeritud baitides.", + "Type the suite ID again to confirm": "Kinnitamiseks sisestage komplekti ID uuesti", + "This does not match the suite ID.": "See ei ühti komplekti ID-ga.", + "Confirm with your master password": "Kinnitage põhiparooliga", + "Confirm": "Kinnita", + "That master password is not right.": "See põhiparool ei ole õige.", + "You are sharing with someone new. Enter your master password to confirm.": "Jagate uue inimesega. Kinnitamiseks sisestage põhiparool.", + "Enter your master password to confirm this share.": "Selle jagamise kinnitamiseks sisestage põhiparool.", + "Enter your master password to confirm this delegation.": "Selle delegeerimise kinnitamiseks sisestage põhiparool.", + "Approve {member}": "Kinnita {member}", + "Recipient": "Adressaat", + "No vault yet": "Seifi veel pole", + "No matching users": "Sobivaid kasutajaid pole", + "Partner organisations": "Partnerorganisatsioonid", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vaheta saladusi teise Keepiqiga. Mõlemad administraatorid lisavad teineteise ja võrdlevad juursõrmejälgi telefoni teel või silmast silma enne salvestamist.", + "Federation needs Nextcloud 33 or later.": "Föderatsioon vajab Nextcloud 33 või uuemat.", + "Your root fingerprint": "Sinu juursõrmejälg", + "No partners yet.": "Partnereid veel pole.", + "Users here may share to this partner": "Siinsed kasutajad võivad selle partneriga jagada", + "This partner may share to users here": "See partner võib siinsete kasutajatega jagada", + "Partner address": "Partneri aadress", + "Check partner": "Kontrolli partnerit", + "Partner root fingerprint": "Partneri juursõrmejälg", + "I compared this fingerprint with the partner's administrator": "Võrdlesin seda sõrmejälge partneri administraatoriga", + "Add partner": "Lisa partner", + "A secret from another organisation": "Saladus teisest organisatsioonist", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s jagas teiega kirjet \"%2$s\". Võtke see vastu lehel Saabunud teistest organisatsioonidest.", + "Incoming from other organisations": "Saabunud teistest organisatsioonidest", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partnerorganisatsioonide inimesed saavad teiega saladust jagada. Võtke see vastu, et hoida oma seifis kirjutuskaitstud koopiat.", + "Nothing shared with you yet": "Teiega pole veel midagi jagatud", + "Secrets that people in partner organisations share with you appear here.": "Saladused, mida partnerorganisatsioonide inimesed teiega jagavad, kuvatakse siin.", + "From {sender}": "Saatja: {sender}", + "Accept": "Võta vastu", + "Open in vault": "Ava seifis", + "The other organisation did not hand over the secret. Try again later.": "Teine organisatsioon ei andnud saladust üle. Proovige hiljem uuesti.", + "Set up your vault before you accept a shared secret.": "Seadistage oma seif enne jagatud saladuse vastuvõtmist.", + "Something went wrong. Try again.": "Midagi läks valesti. Proovige uuesti.", + "Waiting for your answer": "Ootab teie vastust", + "In your vault, read-only": "Teie seifis, kirjutuskaitstud", + "Withdrawn by the sender": "Saatja võttis tagasi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} jagas seda teisest organisatsioonist. Saate seda lugeda, kuid mitte muuta ega jagada.", + "Someone": "Keegi", + "Share with someone at another organisation": "Jaga kellegagi teisest organisatsioonist", + "Their account at the other organisation": "Selle inimese konto teises organisatsioonis", + "Check account": "Kontrolli kontot", + "Certificate fingerprint of {account}": "Konto {account} sertifikaadi sõrmejälg", + "Compare it with them by phone if you want to be sure.": "Kui soovite kindel olla, võrrelge seda selle inimesega telefoni teel.", + "Shared. {account} can accept it in their own vault.": "Jagatud. {account} saab selle oma seifis vastu võtta.", + "The certificate could not be verified. Nothing was shared.": "Sertifikaati ei õnnestunud kontrollida. Midagi ei jagatud.", + "That organisation is not one of your partners.": "See organisatsioon ei ole teie partner.", + "No one with that account can receive secrets from you.": "Keegi selle kontoga ei saa teilt saladusi vastu võtta.", + "The other organisation did not answer. Try again later.": "Teine organisatsioon ei vastanud. Proovige hiljem uuesti.", + "This secret is already shared with that account.": "See saladus on selle kontoga juba jagatud.", + "Other organisations": "Teised organisatsioonid", + "Receive secrets from other organisations": "Võta vastu saladusi teistest organisatsioonidest", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Partnerorganisatsioonide inimesed saavad siis teie konto leida ja teiega saladusi jagada. Iga saladuse võtate vastu ise.", + "Shared": "Jagatud", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Peatatud: nende sertifikaat või partnerlus muutus. Tühistage jagamine või jagage uuesti.", + "Their organisation did not get the last change. Revoke it or share again.": "Nende organisatsioon ei saanud viimast muudatust. Tühistage jagamine või jagage uuesti.", + "Being withdrawn": "Tühistatakse", + "Shared with another organisation": "Jagatud teise organisatsiooniga", + "Change sent to another organisation": "Muudatus saadetud teisele organisatsioonile", + "Share with another organisation revoked": "Jagamine teise organisatsiooniga tühistatud", + "Share with another organisation paused": "Jagamine teise organisatsiooniga peatatud", + "Another organisation did not get a change": "Teine organisatsioon ei saanud muudatust", + "Secret received from another organisation": "Saladus saadud teisest organisatsioonist", + "Secret from another organisation accepted": "Saladus teisest organisatsioonist vastu võetud", + "Secret from another organisation declined": "Saladus teisest organisatsioonist tagasi lükatud", + "Copy from another organisation updated": "Koopia teisest organisatsioonist uuendatud", + "Copy from another organisation removed": "Koopia teisest organisatsioonist eemaldatud", + "Declined: they removed their copy. Share again if they need it.": "Tagasi lükatud: saaja eemaldas oma koopia. Jagage uuesti, kui tal seda vaja on.", + "Recipient at another organisation removed their copy": "Teise organisatsiooni saaja eemaldas oma koopia", + "Removed the user from %n team folder.": "Kasutaja eemaldati %n meeskonnakaustast.", + "Removed the user from %n team folders.": "Kasutaja eemaldati %n meeskonnakaustast.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Kasutaja eemaldati %n meeskonnakaustast.", + "Kasutaja eemaldati %n meeskonnakaustast." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Taastatud koopia pärineb lõppenud jagamisest. See jääb kirjutuskaitstuks.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Taastatud koopia jaganud organisatsiooniga ei saadud ühendust. Koopia jääb kirjutuskaitstuks ega järgi nende muudatusi.", + "Recipient at another organisation restored their copy": "Teise organisatsiooni saaja taastas oma koopia" }, "plurals": null } diff --git a/l10n/fi.js b/l10n/fi.js index a222cec2a..db6d45927 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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ä." + "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ä.", + "Shared with groups": "Jaettu ryhmien kanssa", + "Not shared with any group yet.": "Ei vielä jaettu minkään ryhmän kanssa.", + "Revoke the share with {group}": "Peru jako ryhmän {group} kanssa", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jaettu ryhmän {group} kanssa: {received} jäsentä sai sen, {skipped} ei saanut, koska heillä ei ole vielä salausta käytössä.", + "Search groups": "Hae ryhmiä", + "Failed to share": "Jakaminen epäonnistui", + "Columns": "Sarakkeet", + "Column {number}": "Sarake {number}", + "Map one column to Name. Every secret needs a name.": "Liitä yksi sarake nimeen. Jokainen salaisuus tarvitsee nimen.", + "Notes": "Muistiinpanot", + "Do not import": "Älä tuo", + "Hide this value": "Piilota tämä arvo", + "Show this value": "Näytä tämä arvo", + "Defaults": "Oletukset", + "New secrets start as this type, and your secret list opens in this view.": "Uudet salaisuudet alkavat tällä tyypillä, ja salaisuuslistasi avautuu tässä näkymässä.", + "Default item type": "Oletuskohdetyyppi", + "Cards": "Kortit", + "Table": "Taulukko", + "Could not save your default": "Oletusarvoa ei voitu tallentaa", + "Recently used": "Äskettäin käytetyt", + "Opened": "Avattu", + "You have not opened any secrets yet": "Et ole vielä avannut yhtään salaisuutta", + "Could not delete the item type.": "Kohdetyyppiä ei voitu poistaa.", + "Could not load the item types.": "Kohdetyyppejä ei voitu ladata.", + "Could not save the item type.": "Kohdetyyppiä ei voitu tallentaa.", + "Delete item type": "Poista kohdetyyppi", + "Edit item type": "Muokkaa kohdetyyppiä", + "Fields": "Kentät", + "Fields: {count}": "Kentät: {count}", + "Hidden": "Piilotettu", + "Item types": "Kohdetyypit", + "Move up": "Siirrä ylös", + "New item type": "Uusi kohdetyyppi", + "No item types defined yet.": "Kohdetyyppejä ei ole vielä määritetty.", + "Required": "Pakollinen", + "Text": "Teksti", + "This field is required": "Tämä kenttä on pakollinen", + "Web address": "Verkko-osoite", + "{label} (required)": "{label} (pakollinen)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Poistetaanko ”{name}”? Tämän tyypin salaisuudet pysyvät luettavina ja muuttuvat Kirjaudu-kohteiksi.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tässä määrittämäsi kohdetyypit näkyvät kaikille Uusi salaisuus -ikkunassa valitsemillasi kentillä.", + "Secret moved to the trash": "Salaisuus siirretty roskakoriin", + "Secret restored from the trash": "Salaisuus palautettu roskakorista", + "Secret deleted for good": "Salaisuus poistettu pysyvästi", + "Secret archived": "Salaisuus arkistoitu", + "Secret unarchived": "Salaisuus palautettu arkistosta", + "Unarchive": "Palauta arkistosta", + "Could not archive the secret": "Salaisuuden arkistointi epäonnistui", + "Could not unarchive the secret": "Salaisuuden palautus arkistosta epäonnistui", + "Archive {count} secrets": "Arkistoi salaisuudet: {count}", + "Unarchive {count} secrets": "Palauta arkistosta salaisuudet: {count}", + "Restore {count} secrets": "Palauta salaisuudet: {count}", + "Delete {count} secrets for good": "Poista pysyvästi salaisuudet: {count}", + "Done for {ok} of {total} secrets": "Valmis {ok}/{total} salaisuudelle", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkistoidut salaisuudet poistuvat holvin luettelosta, hausta, automaattisesta täytöstä ja kuntoraportista. Niiden jaot säilyvät. Löydät ne kohdasta Arkisto.", + "These secrets come back to the vault list, search and autofill.": "Nämä salaisuudet palaavat holvin luetteloon, hakuun ja automaattiseen täyttöön.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Nämä salaisuudet palaavat holvin luetteloon. Niiden vanhat jaot eivät palaa, joten jaa ne tarvittaessa uudelleen.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tämä poistaa salaisuudet liitteineen ja versiohistorioineen. Toimintoa ei voi perua.", + "Delete for good": "Poista pysyvästi", + "Trash": "Roskakori", + "The trash is empty": "Roskakori on tyhjä", + "No archived secrets": "Ei arkistoituja salaisuuksia", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Poistetut salaisuudet odottavat täällä säilytysajan loppuun, minkä jälkeen ne poistetaan pysyvästi.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkistoi salaisuus sen tietopaneelista, niin se pysyy poissa holvin luettelosta, hausta ja automaattisesta täytöstä.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Salattujen liitteiden rajat (valvotaan palvelimella tallennetuissa salatuissa tavuissa), versiohistorian säilytys ja kuinka kauan poistetut salaisuudet pysyvät roskakorissa.", + "Days a deleted secret stays in the trash (1 to 365)": "Päiviä, jotka poistettu salaisuus pysyy roskakorissa (1–365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tämä siirtää salaisuuden roskakoriin ja lopettaa sen jaot heti. Voit palauttaa sen roskakorista säilytysajan loppuun asti: 30 päivää, ellei ylläpitäjä ole muuttanut sitä.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tämä siirtää salaisuudet roskakoriin ({count}) ja lopettaa niiden jaot heti. Voit palauttaa ne roskakorista säilytysajan loppuun asti.", + "Remove {name} from favourites": "Poista {name} suosikeista", + "Add {name} to favourites": "Lisää {name} suosikkeihin", + "Could not change the favourite": "Suosikkia ei voitu muuttaa", + "Remove from favourites": "Poista suosikeista", + "Add to favourites": "Lisää suosikkeihin", + "Tags": "Tunnisteet", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tunnisteita ei ole salattu. Palvelimen ylläpitäjät voivat lukea ne, kuten kansioiden nimet.", + "Favourites": "Suosikit", + "Filter by tag": "Suodata tunnisteen mukaan", + "All tags": "Kaikki tunnisteet", + "Last used": "Viimeksi käytetty", + "Tags for {count} secrets": "Tunnisteet {count} salaisuudelle", + "Tag": "Tunniste", + "Remove tag": "Poista tunniste", + "Add tag": "Lisää tunniste", + "Could not change the tags. Try again.": "Tunnisteita ei voitu muuttaa. Yritä uudelleen.", + "Could not approve the application. It is still in the queue.": "Hakemusta ei voitu hyväksyä. Se on yhä jonossa.", + "Could not reject the application. It is still in the queue.": "Hakemusta ei voitu hylätä. Se on yhä jonossa.", + "Removed the user from {count} team folders.": "Käyttäjä poistettiin {count} tiimikansiosta.", + "Approve a share": "Hyväksy jako", + "This approval link is incomplete. Open it again from the notification.": "Tämä hyväksyntälinkki on puutteellinen. Avaa se uudelleen ilmoituksesta.", + "Deny": "Hylkää", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} liittyi ryhmään, jonka kanssa jaat salaisuuden. Jaetaanko salaisuus myös hänelle?", + "{requester} asks you to share a secret with {user}.": "{requester} pyytää sinua jakamaan salaisuuden käyttäjälle {user}.", + "Shared. The recipient can now open the secret.": "Jaettu. Vastaanottaja voi nyt avata salaisuuden.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Vastaanottaja ei ole vielä ottanut Keepiqia käyttöön, joten mitään ei jaettu. Yritä uudelleen, kun hän on tehnyt sen.", + "Could not share the secret. Only its owner can approve this.": "Salaisuutta ei voitu jakaa. Vain sen omistaja voi hyväksyä tämän.", + "Could not share the secret. Try again.": "Salaisuutta ei voitu jakaa. Yritä uudelleen.", + "Denied. Nothing was shared.": "Hylätty. Mitään ei jaettu.", + "Could not deny the request. Try again.": "Pyyntöä ei voitu hylätä. Yritä uudelleen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s pyytää sinua jakamaan salaisuuden \"%2$s\" käyttäjälle %3$s.", + "Expires on (optional)": "Vanhenee (valinnainen)", + "Hand over to": "Luovuta käyttäjälle", + "Choose a recipient": "Valitse vastaanottaja", + "Hand over temporarily": "Luovuta väliaikaisesti", + "Expiry rules": "Vanhenemissäännöt", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Määritä, kuinka kauan yhden kohdetyypin tai yhden kansion salasanat saavat olla voimassa ja milloin sinua muistutetaan. Kun useita päivämääriä on voimassa, aikaisin ratkaisee.", + "Delete rule": "Poista sääntö", + "Set by your administrator": "Ylläpitäjäsi asettama", + "No expiry rules yet.": "Ei vielä vanhenemissääntöjä.", + "Applies to": "Koskee", + "Item type": "Kohdetyyppi", + "Maximum age in days (empty for reminders only)": "Enimmäisikä päivinä (tyhjä vain muistutuksille)", + "Remind me this many days before, comma separated": "Muistuta minua näin monta päivää ennen, pilkuilla eroteltuna", + "Save rule": "Tallenna sääntö", + "An item type": "Kohdetyyppi", + "A folder": "Kansio", + "Folder {name}": "Kansio {name}", + "Type {name}": "Tyyppi {name}", + "Expires after {days} days": "Vanhenee {days} päivän kuluttua", + "Reminders {days} days before": "Muistutukset {days} päivää ennen", + "Could not save the expiry rule.": "Vanhenemissääntöä ei voitu tallentaa.", + "Could not delete the expiry rule.": "Vanhenemissääntöä ei voitu poistaa.", + "All statuses": "Kaikki tilat", + "Compromised": "Vaarantunut", + "Could not load the members.": "Jäsenten lataaminen epäonnistui.", + "Emergency contact": "Hätäyhteyshenkilö", + "Leaving user": "Lähtevä käyttäjä", + "No": "Ei", + "No users match this filter.": "Yksikään käyttäjä ei vastaa tätä suodatinta.", + "Not set up": "Ei määritetty", + "Revoke suite": "Peru sarja", + "Revoked": "Peruttu", + "Search users": "Hae käyttäjiä", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Katso, ketkä käyttäjät ovat ottaneet holvin käyttöön. Aloita poistuminen tai peru sarja riviltä.", + "Successor": "Seuraaja", + "Team folders": "Tiimikansiot", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Käyttäjä on yhä ryhmässä {groups}, joka on tiimikansion jäsen. Poista käyttäjä ryhmästä tai poista tili käytöstä.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Käyttäjä on yhä ryhmissä {groups}, jotka ovat tiimikansioiden jäseniä. Poista käyttäjä ryhmistä tai poista tili käytöstä.", + "Vault status": "Holvin tila", + "Yes": "Kyllä", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-vienti on SALAAMATON. Jokainen salasana ja käyttäjätunnus on luettavissa selkokielisenä ladatussa tiedostossa. Säilytä se turvallisesti ja poista se heti käytön jälkeen.", + "Root certificate expiring soon": "Juurivarmenne vanhenee pian", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Holvin juurivarmenne vanhenee %1$d päivän kuluttua. Uusi se ennen sitä. Uusiminen allekirjoittaa jokaisen salauspaketin uudelleen.", + "Compromise recovery aborted": "Vaarantumisen jälkeinen palautus keskeytetty", + "Key rotation ended by a compromise revoke": "Avainkierto päättyi vaarantumisen vuoksi tehtyyn peruutukseen", + "Encryption suite revoke refused": "Salauspaketin peruutus hylätty", + "Master password proof refused": "Pääsalasanan todiste hylätty", + "Your current master password": "Nykyinen pääsalasanasi", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti hänet. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti heidät. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Näitä hätäyhteyshenkilöitä ei siirretty uuteen avaimeesi. Heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäytöstä, jos haluat heidät yhä.", + "Renew root certificate": "Uusi juurivarmenne", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Tämä luo uuden juuri- ja välivarmenteen. Jokainen aktiivinen salauspaketti allekirjoitetaan uudelleen. Tätä ei voi perua.", + "Renew root": "Uusi juuri", + "Root renewed. {n} encryption suites signed again.": "Juuri uusittu. Uudelleen allekirjoitettuja salauspaketteja: {n}.", + "Could not renew the root certificate.": "Juurivarmennetta ei voitu uusia.", + "Lease policy for this application": "Tämän sovelluksen vuokrauskäytäntö", + "In force now: {default} seconds by default, {max} seconds at most.": "Voimassa nyt: oletuksena {default} sekuntia, enintään {max} sekuntia.", + "Leases are not renewable": "Vuokria ei voi uusia", + "Lease policy saved.": "Vuokrauskäytäntö tallennettu.", + "Leave a field empty to use the instance value.": "Jätä kenttä tyhjäksi käyttääksesi instanssin arvoa.", + "Instance value: {value}": "Instanssin arvo: {value}", + "Renewal": "Uusiminen", + "Use the instance value ({value})": "Käytä instanssin arvoa ({value})", + "Allowed": "Sallittu", + "Not allowed": "Ei sallittu", + "Save lease policy": "Tallenna vuokrauskäytäntö", + "Only an administrator can change this policy.": "Vain ylläpitäjä voi muuttaa tätä käytäntöä.", + "Could not save the lease policy.": "Vuokrauskäytäntöä ei voitu tallentaa.", + "{member} got access from {confirmer}.": "{member} sai käyttöoikeuden käyttäjältä {confirmer}.", + "Automatically confirm new team folder members": "Vahvista uudet tiimikansion jäsenet automaattisesti", + "Gave %n new member access to a team folder.": "%n uusi jäsen sai pääsyn tiimikansioon.", + "Gave %n new members access to a team folder.": "%n uutta jäsentä sai pääsyn tiimikansioon.", + "Give new team folder members access without waiting for the folder owner.": "Anna uusille tiimikansion jäsenille pääsy odottamatta kansion omistajaa.", + "New team folder members": "Uudet tiimikansion jäsenet", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Omistaja tai kirjoitusoikeudellinen jäsen vahvistaa heidät avoimesta holvistaan. Keepiq ei koskaan pura salausta palvelimella.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Odotetaan, että kirjoitusoikeudellinen jäsen avaa Keepiqin. Voit myös jakaa nyt.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Osa vaarantumisvasteesta epäonnistui ({failed} vaihe(tta)). Tarkista palvelimen loki ja peru sitten sarja uudelleen viimeistelläksesi sen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tämä perui myös sarjan {suite} ja päätti avainten siirron {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystiedon.", + "Revoking the second suite deleted %n emergency-access contacts.": "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystietoa.", + "A suite revoked as compromised cannot be reinstated.": "Vaarantuneena perutun sarjan palauttaminen ei ole mahdollista.", + "Archives to keep": "Säilytettävät arkistot", + "Back up every vault automatically": "Varmuuskopioi jokainen holvi automaattisesti", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varmuuskopioi jokainen holvi aikataulun mukaan. Arkistot sisältävät vain salattua tekstiä, ja ne palautetaan occ-komennolla.", + "Back up now": "Varmuuskopioi nyt", + "Backup public key (PEM, optional)": "Varmuuskopion julkinen avain (PEM, valinnainen)", + "Backup requested for the next cron run": "Varmuuskopio pyydetty seuraavalle cron-ajolle", + "Encrypted": "Salattu", + "Every (hours)": "Joka (tuntia)", + "Last backup {when} failed: {error}": "Viimeisin varmuuskopio {when} epäonnistui: {error}", + "Last backup {when} succeeded.": "Viimeisin varmuuskopio {when} onnistui.", + "No archives yet.": "Ei vielä arkistoja.", + "Size": "Koko", + "Vault backups": "Holvin varmuuskopiot", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Avaimen kanssa jokainen arkisto salataan sille. Säilytä yksityinen avain tämän palvelimen ulkopuolella: tarvitset sitä tarkistamiseen tai palauttamiseen.", + "Written": "Kirjoitettu", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eikä hän voi avata holvia, kun tämä on päällä.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eivätkä he voi avata holvia, kun tämä on päällä.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varakoodit eivät kelpaa. Jos käyttäjäsi kirjautuvat identiteetintarjoajan kautta, jolla on oma toinen tekijä, jätä heidän ryhmänsä pois.", + "Block personal vault export": "Estä henkilökohtaisen holvin vienti", + "Keep work logins in team folders": "Pidä työkirjautumiset tiimikansioissa", + "Move to a team folder": "Siirrä tiimikansioon", + "Not in a team folder": "Ei tiimikansiossa", + "Only for these groups (empty is everyone)": "Vain näille ryhmille (tyhjä tarkoittaa kaikkia)", + "Require two-factor login before the vault opens": "Vaadi kaksivaiheinen kirjautuminen ennen holvin avaamista", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Säännöt jokaiselle holville. Kukin koskee kaikkia tai vain valitsemiasi ryhmiä.", + "Secret types that belong in a team folder": "Salaisuustyypit, jotka kuuluvat tiimikansioon", + "Set up two-factor login": "Ota kaksivaiheinen kirjautuminen käyttöön", + "Team folder you can write to": "Tiimikansio, johon voit kirjoittaa", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Käyttäjät eivät voi ladata varmuuskopiota, CSV-tiedostoa tai siirtotiedostoa. Heidän henkilökohtainen tietopakettinsa on yhä saatavilla.", + "Users cannot save these secret types in a personal folder.": "Käyttäjät eivät voi tallentaa näitä salaisuustyyppejä henkilökohtaiseen kansioon.", + "Vault policies": "Holvin käytännöt", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organisaatiosi ei salli henkilökohtaisen holvisi vientiä. Henkilökohtainen tietopakettisi asetuksissa on yhä saatavilla.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organisaatiosi pitää nämä salaisuudet tiimikansiossa. Siirrä jokainen tiimikansioon.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organisaatiosi pitää tämäntyyppiset salaisuudet tiimikansiossa. Valitse jokin tiimikansioistasi tai sellainen, johon voit kirjoittaa.", + "Your organisation requires two-factor login before you can open your vault.": "Organisaatiosi vaatii kaksivaiheisen kirjautumisen ennen kuin voit avata holvisi.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Käyttäjät valitsevat, kuinka kauan laajennus pysyy avattuna käyttämättömänä. Sinä asetat pisimmän valittavan ajan.", + "Longest idle time before the extension locks": "Pisin käyttämätön aika ennen laajennuksen lukitsemista", + "1 minute": "1 minuutti", + "5 minutes": "5 minuuttia", + "15 minutes": "15 minuuttia", + "1 hour": "1 tunti", + "4 hours": "4 tuntia", + "Connector": "Liitin", + "Directory (tenant) ID": "Hakemiston (vuokraajan) tunnus", + "Application (client) ID": "Sovelluksen (asiakkaan) tunnus", + "Data collection rule immutable ID": "Tiedonkeruusäännön muuttumaton tunnus", + "Stream name": "Virran nimi", + "Splunk index (optional)": "Splunk-indeksi (valinnainen)", + "Sourcetype (optional)": "Sourcetype (valinnainen)", + "Leave blank to keep the current one": "Jätä tyhjäksi säilyttääksesi nykyisen", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogin kautta", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Tiedonkeruun päätepiste (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collectorin URL (https)", + "Client secret (write-only)": "Asiakassalaisuus (vain kirjoitus)", + "HEC token (write-only)": "HEC-tunnus (vain kirjoitus)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Välitä sallitut valvontatapahtumat Splunkiin, Microsoft Sentineliin, syslog-vastaanottimeen tai webhookiin. Viestit sisältävät vain puhdistettua metatietoa: mikään salainen arvo, nimi, kirjautumistunnus tai salateksti ei koskaan poistu palvelimelta.", + "%n change waiting to sync": "%n muutos odottaa synkronointia", + "%n changes waiting to sync": "%n muutosta odottaa synkronointia", + "Changes that could not sync": "Muutokset, joita ei voitu synkronoida", + "Choose a version": "Valitse versio", + "Copy value": "Kopioi arvo", + "Deleted": "Poistettu", + "Discard": "Hylkää", + "Keep my offline change": "Säilytä offline-muutokseni", + "Keep the server version": "Säilytä palvelimen versio", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq on offline-tilassa vain luku -tilassa. Ylläpitäjä ei ole ottanut offline-muokkausta käyttöön.", + "Let users edit secrets offline": "Salli käyttäjien muokata salaisuuksia offline-tilassa", + "Not synced yet": "Ei vielä synkronoitu", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-muutokset säilyvät laitteella käyttäjälle salattuina ja synkronoidaan seuraavan verkkoavauksen yhteydessä. Jakaminen, kansiot ja liitteet vaativat edelleen yhteyden.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Muokkaukset, siirrot ja poistot säilyvät tällä laitteella ja synkronoidaan, kun olet taas verkossa. Jakaminen ja liitteet vaativat yhteyden.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Muutoksesi säilyvät tällä laitteella ja synkronoidaan, kun olet taas verkossa. Viimeksi synkronoitu {when}.", + "Open my changes": "Avaa muutokseni", + "Sharing needs a connection": "Jakaminen vaatii yhteyden", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Joku muutti tätä salaisuutta palvelimella offline-kopiosi tekemisen jälkeen. Valitse säilytettävä versio.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronoi tai hylkää offline-muutoksesi ennen avainten vaihtamista.", + "That password did not open your changes.": "Salasana ei avannut muutoksiasi.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline-tilannekuva tallentaa salatut salaisuudet (avattavissa vain käyttäjän pääsalasanasta johdetulla avaimella, aivan kuten palvelimella) ja salaa nimet, URL-osoitteet ja kansioiden nimet levossa. Offline-käyttö on vain luku -tilassa, ellet salli offline-muokkausta alla. Poista tämä käytöstä laitteilta, jotka eivät saa koskaan tallentaa tunnistetietoja välimuistiin; käytöstä poistaminen tyhjentää välimuistit seuraavalla latauksella.", + "The previous vault copy is gone, so these changes cannot be opened.": "Holvin aiempi kopio on poissa, joten näitä muutoksia ei voi avata.", + "The server version": "Palvelimen versio", + "This secret changed while you were offline": "Tämä salaisuus muuttui, kun olit offline-tilassa", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Poistit tämän salaisuuden offline-tilassa, mutta sitä on sittemmin muutettu palvelimella. Valitse säilytettävä versio.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Avaimesi vaihdettiin toisella laitteella. Anna aiempi pääsalasanasi synkronoidaksesi offline-muutokset, tai hylkää ne.", + "Your offline change": "Offline-muutoksesi", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti hänet. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.","%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti heidät. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n kohdetta ei voi esittää CXF-muodossa, ja se ohitetaan.","%n kohdetta ei voi esittää CXF-muodossa, ja ne ohitetaan."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n vanhempi versio poistettiin, koska vain viimeaikainen historia voidaan siirtää.","%n vanhempaa versiota poistettiin, koska vain viimeaikainen historia voidaan siirtää."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n salaisuuden kopio on vielä salattava ja jaettava.","%n salaisuuden kopiota on vielä salattava ja jaettava."], + "_%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 salaisuutta ei voitu purkaa, eikä se ole tässä viennissä.","%n salaisuutta ei voitu purkaa, eivätkä ne ole tässä viennissä."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n salaisuutta ei voitu purkaa vanhalla avaimellasi, joten sitä ei siirretty.","%n salaisuutta ei voitu purkaa vanhalla avaimellasi, joten niitä ei siirretty."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n salaisuutta ei siirretty.","%n salaisuutta ei siirretty."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n salaisuus on edelleen salattu aiemmalla avaimellasi.","%n salaisuutta on edelleen salattu aiemmalla avaimellasi."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n salaisuus ohitettiin, koska seuraajalla ei ole vielä kopiota — lisää seuraaja kansioon ja suorita uudelleen.","%n salaisuutta ohitettiin, koska seuraajalla ei ole vielä kopiota — lisää seuraaja kansioon ja suorita uudelleen."], + "_%n secret_::_%n secrets_": ["%n salaisuus","%n salaisuutta"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eikä hän voi avata holvia, kun tämä on päällä.","%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eivätkä he voi avata holvia, kun tämä on päällä."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Viimeistele kuitenkin ja menetä käyttöoikeus %n salaisuuteen","Viimeistele kuitenkin ja menetä käyttöoikeus %n salaisuuteen"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n uusi jäsen sai pääsyn tiimikansioon.","%n uutta jäsentä sai pääsyn tiimikansioon."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Avaimen kierto valmis. %n salaisuus salattiin uudelleen uudella avaimellasi.","Avaimen kierto valmis. %n salaisuutta salattiin uudelleen uudella avaimellasi."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Toisen sarjan peruminen poisti %n hätäkäyttöyhteystiedon.","Toisen sarjan peruminen poisti %n hätäkäyttöyhteystietoa."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilön.","Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilöä."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["nähty vuodoissa %n kerran","nähty vuodoissa %n kertaa"], + "_shared with %n secret_::_shared with %n secrets_": ["jaettu %n salaisuudelle","jaettu %n salaisuudelle"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Tämä kansio sisältää suoraan %n salaisuuden.","Tämä kansio sisältää suoraan %n salaisuutta."], + "_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._": ["Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.","Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n muutos odottaa synkronointia","%n muutosta odottaa synkronointia"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Käyttäjä on yhä ryhmässä {groups}, joka on tiimikansion jäsen. Poista käyttäjä ryhmästä tai poista tili käytöstä.","Käyttäjä on yhä ryhmissä {groups}, jotka ovat tiimikansioiden jäseniä. Poista käyttäjä ryhmistä tai poista tili käytöstä."], + "Allow approval from another device": "Salli hyväksyntä toiselta laitteelta", + "App": "Sovellus", + "Approve a new device": "Hyväksy uusi laite", + "Approve from another device": "Hyväksy toiselta laitteelta", + "Asked at": "Pyydetty", + "Check that the new device shows these words:": "Tarkista, että uusi laite näyttää nämä sanat:", + "Denied. If you did not ask, end your other sessions:": "Hylätty. Jos et pyytänyt tätä, lopeta muut istuntosi:", + "Device": "Laite", + "IP address": "IP-osoite", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Anna käyttäjien avata uusi selain hyväksymällä se laitteelta, jolla Keepiq on jo avattu.", + "New device approval": "Uuden laitteen hyväksyntä", + "Nextcloud security settings": "Nextcloudin tietoturva-asetukset", + "Only approve a device you are using right now.": "Hyväksy vain laite, jota käytät juuri nyt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Avaa Keepiq laitteella, jolla se on avattu, ja hyväksy tämä laite. Tarkista, että se näyttää samat sanat:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Hyväksyvä laite sinetöi avausavaimen uudelle laitteelle. Palvelin vain välittää sen eikä voi avata sitä.", + "The master password is not right, or the request has ended.": "Pääsalasana on väärä tai pyyntö on päättynyt.", + "The request expired. Ask again or use your master password.": "Pyyntö vanheni. Pyydä uudelleen tai käytä pääsalasanaasi.", + "The request was denied.": "Pyyntö hylättiin.", + "Too many requests. Try again in an hour or use your master password.": "Liian monta pyyntöä. Yritä uudelleen tunnin kuluttua tai käytä pääsalasanaasi.", + "Unknown device": "Tuntematon laite", + "Web app": "Verkkosovellus", + "A device": "Laite", + "A new device asks to open your vault": "Uusi laite pyytää avaamaan holvisi", + "%s asks to be approved. Only approve a device you are using right now.": "%s pyytää hyväksyntää. Hyväksy vain laite, jota käytät juuri nyt.", + "Access ends on (optional)": "Käyttöoikeus päättyy (valinnainen)", + "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.": "Keepiqin sovellukset eivät näytä tai kopioi salasanaa. Tekninen osaaja voi silti lukea sen omalta laitteeltaan. Vaihda se, kun käyttöoikeus päättyy.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Tämä salaisuus on vain käyttöön. Kirjaudu sisään Keepiq-selainlaajennuksen kautta.", + "Until {date}": "{date} asti", + "Use only": "Vain käyttö", + "Use only (can sign in, cannot view or copy)": "Vain käyttö (voi kirjautua, ei voi nähdä tai kopioida)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Voit kirjautua näillä tunnuksilla Keepiq-selainlaajennuksen kautta. Omistaja on päättänyt, ettet voi nähdä tai kopioida niitä.", + "Your access ends on {date}": "Käyttöoikeutesi päättyy {date}", + "Your access to this secret has ended": "Käyttöoikeutesi tähän salaisuuteen on päättynyt", + "Your access to \"%s\" ends tomorrow": "Käyttöoikeutesi kohteeseen ”%s” päättyy huomenna", + "Your access to \"%s\" has ended": "Käyttöoikeutesi kohteeseen ”%s” on päättynyt", + "%1$s no longer has access to \"%2$s\"": "Käyttäjällä %1$s ei ole enää käyttöoikeutta kohteeseen ”%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s pystyi näkemään tämän salasanan. Vaihda se, jos %1$s ei enää saa tietää sitä.", + "%s could not view this password in Keepiq.": "%s ei voinut nähdä tätä salasanaa Keepiqissä.", + "{approvals} of {threshold} approvals": "{approvals}/{threshold} hyväksyntää", + "a recovery officer": "palautusvastaava", + "Account recovery": "Tilin palautus", + "Approvals needed": "Tarvittavat hyväksynnät", + "Ask {user} which words they see, by phone or in person. They must be:": "Kysy käyttäjältä {user} puhelimitse tai kasvotusten, mitkä sanat hän näkee. Niiden pitää olla:", + "Check again": "Tarkista uudelleen", + "Create the recovery key": "Luo palautusavain", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Luo palautusavain. Selaimesi luo sen ja antaa jokaiselle vastaavalle kopion, jonka vain hän voi avata.", + "Decline": "Hylkää", + "Enrol in account recovery": "Liity tilin palautukseen", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Liity, jotta organisaatiosi voi auttaa sinua saamaan holvisi takaisin, jos unohdat pääsalasanasi.", + "Every user is enrolled": "Kaikki käyttäjät ovat liittyneet", + "Finish the recovery in the browser you asked from.": "Viimeistele palautus selaimessa, josta pyysit sitä.", + "Forgot your master password?": "Unohditko pääsalasanasi?", + "Hand the key over": "Luovuta avain", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Anna pääsalasanansa unohtaneiden käyttäjien saada holvinsa takaisin nimeämiesi palautusvastaavien hyväksynnällä.", + "New master password": "Uusi pääsalasana", + "No one is asking to recover their account.": "Kukaan ei pyydä tilinsä palautusta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Palautusavainta ei vielä ole. Yksi vastaavista luo sen Keepiq-asetuksissaan.", + "Off": "Pois", + "Officer {user} has no encryption set up yet.": "Vastaavalla {user} ei ole vielä salausta käytössä.", + "Officers (user IDs, separated by commas)": "Vastaavat (käyttäjätunnukset pilkuin erotettuina)", + "Policy": "Käytäntö", + "Publish this fingerprint internally, so users can check it before they enrol.": "Julkaise tämä sormenjälki sisäisesti, jotta käyttäjät voivat tarkistaa sen ennen liittymistä.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Palautettu käyttäjän {officer} avulla. Vaihda holviavaimesi nyt kohdassa Asetukset, Turvallisuus: \"Pääsalasanani on vaarantunut\".", + "Recovery key fingerprint: {fingerprint}": "Palautusavaimen sormenjälki: {fingerprint}", + "Recovery officer": "Palautusvastaava", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Poistetut vastaavat menettävät kopionsa nyt, mutta ovat voineet avata sen aiemmin. Pyydä vastaavaa luomaan uusi palautusavain.", + "Repeat the new master password": "Toista uusi pääsalasana", + "Retire this recovery key": "Poista tämä palautusavain käytöstä", + "Set the new master password": "Aseta uusi pääsalasana", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Palautusvarmennetta ei ole myöntänyt tämä Keepiq. Älä liity ja kerro asiasta ylläpitäjälle.", + "The words match, approve": "Sanat täsmäävät, hyväksy", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tämä käyttäjä on liittynyt tilin palautukseen. Palautus säilyttää hänen salaisuutensa; peruutus poistaa hänen liittymisensä.", + "Users may enrol": "Käyttäjät voivat liittyä", + "Withdraw from account recovery": "Eroa tilin palautuksesta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Olet liittynyt tilin palautukseen. Palautusavaimen sormenjälki: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Olet liittynyt. Jos unohdat pääsalasanasi, organisaatiosi voi auttaa sinua saamaan holvisi takaisin.", + "Your key is back. Choose a new master password.": "Avaimesi on palautettu. Valitse uusi pääsalasana.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Palautusvastaaviasi on tiedotettu. Lue heille nämä sanat, kun he soittavat tai tapaavat sinut:", + "You are now an account recovery officer": "Olet nyt tilin palautusvastaava", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pyytää tilinsä palautusta. Vertaa sanoja hänen kanssaan ennen hyväksymistä.", + "A user": "Käyttäjä", + "Your account recovery request was declined": "Tilin palautuspyyntösi hylättiin", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Tilisi palautus on valmis. Avaa Keepiq selaimessa, josta pyysit sitä.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pyytää uuden laitteen avaamista kerran. Pääsalasana pysyy ennallaan.", + "Ask your organisation instead": "Pyydä sen sijaan organisaatioltasi", + "The request ended. Ask again or use your master password.": "Pyyntö päättyi. Pyydä uudelleen tai käytä pääsalasanaasi.", + "Added by {user}": "Lisännyt {user}", + "Editor": "Muokkaaja", + "Manager": "Ylläpitäjä", + "Role of {member}": "Käyttäjän {member} rooli", + "Team folders you manage": "Hallitsemasi tiimikansiot", + "Viewer": "Katselija", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sinulla ei ole kopiota näistä salaisuuksista, joten uudet jäsenet eivät ole vielä saaneet niitä. Omistaja voi jakaa ne: {names}", + "Admin areas": "Hallinta-alueet", + "Give a group only the parts of Keepiq administration it needs.": "Anna ryhmälle vain ne Keepiqin hallinnan osat, joita se tarvitsee.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegoi yksi tai useampi alue ryhmälle hallintaoikeuksien sivulla. Instanssin ylläpitäjillä on kaikki alueet.", + "Open administration privileges": "Avaa hallintaoikeudet", + "Policies": "Käytännöt", + "Applications and machine access": "Sovellukset ja koneiden pääsy", + "People and offboarding": "Henkilöt ja lähtijät", + "Audit and compliance": "Tarkastus ja vaatimustenmukaisuus", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versio, varmenteiden myöntäjä, liitteet, offline-välimuisti, vuototarkistus, salaisuustyypit ja varmuuskopiot", + "master password, organisation password, vault policies, rotation, version history and trash": "pääsalasana, organisaation salasana, holvin käytännöt, kierto, versiohistoria ja roskakori", + "application queue, application requests and machine leases": "sovellusjono, sovellusten pyynnöt ja koneiden vuokraukset", + "team offboarding, encryption suites and admin handover": "tiimistä lähtijät, salauspaketit ja ylläpitäjän haltuunotto", + "audit log, compliance reports, SIEM export and honey alerts": "tarkastusloki, vaatimustenmukaisuusraportit, SIEM-vienti ja syöttihälytykset", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kuinka monta salaisuuden versiota säilytetään, kuinka kauan, ja kuinka kauan poistetut salaisuudet pysyvät roskakorissa.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Salattujen liitteiden rajat, joita palvelin valvoo tallennettuina salattuina tavuina.", + "Type the suite ID again to confirm": "Vahvista kirjoittamalla sarjan tunnus uudelleen", + "This does not match the suite ID.": "Tämä ei vastaa sarjan tunnusta.", + "Confirm with your master password": "Vahvista pääsalasanalla", + "Confirm": "Vahvista", + "That master password is not right.": "Pääsalasana ei ole oikein.", + "You are sharing with someone new. Enter your master password to confirm.": "Jaat uudelle henkilölle. Vahvista syöttämällä pääsalasanasi.", + "Enter your master password to confirm this share.": "Vahvista tämä jako syöttämällä pääsalasanasi.", + "Enter your master password to confirm this delegation.": "Vahvista tämä delegointi syöttämällä pääsalasanasi.", + "Approve {member}": "Hyväksy {member}", + "Recipient": "Vastaanottaja", + "No vault yet": "Ei vielä holvia", + "No matching users": "Ei vastaavia käyttäjiä", + "Partner organisations": "Kumppaniorganisaatiot", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vaihda salaisuuksia toisen Keepiqin kanssa. Molemmat ylläpitäjät lisäävät toisensa ja vertaavat juurisormenjälkiä puhelimessa tai kasvokkain ennen tallentamista.", + "Federation needs Nextcloud 33 or later.": "Federointi vaatii Nextcloud 33:n tai uudemman.", + "Your root fingerprint": "Oma juurisormenjälkesi", + "No partners yet.": "Ei vielä kumppaneita.", + "Users here may share to this partner": "Täkäläiset käyttäjät voivat jakaa tälle kumppanille", + "This partner may share to users here": "Tämä kumppani voi jakaa täkäläisille käyttäjille", + "Partner address": "Kumppanin osoite", + "Check partner": "Tarkista kumppani", + "Partner root fingerprint": "Kumppanin juurisormenjälki", + "I compared this fingerprint with the partner's administrator": "Vertasin tätä sormenjälkeä kumppanin ylläpitäjän kanssa", + "Add partner": "Lisää kumppani", + "A secret from another organisation": "Salaisuus toisesta organisaatiosta", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s jakoi kohteen \"%2$s\" kanssasi. Hyväksy se kohdassa Saapuneet muista organisaatioista.", + "Incoming from other organisations": "Saapuneet muista organisaatioista", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Kumppaniorganisaatioiden henkilöt voivat jakaa salaisuuden kanssasi. Hyväksy se, niin saat vain luku -kopion holviisi.", + "Nothing shared with you yet": "Kanssasi ei ole vielä jaettu mitään", + "Secrets that people in partner organisations share with you appear here.": "Kumppaniorganisaatioiden henkilöiden kanssasi jakamat salaisuudet näkyvät täällä.", + "From {sender}": "Lähettäjä: {sender}", + "Accept": "Hyväksy", + "Open in vault": "Avaa holvissa", + "The other organisation did not hand over the secret. Try again later.": "Toinen organisaatio ei luovuttanut salaisuutta. Yritä myöhemmin uudelleen.", + "Set up your vault before you accept a shared secret.": "Ota holvi käyttöön ennen kuin hyväksyt jaetun salaisuuden.", + "Something went wrong. Try again.": "Jokin meni vikaan. Yritä uudelleen.", + "Waiting for your answer": "Odottaa vastaustasi", + "In your vault, read-only": "Holvissasi, vain luku", + "Withdrawn by the sender": "Lähettäjä perui jaon", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} jakoi tämän toisesta organisaatiosta. Voit lukea sen, mutta et muokata tai jakaa sitä.", + "Someone": "Joku", + "Share with someone at another organisation": "Jaa jollekulle toisessa organisaatiossa", + "Their account at the other organisation": "Henkilön tili toisessa organisaatiossa", + "Check account": "Tarkista tili", + "Certificate fingerprint of {account}": "Käyttäjän {account} varmenteen sormenjälki", + "Compare it with them by phone if you want to be sure.": "Vertaa sitä henkilön kanssa puhelimessa, jos haluat olla varma.", + "Shared. {account} can accept it in their own vault.": "Jaettu. {account} voi hyväksyä sen omaan holviinsa.", + "The certificate could not be verified. Nothing was shared.": "Varmennetta ei voitu vahvistaa. Mitään ei jaettu.", + "That organisation is not one of your partners.": "Kyseinen organisaatio ei ole kumppanisi.", + "No one with that account can receive secrets from you.": "Kukaan tällä tilillä ei voi vastaanottaa salaisuuksia sinulta.", + "The other organisation did not answer. Try again later.": "Toinen organisaatio ei vastannut. Yritä myöhemmin uudelleen.", + "This secret is already shared with that account.": "Tämä salaisuus on jo jaettu kyseiselle tilille.", + "Other organisations": "Muut organisaatiot", + "Receive secrets from other organisations": "Vastaanota salaisuuksia muista organisaatioista", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Kumppaniorganisaatioiden henkilöt voivat silloin löytää tilisi ja jakaa salaisuuksia kanssasi. Hyväksyt jokaisen itse.", + "Shared": "Jaettu", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Keskeytetty: heidän varmenteensa tai kumppanuus muuttui. Peruuta jako tai jaa uudelleen.", + "Their organisation did not get the last change. Revoke it or share again.": "Heidän organisaationsa ei saanut viimeisintä muutosta. Peruuta jako tai jaa uudelleen.", + "Being withdrawn": "Peruutetaan", + "Shared with another organisation": "Jaettu toiselle organisaatiolle", + "Change sent to another organisation": "Muutos lähetetty toiselle organisaatiolle", + "Share with another organisation revoked": "Jako toiselle organisaatiolle peruutettu", + "Share with another organisation paused": "Jako toiselle organisaatiolle keskeytetty", + "Another organisation did not get a change": "Toinen organisaatio ei saanut muutosta", + "Secret received from another organisation": "Salaisuus vastaanotettu toisesta organisaatiosta", + "Secret from another organisation accepted": "Salaisuus toisesta organisaatiosta hyväksytty", + "Secret from another organisation declined": "Salaisuus toisesta organisaatiosta hylätty", + "Copy from another organisation updated": "Kopio toisesta organisaatiosta päivitetty", + "Copy from another organisation removed": "Kopio toisesta organisaatiosta poistettu", + "Declined: they removed their copy. Share again if they need it.": "Hylätty: vastaanottaja poisti kopionsa. Jaa uudelleen, jos hän tarvitsee sitä.", + "Recipient at another organisation removed their copy": "Toisen organisaation vastaanottaja poisti kopionsa", + "Removed the user from %n team folder.": "Käyttäjä poistettiin %n tiimikansiosta.", + "Removed the user from %n team folders.": "Käyttäjä poistettiin %n tiimikansiosta.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Käyttäjä poistettiin %n tiimikansiosta.","Käyttäjä poistettiin %n tiimikansiosta."], + "A restored copy came from a share that has ended. It stays read-only.": "Palautettu kopio on peräisin päättyneestä jaosta. Se pysyy vain luku -tilassa.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Palautetun kopion jakaneeseen organisaatioon ei saatu yhteyttä. Kopio pysyy vain luku -tilassa eikä seuraa heidän muutoksiaan.", + "Recipient at another organisation restored their copy": "Toisen organisaation vastaanottaja palautti kopionsa" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/fi.json b/l10n/fi.json index 0c8fab5ad..28fb9d2bb 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1181,7 +1181,567 @@ "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ä." + "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ä.", + "Shared with groups": "Jaettu ryhmien kanssa", + "Not shared with any group yet.": "Ei vielä jaettu minkään ryhmän kanssa.", + "Revoke the share with {group}": "Peru jako ryhmän {group} kanssa", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Jaettu ryhmän {group} kanssa: {received} jäsentä sai sen, {skipped} ei saanut, koska heillä ei ole vielä salausta käytössä.", + "Search groups": "Hae ryhmiä", + "Failed to share": "Jakaminen epäonnistui", + "Columns": "Sarakkeet", + "Column {number}": "Sarake {number}", + "Map one column to Name. Every secret needs a name.": "Liitä yksi sarake nimeen. Jokainen salaisuus tarvitsee nimen.", + "Notes": "Muistiinpanot", + "Do not import": "Älä tuo", + "Hide this value": "Piilota tämä arvo", + "Show this value": "Näytä tämä arvo", + "Defaults": "Oletukset", + "New secrets start as this type, and your secret list opens in this view.": "Uudet salaisuudet alkavat tällä tyypillä, ja salaisuuslistasi avautuu tässä näkymässä.", + "Default item type": "Oletuskohdetyyppi", + "Cards": "Kortit", + "Table": "Taulukko", + "Could not save your default": "Oletusarvoa ei voitu tallentaa", + "Recently used": "Äskettäin käytetyt", + "Opened": "Avattu", + "You have not opened any secrets yet": "Et ole vielä avannut yhtään salaisuutta", + "Could not delete the item type.": "Kohdetyyppiä ei voitu poistaa.", + "Could not load the item types.": "Kohdetyyppejä ei voitu ladata.", + "Could not save the item type.": "Kohdetyyppiä ei voitu tallentaa.", + "Delete item type": "Poista kohdetyyppi", + "Edit item type": "Muokkaa kohdetyyppiä", + "Fields": "Kentät", + "Fields: {count}": "Kentät: {count}", + "Hidden": "Piilotettu", + "Item types": "Kohdetyypit", + "Move up": "Siirrä ylös", + "New item type": "Uusi kohdetyyppi", + "No item types defined yet.": "Kohdetyyppejä ei ole vielä määritetty.", + "Required": "Pakollinen", + "Text": "Teksti", + "This field is required": "Tämä kenttä on pakollinen", + "Web address": "Verkko-osoite", + "{label} (required)": "{label} (pakollinen)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Poistetaanko ”{name}”? Tämän tyypin salaisuudet pysyvät luettavina ja muuttuvat Kirjaudu-kohteiksi.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tässä määrittämäsi kohdetyypit näkyvät kaikille Uusi salaisuus -ikkunassa valitsemillasi kentillä.", + "Secret moved to the trash": "Salaisuus siirretty roskakoriin", + "Secret restored from the trash": "Salaisuus palautettu roskakorista", + "Secret deleted for good": "Salaisuus poistettu pysyvästi", + "Secret archived": "Salaisuus arkistoitu", + "Secret unarchived": "Salaisuus palautettu arkistosta", + "Unarchive": "Palauta arkistosta", + "Could not archive the secret": "Salaisuuden arkistointi epäonnistui", + "Could not unarchive the secret": "Salaisuuden palautus arkistosta epäonnistui", + "Archive {count} secrets": "Arkistoi salaisuudet: {count}", + "Unarchive {count} secrets": "Palauta arkistosta salaisuudet: {count}", + "Restore {count} secrets": "Palauta salaisuudet: {count}", + "Delete {count} secrets for good": "Poista pysyvästi salaisuudet: {count}", + "Done for {ok} of {total} secrets": "Valmis {ok}/{total} salaisuudelle", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkistoidut salaisuudet poistuvat holvin luettelosta, hausta, automaattisesta täytöstä ja kuntoraportista. Niiden jaot säilyvät. Löydät ne kohdasta Arkisto.", + "These secrets come back to the vault list, search and autofill.": "Nämä salaisuudet palaavat holvin luetteloon, hakuun ja automaattiseen täyttöön.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Nämä salaisuudet palaavat holvin luetteloon. Niiden vanhat jaot eivät palaa, joten jaa ne tarvittaessa uudelleen.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tämä poistaa salaisuudet liitteineen ja versiohistorioineen. Toimintoa ei voi perua.", + "Delete for good": "Poista pysyvästi", + "Trash": "Roskakori", + "The trash is empty": "Roskakori on tyhjä", + "No archived secrets": "Ei arkistoituja salaisuuksia", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Poistetut salaisuudet odottavat täällä säilytysajan loppuun, minkä jälkeen ne poistetaan pysyvästi.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkistoi salaisuus sen tietopaneelista, niin se pysyy poissa holvin luettelosta, hausta ja automaattisesta täytöstä.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Salattujen liitteiden rajat (valvotaan palvelimella tallennetuissa salatuissa tavuissa), versiohistorian säilytys ja kuinka kauan poistetut salaisuudet pysyvät roskakorissa.", + "Days a deleted secret stays in the trash (1 to 365)": "Päiviä, jotka poistettu salaisuus pysyy roskakorissa (1–365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tämä siirtää salaisuuden roskakoriin ja lopettaa sen jaot heti. Voit palauttaa sen roskakorista säilytysajan loppuun asti: 30 päivää, ellei ylläpitäjä ole muuttanut sitä.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tämä siirtää salaisuudet roskakoriin ({count}) ja lopettaa niiden jaot heti. Voit palauttaa ne roskakorista säilytysajan loppuun asti.", + "Remove {name} from favourites": "Poista {name} suosikeista", + "Add {name} to favourites": "Lisää {name} suosikkeihin", + "Could not change the favourite": "Suosikkia ei voitu muuttaa", + "Remove from favourites": "Poista suosikeista", + "Add to favourites": "Lisää suosikkeihin", + "Tags": "Tunnisteet", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tunnisteita ei ole salattu. Palvelimen ylläpitäjät voivat lukea ne, kuten kansioiden nimet.", + "Favourites": "Suosikit", + "Filter by tag": "Suodata tunnisteen mukaan", + "All tags": "Kaikki tunnisteet", + "Last used": "Viimeksi käytetty", + "Tags for {count} secrets": "Tunnisteet {count} salaisuudelle", + "Tag": "Tunniste", + "Remove tag": "Poista tunniste", + "Add tag": "Lisää tunniste", + "Could not change the tags. Try again.": "Tunnisteita ei voitu muuttaa. Yritä uudelleen.", + "Could not approve the application. It is still in the queue.": "Hakemusta ei voitu hyväksyä. Se on yhä jonossa.", + "Could not reject the application. It is still in the queue.": "Hakemusta ei voitu hylätä. Se on yhä jonossa.", + "Removed the user from {count} team folders.": "Käyttäjä poistettiin {count} tiimikansiosta.", + "Approve a share": "Hyväksy jako", + "This approval link is incomplete. Open it again from the notification.": "Tämä hyväksyntälinkki on puutteellinen. Avaa se uudelleen ilmoituksesta.", + "Deny": "Hylkää", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} liittyi ryhmään, jonka kanssa jaat salaisuuden. Jaetaanko salaisuus myös hänelle?", + "{requester} asks you to share a secret with {user}.": "{requester} pyytää sinua jakamaan salaisuuden käyttäjälle {user}.", + "Shared. The recipient can now open the secret.": "Jaettu. Vastaanottaja voi nyt avata salaisuuden.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Vastaanottaja ei ole vielä ottanut Keepiqia käyttöön, joten mitään ei jaettu. Yritä uudelleen, kun hän on tehnyt sen.", + "Could not share the secret. Only its owner can approve this.": "Salaisuutta ei voitu jakaa. Vain sen omistaja voi hyväksyä tämän.", + "Could not share the secret. Try again.": "Salaisuutta ei voitu jakaa. Yritä uudelleen.", + "Denied. Nothing was shared.": "Hylätty. Mitään ei jaettu.", + "Could not deny the request. Try again.": "Pyyntöä ei voitu hylätä. Yritä uudelleen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s pyytää sinua jakamaan salaisuuden \"%2$s\" käyttäjälle %3$s.", + "Expires on (optional)": "Vanhenee (valinnainen)", + "Hand over to": "Luovuta käyttäjälle", + "Choose a recipient": "Valitse vastaanottaja", + "Hand over temporarily": "Luovuta väliaikaisesti", + "Expiry rules": "Vanhenemissäännöt", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Määritä, kuinka kauan yhden kohdetyypin tai yhden kansion salasanat saavat olla voimassa ja milloin sinua muistutetaan. Kun useita päivämääriä on voimassa, aikaisin ratkaisee.", + "Delete rule": "Poista sääntö", + "Set by your administrator": "Ylläpitäjäsi asettama", + "No expiry rules yet.": "Ei vielä vanhenemissääntöjä.", + "Applies to": "Koskee", + "Item type": "Kohdetyyppi", + "Maximum age in days (empty for reminders only)": "Enimmäisikä päivinä (tyhjä vain muistutuksille)", + "Remind me this many days before, comma separated": "Muistuta minua näin monta päivää ennen, pilkuilla eroteltuna", + "Save rule": "Tallenna sääntö", + "An item type": "Kohdetyyppi", + "A folder": "Kansio", + "Folder {name}": "Kansio {name}", + "Type {name}": "Tyyppi {name}", + "Expires after {days} days": "Vanhenee {days} päivän kuluttua", + "Reminders {days} days before": "Muistutukset {days} päivää ennen", + "Could not save the expiry rule.": "Vanhenemissääntöä ei voitu tallentaa.", + "Could not delete the expiry rule.": "Vanhenemissääntöä ei voitu poistaa.", + "All statuses": "Kaikki tilat", + "Compromised": "Vaarantunut", + "Could not load the members.": "Jäsenten lataaminen epäonnistui.", + "Emergency contact": "Hätäyhteyshenkilö", + "Leaving user": "Lähtevä käyttäjä", + "No": "Ei", + "No users match this filter.": "Yksikään käyttäjä ei vastaa tätä suodatinta.", + "Not set up": "Ei määritetty", + "Revoke suite": "Peru sarja", + "Revoked": "Peruttu", + "Search users": "Hae käyttäjiä", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Katso, ketkä käyttäjät ovat ottaneet holvin käyttöön. Aloita poistuminen tai peru sarja riviltä.", + "Successor": "Seuraaja", + "Team folders": "Tiimikansiot", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Käyttäjä on yhä ryhmässä {groups}, joka on tiimikansion jäsen. Poista käyttäjä ryhmästä tai poista tili käytöstä.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Käyttäjä on yhä ryhmissä {groups}, jotka ovat tiimikansioiden jäseniä. Poista käyttäjä ryhmistä tai poista tili käytöstä.", + "Vault status": "Holvin tila", + "Yes": "Kyllä", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-vienti on SALAAMATON. Jokainen salasana ja käyttäjätunnus on luettavissa selkokielisenä ladatussa tiedostossa. Säilytä se turvallisesti ja poista se heti käytön jälkeen.", + "Root certificate expiring soon": "Juurivarmenne vanhenee pian", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Holvin juurivarmenne vanhenee %1$d päivän kuluttua. Uusi se ennen sitä. Uusiminen allekirjoittaa jokaisen salauspaketin uudelleen.", + "Compromise recovery aborted": "Vaarantumisen jälkeinen palautus keskeytetty", + "Key rotation ended by a compromise revoke": "Avainkierto päättyi vaarantumisen vuoksi tehtyyn peruutukseen", + "Encryption suite revoke refused": "Salauspaketin peruutus hylätty", + "Master password proof refused": "Pääsalasanan todiste hylätty", + "Your current master password": "Nykyinen pääsalasanasi", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti hänet. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti heidät. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Näitä hätäyhteyshenkilöitä ei siirretty uuteen avaimeesi. Heidän hätäkäyttöoikeutensa poistettiin. Lisää heidät uudelleen Hätäkäytöstä, jos haluat heidät yhä.", + "Renew root certificate": "Uusi juurivarmenne", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Tämä luo uuden juuri- ja välivarmenteen. Jokainen aktiivinen salauspaketti allekirjoitetaan uudelleen. Tätä ei voi perua.", + "Renew root": "Uusi juuri", + "Root renewed. {n} encryption suites signed again.": "Juuri uusittu. Uudelleen allekirjoitettuja salauspaketteja: {n}.", + "Could not renew the root certificate.": "Juurivarmennetta ei voitu uusia.", + "Lease policy for this application": "Tämän sovelluksen vuokrauskäytäntö", + "In force now: {default} seconds by default, {max} seconds at most.": "Voimassa nyt: oletuksena {default} sekuntia, enintään {max} sekuntia.", + "Leases are not renewable": "Vuokria ei voi uusia", + "Lease policy saved.": "Vuokrauskäytäntö tallennettu.", + "Leave a field empty to use the instance value.": "Jätä kenttä tyhjäksi käyttääksesi instanssin arvoa.", + "Instance value: {value}": "Instanssin arvo: {value}", + "Renewal": "Uusiminen", + "Use the instance value ({value})": "Käytä instanssin arvoa ({value})", + "Allowed": "Sallittu", + "Not allowed": "Ei sallittu", + "Save lease policy": "Tallenna vuokrauskäytäntö", + "Only an administrator can change this policy.": "Vain ylläpitäjä voi muuttaa tätä käytäntöä.", + "Could not save the lease policy.": "Vuokrauskäytäntöä ei voitu tallentaa.", + "{member} got access from {confirmer}.": "{member} sai käyttöoikeuden käyttäjältä {confirmer}.", + "Automatically confirm new team folder members": "Vahvista uudet tiimikansion jäsenet automaattisesti", + "Gave %n new member access to a team folder.": "%n uusi jäsen sai pääsyn tiimikansioon.", + "Gave %n new members access to a team folder.": "%n uutta jäsentä sai pääsyn tiimikansioon.", + "Give new team folder members access without waiting for the folder owner.": "Anna uusille tiimikansion jäsenille pääsy odottamatta kansion omistajaa.", + "New team folder members": "Uudet tiimikansion jäsenet", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Omistaja tai kirjoitusoikeudellinen jäsen vahvistaa heidät avoimesta holvistaan. Keepiq ei koskaan pura salausta palvelimella.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Odotetaan, että kirjoitusoikeudellinen jäsen avaa Keepiqin. Voit myös jakaa nyt.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Osa vaarantumisvasteesta epäonnistui ({failed} vaihe(tta)). Tarkista palvelimen loki ja peru sitten sarja uudelleen viimeistelläksesi sen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tämä perui myös sarjan {suite} ja päätti avainten siirron {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystiedon.", + "Revoking the second suite deleted %n emergency-access contacts.": "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystietoa.", + "A suite revoked as compromised cannot be reinstated.": "Vaarantuneena perutun sarjan palauttaminen ei ole mahdollista.", + "Archives to keep": "Säilytettävät arkistot", + "Back up every vault automatically": "Varmuuskopioi jokainen holvi automaattisesti", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varmuuskopioi jokainen holvi aikataulun mukaan. Arkistot sisältävät vain salattua tekstiä, ja ne palautetaan occ-komennolla.", + "Back up now": "Varmuuskopioi nyt", + "Backup public key (PEM, optional)": "Varmuuskopion julkinen avain (PEM, valinnainen)", + "Backup requested for the next cron run": "Varmuuskopio pyydetty seuraavalle cron-ajolle", + "Encrypted": "Salattu", + "Every (hours)": "Joka (tuntia)", + "Last backup {when} failed: {error}": "Viimeisin varmuuskopio {when} epäonnistui: {error}", + "Last backup {when} succeeded.": "Viimeisin varmuuskopio {when} onnistui.", + "No archives yet.": "Ei vielä arkistoja.", + "Size": "Koko", + "Vault backups": "Holvin varmuuskopiot", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Avaimen kanssa jokainen arkisto salataan sille. Säilytä yksityinen avain tämän palvelimen ulkopuolella: tarvitset sitä tarkistamiseen tai palauttamiseen.", + "Written": "Kirjoitettu", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eikä hän voi avata holvia, kun tämä on päällä.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eivätkä he voi avata holvia, kun tämä on päällä.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varakoodit eivät kelpaa. Jos käyttäjäsi kirjautuvat identiteetintarjoajan kautta, jolla on oma toinen tekijä, jätä heidän ryhmänsä pois.", + "Block personal vault export": "Estä henkilökohtaisen holvin vienti", + "Keep work logins in team folders": "Pidä työkirjautumiset tiimikansioissa", + "Move to a team folder": "Siirrä tiimikansioon", + "Not in a team folder": "Ei tiimikansiossa", + "Only for these groups (empty is everyone)": "Vain näille ryhmille (tyhjä tarkoittaa kaikkia)", + "Require two-factor login before the vault opens": "Vaadi kaksivaiheinen kirjautuminen ennen holvin avaamista", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Säännöt jokaiselle holville. Kukin koskee kaikkia tai vain valitsemiasi ryhmiä.", + "Secret types that belong in a team folder": "Salaisuustyypit, jotka kuuluvat tiimikansioon", + "Set up two-factor login": "Ota kaksivaiheinen kirjautuminen käyttöön", + "Team folder you can write to": "Tiimikansio, johon voit kirjoittaa", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Käyttäjät eivät voi ladata varmuuskopiota, CSV-tiedostoa tai siirtotiedostoa. Heidän henkilökohtainen tietopakettinsa on yhä saatavilla.", + "Users cannot save these secret types in a personal folder.": "Käyttäjät eivät voi tallentaa näitä salaisuustyyppejä henkilökohtaiseen kansioon.", + "Vault policies": "Holvin käytännöt", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organisaatiosi ei salli henkilökohtaisen holvisi vientiä. Henkilökohtainen tietopakettisi asetuksissa on yhä saatavilla.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organisaatiosi pitää nämä salaisuudet tiimikansiossa. Siirrä jokainen tiimikansioon.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organisaatiosi pitää tämäntyyppiset salaisuudet tiimikansiossa. Valitse jokin tiimikansioistasi tai sellainen, johon voit kirjoittaa.", + "Your organisation requires two-factor login before you can open your vault.": "Organisaatiosi vaatii kaksivaiheisen kirjautumisen ennen kuin voit avata holvisi.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Käyttäjät valitsevat, kuinka kauan laajennus pysyy avattuna käyttämättömänä. Sinä asetat pisimmän valittavan ajan.", + "Longest idle time before the extension locks": "Pisin käyttämätön aika ennen laajennuksen lukitsemista", + "1 minute": "1 minuutti", + "5 minutes": "5 minuuttia", + "15 minutes": "15 minuuttia", + "1 hour": "1 tunti", + "4 hours": "4 tuntia", + "Connector": "Liitin", + "Directory (tenant) ID": "Hakemiston (vuokraajan) tunnus", + "Application (client) ID": "Sovelluksen (asiakkaan) tunnus", + "Data collection rule immutable ID": "Tiedonkeruusäännön muuttumaton tunnus", + "Stream name": "Virran nimi", + "Splunk index (optional)": "Splunk-indeksi (valinnainen)", + "Sourcetype (optional)": "Sourcetype (valinnainen)", + "Leave blank to keep the current one": "Jätä tyhjäksi säilyttääksesi nykyisen", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogin kautta", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Tiedonkeruun päätepiste (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collectorin URL (https)", + "Client secret (write-only)": "Asiakassalaisuus (vain kirjoitus)", + "HEC token (write-only)": "HEC-tunnus (vain kirjoitus)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Välitä sallitut valvontatapahtumat Splunkiin, Microsoft Sentineliin, syslog-vastaanottimeen tai webhookiin. Viestit sisältävät vain puhdistettua metatietoa: mikään salainen arvo, nimi, kirjautumistunnus tai salateksti ei koskaan poistu palvelimelta.", + "%n change waiting to sync": "%n muutos odottaa synkronointia", + "%n changes waiting to sync": "%n muutosta odottaa synkronointia", + "Changes that could not sync": "Muutokset, joita ei voitu synkronoida", + "Choose a version": "Valitse versio", + "Copy value": "Kopioi arvo", + "Deleted": "Poistettu", + "Discard": "Hylkää", + "Keep my offline change": "Säilytä offline-muutokseni", + "Keep the server version": "Säilytä palvelimen versio", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq on offline-tilassa vain luku -tilassa. Ylläpitäjä ei ole ottanut offline-muokkausta käyttöön.", + "Let users edit secrets offline": "Salli käyttäjien muokata salaisuuksia offline-tilassa", + "Not synced yet": "Ei vielä synkronoitu", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-muutokset säilyvät laitteella käyttäjälle salattuina ja synkronoidaan seuraavan verkkoavauksen yhteydessä. Jakaminen, kansiot ja liitteet vaativat edelleen yhteyden.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Muokkaukset, siirrot ja poistot säilyvät tällä laitteella ja synkronoidaan, kun olet taas verkossa. Jakaminen ja liitteet vaativat yhteyden.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Muutoksesi säilyvät tällä laitteella ja synkronoidaan, kun olet taas verkossa. Viimeksi synkronoitu {when}.", + "Open my changes": "Avaa muutokseni", + "Sharing needs a connection": "Jakaminen vaatii yhteyden", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Joku muutti tätä salaisuutta palvelimella offline-kopiosi tekemisen jälkeen. Valitse säilytettävä versio.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronoi tai hylkää offline-muutoksesi ennen avainten vaihtamista.", + "That password did not open your changes.": "Salasana ei avannut muutoksiasi.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline-tilannekuva tallentaa salatut salaisuudet (avattavissa vain käyttäjän pääsalasanasta johdetulla avaimella, aivan kuten palvelimella) ja salaa nimet, URL-osoitteet ja kansioiden nimet levossa. Offline-käyttö on vain luku -tilassa, ellet salli offline-muokkausta alla. Poista tämä käytöstä laitteilta, jotka eivät saa koskaan tallentaa tunnistetietoja välimuistiin; käytöstä poistaminen tyhjentää välimuistit seuraavalla latauksella.", + "The previous vault copy is gone, so these changes cannot be opened.": "Holvin aiempi kopio on poissa, joten näitä muutoksia ei voi avata.", + "The server version": "Palvelimen versio", + "This secret changed while you were offline": "Tämä salaisuus muuttui, kun olit offline-tilassa", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Poistit tämän salaisuuden offline-tilassa, mutta sitä on sittemmin muutettu palvelimella. Valitse säilytettävä versio.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Avaimesi vaihdettiin toisella laitteella. Anna aiempi pääsalasanasi synkronoidaksesi offline-muutokset, tai hylkää ne.", + "Your offline change": "Offline-muutoksesi", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti hänet. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen.", + "%n hätäyhteyshenkilöllä oli avoin käyttöoikeuspyyntö, kun avainkierto poisti heidät. Tarkista, kuka pyysi, ennen kuin lisäät ketään uudelleen." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n kohdetta ei voi esittää CXF-muodossa, ja se ohitetaan.", + "%n kohdetta ei voi esittää CXF-muodossa, ja ne ohitetaan." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n vanhempi versio poistettiin, koska vain viimeaikainen historia voidaan siirtää.", + "%n vanhempaa versiota poistettiin, koska vain viimeaikainen historia voidaan siirtää." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n salaisuuden kopio on vielä salattava ja jaettava.", + "%n salaisuuden kopiota on vielä salattava ja jaettava." + ], + "_%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 salaisuutta ei voitu purkaa, eikä se ole tässä viennissä.", + "%n salaisuutta ei voitu purkaa, eivätkä ne ole tässä viennissä." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n salaisuutta ei voitu purkaa vanhalla avaimellasi, joten sitä ei siirretty.", + "%n salaisuutta ei voitu purkaa vanhalla avaimellasi, joten niitä ei siirretty." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n salaisuutta ei siirretty.", + "%n salaisuutta ei siirretty." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n salaisuus on edelleen salattu aiemmalla avaimellasi.", + "%n salaisuutta on edelleen salattu aiemmalla avaimellasi." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n salaisuus ohitettiin, koska seuraajalla ei ole vielä kopiota — lisää seuraaja kansioon ja suorita uudelleen.", + "%n salaisuutta ohitettiin, koska seuraajalla ei ole vielä kopiota — lisää seuraaja kansioon ja suorita uudelleen." + ], + "_%n secret_::_%n secrets_": [ + "%n salaisuus", + "%n salaisuutta" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eikä hän voi avata holvia, kun tämä on päällä.", + "%n käyttäjällä soveltamisalassa ei vielä ole kaksivaiheista kirjautumista, eivätkä he voi avata holvia, kun tämä on päällä." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Viimeistele kuitenkin ja menetä käyttöoikeus %n salaisuuteen", + "Viimeistele kuitenkin ja menetä käyttöoikeus %n salaisuuteen" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n uusi jäsen sai pääsyn tiimikansioon.", + "%n uutta jäsentä sai pääsyn tiimikansioon." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Avaimen kierto valmis. %n salaisuus salattiin uudelleen uudella avaimellasi.", + "Avaimen kierto valmis. %n salaisuutta salattiin uudelleen uudella avaimellasi." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystiedon.", + "Toisen sarjan peruminen poisti %n hätäkäyttöyhteystietoa." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilön.", + "Tämän sarjan peruuttaminen poisti %n hätäkäytön yhteyshenkilöä." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "nähty vuodoissa %n kerran", + "nähty vuodoissa %n kertaa" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "jaettu %n salaisuudelle", + "jaettu %n salaisuudelle" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Tämä kansio sisältää suoraan %n salaisuuden.", + "Tämä kansio sisältää suoraan %n salaisuutta." + ], + "_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._": [ + "Avaimen kierto poisti %n hätäyhteyshenkilön. Tarkista Hätäkäyttöoikeus ja lisää hänet uudelleen, jos haluat hänet yhä.", + "Avaimen kierto poisti %n hätäyhteyshenkilöä. Tarkista Hätäkäyttöoikeus ja lisää heidät uudelleen, jos haluat heidät yhä." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n muutos odottaa synkronointia", + "%n muutosta odottaa synkronointia" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Käyttäjä on yhä ryhmässä {groups}, joka on tiimikansion jäsen. Poista käyttäjä ryhmästä tai poista tili käytöstä.", + "Käyttäjä on yhä ryhmissä {groups}, jotka ovat tiimikansioiden jäseniä. Poista käyttäjä ryhmistä tai poista tili käytöstä." + ], + "Allow approval from another device": "Salli hyväksyntä toiselta laitteelta", + "App": "Sovellus", + "Approve a new device": "Hyväksy uusi laite", + "Approve from another device": "Hyväksy toiselta laitteelta", + "Asked at": "Pyydetty", + "Check that the new device shows these words:": "Tarkista, että uusi laite näyttää nämä sanat:", + "Denied. If you did not ask, end your other sessions:": "Hylätty. Jos et pyytänyt tätä, lopeta muut istuntosi:", + "Device": "Laite", + "IP address": "IP-osoite", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Anna käyttäjien avata uusi selain hyväksymällä se laitteelta, jolla Keepiq on jo avattu.", + "New device approval": "Uuden laitteen hyväksyntä", + "Nextcloud security settings": "Nextcloudin tietoturva-asetukset", + "Only approve a device you are using right now.": "Hyväksy vain laite, jota käytät juuri nyt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Avaa Keepiq laitteella, jolla se on avattu, ja hyväksy tämä laite. Tarkista, että se näyttää samat sanat:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Hyväksyvä laite sinetöi avausavaimen uudelle laitteelle. Palvelin vain välittää sen eikä voi avata sitä.", + "The master password is not right, or the request has ended.": "Pääsalasana on väärä tai pyyntö on päättynyt.", + "The request expired. Ask again or use your master password.": "Pyyntö vanheni. Pyydä uudelleen tai käytä pääsalasanaasi.", + "The request was denied.": "Pyyntö hylättiin.", + "Too many requests. Try again in an hour or use your master password.": "Liian monta pyyntöä. Yritä uudelleen tunnin kuluttua tai käytä pääsalasanaasi.", + "Unknown device": "Tuntematon laite", + "Web app": "Verkkosovellus", + "A device": "Laite", + "A new device asks to open your vault": "Uusi laite pyytää avaamaan holvisi", + "%s asks to be approved. Only approve a device you are using right now.": "%s pyytää hyväksyntää. Hyväksy vain laite, jota käytät juuri nyt.", + "Access ends on (optional)": "Käyttöoikeus päättyy (valinnainen)", + "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.": "Keepiqin sovellukset eivät näytä tai kopioi salasanaa. Tekninen osaaja voi silti lukea sen omalta laitteeltaan. Vaihda se, kun käyttöoikeus päättyy.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Tämä salaisuus on vain käyttöön. Kirjaudu sisään Keepiq-selainlaajennuksen kautta.", + "Until {date}": "{date} asti", + "Use only": "Vain käyttö", + "Use only (can sign in, cannot view or copy)": "Vain käyttö (voi kirjautua, ei voi nähdä tai kopioida)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Voit kirjautua näillä tunnuksilla Keepiq-selainlaajennuksen kautta. Omistaja on päättänyt, ettet voi nähdä tai kopioida niitä.", + "Your access ends on {date}": "Käyttöoikeutesi päättyy {date}", + "Your access to this secret has ended": "Käyttöoikeutesi tähän salaisuuteen on päättynyt", + "Your access to \"%s\" ends tomorrow": "Käyttöoikeutesi kohteeseen ”%s” päättyy huomenna", + "Your access to \"%s\" has ended": "Käyttöoikeutesi kohteeseen ”%s” on päättynyt", + "%1$s no longer has access to \"%2$s\"": "Käyttäjällä %1$s ei ole enää käyttöoikeutta kohteeseen ”%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s pystyi näkemään tämän salasanan. Vaihda se, jos %1$s ei enää saa tietää sitä.", + "%s could not view this password in Keepiq.": "%s ei voinut nähdä tätä salasanaa Keepiqissä.", + "{approvals} of {threshold} approvals": "{approvals}/{threshold} hyväksyntää", + "a recovery officer": "palautusvastaava", + "Account recovery": "Tilin palautus", + "Approvals needed": "Tarvittavat hyväksynnät", + "Ask {user} which words they see, by phone or in person. They must be:": "Kysy käyttäjältä {user} puhelimitse tai kasvotusten, mitkä sanat hän näkee. Niiden pitää olla:", + "Check again": "Tarkista uudelleen", + "Create the recovery key": "Luo palautusavain", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Luo palautusavain. Selaimesi luo sen ja antaa jokaiselle vastaavalle kopion, jonka vain hän voi avata.", + "Decline": "Hylkää", + "Enrol in account recovery": "Liity tilin palautukseen", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Liity, jotta organisaatiosi voi auttaa sinua saamaan holvisi takaisin, jos unohdat pääsalasanasi.", + "Every user is enrolled": "Kaikki käyttäjät ovat liittyneet", + "Finish the recovery in the browser you asked from.": "Viimeistele palautus selaimessa, josta pyysit sitä.", + "Forgot your master password?": "Unohditko pääsalasanasi?", + "Hand the key over": "Luovuta avain", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Anna pääsalasanansa unohtaneiden käyttäjien saada holvinsa takaisin nimeämiesi palautusvastaavien hyväksynnällä.", + "New master password": "Uusi pääsalasana", + "No one is asking to recover their account.": "Kukaan ei pyydä tilinsä palautusta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Palautusavainta ei vielä ole. Yksi vastaavista luo sen Keepiq-asetuksissaan.", + "Off": "Pois", + "Officer {user} has no encryption set up yet.": "Vastaavalla {user} ei ole vielä salausta käytössä.", + "Officers (user IDs, separated by commas)": "Vastaavat (käyttäjätunnukset pilkuin erotettuina)", + "Policy": "Käytäntö", + "Publish this fingerprint internally, so users can check it before they enrol.": "Julkaise tämä sormenjälki sisäisesti, jotta käyttäjät voivat tarkistaa sen ennen liittymistä.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Palautettu käyttäjän {officer} avulla. Vaihda holviavaimesi nyt kohdassa Asetukset, Turvallisuus: \"Pääsalasanani on vaarantunut\".", + "Recovery key fingerprint: {fingerprint}": "Palautusavaimen sormenjälki: {fingerprint}", + "Recovery officer": "Palautusvastaava", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Poistetut vastaavat menettävät kopionsa nyt, mutta ovat voineet avata sen aiemmin. Pyydä vastaavaa luomaan uusi palautusavain.", + "Repeat the new master password": "Toista uusi pääsalasana", + "Retire this recovery key": "Poista tämä palautusavain käytöstä", + "Set the new master password": "Aseta uusi pääsalasana", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Palautusvarmennetta ei ole myöntänyt tämä Keepiq. Älä liity ja kerro asiasta ylläpitäjälle.", + "The words match, approve": "Sanat täsmäävät, hyväksy", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tämä käyttäjä on liittynyt tilin palautukseen. Palautus säilyttää hänen salaisuutensa; peruutus poistaa hänen liittymisensä.", + "Users may enrol": "Käyttäjät voivat liittyä", + "Withdraw from account recovery": "Eroa tilin palautuksesta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Olet liittynyt tilin palautukseen. Palautusavaimen sormenjälki: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Olet liittynyt. Jos unohdat pääsalasanasi, organisaatiosi voi auttaa sinua saamaan holvisi takaisin.", + "Your key is back. Choose a new master password.": "Avaimesi on palautettu. Valitse uusi pääsalasana.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Palautusvastaaviasi on tiedotettu. Lue heille nämä sanat, kun he soittavat tai tapaavat sinut:", + "You are now an account recovery officer": "Olet nyt tilin palautusvastaava", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pyytää tilinsä palautusta. Vertaa sanoja hänen kanssaan ennen hyväksymistä.", + "A user": "Käyttäjä", + "Your account recovery request was declined": "Tilin palautuspyyntösi hylättiin", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Tilisi palautus on valmis. Avaa Keepiq selaimessa, josta pyysit sitä.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pyytää uuden laitteen avaamista kerran. Pääsalasana pysyy ennallaan.", + "Ask your organisation instead": "Pyydä sen sijaan organisaatioltasi", + "The request ended. Ask again or use your master password.": "Pyyntö päättyi. Pyydä uudelleen tai käytä pääsalasanaasi.", + "Added by {user}": "Lisännyt {user}", + "Editor": "Muokkaaja", + "Manager": "Ylläpitäjä", + "Role of {member}": "Käyttäjän {member} rooli", + "Team folders you manage": "Hallitsemasi tiimikansiot", + "Viewer": "Katselija", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Sinulla ei ole kopiota näistä salaisuuksista, joten uudet jäsenet eivät ole vielä saaneet niitä. Omistaja voi jakaa ne: {names}", + "Admin areas": "Hallinta-alueet", + "Give a group only the parts of Keepiq administration it needs.": "Anna ryhmälle vain ne Keepiqin hallinnan osat, joita se tarvitsee.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegoi yksi tai useampi alue ryhmälle hallintaoikeuksien sivulla. Instanssin ylläpitäjillä on kaikki alueet.", + "Open administration privileges": "Avaa hallintaoikeudet", + "Policies": "Käytännöt", + "Applications and machine access": "Sovellukset ja koneiden pääsy", + "People and offboarding": "Henkilöt ja lähtijät", + "Audit and compliance": "Tarkastus ja vaatimustenmukaisuus", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versio, varmenteiden myöntäjä, liitteet, offline-välimuisti, vuototarkistus, salaisuustyypit ja varmuuskopiot", + "master password, organisation password, vault policies, rotation, version history and trash": "pääsalasana, organisaation salasana, holvin käytännöt, kierto, versiohistoria ja roskakori", + "application queue, application requests and machine leases": "sovellusjono, sovellusten pyynnöt ja koneiden vuokraukset", + "team offboarding, encryption suites and admin handover": "tiimistä lähtijät, salauspaketit ja ylläpitäjän haltuunotto", + "audit log, compliance reports, SIEM export and honey alerts": "tarkastusloki, vaatimustenmukaisuusraportit, SIEM-vienti ja syöttihälytykset", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kuinka monta salaisuuden versiota säilytetään, kuinka kauan, ja kuinka kauan poistetut salaisuudet pysyvät roskakorissa.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Salattujen liitteiden rajat, joita palvelin valvoo tallennettuina salattuina tavuina.", + "Type the suite ID again to confirm": "Vahvista kirjoittamalla sarjan tunnus uudelleen", + "This does not match the suite ID.": "Tämä ei vastaa sarjan tunnusta.", + "Confirm with your master password": "Vahvista pääsalasanalla", + "Confirm": "Vahvista", + "That master password is not right.": "Pääsalasana ei ole oikein.", + "You are sharing with someone new. Enter your master password to confirm.": "Jaat uudelle henkilölle. Vahvista syöttämällä pääsalasanasi.", + "Enter your master password to confirm this share.": "Vahvista tämä jako syöttämällä pääsalasanasi.", + "Enter your master password to confirm this delegation.": "Vahvista tämä delegointi syöttämällä pääsalasanasi.", + "Approve {member}": "Hyväksy {member}", + "Recipient": "Vastaanottaja", + "No vault yet": "Ei vielä holvia", + "No matching users": "Ei vastaavia käyttäjiä", + "Partner organisations": "Kumppaniorganisaatiot", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vaihda salaisuuksia toisen Keepiqin kanssa. Molemmat ylläpitäjät lisäävät toisensa ja vertaavat juurisormenjälkiä puhelimessa tai kasvokkain ennen tallentamista.", + "Federation needs Nextcloud 33 or later.": "Federointi vaatii Nextcloud 33:n tai uudemman.", + "Your root fingerprint": "Oma juurisormenjälkesi", + "No partners yet.": "Ei vielä kumppaneita.", + "Users here may share to this partner": "Täkäläiset käyttäjät voivat jakaa tälle kumppanille", + "This partner may share to users here": "Tämä kumppani voi jakaa täkäläisille käyttäjille", + "Partner address": "Kumppanin osoite", + "Check partner": "Tarkista kumppani", + "Partner root fingerprint": "Kumppanin juurisormenjälki", + "I compared this fingerprint with the partner's administrator": "Vertasin tätä sormenjälkeä kumppanin ylläpitäjän kanssa", + "Add partner": "Lisää kumppani", + "A secret from another organisation": "Salaisuus toisesta organisaatiosta", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s jakoi kohteen \"%2$s\" kanssasi. Hyväksy se kohdassa Saapuneet muista organisaatioista.", + "Incoming from other organisations": "Saapuneet muista organisaatioista", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Kumppaniorganisaatioiden henkilöt voivat jakaa salaisuuden kanssasi. Hyväksy se, niin saat vain luku -kopion holviisi.", + "Nothing shared with you yet": "Kanssasi ei ole vielä jaettu mitään", + "Secrets that people in partner organisations share with you appear here.": "Kumppaniorganisaatioiden henkilöiden kanssasi jakamat salaisuudet näkyvät täällä.", + "From {sender}": "Lähettäjä: {sender}", + "Accept": "Hyväksy", + "Open in vault": "Avaa holvissa", + "The other organisation did not hand over the secret. Try again later.": "Toinen organisaatio ei luovuttanut salaisuutta. Yritä myöhemmin uudelleen.", + "Set up your vault before you accept a shared secret.": "Ota holvi käyttöön ennen kuin hyväksyt jaetun salaisuuden.", + "Something went wrong. Try again.": "Jokin meni vikaan. Yritä uudelleen.", + "Waiting for your answer": "Odottaa vastaustasi", + "In your vault, read-only": "Holvissasi, vain luku", + "Withdrawn by the sender": "Lähettäjä perui jaon", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} jakoi tämän toisesta organisaatiosta. Voit lukea sen, mutta et muokata tai jakaa sitä.", + "Someone": "Joku", + "Share with someone at another organisation": "Jaa jollekulle toisessa organisaatiossa", + "Their account at the other organisation": "Henkilön tili toisessa organisaatiossa", + "Check account": "Tarkista tili", + "Certificate fingerprint of {account}": "Käyttäjän {account} varmenteen sormenjälki", + "Compare it with them by phone if you want to be sure.": "Vertaa sitä henkilön kanssa puhelimessa, jos haluat olla varma.", + "Shared. {account} can accept it in their own vault.": "Jaettu. {account} voi hyväksyä sen omaan holviinsa.", + "The certificate could not be verified. Nothing was shared.": "Varmennetta ei voitu vahvistaa. Mitään ei jaettu.", + "That organisation is not one of your partners.": "Kyseinen organisaatio ei ole kumppanisi.", + "No one with that account can receive secrets from you.": "Kukaan tällä tilillä ei voi vastaanottaa salaisuuksia sinulta.", + "The other organisation did not answer. Try again later.": "Toinen organisaatio ei vastannut. Yritä myöhemmin uudelleen.", + "This secret is already shared with that account.": "Tämä salaisuus on jo jaettu kyseiselle tilille.", + "Other organisations": "Muut organisaatiot", + "Receive secrets from other organisations": "Vastaanota salaisuuksia muista organisaatioista", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Kumppaniorganisaatioiden henkilöt voivat silloin löytää tilisi ja jakaa salaisuuksia kanssasi. Hyväksyt jokaisen itse.", + "Shared": "Jaettu", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Keskeytetty: heidän varmenteensa tai kumppanuus muuttui. Peruuta jako tai jaa uudelleen.", + "Their organisation did not get the last change. Revoke it or share again.": "Heidän organisaationsa ei saanut viimeisintä muutosta. Peruuta jako tai jaa uudelleen.", + "Being withdrawn": "Peruutetaan", + "Shared with another organisation": "Jaettu toiselle organisaatiolle", + "Change sent to another organisation": "Muutos lähetetty toiselle organisaatiolle", + "Share with another organisation revoked": "Jako toiselle organisaatiolle peruutettu", + "Share with another organisation paused": "Jako toiselle organisaatiolle keskeytetty", + "Another organisation did not get a change": "Toinen organisaatio ei saanut muutosta", + "Secret received from another organisation": "Salaisuus vastaanotettu toisesta organisaatiosta", + "Secret from another organisation accepted": "Salaisuus toisesta organisaatiosta hyväksytty", + "Secret from another organisation declined": "Salaisuus toisesta organisaatiosta hylätty", + "Copy from another organisation updated": "Kopio toisesta organisaatiosta päivitetty", + "Copy from another organisation removed": "Kopio toisesta organisaatiosta poistettu", + "Declined: they removed their copy. Share again if they need it.": "Hylätty: vastaanottaja poisti kopionsa. Jaa uudelleen, jos hän tarvitsee sitä.", + "Recipient at another organisation removed their copy": "Toisen organisaation vastaanottaja poisti kopionsa", + "Removed the user from %n team folder.": "Käyttäjä poistettiin %n tiimikansiosta.", + "Removed the user from %n team folders.": "Käyttäjä poistettiin %n tiimikansiosta.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Käyttäjä poistettiin %n tiimikansiosta.", + "Käyttäjä poistettiin %n tiimikansiosta." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Palautettu kopio on peräisin päättyneestä jaosta. Se pysyy vain luku -tilassa.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Palautetun kopion jakaneeseen organisaatioon ei saatu yhteyttä. Kopio pysyy vain luku -tilassa eikä seuraa heidän muutoksiaan.", + "Recipient at another organisation restored their copy": "Toisen organisaation vastaanottaja palautti kopionsa" }, "plurals": null } diff --git a/l10n/fr.js b/l10n/fr.js index f998d891e..8da8a054c 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Partagé avec des groupes", + "Not shared with any group yet.": "Pas encore partagé avec un groupe.", + "Revoke the share with {group}": "Révoquer le partage avec {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partagé avec {group} : {received} membres l'ont reçu, {skipped} non, car ils n'ont pas encore configuré le chiffrement.", + "Search groups": "Rechercher des groupes", + "Failed to share": "Échec du partage", + "Columns": "Colonnes", + "Column {number}": "Colonne {number}", + "Map one column to Name. Every secret needs a name.": "Associez une colonne au nom. Chaque secret a besoin d'un nom.", + "Notes": "Notes", + "Do not import": "Ne pas importer", + "Hide this value": "Masquer cette valeur", + "Show this value": "Afficher cette valeur", + "Defaults": "Valeurs par défaut", + "New secrets start as this type, and your secret list opens in this view.": "Les nouveaux secrets commencent avec ce type, et votre liste de secrets s'ouvre dans cette vue.", + "Default item type": "Type d'élément par défaut", + "Cards": "Cartes", + "Table": "Tableau", + "Could not save your default": "Impossible d'enregistrer votre valeur par défaut", + "Recently used": "Récemment utilisés", + "Opened": "Ouvert", + "You have not opened any secrets yet": "Vous n'avez encore ouvert aucun secret", + "Could not delete the item type.": "Impossible de supprimer le type d'élément.", + "Could not load the item types.": "Impossible de charger les types d'élément.", + "Could not save the item type.": "Impossible d'enregistrer le type d'élément.", + "Delete item type": "Supprimer le type d'élément", + "Edit item type": "Modifier le type d'élément", + "Fields": "Champs", + "Fields: {count}": "Champs : {count}", + "Hidden": "Masqué", + "Item types": "Types d'élément", + "Move up": "Monter", + "New item type": "Nouveau type d'élément", + "No item types defined yet.": "Aucun type d'élément défini pour le moment.", + "Required": "Obligatoire", + "Text": "Texte", + "This field is required": "Ce champ est obligatoire", + "Web address": "Adresse web", + "{label} (required)": "{label} (obligatoire)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Supprimer « {name} » ? Les secrets de ce type restent lisibles et deviennent des éléments Identifiant.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Les types d'élément que vous définissez ici apparaissent pour tous dans la fenêtre Nouveau secret, avec les champs que vous choisissez.", + "Secret moved to the trash": "Secret déplacé dans la corbeille", + "Secret restored from the trash": "Secret restauré depuis la corbeille", + "Secret deleted for good": "Secret supprimé définitivement", + "Secret archived": "Secret archivé", + "Secret unarchived": "Secret désarchivé", + "Unarchive": "Désarchiver", + "Could not archive the secret": "Impossible d'archiver le secret", + "Could not unarchive the secret": "Impossible de désarchiver le secret", + "Archive {count} secrets": "Archiver {count} secrets", + "Unarchive {count} secrets": "Désarchiver {count} secrets", + "Restore {count} secrets": "Restaurer {count} secrets", + "Delete {count} secrets for good": "Supprimer définitivement {count} secrets", + "Done for {ok} of {total} secrets": "Terminé pour {ok} secrets sur {total}", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Les secrets archivés quittent la liste du coffre, la recherche, le remplissage automatique et le rapport de santé. Ils restent partagés. Vous les trouvez sous Archive.", + "These secrets come back to the vault list, search and autofill.": "Ces secrets reviennent dans la liste du coffre, la recherche et le remplissage automatique.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ces secrets reviennent dans la liste du coffre. Leurs anciens partages ne reviennent pas, partagez-les de nouveau si nécessaire.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ceci supprime les secrets avec leurs pièces jointes et leur historique des versions. Cette action est irréversible.", + "Delete for good": "Supprimer définitivement", + "Trash": "Corbeille", + "The trash is empty": "La corbeille est vide", + "No archived secrets": "Aucun secret archivé", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Les secrets supprimés attendent ici jusqu'à la fin de la durée de conservation, puis ils sont supprimés définitivement.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivez un secret depuis son panneau de détails pour le garder hors de la liste du coffre, de la recherche et du remplissage automatique.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limites des pièces jointes chiffrées (appliquées côté serveur sur les octets chiffrés stockés), conservation de l'historique des versions et durée de séjour des secrets supprimés dans la corbeille.", + "Days a deleted secret stays in the trash (1 to 365)": "Jours pendant lesquels un secret supprimé reste dans la corbeille (1 à 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ceci déplace le secret dans la corbeille et met fin à ses partages dès maintenant. Vous pouvez le restaurer depuis la corbeille jusqu'à la fin de la durée de conservation, soit 30 jours sauf si votre administrateur l'a modifiée.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ceci déplace {count} secrets dans la corbeille et met fin à leurs partages dès maintenant. Vous pouvez les restaurer depuis la corbeille jusqu'à la fin de la durée de conservation.", + "Remove {name} from favourites": "Retirer {name} des favoris", + "Add {name} to favourites": "Ajouter {name} aux favoris", + "Could not change the favourite": "Impossible de modifier le favori", + "Remove from favourites": "Retirer des favoris", + "Add to favourites": "Ajouter aux favoris", + "Tags": "Étiquettes", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Les étiquettes ne sont pas chiffrées. Les administrateurs du serveur peuvent les lire, comme les noms de dossiers.", + "Favourites": "Favoris", + "Filter by tag": "Filtrer par étiquette", + "All tags": "Toutes les étiquettes", + "Last used": "Dernière utilisation", + "Tags for {count} secrets": "Étiquettes pour {count} secrets", + "Tag": "Étiquette", + "Remove tag": "Retirer l'étiquette", + "Add tag": "Ajouter une étiquette", + "Could not change the tags. Try again.": "Impossible de modifier les étiquettes. Réessayez.", + "Could not approve the application. It is still in the queue.": "Impossible d'approuver la demande. Elle est toujours dans la file d'attente.", + "Could not reject the application. It is still in the queue.": "Impossible de rejeter la demande. Elle est toujours dans la file d'attente.", + "Removed the user from {count} team folders.": "Utilisateur retiré de {count} dossiers d'équipe.", + "Approve a share": "Approuver un partage", + "This approval link is incomplete. Open it again from the notification.": "Ce lien d'approbation est incomplet. Ouvrez-le à nouveau depuis la notification.", + "Deny": "Refuser", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} a rejoint un groupe avec lequel vous partagez un secret. Partager aussi le secret avec cette personne ?", + "{requester} asks you to share a secret with {user}.": "{requester} vous demande de partager un secret avec {user}.", + "Shared. The recipient can now open the secret.": "Partagé. Le destinataire peut maintenant ouvrir le secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Le destinataire n'a pas encore configuré Keepiq, rien n'a donc été partagé. Réessayez lorsque ce sera fait.", + "Could not share the secret. Only its owner can approve this.": "Impossible de partager le secret. Seul son propriétaire peut approuver cette demande.", + "Could not share the secret. Try again.": "Impossible de partager le secret. Réessayez.", + "Denied. Nothing was shared.": "Refusé. Rien n'a été partagé.", + "Could not deny the request. Try again.": "Impossible de refuser la demande. Réessayez.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vous demande de partager le secret \"%2$s\" avec %3$s.", + "Expires on (optional)": "Expire le (facultatif)", + "Hand over to": "Transférer à", + "Choose a recipient": "Choisir un destinataire", + "Hand over temporarily": "Transférer temporairement", + "Expiry rules": "Règles d'expiration", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Définissez la durée de vie des mots de passe d'un type d'élément ou d'un dossier, et quand recevoir un rappel. Si plusieurs dates s'appliquent, la plus proche l'emporte.", + "Delete rule": "Supprimer la règle", + "Set by your administrator": "Défini par votre administrateur", + "No expiry rules yet.": "Aucune règle d'expiration pour le moment.", + "Applies to": "S'applique à", + "Item type": "Type d'élément", + "Maximum age in days (empty for reminders only)": "Âge maximal en jours (vide pour les rappels uniquement)", + "Remind me this many days before, comma separated": "Me rappeler ce nombre de jours avant, séparés par des virgules", + "Save rule": "Enregistrer la règle", + "An item type": "Un type d'élément", + "A folder": "Un dossier", + "Folder {name}": "Dossier {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Expire après {days} jours", + "Reminders {days} days before": "Rappels {days} jours avant", + "Could not save the expiry rule.": "Impossible d'enregistrer la règle d'expiration.", + "Could not delete the expiry rule.": "Impossible de supprimer la règle d'expiration.", + "All statuses": "Tous les statuts", + "Compromised": "Compromis", + "Could not load the members.": "Impossible de charger les membres.", + "Emergency contact": "Contact d'urgence", + "Leaving user": "Utilisateur sortant", + "No": "Non", + "No users match this filter.": "Aucun utilisateur ne correspond à ce filtre.", + "Not set up": "Non configuré", + "Revoke suite": "Révoquer la suite", + "Revoked": "Révoquée", + "Search users": "Rechercher des utilisateurs", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Voyez quels utilisateurs ont configuré un coffre. Lancez le départ ou révoquez une suite depuis une ligne.", + "Successor": "Successeur", + "Team folders": "Dossiers d'équipe", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utilisateur est encore dans le groupe {groups}, membre d'un dossier d'équipe. Retirez-le du groupe ou désactivez le compte.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utilisateur est encore dans les groupes {groups}, membres de dossiers d'équipe. Retirez-le des groupes ou désactivez le compte.", + "Vault status": "Statut du coffre", + "Yes": "Oui", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un export CXF n'est PAS CHIFFRÉ. Chaque mot de passe et chaque identifiant sera lisible en clair dans le fichier téléchargé. Conservez-le en sécurité et supprimez-le immédiatement après usage.", + "Root certificate expiring soon": "Le certificat racine expire bientôt", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Le certificat racine du coffre expire dans %1$d jour(s). Renouvelez-le avant cette date. Le renouvellement signe à nouveau chaque suite de chiffrement.", + "Compromise recovery aborted": "Récupération après compromission annulée", + "Key rotation ended by a compromise revoke": "Rotation de clé terminée par une révocation pour compromission", + "Encryption suite revoke refused": "Révocation de la suite de chiffrement refusée", + "Master password proof refused": "Preuve du mot de passe principal refusée", + "Your current master password": "Votre mot de passe principal actuel", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact d'urgence avait une demande d'accès en attente quand votre rotation de clé l'a supprimé. Vérifiez qui l'a demandé avant de rajouter quelqu'un.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacts d'urgence avaient une demande d'accès en attente quand votre rotation de clé les a supprimés. Vérifiez qui l'a demandé avant de rajouter quelqu'un.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ces contacts d'urgence n'ont pas été transférés vers votre nouvelle clé. Leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les voulez encore.", + "Renew root certificate": "Renouveler le certificat racine", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Cela crée un nouveau certificat racine et un nouveau certificat intermédiaire. Chaque suite de chiffrement active est signée à nouveau. Cette action est irréversible.", + "Renew root": "Renouveler la racine", + "Root renewed. {n} encryption suites signed again.": "Racine renouvelée. Suites de chiffrement signées à nouveau : {n}.", + "Could not renew the root certificate.": "Impossible de renouveler le certificat racine.", + "Lease policy for this application": "Politique de bail pour cette application", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigueur : {default} secondes par défaut, {max} secondes au maximum.", + "Leases are not renewable": "Les baux ne sont pas renouvelables", + "Lease policy saved.": "Politique de bail enregistrée.", + "Leave a field empty to use the instance value.": "Laissez un champ vide pour utiliser la valeur de l'instance.", + "Instance value: {value}": "Valeur de l'instance : {value}", + "Renewal": "Renouvellement", + "Use the instance value ({value})": "Utiliser la valeur de l'instance ({value})", + "Allowed": "Autorisé", + "Not allowed": "Non autorisé", + "Save lease policy": "Enregistrer la politique de bail", + "Only an administrator can change this policy.": "Seul un administrateur peut modifier cette politique.", + "Could not save the lease policy.": "Impossible d'enregistrer la politique de bail.", + "{member} got access from {confirmer}.": "{member} a reçu l'accès de {confirmer}.", + "Automatically confirm new team folder members": "Confirmer automatiquement les nouveaux membres des dossiers d'équipe", + "Gave %n new member access to a team folder.": "%n nouveau membre a reçu l'accès à un dossier d'équipe.", + "Gave %n new members access to a team folder.": "%n nouveaux membres ont reçu l'accès à un dossier d'équipe.", + "Give new team folder members access without waiting for the folder owner.": "Donnez l'accès aux nouveaux membres sans attendre le propriétaire du dossier.", + "New team folder members": "Nouveaux membres des dossiers d'équipe", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Le propriétaire ou un membre avec droit d'écriture les confirme depuis son coffre ouvert. Keepiq ne déchiffre jamais sur le serveur.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "En attente d'un membre avec droit d'écriture qui ouvre Keepiq. Vous pouvez aussi partager maintenant.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Une partie de la réponse à la compromission a échoué ({failed} étape(s)). Consultez le journal du serveur, puis révoquez à nouveau la suite pour terminer.", + "This also revoked suite {suite} and ended key migration {migration}.": "Cela a aussi révoqué la suite {suite} et mis fin à la migration de clés {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La révocation de la deuxième suite a supprimé %n contact d'accès d'urgence.", + "Revoking the second suite deleted %n emergency-access contacts.": "La révocation de la deuxième suite a supprimé %n contacts d'accès d'urgence.", + "A suite revoked as compromised cannot be reinstated.": "Une suite révoquée comme compromise ne peut pas être rétablie.", + "Archives to keep": "Archives à conserver", + "Back up every vault automatically": "Sauvegarder chaque coffre automatiquement", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sauvegardez chaque coffre selon un calendrier. Les archives ne contiennent que du texte chiffré et se restaurent avec occ.", + "Back up now": "Sauvegarder maintenant", + "Backup public key (PEM, optional)": "Clé publique de sauvegarde (PEM, facultative)", + "Backup requested for the next cron run": "Sauvegarde demandée pour la prochaine exécution cron", + "Encrypted": "Chiffrée", + "Every (hours)": "Toutes les (heures)", + "Last backup {when} failed: {error}": "Dernière sauvegarde {when} échouée : {error}", + "Last backup {when} succeeded.": "Dernière sauvegarde {when} réussie.", + "No archives yet.": "Aucune archive pour l'instant.", + "Size": "Taille", + "Vault backups": "Sauvegardes du coffre", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Avec une clé, chaque archive est chiffrée pour elle. Gardez la clé privée hors de ce serveur : elle sert à vérifier ou restaurer.", + "Written": "Écrite", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilisateur concerné n'a pas encore de connexion à deux facteurs et ne peut pas ouvrir le coffre tant que ceci est actif.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilisateurs concernés n'ont pas encore de connexion à deux facteurs et ne peuvent pas ouvrir le coffre tant que ceci est actif.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Les codes de secours ne comptent pas. Si vos utilisateurs se connectent via un fournisseur d'identité avec son propre second facteur, excluez leurs groupes.", + "Block personal vault export": "Bloquer l'export du coffre personnel", + "Keep work logins in team folders": "Garder les identifiants professionnels dans les dossiers d'équipe", + "Move to a team folder": "Déplacer vers un dossier d'équipe", + "Not in a team folder": "Pas dans un dossier d'équipe", + "Only for these groups (empty is everyone)": "Seulement pour ces groupes (vide signifie tout le monde)", + "Require two-factor login before the vault opens": "Exiger la connexion à deux facteurs avant l'ouverture du coffre", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Règles pour chaque coffre. Chacune s'applique à tous, ou seulement aux groupes que vous choisissez.", + "Secret types that belong in a team folder": "Types de secret qui vont dans un dossier d'équipe", + "Set up two-factor login": "Configurer la connexion à deux facteurs", + "Team folder you can write to": "Dossier d'équipe où vous pouvez écrire", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Les utilisateurs ne peuvent pas télécharger de sauvegarde, de CSV ni de fichier de transfert. Leur paquet de données personnelles reste disponible.", + "Users cannot save these secret types in a personal folder.": "Les utilisateurs ne peuvent pas enregistrer ces types de secret dans un dossier personnel.", + "Vault policies": "Règles du coffre", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Votre organisation n'autorise pas l'export de votre coffre personnel. Votre paquet de données personnelles dans vos paramètres reste disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Votre organisation garde ces secrets dans un dossier d'équipe. Déplacez chacun vers un dossier d'équipe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Votre organisation garde ce type de secret dans un dossier d'équipe. Choisissez un de vos dossiers d'équipe, ou un où vous pouvez écrire.", + "Your organisation requires two-factor login before you can open your vault.": "Votre organisation exige la connexion à deux facteurs avant que vous puissiez ouvrir votre coffre.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Les utilisateurs choisissent combien de temps l'extension reste déverrouillée en cas d'inactivité. Vous fixez la durée maximale qu'ils peuvent choisir.", + "Longest idle time before the extension locks": "Durée d'inactivité maximale avant le verrouillage de l'extension", + "1 minute": "1 minute", + "5 minutes": "5 minutes", + "15 minutes": "15 minutes", + "1 hour": "1 heure", + "4 hours": "4 heures", + "Connector": "Connecteur", + "Directory (tenant) ID": "ID de répertoire (locataire)", + "Application (client) ID": "ID d'application (client)", + "Data collection rule immutable ID": "ID immuable de la règle de collecte de données", + "Stream name": "Nom du flux", + "Splunk index (optional)": "Index Splunk (facultatif)", + "Sourcetype (optional)": "Sourcetype (facultatif)", + "Leave blank to keep the current one": "Laisser vide pour conserver la valeur actuelle", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Point de terminaison de collecte de données (URL https)", + "HTTP Event Collector URL (https)": "URL du HTTP Event Collector (https)", + "Client secret (write-only)": "Secret client (écriture seule)", + "HEC token (write-only)": "Jeton HEC (écriture seule)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Transférer les événements d'audit autorisés vers Splunk, Microsoft Sentinel, un récepteur syslog ou un webhook. Les messages ne contiennent que des métadonnées nettoyées : aucune valeur secrète, aucun nom, identifiant ou texte chiffré ne quitte jamais le serveur.", + "%n change waiting to sync": "%n modification en attente de synchronisation", + "%n changes waiting to sync": "%n modifications en attente de synchronisation", + "Changes that could not sync": "Modifications qui n'ont pas pu être synchronisées", + "Choose a version": "Choisir une version", + "Copy value": "Copier la valeur", + "Deleted": "Supprimé", + "Discard": "Abandonner", + "Keep my offline change": "Garder ma modification hors ligne", + "Keep the server version": "Garder la version du serveur", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq est en lecture seule hors ligne. Votre administrateur n'a pas activé les modifications hors ligne.", + "Let users edit secrets offline": "Permettre aux utilisateurs de modifier les secrets hors ligne", + "Not synced yet": "Pas encore synchronisé", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Les modifications hors ligne restent sur l'appareil, chiffrées pour l'utilisateur, et se synchronisent au prochain déverrouillage en ligne. Le partage, les dossiers et les pièces jointes nécessitent toujours une connexion.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Hors ligne. Les modifications, déplacements et suppressions restent sur cet appareil et se synchronisent dès votre retour en ligne. Le partage et les pièces jointes nécessitent une connexion.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Hors ligne. Vos modifications restent sur cet appareil et se synchronisent dès votre retour en ligne. Dernière synchronisation {when}.", + "Open my changes": "Ouvrir mes modifications", + "Sharing needs a connection": "Le partage nécessite une connexion", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Quelqu'un a modifié ce secret sur le serveur après la création de votre copie hors ligne. Choisissez la version à garder.", + "Sync or discard your offline changes before you rotate your keys.": "Synchronisez ou abandonnez vos modifications hors ligne avant de renouveler vos clés.", + "That password did not open your changes.": "Ce mot de passe n'a pas ouvert vos modifications.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "L'instantané hors ligne stocke les secrets chiffrés (ouvrables uniquement avec la clé dérivée du mot de passe principal de l'utilisateur, exactement comme sur le serveur) et chiffre au repos les noms, URL et noms de dossiers. L'accès hors ligne est en lecture seule, sauf si vous autorisez les modifications hors ligne ci-dessous. Désactivez ceci pour les appareils qui ne doivent jamais mettre en cache des identifiants ; la désactivation purge les caches existants au prochain chargement.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copie précédente du coffre a disparu, ces modifications ne peuvent donc pas être ouvertes.", + "The server version": "La version du serveur", + "This secret changed while you were offline": "Ce secret a été modifié pendant que vous étiez hors ligne", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vous avez supprimé ce secret hors ligne, mais il a été modifié sur le serveur depuis. Choisissez la version à garder.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vos clés ont été changées sur un autre appareil. Saisissez votre mot de passe principal précédent pour synchroniser vos modifications hors ligne, ou abandonnez-les.", + "Your offline change": "Votre modification hors ligne", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contact d'urgence avait une demande d'accès en attente quand votre rotation de clé l'a supprimé. Vérifiez qui l'a demandé avant de rajouter quelqu'un.","%n contacts d'urgence avaient une demande d'accès en attente quand votre rotation de clé les a supprimés. Vérifiez qui l'a demandé avant de rajouter quelqu'un."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n élément ne peut pas être représenté en CXF et sera ignoré.","%n éléments ne peuvent pas être représentés en CXF et seront ignorés."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n version plus ancienne a été supprimée, car seul l'historique récent peut être transféré.","%n versions plus anciennes ont été supprimées, car seul l'historique récent peut être transféré."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n copie de secret doit encore être chiffrée et partagée.","%n copies de secret doivent encore être chiffrées et partagées."], + "_%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 secret n'a pas pu être déchiffré et ne figure pas dans cet export.","%n secrets n'ont pas pu être déchiffrés et ne figurent pas dans cet export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n secret n'a pas pu être déchiffré avec votre ancienne clé, il n'a donc pas été migré.","%n secrets n'ont pas pu être déchiffrés avec votre ancienne clé, ils n'ont donc pas été migrés."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n secret n'a pas été migré.","%n secrets n'ont pas été migrés."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n secret est encore chiffré avec votre clé précédente.","%n secrets sont encore chiffrés avec votre clé précédente."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n secret a été ignoré car le successeur n'en possède pas encore de copie — ajoutez le successeur au dossier et relancez.","%n secrets ont été ignorés car le successeur n'en possède pas encore de copie — ajoutez le successeur au dossier et relancez."], + "_%n secret_::_%n secrets_": ["%n secret","%n secrets"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n utilisateur concerné n'a pas encore de connexion à deux facteurs et ne peut pas ouvrir le coffre tant que ceci est actif.","%n utilisateurs concernés n'ont pas encore de connexion à deux facteurs et ne peuvent pas ouvrir le coffre tant que ceci est actif."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Terminer quand même, en perdant l'accès à %n secret","Terminer quand même, en perdant l'accès à %n secrets"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nouveau membre a reçu l'accès à un dossier d'équipe.","%n nouveaux membres ont reçu l'accès à un dossier d'équipe."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotation de clé terminée. %n secret a été rechiffré avec votre nouvelle clé.","Rotation de clé terminée. %n secrets ont été rechiffrés avec votre nouvelle clé."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["La révocation de la deuxième suite a supprimé %n contact d'accès d'urgence.","La révocation de la deuxième suite a supprimé %n contacts d'accès d'urgence."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["La révocation de cette suite a supprimé %n contact d'accès d'urgence.","La révocation de cette suite a supprimé %n contacts d'accès d'urgence."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["vu %n fois dans des fuites","vu %n fois dans des fuites"], + "_shared with %n secret_::_shared with %n secrets_": ["partagé avec %n secret","partagé avec %n secrets"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ce dossier contient directement %n secret.","Ce dossier contient directement %n secrets."], + "_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._": ["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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n modification en attente de synchronisation","%n modifications en attente de synchronisation"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["L'utilisateur est encore dans le groupe {groups}, membre d'un dossier d'équipe. Retirez-le du groupe ou désactivez le compte.","L'utilisateur est encore dans les groupes {groups}, membres de dossiers d'équipe. Retirez-le des groupes ou désactivez le compte."], + "Allow approval from another device": "Autoriser l'approbation depuis un autre appareil", + "App": "Application", + "Approve a new device": "Approuver un nouvel appareil", + "Approve from another device": "Approuver depuis un autre appareil", + "Asked at": "Demandé à", + "Check that the new device shows these words:": "Vérifiez que le nouvel appareil affiche ces mots :", + "Denied. If you did not ask, end your other sessions:": "Refusé. Si vous n'avez rien demandé, fermez vos autres sessions :", + "Device": "Appareil", + "IP address": "Adresse IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permettre aux utilisateurs de déverrouiller un nouveau navigateur en l'approuvant depuis un appareil où Keepiq est déjà déverrouillé.", + "New device approval": "Approbation des nouveaux appareils", + "Nextcloud security settings": "Paramètres de sécurité de Nextcloud", + "Only approve a device you are using right now.": "N'approuvez qu'un appareil que vous utilisez en ce moment.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ouvrez Keepiq sur un appareil où il est déverrouillé et approuvez celui-ci. Vérifiez qu'il affiche les mêmes mots :", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L'appareil qui approuve scelle la clé de déverrouillage pour le nouvel appareil. Le serveur ne fait que la transmettre et ne peut pas l'ouvrir.", + "The master password is not right, or the request has ended.": "Le mot de passe principal est incorrect ou la demande a pris fin.", + "The request expired. Ask again or use your master password.": "La demande a expiré. Redemandez ou utilisez votre mot de passe principal.", + "The request was denied.": "La demande a été refusée.", + "Too many requests. Try again in an hour or use your master password.": "Trop de demandes. Réessayez dans une heure ou utilisez votre mot de passe principal.", + "Unknown device": "Appareil inconnu", + "Web app": "Application web", + "A device": "Un appareil", + "A new device asks to open your vault": "Un nouvel appareil demande à ouvrir votre coffre", + "%s asks to be approved. Only approve a device you are using right now.": "%s demande à être approuvé. N'approuvez qu'un appareil que vous utilisez en ce moment.", + "Access ends on (optional)": "L'accès prend fin le (facultatif)", + "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.": "Les applications Keepiq n'affichent ni ne copient le mot de passe. Une personne ayant des compétences techniques peut toujours le lire depuis son propre appareil. Changez-le lorsque son accès prend fin.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ce secret est en utilisation seule. Connectez-vous via l'extension de navigateur Keepiq.", + "Until {date}": "Jusqu'au {date}", + "Use only": "Utilisation seule", + "Use only (can sign in, cannot view or copy)": "Utilisation seule (connexion possible, ni affichage ni copie)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Vous pouvez vous connecter avec cet identifiant via l'extension de navigateur Keepiq. Son propriétaire a choisi de ne pas vous permettre de l'afficher ou de le copier.", + "Your access ends on {date}": "Votre accès prend fin le {date}", + "Your access to this secret has ended": "Votre accès à ce secret a pris fin", + "Your access to \"%s\" ends tomorrow": "Votre accès à « %s » prend fin demain", + "Your access to \"%s\" has ended": "Votre accès à « %s » a pris fin", + "%1$s no longer has access to \"%2$s\"": "%1$s n'a plus accès à « %2$s »", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s pouvait voir ce mot de passe. Changez-le si %1$s ne doit plus le connaître.", + "%s could not view this password in Keepiq.": "%s n'a pas pu afficher ce mot de passe dans Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} sur {threshold} approbations", + "a recovery officer": "un responsable de récupération", + "Account recovery": "Récupération de compte", + "Approvals needed": "Approbations nécessaires", + "Ask {user} which words they see, by phone or in person. They must be:": "Demandez à {user} quels mots s'affichent, par téléphone ou en personne. Ils doivent être :", + "Check again": "Vérifier à nouveau", + "Create the recovery key": "Créer la clé de récupération", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Créez la clé de récupération. Votre navigateur la génère et donne à chaque responsable une copie que lui seul peut ouvrir.", + "Decline": "Refuser", + "Enrol in account recovery": "S'inscrire à la récupération de compte", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscrivez-vous pour que votre organisation puisse vous aider à retrouver votre coffre si vous oubliez votre mot de passe maître.", + "Every user is enrolled": "Tous les utilisateurs sont inscrits", + "Finish the recovery in the browser you asked from.": "Terminez la récupération dans le navigateur depuis lequel vous l'avez demandée.", + "Forgot your master password?": "Mot de passe maître oublié ?", + "Hand the key over": "Remettre la clé", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permettez aux utilisateurs qui ont oublié leur mot de passe maître de retrouver leur coffre, avec l'approbation des responsables de récupération que vous désignez.", + "New master password": "Nouveau mot de passe maître", + "No one is asking to recover their account.": "Personne ne demande à récupérer son compte.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Pas encore de clé de récupération. Un des responsables la crée dans ses paramètres Keepiq.", + "Off": "Désactivé", + "Officer {user} has no encryption set up yet.": "Le responsable {user} n'a pas encore configuré le chiffrement.", + "Officers (user IDs, separated by commas)": "Responsables (identifiants d'utilisateur, séparés par des virgules)", + "Policy": "Politique", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiez cette empreinte en interne, pour que les utilisateurs puissent la vérifier avant de s'inscrire.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Récupéré avec l'aide de {officer}. Changez maintenant la clé de votre coffre dans Paramètres, Sécurité : \"Mon mot de passe maître a été compromis\".", + "Recovery key fingerprint: {fingerprint}": "Empreinte de la clé de récupération : {fingerprint}", + "Recovery officer": "Responsable de récupération", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Les responsables retirés perdent leur copie maintenant, mais ont pu l'ouvrir avant. Demandez à un responsable de créer une nouvelle clé de récupération.", + "Repeat the new master password": "Répétez le nouveau mot de passe maître", + "Retire this recovery key": "Retirer cette clé de récupération", + "Set the new master password": "Définir le nouveau mot de passe maître", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Le certificat de récupération n'a pas été émis par ce Keepiq. Ne vous inscrivez pas et prévenez votre administrateur.", + "The words match, approve": "Les mots correspondent, approuver", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Cet utilisateur est inscrit à la récupération de compte. Récupérer conserve ses secrets ; révoquer supprime son inscription.", + "Users may enrol": "Les utilisateurs peuvent s'inscrire", + "Withdraw from account recovery": "Se retirer de la récupération de compte", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Vous êtes inscrit à la récupération de compte. Empreinte de la clé de récupération : {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Vous êtes inscrit. Si vous oubliez votre mot de passe maître, votre organisation peut vous aider à retrouver votre coffre.", + "Your key is back. Choose a new master password.": "Votre clé est de retour. Choisissez un nouveau mot de passe maître.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vos responsables de récupération ont été prévenus. Lisez-leur ces mots quand ils vous appellent ou vous rencontrent :", + "You are now an account recovery officer": "Vous êtes maintenant responsable de récupération de comptes", + "%s asks to recover their account. Compare the words with them before you approve.": "%s demande à récupérer son compte. Comparez les mots avec cette personne avant d'approuver.", + "A user": "Un utilisateur", + "Your account recovery request was declined": "Votre demande de récupération de compte a été refusée", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La récupération de votre compte est prête. Ouvrez Keepiq dans le navigateur depuis lequel vous l'avez demandée.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} demande à déverrouiller un nouvel appareil une seule fois. Son mot de passe maître reste inchangé.", + "Ask your organisation instead": "Demander plutôt à votre organisation", + "The request ended. Ask again or use your master password.": "La demande est terminée. Redemandez ou utilisez votre mot de passe maître.", + "Added by {user}": "Ajouté par {user}", + "Editor": "Éditeur", + "Manager": "Gestionnaire", + "Role of {member}": "Rôle de {member}", + "Team folders you manage": "Dossiers d'équipe que vous gérez", + "Viewer": "Lecteur", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Vous n'avez aucune copie de ces secrets, les nouveaux membres ne les ont donc pas encore reçus. Le propriétaire peut les partager : {names}", + "Admin areas": "Domaines d'administration", + "Give a group only the parts of Keepiq administration it needs.": "Donnez à un groupe uniquement les parties de l'administration de Keepiq dont il a besoin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Déléguez un ou plusieurs domaines à un groupe sur la page des privilèges d'administration. Les administrateurs de l'instance détiennent tous les domaines.", + "Open administration privileges": "Ouvrir les privilèges d'administration", + "Policies": "Politiques", + "Applications and machine access": "Applications et accès machine", + "People and offboarding": "Personnes et départs", + "Audit and compliance": "Audit et conformité", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, autorité de certification, pièces jointes, cache hors ligne, vérification des fuites, types de secrets et sauvegardes", + "master password, organisation password, vault policies, rotation, version history and trash": "mot de passe maître, mot de passe de l'organisation, politiques du coffre, rotation, historique des versions et corbeille", + "application queue, application requests and machine leases": "file des applications, demandes des applications et baux machine", + "team offboarding, encryption suites and admin handover": "départs d'équipe, suites de chiffrement et reprise par l'administrateur", + "audit log, compliance reports, SIEM export and honey alerts": "journal d'audit, rapports de conformité, export SIEM et alertes leurres", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Combien de versions d'un secret sont conservées, pendant combien de temps, et combien de temps les secrets supprimés restent dans la corbeille.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limites des pièces jointes chiffrées, appliquées sur le serveur en octets chiffrés stockés.", + "Type the suite ID again to confirm": "Saisissez à nouveau l'ID de la suite pour confirmer", + "This does not match the suite ID.": "Ceci ne correspond pas à l'ID de la suite.", + "Confirm with your master password": "Confirmez avec votre mot de passe maître", + "Confirm": "Confirmer", + "That master password is not right.": "Ce mot de passe maître n'est pas correct.", + "You are sharing with someone new. Enter your master password to confirm.": "Vous partagez avec une nouvelle personne. Saisissez votre mot de passe maître pour confirmer.", + "Enter your master password to confirm this share.": "Saisissez votre mot de passe maître pour confirmer ce partage.", + "Enter your master password to confirm this delegation.": "Saisissez votre mot de passe maître pour confirmer cette délégation.", + "Approve {member}": "Approuver {member}", + "Recipient": "Destinataire", + "No vault yet": "Pas encore de coffre", + "No matching users": "Aucun utilisateur correspondant", + "Partner organisations": "Organisations partenaires", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Échangez des secrets avec un autre Keepiq. Les deux administrateurs s’ajoutent mutuellement et comparent les empreintes racines par téléphone ou en personne avant d’enregistrer.", + "Federation needs Nextcloud 33 or later.": "La fédération nécessite Nextcloud 33 ou plus récent.", + "Your root fingerprint": "Votre empreinte racine", + "No partners yet.": "Aucun partenaire pour le moment.", + "Users here may share to this partner": "Les utilisateurs d’ici peuvent partager avec ce partenaire", + "This partner may share to users here": "Ce partenaire peut partager avec les utilisateurs d’ici", + "Partner address": "Adresse du partenaire", + "Check partner": "Vérifier le partenaire", + "Partner root fingerprint": "Empreinte racine du partenaire", + "I compared this fingerprint with the partner's administrator": "J’ai comparé cette empreinte avec l’administrateur du partenaire", + "Add partner": "Ajouter le partenaire", + "A secret from another organisation": "Un secret d’une autre organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s a partagé \"%2$s\" avec vous. Acceptez-le sous Reçus d’autres organisations.", + "Incoming from other organisations": "Reçus d’autres organisations", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Les personnes des organisations partenaires peuvent partager un secret avec vous. Acceptez-le pour conserver une copie en lecture seule dans votre coffre.", + "Nothing shared with you yet": "Rien n’a encore été partagé avec vous", + "Secrets that people in partner organisations share with you appear here.": "Les secrets que les personnes des organisations partenaires partagent avec vous apparaissent ici.", + "From {sender}": "De {sender}", + "Accept": "Accepter", + "Open in vault": "Ouvrir dans le coffre", + "The other organisation did not hand over the secret. Try again later.": "L’autre organisation n’a pas transmis le secret. Réessayez plus tard.", + "Set up your vault before you accept a shared secret.": "Configurez votre coffre avant d’accepter un secret partagé.", + "Something went wrong. Try again.": "Une erreur s’est produite. Réessayez.", + "Waiting for your answer": "En attente de votre réponse", + "In your vault, read-only": "Dans votre coffre, en lecture seule", + "Withdrawn by the sender": "Retiré par l’expéditeur", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} a partagé ceci depuis une autre organisation. Vous pouvez le lire, mais pas le modifier ni le partager.", + "Someone": "Quelqu’un", + "Share with someone at another organisation": "Partager avec une personne d’une autre organisation", + "Their account at the other organisation": "Son compte dans l’autre organisation", + "Check account": "Vérifier le compte", + "Certificate fingerprint of {account}": "Empreinte du certificat de {account}", + "Compare it with them by phone if you want to be sure.": "Comparez-la avec cette personne par téléphone si vous voulez en être sûr.", + "Shared. {account} can accept it in their own vault.": "Partagé. {account} peut l’accepter dans son propre coffre.", + "The certificate could not be verified. Nothing was shared.": "Le certificat n’a pas pu être vérifié. Rien n’a été partagé.", + "That organisation is not one of your partners.": "Cette organisation ne fait pas partie de vos partenaires.", + "No one with that account can receive secrets from you.": "Personne avec ce compte ne peut recevoir de secrets de votre part.", + "The other organisation did not answer. Try again later.": "L’autre organisation n’a pas répondu. Réessayez plus tard.", + "This secret is already shared with that account.": "Ce secret est déjà partagé avec ce compte.", + "Other organisations": "Autres organisations", + "Receive secrets from other organisations": "Recevoir des secrets d’autres organisations", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Les personnes des organisations partenaires peuvent alors trouver votre compte et partager des secrets avec vous. Vous acceptez chacun d’eux vous-même.", + "Shared": "Partagé", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pause : leur certificat ou le partenariat a changé. Révoquez le partage ou partagez à nouveau.", + "Their organisation did not get the last change. Revoke it or share again.": "Leur organisation n’a pas reçu la dernière modification. Révoquez le partage ou partagez à nouveau.", + "Being withdrawn": "Retrait en cours", + "Shared with another organisation": "Partagé avec une autre organisation", + "Change sent to another organisation": "Modification envoyée à une autre organisation", + "Share with another organisation revoked": "Partage avec une autre organisation révoqué", + "Share with another organisation paused": "Partage avec une autre organisation mis en pause", + "Another organisation did not get a change": "Une autre organisation n’a pas reçu une modification", + "Secret received from another organisation": "Secret reçu d’une autre organisation", + "Secret from another organisation accepted": "Secret d’une autre organisation accepté", + "Secret from another organisation declined": "Secret d’une autre organisation refusé", + "Copy from another organisation updated": "Copie d’une autre organisation mise à jour", + "Copy from another organisation removed": "Copie d’une autre organisation supprimée", + "Declined: they removed their copy. Share again if they need it.": "Refusé : le destinataire a supprimé sa copie. Partagez à nouveau s’il en a besoin.", + "Recipient at another organisation removed their copy": "Un destinataire d’une autre organisation a supprimé sa copie", + "Removed the user from %n team folder.": "Utilisateur retiré de %n dossier d'équipe.", + "Removed the user from %n team folders.": "Utilisateur retiré de %n dossiers d'équipe.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Utilisateur retiré de %n dossier d'équipe.","Utilisateur retiré de %n dossiers d'équipe."], + "A restored copy came from a share that has ended. It stays read-only.": "Une copie restaurée provient d’un partage qui a pris fin. Elle reste en lecture seule.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L’organisation qui a partagé une copie restaurée est injoignable. La copie reste en lecture seule et ne suit pas leurs modifications.", + "Recipient at another organisation restored their copy": "Un destinataire d’une autre organisation a restauré sa copie" }, - "nplurals=2; plural=(n != 1);" + "nplurals=2; plural=(n > 1);" ) diff --git a/l10n/fr.json b/l10n/fr.json index 3eb24aab2..6f17d245c 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Partagé avec des groupes", + "Not shared with any group yet.": "Pas encore partagé avec un groupe.", + "Revoke the share with {group}": "Révoquer le partage avec {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partagé avec {group} : {received} membres l'ont reçu, {skipped} non, car ils n'ont pas encore configuré le chiffrement.", + "Search groups": "Rechercher des groupes", + "Failed to share": "Échec du partage", + "Columns": "Colonnes", + "Column {number}": "Colonne {number}", + "Map one column to Name. Every secret needs a name.": "Associez une colonne au nom. Chaque secret a besoin d'un nom.", + "Notes": "Notes", + "Do not import": "Ne pas importer", + "Hide this value": "Masquer cette valeur", + "Show this value": "Afficher cette valeur", + "Defaults": "Valeurs par défaut", + "New secrets start as this type, and your secret list opens in this view.": "Les nouveaux secrets commencent avec ce type, et votre liste de secrets s'ouvre dans cette vue.", + "Default item type": "Type d'élément par défaut", + "Cards": "Cartes", + "Table": "Tableau", + "Could not save your default": "Impossible d'enregistrer votre valeur par défaut", + "Recently used": "Récemment utilisés", + "Opened": "Ouvert", + "You have not opened any secrets yet": "Vous n'avez encore ouvert aucun secret", + "Could not delete the item type.": "Impossible de supprimer le type d'élément.", + "Could not load the item types.": "Impossible de charger les types d'élément.", + "Could not save the item type.": "Impossible d'enregistrer le type d'élément.", + "Delete item type": "Supprimer le type d'élément", + "Edit item type": "Modifier le type d'élément", + "Fields": "Champs", + "Fields: {count}": "Champs : {count}", + "Hidden": "Masqué", + "Item types": "Types d'élément", + "Move up": "Monter", + "New item type": "Nouveau type d'élément", + "No item types defined yet.": "Aucun type d'élément défini pour le moment.", + "Required": "Obligatoire", + "Text": "Texte", + "This field is required": "Ce champ est obligatoire", + "Web address": "Adresse web", + "{label} (required)": "{label} (obligatoire)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Supprimer « {name} » ? Les secrets de ce type restent lisibles et deviennent des éléments Identifiant.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Les types d'élément que vous définissez ici apparaissent pour tous dans la fenêtre Nouveau secret, avec les champs que vous choisissez.", + "Secret moved to the trash": "Secret déplacé dans la corbeille", + "Secret restored from the trash": "Secret restauré depuis la corbeille", + "Secret deleted for good": "Secret supprimé définitivement", + "Secret archived": "Secret archivé", + "Secret unarchived": "Secret désarchivé", + "Unarchive": "Désarchiver", + "Could not archive the secret": "Impossible d'archiver le secret", + "Could not unarchive the secret": "Impossible de désarchiver le secret", + "Archive {count} secrets": "Archiver {count} secrets", + "Unarchive {count} secrets": "Désarchiver {count} secrets", + "Restore {count} secrets": "Restaurer {count} secrets", + "Delete {count} secrets for good": "Supprimer définitivement {count} secrets", + "Done for {ok} of {total} secrets": "Terminé pour {ok} secrets sur {total}", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Les secrets archivés quittent la liste du coffre, la recherche, le remplissage automatique et le rapport de santé. Ils restent partagés. Vous les trouvez sous Archive.", + "These secrets come back to the vault list, search and autofill.": "Ces secrets reviennent dans la liste du coffre, la recherche et le remplissage automatique.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ces secrets reviennent dans la liste du coffre. Leurs anciens partages ne reviennent pas, partagez-les de nouveau si nécessaire.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ceci supprime les secrets avec leurs pièces jointes et leur historique des versions. Cette action est irréversible.", + "Delete for good": "Supprimer définitivement", + "Trash": "Corbeille", + "The trash is empty": "La corbeille est vide", + "No archived secrets": "Aucun secret archivé", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Les secrets supprimés attendent ici jusqu'à la fin de la durée de conservation, puis ils sont supprimés définitivement.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivez un secret depuis son panneau de détails pour le garder hors de la liste du coffre, de la recherche et du remplissage automatique.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limites des pièces jointes chiffrées (appliquées côté serveur sur les octets chiffrés stockés), conservation de l'historique des versions et durée de séjour des secrets supprimés dans la corbeille.", + "Days a deleted secret stays in the trash (1 to 365)": "Jours pendant lesquels un secret supprimé reste dans la corbeille (1 à 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ceci déplace le secret dans la corbeille et met fin à ses partages dès maintenant. Vous pouvez le restaurer depuis la corbeille jusqu'à la fin de la durée de conservation, soit 30 jours sauf si votre administrateur l'a modifiée.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ceci déplace {count} secrets dans la corbeille et met fin à leurs partages dès maintenant. Vous pouvez les restaurer depuis la corbeille jusqu'à la fin de la durée de conservation.", + "Remove {name} from favourites": "Retirer {name} des favoris", + "Add {name} to favourites": "Ajouter {name} aux favoris", + "Could not change the favourite": "Impossible de modifier le favori", + "Remove from favourites": "Retirer des favoris", + "Add to favourites": "Ajouter aux favoris", + "Tags": "Étiquettes", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Les étiquettes ne sont pas chiffrées. Les administrateurs du serveur peuvent les lire, comme les noms de dossiers.", + "Favourites": "Favoris", + "Filter by tag": "Filtrer par étiquette", + "All tags": "Toutes les étiquettes", + "Last used": "Dernière utilisation", + "Tags for {count} secrets": "Étiquettes pour {count} secrets", + "Tag": "Étiquette", + "Remove tag": "Retirer l'étiquette", + "Add tag": "Ajouter une étiquette", + "Could not change the tags. Try again.": "Impossible de modifier les étiquettes. Réessayez.", + "Could not approve the application. It is still in the queue.": "Impossible d'approuver la demande. Elle est toujours dans la file d'attente.", + "Could not reject the application. It is still in the queue.": "Impossible de rejeter la demande. Elle est toujours dans la file d'attente.", + "Removed the user from {count} team folders.": "Utilisateur retiré de {count} dossiers d'équipe.", + "Approve a share": "Approuver un partage", + "This approval link is incomplete. Open it again from the notification.": "Ce lien d'approbation est incomplet. Ouvrez-le à nouveau depuis la notification.", + "Deny": "Refuser", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} a rejoint un groupe avec lequel vous partagez un secret. Partager aussi le secret avec cette personne ?", + "{requester} asks you to share a secret with {user}.": "{requester} vous demande de partager un secret avec {user}.", + "Shared. The recipient can now open the secret.": "Partagé. Le destinataire peut maintenant ouvrir le secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Le destinataire n'a pas encore configuré Keepiq, rien n'a donc été partagé. Réessayez lorsque ce sera fait.", + "Could not share the secret. Only its owner can approve this.": "Impossible de partager le secret. Seul son propriétaire peut approuver cette demande.", + "Could not share the secret. Try again.": "Impossible de partager le secret. Réessayez.", + "Denied. Nothing was shared.": "Refusé. Rien n'a été partagé.", + "Could not deny the request. Try again.": "Impossible de refuser la demande. Réessayez.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vous demande de partager le secret \"%2$s\" avec %3$s.", + "Expires on (optional)": "Expire le (facultatif)", + "Hand over to": "Transférer à", + "Choose a recipient": "Choisir un destinataire", + "Hand over temporarily": "Transférer temporairement", + "Expiry rules": "Règles d'expiration", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Définissez la durée de vie des mots de passe d'un type d'élément ou d'un dossier, et quand recevoir un rappel. Si plusieurs dates s'appliquent, la plus proche l'emporte.", + "Delete rule": "Supprimer la règle", + "Set by your administrator": "Défini par votre administrateur", + "No expiry rules yet.": "Aucune règle d'expiration pour le moment.", + "Applies to": "S'applique à", + "Item type": "Type d'élément", + "Maximum age in days (empty for reminders only)": "Âge maximal en jours (vide pour les rappels uniquement)", + "Remind me this many days before, comma separated": "Me rappeler ce nombre de jours avant, séparés par des virgules", + "Save rule": "Enregistrer la règle", + "An item type": "Un type d'élément", + "A folder": "Un dossier", + "Folder {name}": "Dossier {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Expire après {days} jours", + "Reminders {days} days before": "Rappels {days} jours avant", + "Could not save the expiry rule.": "Impossible d'enregistrer la règle d'expiration.", + "Could not delete the expiry rule.": "Impossible de supprimer la règle d'expiration.", + "All statuses": "Tous les statuts", + "Compromised": "Compromis", + "Could not load the members.": "Impossible de charger les membres.", + "Emergency contact": "Contact d'urgence", + "Leaving user": "Utilisateur sortant", + "No": "Non", + "No users match this filter.": "Aucun utilisateur ne correspond à ce filtre.", + "Not set up": "Non configuré", + "Revoke suite": "Révoquer la suite", + "Revoked": "Révoquée", + "Search users": "Rechercher des utilisateurs", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Voyez quels utilisateurs ont configuré un coffre. Lancez le départ ou révoquez une suite depuis une ligne.", + "Successor": "Successeur", + "Team folders": "Dossiers d'équipe", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utilisateur est encore dans le groupe {groups}, membre d'un dossier d'équipe. Retirez-le du groupe ou désactivez le compte.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utilisateur est encore dans les groupes {groups}, membres de dossiers d'équipe. Retirez-le des groupes ou désactivez le compte.", + "Vault status": "Statut du coffre", + "Yes": "Oui", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un export CXF n'est PAS CHIFFRÉ. Chaque mot de passe et chaque identifiant sera lisible en clair dans le fichier téléchargé. Conservez-le en sécurité et supprimez-le immédiatement après usage.", + "Root certificate expiring soon": "Le certificat racine expire bientôt", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Le certificat racine du coffre expire dans %1$d jour(s). Renouvelez-le avant cette date. Le renouvellement signe à nouveau chaque suite de chiffrement.", + "Compromise recovery aborted": "Récupération après compromission annulée", + "Key rotation ended by a compromise revoke": "Rotation de clé terminée par une révocation pour compromission", + "Encryption suite revoke refused": "Révocation de la suite de chiffrement refusée", + "Master password proof refused": "Preuve du mot de passe principal refusée", + "Your current master password": "Votre mot de passe principal actuel", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact d'urgence avait une demande d'accès en attente quand votre rotation de clé l'a supprimé. Vérifiez qui l'a demandé avant de rajouter quelqu'un.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacts d'urgence avaient une demande d'accès en attente quand votre rotation de clé les a supprimés. Vérifiez qui l'a demandé avant de rajouter quelqu'un.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ces contacts d'urgence n'ont pas été transférés vers votre nouvelle clé. Leur accès d'urgence a été supprimé. Ajoutez-les à nouveau depuis Accès d'urgence si vous les voulez encore.", + "Renew root certificate": "Renouveler le certificat racine", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Cela crée un nouveau certificat racine et un nouveau certificat intermédiaire. Chaque suite de chiffrement active est signée à nouveau. Cette action est irréversible.", + "Renew root": "Renouveler la racine", + "Root renewed. {n} encryption suites signed again.": "Racine renouvelée. Suites de chiffrement signées à nouveau : {n}.", + "Could not renew the root certificate.": "Impossible de renouveler le certificat racine.", + "Lease policy for this application": "Politique de bail pour cette application", + "In force now: {default} seconds by default, {max} seconds at most.": "En vigueur : {default} secondes par défaut, {max} secondes au maximum.", + "Leases are not renewable": "Les baux ne sont pas renouvelables", + "Lease policy saved.": "Politique de bail enregistrée.", + "Leave a field empty to use the instance value.": "Laissez un champ vide pour utiliser la valeur de l'instance.", + "Instance value: {value}": "Valeur de l'instance : {value}", + "Renewal": "Renouvellement", + "Use the instance value ({value})": "Utiliser la valeur de l'instance ({value})", + "Allowed": "Autorisé", + "Not allowed": "Non autorisé", + "Save lease policy": "Enregistrer la politique de bail", + "Only an administrator can change this policy.": "Seul un administrateur peut modifier cette politique.", + "Could not save the lease policy.": "Impossible d'enregistrer la politique de bail.", + "{member} got access from {confirmer}.": "{member} a reçu l'accès de {confirmer}.", + "Automatically confirm new team folder members": "Confirmer automatiquement les nouveaux membres des dossiers d'équipe", + "Gave %n new member access to a team folder.": "%n nouveau membre a reçu l'accès à un dossier d'équipe.", + "Gave %n new members access to a team folder.": "%n nouveaux membres ont reçu l'accès à un dossier d'équipe.", + "Give new team folder members access without waiting for the folder owner.": "Donnez l'accès aux nouveaux membres sans attendre le propriétaire du dossier.", + "New team folder members": "Nouveaux membres des dossiers d'équipe", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Le propriétaire ou un membre avec droit d'écriture les confirme depuis son coffre ouvert. Keepiq ne déchiffre jamais sur le serveur.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "En attente d'un membre avec droit d'écriture qui ouvre Keepiq. Vous pouvez aussi partager maintenant.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Une partie de la réponse à la compromission a échoué ({failed} étape(s)). Consultez le journal du serveur, puis révoquez à nouveau la suite pour terminer.", + "This also revoked suite {suite} and ended key migration {migration}.": "Cela a aussi révoqué la suite {suite} et mis fin à la migration de clés {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La révocation de la deuxième suite a supprimé %n contact d'accès d'urgence.", + "Revoking the second suite deleted %n emergency-access contacts.": "La révocation de la deuxième suite a supprimé %n contacts d'accès d'urgence.", + "A suite revoked as compromised cannot be reinstated.": "Une suite révoquée comme compromise ne peut pas être rétablie.", + "Archives to keep": "Archives à conserver", + "Back up every vault automatically": "Sauvegarder chaque coffre automatiquement", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sauvegardez chaque coffre selon un calendrier. Les archives ne contiennent que du texte chiffré et se restaurent avec occ.", + "Back up now": "Sauvegarder maintenant", + "Backup public key (PEM, optional)": "Clé publique de sauvegarde (PEM, facultative)", + "Backup requested for the next cron run": "Sauvegarde demandée pour la prochaine exécution cron", + "Encrypted": "Chiffrée", + "Every (hours)": "Toutes les (heures)", + "Last backup {when} failed: {error}": "Dernière sauvegarde {when} échouée : {error}", + "Last backup {when} succeeded.": "Dernière sauvegarde {when} réussie.", + "No archives yet.": "Aucune archive pour l'instant.", + "Size": "Taille", + "Vault backups": "Sauvegardes du coffre", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Avec une clé, chaque archive est chiffrée pour elle. Gardez la clé privée hors de ce serveur : elle sert à vérifier ou restaurer.", + "Written": "Écrite", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilisateur concerné n'a pas encore de connexion à deux facteurs et ne peut pas ouvrir le coffre tant que ceci est actif.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilisateurs concernés n'ont pas encore de connexion à deux facteurs et ne peuvent pas ouvrir le coffre tant que ceci est actif.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Les codes de secours ne comptent pas. Si vos utilisateurs se connectent via un fournisseur d'identité avec son propre second facteur, excluez leurs groupes.", + "Block personal vault export": "Bloquer l'export du coffre personnel", + "Keep work logins in team folders": "Garder les identifiants professionnels dans les dossiers d'équipe", + "Move to a team folder": "Déplacer vers un dossier d'équipe", + "Not in a team folder": "Pas dans un dossier d'équipe", + "Only for these groups (empty is everyone)": "Seulement pour ces groupes (vide signifie tout le monde)", + "Require two-factor login before the vault opens": "Exiger la connexion à deux facteurs avant l'ouverture du coffre", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Règles pour chaque coffre. Chacune s'applique à tous, ou seulement aux groupes que vous choisissez.", + "Secret types that belong in a team folder": "Types de secret qui vont dans un dossier d'équipe", + "Set up two-factor login": "Configurer la connexion à deux facteurs", + "Team folder you can write to": "Dossier d'équipe où vous pouvez écrire", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Les utilisateurs ne peuvent pas télécharger de sauvegarde, de CSV ni de fichier de transfert. Leur paquet de données personnelles reste disponible.", + "Users cannot save these secret types in a personal folder.": "Les utilisateurs ne peuvent pas enregistrer ces types de secret dans un dossier personnel.", + "Vault policies": "Règles du coffre", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Votre organisation n'autorise pas l'export de votre coffre personnel. Votre paquet de données personnelles dans vos paramètres reste disponible.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Votre organisation garde ces secrets dans un dossier d'équipe. Déplacez chacun vers un dossier d'équipe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Votre organisation garde ce type de secret dans un dossier d'équipe. Choisissez un de vos dossiers d'équipe, ou un où vous pouvez écrire.", + "Your organisation requires two-factor login before you can open your vault.": "Votre organisation exige la connexion à deux facteurs avant que vous puissiez ouvrir votre coffre.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Les utilisateurs choisissent combien de temps l'extension reste déverrouillée en cas d'inactivité. Vous fixez la durée maximale qu'ils peuvent choisir.", + "Longest idle time before the extension locks": "Durée d'inactivité maximale avant le verrouillage de l'extension", + "1 minute": "1 minute", + "5 minutes": "5 minutes", + "15 minutes": "15 minutes", + "1 hour": "1 heure", + "4 hours": "4 heures", + "Connector": "Connecteur", + "Directory (tenant) ID": "ID de répertoire (locataire)", + "Application (client) ID": "ID d'application (client)", + "Data collection rule immutable ID": "ID immuable de la règle de collecte de données", + "Stream name": "Nom du flux", + "Splunk index (optional)": "Index Splunk (facultatif)", + "Sourcetype (optional)": "Sourcetype (facultatif)", + "Leave blank to keep the current one": "Laisser vide pour conserver la valeur actuelle", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Point de terminaison de collecte de données (URL https)", + "HTTP Event Collector URL (https)": "URL du HTTP Event Collector (https)", + "Client secret (write-only)": "Secret client (écriture seule)", + "HEC token (write-only)": "Jeton HEC (écriture seule)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Transférer les événements d'audit autorisés vers Splunk, Microsoft Sentinel, un récepteur syslog ou un webhook. Les messages ne contiennent que des métadonnées nettoyées : aucune valeur secrète, aucun nom, identifiant ou texte chiffré ne quitte jamais le serveur.", + "%n change waiting to sync": "%n modification en attente de synchronisation", + "%n changes waiting to sync": "%n modifications en attente de synchronisation", + "Changes that could not sync": "Modifications qui n'ont pas pu être synchronisées", + "Choose a version": "Choisir une version", + "Copy value": "Copier la valeur", + "Deleted": "Supprimé", + "Discard": "Abandonner", + "Keep my offline change": "Garder ma modification hors ligne", + "Keep the server version": "Garder la version du serveur", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq est en lecture seule hors ligne. Votre administrateur n'a pas activé les modifications hors ligne.", + "Let users edit secrets offline": "Permettre aux utilisateurs de modifier les secrets hors ligne", + "Not synced yet": "Pas encore synchronisé", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Les modifications hors ligne restent sur l'appareil, chiffrées pour l'utilisateur, et se synchronisent au prochain déverrouillage en ligne. Le partage, les dossiers et les pièces jointes nécessitent toujours une connexion.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Hors ligne. Les modifications, déplacements et suppressions restent sur cet appareil et se synchronisent dès votre retour en ligne. Le partage et les pièces jointes nécessitent une connexion.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Hors ligne. Vos modifications restent sur cet appareil et se synchronisent dès votre retour en ligne. Dernière synchronisation {when}.", + "Open my changes": "Ouvrir mes modifications", + "Sharing needs a connection": "Le partage nécessite une connexion", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Quelqu'un a modifié ce secret sur le serveur après la création de votre copie hors ligne. Choisissez la version à garder.", + "Sync or discard your offline changes before you rotate your keys.": "Synchronisez ou abandonnez vos modifications hors ligne avant de renouveler vos clés.", + "That password did not open your changes.": "Ce mot de passe n'a pas ouvert vos modifications.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "L'instantané hors ligne stocke les secrets chiffrés (ouvrables uniquement avec la clé dérivée du mot de passe principal de l'utilisateur, exactement comme sur le serveur) et chiffre au repos les noms, URL et noms de dossiers. L'accès hors ligne est en lecture seule, sauf si vous autorisez les modifications hors ligne ci-dessous. Désactivez ceci pour les appareils qui ne doivent jamais mettre en cache des identifiants ; la désactivation purge les caches existants au prochain chargement.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copie précédente du coffre a disparu, ces modifications ne peuvent donc pas être ouvertes.", + "The server version": "La version du serveur", + "This secret changed while you were offline": "Ce secret a été modifié pendant que vous étiez hors ligne", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vous avez supprimé ce secret hors ligne, mais il a été modifié sur le serveur depuis. Choisissez la version à garder.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vos clés ont été changées sur un autre appareil. Saisissez votre mot de passe principal précédent pour synchroniser vos modifications hors ligne, ou abandonnez-les.", + "Your offline change": "Votre modification hors ligne", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contact d'urgence avait une demande d'accès en attente quand votre rotation de clé l'a supprimé. Vérifiez qui l'a demandé avant de rajouter quelqu'un.", + "%n contacts d'urgence avaient une demande d'accès en attente quand votre rotation de clé les a supprimés. Vérifiez qui l'a demandé avant de rajouter quelqu'un." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n élément ne peut pas être représenté en CXF et sera ignoré.", + "%n éléments ne peuvent pas être représentés en CXF et seront ignorés." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n version plus ancienne a été supprimée, car seul l'historique récent peut être transféré.", + "%n versions plus anciennes ont été supprimées, car seul l'historique récent peut être transféré." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n copie de secret doit encore être chiffrée et partagée.", + "%n copies de secret doivent encore être chiffrées et partagées." + ], + "_%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 secret n'a pas pu être déchiffré et ne figure pas dans cet export.", + "%n secrets n'ont pas pu être déchiffrés et ne figurent pas dans cet export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n secret n'a pas pu être déchiffré avec votre ancienne clé, il n'a donc pas été migré.", + "%n secrets n'ont pas pu être déchiffrés avec votre ancienne clé, ils n'ont donc pas été migrés." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n secret n'a pas été migré.", + "%n secrets n'ont pas été migrés." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n secret est encore chiffré avec votre clé précédente.", + "%n secrets sont encore chiffrés avec votre clé précédente." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n secret a été ignoré car le successeur n'en possède pas encore de copie — ajoutez le successeur au dossier et relancez.", + "%n secrets ont été ignorés car le successeur n'en possède pas encore de copie — ajoutez le successeur au dossier et relancez." + ], + "_%n secret_::_%n secrets_": [ + "%n secret", + "%n secrets" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n utilisateur concerné n'a pas encore de connexion à deux facteurs et ne peut pas ouvrir le coffre tant que ceci est actif.", + "%n utilisateurs concernés n'ont pas encore de connexion à deux facteurs et ne peuvent pas ouvrir le coffre tant que ceci est actif." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Terminer quand même, en perdant l'accès à %n secret", + "Terminer quand même, en perdant l'accès à %n secrets" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nouveau membre a reçu l'accès à un dossier d'équipe.", + "%n nouveaux membres ont reçu l'accès à un dossier d'équipe." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotation de clé terminée. %n secret a été rechiffré avec votre nouvelle clé.", + "Rotation de clé terminée. %n secrets ont été rechiffrés avec votre nouvelle clé." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "La révocation de la deuxième suite a supprimé %n contact d'accès d'urgence.", + "La révocation de la deuxième suite a supprimé %n contacts d'accès d'urgence." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "La révocation de cette suite a supprimé %n contact d'accès d'urgence.", + "La révocation de cette suite a supprimé %n contacts d'accès d'urgence." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "vu %n fois dans des fuites", + "vu %n fois dans des fuites" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "partagé avec %n secret", + "partagé avec %n secrets" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ce dossier contient directement %n secret.", + "Ce dossier contient directement %n secrets." + ], + "_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._": [ + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n modification en attente de synchronisation", + "%n modifications en attente de synchronisation" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "L'utilisateur est encore dans le groupe {groups}, membre d'un dossier d'équipe. Retirez-le du groupe ou désactivez le compte.", + "L'utilisateur est encore dans les groupes {groups}, membres de dossiers d'équipe. Retirez-le des groupes ou désactivez le compte." + ], + "Allow approval from another device": "Autoriser l'approbation depuis un autre appareil", + "App": "Application", + "Approve a new device": "Approuver un nouvel appareil", + "Approve from another device": "Approuver depuis un autre appareil", + "Asked at": "Demandé à", + "Check that the new device shows these words:": "Vérifiez que le nouvel appareil affiche ces mots :", + "Denied. If you did not ask, end your other sessions:": "Refusé. Si vous n'avez rien demandé, fermez vos autres sessions :", + "Device": "Appareil", + "IP address": "Adresse IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permettre aux utilisateurs de déverrouiller un nouveau navigateur en l'approuvant depuis un appareil où Keepiq est déjà déverrouillé.", + "New device approval": "Approbation des nouveaux appareils", + "Nextcloud security settings": "Paramètres de sécurité de Nextcloud", + "Only approve a device you are using right now.": "N'approuvez qu'un appareil que vous utilisez en ce moment.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Ouvrez Keepiq sur un appareil où il est déverrouillé et approuvez celui-ci. Vérifiez qu'il affiche les mêmes mots :", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L'appareil qui approuve scelle la clé de déverrouillage pour le nouvel appareil. Le serveur ne fait que la transmettre et ne peut pas l'ouvrir.", + "The master password is not right, or the request has ended.": "Le mot de passe principal est incorrect ou la demande a pris fin.", + "The request expired. Ask again or use your master password.": "La demande a expiré. Redemandez ou utilisez votre mot de passe principal.", + "The request was denied.": "La demande a été refusée.", + "Too many requests. Try again in an hour or use your master password.": "Trop de demandes. Réessayez dans une heure ou utilisez votre mot de passe principal.", + "Unknown device": "Appareil inconnu", + "Web app": "Application web", + "A device": "Un appareil", + "A new device asks to open your vault": "Un nouvel appareil demande à ouvrir votre coffre", + "%s asks to be approved. Only approve a device you are using right now.": "%s demande à être approuvé. N'approuvez qu'un appareil que vous utilisez en ce moment.", + "Access ends on (optional)": "L'accès prend fin le (facultatif)", + "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.": "Les applications Keepiq n'affichent ni ne copient le mot de passe. Une personne ayant des compétences techniques peut toujours le lire depuis son propre appareil. Changez-le lorsque son accès prend fin.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ce secret est en utilisation seule. Connectez-vous via l'extension de navigateur Keepiq.", + "Until {date}": "Jusqu'au {date}", + "Use only": "Utilisation seule", + "Use only (can sign in, cannot view or copy)": "Utilisation seule (connexion possible, ni affichage ni copie)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Vous pouvez vous connecter avec cet identifiant via l'extension de navigateur Keepiq. Son propriétaire a choisi de ne pas vous permettre de l'afficher ou de le copier.", + "Your access ends on {date}": "Votre accès prend fin le {date}", + "Your access to this secret has ended": "Votre accès à ce secret a pris fin", + "Your access to \"%s\" ends tomorrow": "Votre accès à « %s » prend fin demain", + "Your access to \"%s\" has ended": "Votre accès à « %s » a pris fin", + "%1$s no longer has access to \"%2$s\"": "%1$s n'a plus accès à « %2$s »", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s pouvait voir ce mot de passe. Changez-le si %1$s ne doit plus le connaître.", + "%s could not view this password in Keepiq.": "%s n'a pas pu afficher ce mot de passe dans Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} sur {threshold} approbations", + "a recovery officer": "un responsable de récupération", + "Account recovery": "Récupération de compte", + "Approvals needed": "Approbations nécessaires", + "Ask {user} which words they see, by phone or in person. They must be:": "Demandez à {user} quels mots s'affichent, par téléphone ou en personne. Ils doivent être :", + "Check again": "Vérifier à nouveau", + "Create the recovery key": "Créer la clé de récupération", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Créez la clé de récupération. Votre navigateur la génère et donne à chaque responsable une copie que lui seul peut ouvrir.", + "Decline": "Refuser", + "Enrol in account recovery": "S'inscrire à la récupération de compte", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscrivez-vous pour que votre organisation puisse vous aider à retrouver votre coffre si vous oubliez votre mot de passe maître.", + "Every user is enrolled": "Tous les utilisateurs sont inscrits", + "Finish the recovery in the browser you asked from.": "Terminez la récupération dans le navigateur depuis lequel vous l'avez demandée.", + "Forgot your master password?": "Mot de passe maître oublié ?", + "Hand the key over": "Remettre la clé", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permettez aux utilisateurs qui ont oublié leur mot de passe maître de retrouver leur coffre, avec l'approbation des responsables de récupération que vous désignez.", + "New master password": "Nouveau mot de passe maître", + "No one is asking to recover their account.": "Personne ne demande à récupérer son compte.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Pas encore de clé de récupération. Un des responsables la crée dans ses paramètres Keepiq.", + "Off": "Désactivé", + "Officer {user} has no encryption set up yet.": "Le responsable {user} n'a pas encore configuré le chiffrement.", + "Officers (user IDs, separated by commas)": "Responsables (identifiants d'utilisateur, séparés par des virgules)", + "Policy": "Politique", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiez cette empreinte en interne, pour que les utilisateurs puissent la vérifier avant de s'inscrire.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Récupéré avec l'aide de {officer}. Changez maintenant la clé de votre coffre dans Paramètres, Sécurité : \"Mon mot de passe maître a été compromis\".", + "Recovery key fingerprint: {fingerprint}": "Empreinte de la clé de récupération : {fingerprint}", + "Recovery officer": "Responsable de récupération", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Les responsables retirés perdent leur copie maintenant, mais ont pu l'ouvrir avant. Demandez à un responsable de créer une nouvelle clé de récupération.", + "Repeat the new master password": "Répétez le nouveau mot de passe maître", + "Retire this recovery key": "Retirer cette clé de récupération", + "Set the new master password": "Définir le nouveau mot de passe maître", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Le certificat de récupération n'a pas été émis par ce Keepiq. Ne vous inscrivez pas et prévenez votre administrateur.", + "The words match, approve": "Les mots correspondent, approuver", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Cet utilisateur est inscrit à la récupération de compte. Récupérer conserve ses secrets ; révoquer supprime son inscription.", + "Users may enrol": "Les utilisateurs peuvent s'inscrire", + "Withdraw from account recovery": "Se retirer de la récupération de compte", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Vous êtes inscrit à la récupération de compte. Empreinte de la clé de récupération : {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Vous êtes inscrit. Si vous oubliez votre mot de passe maître, votre organisation peut vous aider à retrouver votre coffre.", + "Your key is back. Choose a new master password.": "Votre clé est de retour. Choisissez un nouveau mot de passe maître.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vos responsables de récupération ont été prévenus. Lisez-leur ces mots quand ils vous appellent ou vous rencontrent :", + "You are now an account recovery officer": "Vous êtes maintenant responsable de récupération de comptes", + "%s asks to recover their account. Compare the words with them before you approve.": "%s demande à récupérer son compte. Comparez les mots avec cette personne avant d'approuver.", + "A user": "Un utilisateur", + "Your account recovery request was declined": "Votre demande de récupération de compte a été refusée", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "La récupération de votre compte est prête. Ouvrez Keepiq dans le navigateur depuis lequel vous l'avez demandée.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} demande à déverrouiller un nouvel appareil une seule fois. Son mot de passe maître reste inchangé.", + "Ask your organisation instead": "Demander plutôt à votre organisation", + "The request ended. Ask again or use your master password.": "La demande est terminée. Redemandez ou utilisez votre mot de passe maître.", + "Added by {user}": "Ajouté par {user}", + "Editor": "Éditeur", + "Manager": "Gestionnaire", + "Role of {member}": "Rôle de {member}", + "Team folders you manage": "Dossiers d'équipe que vous gérez", + "Viewer": "Lecteur", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Vous n'avez aucune copie de ces secrets, les nouveaux membres ne les ont donc pas encore reçus. Le propriétaire peut les partager : {names}", + "Admin areas": "Domaines d'administration", + "Give a group only the parts of Keepiq administration it needs.": "Donnez à un groupe uniquement les parties de l'administration de Keepiq dont il a besoin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Déléguez un ou plusieurs domaines à un groupe sur la page des privilèges d'administration. Les administrateurs de l'instance détiennent tous les domaines.", + "Open administration privileges": "Ouvrir les privilèges d'administration", + "Policies": "Politiques", + "Applications and machine access": "Applications et accès machine", + "People and offboarding": "Personnes et départs", + "Audit and compliance": "Audit et conformité", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, autorité de certification, pièces jointes, cache hors ligne, vérification des fuites, types de secrets et sauvegardes", + "master password, organisation password, vault policies, rotation, version history and trash": "mot de passe maître, mot de passe de l'organisation, politiques du coffre, rotation, historique des versions et corbeille", + "application queue, application requests and machine leases": "file des applications, demandes des applications et baux machine", + "team offboarding, encryption suites and admin handover": "départs d'équipe, suites de chiffrement et reprise par l'administrateur", + "audit log, compliance reports, SIEM export and honey alerts": "journal d'audit, rapports de conformité, export SIEM et alertes leurres", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Combien de versions d'un secret sont conservées, pendant combien de temps, et combien de temps les secrets supprimés restent dans la corbeille.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limites des pièces jointes chiffrées, appliquées sur le serveur en octets chiffrés stockés.", + "Type the suite ID again to confirm": "Saisissez à nouveau l'ID de la suite pour confirmer", + "This does not match the suite ID.": "Ceci ne correspond pas à l'ID de la suite.", + "Confirm with your master password": "Confirmez avec votre mot de passe maître", + "Confirm": "Confirmer", + "That master password is not right.": "Ce mot de passe maître n'est pas correct.", + "You are sharing with someone new. Enter your master password to confirm.": "Vous partagez avec une nouvelle personne. Saisissez votre mot de passe maître pour confirmer.", + "Enter your master password to confirm this share.": "Saisissez votre mot de passe maître pour confirmer ce partage.", + "Enter your master password to confirm this delegation.": "Saisissez votre mot de passe maître pour confirmer cette délégation.", + "Approve {member}": "Approuver {member}", + "Recipient": "Destinataire", + "No vault yet": "Pas encore de coffre", + "No matching users": "Aucun utilisateur correspondant", + "Partner organisations": "Organisations partenaires", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Échangez des secrets avec un autre Keepiq. Les deux administrateurs s’ajoutent mutuellement et comparent les empreintes racines par téléphone ou en personne avant d’enregistrer.", + "Federation needs Nextcloud 33 or later.": "La fédération nécessite Nextcloud 33 ou plus récent.", + "Your root fingerprint": "Votre empreinte racine", + "No partners yet.": "Aucun partenaire pour le moment.", + "Users here may share to this partner": "Les utilisateurs d’ici peuvent partager avec ce partenaire", + "This partner may share to users here": "Ce partenaire peut partager avec les utilisateurs d’ici", + "Partner address": "Adresse du partenaire", + "Check partner": "Vérifier le partenaire", + "Partner root fingerprint": "Empreinte racine du partenaire", + "I compared this fingerprint with the partner's administrator": "J’ai comparé cette empreinte avec l’administrateur du partenaire", + "Add partner": "Ajouter le partenaire", + "A secret from another organisation": "Un secret d’une autre organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s a partagé \"%2$s\" avec vous. Acceptez-le sous Reçus d’autres organisations.", + "Incoming from other organisations": "Reçus d’autres organisations", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Les personnes des organisations partenaires peuvent partager un secret avec vous. Acceptez-le pour conserver une copie en lecture seule dans votre coffre.", + "Nothing shared with you yet": "Rien n’a encore été partagé avec vous", + "Secrets that people in partner organisations share with you appear here.": "Les secrets que les personnes des organisations partenaires partagent avec vous apparaissent ici.", + "From {sender}": "De {sender}", + "Accept": "Accepter", + "Open in vault": "Ouvrir dans le coffre", + "The other organisation did not hand over the secret. Try again later.": "L’autre organisation n’a pas transmis le secret. Réessayez plus tard.", + "Set up your vault before you accept a shared secret.": "Configurez votre coffre avant d’accepter un secret partagé.", + "Something went wrong. Try again.": "Une erreur s’est produite. Réessayez.", + "Waiting for your answer": "En attente de votre réponse", + "In your vault, read-only": "Dans votre coffre, en lecture seule", + "Withdrawn by the sender": "Retiré par l’expéditeur", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} a partagé ceci depuis une autre organisation. Vous pouvez le lire, mais pas le modifier ni le partager.", + "Someone": "Quelqu’un", + "Share with someone at another organisation": "Partager avec une personne d’une autre organisation", + "Their account at the other organisation": "Son compte dans l’autre organisation", + "Check account": "Vérifier le compte", + "Certificate fingerprint of {account}": "Empreinte du certificat de {account}", + "Compare it with them by phone if you want to be sure.": "Comparez-la avec cette personne par téléphone si vous voulez en être sûr.", + "Shared. {account} can accept it in their own vault.": "Partagé. {account} peut l’accepter dans son propre coffre.", + "The certificate could not be verified. Nothing was shared.": "Le certificat n’a pas pu être vérifié. Rien n’a été partagé.", + "That organisation is not one of your partners.": "Cette organisation ne fait pas partie de vos partenaires.", + "No one with that account can receive secrets from you.": "Personne avec ce compte ne peut recevoir de secrets de votre part.", + "The other organisation did not answer. Try again later.": "L’autre organisation n’a pas répondu. Réessayez plus tard.", + "This secret is already shared with that account.": "Ce secret est déjà partagé avec ce compte.", + "Other organisations": "Autres organisations", + "Receive secrets from other organisations": "Recevoir des secrets d’autres organisations", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Les personnes des organisations partenaires peuvent alors trouver votre compte et partager des secrets avec vous. Vous acceptez chacun d’eux vous-même.", + "Shared": "Partagé", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pause : leur certificat ou le partenariat a changé. Révoquez le partage ou partagez à nouveau.", + "Their organisation did not get the last change. Revoke it or share again.": "Leur organisation n’a pas reçu la dernière modification. Révoquez le partage ou partagez à nouveau.", + "Being withdrawn": "Retrait en cours", + "Shared with another organisation": "Partagé avec une autre organisation", + "Change sent to another organisation": "Modification envoyée à une autre organisation", + "Share with another organisation revoked": "Partage avec une autre organisation révoqué", + "Share with another organisation paused": "Partage avec une autre organisation mis en pause", + "Another organisation did not get a change": "Une autre organisation n’a pas reçu une modification", + "Secret received from another organisation": "Secret reçu d’une autre organisation", + "Secret from another organisation accepted": "Secret d’une autre organisation accepté", + "Secret from another organisation declined": "Secret d’une autre organisation refusé", + "Copy from another organisation updated": "Copie d’une autre organisation mise à jour", + "Copy from another organisation removed": "Copie d’une autre organisation supprimée", + "Declined: they removed their copy. Share again if they need it.": "Refusé : le destinataire a supprimé sa copie. Partagez à nouveau s’il en a besoin.", + "Recipient at another organisation removed their copy": "Un destinataire d’une autre organisation a supprimé sa copie", + "Removed the user from %n team folder.": "Utilisateur retiré de %n dossier d'équipe.", + "Removed the user from %n team folders.": "Utilisateur retiré de %n dossiers d'équipe.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Utilisateur retiré de %n dossier d'équipe.", + "Utilisateur retiré de %n dossiers d'équipe." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Une copie restaurée provient d’un partage qui a pris fin. Elle reste en lecture seule.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L’organisation qui a partagé une copie restaurée est injoignable. La copie reste en lecture seule et ne suit pas leurs modifications.", + "Recipient at another organisation restored their copy": "Un destinataire d’une autre organisation a restauré sa copie" }, "plurals": null } diff --git a/l10n/ga.js b/l10n/ga.js index ee9c8dfa3..4b8d7f19e 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Comhroinnte le grúpaí", + "Not shared with any group yet.": "Níor comhroinneadh le grúpa ar bith fós.", + "Revoke the share with {group}": "Cealaigh an chomhroinnt le {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Comhroinnte le {group}: fuair {received} ball é, ní bhfuair {skipped} é mar níl criptiú socraithe acu fós.", + "Search groups": "Cuardaigh grúpaí", + "Failed to share": "Theip ar an gcomhroinnt", + "Columns": "Colúin", + "Column {number}": "Colún {number}", + "Map one column to Name. Every secret needs a name.": "Nasc colún amháin leis an Ainm. Tá ainm ag teastáil ó gach rún.", + "Notes": "Nótaí", + "Do not import": "Ná hiompórtáil", + "Hide this value": "Folaigh an luach seo", + "Show this value": "Taispeáin an luach seo", + "Defaults": "Réamhshocruithe", + "New secrets start as this type, and your secret list opens in this view.": "Tosaíonn rúin nua mar an cineál seo, agus osclaíonn do liosta rún san amharc seo.", + "Default item type": "Cineál míre réamhshocraithe", + "Cards": "Cártaí", + "Table": "Tábla", + "Could not save your default": "Níorbh fhéidir do réamhshocrú a shábháil", + "Recently used": "Úsáidte le déanaí", + "Opened": "Oscailte", + "You have not opened any secrets yet": "Níor oscail tú aon rún fós", + "Could not delete the item type.": "Níorbh fhéidir an cineál míre a scriosadh.", + "Could not load the item types.": "Níorbh fhéidir na cineálacha míre a lódáil.", + "Could not save the item type.": "Níorbh fhéidir an cineál míre a shábháil.", + "Delete item type": "Scrios an cineál míre", + "Edit item type": "Cuir an cineál míre in eagar", + "Fields": "Réimsí", + "Fields: {count}": "Réimsí: {count}", + "Hidden": "Folaithe", + "Item types": "Cineálacha míre", + "Move up": "Bog suas", + "New item type": "Cineál míre nua", + "No item types defined yet.": "Níl aon chineál míre sainithe fós.", + "Required": "Riachtanach", + "Text": "Téacs", + "This field is required": "Tá an réimse seo riachtanach", + "Web address": "Seoladh gréasáin", + "{label} (required)": "{label} (riachtanach)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Scrios “{name}”? Fanann rúin den chineál seo inléite agus éiríonn siad ina míreanna Logáil isteach.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Feictear do chách na cineálacha míre a shainíonn tú anseo sa dialóg Rún nua, leis na réimsí a roghnaíonn tú.", + "Secret moved to the trash": "Bogadh an rún go dtí an bruscar", + "Secret restored from the trash": "Aisghabhadh an rún ón mbruscar", + "Secret deleted for good": "Scriosadh an rún go buan", + "Secret archived": "Cartlannaíodh an rún", + "Secret unarchived": "Tógadh an rún as an gcartlann", + "Unarchive": "Tóg as an gcartlann", + "Could not archive the secret": "Níorbh fhéidir an rún a chartlannú", + "Could not unarchive the secret": "Níorbh fhéidir an rún a thógáil as an gcartlann", + "Archive {count} secrets": "Cartlannaigh rúin: {count}", + "Unarchive {count} secrets": "Tóg rúin as an gcartlann: {count}", + "Restore {count} secrets": "Aisghabh rúin: {count}", + "Delete {count} secrets for good": "Scrios rúin go buan: {count}", + "Done for {ok} of {total} secrets": "Déanta do {ok} as {total} rún", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Fágann rúin chartlannaithe liosta an chrinnín, an cuardach, an líonadh uathoibríoch agus an tuarascáil sláinte. Coinníonn siad a gcomhroinnt. Gheobhaidh tú iad faoi Chartlann.", + "These secrets come back to the vault list, search and autofill.": "Tagann na rúin seo ar ais i liosta an chrinnín, sa chuardach agus sa líonadh uathoibríoch.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tagann na rúin seo ar ais i liosta an chrinnín. Ní thagann a sean-chomhroinnt ar ais, mar sin comhroinn arís iad más gá.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Scriosann sé seo na rúin lena gceangaltáin agus a stair leaganacha. Ní féidir é seo a chealú.", + "Delete for good": "Scrios go buan", + "Trash": "Bruscar", + "The trash is empty": "Tá an bruscar folamh", + "No archived secrets": "Níl aon rúin chartlannaithe", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Fanann rúin scriosta anseo go dtí go gcríochnaíonn an tréimhse choinneála, ansin scriostar go buan iad.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Cartlannaigh rún óna phainéal sonraí chun é a choinneáil amach ó liosta an chrinnín, ón gcuardach agus ón líonadh uathoibríoch.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Teorainneacha do cheangaltáin chriptithe (curtha i bhfeidhm ar an bhfreastalaí i mbearta criptithe stóráilte), coinneáil stair leaganacha agus cá fhad a fhanann rúin scriosta sa bhruscar.", + "Days a deleted secret stays in the trash (1 to 365)": "Laethanta a fhanann rún scriosta sa bhruscar (1 go 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Bogann sé seo an rún go dtí an bruscar agus cuireann sé deireadh lena chomhroinnt anois. Is féidir leat é a aisghabháil ón mbruscar go dtí go gcríochnaíonn an tréimhse choinneála: 30 lá, mura bhfuil sé athraithe ag do riarthóir.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Bogann sé seo rúin go dtí an bruscar ({count}) agus cuireann sé deireadh lena gcomhroinnt anois. Is féidir leat iad a aisghabháil ón mbruscar go dtí go gcríochnaíonn an tréimhse choinneála.", + "Remove {name} from favourites": "Bain {name} as na ceanáin", + "Add {name} to favourites": "Cuir {name} leis na ceanáin", + "Could not change the favourite": "Níorbh fhéidir an ceanán a athrú", + "Remove from favourites": "Bain as na ceanáin", + "Add to favourites": "Cuir leis na ceanáin", + "Tags": "Clibeanna", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Níl na clibeanna criptithe. Is féidir le riarthóirí an fhreastalaí iad a léamh, mar is féidir leo ainmneacha fillteán.", + "Favourites": "Ceanáin", + "Filter by tag": "Scag de réir clibe", + "All tags": "Gach clib", + "Last used": "Úsáidte go deireanach", + "Tags for {count} secrets": "Clibeanna do {count} rún", + "Tag": "Clib", + "Remove tag": "Bain an chlib", + "Add tag": "Cuir clib leis", + "Could not change the tags. Try again.": "Níorbh fhéidir na clibeanna a athrú. Bain triail eile as.", + "Could not approve the application. It is still in the queue.": "Níorbh fhéidir an t-iarratas a cheadú. Tá sé fós sa scuaine.", + "Could not reject the application. It is still in the queue.": "Níorbh fhéidir an t-iarratas a dhiúltú. Tá sé fós sa scuaine.", + "Removed the user from {count} team folders.": "Baineadh an t-úsáideoir de {count} fillteán foirne.", + "Approve a share": "Ceadaigh comhroinnt", + "This approval link is incomplete. Open it again from the notification.": "Tá an nasc ceadaithe seo neamhiomlán. Oscail arís é ón bhfógra.", + "Deny": "Diúltaigh", + "{user} joined a group you share a secret with. Share the secret with them too?": "Chuaigh {user} isteach i ngrúpa a gcomhroinneann tú rún leis. An gcomhroinnfear an rún leo freisin?", + "{requester} asks you to share a secret with {user}.": "Iarrann {requester} ort rún a chomhroinnt le {user}.", + "Shared. The recipient can now open the secret.": "Comhroinnte. Is féidir leis an bhfaighteoir an rún a oscailt anois.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Níl Keepiq socraithe ag an bhfaighteoir fós, mar sin níor comhroinneadh aon rud. Bain triail eile as nuair a bheidh.", + "Could not share the secret. Only its owner can approve this.": "Níorbh fhéidir an rún a chomhroinnt. Ní féidir ach lena úinéir é seo a cheadú.", + "Could not share the secret. Try again.": "Níorbh fhéidir an rún a chomhroinnt. Bain triail eile as.", + "Denied. Nothing was shared.": "Diúltaithe. Níor comhroinneadh aon rud.", + "Could not deny the request. Try again.": "Níorbh fhéidir an t-iarratas a dhiúltú. Bain triail eile as.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "Iarrann %1$s ort an rún \"%2$s\" a chomhroinnt le %3$s.", + "Expires on (optional)": "Rachaidh in éag ar (roghnach)", + "Hand over to": "Tabhair ar láimh do", + "Choose a recipient": "Roghnaigh faighteoir", + "Hand over temporarily": "Tabhair ar láimh go sealadach", + "Expiry rules": "Rialacha éaga", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Socraigh cé chomh fada is féidir le pasfhocail de chineál míre amháin nó i bhfillteán amháin maireachtáil, agus cathain a chuirfear i gcuimhne duit. Nuair a bhaineann roinnt dátaí, is é an ceann is luaithe a chomhaireann.", + "Delete rule": "Scrios riail", + "Set by your administrator": "Socraithe ag do riarthóir", + "No expiry rules yet.": "Níl aon rialacha éaga ann fós.", + "Applies to": "Baineann le", + "Item type": "Cineál míre", + "Maximum age in days (empty for reminders only)": "Aois uasta i laethanta (folamh le haghaidh meabhrúchán amháin)", + "Remind me this many days before, comma separated": "Cuir i gcuimhne dom an méid seo laethanta roimh ré, scartha le camóga", + "Save rule": "Sábháil riail", + "An item type": "Cineál míre", + "A folder": "Fillteán", + "Folder {name}": "Fillteán {name}", + "Type {name}": "Cineál {name}", + "Expires after {days} days": "Rachaidh in éag tar éis {days} lá", + "Reminders {days} days before": "Meabhrúcháin {days} lá roimh ré", + "Could not save the expiry rule.": "Níorbh fhéidir an riail éaga a shábháil.", + "Could not delete the expiry rule.": "Níorbh fhéidir an riail éaga a scriosadh.", + "All statuses": "Gach stádas", + "Compromised": "I mbaol", + "Could not load the members.": "Níorbh fhéidir na baill a lódáil.", + "Emergency contact": "Teagmhálaí éigeandála", + "Leaving user": "Úsáideoir atá ag imeacht", + "No": "Níl", + "No users match this filter.": "Níl aon úsáideoir ag teacht leis an scagaire seo.", + "Not set up": "Gan socrú", + "Revoke suite": "Cúlghair an sraith", + "Revoked": "Cúlghairthe", + "Search users": "Cuardaigh úsáideoirí", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Féach cé na húsáideoirí a shocraigh cruinneachán. Tosaigh an t-imeacht nó cúlghair sraith ó shraith.", + "Successor": "Comharba", + "Team folders": "Fillteáin foirne", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Tá an t-úsáideoir fós sa ghrúpa {groups}, atá ina bhall d'fhillteán foirne. Bain é den ghrúpa nó díchumasaigh an cuntas.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas.", + "Vault status": "Stádas an chruinneacháin", + "Yes": "Tá", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Níl easpórtáil CXF CRIPTITHE. Beidh gach pasfhocal agus logáil isteach inléite mar ghnáth-théacs sa chomhad íoslódáilte. Stóráil go sábháilte é agus scrios láithreach é tar éis a úsáide.", + "Root certificate expiring soon": "Rachaidh an fréamhdheimhniú as feidhm go luath", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Rachaidh fréamhdheimhniú an tsábháilteáin as feidhm i gceann %1$d lá. Athnuaigh é roimhe sin. Síníonn athnuachan gach sraith criptiúcháin arís.", + "Compromise recovery aborted": "Cealaíodh an t-aisghabháil tar éis comhréitigh", + "Key rotation ended by a compromise revoke": "Cuireadh deireadh le malartú eochrach le cúlghairm mar gheall ar chomhréiteach", + "Encryption suite revoke refused": "Diúltaíodh do chúlghairm na sraithe criptiúcháin", + "Master password proof refused": "Diúltaíodh do chruthúnas an phríomhfhocail faire", + "Your current master password": "Do phríomhfhocal faire reatha", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach é. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Níor aistríodh na teagmhálaithe éigeandála seo chuig d'eochair nua. Baineadh a rochtain éigeandála. Cuir ar ais iad ó Rochtain éigeandála más mian leat iad fós.", + "Renew root certificate": "Athnuaigh an fréamhtheastas", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Cruthaíonn sé seo fréamhtheastas agus teastas idirmheánach nua. Sínítear gach sraith criptiúcháin ghníomhach arís. Ní féidir é seo a chealú.", + "Renew root": "Athnuaigh an fhréamh", + "Root renewed. {n} encryption suites signed again.": "Fréamh athnuaite. Sraitheanna criptiúcháin sínithe arís: {n}.", + "Could not renew the root certificate.": "Níorbh fhéidir an fréamhtheastas a athnuachan.", + "Lease policy for this application": "Polasaí léasa don fheidhmchlár seo", + "In force now: {default} seconds by default, {max} seconds at most.": "I bhfeidhm anois: {default} soicind de réir réamhshocraithe, {max} soicind ar a mhéad.", + "Leases are not renewable": "Ní féidir léasanna a athnuachan", + "Lease policy saved.": "Polasaí léasa sábháilte.", + "Leave a field empty to use the instance value.": "Fág réimse folamh chun luach an chásanna a úsáid.", + "Instance value: {value}": "Luach an chásanna: {value}", + "Renewal": "Athnuachan", + "Use the instance value ({value})": "Úsáid luach an chásanna ({value})", + "Allowed": "Ceadaithe", + "Not allowed": "Gan cead", + "Save lease policy": "Sábháil polasaí léasa", + "Only an administrator can change this policy.": "Ní féidir ach le riarthóir an polasaí seo a athrú.", + "Could not save the lease policy.": "Níorbh fhéidir an polasaí léasa a shábháil.", + "{member} got access from {confirmer}.": "Fuair {member} rochtain ó {confirmer}.", + "Automatically confirm new team folder members": "Deimhnigh baill nua fillteán foirne go huathoibríoch", + "Gave %n new member access to a team folder.": "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Gave %n new members access to a team folder.": "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Give new team folder members access without waiting for the folder owner.": "Tabhair rochtain do bhaill nua gan fanacht le húinéir an fhillteáin.", + "New team folder members": "Baill nua fillteán foirne", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Deimhníonn an t-úinéir nó ball le cead scríbhneoireachta iad óna chruinneachán oscailte. Ní dhíchriptíonn Keepiq riamh ar an bhfreastalaí.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ag fanacht le ball le cead scríbhneoireachta Keepiq a oscailt. Is féidir leat comhroinnt anois freisin.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Theip ar chuid den fhreagairt ar shárú ({failed} céim). Seiceáil loga an fhreastalaí, ansin cúlghair an tsraith arís chun é a chríochnú.", + "This also revoked suite {suite} and ended key migration {migration}.": "Chúlghair sé seo sraith {suite} freisin agus chuir sé deireadh le haistriú eochrach {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Scrios cúlghairm an dara sraith %n teagmháil rochtana éigeandála.", + "Revoking the second suite deleted %n emergency-access contacts.": "Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála.", + "A suite revoked as compromised cannot be reinstated.": "Ní féidir sraith a cúlghaireadh mar shraith a sáraíodh a athbhunú.", + "Archives to keep": "Cartlanna le coinneáil", + "Back up every vault automatically": "Déan cúltaca de gach cruinneachán go huathoibríoch", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Déan cúltaca de gach cruinneachán de réir sceidil. Níl ach téacs criptithe sna cartlanna agus athchóirítear iad le occ.", + "Back up now": "Déan cúltaca anois", + "Backup public key (PEM, optional)": "Eochair phoiblí chúltaca (PEM, roghnach)", + "Backup requested for the next cron run": "Cúltaca iarrtha don chéad rith cron eile", + "Encrypted": "Criptithe", + "Every (hours)": "Gach (uair)", + "Last backup {when} failed: {error}": "Theip ar an gcúltaca deireanach {when}: {error}", + "Last backup {when} succeeded.": "D'éirigh leis an gcúltaca deireanach {when}.", + "No archives yet.": "Níl aon chartlann fós.", + "Size": "Méid", + "Vault backups": "Cúltacaí an chruinneacháin", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Le heochair, criptítear gach cartlann di. Coinnigh an eochair phríobháideach lasmuigh den fhreastalaí seo: teastaíonn sí chun fíorú nó athchóiriú.", + "Written": "Scríofa", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leis an cruinneachán a oscailt fad atá sé seo ar siúl.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Ní chomhairtear cóid chúltaca. Má logálann d'úsáideoirí isteach trí sholáthraí aitheantais a bhfuil a dhara fachtóir féin aige, fág a ngrúpaí amach.", + "Block personal vault export": "Cuir bac ar onnmhairiú an chruinneacháin phearsanta", + "Keep work logins in team folders": "Coinnigh logálacha oibre i bhfillteáin foirne", + "Move to a team folder": "Bog go fillteán foirne", + "Not in a team folder": "Ní i bhfillteán foirne", + "Only for these groups (empty is everyone)": "Do na grúpaí seo amháin (folamh = gach duine)", + "Require two-factor login before the vault opens": "Éiligh logáil isteach dhá fhachtóir sula n-osclaíonn an cruinneachán", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rialacha do gach cruinneachán. Baineann gach ceann le gach duine, nó leis na grúpaí a roghnaíonn tú amháin.", + "Secret types that belong in a team folder": "Cineálacha rúin a bhaineann le fillteán foirne", + "Set up two-factor login": "Socraigh logáil isteach dhá fhachtóir", + "Team folder you can write to": "Fillteán foirne inar féidir leat scríobh", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Ní féidir le húsáideoirí cúltaca, CSV ná comhad aistrithe a íoslódáil. Fanann a bpacáiste sonraí pearsanta ar fáil.", + "Users cannot save these secret types in a personal folder.": "Ní féidir le húsáideoirí na cineálacha rúin seo a shábháil i bhfillteán pearsanta.", + "Vault policies": "Polasaithe an chruinneacháin", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ní cheadaíonn d'eagraíocht do chruinneachán pearsanta a onnmhairiú. Fanann do phacáiste sonraí pearsanta i do shocruithe ar fáil.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Coinníonn d'eagraíocht na rúin seo i bhfillteán foirne. Bog gach ceann go fillteán foirne.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Coinníonn d'eagraíocht an cineál rúin seo i bhfillteán foirne. Roghnaigh ceann de d'fhillteáin foirne, nó ceann inar féidir leat scríobh.", + "Your organisation requires two-factor login before you can open your vault.": "Éilíonn d'eagraíocht logáil isteach dhá fhachtóir sular féidir leat do chruinneachán a oscailt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Roghnaíonn úsáideoirí cé chomh fada a fhanann an síneadh díghlasáilte agus é díomhaoin. Socraíonn tusa an t-am is faide is féidir leo a roghnú.", + "Longest idle time before the extension locks": "An t-am díomhaoin is faide sula gcuirtear an síneadh faoi ghlas", + "1 minute": "1 nóiméad", + "5 minutes": "5 nóiméad", + "15 minutes": "15 nóiméad", + "1 hour": "1 uair an chloig", + "4 hours": "4 uair an chloig", + "Connector": "Nascóir", + "Directory (tenant) ID": "Aitheantas an eolaire (tionónta)", + "Application (client) ID": "Aitheantas an fheidhmchláir (cliant)", + "Data collection rule immutable ID": "Aitheantas do-athraithe na rialach bailithe sonraí", + "Stream name": "Ainm an tsrutha", + "Splunk index (optional)": "Innéacs Splunk (roghnach)", + "Sourcetype (optional)": "Sourcetype (roghnach)", + "Leave blank to keep the current one": "Fág bán é chun an ceann reatha a choinneáil", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF thar syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Críochphointe bailithe sonraí (URL https)", + "HTTP Event Collector URL (https)": "URL an HTTP Event Collector (https)", + "Client secret (write-only)": "Rún cliaint (scríobh amháin)", + "HEC token (write-only)": "Comhartha HEC (scríobh amháin)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Seol imeachtaí iniúchta ceadaithe ar aghaidh chuig Splunk, Microsoft Sentinel, glacadóir syslog nó crúca gréasáin. Ní iompraíonn teachtaireachtaí ach meiteashonraí glanta: ní fhágann aon luach rúnda, ainm, logáil isteach ná téacs criptithe an freastalaí riamh.", + "%n change waiting to sync": "%n athrú ag fanacht le sioncrónú", + "%n changes waiting to sync": "%n athrú ag fanacht le sioncrónú", + "Changes that could not sync": "Athruithe nárbh fhéidir a shioncrónú", + "Choose a version": "Roghnaigh leagan", + "Copy value": "Cóipeáil an luach", + "Deleted": "Scriosta", + "Discard": "Caith uait", + "Keep my offline change": "Coinnigh m'athrú as líne", + "Keep the server version": "Coinnigh leagan an fhreastalaí", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Is léamh amháin é Keepiq as líne. Níor chas do riarthóir eagarthóireacht as líne air.", + "Let users edit secrets offline": "Lig d'úsáideoirí rúin a chur in eagar as líne", + "Not synced yet": "Gan sioncrónú fós", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Fanann athruithe as líne ar an ngléas, criptithe don úsáideoir, agus sioncrónaítear iad ag an gcéad díghlasáil eile ar líne. Teastaíonn nasc fós le haghaidh comhroinnte, fillteán agus ceangaltán.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "As líne. Fanann eagarthóireacht, bogadh agus scriosadh ar an ngléas seo agus sioncrónaítear iad nuair a bheidh tú ar líne arís. Teastaíonn nasc le haghaidh comhroinnte agus ceangaltán.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "As líne. Fanann d'athruithe ar an ngléas seo agus sioncrónaítear iad nuair a bheidh tú ar líne arís. Sioncrónaithe go deireanach {when}.", + "Open my changes": "Oscail m'athruithe", + "Sharing needs a connection": "Teastaíonn nasc le haghaidh comhroinnte", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "D'athraigh duine éigin an rún seo ar an bhfreastalaí tar éis do chóip as líne a dhéanamh. Roghnaigh cén leagan le coinneáil.", + "Sync or discard your offline changes before you rotate your keys.": "Sioncrónaigh nó caith uait d'athruithe as líne sula n-athraíonn tú d'eochracha.", + "That password did not open your changes.": "Níor oscail an focal faire sin d'athruithe.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Stórálann an léargas as líne rúin chriptithe (ní féidir iad a oscailt ach leis an eochair a dhíorthaítear ó phríomhfhocal faire an úsáideora, díreach mar ar an bhfreastalaí) agus criptíonn sé ainmneacha, URLanna agus ainmneacha fillteán agus iad stóráilte. Is léamh amháin í rochtain as líne mura gceadaíonn tú eagarthóireacht as líne thíos. Múch é seo do ghléasanna nár cheart dóibh dintiúir a thaisceadh riamh; glanann múchadh na taisce atá ann ag an gcéad lódáil eile.", + "The previous vault copy is gone, so these changes cannot be opened.": "Tá an chóip roimhe seo den chiste imithe, mar sin ní féidir na hathruithe seo a oscailt.", + "The server version": "Leagan an fhreastalaí", + "This secret changed while you were offline": "Athraíodh an rún seo agus tú as líne", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Scrios tú an rún seo as líne, ach athraíodh é ar an bhfreastalaí ó shin. Roghnaigh cén leagan le coinneáil.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Athraíodh d'eochracha ar ghléas eile. Cuir isteach do phríomhfhocal faire roimhe seo chun d'athruithe as líne a shioncrónú, nó caith uait iad.", + "Your offline change": "D'athrú as líne", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach é. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.","Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.","Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["Ní féidir %n mhír a léiriú i CXF agus fágfar ar lár í.","Ní féidir %n mír a léiriú i CXF agus fágfar ar lár iad.","Ní féidir %n mír a léiriú i CXF agus fágfar ar lár iad."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú.","Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú.","Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Tá %n chóip rúin fós le criptiú agus le comhroinnt.","Tá %n cóip rúin fós le criptiú agus le comhroinnt.","Tá %n cóip rúin fós le criptiú agus le comhroinnt."], + "_%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 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."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh é.","Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh iad.","Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh iad."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["Níor aistríodh %n rún.","Níor aistríodh %n rún.","Níor aistríodh %n rún."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["Tá %n rún criptithe le d'eochair roimhe seo go fóill.","Tá %n rún criptithe le d'eochair roimhe seo go fóill.","Tá %n rún criptithe le d'eochair roimhe seo go fóill."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é.","Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é.","Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é."], + "_%n secret_::_%n secrets_": ["%n rún","%n rún","%n rún"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leis an cruinneachán a oscailt fad atá sé seo ar siúl.","Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl.","Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh","Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh","Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["Fuair %n bhall nua rochtain ar fhillteán foirne.","Fuair %n bhall nua rochtain ar fhillteán foirne.","Fuair %n bhall nua rochtain ar fhillteán foirne."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua.","Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua.","Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Scrios cúlghairm an dara sraith %n teagmháil rochtana éigeandála.","Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála.","Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revoking this suite deleted %n emergency-access contact.","Revoking this suite deleted %n emergency-access contacts.","Revoking this suite deleted %n emergency-access contacts."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["feicthe %n uair i sáruithe","feicthe %n uair i sáruithe","feicthe %n uair i sáruithe"], + "_shared with %n secret_::_shared with %n secrets_": ["comhroinnte le %n rún","comhroinnte le %n rún","comhroinnte le %n rún"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Tá %n rún sa fillteán seo go díreach.","Tá %n rún sa fillteán seo go díreach.","Tá %n rún sa fillteán seo go díreach."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n athrú ag fanacht le sioncrónú","%n athrú ag fanacht le sioncrónú","%n athrú ag fanacht le sioncrónú"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Tá an t-úsáideoir fós sa ghrúpa {groups}, atá ina bhall d'fhillteán foirne. Bain é den ghrúpa nó díchumasaigh an cuntas.","Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas.","Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas."], + "Allow approval from another device": "Ceadaigh ceadú ó ghléas eile", + "App": "Aip", + "Approve a new device": "Ceadaigh gléas nua", + "Approve from another device": "Ceadaigh ó ghléas eile", + "Asked at": "Iarrtha ag", + "Check that the new device shows these words:": "Seiceáil go dtaispeánann an gléas nua na focail seo:", + "Denied. If you did not ask, end your other sessions:": "Diúltaithe. Mura ndearna tú an t-iarratas, cuir deireadh le do sheisiúin eile:", + "Device": "Gléas", + "IP address": "Seoladh IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lig d'úsáideoirí brabhsálaí nua a dhíghlasáil trína cheadú ó ghléas ina bhfuil Keepiq díghlasáilte cheana féin.", + "New device approval": "Ceadú gléas nua", + "Nextcloud security settings": "Socruithe slándála Nextcloud", + "Only approve a device you are using right now.": "Ná ceadaigh ach gléas atá á úsáid agat anois.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Oscail Keepiq ar ghléas ina bhfuil sé díghlasáilte agus ceadaigh an ceann seo. Seiceáil go dtaispeánann sé na focail chéanna:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Séalaíonn an gléas a cheadaíonn an eochair díghlasála don ghléas nua. Ní dhéanann an freastalaí ach í a chur ar aghaidh agus ní féidir leis í a oscailt.", + "The master password is not right, or the request has ended.": "Níl an máistirfhocal faire ceart, nó tá deireadh leis an iarratas.", + "The request expired. Ask again or use your master password.": "Tá an t-iarratas imithe in éag. Iarr arís nó úsáid do mháistirfhocal faire.", + "The request was denied.": "Diúltaíodh don iarratas.", + "Too many requests. Try again in an hour or use your master password.": "An iomarca iarratas. Bain triail eile as i gceann uair an chloig nó úsáid do mháistirfhocal faire.", + "Unknown device": "Gléas anaithnid", + "Web app": "Aip ghréasáin", + "A device": "Gléas", + "A new device asks to open your vault": "Iarrann gléas nua do thaisceadán a oscailt", + "%s asks to be approved. Only approve a device you are using right now.": "Iarrann %s go gceadófaí é. Ná ceadaigh ach gléas atá á úsáid agat anois.", + "Access ends on (optional)": "Críochnaíonn rochtain ar (roghnach)", + "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.": "Ní thaispeánfaidh ná ní chóipeálfaidh aipeanna Keepiq an pasfhocal. Is féidir le duine a bhfuil scileanna teicniúla aige é a léamh óna ghléas féin fós. Athraigh é nuair a chríochnaíonn a rochtain.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Is le húsáid amháin an rún seo. Sínigh isteach tríd an síneadh brabhsálaí Keepiq.", + "Until {date}": "Go dtí {date}", + "Use only": "Úsáid amháin", + "Use only (can sign in, cannot view or copy)": "Úsáid amháin (is féidir síniú isteach, ní féidir féachaint air ná é a chóipeáil)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Is féidir leat síniú isteach leis an logáil isteach seo tríd an síneadh brabhsálaí Keepiq. Roghnaigh an t-úinéir gan ligean duit féachaint air ná é a chóipeáil.", + "Your access ends on {date}": "Críochnaíonn do rochtain ar {date}", + "Your access to this secret has ended": "Tá deireadh le do rochtain ar an rún seo", + "Your access to \"%s\" ends tomorrow": "Críochnaíonn do rochtain ar \"%s\" amárach", + "Your access to \"%s\" has ended": "Tá deireadh le do rochtain ar \"%s\"", + "%1$s no longer has access to \"%2$s\"": "Níl rochtain ag %1$s ar \"%2$s\" a thuilleadh", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "Bhí %1$s in ann an pasfhocal seo a fheiceáil. Athraigh é mura cóir go mbeadh a fhios ag %1$s é a thuilleadh.", + "%s could not view this password in Keepiq.": "Ní raibh %s in ann an pasfhocal seo a fheiceáil in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} as {threshold} faomhadh", + "a recovery officer": "oifigeach aisghabhála", + "Account recovery": "Aisghabháil cuntais", + "Approvals needed": "Faomhaí ag teastáil", + "Ask {user} which words they see, by phone or in person. They must be:": "Fiafraigh de {user} cé na focail a fheiceann siad, ar an bhfón nó go pearsanta. Caithfidh siad a bheith:", + "Check again": "Seiceáil arís", + "Create the recovery key": "Cruthaigh an eochair aisghabhála", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Cruthaigh an eochair aisghabhála. Déanann do bhrabhsálaí í agus tugann sé cóip do gach oifigeach nach féidir ach leo féin a oscailt.", + "Decline": "Diúltaigh", + "Enrol in account recovery": "Cláraigh le haghaidh aisghabháil cuntais", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Cláraigh ionas gur féidir le d'eagraíocht cabhrú leat do thaisceadán a fháil ar ais má dhéanann tú dearmad ar do mháistirfhocal faire.", + "Every user is enrolled": "Tá gach úsáideoir cláraithe", + "Finish the recovery in the browser you asked from.": "Críochnaigh an aisghabháil sa bhrabhsálaí ónar iarr tú í.", + "Forgot your master password?": "Ar dhearmad tú do mháistirfhocal faire?", + "Hand the key over": "Tabhair an eochair uait", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lig d'úsáideoirí a rinne dearmad ar a máistirfhocal faire a dtaisceadán a fháil ar ais, faofa ag oifigigh aisghabhála a ainmníonn tú.", + "New master password": "Máistirfhocal faire nua", + "No one is asking to recover their account.": "Níl aon duine ag iarraidh a gcuntas a aisghabháil.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Níl eochair aisghabhála ann fós. Cruthaíonn duine de na hoifigigh í ina socruithe Keepiq.", + "Off": "As", + "Officer {user} has no encryption set up yet.": "Níl criptiú socraithe fós ag an oifigeach {user}.", + "Officers (user IDs, separated by commas)": "Oifigigh (aitheantais úsáideora, scartha le camóga)", + "Policy": "Polasaí", + "Publish this fingerprint internally, so users can check it before they enrol.": "Foilsigh an méarlorg seo go hinmheánach, ionas gur féidir le húsáideoirí é a sheiceáil sula gcláraíonn siad.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Aisghafa le cabhair ó {officer}. Athraigh eochair do thaisceadáin anois i Socruithe, Slándáil: \"Cuireadh mo mháistirfhocal faire i mbaol\".", + "Recovery key fingerprint: {fingerprint}": "Méarlorg na heochrach aisghabhála: {fingerprint}", + "Recovery officer": "Oifigeach aisghabhála", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Cailleann oifigigh bainte a gcóip anois, ach b'fhéidir gur oscail siad í roimhe seo. Iarr ar oifigeach eochair aisghabhála nua a chruthú.", + "Repeat the new master password": "Athscríobh an máistirfhocal faire nua", + "Retire this recovery key": "Scoir an eochair aisghabhála seo", + "Set the new master password": "Socraigh an máistirfhocal faire nua", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Níor eisigh an Keepiq seo an teastas aisghabhála. Ná cláraigh agus inis do do riarthóir.", + "The words match, approve": "Tá na focail ag teacht le chéile, faomh", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tá an t-úsáideoir seo cláraithe le haghaidh aisghabháil cuntais. Coinníonn aisghabháil a rúin; scriosann cúlghairm a gclárú.", + "Users may enrol": "Is féidir le húsáideoirí clárú", + "Withdraw from account recovery": "Tarraing siar ó aisghabháil cuntais", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Tá tú cláraithe le haghaidh aisghabháil cuntais. Méarlorg na heochrach aisghabhála: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Tá tú cláraithe. Má dhéanann tú dearmad ar do mháistirfhocal faire, is féidir le d'eagraíocht cabhrú leat do thaisceadán a fháil ar ais.", + "Your key is back. Choose a new master password.": "Tá d'eochair ar ais. Roghnaigh máistirfhocal faire nua.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Cuireadh d'oifigigh aisghabhála ar an eolas. Léigh na focail seo dóibh nuair a ghlaonn siad ort nó nuair a bhuaileann siad leat:", + "You are now an account recovery officer": "Is oifigeach aisghabhála cuntas thú anois", + "%s asks to recover their account. Compare the words with them before you approve.": "Tá %s ag iarraidh a gcuntas a aisghabháil. Cuir na focail i gcomparáid leo sula bhfaomhann tú.", + "A user": "Úsáideoir", + "Your account recovery request was declined": "Diúltaíodh d'iarratas ar aisghabháil cuntais", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Tá d'aisghabháil cuntais réidh. Oscail Keepiq sa bhrabhsálaí ónar iarr tú í.", + "{user} asks to unlock a new device once. They keep their master password.": "Iarrann {user} gléas nua a dhíghlasáil uair amháin. Coinníonn siad a bpríomhfhocal faire.", + "Ask your organisation instead": "Iarr ar d'eagraíocht ina ionad sin", + "The request ended. Ask again or use your master password.": "Tá an t-iarratas thart. Iarr arís nó úsáid do phríomhfhocal faire.", + "Added by {user}": "Curtha leis ag {user}", + "Editor": "Eagarthóir", + "Manager": "Bainisteoir", + "Role of {member}": "Ról {member}", + "Team folders you manage": "Fillteáin foirne a bhainistíonn tú", + "Viewer": "Breathnóir", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Níl cóip de na rúin seo agat, mar sin níor fhaigh na baill nua iad fós. Is féidir leis an úinéir iad a roinnt: {names}", + "Admin areas": "Réimsí riaracháin", + "Give a group only the parts of Keepiq administration it needs.": "Tabhair do ghrúpa na codanna de riarachán Keepiq a theastaíonn uaidh amháin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Tarmligh réimse amháin nó níos mó chuig grúpa ar leathanach na bpribhléidí riaracháin. Tá gach réimse ag riarthóirí an chásanna.", + "Open administration privileges": "Oscail pribhléidí riaracháin", + "Policies": "Polasaithe", + "Applications and machine access": "Feidhmchláir agus rochtain meaisíní", + "People and offboarding": "Daoine agus imeachtaí", + "Audit and compliance": "Iniúchadh agus comhlíonadh", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "leagan, údarás deimhnithe, ceangaltáin, taisce as líne, seiceáil sceitheadh, cineálacha rún agus cúltacaí", + "master password, organisation password, vault policies, rotation, version history and trash": "máistirfhocal faire, focal faire na heagraíochta, polasaithe an chruinneacháin, rothlú, stair leaganacha agus bruscar", + "application queue, application requests and machine leases": "scuaine feidhmchlár, iarratais feidhmchlár agus léasanna meaisín", + "team offboarding, encryption suites and admin handover": "imeachtaí ón bhfoireann, sraitheanna criptithe agus gabháil seilbhe ag riarthóir", + "audit log, compliance reports, SIEM export and honey alerts": "loga iniúchta, tuarascálacha comhlíonta, easpórtáil SIEM agus foláirimh baoite", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cé mhéad leagan de rún a choinnítear, ar feadh cé chomh fada, agus cé chomh fada a fhanann rúin scriosta sa bhruscar.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Teorainneacha do cheangaltáin chriptithe, curtha i bhfeidhm ar an bhfreastalaí i mbearta criptithe stóráilte.", + "Type the suite ID again to confirm": "Clóscríobh aitheantas na sraithe arís le deimhniú", + "This does not match the suite ID.": "Ní hionann é seo agus aitheantas na sraithe.", + "Confirm with your master password": "Deimhnigh le do phríomhphasfhocal", + "Confirm": "Deimhnigh", + "That master password is not right.": "Níl an príomhphasfhocal sin ceart.", + "You are sharing with someone new. Enter your master password to confirm.": "Tá tú ag comhroinnt le duine nua. Cuir isteach do phríomhphasfhocal le deimhniú.", + "Enter your master password to confirm this share.": "Cuir isteach do phríomhphasfhocal chun an chomhroinnt seo a dheimhniú.", + "Enter your master password to confirm this delegation.": "Cuir isteach do phríomhphasfhocal chun an tarmligean seo a dheimhniú.", + "Approve {member}": "Ceadaigh {member}", + "Recipient": "Faighteoir", + "No vault yet": "Gan vailt fós", + "No matching users": "Níl aon úsáideoirí comhoiriúnacha", + "Partner organisations": "Eagraíochtaí comhpháirtíochta", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Malartaigh rúin le Keepiq eile. Cuireann an dá riarthóir a chéile leis agus déanann siad comparáid idir na méarloirg fréimhe ar an bhfón nó go pearsanta sula sábhálann siad.", + "Federation needs Nextcloud 33 or later.": "Teastaíonn Nextcloud 33 nó níos nuaí don chónaidhm.", + "Your root fingerprint": "Do mhéarlorg fréimhe", + "No partners yet.": "Níl aon chomhpháirtithe fós.", + "Users here may share to this partner": "Is féidir le húsáideoirí anseo comhroinnt leis an gcomhpháirtí seo", + "This partner may share to users here": "Is féidir leis an gcomhpháirtí seo comhroinnt le húsáideoirí anseo", + "Partner address": "Seoladh an chomhpháirtí", + "Check partner": "Seiceáil an comhpháirtí", + "Partner root fingerprint": "Méarlorg fréimhe an chomhpháirtí", + "I compared this fingerprint with the partner's administrator": "Rinne mé comparáid idir an méarlorg seo agus riarthóir an chomhpháirtí", + "Add partner": "Cuir comhpháirtí leis", + "A secret from another organisation": "Rún ó eagraíocht eile", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Chomhroinn %1$s \"%2$s\" leat. Glac leis faoi Isteach ó eagraíochtaí eile.", + "Incoming from other organisations": "Isteach ó eagraíochtaí eile", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Is féidir le daoine in eagraíochtaí comhpháirtíochta rún a chomhroinnt leat. Glac leis chun cóip inléite amháin a choinneáil i do vailt.", + "Nothing shared with you yet": "Níl aon rud comhroinnte leat fós", + "Secrets that people in partner organisations share with you appear here.": "Taispeántar anseo na rúin a chomhroinneann daoine in eagraíochtaí comhpháirtíochta leat.", + "From {sender}": "Ó {sender}", + "Accept": "Glac leis", + "Open in vault": "Oscail sa vailt", + "The other organisation did not hand over the secret. Try again later.": "Níor thug an eagraíocht eile an rún uaithi. Bain triail eile as níos déanaí.", + "Set up your vault before you accept a shared secret.": "Socraigh do vailt sula nglacann tú le rún comhroinnte.", + "Something went wrong. Try again.": "Chuaigh rud éigin mícheart. Bain triail eile as.", + "Waiting for your answer": "Ag fanacht le do fhreagra", + "In your vault, read-only": "I do vailt, inléite amháin", + "Withdrawn by the sender": "Tarraingthe siar ag an seoltóir", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Chomhroinn {sender} é seo ó eagraíocht eile. Is féidir leat é a léamh, ach ní féidir leat é a athrú ná a chomhroinnt.", + "Someone": "Duine éigin", + "Share with someone at another organisation": "Comhroinn le duine in eagraíocht eile", + "Their account at the other organisation": "Cuntas an duine san eagraíocht eile", + "Check account": "Seiceáil an cuntas", + "Certificate fingerprint of {account}": "Méarlorg deimhnithe {account}", + "Compare it with them by phone if you want to be sure.": "Cuir i gcomparáid é leis an duine ar an bhfón más mian leat a bheith cinnte.", + "Shared. {account} can accept it in their own vault.": "Comhroinnte. Is féidir le {account} glacadh leis ina vailt féin.", + "The certificate could not be verified. Nothing was shared.": "Níorbh fhéidir an deimhniú a fhíorú. Níor comhroinneadh aon rud.", + "That organisation is not one of your partners.": "Ní ceann de do chomhpháirtithe í an eagraíocht sin.", + "No one with that account can receive secrets from you.": "Ní féidir le duine ar bith leis an gcuntas sin rúin a fháil uait.", + "The other organisation did not answer. Try again later.": "Níor fhreagair an eagraíocht eile. Bain triail eile as níos déanaí.", + "This secret is already shared with that account.": "Tá an rún seo comhroinnte leis an gcuntas sin cheana.", + "Other organisations": "Eagraíochtaí eile", + "Receive secrets from other organisations": "Faigh rúin ó eagraíochtaí eile", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Is féidir le daoine in eagraíochtaí comhpháirtíochta do chuntas a aimsiú ansin agus rúin a chomhroinnt leat. Glacann tú féin le gach ceann acu.", + "Shared": "Comhroinnte", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Ar sos: d’athraigh a ndeimhniú nó an chomhpháirtíocht. Cúlghair é nó comhroinn arís.", + "Their organisation did not get the last change. Revoke it or share again.": "Ní bhfuair a n-eagraíocht an t-athrú is déanaí. Cúlghair é nó comhroinn arís.", + "Being withdrawn": "Á tharraingt siar", + "Shared with another organisation": "Comhroinnte le heagraíocht eile", + "Change sent to another organisation": "Athrú seolta chuig eagraíocht eile", + "Share with another organisation revoked": "Comhroinnt le heagraíocht eile cúlghairthe", + "Share with another organisation paused": "Comhroinnt le heagraíocht eile ar sos", + "Another organisation did not get a change": "Ní bhfuair eagraíocht eile athrú", + "Secret received from another organisation": "Rún faighte ó eagraíocht eile", + "Secret from another organisation accepted": "Rún ó eagraíocht eile glactha", + "Secret from another organisation declined": "Rún ó eagraíocht eile diúltaithe", + "Copy from another organisation updated": "Cóip ó eagraíocht eile nuashonraithe", + "Copy from another organisation removed": "Cóip ó eagraíocht eile bainte", + "Declined: they removed their copy. Share again if they need it.": "Diúltaithe: bhain an faighteoir a chóip. Comhroinn arís má tá sí ag teastáil.", + "Recipient at another organisation removed their copy": "Bhain faighteoir in eagraíocht eile a chóip", + "Removed the user from %n team folder.": "Baineadh an t-úsáideoir de %n fhillteán foirne.", + "Removed the user from %n team folders.": "Baineadh an t-úsáideoir de %n fillteán foirne.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Baineadh an t-úsáideoir de %n fhillteán foirne.","Baineadh an t-úsáideoir de %n fillteán foirne.","Baineadh an t-úsáideoir de %n fillteán foirne."], + "A restored copy came from a share that has ended. It stays read-only.": "Tháinig cóip athchóirithe ó chomhroinnt atá críochnaithe. Fanann sí inléite amháin.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Níorbh fhéidir teagmháil a dhéanamh leis an eagraíocht a chomhroinn cóip athchóirithe. Fanann an chóip inléite amháin agus ní leanann sí a n-athruithe.", + "Recipient at another organisation restored their copy": "D’athchóirigh faighteoir in eagraíocht eile a chóip" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);" ) diff --git a/l10n/ga.json b/l10n/ga.json index 9ac575399..bc4183a24 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Comhroinnte le grúpaí", + "Not shared with any group yet.": "Níor comhroinneadh le grúpa ar bith fós.", + "Revoke the share with {group}": "Cealaigh an chomhroinnt le {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Comhroinnte le {group}: fuair {received} ball é, ní bhfuair {skipped} é mar níl criptiú socraithe acu fós.", + "Search groups": "Cuardaigh grúpaí", + "Failed to share": "Theip ar an gcomhroinnt", + "Columns": "Colúin", + "Column {number}": "Colún {number}", + "Map one column to Name. Every secret needs a name.": "Nasc colún amháin leis an Ainm. Tá ainm ag teastáil ó gach rún.", + "Notes": "Nótaí", + "Do not import": "Ná hiompórtáil", + "Hide this value": "Folaigh an luach seo", + "Show this value": "Taispeáin an luach seo", + "Defaults": "Réamhshocruithe", + "New secrets start as this type, and your secret list opens in this view.": "Tosaíonn rúin nua mar an cineál seo, agus osclaíonn do liosta rún san amharc seo.", + "Default item type": "Cineál míre réamhshocraithe", + "Cards": "Cártaí", + "Table": "Tábla", + "Could not save your default": "Níorbh fhéidir do réamhshocrú a shábháil", + "Recently used": "Úsáidte le déanaí", + "Opened": "Oscailte", + "You have not opened any secrets yet": "Níor oscail tú aon rún fós", + "Could not delete the item type.": "Níorbh fhéidir an cineál míre a scriosadh.", + "Could not load the item types.": "Níorbh fhéidir na cineálacha míre a lódáil.", + "Could not save the item type.": "Níorbh fhéidir an cineál míre a shábháil.", + "Delete item type": "Scrios an cineál míre", + "Edit item type": "Cuir an cineál míre in eagar", + "Fields": "Réimsí", + "Fields: {count}": "Réimsí: {count}", + "Hidden": "Folaithe", + "Item types": "Cineálacha míre", + "Move up": "Bog suas", + "New item type": "Cineál míre nua", + "No item types defined yet.": "Níl aon chineál míre sainithe fós.", + "Required": "Riachtanach", + "Text": "Téacs", + "This field is required": "Tá an réimse seo riachtanach", + "Web address": "Seoladh gréasáin", + "{label} (required)": "{label} (riachtanach)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Scrios “{name}”? Fanann rúin den chineál seo inléite agus éiríonn siad ina míreanna Logáil isteach.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Feictear do chách na cineálacha míre a shainíonn tú anseo sa dialóg Rún nua, leis na réimsí a roghnaíonn tú.", + "Secret moved to the trash": "Bogadh an rún go dtí an bruscar", + "Secret restored from the trash": "Aisghabhadh an rún ón mbruscar", + "Secret deleted for good": "Scriosadh an rún go buan", + "Secret archived": "Cartlannaíodh an rún", + "Secret unarchived": "Tógadh an rún as an gcartlann", + "Unarchive": "Tóg as an gcartlann", + "Could not archive the secret": "Níorbh fhéidir an rún a chartlannú", + "Could not unarchive the secret": "Níorbh fhéidir an rún a thógáil as an gcartlann", + "Archive {count} secrets": "Cartlannaigh rúin: {count}", + "Unarchive {count} secrets": "Tóg rúin as an gcartlann: {count}", + "Restore {count} secrets": "Aisghabh rúin: {count}", + "Delete {count} secrets for good": "Scrios rúin go buan: {count}", + "Done for {ok} of {total} secrets": "Déanta do {ok} as {total} rún", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Fágann rúin chartlannaithe liosta an chrinnín, an cuardach, an líonadh uathoibríoch agus an tuarascáil sláinte. Coinníonn siad a gcomhroinnt. Gheobhaidh tú iad faoi Chartlann.", + "These secrets come back to the vault list, search and autofill.": "Tagann na rúin seo ar ais i liosta an chrinnín, sa chuardach agus sa líonadh uathoibríoch.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tagann na rúin seo ar ais i liosta an chrinnín. Ní thagann a sean-chomhroinnt ar ais, mar sin comhroinn arís iad más gá.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Scriosann sé seo na rúin lena gceangaltáin agus a stair leaganacha. Ní féidir é seo a chealú.", + "Delete for good": "Scrios go buan", + "Trash": "Bruscar", + "The trash is empty": "Tá an bruscar folamh", + "No archived secrets": "Níl aon rúin chartlannaithe", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Fanann rúin scriosta anseo go dtí go gcríochnaíonn an tréimhse choinneála, ansin scriostar go buan iad.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Cartlannaigh rún óna phainéal sonraí chun é a choinneáil amach ó liosta an chrinnín, ón gcuardach agus ón líonadh uathoibríoch.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Teorainneacha do cheangaltáin chriptithe (curtha i bhfeidhm ar an bhfreastalaí i mbearta criptithe stóráilte), coinneáil stair leaganacha agus cá fhad a fhanann rúin scriosta sa bhruscar.", + "Days a deleted secret stays in the trash (1 to 365)": "Laethanta a fhanann rún scriosta sa bhruscar (1 go 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Bogann sé seo an rún go dtí an bruscar agus cuireann sé deireadh lena chomhroinnt anois. Is féidir leat é a aisghabháil ón mbruscar go dtí go gcríochnaíonn an tréimhse choinneála: 30 lá, mura bhfuil sé athraithe ag do riarthóir.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Bogann sé seo rúin go dtí an bruscar ({count}) agus cuireann sé deireadh lena gcomhroinnt anois. Is féidir leat iad a aisghabháil ón mbruscar go dtí go gcríochnaíonn an tréimhse choinneála.", + "Remove {name} from favourites": "Bain {name} as na ceanáin", + "Add {name} to favourites": "Cuir {name} leis na ceanáin", + "Could not change the favourite": "Níorbh fhéidir an ceanán a athrú", + "Remove from favourites": "Bain as na ceanáin", + "Add to favourites": "Cuir leis na ceanáin", + "Tags": "Clibeanna", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Níl na clibeanna criptithe. Is féidir le riarthóirí an fhreastalaí iad a léamh, mar is féidir leo ainmneacha fillteán.", + "Favourites": "Ceanáin", + "Filter by tag": "Scag de réir clibe", + "All tags": "Gach clib", + "Last used": "Úsáidte go deireanach", + "Tags for {count} secrets": "Clibeanna do {count} rún", + "Tag": "Clib", + "Remove tag": "Bain an chlib", + "Add tag": "Cuir clib leis", + "Could not change the tags. Try again.": "Níorbh fhéidir na clibeanna a athrú. Bain triail eile as.", + "Could not approve the application. It is still in the queue.": "Níorbh fhéidir an t-iarratas a cheadú. Tá sé fós sa scuaine.", + "Could not reject the application. It is still in the queue.": "Níorbh fhéidir an t-iarratas a dhiúltú. Tá sé fós sa scuaine.", + "Removed the user from {count} team folders.": "Baineadh an t-úsáideoir de {count} fillteán foirne.", + "Approve a share": "Ceadaigh comhroinnt", + "This approval link is incomplete. Open it again from the notification.": "Tá an nasc ceadaithe seo neamhiomlán. Oscail arís é ón bhfógra.", + "Deny": "Diúltaigh", + "{user} joined a group you share a secret with. Share the secret with them too?": "Chuaigh {user} isteach i ngrúpa a gcomhroinneann tú rún leis. An gcomhroinnfear an rún leo freisin?", + "{requester} asks you to share a secret with {user}.": "Iarrann {requester} ort rún a chomhroinnt le {user}.", + "Shared. The recipient can now open the secret.": "Comhroinnte. Is féidir leis an bhfaighteoir an rún a oscailt anois.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Níl Keepiq socraithe ag an bhfaighteoir fós, mar sin níor comhroinneadh aon rud. Bain triail eile as nuair a bheidh.", + "Could not share the secret. Only its owner can approve this.": "Níorbh fhéidir an rún a chomhroinnt. Ní féidir ach lena úinéir é seo a cheadú.", + "Could not share the secret. Try again.": "Níorbh fhéidir an rún a chomhroinnt. Bain triail eile as.", + "Denied. Nothing was shared.": "Diúltaithe. Níor comhroinneadh aon rud.", + "Could not deny the request. Try again.": "Níorbh fhéidir an t-iarratas a dhiúltú. Bain triail eile as.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "Iarrann %1$s ort an rún \"%2$s\" a chomhroinnt le %3$s.", + "Expires on (optional)": "Rachaidh in éag ar (roghnach)", + "Hand over to": "Tabhair ar láimh do", + "Choose a recipient": "Roghnaigh faighteoir", + "Hand over temporarily": "Tabhair ar láimh go sealadach", + "Expiry rules": "Rialacha éaga", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Socraigh cé chomh fada is féidir le pasfhocail de chineál míre amháin nó i bhfillteán amháin maireachtáil, agus cathain a chuirfear i gcuimhne duit. Nuair a bhaineann roinnt dátaí, is é an ceann is luaithe a chomhaireann.", + "Delete rule": "Scrios riail", + "Set by your administrator": "Socraithe ag do riarthóir", + "No expiry rules yet.": "Níl aon rialacha éaga ann fós.", + "Applies to": "Baineann le", + "Item type": "Cineál míre", + "Maximum age in days (empty for reminders only)": "Aois uasta i laethanta (folamh le haghaidh meabhrúchán amháin)", + "Remind me this many days before, comma separated": "Cuir i gcuimhne dom an méid seo laethanta roimh ré, scartha le camóga", + "Save rule": "Sábháil riail", + "An item type": "Cineál míre", + "A folder": "Fillteán", + "Folder {name}": "Fillteán {name}", + "Type {name}": "Cineál {name}", + "Expires after {days} days": "Rachaidh in éag tar éis {days} lá", + "Reminders {days} days before": "Meabhrúcháin {days} lá roimh ré", + "Could not save the expiry rule.": "Níorbh fhéidir an riail éaga a shábháil.", + "Could not delete the expiry rule.": "Níorbh fhéidir an riail éaga a scriosadh.", + "All statuses": "Gach stádas", + "Compromised": "I mbaol", + "Could not load the members.": "Níorbh fhéidir na baill a lódáil.", + "Emergency contact": "Teagmhálaí éigeandála", + "Leaving user": "Úsáideoir atá ag imeacht", + "No": "Níl", + "No users match this filter.": "Níl aon úsáideoir ag teacht leis an scagaire seo.", + "Not set up": "Gan socrú", + "Revoke suite": "Cúlghair an sraith", + "Revoked": "Cúlghairthe", + "Search users": "Cuardaigh úsáideoirí", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Féach cé na húsáideoirí a shocraigh cruinneachán. Tosaigh an t-imeacht nó cúlghair sraith ó shraith.", + "Successor": "Comharba", + "Team folders": "Fillteáin foirne", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Tá an t-úsáideoir fós sa ghrúpa {groups}, atá ina bhall d'fhillteán foirne. Bain é den ghrúpa nó díchumasaigh an cuntas.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas.", + "Vault status": "Stádas an chruinneacháin", + "Yes": "Tá", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Níl easpórtáil CXF CRIPTITHE. Beidh gach pasfhocal agus logáil isteach inléite mar ghnáth-théacs sa chomhad íoslódáilte. Stóráil go sábháilte é agus scrios láithreach é tar éis a úsáide.", + "Root certificate expiring soon": "Rachaidh an fréamhdheimhniú as feidhm go luath", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Rachaidh fréamhdheimhniú an tsábháilteáin as feidhm i gceann %1$d lá. Athnuaigh é roimhe sin. Síníonn athnuachan gach sraith criptiúcháin arís.", + "Compromise recovery aborted": "Cealaíodh an t-aisghabháil tar éis comhréitigh", + "Key rotation ended by a compromise revoke": "Cuireadh deireadh le malartú eochrach le cúlghairm mar gheall ar chomhréiteach", + "Encryption suite revoke refused": "Diúltaíodh do chúlghairm na sraithe criptiúcháin", + "Master password proof refused": "Diúltaíodh do chruthúnas an phríomhfhocail faire", + "Your current master password": "Do phríomhfhocal faire reatha", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach é. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Níor aistríodh na teagmhálaithe éigeandála seo chuig d'eochair nua. Baineadh a rochtain éigeandála. Cuir ar ais iad ó Rochtain éigeandála más mian leat iad fós.", + "Renew root certificate": "Athnuaigh an fréamhtheastas", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Cruthaíonn sé seo fréamhtheastas agus teastas idirmheánach nua. Sínítear gach sraith criptiúcháin ghníomhach arís. Ní féidir é seo a chealú.", + "Renew root": "Athnuaigh an fhréamh", + "Root renewed. {n} encryption suites signed again.": "Fréamh athnuaite. Sraitheanna criptiúcháin sínithe arís: {n}.", + "Could not renew the root certificate.": "Níorbh fhéidir an fréamhtheastas a athnuachan.", + "Lease policy for this application": "Polasaí léasa don fheidhmchlár seo", + "In force now: {default} seconds by default, {max} seconds at most.": "I bhfeidhm anois: {default} soicind de réir réamhshocraithe, {max} soicind ar a mhéad.", + "Leases are not renewable": "Ní féidir léasanna a athnuachan", + "Lease policy saved.": "Polasaí léasa sábháilte.", + "Leave a field empty to use the instance value.": "Fág réimse folamh chun luach an chásanna a úsáid.", + "Instance value: {value}": "Luach an chásanna: {value}", + "Renewal": "Athnuachan", + "Use the instance value ({value})": "Úsáid luach an chásanna ({value})", + "Allowed": "Ceadaithe", + "Not allowed": "Gan cead", + "Save lease policy": "Sábháil polasaí léasa", + "Only an administrator can change this policy.": "Ní féidir ach le riarthóir an polasaí seo a athrú.", + "Could not save the lease policy.": "Níorbh fhéidir an polasaí léasa a shábháil.", + "{member} got access from {confirmer}.": "Fuair {member} rochtain ó {confirmer}.", + "Automatically confirm new team folder members": "Deimhnigh baill nua fillteán foirne go huathoibríoch", + "Gave %n new member access to a team folder.": "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Gave %n new members access to a team folder.": "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Give new team folder members access without waiting for the folder owner.": "Tabhair rochtain do bhaill nua gan fanacht le húinéir an fhillteáin.", + "New team folder members": "Baill nua fillteán foirne", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Deimhníonn an t-úinéir nó ball le cead scríbhneoireachta iad óna chruinneachán oscailte. Ní dhíchriptíonn Keepiq riamh ar an bhfreastalaí.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ag fanacht le ball le cead scríbhneoireachta Keepiq a oscailt. Is féidir leat comhroinnt anois freisin.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Theip ar chuid den fhreagairt ar shárú ({failed} céim). Seiceáil loga an fhreastalaí, ansin cúlghair an tsraith arís chun é a chríochnú.", + "This also revoked suite {suite} and ended key migration {migration}.": "Chúlghair sé seo sraith {suite} freisin agus chuir sé deireadh le haistriú eochrach {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Scrios cúlghairm an dara sraith %n teagmháil rochtana éigeandála.", + "Revoking the second suite deleted %n emergency-access contacts.": "Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála.", + "A suite revoked as compromised cannot be reinstated.": "Ní féidir sraith a cúlghaireadh mar shraith a sáraíodh a athbhunú.", + "Archives to keep": "Cartlanna le coinneáil", + "Back up every vault automatically": "Déan cúltaca de gach cruinneachán go huathoibríoch", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Déan cúltaca de gach cruinneachán de réir sceidil. Níl ach téacs criptithe sna cartlanna agus athchóirítear iad le occ.", + "Back up now": "Déan cúltaca anois", + "Backup public key (PEM, optional)": "Eochair phoiblí chúltaca (PEM, roghnach)", + "Backup requested for the next cron run": "Cúltaca iarrtha don chéad rith cron eile", + "Encrypted": "Criptithe", + "Every (hours)": "Gach (uair)", + "Last backup {when} failed: {error}": "Theip ar an gcúltaca deireanach {when}: {error}", + "Last backup {when} succeeded.": "D'éirigh leis an gcúltaca deireanach {when}.", + "No archives yet.": "Níl aon chartlann fós.", + "Size": "Méid", + "Vault backups": "Cúltacaí an chruinneacháin", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Le heochair, criptítear gach cartlann di. Coinnigh an eochair phríobháideach lasmuigh den fhreastalaí seo: teastaíonn sí chun fíorú nó athchóiriú.", + "Written": "Scríofa", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leis an cruinneachán a oscailt fad atá sé seo ar siúl.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Ní chomhairtear cóid chúltaca. Má logálann d'úsáideoirí isteach trí sholáthraí aitheantais a bhfuil a dhara fachtóir féin aige, fág a ngrúpaí amach.", + "Block personal vault export": "Cuir bac ar onnmhairiú an chruinneacháin phearsanta", + "Keep work logins in team folders": "Coinnigh logálacha oibre i bhfillteáin foirne", + "Move to a team folder": "Bog go fillteán foirne", + "Not in a team folder": "Ní i bhfillteán foirne", + "Only for these groups (empty is everyone)": "Do na grúpaí seo amháin (folamh = gach duine)", + "Require two-factor login before the vault opens": "Éiligh logáil isteach dhá fhachtóir sula n-osclaíonn an cruinneachán", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rialacha do gach cruinneachán. Baineann gach ceann le gach duine, nó leis na grúpaí a roghnaíonn tú amháin.", + "Secret types that belong in a team folder": "Cineálacha rúin a bhaineann le fillteán foirne", + "Set up two-factor login": "Socraigh logáil isteach dhá fhachtóir", + "Team folder you can write to": "Fillteán foirne inar féidir leat scríobh", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Ní féidir le húsáideoirí cúltaca, CSV ná comhad aistrithe a íoslódáil. Fanann a bpacáiste sonraí pearsanta ar fáil.", + "Users cannot save these secret types in a personal folder.": "Ní féidir le húsáideoirí na cineálacha rúin seo a shábháil i bhfillteán pearsanta.", + "Vault policies": "Polasaithe an chruinneacháin", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ní cheadaíonn d'eagraíocht do chruinneachán pearsanta a onnmhairiú. Fanann do phacáiste sonraí pearsanta i do shocruithe ar fáil.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Coinníonn d'eagraíocht na rúin seo i bhfillteán foirne. Bog gach ceann go fillteán foirne.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Coinníonn d'eagraíocht an cineál rúin seo i bhfillteán foirne. Roghnaigh ceann de d'fhillteáin foirne, nó ceann inar féidir leat scríobh.", + "Your organisation requires two-factor login before you can open your vault.": "Éilíonn d'eagraíocht logáil isteach dhá fhachtóir sular féidir leat do chruinneachán a oscailt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Roghnaíonn úsáideoirí cé chomh fada a fhanann an síneadh díghlasáilte agus é díomhaoin. Socraíonn tusa an t-am is faide is féidir leo a roghnú.", + "Longest idle time before the extension locks": "An t-am díomhaoin is faide sula gcuirtear an síneadh faoi ghlas", + "1 minute": "1 nóiméad", + "5 minutes": "5 nóiméad", + "15 minutes": "15 nóiméad", + "1 hour": "1 uair an chloig", + "4 hours": "4 uair an chloig", + "Connector": "Nascóir", + "Directory (tenant) ID": "Aitheantas an eolaire (tionónta)", + "Application (client) ID": "Aitheantas an fheidhmchláir (cliant)", + "Data collection rule immutable ID": "Aitheantas do-athraithe na rialach bailithe sonraí", + "Stream name": "Ainm an tsrutha", + "Splunk index (optional)": "Innéacs Splunk (roghnach)", + "Sourcetype (optional)": "Sourcetype (roghnach)", + "Leave blank to keep the current one": "Fág bán é chun an ceann reatha a choinneáil", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF thar syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Críochphointe bailithe sonraí (URL https)", + "HTTP Event Collector URL (https)": "URL an HTTP Event Collector (https)", + "Client secret (write-only)": "Rún cliaint (scríobh amháin)", + "HEC token (write-only)": "Comhartha HEC (scríobh amháin)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Seol imeachtaí iniúchta ceadaithe ar aghaidh chuig Splunk, Microsoft Sentinel, glacadóir syslog nó crúca gréasáin. Ní iompraíonn teachtaireachtaí ach meiteashonraí glanta: ní fhágann aon luach rúnda, ainm, logáil isteach ná téacs criptithe an freastalaí riamh.", + "%n change waiting to sync": "%n athrú ag fanacht le sioncrónú", + "%n changes waiting to sync": "%n athrú ag fanacht le sioncrónú", + "Changes that could not sync": "Athruithe nárbh fhéidir a shioncrónú", + "Choose a version": "Roghnaigh leagan", + "Copy value": "Cóipeáil an luach", + "Deleted": "Scriosta", + "Discard": "Caith uait", + "Keep my offline change": "Coinnigh m'athrú as líne", + "Keep the server version": "Coinnigh leagan an fhreastalaí", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Is léamh amháin é Keepiq as líne. Níor chas do riarthóir eagarthóireacht as líne air.", + "Let users edit secrets offline": "Lig d'úsáideoirí rúin a chur in eagar as líne", + "Not synced yet": "Gan sioncrónú fós", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Fanann athruithe as líne ar an ngléas, criptithe don úsáideoir, agus sioncrónaítear iad ag an gcéad díghlasáil eile ar líne. Teastaíonn nasc fós le haghaidh comhroinnte, fillteán agus ceangaltán.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "As líne. Fanann eagarthóireacht, bogadh agus scriosadh ar an ngléas seo agus sioncrónaítear iad nuair a bheidh tú ar líne arís. Teastaíonn nasc le haghaidh comhroinnte agus ceangaltán.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "As líne. Fanann d'athruithe ar an ngléas seo agus sioncrónaítear iad nuair a bheidh tú ar líne arís. Sioncrónaithe go deireanach {when}.", + "Open my changes": "Oscail m'athruithe", + "Sharing needs a connection": "Teastaíonn nasc le haghaidh comhroinnte", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "D'athraigh duine éigin an rún seo ar an bhfreastalaí tar éis do chóip as líne a dhéanamh. Roghnaigh cén leagan le coinneáil.", + "Sync or discard your offline changes before you rotate your keys.": "Sioncrónaigh nó caith uait d'athruithe as líne sula n-athraíonn tú d'eochracha.", + "That password did not open your changes.": "Níor oscail an focal faire sin d'athruithe.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Stórálann an léargas as líne rúin chriptithe (ní féidir iad a oscailt ach leis an eochair a dhíorthaítear ó phríomhfhocal faire an úsáideora, díreach mar ar an bhfreastalaí) agus criptíonn sé ainmneacha, URLanna agus ainmneacha fillteán agus iad stóráilte. Is léamh amháin í rochtain as líne mura gceadaíonn tú eagarthóireacht as líne thíos. Múch é seo do ghléasanna nár cheart dóibh dintiúir a thaisceadh riamh; glanann múchadh na taisce atá ann ag an gcéad lódáil eile.", + "The previous vault copy is gone, so these changes cannot be opened.": "Tá an chóip roimhe seo den chiste imithe, mar sin ní féidir na hathruithe seo a oscailt.", + "The server version": "Leagan an fhreastalaí", + "This secret changed while you were offline": "Athraíodh an rún seo agus tú as líne", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Scrios tú an rún seo as líne, ach athraíodh é ar an bhfreastalaí ó shin. Roghnaigh cén leagan le coinneáil.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Athraíodh d'eochracha ar ghléas eile. Cuir isteach do phríomhfhocal faire roimhe seo chun d'athruithe as líne a shioncrónú, nó caith uait iad.", + "Your offline change": "D'athrú as líne", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach é. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais.", + "Bhí iarratas rochtana ar feitheamh ag %n teagmhálaí éigeandála nuair a bhain do mhalartú eochrach iad. Seiceáil cé a d'iarr sula gcuireann tú duine ar bith ar ais." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "Ní féidir %n mhír a léiriú i CXF agus fágfar ar lár í.", + "Ní féidir %n mír a léiriú i CXF agus fágfar ar lár iad.", + "Ní féidir %n mír a léiriú i CXF agus fágfar ar lár iad." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú.", + "Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú.", + "Scaoileadh %n leagan níos sine, mar ní féidir ach an stair is déanaí a aistriú." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Tá %n chóip rúin fós le criptiú agus le comhroinnt.", + "Tá %n cóip rúin fós le criptiú agus le comhroinnt.", + "Tá %n cóip rúin fós le criptiú agus le comhroinnt." + ], + "_%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 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." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh é.", + "Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh iad.", + "Níorbh fhéidir %n rún a dhíchriptiú le do sheaneochair, mar sin níor aistríodh iad." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "Níor aistríodh %n rún.", + "Níor aistríodh %n rún.", + "Níor aistríodh %n rún." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "Tá %n rún criptithe le d'eochair roimhe seo go fóill.", + "Tá %n rún criptithe le d'eochair roimhe seo go fóill.", + "Tá %n rún criptithe le d'eochair roimhe seo go fóill." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é.", + "Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é.", + "Fágadh %n rún ar lár toisc nach bhfuil cóip ag an gcomharba fós — cuir an comharba leis an bhfillteán agus rith arís é." + ], + "_%n secret_::_%n secrets_": [ + "%n rún", + "%n rún", + "%n rún" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leis an cruinneachán a oscailt fad atá sé seo ar siúl.", + "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl.", + "Níl logáil isteach dhá fhachtóir ag %n úsáideoir sa raon fós agus ní féidir leo an cruinneachán a oscailt fad atá sé seo ar siúl." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh", + "Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh", + "Críochnaigh ar aon nós, agus rochtain ar %n rún á cailleadh" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Fuair %n bhall nua rochtain ar fhillteán foirne.", + "Fuair %n bhall nua rochtain ar fhillteán foirne." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua.", + "Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua.", + "Rothlú eochrach críochnaithe. Athchriptíodh %n rún le d'eochair nua." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Scrios cúlghairm an dara sraith %n teagmháil rochtana éigeandála.", + "Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála.", + "Scrios cúlghairm an dara sraith %n teagmhálacha rochtana éigeandála." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.", + "Revoking this suite deleted %n emergency-access contacts." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "feicthe %n uair i sáruithe", + "feicthe %n uair i sáruithe", + "feicthe %n uair i sáruithe" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "comhroinnte le %n rún", + "comhroinnte le %n rún", + "comhroinnte le %n rún" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Tá %n rún sa fillteán seo go díreach.", + "Tá %n rún sa fillteán seo go díreach.", + "Tá %n rún sa fillteán seo go díreach." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n athrú ag fanacht le sioncrónú", + "%n athrú ag fanacht le sioncrónú", + "%n athrú ag fanacht le sioncrónú" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Tá an t-úsáideoir fós sa ghrúpa {groups}, atá ina bhall d'fhillteán foirne. Bain é den ghrúpa nó díchumasaigh an cuntas.", + "Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas.", + "Tá an t-úsáideoir fós sna grúpaí {groups}, atá ina mbaill d'fhillteáin foirne. Bain é de na grúpaí nó díchumasaigh an cuntas." + ], + "Allow approval from another device": "Ceadaigh ceadú ó ghléas eile", + "App": "Aip", + "Approve a new device": "Ceadaigh gléas nua", + "Approve from another device": "Ceadaigh ó ghléas eile", + "Asked at": "Iarrtha ag", + "Check that the new device shows these words:": "Seiceáil go dtaispeánann an gléas nua na focail seo:", + "Denied. If you did not ask, end your other sessions:": "Diúltaithe. Mura ndearna tú an t-iarratas, cuir deireadh le do sheisiúin eile:", + "Device": "Gléas", + "IP address": "Seoladh IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lig d'úsáideoirí brabhsálaí nua a dhíghlasáil trína cheadú ó ghléas ina bhfuil Keepiq díghlasáilte cheana féin.", + "New device approval": "Ceadú gléas nua", + "Nextcloud security settings": "Socruithe slándála Nextcloud", + "Only approve a device you are using right now.": "Ná ceadaigh ach gléas atá á úsáid agat anois.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Oscail Keepiq ar ghléas ina bhfuil sé díghlasáilte agus ceadaigh an ceann seo. Seiceáil go dtaispeánann sé na focail chéanna:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Séalaíonn an gléas a cheadaíonn an eochair díghlasála don ghléas nua. Ní dhéanann an freastalaí ach í a chur ar aghaidh agus ní féidir leis í a oscailt.", + "The master password is not right, or the request has ended.": "Níl an máistirfhocal faire ceart, nó tá deireadh leis an iarratas.", + "The request expired. Ask again or use your master password.": "Tá an t-iarratas imithe in éag. Iarr arís nó úsáid do mháistirfhocal faire.", + "The request was denied.": "Diúltaíodh don iarratas.", + "Too many requests. Try again in an hour or use your master password.": "An iomarca iarratas. Bain triail eile as i gceann uair an chloig nó úsáid do mháistirfhocal faire.", + "Unknown device": "Gléas anaithnid", + "Web app": "Aip ghréasáin", + "A device": "Gléas", + "A new device asks to open your vault": "Iarrann gléas nua do thaisceadán a oscailt", + "%s asks to be approved. Only approve a device you are using right now.": "Iarrann %s go gceadófaí é. Ná ceadaigh ach gléas atá á úsáid agat anois.", + "Access ends on (optional)": "Críochnaíonn rochtain ar (roghnach)", + "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.": "Ní thaispeánfaidh ná ní chóipeálfaidh aipeanna Keepiq an pasfhocal. Is féidir le duine a bhfuil scileanna teicniúla aige é a léamh óna ghléas féin fós. Athraigh é nuair a chríochnaíonn a rochtain.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Is le húsáid amháin an rún seo. Sínigh isteach tríd an síneadh brabhsálaí Keepiq.", + "Until {date}": "Go dtí {date}", + "Use only": "Úsáid amháin", + "Use only (can sign in, cannot view or copy)": "Úsáid amháin (is féidir síniú isteach, ní féidir féachaint air ná é a chóipeáil)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Is féidir leat síniú isteach leis an logáil isteach seo tríd an síneadh brabhsálaí Keepiq. Roghnaigh an t-úinéir gan ligean duit féachaint air ná é a chóipeáil.", + "Your access ends on {date}": "Críochnaíonn do rochtain ar {date}", + "Your access to this secret has ended": "Tá deireadh le do rochtain ar an rún seo", + "Your access to \"%s\" ends tomorrow": "Críochnaíonn do rochtain ar \"%s\" amárach", + "Your access to \"%s\" has ended": "Tá deireadh le do rochtain ar \"%s\"", + "%1$s no longer has access to \"%2$s\"": "Níl rochtain ag %1$s ar \"%2$s\" a thuilleadh", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "Bhí %1$s in ann an pasfhocal seo a fheiceáil. Athraigh é mura cóir go mbeadh a fhios ag %1$s é a thuilleadh.", + "%s could not view this password in Keepiq.": "Ní raibh %s in ann an pasfhocal seo a fheiceáil in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} as {threshold} faomhadh", + "a recovery officer": "oifigeach aisghabhála", + "Account recovery": "Aisghabháil cuntais", + "Approvals needed": "Faomhaí ag teastáil", + "Ask {user} which words they see, by phone or in person. They must be:": "Fiafraigh de {user} cé na focail a fheiceann siad, ar an bhfón nó go pearsanta. Caithfidh siad a bheith:", + "Check again": "Seiceáil arís", + "Create the recovery key": "Cruthaigh an eochair aisghabhála", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Cruthaigh an eochair aisghabhála. Déanann do bhrabhsálaí í agus tugann sé cóip do gach oifigeach nach féidir ach leo féin a oscailt.", + "Decline": "Diúltaigh", + "Enrol in account recovery": "Cláraigh le haghaidh aisghabháil cuntais", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Cláraigh ionas gur féidir le d'eagraíocht cabhrú leat do thaisceadán a fháil ar ais má dhéanann tú dearmad ar do mháistirfhocal faire.", + "Every user is enrolled": "Tá gach úsáideoir cláraithe", + "Finish the recovery in the browser you asked from.": "Críochnaigh an aisghabháil sa bhrabhsálaí ónar iarr tú í.", + "Forgot your master password?": "Ar dhearmad tú do mháistirfhocal faire?", + "Hand the key over": "Tabhair an eochair uait", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lig d'úsáideoirí a rinne dearmad ar a máistirfhocal faire a dtaisceadán a fháil ar ais, faofa ag oifigigh aisghabhála a ainmníonn tú.", + "New master password": "Máistirfhocal faire nua", + "No one is asking to recover their account.": "Níl aon duine ag iarraidh a gcuntas a aisghabháil.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Níl eochair aisghabhála ann fós. Cruthaíonn duine de na hoifigigh í ina socruithe Keepiq.", + "Off": "As", + "Officer {user} has no encryption set up yet.": "Níl criptiú socraithe fós ag an oifigeach {user}.", + "Officers (user IDs, separated by commas)": "Oifigigh (aitheantais úsáideora, scartha le camóga)", + "Policy": "Polasaí", + "Publish this fingerprint internally, so users can check it before they enrol.": "Foilsigh an méarlorg seo go hinmheánach, ionas gur féidir le húsáideoirí é a sheiceáil sula gcláraíonn siad.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Aisghafa le cabhair ó {officer}. Athraigh eochair do thaisceadáin anois i Socruithe, Slándáil: \"Cuireadh mo mháistirfhocal faire i mbaol\".", + "Recovery key fingerprint: {fingerprint}": "Méarlorg na heochrach aisghabhála: {fingerprint}", + "Recovery officer": "Oifigeach aisghabhála", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Cailleann oifigigh bainte a gcóip anois, ach b'fhéidir gur oscail siad í roimhe seo. Iarr ar oifigeach eochair aisghabhála nua a chruthú.", + "Repeat the new master password": "Athscríobh an máistirfhocal faire nua", + "Retire this recovery key": "Scoir an eochair aisghabhála seo", + "Set the new master password": "Socraigh an máistirfhocal faire nua", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Níor eisigh an Keepiq seo an teastas aisghabhála. Ná cláraigh agus inis do do riarthóir.", + "The words match, approve": "Tá na focail ag teacht le chéile, faomh", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tá an t-úsáideoir seo cláraithe le haghaidh aisghabháil cuntais. Coinníonn aisghabháil a rúin; scriosann cúlghairm a gclárú.", + "Users may enrol": "Is féidir le húsáideoirí clárú", + "Withdraw from account recovery": "Tarraing siar ó aisghabháil cuntais", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Tá tú cláraithe le haghaidh aisghabháil cuntais. Méarlorg na heochrach aisghabhála: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Tá tú cláraithe. Má dhéanann tú dearmad ar do mháistirfhocal faire, is féidir le d'eagraíocht cabhrú leat do thaisceadán a fháil ar ais.", + "Your key is back. Choose a new master password.": "Tá d'eochair ar ais. Roghnaigh máistirfhocal faire nua.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Cuireadh d'oifigigh aisghabhála ar an eolas. Léigh na focail seo dóibh nuair a ghlaonn siad ort nó nuair a bhuaileann siad leat:", + "You are now an account recovery officer": "Is oifigeach aisghabhála cuntas thú anois", + "%s asks to recover their account. Compare the words with them before you approve.": "Tá %s ag iarraidh a gcuntas a aisghabháil. Cuir na focail i gcomparáid leo sula bhfaomhann tú.", + "A user": "Úsáideoir", + "Your account recovery request was declined": "Diúltaíodh d'iarratas ar aisghabháil cuntais", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Tá d'aisghabháil cuntais réidh. Oscail Keepiq sa bhrabhsálaí ónar iarr tú í.", + "{user} asks to unlock a new device once. They keep their master password.": "Iarrann {user} gléas nua a dhíghlasáil uair amháin. Coinníonn siad a bpríomhfhocal faire.", + "Ask your organisation instead": "Iarr ar d'eagraíocht ina ionad sin", + "The request ended. Ask again or use your master password.": "Tá an t-iarratas thart. Iarr arís nó úsáid do phríomhfhocal faire.", + "Added by {user}": "Curtha leis ag {user}", + "Editor": "Eagarthóir", + "Manager": "Bainisteoir", + "Role of {member}": "Ról {member}", + "Team folders you manage": "Fillteáin foirne a bhainistíonn tú", + "Viewer": "Breathnóir", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Níl cóip de na rúin seo agat, mar sin níor fhaigh na baill nua iad fós. Is féidir leis an úinéir iad a roinnt: {names}", + "Admin areas": "Réimsí riaracháin", + "Give a group only the parts of Keepiq administration it needs.": "Tabhair do ghrúpa na codanna de riarachán Keepiq a theastaíonn uaidh amháin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Tarmligh réimse amháin nó níos mó chuig grúpa ar leathanach na bpribhléidí riaracháin. Tá gach réimse ag riarthóirí an chásanna.", + "Open administration privileges": "Oscail pribhléidí riaracháin", + "Policies": "Polasaithe", + "Applications and machine access": "Feidhmchláir agus rochtain meaisíní", + "People and offboarding": "Daoine agus imeachtaí", + "Audit and compliance": "Iniúchadh agus comhlíonadh", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "leagan, údarás deimhnithe, ceangaltáin, taisce as líne, seiceáil sceitheadh, cineálacha rún agus cúltacaí", + "master password, organisation password, vault policies, rotation, version history and trash": "máistirfhocal faire, focal faire na heagraíochta, polasaithe an chruinneacháin, rothlú, stair leaganacha agus bruscar", + "application queue, application requests and machine leases": "scuaine feidhmchlár, iarratais feidhmchlár agus léasanna meaisín", + "team offboarding, encryption suites and admin handover": "imeachtaí ón bhfoireann, sraitheanna criptithe agus gabháil seilbhe ag riarthóir", + "audit log, compliance reports, SIEM export and honey alerts": "loga iniúchta, tuarascálacha comhlíonta, easpórtáil SIEM agus foláirimh baoite", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cé mhéad leagan de rún a choinnítear, ar feadh cé chomh fada, agus cé chomh fada a fhanann rúin scriosta sa bhruscar.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Teorainneacha do cheangaltáin chriptithe, curtha i bhfeidhm ar an bhfreastalaí i mbearta criptithe stóráilte.", + "Type the suite ID again to confirm": "Clóscríobh aitheantas na sraithe arís le deimhniú", + "This does not match the suite ID.": "Ní hionann é seo agus aitheantas na sraithe.", + "Confirm with your master password": "Deimhnigh le do phríomhphasfhocal", + "Confirm": "Deimhnigh", + "That master password is not right.": "Níl an príomhphasfhocal sin ceart.", + "You are sharing with someone new. Enter your master password to confirm.": "Tá tú ag comhroinnt le duine nua. Cuir isteach do phríomhphasfhocal le deimhniú.", + "Enter your master password to confirm this share.": "Cuir isteach do phríomhphasfhocal chun an chomhroinnt seo a dheimhniú.", + "Enter your master password to confirm this delegation.": "Cuir isteach do phríomhphasfhocal chun an tarmligean seo a dheimhniú.", + "Approve {member}": "Ceadaigh {member}", + "Recipient": "Faighteoir", + "No vault yet": "Gan vailt fós", + "No matching users": "Níl aon úsáideoirí comhoiriúnacha", + "Partner organisations": "Eagraíochtaí comhpháirtíochta", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Malartaigh rúin le Keepiq eile. Cuireann an dá riarthóir a chéile leis agus déanann siad comparáid idir na méarloirg fréimhe ar an bhfón nó go pearsanta sula sábhálann siad.", + "Federation needs Nextcloud 33 or later.": "Teastaíonn Nextcloud 33 nó níos nuaí don chónaidhm.", + "Your root fingerprint": "Do mhéarlorg fréimhe", + "No partners yet.": "Níl aon chomhpháirtithe fós.", + "Users here may share to this partner": "Is féidir le húsáideoirí anseo comhroinnt leis an gcomhpháirtí seo", + "This partner may share to users here": "Is féidir leis an gcomhpháirtí seo comhroinnt le húsáideoirí anseo", + "Partner address": "Seoladh an chomhpháirtí", + "Check partner": "Seiceáil an comhpháirtí", + "Partner root fingerprint": "Méarlorg fréimhe an chomhpháirtí", + "I compared this fingerprint with the partner's administrator": "Rinne mé comparáid idir an méarlorg seo agus riarthóir an chomhpháirtí", + "Add partner": "Cuir comhpháirtí leis", + "A secret from another organisation": "Rún ó eagraíocht eile", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Chomhroinn %1$s \"%2$s\" leat. Glac leis faoi Isteach ó eagraíochtaí eile.", + "Incoming from other organisations": "Isteach ó eagraíochtaí eile", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Is féidir le daoine in eagraíochtaí comhpháirtíochta rún a chomhroinnt leat. Glac leis chun cóip inléite amháin a choinneáil i do vailt.", + "Nothing shared with you yet": "Níl aon rud comhroinnte leat fós", + "Secrets that people in partner organisations share with you appear here.": "Taispeántar anseo na rúin a chomhroinneann daoine in eagraíochtaí comhpháirtíochta leat.", + "From {sender}": "Ó {sender}", + "Accept": "Glac leis", + "Open in vault": "Oscail sa vailt", + "The other organisation did not hand over the secret. Try again later.": "Níor thug an eagraíocht eile an rún uaithi. Bain triail eile as níos déanaí.", + "Set up your vault before you accept a shared secret.": "Socraigh do vailt sula nglacann tú le rún comhroinnte.", + "Something went wrong. Try again.": "Chuaigh rud éigin mícheart. Bain triail eile as.", + "Waiting for your answer": "Ag fanacht le do fhreagra", + "In your vault, read-only": "I do vailt, inléite amháin", + "Withdrawn by the sender": "Tarraingthe siar ag an seoltóir", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Chomhroinn {sender} é seo ó eagraíocht eile. Is féidir leat é a léamh, ach ní féidir leat é a athrú ná a chomhroinnt.", + "Someone": "Duine éigin", + "Share with someone at another organisation": "Comhroinn le duine in eagraíocht eile", + "Their account at the other organisation": "Cuntas an duine san eagraíocht eile", + "Check account": "Seiceáil an cuntas", + "Certificate fingerprint of {account}": "Méarlorg deimhnithe {account}", + "Compare it with them by phone if you want to be sure.": "Cuir i gcomparáid é leis an duine ar an bhfón más mian leat a bheith cinnte.", + "Shared. {account} can accept it in their own vault.": "Comhroinnte. Is féidir le {account} glacadh leis ina vailt féin.", + "The certificate could not be verified. Nothing was shared.": "Níorbh fhéidir an deimhniú a fhíorú. Níor comhroinneadh aon rud.", + "That organisation is not one of your partners.": "Ní ceann de do chomhpháirtithe í an eagraíocht sin.", + "No one with that account can receive secrets from you.": "Ní féidir le duine ar bith leis an gcuntas sin rúin a fháil uait.", + "The other organisation did not answer. Try again later.": "Níor fhreagair an eagraíocht eile. Bain triail eile as níos déanaí.", + "This secret is already shared with that account.": "Tá an rún seo comhroinnte leis an gcuntas sin cheana.", + "Other organisations": "Eagraíochtaí eile", + "Receive secrets from other organisations": "Faigh rúin ó eagraíochtaí eile", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Is féidir le daoine in eagraíochtaí comhpháirtíochta do chuntas a aimsiú ansin agus rúin a chomhroinnt leat. Glacann tú féin le gach ceann acu.", + "Shared": "Comhroinnte", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Ar sos: d’athraigh a ndeimhniú nó an chomhpháirtíocht. Cúlghair é nó comhroinn arís.", + "Their organisation did not get the last change. Revoke it or share again.": "Ní bhfuair a n-eagraíocht an t-athrú is déanaí. Cúlghair é nó comhroinn arís.", + "Being withdrawn": "Á tharraingt siar", + "Shared with another organisation": "Comhroinnte le heagraíocht eile", + "Change sent to another organisation": "Athrú seolta chuig eagraíocht eile", + "Share with another organisation revoked": "Comhroinnt le heagraíocht eile cúlghairthe", + "Share with another organisation paused": "Comhroinnt le heagraíocht eile ar sos", + "Another organisation did not get a change": "Ní bhfuair eagraíocht eile athrú", + "Secret received from another organisation": "Rún faighte ó eagraíocht eile", + "Secret from another organisation accepted": "Rún ó eagraíocht eile glactha", + "Secret from another organisation declined": "Rún ó eagraíocht eile diúltaithe", + "Copy from another organisation updated": "Cóip ó eagraíocht eile nuashonraithe", + "Copy from another organisation removed": "Cóip ó eagraíocht eile bainte", + "Declined: they removed their copy. Share again if they need it.": "Diúltaithe: bhain an faighteoir a chóip. Comhroinn arís má tá sí ag teastáil.", + "Recipient at another organisation removed their copy": "Bhain faighteoir in eagraíocht eile a chóip", + "Removed the user from %n team folder.": "Baineadh an t-úsáideoir de %n fhillteán foirne.", + "Removed the user from %n team folders.": "Baineadh an t-úsáideoir de %n fillteán foirne.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Baineadh an t-úsáideoir de %n fhillteán foirne.", + "Baineadh an t-úsáideoir de %n fillteán foirne.", + "Baineadh an t-úsáideoir de %n fillteán foirne." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Tháinig cóip athchóirithe ó chomhroinnt atá críochnaithe. Fanann sí inléite amháin.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Níorbh fhéidir teagmháil a dhéanamh leis an eagraíocht a chomhroinn cóip athchóirithe. Fanann an chóip inléite amháin agus ní leanann sí a n-athruithe.", + "Recipient at another organisation restored their copy": "D’athchóirigh faighteoir in eagraíocht eile a chóip" }, "plurals": null } diff --git a/l10n/hr.js b/l10n/hr.js index 3df6cc0c4..2e0a66b69 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Dijeljeno s grupama", + "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.", + "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.", + "Search groups": "Pretraži grupe", + "Failed to share": "Dijeljenje nije uspjelo", + "Columns": "Stupci", + "Column {number}": "Stupac {number}", + "Map one column to Name. Every secret needs a name.": "Povežite jedan stupac s nazivom. Svaka tajna treba naziv.", + "Notes": "Bilješke", + "Do not import": "Ne uvozi", + "Hide this value": "Sakrij ovu vrijednost", + "Show this value": "Prikaži ovu vrijednost", + "Defaults": "Zadano", + "New secrets start as this type, and your secret list opens in this view.": "Nove tajne počinju kao ova vrsta, a tvoj popis tajni otvara se u ovom prikazu.", + "Default item type": "Zadana vrsta stavke", + "Cards": "Kartice", + "Table": "Tablica", + "Could not save your default": "Zadanu vrijednost nije moguće spremiti", + "Recently used": "Nedavno korišteno", + "Opened": "Otvoreno", + "You have not opened any secrets yet": "Još nisi otvorio nijednu tajnu", + "Could not delete the item type.": "Vrstu stavke nije moguće izbrisati.", + "Could not load the item types.": "Vrste stavki nije moguće učitati.", + "Could not save the item type.": "Vrstu stavke nije moguće spremiti.", + "Delete item type": "Izbriši vrstu stavke", + "Edit item type": "Uredi vrstu stavke", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skriveno", + "Item types": "Vrste stavki", + "Move up": "Pomakni gore", + "New item type": "Nova vrsta stavke", + "No item types defined yet.": "Još nema definiranih vrsta stavki.", + "Required": "Obavezno", + "Text": "Tekst", + "This field is required": "Ovo je polje obavezno", + "Web address": "Web-adresa", + "{label} (required)": "{label} (obavezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrisati „{name}”? Tajne ove vrste ostaju čitljive i postaju stavke Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste stavki koje ovdje definiraš svima se prikazuju u dijalogu Nova tajna, s poljima koja odabereš.", + "Secret moved to the trash": "Tajna premještena u smeće", + "Secret restored from the trash": "Tajna vraćena iz smeća", + "Secret deleted for good": "Tajna trajno izbrisana", + "Secret archived": "Tajna arhivirana", + "Secret unarchived": "Tajna vraćena iz arhive", + "Unarchive": "Vrati iz arhive", + "Could not archive the secret": "Tajnu nije moguće arhivirati", + "Could not unarchive the secret": "Tajnu nije moguće vratiti iz arhive", + "Archive {count} secrets": "Arhiviraj tajne: {count}", + "Unarchive {count} secrets": "Vrati iz arhive tajne: {count}", + "Restore {count} secrets": "Vrati tajne: {count}", + "Delete {count} secrets for good": "Trajno izbriši tajne: {count}", + "Done for {ok} of {total} secrets": "Gotovo za {ok} od {total} tajni", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane tajne nestaju s popisa trezora, iz pretraživanja, automatskog ispunjavanja i izvješća o stanju. Zadržavaju dijeljenja. Pronaći ćete ih u Arhivi.", + "These secrets come back to the vault list, search and autofill.": "Ove tajne vraćaju se na popis trezora, u pretraživanje i automatsko ispunjavanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ove tajne vraćaju se na popis trezora. Stara dijeljenja se ne vraćaju, pa ih ponovno podijelite gdje je potrebno.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ovime se brišu tajne zajedno s privicima i poviješću verzija. To se ne može poništiti.", + "Delete for good": "Trajno izbriši", + "Trash": "Smeće", + "The trash is empty": "Smeće je prazno", + "No archived secrets": "Nema arhiviranih tajni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane tajne čekaju ovdje do kraja razdoblja čuvanja, a zatim se trajno brišu.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte tajnu na njezinoj ploči s detaljima kako bi ostala izvan popisa trezora, pretraživanja i automatskog ispunjavanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ograničenja za šifrirane privitke (provode se na poslužitelju u spremljenim šifriranim bajtovima), čuvanje povijesti verzija i koliko dugo izbrisane tajne ostaju u smeću.", + "Days a deleted secret stays in the trash (1 to 365)": "Broj dana koliko izbrisana tajna ostaje u smeću (1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ovime se tajna premješta u smeće i njezina dijeljenja odmah prestaju. Možete je vratiti iz smeća do kraja razdoblja čuvanja: 30 dana, osim ako je administrator to promijenio.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ovime se tajne premještaju u smeće ({count}) i njihova dijeljenja odmah prestaju. Možete ih vratiti iz smeća do kraja razdoblja čuvanja.", + "Remove {name} from favourites": "Ukloni {name} iz favorita", + "Add {name} to favourites": "Dodaj {name} u favorite", + "Could not change the favourite": "Favorit nije bilo moguće promijeniti", + "Remove from favourites": "Ukloni iz favorita", + "Add to favourites": "Dodaj u favorite", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake nisu šifrirane. Administratori poslužitelja mogu ih čitati, kao i nazive mapa.", + "Favourites": "Favoriti", + "Filter by tag": "Filtriraj po oznaci", + "All tags": "Sve oznake", + "Last used": "Zadnje korišteno", + "Tags for {count} secrets": "Oznake za {count} tajni", + "Tag": "Oznaka", + "Remove tag": "Ukloni oznaku", + "Add tag": "Dodaj oznaku", + "Could not change the tags. Try again.": "Oznake nije bilo moguće promijeniti. Pokušajte ponovno.", + "Could not approve the application. It is still in the queue.": "Zahtjev nije moguće odobriti. Još je u redu čekanja.", + "Could not reject the application. It is still in the queue.": "Zahtjev nije moguće odbiti. Još je u redu čekanja.", + "Removed the user from {count} team folders.": "Korisnik je uklonjen iz {count} timskih mapa.", + "Approve a share": "Odobri dijeljenje", + "This approval link is incomplete. Open it again from the notification.": "Ova poveznica za odobrenje je nepotpuna. Otvorite je ponovno iz obavijesti.", + "Deny": "Odbij", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se pridružio grupi s kojom dijelite tajnu. Želite li podijeliti tajnu i s njim?", + "{requester} asks you to share a secret with {user}.": "{requester} traži da podijelite tajnu s korisnikom {user}.", + "Shared. The recipient can now open the secret.": "Podijeljeno. Primatelj sada može otvoriti tajnu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Primatelj još nije postavio Keepiq pa ništa nije podijeljeno. Pokušajte ponovno kada to učini.", + "Could not share the secret. Only its owner can approve this.": "Tajnu nije moguće podijeliti. Ovo može odobriti samo njezin vlasnik.", + "Could not share the secret. Try again.": "Tajnu nije moguće podijeliti. Pokušajte ponovno.", + "Denied. Nothing was shared.": "Odbijeno. Ništa nije podijeljeno.", + "Could not deny the request. Try again.": "Zahtjev nije moguće odbiti. Pokušajte ponovno.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s traži da podijelite tajnu \"%2$s\" s korisnikom %3$s.", + "Expires on (optional)": "Istječe (neobavezno)", + "Hand over to": "Predaj korisniku", + "Choose a recipient": "Odaberite primatelja", + "Hand over temporarily": "Privremeno predaj", + "Expiry rules": "Pravila isteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Postavite koliko dugo smiju trajati zaporke jedne vrste stavke ili u jednoj mapi te kada želite podsjetnik. Kada vrijedi više datuma, računa se najraniji.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Postavio vaš administrator", + "No expiry rules yet.": "Još nema pravila isteka.", + "Applies to": "Primjenjuje se na", + "Item type": "Vrsta stavke", + "Maximum age in days (empty for reminders only)": "Najveća starost u danima (prazno samo za podsjetnike)", + "Remind me this many days before, comma separated": "Podsjeti me ovoliko dana prije, odvojeno zarezima", + "Save rule": "Spremi pravilo", + "An item type": "Vrsta stavke", + "A folder": "Mapa", + "Folder {name}": "Mapa {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Istječe nakon {days} dana", + "Reminders {days} days before": "Podsjetnici {days} dana prije", + "Could not save the expiry rule.": "Pravilo isteka nije moguće spremiti.", + "Could not delete the expiry rule.": "Pravilo isteka nije moguće izbrisati.", + "All statuses": "Svi statusi", + "Compromised": "Kompromitiran", + "Could not load the members.": "Nije moguće učitati članove.", + "Emergency contact": "Kontakt za hitne slučajeve", + "Leaving user": "Korisnik koji odlazi", + "No": "Ne", + "No users match this filter.": "Nijedan korisnik ne odgovara ovom filtru.", + "Not set up": "Nije postavljeno", + "Revoke suite": "Opozovi paket", + "Revoked": "Opozvan", + "Search users": "Pretraži korisnike", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pogledajte koji su korisnici postavili trezor. Pokrenite odjavu ili opozovite paket iz retka.", + "Successor": "Nasljednik", + "Team folders": "Timske mape", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Korisnik je još u grupi {groups}, koja je članica timske mape. Uklonite ga iz grupe ili onemogućite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun.", + "Vault status": "Status trezora", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz u CXF NIJE ŠIFRIRAN. Svaka lozinka i prijava bit će čitljiva kao otvoreni tekst u preuzetoj datoteci. Čuvajte je na sigurnom i izbrišite je odmah nakon uporabe.", + "Root certificate expiring soon": "Korijenski certifikat uskoro istječe", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korijenski certifikat trezora istječe za %1$d dan(a). Obnovite ga prije toga. Obnova ponovno potpisuje svaki paket šifriranja.", + "Compromise recovery aborted": "Oporavak nakon kompromitacije prekinut", + "Key rotation ended by a compromise revoke": "Rotacija ključa završena opozivom zbog kompromitacije", + "Encryption suite revoke refused": "Opoziv paketa šifriranja odbijen", + "Master password proof refused": "Dokaz glavne lozinke odbijen", + "Your current master password": "Vaša trenutna glavna lozinka", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ti kontakti za hitne slučajeve nisu preneseni na vaš novi ključ. Njihov hitni pristup je uklonjen. Ponovno ih dodajte u Hitnom pristupu ako ih još želite.", + "Renew root certificate": "Obnovi korijenski certifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ovo stvara novi korijenski i posrednički certifikat. Svaki aktivni paket šifriranja ponovno se potpisuje. To se ne može poništiti.", + "Renew root": "Obnovi korijen", + "Root renewed. {n} encryption suites signed again.": "Korijen obnovljen. Ponovno potpisanih paketa šifriranja: {n}.", + "Could not renew the root certificate.": "Korijenski certifikat nije moguće obnoviti.", + "Lease policy for this application": "Pravila zakupa za ovu aplikaciju", + "In force now: {default} seconds by default, {max} seconds at most.": "Trenutačno vrijedi: zadano {default} sekundi, najviše {max} sekundi.", + "Leases are not renewable": "Zakupi se ne mogu obnavljati", + "Lease policy saved.": "Pravila zakupa spremljena.", + "Leave a field empty to use the instance value.": "Ostavite polje prazno za korištenje vrijednosti instance.", + "Instance value: {value}": "Vrijednost instance: {value}", + "Renewal": "Obnavljanje", + "Use the instance value ({value})": "Koristi vrijednost instance ({value})", + "Allowed": "Dopušteno", + "Not allowed": "Nije dopušteno", + "Save lease policy": "Spremi pravila zakupa", + "Only an administrator can change this policy.": "Samo administrator može promijeniti ova pravila.", + "Could not save the lease policy.": "Pravila zakupa nije moguće spremiti.", + "{member} got access from {confirmer}.": "{member} je dobio pristup od {confirmer}.", + "Automatically confirm new team folder members": "Automatski potvrdi nove članove timskih mapa", + "Gave %n new member access to a team folder.": "%n novi član dobio je pristup timskoj mapi.", + "Gave %n new members access to a team folder.": "Novi članovi (%n) dobili su pristup timskoj mapi.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novim članovima pristup bez čekanja vlasnika mape.", + "New team folder members": "Novi članovi timskih mapa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlasnik ili član s pravom pisanja potvrđuje ih iz otvorenog trezora. Keepiq nikad ne dešifrira na poslužitelju.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeka se da član s pravom pisanja otvori Keepiq. Možete i odmah dijeliti.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dio odgovora na kompromitaciju nije uspio ({failed} korak(a)). Provjerite zapisnik poslužitelja, a zatim ponovno opozovite paket da biste ga dovršili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Time je opozvan i paket {suite} te završena migracija ključeva {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Revoking the second suite deleted %n emergency-access contacts.": "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "A suite revoked as compromised cannot be reinstated.": "Paket opozvan kao kompromitiran ne može se vratiti.", + "Archives to keep": "Arhive za čuvanje", + "Back up every vault automatically": "Automatski sigurnosno kopiraj svaki trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sigurnosno kopirajte svaki trezor prema rasporedu. Arhive sadrže samo šifrirani tekst i vraćaju se s occ.", + "Back up now": "Kopiraj sada", + "Backup public key (PEM, optional)": "Javni ključ sigurnosne kopije (PEM, neobavezno)", + "Backup requested for the next cron run": "Kopija zatražena za sljedeće pokretanje crona", + "Encrypted": "Šifrirano", + "Every (hours)": "Svakih (sati)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} nije uspjela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspjela.", + "No archives yet.": "Još nema arhiva.", + "Size": "Veličina", + "Vault backups": "Sigurnosne kopije trezora", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se svaka arhiva šifrira za njega. Privatni ključ čuvajte izvan ovog poslužitelja: trebate ga za provjeru ili vraćanje.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervni kodovi se ne računaju. Ako se vaši korisnici prijavljuju preko pružatelja identiteta s vlastitim drugim faktorom, izostavite njihove grupe.", + "Block personal vault export": "Blokiraj izvoz osobnog trezora", + "Keep work logins in team folders": "Čuvaj poslovne prijave u timskim mapama", + "Move to a team folder": "Premjesti u timsku mapu", + "Not in a team folder": "Nije u timskoj mapi", + "Only for these groups (empty is everyone)": "Samo za ove grupe (prazno znači svi)", + "Require two-factor login before the vault opens": "Zahtijevaj prijavu u dva koraka prije otvaranja trezora", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za svaki trezor. Svako vrijedi za sve ili samo za grupe koje odaberete.", + "Secret types that belong in a team folder": "Vrste tajni koje pripadaju u timsku mapu", + "Set up two-factor login": "Postavi prijavu u dva koraka", + "Team folder you can write to": "Timska mapa u koju možete pisati", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Korisnici ne mogu preuzeti sigurnosnu kopiju, CSV ni datoteku za prijenos. Njihov paket osobnih podataka ostaje dostupan.", + "Users cannot save these secret types in a personal folder.": "Korisnici ne mogu spremiti ove vrste tajni u osobnu mapu.", + "Vault policies": "Pravila trezora", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dopušta izvoz vašeg osobnog trezora. Vaš paket osobnih podataka u postavkama ostaje dostupan.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija čuva ove tajne u timskoj mapi. Premjestite svaku u timsku mapu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija čuva ovu vrstu tajne u timskoj mapi. Odaberite jednu od svojih timskih mapa ili onu u koju možete pisati.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahtijeva prijavu u dva koraka prije nego što možete otvoriti svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Korisnici biraju koliko dugo proširenje ostaje otključano tijekom neaktivnosti. Vi postavljate najdulje vrijeme koje smiju odabrati.", + "Longest idle time before the extension locks": "Najdulje vrijeme neaktivnosti prije zaključavanja proširenja", + "1 minute": "1 minuta", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 sat", + "4 hours": "4 sata", + "Connector": "Konektor", + "Directory (tenant) ID": "ID direktorija (zakupca)", + "Application (client) ID": "ID aplikacije (klijenta)", + "Data collection rule immutable ID": "Nepromjenjivi ID pravila prikupljanja podataka", + "Stream name": "Naziv toka", + "Splunk index (optional)": "Splunk indeks (neobavezno)", + "Sourcetype (optional)": "Sourcetype (neobavezno)", + "Leave blank to keep the current one": "Ostavite prazno da zadržite trenutnu vrijednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF preko sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Krajnja točka prikupljanja podataka (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajna klijenta (samo pisanje)", + "HEC token (write-only)": "HEC token (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Prosljeđujte dopuštene revizijske događaje u Splunk, Microsoft Sentinel, syslog prijamnik ili webhook. Poruke sadrže samo očišćene metapodatke: nijedna tajna vrijednost, ime, prijava ili šifrirani tekst nikada ne napušta poslužitelj.", + "%n change waiting to sync": "%n promjena čeka sinkronizaciju", + "%n changes waiting to sync": "%n promjena čeka sinkronizaciju", + "Changes that could not sync": "Promjene koje se nisu mogle sinkronizirati", + "Choose a version": "Odaberite verziju", + "Copy value": "Kopiraj vrijednost", + "Deleted": "Izbrisano", + "Discard": "Odbaci", + "Keep my offline change": "Zadrži moju izvanmrežnu promjenu", + "Keep the server version": "Zadrži verziju s poslužitelja", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je izvan mreže samo za čitanje. Administrator nije uključio uređivanje izvan mreže.", + "Let users edit secrets offline": "Dopusti korisnicima uređivanje tajni izvan mreže", + "Not synced yet": "Još nije sinkronizirano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Izvanmrežne promjene ostaju na uređaju, šifrirane za korisnika, i sinkroniziraju se pri sljedećem otključavanju na mreži. Dijeljenje, mape i privici i dalje trebaju vezu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Izvan mreže. Uređivanja, premještanja i brisanja ostaju na ovom uređaju i sinkroniziraju se kad ponovno budete na mreži. Dijeljenje i privici trebaju vezu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Izvan mreže. Vaše promjene ostaju na ovom uređaju i sinkroniziraju se kad ponovno budete na mreži. Zadnja sinkronizacija {when}.", + "Open my changes": "Otvori moje promjene", + "Sharing needs a connection": "Dijeljenje treba vezu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Netko je promijenio ovu tajnu na poslužitelju nakon što je napravljena vaša izvanmrežna kopija. Odaberite koju verziju zadržati.", + "Sync or discard your offline changes before you rotate your keys.": "Sinkronizirajte ili odbacite izvanmrežne promjene prije zamjene ključeva.", + "That password did not open your changes.": "Ta lozinka nije otvorila vaše promjene.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Izvanmrežna snimka sprema šifrirane tajne (otvaraju se samo ključem izvedenim iz glavne lozinke korisnika, točno kao na poslužitelju) i šifrira nazive, URL-ove i nazive mapa u pohrani. Izvanmrežni pristup je samo za čitanje, osim ako ispod dopustite uređivanje izvan mreže. Isključite ovo za uređaje koji nikad ne smiju predmemorirati vjerodajnice; isključivanje briše postojeće predmemorije pri sljedećem učitavanju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prethodne kopije trezora više nema, pa se ove promjene ne mogu otvoriti.", + "The server version": "Verzija s poslužitelja", + "This secret changed while you were offline": "Ova tajna promijenila se dok ste bili izvan mreže", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ovu ste tajnu izbrisali izvan mreže, ali je u međuvremenu promijenjena na poslužitelju. Odaberite koju verziju zadržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključevi promijenjeni su na drugom uređaju. Unesite prethodnu glavnu lozinku za sinkronizaciju izvanmrežnih promjena ili ih odbacite.", + "Your offline change": "Vaša izvanmrežna promjena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.","Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.","Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n stavku nije moguće prikazati u CXF-u i bit će preskočena.","%n stavki nije moguće prikazati u CXF-u i bit će preskočene.","%n stavki nije moguće prikazati u CXF-u i bit će preskočene."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n starija verzija je odbačena jer se može prenijeti samo nedavna povijest.","%n starijih verzija je odbačeno jer se može prenijeti samo nedavna povijest.","%n starijih verzija je odbačeno jer se može prenijeti samo nedavna povijest."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopiju tajne još treba šifrirati i podijeliti.","%n kopija tajni još treba šifrirati i podijeliti.","%n kopija tajni još treba šifrirati i podijeliti."], + "_%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 tajna nije mogla biti dešifrirana i nije u ovom izvozu.","%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu.","%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n tajna se nije mogla dešifrirati vašim starim ključem, pa nije migrirana.","%n tajni se nije moglo dešifrirati vašim starim ključem, pa nisu migrirane.","%n tajni se nije moglo dešifrirati vašim starim ključem, pa nisu migrirane."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n tajna nije migrirana.","%n tajni nije migrirano.","%n tajni nije migrirano."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n tajna je još šifrirana vašim prethodnim ključem.","%n tajni je još šifrirano vašim prethodnim ključem.","%n tajni je još šifrirano vašim prethodnim ključem."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n tajna je preskočena jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.","%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.","%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno."], + "_%n secret_::_%n secrets_": ["%n tajna","%n tajne","%n tajne"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.","Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.","Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Ipak završi, uz gubitak pristupa %n tajni","Ipak završi, uz gubitak pristupa %n tajni","Ipak završi, uz gubitak pristupa %n tajni"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n novi član dobio je pristup timskoj mapi.","Novi članovi (%n) dobili su pristup timskoj mapi.","Novi članovi (%n) dobili su pristup timskoj mapi."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotacija ključa je završena. %n tajna je ponovno šifrirana vašim novim ključem.","Rotacija ključa je završena. %n tajni je ponovno šifrirano vašim novim ključem.","Rotacija ključa je završena. %n tajni je ponovno šifrirano vašim novim ključem."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.","Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.","Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.","Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.","Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["zabilježeno %n put u procurjelim podacima","zabilježeno %n puta u procurjelim podacima","zabilježeno %n puta u procurjelim podacima"], + "_shared with %n secret_::_shared with %n secrets_": ["podijeljeno s %n tajnom","podijeljeno s %n tajni","podijeljeno s %n tajni"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ova mapa izravno sadrži %n tajnu.","Ova mapa izravno sadrži %n tajne.","Ova mapa izravno sadrži %n tajne."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n promjena čeka sinkronizaciju","%n promjena čeka sinkronizaciju","%n promjena čeka sinkronizaciju"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Korisnik je još u grupi {groups}, koja je članica timske mape. Uklonite ga iz grupe ili onemogućite račun.","Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun.","Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun."], + "Allow approval from another device": "Dopusti odobrenje s drugog uređaja", + "App": "Aplikacija", + "Approve a new device": "Odobri novi uređaj", + "Approve from another device": "Odobri s drugog uređaja", + "Asked at": "Zatraženo u", + "Check that the new device shows these words:": "Provjerite prikazuje li novi uređaj ove riječi:", + "Denied. If you did not ask, end your other sessions:": "Odbijeno. Ako to niste zatražili, završite ostale sesije:", + "Device": "Uređaj", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Omogući korisnicima otključavanje novog preglednika odobrenjem s uređaja na kojem je Keepiq već otključan.", + "New device approval": "Odobrenje novih uređaja", + "Nextcloud security settings": "Sigurnosne postavke Nextclouda", + "Only approve a device you are using right now.": "Odobrite samo uređaj koji upravo koristite.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorite Keepiq na uređaju na kojem je otključan i odobrite ovaj uređaj. Provjerite prikazuje li iste riječi:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Uređaj koji odobrava zapečaćuje ključ za otključavanje za novi uređaj. Poslužitelj ga samo prosljeđuje i ne može ga otvoriti.", + "The master password is not right, or the request has ended.": "Glavna lozinka nije točna ili je zahtjev završio.", + "The request expired. Ask again or use your master password.": "Zahtjev je istekao. Zatražite ponovno ili upotrijebite glavnu lozinku.", + "The request was denied.": "Zahtjev je odbijen.", + "Too many requests. Try again in an hour or use your master password.": "Previše zahtjeva. Pokušajte ponovno za sat vremena ili upotrijebite glavnu lozinku.", + "Unknown device": "Nepoznati uređaj", + "Web app": "Web aplikacija", + "A device": "Uređaj", + "A new device asks to open your vault": "Novi uređaj traži otvaranje vašeg trezora", + "%s asks to be approved. Only approve a device you are using right now.": "%s traži odobrenje. Odobrite samo uređaj koji upravo koristite.", + "Access ends on (optional)": "Pristup završava (neobavezno)", + "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.": "Aplikacije Keepiq neće prikazati ni kopirati lozinku. Netko s tehničkim znanjem i dalje je može pročitati na vlastitom uređaju. Promijenite je kada pristup završi.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ova tajna služi samo za korištenje. Prijavite se putem proširenja preglednika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo korištenje", + "Use only (can sign in, cannot view or copy)": "Samo korištenje (može se prijaviti, ne može vidjeti ni kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ovom se prijavom možete prijaviti putem proširenja preglednika Keepiq. Vlasnik je odlučio da je ne možete vidjeti ni kopirati.", + "Your access ends on {date}": "Vaš pristup završava {date}", + "Your access to this secret has ended": "Vaš pristup ovoj tajni je završio", + "Your access to \"%s\" ends tomorrow": "Vaš pristup stavci „%s” završava sutra", + "Your access to \"%s\" has ended": "Vaš pristup stavci „%s” je završio", + "%1$s no longer has access to \"%2$s\"": "%1$s više nema pristup stavci „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je mogao/la vidjeti ovu lozinku. Promijenite je ako je %1$s više ne bi trebao/la znati.", + "%s could not view this password in Keepiq.": "%s nije mogao/la vidjeti ovu lozinku u Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobrenja", + "a recovery officer": "službenik za oporavak", + "Account recovery": "Oporavak računa", + "Approvals needed": "Potrebna odobrenja", + "Ask {user} which words they see, by phone or in person. They must be:": "Pitajte korisnika {user} koje riječi vidi, telefonom ili osobno. Moraju biti:", + "Check again": "Provjeri ponovno", + "Create the recovery key": "Stvori ključ za oporavak", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Stvorite ključ za oporavak. Vaš preglednik ga izrađuje i svakom službeniku daje kopiju koju samo on može otvoriti.", + "Decline": "Odbij", + "Enrol in account recovery": "Prijavite se za oporavak računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se kako bi vam organizacija mogla pomoći vratiti trezor ako zaboravite glavnu zaporku.", + "Every user is enrolled": "Svi korisnici su prijavljeni", + "Finish the recovery in the browser you asked from.": "Dovršite oporavak u pregledniku iz kojeg ste ga zatražili.", + "Forgot your master password?": "Zaboravili ste glavnu zaporku?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Omogućite korisnicima koji su zaboravili glavnu zaporku da vrate trezor, uz odobrenje službenika za oporavak koje imenujete.", + "New master password": "Nova glavna zaporka", + "No one is asking to recover their account.": "Nitko ne traži oporavak računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Još nema ključa za oporavak. Jedan od službenika ga stvara u svojim postavkama Keepiqa.", + "Off": "Isključeno", + "Officer {user} has no encryption set up yet.": "Službenik {user} još nema postavljeno šifriranje.", + "Officers (user IDs, separated by commas)": "Službenici (korisnički ID-ovi, odvojeni zarezima)", + "Policy": "Pravila", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ovaj otisak interno kako bi ga korisnici mogli provjeriti prije prijave.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Oporavljeno uz pomoć korisnika {officer}. Sada promijenite ključ trezora u Postavkama, Sigurnost: \"Moja glavna zaporka je kompromitirana\".", + "Recovery key fingerprint: {fingerprint}": "Otisak ključa za oporavak: {fingerprint}", + "Recovery officer": "Službenik za oporavak", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Uklonjeni službenici sada gube svoju kopiju, ali su je možda prije otvorili. Neka službenik stvori novi ključ za oporavak.", + "Repeat the new master password": "Ponovite novu glavnu zaporku", + "Retire this recovery key": "Povuci ovaj ključ za oporavak", + "Set the new master password": "Postavi novu glavnu zaporku", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikat za oporavak nije izdao ovaj Keepiq. Nemojte se prijaviti i obavijestite administratora.", + "The words match, approve": "Riječi se podudaraju, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ovaj korisnik je prijavljen za oporavak računa. Oporavak čuva njegove tajne; opoziv briše njegovu prijavu.", + "Users may enrol": "Korisnici se mogu prijaviti", + "Withdraw from account recovery": "Odjavi se s oporavka računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste za oporavak računa. Otisak ključa za oporavak: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Ako zaboravite glavnu zaporku, organizacija vam može pomoći vratiti trezor.", + "Your key is back. Choose a new master password.": "Ključ je vraćen. Odaberite novu glavnu zaporku.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši službenici za oporavak su obaviješteni. Pročitajte im ove riječi kad vas nazovu ili se nađete:", + "You are now an account recovery officer": "Sada ste službenik za oporavak računa", + "%s asks to recover their account. Compare the words with them before you approve.": "%s traži oporavak računa. Usporedite riječi s njim prije odobravanja.", + "A user": "Korisnik", + "Your account recovery request was declined": "Vaš zahtjev za oporavak računa je odbijen", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Oporavak računa je spreman. Otvorite Keepiq u pregledniku iz kojeg ste ga zatražili.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} traži jednokratno otključavanje novog uređaja. Glavna lozinka ostaje ista.", + "Ask your organisation instead": "Umjesto toga pitajte svoju organizaciju", + "The request ended. Ask again or use your master password.": "Zahtjev je završio. Zatražite ponovno ili upotrijebite glavnu lozinku.", + "Added by {user}": "Dodao/la {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Uloga korisnika {member}", + "Team folders you manage": "Timske mape kojima upravljate", + "Viewer": "Preglednik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemate kopiju ovih tajni, pa ih novi članovi još nisu dobili. Vlasnik ih može podijeliti: {names}", + "Admin areas": "Područja administracije", + "Give a group only the parts of Keepiq administration it needs.": "Dajte grupi samo one dijelove administracije Keepiqa koji su joj potrebni.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegirajte jedno ili više područja grupi na stranici administratorskih ovlasti. Administratori instance imaju svako područje.", + "Open administration privileges": "Otvori administratorske ovlasti", + "Policies": "Pravila", + "Applications and machine access": "Aplikacije i pristup strojeva", + "People and offboarding": "Ljudi i odlasci", + "Audit and compliance": "Revizija i usklađenost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzija, certifikacijsko tijelo, privici, izvanmrežna predmemorija, provjera curenja, vrste tajni i sigurnosne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavna lozinka, lozinka organizacije, pravila trezora, rotacija, povijest verzija i smeće", + "application queue, application requests and machine leases": "red aplikacija, zahtjevi aplikacija i najmovi strojeva", + "team offboarding, encryption suites and admin handover": "odlasci iz tima, paketi šifriranja i preuzimanje od strane administratora", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski zapisnik, izvješća o usklađenosti, SIEM izvoz i upozorenja mamaca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko se verzija tajne čuva, koliko dugo, i koliko dugo izbrisane tajne ostaju u smeću.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ograničenja za šifrirane privitke, koja poslužitelj primjenjuje na spremljene šifrirane bajtove.", + "Type the suite ID again to confirm": "Ponovno upišite ID paketa za potvrdu", + "This does not match the suite ID.": "Ovo se ne podudara s ID-om paketa.", + "Confirm with your master password": "Potvrdite glavnom lozinkom", + "Confirm": "Potvrdi", + "That master password is not right.": "Ta glavna lozinka nije ispravna.", + "You are sharing with someone new. Enter your master password to confirm.": "Dijelite s novom osobom. Unesite glavnu lozinku za potvrdu.", + "Enter your master password to confirm this share.": "Unesite glavnu lozinku za potvrdu ovog dijeljenja.", + "Enter your master password to confirm this delegation.": "Unesite glavnu lozinku za potvrdu ovog delegiranja.", + "Approve {member}": "Odobri {member}", + "Recipient": "Primatelj", + "No vault yet": "Još nema trezora", + "No matching users": "Nema odgovarajućih korisnika", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Razmjenjujte tajne s drugim Keepiqom. Oba administratora dodaju jedan drugoga i prije spremanja usporede korijenske otiske telefonom ili osobno.", + "Federation needs Nextcloud 33 or later.": "Federacija zahtijeva Nextcloud 33 ili noviji.", + "Your root fingerprint": "Vaš korijenski otisak", + "No partners yet.": "Još nema partnera.", + "Users here may share to this partner": "Korisnici ovdje smiju dijeliti s ovim partnerom", + "This partner may share to users here": "Ovaj partner smije dijeliti s korisnicima ovdje", + "Partner address": "Adresa partnera", + "Check partner": "Provjeri partnera", + "Partner root fingerprint": "Korijenski otisak partnera", + "I compared this fingerprint with the partner's administrator": "Usporedio sam ovaj otisak s administratorom partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Tajna iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s dijeli s vama \"%2$s\". Prihvatite je u odjeljku Dolazno iz drugih organizacija.", + "Incoming from other organisations": "Dolazno iz drugih organizacija", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osobe u partnerskim organizacijama mogu s vama dijeliti tajnu. Prihvatite je kako biste zadržali kopiju samo za čitanje u svom trezoru.", + "Nothing shared with you yet": "Još ništa nije dijeljeno s vama", + "Secrets that people in partner organisations share with you appear here.": "Ovdje se prikazuju tajne koje s vama dijele osobe u partnerskim organizacijama.", + "From {sender}": "Od {sender}", + "Accept": "Prihvati", + "Open in vault": "Otvori u trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija nije predala tajnu. Pokušajte ponovno kasnije.", + "Set up your vault before you accept a shared secret.": "Postavite svoj trezor prije nego što prihvatite dijeljenu tajnu.", + "Something went wrong. Try again.": "Nešto je pošlo po zlu. Pokušajte ponovno.", + "Waiting for your answer": "Čeka vaš odgovor", + "In your vault, read-only": "U vašem trezoru, samo za čitanje", + "Withdrawn by the sender": "Pošiljatelj je povukao", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} dijeli ovo iz druge organizacije. Možete to čitati, ali ne i mijenjati ili dijeliti.", + "Someone": "Netko", + "Share with someone at another organisation": "Dijeli s nekim iz druge organizacije", + "Their account at the other organisation": "Račun te osobe u drugoj organizaciji", + "Check account": "Provjeri račun", + "Certificate fingerprint of {account}": "Otisak certifikata za {account}", + "Compare it with them by phone if you want to be sure.": "Usporedite ga s tom osobom telefonom ako želite biti sigurni.", + "Shared. {account} can accept it in their own vault.": "Podijeljeno. {account} to može prihvatiti u svom trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikat nije moguće provjeriti. Ništa nije podijeljeno.", + "That organisation is not one of your partners.": "Ta organizacija nije jedan od vaših partnera.", + "No one with that account can receive secrets from you.": "Nitko s tim računom ne može primati tajne od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija nije odgovorila. Pokušajte ponovno kasnije.", + "This secret is already shared with that account.": "Ova je tajna već podijeljena s tim računom.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Primanje tajni iz drugih organizacija", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osobe u partnerskim organizacijama tada mogu pronaći vaš račun i dijeliti tajne s vama. Svaku od njih prihvaćate sami.", + "Shared": "Podijeljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauzirano: promijenio se njihov certifikat ili partnerstvo. Opozovite ili podijelite ponovno.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija nije dobila posljednju promjenu. Opozovite ili podijelite ponovno.", + "Being withdrawn": "Povlači se", + "Shared with another organisation": "Podijeljeno s drugom organizacijom", + "Change sent to another organisation": "Promjena poslana drugoj organizaciji", + "Share with another organisation revoked": "Dijeljenje s drugom organizacijom ukinuto", + "Share with another organisation paused": "Dijeljenje s drugom organizacijom pauzirano", + "Another organisation did not get a change": "Druga organizacija nije dobila promjenu", + "Secret received from another organisation": "Tajna primljena iz druge organizacije", + "Secret from another organisation accepted": "Tajna iz druge organizacije prihvaćena", + "Secret from another organisation declined": "Tajna iz druge organizacije odbijena", + "Copy from another organisation updated": "Kopija iz druge organizacije ažurirana", + "Copy from another organisation removed": "Kopija iz druge organizacije uklonjena", + "Declined: they removed their copy. Share again if they need it.": "Odbijeno: primatelj je uklonio svoju kopiju. Podijelite ponovno ako mu treba.", + "Recipient at another organisation removed their copy": "Primatelj iz druge organizacije uklonio je svoju kopiju", + "Removed the user from %n team folder.": "Korisnik je uklonjen iz %n timske mape.", + "Removed the user from %n team folders.": "Korisnik je uklonjen iz %n timskih mapa.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Korisnik je uklonjen iz %n timske mape.","Korisnik je uklonjen iz %n timskih mapa.","Korisnik je uklonjen iz %n timskih mapa."], + "A restored copy came from a share that has ended. It stays read-only.": "Vraćena kopija potječe iz dijeljenja koje je završilo. Ostaje samo za čitanje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacija koja je podijelila vraćenu kopiju nije dostupna. Kopija ostaje samo za čitanje i ne prati njihove promjene.", + "Recipient at another organisation restored their copy": "Primatelj iz druge organizacije vratio je svoju kopiju" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/hr.json b/l10n/hr.json index 95ac3d5da..503e4b4a7 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Dijeljeno s grupama", + "Not shared with any group yet.": "Još nije dijeljeno ni s jednom grupom.", + "Revoke the share with {group}": "Opozovi dijeljenje s grupom {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Dijeljeno s grupom {group}: {received} članova je to primilo, {skipped} nije jer još nisu postavili šifriranje.", + "Search groups": "Pretraži grupe", + "Failed to share": "Dijeljenje nije uspjelo", + "Columns": "Stupci", + "Column {number}": "Stupac {number}", + "Map one column to Name. Every secret needs a name.": "Povežite jedan stupac s nazivom. Svaka tajna treba naziv.", + "Notes": "Bilješke", + "Do not import": "Ne uvozi", + "Hide this value": "Sakrij ovu vrijednost", + "Show this value": "Prikaži ovu vrijednost", + "Defaults": "Zadano", + "New secrets start as this type, and your secret list opens in this view.": "Nove tajne počinju kao ova vrsta, a tvoj popis tajni otvara se u ovom prikazu.", + "Default item type": "Zadana vrsta stavke", + "Cards": "Kartice", + "Table": "Tablica", + "Could not save your default": "Zadanu vrijednost nije moguće spremiti", + "Recently used": "Nedavno korišteno", + "Opened": "Otvoreno", + "You have not opened any secrets yet": "Još nisi otvorio nijednu tajnu", + "Could not delete the item type.": "Vrstu stavke nije moguće izbrisati.", + "Could not load the item types.": "Vrste stavki nije moguće učitati.", + "Could not save the item type.": "Vrstu stavke nije moguće spremiti.", + "Delete item type": "Izbriši vrstu stavke", + "Edit item type": "Uredi vrstu stavke", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skriveno", + "Item types": "Vrste stavki", + "Move up": "Pomakni gore", + "New item type": "Nova vrsta stavke", + "No item types defined yet.": "Još nema definiranih vrsta stavki.", + "Required": "Obavezno", + "Text": "Tekst", + "This field is required": "Ovo je polje obavezno", + "Web address": "Web-adresa", + "{label} (required)": "{label} (obavezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrisati „{name}”? Tajne ove vrste ostaju čitljive i postaju stavke Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste stavki koje ovdje definiraš svima se prikazuju u dijalogu Nova tajna, s poljima koja odabereš.", + "Secret moved to the trash": "Tajna premještena u smeće", + "Secret restored from the trash": "Tajna vraćena iz smeća", + "Secret deleted for good": "Tajna trajno izbrisana", + "Secret archived": "Tajna arhivirana", + "Secret unarchived": "Tajna vraćena iz arhive", + "Unarchive": "Vrati iz arhive", + "Could not archive the secret": "Tajnu nije moguće arhivirati", + "Could not unarchive the secret": "Tajnu nije moguće vratiti iz arhive", + "Archive {count} secrets": "Arhiviraj tajne: {count}", + "Unarchive {count} secrets": "Vrati iz arhive tajne: {count}", + "Restore {count} secrets": "Vrati tajne: {count}", + "Delete {count} secrets for good": "Trajno izbriši tajne: {count}", + "Done for {ok} of {total} secrets": "Gotovo za {ok} od {total} tajni", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane tajne nestaju s popisa trezora, iz pretraživanja, automatskog ispunjavanja i izvješća o stanju. Zadržavaju dijeljenja. Pronaći ćete ih u Arhivi.", + "These secrets come back to the vault list, search and autofill.": "Ove tajne vraćaju se na popis trezora, u pretraživanje i automatsko ispunjavanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ove tajne vraćaju se na popis trezora. Stara dijeljenja se ne vraćaju, pa ih ponovno podijelite gdje je potrebno.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ovime se brišu tajne zajedno s privicima i poviješću verzija. To se ne može poništiti.", + "Delete for good": "Trajno izbriši", + "Trash": "Smeće", + "The trash is empty": "Smeće je prazno", + "No archived secrets": "Nema arhiviranih tajni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane tajne čekaju ovdje do kraja razdoblja čuvanja, a zatim se trajno brišu.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte tajnu na njezinoj ploči s detaljima kako bi ostala izvan popisa trezora, pretraživanja i automatskog ispunjavanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ograničenja za šifrirane privitke (provode se na poslužitelju u spremljenim šifriranim bajtovima), čuvanje povijesti verzija i koliko dugo izbrisane tajne ostaju u smeću.", + "Days a deleted secret stays in the trash (1 to 365)": "Broj dana koliko izbrisana tajna ostaje u smeću (1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ovime se tajna premješta u smeće i njezina dijeljenja odmah prestaju. Možete je vratiti iz smeća do kraja razdoblja čuvanja: 30 dana, osim ako je administrator to promijenio.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ovime se tajne premještaju u smeće ({count}) i njihova dijeljenja odmah prestaju. Možete ih vratiti iz smeća do kraja razdoblja čuvanja.", + "Remove {name} from favourites": "Ukloni {name} iz favorita", + "Add {name} to favourites": "Dodaj {name} u favorite", + "Could not change the favourite": "Favorit nije bilo moguće promijeniti", + "Remove from favourites": "Ukloni iz favorita", + "Add to favourites": "Dodaj u favorite", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake nisu šifrirane. Administratori poslužitelja mogu ih čitati, kao i nazive mapa.", + "Favourites": "Favoriti", + "Filter by tag": "Filtriraj po oznaci", + "All tags": "Sve oznake", + "Last used": "Zadnje korišteno", + "Tags for {count} secrets": "Oznake za {count} tajni", + "Tag": "Oznaka", + "Remove tag": "Ukloni oznaku", + "Add tag": "Dodaj oznaku", + "Could not change the tags. Try again.": "Oznake nije bilo moguće promijeniti. Pokušajte ponovno.", + "Could not approve the application. It is still in the queue.": "Zahtjev nije moguće odobriti. Još je u redu čekanja.", + "Could not reject the application. It is still in the queue.": "Zahtjev nije moguće odbiti. Još je u redu čekanja.", + "Removed the user from {count} team folders.": "Korisnik je uklonjen iz {count} timskih mapa.", + "Approve a share": "Odobri dijeljenje", + "This approval link is incomplete. Open it again from the notification.": "Ova poveznica za odobrenje je nepotpuna. Otvorite je ponovno iz obavijesti.", + "Deny": "Odbij", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se pridružio grupi s kojom dijelite tajnu. Želite li podijeliti tajnu i s njim?", + "{requester} asks you to share a secret with {user}.": "{requester} traži da podijelite tajnu s korisnikom {user}.", + "Shared. The recipient can now open the secret.": "Podijeljeno. Primatelj sada može otvoriti tajnu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Primatelj još nije postavio Keepiq pa ništa nije podijeljeno. Pokušajte ponovno kada to učini.", + "Could not share the secret. Only its owner can approve this.": "Tajnu nije moguće podijeliti. Ovo može odobriti samo njezin vlasnik.", + "Could not share the secret. Try again.": "Tajnu nije moguće podijeliti. Pokušajte ponovno.", + "Denied. Nothing was shared.": "Odbijeno. Ništa nije podijeljeno.", + "Could not deny the request. Try again.": "Zahtjev nije moguće odbiti. Pokušajte ponovno.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s traži da podijelite tajnu \"%2$s\" s korisnikom %3$s.", + "Expires on (optional)": "Istječe (neobavezno)", + "Hand over to": "Predaj korisniku", + "Choose a recipient": "Odaberite primatelja", + "Hand over temporarily": "Privremeno predaj", + "Expiry rules": "Pravila isteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Postavite koliko dugo smiju trajati zaporke jedne vrste stavke ili u jednoj mapi te kada želite podsjetnik. Kada vrijedi više datuma, računa se najraniji.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Postavio vaš administrator", + "No expiry rules yet.": "Još nema pravila isteka.", + "Applies to": "Primjenjuje se na", + "Item type": "Vrsta stavke", + "Maximum age in days (empty for reminders only)": "Najveća starost u danima (prazno samo za podsjetnike)", + "Remind me this many days before, comma separated": "Podsjeti me ovoliko dana prije, odvojeno zarezima", + "Save rule": "Spremi pravilo", + "An item type": "Vrsta stavke", + "A folder": "Mapa", + "Folder {name}": "Mapa {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Istječe nakon {days} dana", + "Reminders {days} days before": "Podsjetnici {days} dana prije", + "Could not save the expiry rule.": "Pravilo isteka nije moguće spremiti.", + "Could not delete the expiry rule.": "Pravilo isteka nije moguće izbrisati.", + "All statuses": "Svi statusi", + "Compromised": "Kompromitiran", + "Could not load the members.": "Nije moguće učitati članove.", + "Emergency contact": "Kontakt za hitne slučajeve", + "Leaving user": "Korisnik koji odlazi", + "No": "Ne", + "No users match this filter.": "Nijedan korisnik ne odgovara ovom filtru.", + "Not set up": "Nije postavljeno", + "Revoke suite": "Opozovi paket", + "Revoked": "Opozvan", + "Search users": "Pretraži korisnike", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pogledajte koji su korisnici postavili trezor. Pokrenite odjavu ili opozovite paket iz retka.", + "Successor": "Nasljednik", + "Team folders": "Timske mape", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Korisnik je još u grupi {groups}, koja je članica timske mape. Uklonite ga iz grupe ili onemogućite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun.", + "Vault status": "Status trezora", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz u CXF NIJE ŠIFRIRAN. Svaka lozinka i prijava bit će čitljiva kao otvoreni tekst u preuzetoj datoteci. Čuvajte je na sigurnom i izbrišite je odmah nakon uporabe.", + "Root certificate expiring soon": "Korijenski certifikat uskoro istječe", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korijenski certifikat trezora istječe za %1$d dan(a). Obnovite ga prije toga. Obnova ponovno potpisuje svaki paket šifriranja.", + "Compromise recovery aborted": "Oporavak nakon kompromitacije prekinut", + "Key rotation ended by a compromise revoke": "Rotacija ključa završena opozivom zbog kompromitacije", + "Encryption suite revoke refused": "Opoziv paketa šifriranja odbijen", + "Master password proof refused": "Dokaz glavne lozinke odbijen", + "Your current master password": "Vaša trenutna glavna lozinka", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ti kontakti za hitne slučajeve nisu preneseni na vaš novi ključ. Njihov hitni pristup je uklonjen. Ponovno ih dodajte u Hitnom pristupu ako ih još želite.", + "Renew root certificate": "Obnovi korijenski certifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ovo stvara novi korijenski i posrednički certifikat. Svaki aktivni paket šifriranja ponovno se potpisuje. To se ne može poništiti.", + "Renew root": "Obnovi korijen", + "Root renewed. {n} encryption suites signed again.": "Korijen obnovljen. Ponovno potpisanih paketa šifriranja: {n}.", + "Could not renew the root certificate.": "Korijenski certifikat nije moguće obnoviti.", + "Lease policy for this application": "Pravila zakupa za ovu aplikaciju", + "In force now: {default} seconds by default, {max} seconds at most.": "Trenutačno vrijedi: zadano {default} sekundi, najviše {max} sekundi.", + "Leases are not renewable": "Zakupi se ne mogu obnavljati", + "Lease policy saved.": "Pravila zakupa spremljena.", + "Leave a field empty to use the instance value.": "Ostavite polje prazno za korištenje vrijednosti instance.", + "Instance value: {value}": "Vrijednost instance: {value}", + "Renewal": "Obnavljanje", + "Use the instance value ({value})": "Koristi vrijednost instance ({value})", + "Allowed": "Dopušteno", + "Not allowed": "Nije dopušteno", + "Save lease policy": "Spremi pravila zakupa", + "Only an administrator can change this policy.": "Samo administrator može promijeniti ova pravila.", + "Could not save the lease policy.": "Pravila zakupa nije moguće spremiti.", + "{member} got access from {confirmer}.": "{member} je dobio pristup od {confirmer}.", + "Automatically confirm new team folder members": "Automatski potvrdi nove članove timskih mapa", + "Gave %n new member access to a team folder.": "%n novi član dobio je pristup timskoj mapi.", + "Gave %n new members access to a team folder.": "Novi članovi (%n) dobili su pristup timskoj mapi.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novim članovima pristup bez čekanja vlasnika mape.", + "New team folder members": "Novi članovi timskih mapa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlasnik ili član s pravom pisanja potvrđuje ih iz otvorenog trezora. Keepiq nikad ne dešifrira na poslužitelju.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čeka se da član s pravom pisanja otvori Keepiq. Možete i odmah dijeliti.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dio odgovora na kompromitaciju nije uspio ({failed} korak(a)). Provjerite zapisnik poslužitelja, a zatim ponovno opozovite paket da biste ga dovršili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Time je opozvan i paket {suite} te završena migracija ključeva {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Revoking the second suite deleted %n emergency-access contacts.": "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "A suite revoked as compromised cannot be reinstated.": "Paket opozvan kao kompromitiran ne može se vratiti.", + "Archives to keep": "Arhive za čuvanje", + "Back up every vault automatically": "Automatski sigurnosno kopiraj svaki trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sigurnosno kopirajte svaki trezor prema rasporedu. Arhive sadrže samo šifrirani tekst i vraćaju se s occ.", + "Back up now": "Kopiraj sada", + "Backup public key (PEM, optional)": "Javni ključ sigurnosne kopije (PEM, neobavezno)", + "Backup requested for the next cron run": "Kopija zatražena za sljedeće pokretanje crona", + "Encrypted": "Šifrirano", + "Every (hours)": "Svakih (sati)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} nije uspjela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspjela.", + "No archives yet.": "Još nema arhiva.", + "Size": "Veličina", + "Vault backups": "Sigurnosne kopije trezora", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se svaka arhiva šifrira za njega. Privatni ključ čuvajte izvan ovog poslužitelja: trebate ga za provjeru ili vraćanje.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervni kodovi se ne računaju. Ako se vaši korisnici prijavljuju preko pružatelja identiteta s vlastitim drugim faktorom, izostavite njihove grupe.", + "Block personal vault export": "Blokiraj izvoz osobnog trezora", + "Keep work logins in team folders": "Čuvaj poslovne prijave u timskim mapama", + "Move to a team folder": "Premjesti u timsku mapu", + "Not in a team folder": "Nije u timskoj mapi", + "Only for these groups (empty is everyone)": "Samo za ove grupe (prazno znači svi)", + "Require two-factor login before the vault opens": "Zahtijevaj prijavu u dva koraka prije otvaranja trezora", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za svaki trezor. Svako vrijedi za sve ili samo za grupe koje odaberete.", + "Secret types that belong in a team folder": "Vrste tajni koje pripadaju u timsku mapu", + "Set up two-factor login": "Postavi prijavu u dva koraka", + "Team folder you can write to": "Timska mapa u koju možete pisati", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Korisnici ne mogu preuzeti sigurnosnu kopiju, CSV ni datoteku za prijenos. Njihov paket osobnih podataka ostaje dostupan.", + "Users cannot save these secret types in a personal folder.": "Korisnici ne mogu spremiti ove vrste tajni u osobnu mapu.", + "Vault policies": "Pravila trezora", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dopušta izvoz vašeg osobnog trezora. Vaš paket osobnih podataka u postavkama ostaje dostupan.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija čuva ove tajne u timskoj mapi. Premjestite svaku u timsku mapu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija čuva ovu vrstu tajne u timskoj mapi. Odaberite jednu od svojih timskih mapa ili onu u koju možete pisati.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahtijeva prijavu u dva koraka prije nego što možete otvoriti svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Korisnici biraju koliko dugo proširenje ostaje otključano tijekom neaktivnosti. Vi postavljate najdulje vrijeme koje smiju odabrati.", + "Longest idle time before the extension locks": "Najdulje vrijeme neaktivnosti prije zaključavanja proširenja", + "1 minute": "1 minuta", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 sat", + "4 hours": "4 sata", + "Connector": "Konektor", + "Directory (tenant) ID": "ID direktorija (zakupca)", + "Application (client) ID": "ID aplikacije (klijenta)", + "Data collection rule immutable ID": "Nepromjenjivi ID pravila prikupljanja podataka", + "Stream name": "Naziv toka", + "Splunk index (optional)": "Splunk indeks (neobavezno)", + "Sourcetype (optional)": "Sourcetype (neobavezno)", + "Leave blank to keep the current one": "Ostavite prazno da zadržite trenutnu vrijednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF preko sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Krajnja točka prikupljanja podataka (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajna klijenta (samo pisanje)", + "HEC token (write-only)": "HEC token (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Prosljeđujte dopuštene revizijske događaje u Splunk, Microsoft Sentinel, syslog prijamnik ili webhook. Poruke sadrže samo očišćene metapodatke: nijedna tajna vrijednost, ime, prijava ili šifrirani tekst nikada ne napušta poslužitelj.", + "%n change waiting to sync": "%n promjena čeka sinkronizaciju", + "%n changes waiting to sync": "%n promjena čeka sinkronizaciju", + "Changes that could not sync": "Promjene koje se nisu mogle sinkronizirati", + "Choose a version": "Odaberite verziju", + "Copy value": "Kopiraj vrijednost", + "Deleted": "Izbrisano", + "Discard": "Odbaci", + "Keep my offline change": "Zadrži moju izvanmrežnu promjenu", + "Keep the server version": "Zadrži verziju s poslužitelja", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je izvan mreže samo za čitanje. Administrator nije uključio uređivanje izvan mreže.", + "Let users edit secrets offline": "Dopusti korisnicima uređivanje tajni izvan mreže", + "Not synced yet": "Još nije sinkronizirano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Izvanmrežne promjene ostaju na uređaju, šifrirane za korisnika, i sinkroniziraju se pri sljedećem otključavanju na mreži. Dijeljenje, mape i privici i dalje trebaju vezu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Izvan mreže. Uređivanja, premještanja i brisanja ostaju na ovom uređaju i sinkroniziraju se kad ponovno budete na mreži. Dijeljenje i privici trebaju vezu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Izvan mreže. Vaše promjene ostaju na ovom uređaju i sinkroniziraju se kad ponovno budete na mreži. Zadnja sinkronizacija {when}.", + "Open my changes": "Otvori moje promjene", + "Sharing needs a connection": "Dijeljenje treba vezu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Netko je promijenio ovu tajnu na poslužitelju nakon što je napravljena vaša izvanmrežna kopija. Odaberite koju verziju zadržati.", + "Sync or discard your offline changes before you rotate your keys.": "Sinkronizirajte ili odbacite izvanmrežne promjene prije zamjene ključeva.", + "That password did not open your changes.": "Ta lozinka nije otvorila vaše promjene.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Izvanmrežna snimka sprema šifrirane tajne (otvaraju se samo ključem izvedenim iz glavne lozinke korisnika, točno kao na poslužitelju) i šifrira nazive, URL-ove i nazive mapa u pohrani. Izvanmrežni pristup je samo za čitanje, osim ako ispod dopustite uređivanje izvan mreže. Isključite ovo za uređaje koji nikad ne smiju predmemorirati vjerodajnice; isključivanje briše postojeće predmemorije pri sljedećem učitavanju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prethodne kopije trezora više nema, pa se ove promjene ne mogu otvoriti.", + "The server version": "Verzija s poslužitelja", + "This secret changed while you were offline": "Ova tajna promijenila se dok ste bili izvan mreže", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ovu ste tajnu izbrisali izvan mreže, ali je u međuvremenu promijenjena na poslužitelju. Odaberite koju verziju zadržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključevi promijenjeni su na drugom uređaju. Unesite prethodnu glavnu lozinku za sinkronizaciju izvanmrežnih promjena ili ih odbacite.", + "Your offline change": "Vaša izvanmrežna promjena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n kontakt za hitne slučajeve imao je zahtjev za pristup na čekanju kada ga je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate.", + "Kontakti za hitne slučajeve (%n) imali su zahtjev za pristup na čekanju kada ih je rotacija ključa uklonila. Provjerite tko je tražio prije nego što ikoga ponovno dodate." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n stavku nije moguće prikazati u CXF-u i bit će preskočena.", + "%n stavki nije moguće prikazati u CXF-u i bit će preskočene.", + "%n stavki nije moguće prikazati u CXF-u i bit će preskočene." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n starija verzija je odbačena jer se može prenijeti samo nedavna povijest.", + "%n starijih verzija je odbačeno jer se može prenijeti samo nedavna povijest.", + "%n starijih verzija je odbačeno jer se može prenijeti samo nedavna povijest." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopiju tajne još treba šifrirati i podijeliti.", + "%n kopija tajni još treba šifrirati i podijeliti.", + "%n kopija tajni još treba šifrirati i podijeliti." + ], + "_%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 tajna nije mogla biti dešifrirana i nije u ovom izvozu.", + "%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu.", + "%n tajni nije moglo biti dešifrirano i nisu u ovom izvozu." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n tajna se nije mogla dešifrirati vašim starim ključem, pa nije migrirana.", + "%n tajni se nije moglo dešifrirati vašim starim ključem, pa nisu migrirane.", + "%n tajni se nije moglo dešifrirati vašim starim ključem, pa nisu migrirane." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n tajna nije migrirana.", + "%n tajni nije migrirano.", + "%n tajni nije migrirano." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n tajna je još šifrirana vašim prethodnim ključem.", + "%n tajni je još šifrirano vašim prethodnim ključem.", + "%n tajni je još šifrirano vašim prethodnim ključem." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n tajna je preskočena jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.", + "%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno.", + "%n tajni je preskočeno jer nasljednik još nema kopiju — dodajte nasljednika u mapu i pokrenite ponovno." + ], + "_%n secret_::_%n secrets_": [ + "%n tajna", + "%n tajne", + "%n tajne" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n korisnik u opsegu još nema prijavu u dva koraka i ne može otvoriti trezor dok je ovo uključeno.", + "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno.", + "Korisnici u opsegu (%n) još nemaju prijavu u dva koraka i ne mogu otvoriti trezor dok je ovo uključeno." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Ipak završi, uz gubitak pristupa %n tajni", + "Ipak završi, uz gubitak pristupa %n tajni", + "Ipak završi, uz gubitak pristupa %n tajni" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n novi član dobio je pristup timskoj mapi.", + "Novi članovi (%n) dobili su pristup timskoj mapi.", + "Novi članovi (%n) dobili su pristup timskoj mapi." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotacija ključa je završena. %n tajna je ponovno šifrirana vašim novim ključem.", + "Rotacija ključa je završena. %n tajni je ponovno šifrirano vašim novim ključem.", + "Rotacija ključa je završena. %n tajni je ponovno šifrirano vašim novim ključem." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Opoziv drugog paketa izbrisao je %n kontakt za hitni pristup.", + "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup.", + "Opoziv drugog paketa izbrisao je %n kontakata za hitni pristup." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Opozivanje ovog kompleta izbrisalo je %n kontakt hitnog pristupa.", + "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa.", + "Opozivanje ovog kompleta izbrisalo je %n kontakata hitnog pristupa." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "zabilježeno %n put u procurjelim podacima", + "zabilježeno %n puta u procurjelim podacima", + "zabilježeno %n puta u procurjelim podacima" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "podijeljeno s %n tajnom", + "podijeljeno s %n tajni", + "podijeljeno s %n tajni" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ova mapa izravno sadrži %n tajnu.", + "Ova mapa izravno sadrži %n tajne.", + "Ova mapa izravno sadrži %n tajne." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n promjena čeka sinkronizaciju", + "%n promjena čeka sinkronizaciju", + "%n promjena čeka sinkronizaciju" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Korisnik je još u grupi {groups}, koja je članica timske mape. Uklonite ga iz grupe ili onemogućite račun.", + "Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun.", + "Korisnik je još u grupama {groups}, koje su članice timskih mapa. Uklonite ga iz grupa ili onemogućite račun." + ], + "Allow approval from another device": "Dopusti odobrenje s drugog uređaja", + "App": "Aplikacija", + "Approve a new device": "Odobri novi uređaj", + "Approve from another device": "Odobri s drugog uređaja", + "Asked at": "Zatraženo u", + "Check that the new device shows these words:": "Provjerite prikazuje li novi uređaj ove riječi:", + "Denied. If you did not ask, end your other sessions:": "Odbijeno. Ako to niste zatražili, završite ostale sesije:", + "Device": "Uređaj", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Omogući korisnicima otključavanje novog preglednika odobrenjem s uređaja na kojem je Keepiq već otključan.", + "New device approval": "Odobrenje novih uređaja", + "Nextcloud security settings": "Sigurnosne postavke Nextclouda", + "Only approve a device you are using right now.": "Odobrite samo uređaj koji upravo koristite.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorite Keepiq na uređaju na kojem je otključan i odobrite ovaj uređaj. Provjerite prikazuje li iste riječi:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Uređaj koji odobrava zapečaćuje ključ za otključavanje za novi uređaj. Poslužitelj ga samo prosljeđuje i ne može ga otvoriti.", + "The master password is not right, or the request has ended.": "Glavna lozinka nije točna ili je zahtjev završio.", + "The request expired. Ask again or use your master password.": "Zahtjev je istekao. Zatražite ponovno ili upotrijebite glavnu lozinku.", + "The request was denied.": "Zahtjev je odbijen.", + "Too many requests. Try again in an hour or use your master password.": "Previše zahtjeva. Pokušajte ponovno za sat vremena ili upotrijebite glavnu lozinku.", + "Unknown device": "Nepoznati uređaj", + "Web app": "Web aplikacija", + "A device": "Uređaj", + "A new device asks to open your vault": "Novi uređaj traži otvaranje vašeg trezora", + "%s asks to be approved. Only approve a device you are using right now.": "%s traži odobrenje. Odobrite samo uređaj koji upravo koristite.", + "Access ends on (optional)": "Pristup završava (neobavezno)", + "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.": "Aplikacije Keepiq neće prikazati ni kopirati lozinku. Netko s tehničkim znanjem i dalje je može pročitati na vlastitom uređaju. Promijenite je kada pristup završi.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ova tajna služi samo za korištenje. Prijavite se putem proširenja preglednika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo korištenje", + "Use only (can sign in, cannot view or copy)": "Samo korištenje (može se prijaviti, ne može vidjeti ni kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ovom se prijavom možete prijaviti putem proširenja preglednika Keepiq. Vlasnik je odlučio da je ne možete vidjeti ni kopirati.", + "Your access ends on {date}": "Vaš pristup završava {date}", + "Your access to this secret has ended": "Vaš pristup ovoj tajni je završio", + "Your access to \"%s\" ends tomorrow": "Vaš pristup stavci „%s” završava sutra", + "Your access to \"%s\" has ended": "Vaš pristup stavci „%s” je završio", + "%1$s no longer has access to \"%2$s\"": "%1$s više nema pristup stavci „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je mogao/la vidjeti ovu lozinku. Promijenite je ako je %1$s više ne bi trebao/la znati.", + "%s could not view this password in Keepiq.": "%s nije mogao/la vidjeti ovu lozinku u Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobrenja", + "a recovery officer": "službenik za oporavak", + "Account recovery": "Oporavak računa", + "Approvals needed": "Potrebna odobrenja", + "Ask {user} which words they see, by phone or in person. They must be:": "Pitajte korisnika {user} koje riječi vidi, telefonom ili osobno. Moraju biti:", + "Check again": "Provjeri ponovno", + "Create the recovery key": "Stvori ključ za oporavak", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Stvorite ključ za oporavak. Vaš preglednik ga izrađuje i svakom službeniku daje kopiju koju samo on može otvoriti.", + "Decline": "Odbij", + "Enrol in account recovery": "Prijavite se za oporavak računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se kako bi vam organizacija mogla pomoći vratiti trezor ako zaboravite glavnu zaporku.", + "Every user is enrolled": "Svi korisnici su prijavljeni", + "Finish the recovery in the browser you asked from.": "Dovršite oporavak u pregledniku iz kojeg ste ga zatražili.", + "Forgot your master password?": "Zaboravili ste glavnu zaporku?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Omogućite korisnicima koji su zaboravili glavnu zaporku da vrate trezor, uz odobrenje službenika za oporavak koje imenujete.", + "New master password": "Nova glavna zaporka", + "No one is asking to recover their account.": "Nitko ne traži oporavak računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Još nema ključa za oporavak. Jedan od službenika ga stvara u svojim postavkama Keepiqa.", + "Off": "Isključeno", + "Officer {user} has no encryption set up yet.": "Službenik {user} još nema postavljeno šifriranje.", + "Officers (user IDs, separated by commas)": "Službenici (korisnički ID-ovi, odvojeni zarezima)", + "Policy": "Pravila", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ovaj otisak interno kako bi ga korisnici mogli provjeriti prije prijave.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Oporavljeno uz pomoć korisnika {officer}. Sada promijenite ključ trezora u Postavkama, Sigurnost: \"Moja glavna zaporka je kompromitirana\".", + "Recovery key fingerprint: {fingerprint}": "Otisak ključa za oporavak: {fingerprint}", + "Recovery officer": "Službenik za oporavak", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Uklonjeni službenici sada gube svoju kopiju, ali su je možda prije otvorili. Neka službenik stvori novi ključ za oporavak.", + "Repeat the new master password": "Ponovite novu glavnu zaporku", + "Retire this recovery key": "Povuci ovaj ključ za oporavak", + "Set the new master password": "Postavi novu glavnu zaporku", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikat za oporavak nije izdao ovaj Keepiq. Nemojte se prijaviti i obavijestite administratora.", + "The words match, approve": "Riječi se podudaraju, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ovaj korisnik je prijavljen za oporavak računa. Oporavak čuva njegove tajne; opoziv briše njegovu prijavu.", + "Users may enrol": "Korisnici se mogu prijaviti", + "Withdraw from account recovery": "Odjavi se s oporavka računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste za oporavak računa. Otisak ključa za oporavak: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Ako zaboravite glavnu zaporku, organizacija vam može pomoći vratiti trezor.", + "Your key is back. Choose a new master password.": "Ključ je vraćen. Odaberite novu glavnu zaporku.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši službenici za oporavak su obaviješteni. Pročitajte im ove riječi kad vas nazovu ili se nađete:", + "You are now an account recovery officer": "Sada ste službenik za oporavak računa", + "%s asks to recover their account. Compare the words with them before you approve.": "%s traži oporavak računa. Usporedite riječi s njim prije odobravanja.", + "A user": "Korisnik", + "Your account recovery request was declined": "Vaš zahtjev za oporavak računa je odbijen", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Oporavak računa je spreman. Otvorite Keepiq u pregledniku iz kojeg ste ga zatražili.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} traži jednokratno otključavanje novog uređaja. Glavna lozinka ostaje ista.", + "Ask your organisation instead": "Umjesto toga pitajte svoju organizaciju", + "The request ended. Ask again or use your master password.": "Zahtjev je završio. Zatražite ponovno ili upotrijebite glavnu lozinku.", + "Added by {user}": "Dodao/la {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Uloga korisnika {member}", + "Team folders you manage": "Timske mape kojima upravljate", + "Viewer": "Preglednik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemate kopiju ovih tajni, pa ih novi članovi još nisu dobili. Vlasnik ih može podijeliti: {names}", + "Admin areas": "Područja administracije", + "Give a group only the parts of Keepiq administration it needs.": "Dajte grupi samo one dijelove administracije Keepiqa koji su joj potrebni.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegirajte jedno ili više područja grupi na stranici administratorskih ovlasti. Administratori instance imaju svako područje.", + "Open administration privileges": "Otvori administratorske ovlasti", + "Policies": "Pravila", + "Applications and machine access": "Aplikacije i pristup strojeva", + "People and offboarding": "Ljudi i odlasci", + "Audit and compliance": "Revizija i usklađenost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzija, certifikacijsko tijelo, privici, izvanmrežna predmemorija, provjera curenja, vrste tajni i sigurnosne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavna lozinka, lozinka organizacije, pravila trezora, rotacija, povijest verzija i smeće", + "application queue, application requests and machine leases": "red aplikacija, zahtjevi aplikacija i najmovi strojeva", + "team offboarding, encryption suites and admin handover": "odlasci iz tima, paketi šifriranja i preuzimanje od strane administratora", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski zapisnik, izvješća o usklađenosti, SIEM izvoz i upozorenja mamaca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko se verzija tajne čuva, koliko dugo, i koliko dugo izbrisane tajne ostaju u smeću.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ograničenja za šifrirane privitke, koja poslužitelj primjenjuje na spremljene šifrirane bajtove.", + "Type the suite ID again to confirm": "Ponovno upišite ID paketa za potvrdu", + "This does not match the suite ID.": "Ovo se ne podudara s ID-om paketa.", + "Confirm with your master password": "Potvrdite glavnom lozinkom", + "Confirm": "Potvrdi", + "That master password is not right.": "Ta glavna lozinka nije ispravna.", + "You are sharing with someone new. Enter your master password to confirm.": "Dijelite s novom osobom. Unesite glavnu lozinku za potvrdu.", + "Enter your master password to confirm this share.": "Unesite glavnu lozinku za potvrdu ovog dijeljenja.", + "Enter your master password to confirm this delegation.": "Unesite glavnu lozinku za potvrdu ovog delegiranja.", + "Approve {member}": "Odobri {member}", + "Recipient": "Primatelj", + "No vault yet": "Još nema trezora", + "No matching users": "Nema odgovarajućih korisnika", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Razmjenjujte tajne s drugim Keepiqom. Oba administratora dodaju jedan drugoga i prije spremanja usporede korijenske otiske telefonom ili osobno.", + "Federation needs Nextcloud 33 or later.": "Federacija zahtijeva Nextcloud 33 ili noviji.", + "Your root fingerprint": "Vaš korijenski otisak", + "No partners yet.": "Još nema partnera.", + "Users here may share to this partner": "Korisnici ovdje smiju dijeliti s ovim partnerom", + "This partner may share to users here": "Ovaj partner smije dijeliti s korisnicima ovdje", + "Partner address": "Adresa partnera", + "Check partner": "Provjeri partnera", + "Partner root fingerprint": "Korijenski otisak partnera", + "I compared this fingerprint with the partner's administrator": "Usporedio sam ovaj otisak s administratorom partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Tajna iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s dijeli s vama \"%2$s\". Prihvatite je u odjeljku Dolazno iz drugih organizacija.", + "Incoming from other organisations": "Dolazno iz drugih organizacija", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osobe u partnerskim organizacijama mogu s vama dijeliti tajnu. Prihvatite je kako biste zadržali kopiju samo za čitanje u svom trezoru.", + "Nothing shared with you yet": "Još ništa nije dijeljeno s vama", + "Secrets that people in partner organisations share with you appear here.": "Ovdje se prikazuju tajne koje s vama dijele osobe u partnerskim organizacijama.", + "From {sender}": "Od {sender}", + "Accept": "Prihvati", + "Open in vault": "Otvori u trezoru", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija nije predala tajnu. Pokušajte ponovno kasnije.", + "Set up your vault before you accept a shared secret.": "Postavite svoj trezor prije nego što prihvatite dijeljenu tajnu.", + "Something went wrong. Try again.": "Nešto je pošlo po zlu. Pokušajte ponovno.", + "Waiting for your answer": "Čeka vaš odgovor", + "In your vault, read-only": "U vašem trezoru, samo za čitanje", + "Withdrawn by the sender": "Pošiljatelj je povukao", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} dijeli ovo iz druge organizacije. Možete to čitati, ali ne i mijenjati ili dijeliti.", + "Someone": "Netko", + "Share with someone at another organisation": "Dijeli s nekim iz druge organizacije", + "Their account at the other organisation": "Račun te osobe u drugoj organizaciji", + "Check account": "Provjeri račun", + "Certificate fingerprint of {account}": "Otisak certifikata za {account}", + "Compare it with them by phone if you want to be sure.": "Usporedite ga s tom osobom telefonom ako želite biti sigurni.", + "Shared. {account} can accept it in their own vault.": "Podijeljeno. {account} to može prihvatiti u svom trezoru.", + "The certificate could not be verified. Nothing was shared.": "Certifikat nije moguće provjeriti. Ništa nije podijeljeno.", + "That organisation is not one of your partners.": "Ta organizacija nije jedan od vaših partnera.", + "No one with that account can receive secrets from you.": "Nitko s tim računom ne može primati tajne od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija nije odgovorila. Pokušajte ponovno kasnije.", + "This secret is already shared with that account.": "Ova je tajna već podijeljena s tim računom.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Primanje tajni iz drugih organizacija", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osobe u partnerskim organizacijama tada mogu pronaći vaš račun i dijeliti tajne s vama. Svaku od njih prihvaćate sami.", + "Shared": "Podijeljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauzirano: promijenio se njihov certifikat ili partnerstvo. Opozovite ili podijelite ponovno.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija nije dobila posljednju promjenu. Opozovite ili podijelite ponovno.", + "Being withdrawn": "Povlači se", + "Shared with another organisation": "Podijeljeno s drugom organizacijom", + "Change sent to another organisation": "Promjena poslana drugoj organizaciji", + "Share with another organisation revoked": "Dijeljenje s drugom organizacijom ukinuto", + "Share with another organisation paused": "Dijeljenje s drugom organizacijom pauzirano", + "Another organisation did not get a change": "Druga organizacija nije dobila promjenu", + "Secret received from another organisation": "Tajna primljena iz druge organizacije", + "Secret from another organisation accepted": "Tajna iz druge organizacije prihvaćena", + "Secret from another organisation declined": "Tajna iz druge organizacije odbijena", + "Copy from another organisation updated": "Kopija iz druge organizacije ažurirana", + "Copy from another organisation removed": "Kopija iz druge organizacije uklonjena", + "Declined: they removed their copy. Share again if they need it.": "Odbijeno: primatelj je uklonio svoju kopiju. Podijelite ponovno ako mu treba.", + "Recipient at another organisation removed their copy": "Primatelj iz druge organizacije uklonio je svoju kopiju", + "Removed the user from %n team folder.": "Korisnik je uklonjen iz %n timske mape.", + "Removed the user from %n team folders.": "Korisnik je uklonjen iz %n timskih mapa.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Korisnik je uklonjen iz %n timske mape.", + "Korisnik je uklonjen iz %n timskih mapa.", + "Korisnik je uklonjen iz %n timskih mapa." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Vraćena kopija potječe iz dijeljenja koje je završilo. Ostaje samo za čitanje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacija koja je podijelila vraćenu kopiju nije dostupna. Kopija ostaje samo za čitanje i ne prati njihove promjene.", + "Recipient at another organisation restored their copy": "Primatelj iz druge organizacije vratio je svoju kopiju" }, "plurals": null } diff --git a/l10n/hu.js b/l10n/hu.js index 24b1f8d3a..a2d1e68e0 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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é." + "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é.", + "Shared with groups": "Megosztva csoportokkal", + "Not shared with any group yet.": "Még nincs megosztva egy csoporttal sem.", + "Revoke the share with {group}": "Megosztás visszavonása ezzel: {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Megosztva ezzel: {group}. {received} tag megkapta, {skipped} nem, mert még nem állított be titkosítást.", + "Search groups": "Csoportok keresése", + "Failed to share": "A megosztás sikertelen", + "Columns": "Oszlopok", + "Column {number}": "{number}. oszlop", + "Map one column to Name. Every secret needs a name.": "Rendeljen egy oszlopot a névhez. Minden titoknak kell név.", + "Notes": "Jegyzetek", + "Do not import": "Ne importálja", + "Hide this value": "Érték elrejtése", + "Show this value": "Érték megjelenítése", + "Defaults": "Alapértelmezések", + "New secrets start as this type, and your secret list opens in this view.": "Az új titkok ezzel a típussal indulnak, a titkok listája pedig ebben a nézetben nyílik meg.", + "Default item type": "Alapértelmezett elemtípus", + "Cards": "Kártyák", + "Table": "Táblázat", + "Could not save your default": "Nem sikerült menteni az alapértelmezést", + "Recently used": "Nemrég használt", + "Opened": "Megnyitva", + "You have not opened any secrets yet": "Még nem nyitottál meg egy titkot sem", + "Could not delete the item type.": "Az elemtípust nem sikerült törölni.", + "Could not load the item types.": "Az elemtípusokat nem sikerült betölteni.", + "Could not save the item type.": "Az elemtípust nem sikerült menteni.", + "Delete item type": "Elemtípus törlése", + "Edit item type": "Elemtípus szerkesztése", + "Fields": "Mezők", + "Fields: {count}": "Mezők: {count}", + "Hidden": "Rejtett", + "Item types": "Elemtípusok", + "Move up": "Feljebb", + "New item type": "Új elemtípus", + "No item types defined yet.": "Még nincs megadva elemtípus.", + "Required": "Kötelező", + "Text": "Szöveg", + "This field is required": "Ez a mező kötelező", + "Web address": "Webcím", + "{label} (required)": "{label} (kötelező)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Törlöd ezt: „{name}”? Az ilyen típusú titkok olvashatók maradnak, és Bejelentkezés elemek lesznek.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Az itt megadott elemtípusok mindenkinek megjelennek az Új titok ablakban, az általad választott mezőkkel.", + "Secret moved to the trash": "Titok áthelyezve a kukába", + "Secret restored from the trash": "Titok visszaállítva a kukából", + "Secret deleted for good": "Titok véglegesen törölve", + "Secret archived": "Titok archiválva", + "Secret unarchived": "Titok kivéve az archívumból", + "Unarchive": "Kivétel az archívumból", + "Could not archive the secret": "A titkot nem sikerült archiválni", + "Could not unarchive the secret": "A titkot nem sikerült kivenni az archívumból", + "Archive {count} secrets": "Titkok archiválása: {count}", + "Unarchive {count} secrets": "Titkok kivétele az archívumból: {count}", + "Restore {count} secrets": "Titkok visszaállítása: {count}", + "Delete {count} secrets for good": "Titkok végleges törlése: {count}", + "Done for {ok} of {total} secrets": "Kész: {ok} / {total} titok", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Az archivált titkok eltűnnek a széf listájából, a keresésből, az automatikus kitöltésből és az állapotjelentésből. A megosztásaik megmaradnak. Az Archívumban találod őket.", + "These secrets come back to the vault list, search and autofill.": "Ezek a titkok visszakerülnek a széf listájába, a keresésbe és az automatikus kitöltésbe.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ezek a titkok visszakerülnek a széf listájába. A régi megosztásaik nem jönnek vissza, ezért szükség esetén oszd meg őket újra.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ez törli a titkokat a mellékleteikkel és a verzióelőzményeikkel együtt. Nem vonható vissza.", + "Delete for good": "Végleges törlés", + "Trash": "Kuka", + "The trash is empty": "A kuka üres", + "No archived secrets": "Nincsenek archivált titkok", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "A törölt titkok itt várnak a megőrzési idő végéig, utána véglegesen törlődnek.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiválj egy titkot a részletek panelen, hogy kimaradjon a széf listájából, a keresésből és az automatikus kitöltésből.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Korlátok a titkosított mellékletekhez (a kiszolgálón érvényesítve a tárolt titkosított bájtokra), a verzióelőzmények megőrzése és hogy meddig maradnak a törölt titkok a kukában.", + "Days a deleted secret stays in the trash (1 to 365)": "Hány napig marad egy törölt titok a kukában (1–365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ez a kukába helyezi a titkot, és most megszünteti a megosztásait. A megőrzési idő végéig visszaállíthatod a kukából: 30 nap, hacsak a rendszergazda nem módosította.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ez a kukába helyezi a titkokat ({count}), és most megszünteti a megosztásaikat. A megőrzési idő végéig visszaállíthatod őket a kukából.", + "Remove {name} from favourites": "{name} eltávolítása a kedvencek közül", + "Add {name} to favourites": "{name} hozzáadása a kedvencekhez", + "Could not change the favourite": "Nem sikerült módosítani a kedvencet", + "Remove from favourites": "Eltávolítás a kedvencek közül", + "Add to favourites": "Hozzáadás a kedvencekhez", + "Tags": "Címkék", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "A címkék nincsenek titkosítva. A kiszolgáló rendszergazdái olvashatják őket, akárcsak a mappaneveket.", + "Favourites": "Kedvencek", + "Filter by tag": "Szűrés címke szerint", + "All tags": "Minden címke", + "Last used": "Utoljára használva", + "Tags for {count} secrets": "Címkék {count} titokhoz", + "Tag": "Címke", + "Remove tag": "Címke eltávolítása", + "Add tag": "Címke hozzáadása", + "Could not change the tags. Try again.": "Nem sikerült módosítani a címkéket. Próbálja újra.", + "Could not approve the application. It is still in the queue.": "Nem sikerült jóváhagyni a kérelmet. Még mindig a sorban van.", + "Could not reject the application. It is still in the queue.": "Nem sikerült elutasítani a kérelmet. Még mindig a sorban van.", + "Removed the user from {count} team folders.": "A felhasználó eltávolítva {count} csapatmappából.", + "Approve a share": "Megosztás jóváhagyása", + "This approval link is incomplete. Open it again from the notification.": "Ez a jóváhagyási hivatkozás hiányos. Nyissa meg újra az értesítésből.", + "Deny": "Elutasítás", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} csatlakozott egy csoporthoz, amellyel titkot oszt meg. Megosztja vele is a titkot?", + "{requester} asks you to share a secret with {user}.": "{requester} arra kéri, hogy ossza meg a titkot vele: {user}.", + "Shared. The recipient can now open the secret.": "Megosztva. A címzett most már megnyithatja a titkot.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "A címzett még nem állította be a Keepiqet, így semmi sem lett megosztva. Próbálja újra, ha már megtette.", + "Could not share the secret. Only its owner can approve this.": "Nem sikerült megosztani a titkot. Ezt csak a tulajdonosa hagyhatja jóvá.", + "Could not share the secret. Try again.": "Nem sikerült megosztani a titkot. Próbálja újra.", + "Denied. Nothing was shared.": "Elutasítva. Semmi sem lett megosztva.", + "Could not deny the request. Try again.": "Nem sikerült elutasítani a kérést. Próbálja újra.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s arra kéri, hogy ossza meg a(z) \"%2$s\" titkot vele: %3$s.", + "Expires on (optional)": "Lejárat (nem kötelező)", + "Hand over to": "Átadás neki", + "Choose a recipient": "Válasszon címzettet", + "Hand over temporarily": "Ideiglenes átadás", + "Expiry rules": "Lejárati szabályok", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Állítsa be, meddig élhetnek egy elemtípus vagy egy mappa jelszavai, és mikor kapjon emlékeztetőt. Ha több dátum is érvényes, a legkorábbi számít.", + "Delete rule": "Szabály törlése", + "Set by your administrator": "A rendszergazda állította be", + "No expiry rules yet.": "Még nincsenek lejárati szabályok.", + "Applies to": "Vonatkozik erre", + "Item type": "Elemtípus", + "Maximum age in days (empty for reminders only)": "Maximális kor napokban (üres, ha csak emlékeztető kell)", + "Remind me this many days before, comma separated": "Emlékeztessen ennyi nappal előtte, vesszővel elválasztva", + "Save rule": "Szabály mentése", + "An item type": "Egy elemtípus", + "A folder": "Egy mappa", + "Folder {name}": "Mappa: {name}", + "Type {name}": "Típus: {name}", + "Expires after {days} days": "{days} nap után lejár", + "Reminders {days} days before": "Emlékeztetők {days} nappal előtte", + "Could not save the expiry rule.": "Nem sikerült menteni a lejárati szabályt.", + "Could not delete the expiry rule.": "Nem sikerült törölni a lejárati szabályt.", + "All statuses": "Minden állapot", + "Compromised": "Kompromittált", + "Could not load the members.": "A tagok betöltése nem sikerült.", + "Emergency contact": "Vészhelyzeti kapcsolat", + "Leaving user": "Távozó felhasználó", + "No": "Nem", + "No users match this filter.": "Egy felhasználó sem felel meg ennek a szűrőnek.", + "Not set up": "Nincs beállítva", + "Revoke suite": "Csomag visszavonása", + "Revoked": "Visszavonva", + "Search users": "Felhasználók keresése", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Nézze meg, mely felhasználók állítottak be széfet. Indítsa el a kiléptetést vagy vonjon vissza egy csomagot egy sorból.", + "Successor": "Utód", + "Team folders": "Csapatmappák", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "A felhasználó még a(z) {groups} csoportban van, amely egy csapatmappa tagja. Távolítsa el a csoportból, vagy tiltsa le a fiókot.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "A felhasználó még a(z) {groups} csoportokban van, amelyek csapatmappák tagjai. Távolítsa el a csoportokból, vagy tiltsa le a fiókot.", + "Vault status": "Széf állapota", + "Yes": "Igen", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "A CXF-exportálás TITKOSÍTATLAN. A letöltött fájlban minden jelszó és bejelentkezési név olvasható lesz egyszerű szövegként. Tárolja biztonságosan, és használat után azonnal törölje.", + "Root certificate expiring soon": "A gyökértanúsítvány hamarosan lejár", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "A széf gyökértanúsítványa %1$d nap múlva lejár. Újítsa meg előtte. A megújítás minden titkosítási csomagot újra aláír.", + "Compromise recovery aborted": "A kompromittálás utáni helyreállítás megszakítva", + "Key rotation ended by a compromise revoke": "A kulcscserét egy kompromittálás miatti visszavonás fejezte be", + "Encryption suite revoke refused": "A titkosítási csomag visszavonása elutasítva", + "Master password proof refused": "A mesterjelszó igazolása elutasítva", + "Your current master password": "A jelenlegi mesterjelszava", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította őket. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ezek a vészhelyzeti kapcsolattartók nem kerültek át az új kulcsára. A vészhelyzeti hozzáférésüket eltávolítottuk. Adja hozzá őket újra a Vészhelyzeti hozzáférésben, ha még szeretné.", + "Renew root certificate": "Gyökértanúsítvány megújítása", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ez új gyökér- és köztes tanúsítványt hoz létre. Minden aktív titkosítási csomag újra aláírásra kerül. Ez nem vonható vissza.", + "Renew root": "Gyökér megújítása", + "Root renewed. {n} encryption suites signed again.": "Gyökér megújítva. Újra aláírt titkosítási csomagok: {n}.", + "Could not renew the root certificate.": "A gyökértanúsítvány nem újítható meg.", + "Lease policy for this application": "Bérleti szabályzat ehhez az alkalmazáshoz", + "In force now: {default} seconds by default, {max} seconds at most.": "Jelenleg érvényes: alapértelmezés szerint {default} másodperc, legfeljebb {max} másodperc.", + "Leases are not renewable": "A bérletek nem újíthatók meg", + "Lease policy saved.": "Bérleti szabályzat mentve.", + "Leave a field empty to use the instance value.": "Hagyjon üresen egy mezőt a példány értékének használatához.", + "Instance value: {value}": "Példány értéke: {value}", + "Renewal": "Megújítás", + "Use the instance value ({value})": "A példány értékének használata ({value})", + "Allowed": "Engedélyezett", + "Not allowed": "Nem engedélyezett", + "Save lease policy": "Bérleti szabályzat mentése", + "Only an administrator can change this policy.": "Ezt a szabályzatot csak rendszergazda módosíthatja.", + "Could not save the lease policy.": "A bérleti szabályzat nem menthető.", + "{member} got access from {confirmer}.": "{member} hozzáférést kapott tőle: {confirmer}.", + "Automatically confirm new team folder members": "Új csapatmappa-tagok automatikus megerősítése", + "Gave %n new member access to a team folder.": "%n új tag hozzáférést kapott egy csapatmappához.", + "Gave %n new members access to a team folder.": "%n új tag hozzáférést kapott egy csapatmappához.", + "Give new team folder members access without waiting for the folder owner.": "Adjon hozzáférést az új tagoknak a mappa tulajdonosára várás nélkül.", + "New team folder members": "Új csapatmappa-tagok", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "A tulajdonos vagy egy írási joggal rendelkező tag erősíti meg őket a megnyitott széfből. A Keepiq soha nem fejt vissza a szerveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Várakozás egy írási joggal rendelkező tagra, hogy megnyissa a Keepiqet. Most is megoszthatja.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "A kompromittálásra adott válasz egy része sikertelen volt ({failed} lépés). Ellenőrizze a kiszolgáló naplóját, majd vonja vissza újra a csomagot a befejezéshez.", + "This also revoked suite {suite} and ended key migration {migration}.": "Ez a(z) {suite} csomagot is visszavonta, és befejezte a(z) {migration} kulcsmigrációt.", + "Revoking the second suite deleted %n emergency-access contact.": "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.", + "Revoking the second suite deleted %n emergency-access contacts.": "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.", + "A suite revoked as compromised cannot be reinstated.": "A kompromittáltként visszavont csomag nem állítható vissza.", + "Archives to keep": "Megtartandó archívumok", + "Back up every vault automatically": "Minden széf automatikus mentése", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Mentse minden széfet ütemezetten. Az archívumok csak titkosított szöveget tartalmaznak, és occ-vel állíthatók vissza.", + "Back up now": "Mentés most", + "Backup public key (PEM, optional)": "Mentési nyilvános kulcs (PEM, nem kötelező)", + "Backup requested for the next cron run": "Mentés kérve a következő cron-futásra", + "Encrypted": "Titkosított", + "Every (hours)": "Minden (óra)", + "Last backup {when} failed: {error}": "Az utolsó mentés {when} sikertelen: {error}", + "Last backup {when} succeeded.": "Az utolsó mentés {when} sikeres.", + "No archives yet.": "Még nincs archívum.", + "Size": "Méret", + "Vault backups": "Széfmentések", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Kulccsal minden archívum annak titkosítva készül. A privát kulcsot e szerveren kívül tartsa: ellenőrzéshez vagy visszaállításhoz kell.", + "Written": "Írva", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudja megnyitni a széfet, amíg ez be van kapcsolva.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudják megnyitni a széfet, amíg ez be van kapcsolva.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "A tartalékkódok nem számítanak. Ha felhasználói saját második faktorral rendelkező identitásszolgáltatón keresztül lépnek be, hagyja ki a csoportjaikat.", + "Block personal vault export": "Személyes széf exportjának tiltása", + "Keep work logins in team folders": "Munkahelyi belépések tartása csapatmappákban", + "Move to a team folder": "Áthelyezés csapatmappába", + "Not in a team folder": "Nincs csapatmappában", + "Only for these groups (empty is everyone)": "Csak ezeknek a csoportoknak (üres: mindenki)", + "Require two-factor login before the vault opens": "Kétlépcsős bejelentkezés megkövetelése a széf megnyitása előtt", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Szabályok minden széfre. Mindegyik mindenkire vonatkozik, vagy csak a kiválasztott csoportokra.", + "Secret types that belong in a team folder": "Csapatmappába tartozó titoktípusok", + "Set up two-factor login": "Kétlépcsős bejelentkezés beállítása", + "Team folder you can write to": "Csapatmappa, amelybe írhat", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "A felhasználók nem tölthetnek le biztonsági mentést, CSV-t vagy átviteli fájlt. Személyes adatcsomagjuk elérhető marad.", + "Users cannot save these secret types in a personal folder.": "A felhasználók nem menthetik ezeket a titoktípusokat személyes mappába.", + "Vault policies": "Széfszabályok", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Szervezete nem engedi a személyes széf exportálását. A beállításokban lévő személyes adatcsomagja elérhető marad.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Szervezete ezeket a titkokat csapatmappában tartja. Helyezze át mindegyiket egy csapatmappába.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Szervezete ezt a titoktípust csapatmappában tartja. Válassza az egyik saját csapatmappáját, vagy egyet, amelybe írhat.", + "Your organisation requires two-factor login before you can open your vault.": "Szervezete kétlépcsős bejelentkezést követel meg, mielőtt megnyithatja a széfét.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "A felhasználók választják ki, meddig maradjon feloldva a bővítmény tétlenség esetén. Ön állítja be a leghosszabb választható időt.", + "Longest idle time before the extension locks": "Leghosszabb tétlen idő a bővítmény zárolása előtt", + "1 minute": "1 perc", + "5 minutes": "5 perc", + "15 minutes": "15 perc", + "1 hour": "1 óra", + "4 hours": "4 óra", + "Connector": "Összekötő", + "Directory (tenant) ID": "Címtár (bérlő) azonosítója", + "Application (client) ID": "Alkalmazás (ügyfél) azonosítója", + "Data collection rule immutable ID": "Az adatgyűjtési szabály állandó azonosítója", + "Stream name": "Adatfolyam neve", + "Splunk index (optional)": "Splunk index (nem kötelező)", + "Sourcetype (optional)": "Sourcetype (nem kötelező)", + "Leave blank to keep the current one": "Hagyja üresen a jelenlegi megtartásához", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogon keresztül", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Adatgyűjtési végpont (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Ügyfélkulcs (csak írható)", + "HEC token (write-only)": "HEC token (csak írható)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Továbbítsa az engedélyezett naplózási eseményeket a Splunkba, a Microsoft Sentinelbe, egy syslog fogadóba vagy egy webhookba. Az üzenetek csak megtisztított metaadatokat tartalmaznak: titkos érték, név, bejelentkezés vagy titkosított szöveg soha nem hagyja el a kiszolgálót.", + "%n change waiting to sync": "%n módosítás várakozik szinkronizálásra", + "%n changes waiting to sync": "%n módosítás várakozik szinkronizálásra", + "Changes that could not sync": "Módosítások, amelyeket nem sikerült szinkronizálni", + "Choose a version": "Verzió kiválasztása", + "Copy value": "Érték másolása", + "Deleted": "Törölve", + "Discard": "Elvetés", + "Keep my offline change": "Az offline módosításom megtartása", + "Keep the server version": "A kiszolgáló verziójának megtartása", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "A Keepiq offline csak olvasható. A rendszergazda nem kapcsolta be az offline szerkesztést.", + "Let users edit secrets offline": "A felhasználók offline is szerkeszthetik a titkokat", + "Not synced yet": "Még nincs szinkronizálva", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Az offline módosítások az eszközön maradnak, a felhasználónak titkosítva, és a következő online feloldáskor szinkronizálódnak. A megosztáshoz, mappákhoz és mellékletekhez továbbra is kapcsolat kell.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. A szerkesztések, áthelyezések és törlések ezen az eszközön maradnak, és szinkronizálódnak, amikor újra online lesz. A megosztáshoz és mellékletekhez kapcsolat kell.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. A módosításai ezen az eszközön maradnak, és szinkronizálódnak, amikor újra online lesz. Utolsó szinkronizálás: {when}.", + "Open my changes": "Módosításaim megnyitása", + "Sharing needs a connection": "A megosztáshoz kapcsolat kell", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Valaki módosította ezt a titkot a kiszolgálón az offline másolata elkészülte után. Válassza ki, melyik verziót tartja meg.", + "Sync or discard your offline changes before you rotate your keys.": "A kulcsok cseréje előtt szinkronizálja vagy vesse el az offline módosításait.", + "That password did not open your changes.": "Ez a jelszó nem nyitotta meg a módosításait.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Az offline pillanatkép titkosított titkokat tárol (csak a felhasználó mesterjelszavából származtatott kulccsal nyithatók meg, pontosan úgy, mint a kiszolgálón), és tároláskor titkosítja a neveket, URL-eket és mappaneveket. Az offline hozzáférés csak olvasható, hacsak lent nem engedélyezi az offline szerkesztést. Kapcsolja ki azokon az eszközökön, amelyek soha nem tárolhatnak hitelesítő adatokat; a kikapcsolás a következő betöltéskor törli a meglévő gyorsítótárakat.", + "The previous vault copy is gone, so these changes cannot be opened.": "A széf korábbi másolata már nincs meg, így ezek a módosítások nem nyithatók meg.", + "The server version": "A kiszolgáló verziója", + "This secret changed while you were offline": "Ez a titok megváltozott, amíg offline volt", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ezt a titkot offline törölte, de azóta módosították a kiszolgálón. Válassza ki, melyik verziót tartja meg.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "A kulcsait egy másik eszközön lecserélték. Adja meg korábbi mesterjelszavát az offline módosítások szinkronizálásához, vagy vesse el őket.", + "Your offline change": "Az offline módosítása", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.","%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította őket. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n elem nem ábrázolható CXF-ben, ezért kihagyásra kerül.","%n elem nem ábrázolható CXF-ben, ezért kihagyásra kerülnek."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n régebbi verzió el lett vetve, mert csak a legutóbbi előzmények vihetők át.","%n régebbi verzió el lett vetve, mert csak a legutóbbi előzmények vihetők át."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n titokmásolatot még titkosítani és megosztani kell.","%n titokmásolatot még titkosítani és megosztani kell."], + "_%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 titkot nem sikerült visszafejteni, és nincs benne ebben az exportban.","%n titkot nem sikerült visszafejteni, és nincsenek benne ebben az exportban."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n titkot nem sikerült visszafejteni a régi kulcsával, ezért nem lett átköltöztetve.","%n titkot nem sikerült visszafejteni a régi kulcsával, ezért nem lettek átköltöztetve."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n titok nem lett átköltöztetve.","%n titok nem lett átköltöztetve."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n titok még mindig az előző kulcsával van titkosítva.","%n titok még mindig az előző kulcsával van titkosítva."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n titok kihagyásra került, mert az utódnak még nincs másolata — adja hozzá az utódot a mappához, és futtassa újra.","%n titok kihagyásra került, mert az utódnak még nincs másolata — adja hozzá az utódot a mappához, és futtassa újra."], + "_%n secret_::_%n secrets_": ["%n titok","%n titok"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudja megnyitni a széfet, amíg ez be van kapcsolva.","A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudják megnyitni a széfet, amíg ez be van kapcsolva."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Befejezés mégis, %n titokhoz való hozzáférés elvesztésével","Befejezés mégis, %n titokhoz való hozzáférés elvesztésével"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n új tag hozzáférést kapott egy csapatmappához.","%n új tag hozzáférést kapott egy csapatmappához."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Kulcsrotáció befejezve. %n titok újra lett titkosítva az új kulcsával.","Kulcsrotáció befejezve. %n titok újra lett titkosítva az új kulcsával."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.","A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["A csomag visszavonása %n vészhozzáférési névjegyet törölt.","A csomag visszavonása %n vészhozzáférési névjegyet törölt."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["%n alkalommal szerepelt szivárgásokban","%n alkalommal szerepelt szivárgásokban"], + "_shared with %n secret_::_shared with %n secrets_": ["%n titokkal megosztva","%n titokkal megosztva"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ez a mappa közvetlenül %n titkot tartalmaz.","Ez a mappa közvetlenül %n titkot tartalmaz."], + "_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._": ["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é.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n módosítás várakozik szinkronizálásra","%n módosítás várakozik szinkronizálásra"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["A felhasználó még a(z) {groups} csoportban van, amely egy csapatmappa tagja. Távolítsa el a csoportból, vagy tiltsa le a fiókot.","A felhasználó még a(z) {groups} csoportokban van, amelyek csapatmappák tagjai. Távolítsa el a csoportokból, vagy tiltsa le a fiókot."], + "Allow approval from another device": "Jóváhagyás engedélyezése másik eszközről", + "App": "Alkalmazás", + "Approve a new device": "Új eszköz jóváhagyása", + "Approve from another device": "Jóváhagyás másik eszközről", + "Asked at": "Kérés ideje", + "Check that the new device shows these words:": "Ellenőrizze, hogy az új eszköz ezeket a szavakat mutatja:", + "Denied. If you did not ask, end your other sessions:": "Elutasítva. Ha nem Ön kérte, zárja le a többi munkamenetét:", + "Device": "Eszköz", + "IP address": "IP-cím", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "A felhasználók feloldhatnak egy új böngészőt, ha jóváhagyják egy olyan eszközről, ahol a Keepiq már fel van oldva.", + "New device approval": "Új eszközök jóváhagyása", + "Nextcloud security settings": "Nextcloud biztonsági beállítások", + "Only approve a device you are using right now.": "Csak olyan eszközt hagyjon jóvá, amelyet éppen most használ.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Nyissa meg a Keepiq-et egy eszközön, ahol fel van oldva, és hagyja jóvá ezt az eszközt. Ellenőrizze, hogy ugyanazokat a szavakat mutatja:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "A jóváhagyó eszköz lepecsételi a feloldókulcsot az új eszköz számára. A szerver csak továbbítja, és nem tudja megnyitni.", + "The master password is not right, or the request has ended.": "A mesterjelszó nem megfelelő, vagy a kérés lezárult.", + "The request expired. Ask again or use your master password.": "A kérés lejárt. Kérje újra, vagy használja a mesterjelszavát.", + "The request was denied.": "A kérés el lett utasítva.", + "Too many requests. Try again in an hour or use your master password.": "Túl sok kérés. Próbálja újra egy óra múlva, vagy használja a mesterjelszavát.", + "Unknown device": "Ismeretlen eszköz", + "Web app": "Webalkalmazás", + "A device": "Egy eszköz", + "A new device asks to open your vault": "Egy új eszköz kéri a széfje megnyitását", + "%s asks to be approved. Only approve a device you are using right now.": "%s jóváhagyást kér. Csak olyan eszközt hagyjon jóvá, amelyet éppen most használ.", + "Access ends on (optional)": "Hozzáférés vége (nem kötelező)", + "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.": "A Keepiq alkalmazásai nem jelenítik meg és nem másolják a jelszót. Műszaki tudással rendelkező személy ettől még kiolvashatja a saját eszközéről. Változtassa meg, amikor a hozzáférése véget ér.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ez a titok csak használható. Jelentkezzen be a Keepiq böngészőbővítményén keresztül.", + "Until {date}": "Eddig: {date}", + "Use only": "Csak használat", + "Use only (can sign in, cannot view or copy)": "Csak használat (bejelentkezhet, nem tekintheti meg és nem másolhatja)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ezzel a bejelentkezéssel a Keepiq böngészőbővítményén keresztül jelentkezhet be. A tulajdonos úgy döntött, hogy nem tekintheti meg és nem másolhatja.", + "Your access ends on {date}": "A hozzáférése ekkor ér véget: {date}", + "Your access to this secret has ended": "A hozzáférése ehhez a titokhoz véget ért", + "Your access to \"%s\" ends tomorrow": "A(z) „%s” elemhez való hozzáférése holnap véget ér", + "Your access to \"%s\" has ended": "A(z) „%s” elemhez való hozzáférése véget ért", + "%1$s no longer has access to \"%2$s\"": "%1$s már nem fér hozzá a(z) „%2$s” elemhez", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s láthatta ezt a jelszót. Változtassa meg, ha %1$s már nem ismerheti.", + "%s could not view this password in Keepiq.": "%s nem tekinthette meg ezt a jelszót a Keepiqben.", + "{approvals} of {threshold} approvals": "{approvals} / {threshold} jóváhagyás", + "a recovery officer": "egy helyreállítási felelős", + "Account recovery": "Fiók-helyreállítás", + "Approvals needed": "Szükséges jóváhagyások", + "Ask {user} which words they see, by phone or in person. They must be:": "Kérdezze meg {user} felhasználót telefonon vagy személyesen, milyen szavakat lát. Ezeknek kell lenniük:", + "Check again": "Újraellenőrzés", + "Create the recovery key": "Helyreállítási kulcs létrehozása", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Hozza létre a helyreállítási kulcsot. A böngészője elkészíti, és minden felelősnek ad egy másolatot, amelyet csak ő nyithat meg.", + "Decline": "Elutasítás", + "Enrol in account recovery": "Jelentkezés a fiók-helyreállításra", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Jelentkezzen, hogy a szervezete segíthessen visszakapni a széfjét, ha elfelejti a mesterjelszavát.", + "Every user is enrolled": "Minden felhasználó jelentkezett", + "Finish the recovery in the browser you asked from.": "Fejezze be a helyreállítást abban a böngészőben, amelyből kérte.", + "Forgot your master password?": "Elfelejtette a mesterjelszavát?", + "Hand the key over": "Kulcs átadása", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "A mesterjelszavukat elfelejtő felhasználók visszakaphatják a széfjüket, az Ön által kijelölt helyreállítási felelősök jóváhagyásával.", + "New master password": "Új mesterjelszó", + "No one is asking to recover their account.": "Senki sem kéri a fiókja helyreállítását.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Még nincs helyreállítási kulcs. Az egyik felelős hozza létre a Keepiq-beállításaiban.", + "Off": "Ki", + "Officer {user} has no encryption set up yet.": "{user} felelősnek még nincs beállítva titkosítás.", + "Officers (user IDs, separated by commas)": "Felelősök (felhasználói azonosítók, vesszővel elválasztva)", + "Policy": "Szabályzat", + "Publish this fingerprint internally, so users can check it before they enrol.": "Tegye közzé ezt az ujjlenyomatot a szervezeten belül, hogy a felhasználók jelentkezés előtt ellenőrizhessék.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Helyreállítva {officer} segítségével. Cserélje le most a széfkulcsát: Beállítások, Biztonság: \"A mesterjelszavam kiszivárgott\".", + "Recovery key fingerprint: {fingerprint}": "Helyreállítási kulcs ujjlenyomata: {fingerprint}", + "Recovery officer": "Helyreállítási felelős", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Az eltávolított felelősök most elveszítik a másolatukat, de korábban megnyithatták. Kérjen meg egy felelőst, hogy hozzon létre új helyreállítási kulcsot.", + "Repeat the new master password": "Ismételje meg az új mesterjelszót", + "Retire this recovery key": "Helyreállítási kulcs kivezetése", + "Set the new master password": "Új mesterjelszó beállítása", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "A helyreállítási tanúsítványt nem ez a Keepiq állította ki. Ne jelentkezzen, és értesítse a rendszergazdát.", + "The words match, approve": "A szavak egyeznek, jóváhagyás", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ez a felhasználó jelentkezett a fiók-helyreállításra. A helyreállítás megtartja a titkait; a visszavonás törli a jelentkezését.", + "Users may enrol": "A felhasználók jelentkezhetnek", + "Withdraw from account recovery": "Kilépés a fiók-helyreállításból", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jelentkezett a fiók-helyreállításra. Helyreállítási kulcs ujjlenyomata: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jelentkezett. Ha elfelejti a mesterjelszavát, a szervezete segíthet visszakapni a széfjét.", + "Your key is back. Choose a new master password.": "A kulcsa visszakerült. Válasszon új mesterjelszót.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "A helyreállítási felelősök értesítést kaptak. Olvassa fel nekik ezeket a szavakat, amikor felhívják vagy találkoznak:", + "You are now an account recovery officer": "Mostantól fiók-helyreállítási felelős", + "%s asks to recover their account. Compare the words with them before you approve.": "%s kéri a fiókja helyreállítását. Jóváhagyás előtt hasonlítsa össze vele a szavakat.", + "A user": "Egy felhasználó", + "Your account recovery request was declined": "A fiók-helyreállítási kérelmét elutasították", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "A fiók-helyreállítás kész. Nyissa meg a Keepiq alkalmazást abban a böngészőben, amelyből kérte.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} egy új eszköz egyszeri feloldását kéri. A mesterjelszó változatlan marad.", + "Ask your organisation instead": "Kérje inkább a szervezetétől", + "The request ended. Ask again or use your master password.": "A kérés lezárult. Kérje újra, vagy használja a mesterjelszavát.", + "Added by {user}": "Hozzáadta: {user}", + "Editor": "Szerkesztő", + "Manager": "Kezelő", + "Role of {member}": "{member} szerepköre", + "Team folders you manage": "Az Ön által kezelt csapatmappák", + "Viewer": "Megtekintő", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Önnek nincs másolata ezekről a titkokról, ezért az új tagok még nem kapták meg őket. A tulajdonos megoszthatja őket: {names}", + "Admin areas": "Felügyeleti területek", + "Give a group only the parts of Keepiq administration it needs.": "Csak azokat a Keepiq-felügyeleti részeket adja egy csoportnak, amelyekre szüksége van.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegáljon egy vagy több területet egy csoportnak a felügyeleti jogosultságok oldalon. A példány rendszergazdái minden területtel rendelkeznek.", + "Open administration privileges": "Felügyeleti jogosultságok megnyitása", + "Policies": "Házirendek", + "Applications and machine access": "Alkalmazások és gépi hozzáférés", + "People and offboarding": "Emberek és kilépés", + "Audit and compliance": "Naplózás és megfelelőség", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzió, hitelesítésszolgáltató, mellékletek, offline gyorsítótár, szivárgásellenőrzés, titoktípusok és biztonsági mentések", + "master password, organisation password, vault policies, rotation, version history and trash": "mesterjelszó, szervezeti jelszó, széfházirendek, rotáció, verzióelőzmények és kuka", + "application queue, application requests and machine leases": "alkalmazássor, alkalmazáskérések és gépi bérletek", + "team offboarding, encryption suites and admin handover": "csapatból való kilépés, titkosítási csomagok és rendszergazdai átvétel", + "audit log, compliance reports, SIEM export and honey alerts": "naplófájl, megfelelőségi jelentések, SIEM-export és csaliriasztások", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Egy titok hány verzióját és mennyi ideig őrizzük meg, és a törölt titkok mennyi ideig maradnak a kukában.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "A titkosított mellékletek korlátai, amelyeket a kiszolgáló a tárolt titkosított bájtokban érvényesít.", + "Type the suite ID again to confirm": "A megerősítéshez írja be újra a csomag azonosítóját", + "This does not match the suite ID.": "Ez nem egyezik a csomag azonosítójával.", + "Confirm with your master password": "Megerősítés a főjelszóval", + "Confirm": "Megerősítés", + "That master password is not right.": "Ez a főjelszó nem helyes.", + "You are sharing with someone new. Enter your master password to confirm.": "Új személlyel oszt meg. A megerősítéshez adja meg a főjelszavát.", + "Enter your master password to confirm this share.": "A megosztás megerősítéséhez adja meg a főjelszavát.", + "Enter your master password to confirm this delegation.": "A delegálás megerősítéséhez adja meg a főjelszavát.", + "Approve {member}": "{member} jóváhagyása", + "Recipient": "Címzett", + "No vault yet": "Még nincs tárolója", + "No matching users": "Nincs egyező felhasználó", + "Partner organisations": "Partnerszervezetek", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Cseréljen titkokat egy másik Keepiqkel. Mindkét rendszergazda hozzáadja a másikat, és mentés előtt telefonon vagy személyesen összeveti a gyökér-ujjlenyomatokat.", + "Federation needs Nextcloud 33 or later.": "A föderációhoz Nextcloud 33 vagy újabb szükséges.", + "Your root fingerprint": "Az Ön gyökér-ujjlenyomata", + "No partners yet.": "Még nincs partner.", + "Users here may share to this partner": "Az itteni felhasználók megoszthatnak ezzel a partnerrel", + "This partner may share to users here": "Ez a partner megoszthat az itteni felhasználókkal", + "Partner address": "A partner címe", + "Check partner": "Partner ellenőrzése", + "Partner root fingerprint": "A partner gyökér-ujjlenyomata", + "I compared this fingerprint with the partner's administrator": "Összevetettem ezt az ujjlenyomatot a partner rendszergazdájával", + "Add partner": "Partner hozzáadása", + "A secret from another organisation": "Titok egy másik szervezettől", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s megosztotta Önnel a következőt: \"%2$s\". Fogadja el a Más szervezetektől érkezett oldalon.", + "Incoming from other organisations": "Más szervezetektől érkezett", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "A partnerszervezetek munkatársai megoszthatnak Önnel egy titkot. Fogadja el, hogy írásvédett másolatot tartson a tárolójában.", + "Nothing shared with you yet": "Még semmit sem osztottak meg Önnel", + "Secrets that people in partner organisations share with you appear here.": "Itt jelennek meg azok a titkok, amelyeket a partnerszervezetek munkatársai megosztanak Önnel.", + "From {sender}": "Feladó: {sender}", + "Accept": "Elfogadás", + "Open in vault": "Megnyitás a tárolóban", + "The other organisation did not hand over the secret. Try again later.": "A másik szervezet nem adta át a titkot. Próbálja újra később.", + "Set up your vault before you accept a shared secret.": "Állítsa be a tárolóját, mielőtt elfogad egy megosztott titkot.", + "Something went wrong. Try again.": "Hiba történt. Próbálja újra.", + "Waiting for your answer": "Az Ön válaszára vár", + "In your vault, read-only": "Az Ön tárolójában, csak olvasható", + "Withdrawn by the sender": "A feladó visszavonta", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ezt egy másik szervezettől osztotta meg. Elolvashatja, de nem módosíthatja és nem oszthatja meg.", + "Someone": "Valaki", + "Share with someone at another organisation": "Megosztás valakivel egy másik szervezetből", + "Their account at the other organisation": "Az illető fiókja a másik szervezetnél", + "Check account": "Fiók ellenőrzése", + "Certificate fingerprint of {account}": "{account} tanúsítványának ujjlenyomata", + "Compare it with them by phone if you want to be sure.": "Ha biztos akar lenni, telefonon egyeztesse az illetővel.", + "Shared. {account} can accept it in their own vault.": "Megosztva. {account} a saját tárolójában fogadhatja el.", + "The certificate could not be verified. Nothing was shared.": "A tanúsítványt nem sikerült ellenőrizni. Semmi sem lett megosztva.", + "That organisation is not one of your partners.": "Ez a szervezet nem tartozik a partnerei közé.", + "No one with that account can receive secrets from you.": "Ezzel a fiókkal senki sem fogadhat Öntől titkokat.", + "The other organisation did not answer. Try again later.": "A másik szervezet nem válaszolt. Próbálja újra később.", + "This secret is already shared with that account.": "Ez a titok már meg van osztva ezzel a fiókkal.", + "Other organisations": "Más szervezetek", + "Receive secrets from other organisations": "Titkok fogadása más szervezetektől", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "A partnerszervezetek munkatársai ekkor megtalálhatják a fiókját, és titkokat oszthatnak meg Önnel. Mindegyiket Ön fogadja el.", + "Shared": "Megosztva", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Szüneteltetve: megváltozott a címzett tanúsítványa vagy a partnerség. Vonja vissza, vagy ossza meg újra.", + "Their organisation did not get the last change. Revoke it or share again.": "A címzett szervezete nem kapta meg az utolsó módosítást. Vonja vissza, vagy ossza meg újra.", + "Being withdrawn": "Visszavonás folyamatban", + "Shared with another organisation": "Megosztva egy másik szervezettel", + "Change sent to another organisation": "Módosítás elküldve egy másik szervezetnek", + "Share with another organisation revoked": "Megosztás egy másik szervezettel visszavonva", + "Share with another organisation paused": "Megosztás egy másik szervezettel szüneteltetve", + "Another organisation did not get a change": "Egy másik szervezet nem kapott meg egy módosítást", + "Secret received from another organisation": "Titok érkezett egy másik szervezettől", + "Secret from another organisation accepted": "Egy másik szervezettől kapott titok elfogadva", + "Secret from another organisation declined": "Egy másik szervezettől kapott titok elutasítva", + "Copy from another organisation updated": "Egy másik szervezettől kapott másolat frissítve", + "Copy from another organisation removed": "Egy másik szervezettől kapott másolat eltávolítva", + "Declined: they removed their copy. Share again if they need it.": "Elutasítva: a címzett eltávolította a másolatát. Ossza meg újra, ha szüksége van rá.", + "Recipient at another organisation removed their copy": "Egy másik szervezet címzettje eltávolította a másolatát", + "Removed the user from %n team folder.": "A felhasználó eltávolítva %n csapatmappából.", + "Removed the user from %n team folders.": "A felhasználó eltávolítva %n csapatmappából.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["A felhasználó eltávolítva %n csapatmappából.","A felhasználó eltávolítva %n csapatmappából."], + "A restored copy came from a share that has ended. It stays read-only.": "Egy visszaállított másolat egy véget ért megosztásból származik. Csak olvasható marad.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "A visszaállított másolatot megosztó szervezet nem érhető el. A másolat csak olvasható marad, és nem követi a változtatásaikat.", + "Recipient at another organisation restored their copy": "Egy másik szervezet címzettje visszaállította a másolatát" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/hu.json b/l10n/hu.json index d5bdabab7..d01e759f7 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1181,7 +1181,567 @@ "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é." + "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é.", + "Shared with groups": "Megosztva csoportokkal", + "Not shared with any group yet.": "Még nincs megosztva egy csoporttal sem.", + "Revoke the share with {group}": "Megosztás visszavonása ezzel: {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Megosztva ezzel: {group}. {received} tag megkapta, {skipped} nem, mert még nem állított be titkosítást.", + "Search groups": "Csoportok keresése", + "Failed to share": "A megosztás sikertelen", + "Columns": "Oszlopok", + "Column {number}": "{number}. oszlop", + "Map one column to Name. Every secret needs a name.": "Rendeljen egy oszlopot a névhez. Minden titoknak kell név.", + "Notes": "Jegyzetek", + "Do not import": "Ne importálja", + "Hide this value": "Érték elrejtése", + "Show this value": "Érték megjelenítése", + "Defaults": "Alapértelmezések", + "New secrets start as this type, and your secret list opens in this view.": "Az új titkok ezzel a típussal indulnak, a titkok listája pedig ebben a nézetben nyílik meg.", + "Default item type": "Alapértelmezett elemtípus", + "Cards": "Kártyák", + "Table": "Táblázat", + "Could not save your default": "Nem sikerült menteni az alapértelmezést", + "Recently used": "Nemrég használt", + "Opened": "Megnyitva", + "You have not opened any secrets yet": "Még nem nyitottál meg egy titkot sem", + "Could not delete the item type.": "Az elemtípust nem sikerült törölni.", + "Could not load the item types.": "Az elemtípusokat nem sikerült betölteni.", + "Could not save the item type.": "Az elemtípust nem sikerült menteni.", + "Delete item type": "Elemtípus törlése", + "Edit item type": "Elemtípus szerkesztése", + "Fields": "Mezők", + "Fields: {count}": "Mezők: {count}", + "Hidden": "Rejtett", + "Item types": "Elemtípusok", + "Move up": "Feljebb", + "New item type": "Új elemtípus", + "No item types defined yet.": "Még nincs megadva elemtípus.", + "Required": "Kötelező", + "Text": "Szöveg", + "This field is required": "Ez a mező kötelező", + "Web address": "Webcím", + "{label} (required)": "{label} (kötelező)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Törlöd ezt: „{name}”? Az ilyen típusú titkok olvashatók maradnak, és Bejelentkezés elemek lesznek.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Az itt megadott elemtípusok mindenkinek megjelennek az Új titok ablakban, az általad választott mezőkkel.", + "Secret moved to the trash": "Titok áthelyezve a kukába", + "Secret restored from the trash": "Titok visszaállítva a kukából", + "Secret deleted for good": "Titok véglegesen törölve", + "Secret archived": "Titok archiválva", + "Secret unarchived": "Titok kivéve az archívumból", + "Unarchive": "Kivétel az archívumból", + "Could not archive the secret": "A titkot nem sikerült archiválni", + "Could not unarchive the secret": "A titkot nem sikerült kivenni az archívumból", + "Archive {count} secrets": "Titkok archiválása: {count}", + "Unarchive {count} secrets": "Titkok kivétele az archívumból: {count}", + "Restore {count} secrets": "Titkok visszaállítása: {count}", + "Delete {count} secrets for good": "Titkok végleges törlése: {count}", + "Done for {ok} of {total} secrets": "Kész: {ok} / {total} titok", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Az archivált titkok eltűnnek a széf listájából, a keresésből, az automatikus kitöltésből és az állapotjelentésből. A megosztásaik megmaradnak. Az Archívumban találod őket.", + "These secrets come back to the vault list, search and autofill.": "Ezek a titkok visszakerülnek a széf listájába, a keresésbe és az automatikus kitöltésbe.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ezek a titkok visszakerülnek a széf listájába. A régi megosztásaik nem jönnek vissza, ezért szükség esetén oszd meg őket újra.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ez törli a titkokat a mellékleteikkel és a verzióelőzményeikkel együtt. Nem vonható vissza.", + "Delete for good": "Végleges törlés", + "Trash": "Kuka", + "The trash is empty": "A kuka üres", + "No archived secrets": "Nincsenek archivált titkok", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "A törölt titkok itt várnak a megőrzési idő végéig, utána véglegesen törlődnek.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiválj egy titkot a részletek panelen, hogy kimaradjon a széf listájából, a keresésből és az automatikus kitöltésből.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Korlátok a titkosított mellékletekhez (a kiszolgálón érvényesítve a tárolt titkosított bájtokra), a verzióelőzmények megőrzése és hogy meddig maradnak a törölt titkok a kukában.", + "Days a deleted secret stays in the trash (1 to 365)": "Hány napig marad egy törölt titok a kukában (1–365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ez a kukába helyezi a titkot, és most megszünteti a megosztásait. A megőrzési idő végéig visszaállíthatod a kukából: 30 nap, hacsak a rendszergazda nem módosította.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ez a kukába helyezi a titkokat ({count}), és most megszünteti a megosztásaikat. A megőrzési idő végéig visszaállíthatod őket a kukából.", + "Remove {name} from favourites": "{name} eltávolítása a kedvencek közül", + "Add {name} to favourites": "{name} hozzáadása a kedvencekhez", + "Could not change the favourite": "Nem sikerült módosítani a kedvencet", + "Remove from favourites": "Eltávolítás a kedvencek közül", + "Add to favourites": "Hozzáadás a kedvencekhez", + "Tags": "Címkék", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "A címkék nincsenek titkosítva. A kiszolgáló rendszergazdái olvashatják őket, akárcsak a mappaneveket.", + "Favourites": "Kedvencek", + "Filter by tag": "Szűrés címke szerint", + "All tags": "Minden címke", + "Last used": "Utoljára használva", + "Tags for {count} secrets": "Címkék {count} titokhoz", + "Tag": "Címke", + "Remove tag": "Címke eltávolítása", + "Add tag": "Címke hozzáadása", + "Could not change the tags. Try again.": "Nem sikerült módosítani a címkéket. Próbálja újra.", + "Could not approve the application. It is still in the queue.": "Nem sikerült jóváhagyni a kérelmet. Még mindig a sorban van.", + "Could not reject the application. It is still in the queue.": "Nem sikerült elutasítani a kérelmet. Még mindig a sorban van.", + "Removed the user from {count} team folders.": "A felhasználó eltávolítva {count} csapatmappából.", + "Approve a share": "Megosztás jóváhagyása", + "This approval link is incomplete. Open it again from the notification.": "Ez a jóváhagyási hivatkozás hiányos. Nyissa meg újra az értesítésből.", + "Deny": "Elutasítás", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} csatlakozott egy csoporthoz, amellyel titkot oszt meg. Megosztja vele is a titkot?", + "{requester} asks you to share a secret with {user}.": "{requester} arra kéri, hogy ossza meg a titkot vele: {user}.", + "Shared. The recipient can now open the secret.": "Megosztva. A címzett most már megnyithatja a titkot.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "A címzett még nem állította be a Keepiqet, így semmi sem lett megosztva. Próbálja újra, ha már megtette.", + "Could not share the secret. Only its owner can approve this.": "Nem sikerült megosztani a titkot. Ezt csak a tulajdonosa hagyhatja jóvá.", + "Could not share the secret. Try again.": "Nem sikerült megosztani a titkot. Próbálja újra.", + "Denied. Nothing was shared.": "Elutasítva. Semmi sem lett megosztva.", + "Could not deny the request. Try again.": "Nem sikerült elutasítani a kérést. Próbálja újra.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s arra kéri, hogy ossza meg a(z) \"%2$s\" titkot vele: %3$s.", + "Expires on (optional)": "Lejárat (nem kötelező)", + "Hand over to": "Átadás neki", + "Choose a recipient": "Válasszon címzettet", + "Hand over temporarily": "Ideiglenes átadás", + "Expiry rules": "Lejárati szabályok", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Állítsa be, meddig élhetnek egy elemtípus vagy egy mappa jelszavai, és mikor kapjon emlékeztetőt. Ha több dátum is érvényes, a legkorábbi számít.", + "Delete rule": "Szabály törlése", + "Set by your administrator": "A rendszergazda állította be", + "No expiry rules yet.": "Még nincsenek lejárati szabályok.", + "Applies to": "Vonatkozik erre", + "Item type": "Elemtípus", + "Maximum age in days (empty for reminders only)": "Maximális kor napokban (üres, ha csak emlékeztető kell)", + "Remind me this many days before, comma separated": "Emlékeztessen ennyi nappal előtte, vesszővel elválasztva", + "Save rule": "Szabály mentése", + "An item type": "Egy elemtípus", + "A folder": "Egy mappa", + "Folder {name}": "Mappa: {name}", + "Type {name}": "Típus: {name}", + "Expires after {days} days": "{days} nap után lejár", + "Reminders {days} days before": "Emlékeztetők {days} nappal előtte", + "Could not save the expiry rule.": "Nem sikerült menteni a lejárati szabályt.", + "Could not delete the expiry rule.": "Nem sikerült törölni a lejárati szabályt.", + "All statuses": "Minden állapot", + "Compromised": "Kompromittált", + "Could not load the members.": "A tagok betöltése nem sikerült.", + "Emergency contact": "Vészhelyzeti kapcsolat", + "Leaving user": "Távozó felhasználó", + "No": "Nem", + "No users match this filter.": "Egy felhasználó sem felel meg ennek a szűrőnek.", + "Not set up": "Nincs beállítva", + "Revoke suite": "Csomag visszavonása", + "Revoked": "Visszavonva", + "Search users": "Felhasználók keresése", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Nézze meg, mely felhasználók állítottak be széfet. Indítsa el a kiléptetést vagy vonjon vissza egy csomagot egy sorból.", + "Successor": "Utód", + "Team folders": "Csapatmappák", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "A felhasználó még a(z) {groups} csoportban van, amely egy csapatmappa tagja. Távolítsa el a csoportból, vagy tiltsa le a fiókot.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "A felhasználó még a(z) {groups} csoportokban van, amelyek csapatmappák tagjai. Távolítsa el a csoportokból, vagy tiltsa le a fiókot.", + "Vault status": "Széf állapota", + "Yes": "Igen", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "A CXF-exportálás TITKOSÍTATLAN. A letöltött fájlban minden jelszó és bejelentkezési név olvasható lesz egyszerű szövegként. Tárolja biztonságosan, és használat után azonnal törölje.", + "Root certificate expiring soon": "A gyökértanúsítvány hamarosan lejár", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "A széf gyökértanúsítványa %1$d nap múlva lejár. Újítsa meg előtte. A megújítás minden titkosítási csomagot újra aláír.", + "Compromise recovery aborted": "A kompromittálás utáni helyreállítás megszakítva", + "Key rotation ended by a compromise revoke": "A kulcscserét egy kompromittálás miatti visszavonás fejezte be", + "Encryption suite revoke refused": "A titkosítási csomag visszavonása elutasítva", + "Master password proof refused": "A mesterjelszó igazolása elutasítva", + "Your current master password": "A jelenlegi mesterjelszava", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította őket. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ezek a vészhelyzeti kapcsolattartók nem kerültek át az új kulcsára. A vészhelyzeti hozzáférésüket eltávolítottuk. Adja hozzá őket újra a Vészhelyzeti hozzáférésben, ha még szeretné.", + "Renew root certificate": "Gyökértanúsítvány megújítása", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ez új gyökér- és köztes tanúsítványt hoz létre. Minden aktív titkosítási csomag újra aláírásra kerül. Ez nem vonható vissza.", + "Renew root": "Gyökér megújítása", + "Root renewed. {n} encryption suites signed again.": "Gyökér megújítva. Újra aláírt titkosítási csomagok: {n}.", + "Could not renew the root certificate.": "A gyökértanúsítvány nem újítható meg.", + "Lease policy for this application": "Bérleti szabályzat ehhez az alkalmazáshoz", + "In force now: {default} seconds by default, {max} seconds at most.": "Jelenleg érvényes: alapértelmezés szerint {default} másodperc, legfeljebb {max} másodperc.", + "Leases are not renewable": "A bérletek nem újíthatók meg", + "Lease policy saved.": "Bérleti szabályzat mentve.", + "Leave a field empty to use the instance value.": "Hagyjon üresen egy mezőt a példány értékének használatához.", + "Instance value: {value}": "Példány értéke: {value}", + "Renewal": "Megújítás", + "Use the instance value ({value})": "A példány értékének használata ({value})", + "Allowed": "Engedélyezett", + "Not allowed": "Nem engedélyezett", + "Save lease policy": "Bérleti szabályzat mentése", + "Only an administrator can change this policy.": "Ezt a szabályzatot csak rendszergazda módosíthatja.", + "Could not save the lease policy.": "A bérleti szabályzat nem menthető.", + "{member} got access from {confirmer}.": "{member} hozzáférést kapott tőle: {confirmer}.", + "Automatically confirm new team folder members": "Új csapatmappa-tagok automatikus megerősítése", + "Gave %n new member access to a team folder.": "%n új tag hozzáférést kapott egy csapatmappához.", + "Gave %n new members access to a team folder.": "%n új tag hozzáférést kapott egy csapatmappához.", + "Give new team folder members access without waiting for the folder owner.": "Adjon hozzáférést az új tagoknak a mappa tulajdonosára várás nélkül.", + "New team folder members": "Új csapatmappa-tagok", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "A tulajdonos vagy egy írási joggal rendelkező tag erősíti meg őket a megnyitott széfből. A Keepiq soha nem fejt vissza a szerveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Várakozás egy írási joggal rendelkező tagra, hogy megnyissa a Keepiqet. Most is megoszthatja.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "A kompromittálásra adott válasz egy része sikertelen volt ({failed} lépés). Ellenőrizze a kiszolgáló naplóját, majd vonja vissza újra a csomagot a befejezéshez.", + "This also revoked suite {suite} and ended key migration {migration}.": "Ez a(z) {suite} csomagot is visszavonta, és befejezte a(z) {migration} kulcsmigrációt.", + "Revoking the second suite deleted %n emergency-access contact.": "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.", + "Revoking the second suite deleted %n emergency-access contacts.": "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.", + "A suite revoked as compromised cannot be reinstated.": "A kompromittáltként visszavont csomag nem állítható vissza.", + "Archives to keep": "Megtartandó archívumok", + "Back up every vault automatically": "Minden széf automatikus mentése", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Mentse minden széfet ütemezetten. Az archívumok csak titkosított szöveget tartalmaznak, és occ-vel állíthatók vissza.", + "Back up now": "Mentés most", + "Backup public key (PEM, optional)": "Mentési nyilvános kulcs (PEM, nem kötelező)", + "Backup requested for the next cron run": "Mentés kérve a következő cron-futásra", + "Encrypted": "Titkosított", + "Every (hours)": "Minden (óra)", + "Last backup {when} failed: {error}": "Az utolsó mentés {when} sikertelen: {error}", + "Last backup {when} succeeded.": "Az utolsó mentés {when} sikeres.", + "No archives yet.": "Még nincs archívum.", + "Size": "Méret", + "Vault backups": "Széfmentések", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Kulccsal minden archívum annak titkosítva készül. A privát kulcsot e szerveren kívül tartsa: ellenőrzéshez vagy visszaállításhoz kell.", + "Written": "Írva", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudja megnyitni a széfet, amíg ez be van kapcsolva.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudják megnyitni a széfet, amíg ez be van kapcsolva.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "A tartalékkódok nem számítanak. Ha felhasználói saját második faktorral rendelkező identitásszolgáltatón keresztül lépnek be, hagyja ki a csoportjaikat.", + "Block personal vault export": "Személyes széf exportjának tiltása", + "Keep work logins in team folders": "Munkahelyi belépések tartása csapatmappákban", + "Move to a team folder": "Áthelyezés csapatmappába", + "Not in a team folder": "Nincs csapatmappában", + "Only for these groups (empty is everyone)": "Csak ezeknek a csoportoknak (üres: mindenki)", + "Require two-factor login before the vault opens": "Kétlépcsős bejelentkezés megkövetelése a széf megnyitása előtt", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Szabályok minden széfre. Mindegyik mindenkire vonatkozik, vagy csak a kiválasztott csoportokra.", + "Secret types that belong in a team folder": "Csapatmappába tartozó titoktípusok", + "Set up two-factor login": "Kétlépcsős bejelentkezés beállítása", + "Team folder you can write to": "Csapatmappa, amelybe írhat", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "A felhasználók nem tölthetnek le biztonsági mentést, CSV-t vagy átviteli fájlt. Személyes adatcsomagjuk elérhető marad.", + "Users cannot save these secret types in a personal folder.": "A felhasználók nem menthetik ezeket a titoktípusokat személyes mappába.", + "Vault policies": "Széfszabályok", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Szervezete nem engedi a személyes széf exportálását. A beállításokban lévő személyes adatcsomagja elérhető marad.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Szervezete ezeket a titkokat csapatmappában tartja. Helyezze át mindegyiket egy csapatmappába.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Szervezete ezt a titoktípust csapatmappában tartja. Válassza az egyik saját csapatmappáját, vagy egyet, amelybe írhat.", + "Your organisation requires two-factor login before you can open your vault.": "Szervezete kétlépcsős bejelentkezést követel meg, mielőtt megnyithatja a széfét.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "A felhasználók választják ki, meddig maradjon feloldva a bővítmény tétlenség esetén. Ön állítja be a leghosszabb választható időt.", + "Longest idle time before the extension locks": "Leghosszabb tétlen idő a bővítmény zárolása előtt", + "1 minute": "1 perc", + "5 minutes": "5 perc", + "15 minutes": "15 perc", + "1 hour": "1 óra", + "4 hours": "4 óra", + "Connector": "Összekötő", + "Directory (tenant) ID": "Címtár (bérlő) azonosítója", + "Application (client) ID": "Alkalmazás (ügyfél) azonosítója", + "Data collection rule immutable ID": "Az adatgyűjtési szabály állandó azonosítója", + "Stream name": "Adatfolyam neve", + "Splunk index (optional)": "Splunk index (nem kötelező)", + "Sourcetype (optional)": "Sourcetype (nem kötelező)", + "Leave blank to keep the current one": "Hagyja üresen a jelenlegi megtartásához", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF syslogon keresztül", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Adatgyűjtési végpont (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Ügyfélkulcs (csak írható)", + "HEC token (write-only)": "HEC token (csak írható)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Továbbítsa az engedélyezett naplózási eseményeket a Splunkba, a Microsoft Sentinelbe, egy syslog fogadóba vagy egy webhookba. Az üzenetek csak megtisztított metaadatokat tartalmaznak: titkos érték, név, bejelentkezés vagy titkosított szöveg soha nem hagyja el a kiszolgálót.", + "%n change waiting to sync": "%n módosítás várakozik szinkronizálásra", + "%n changes waiting to sync": "%n módosítás várakozik szinkronizálásra", + "Changes that could not sync": "Módosítások, amelyeket nem sikerült szinkronizálni", + "Choose a version": "Verzió kiválasztása", + "Copy value": "Érték másolása", + "Deleted": "Törölve", + "Discard": "Elvetés", + "Keep my offline change": "Az offline módosításom megtartása", + "Keep the server version": "A kiszolgáló verziójának megtartása", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "A Keepiq offline csak olvasható. A rendszergazda nem kapcsolta be az offline szerkesztést.", + "Let users edit secrets offline": "A felhasználók offline is szerkeszthetik a titkokat", + "Not synced yet": "Még nincs szinkronizálva", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Az offline módosítások az eszközön maradnak, a felhasználónak titkosítva, és a következő online feloldáskor szinkronizálódnak. A megosztáshoz, mappákhoz és mellékletekhez továbbra is kapcsolat kell.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. A szerkesztések, áthelyezések és törlések ezen az eszközön maradnak, és szinkronizálódnak, amikor újra online lesz. A megosztáshoz és mellékletekhez kapcsolat kell.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. A módosításai ezen az eszközön maradnak, és szinkronizálódnak, amikor újra online lesz. Utolsó szinkronizálás: {when}.", + "Open my changes": "Módosításaim megnyitása", + "Sharing needs a connection": "A megosztáshoz kapcsolat kell", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Valaki módosította ezt a titkot a kiszolgálón az offline másolata elkészülte után. Válassza ki, melyik verziót tartja meg.", + "Sync or discard your offline changes before you rotate your keys.": "A kulcsok cseréje előtt szinkronizálja vagy vesse el az offline módosításait.", + "That password did not open your changes.": "Ez a jelszó nem nyitotta meg a módosításait.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Az offline pillanatkép titkosított titkokat tárol (csak a felhasználó mesterjelszavából származtatott kulccsal nyithatók meg, pontosan úgy, mint a kiszolgálón), és tároláskor titkosítja a neveket, URL-eket és mappaneveket. Az offline hozzáférés csak olvasható, hacsak lent nem engedélyezi az offline szerkesztést. Kapcsolja ki azokon az eszközökön, amelyek soha nem tárolhatnak hitelesítő adatokat; a kikapcsolás a következő betöltéskor törli a meglévő gyorsítótárakat.", + "The previous vault copy is gone, so these changes cannot be opened.": "A széf korábbi másolata már nincs meg, így ezek a módosítások nem nyithatók meg.", + "The server version": "A kiszolgáló verziója", + "This secret changed while you were offline": "Ez a titok megváltozott, amíg offline volt", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ezt a titkot offline törölte, de azóta módosították a kiszolgálón. Válassza ki, melyik verziót tartja meg.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "A kulcsait egy másik eszközön lecserélték. Adja meg korábbi mesterjelszavát az offline módosítások szinkronizálásához, vagy vesse el őket.", + "Your offline change": "Az offline módosítása", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad.", + "%n vészhelyzeti kapcsolattartónak függő hozzáférési kérelme volt, amikor a kulcscsere eltávolította őket. Ellenőrizze, ki kérte, mielőtt bárkit újra hozzáad." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n elem nem ábrázolható CXF-ben, ezért kihagyásra kerül.", + "%n elem nem ábrázolható CXF-ben, ezért kihagyásra kerülnek." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n régebbi verzió el lett vetve, mert csak a legutóbbi előzmények vihetők át.", + "%n régebbi verzió el lett vetve, mert csak a legutóbbi előzmények vihetők át." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n titokmásolatot még titkosítani és megosztani kell.", + "%n titokmásolatot még titkosítani és megosztani kell." + ], + "_%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 titkot nem sikerült visszafejteni, és nincs benne ebben az exportban.", + "%n titkot nem sikerült visszafejteni, és nincsenek benne ebben az exportban." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n titkot nem sikerült visszafejteni a régi kulcsával, ezért nem lett átköltöztetve.", + "%n titkot nem sikerült visszafejteni a régi kulcsával, ezért nem lettek átköltöztetve." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n titok nem lett átköltöztetve.", + "%n titok nem lett átköltöztetve." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n titok még mindig az előző kulcsával van titkosítva.", + "%n titok még mindig az előző kulcsával van titkosítva." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n titok kihagyásra került, mert az utódnak még nincs másolata — adja hozzá az utódot a mappához, és futtassa újra.", + "%n titok kihagyásra került, mert az utódnak még nincs másolata — adja hozzá az utódot a mappához, és futtassa újra." + ], + "_%n secret_::_%n secrets_": [ + "%n titok", + "%n titok" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudja megnyitni a széfet, amíg ez be van kapcsolva.", + "A hatókörben %n felhasználónak még nincs kétlépcsős bejelentkezése, és nem tudják megnyitni a széfet, amíg ez be van kapcsolva." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Befejezés mégis, %n titokhoz való hozzáférés elvesztésével", + "Befejezés mégis, %n titokhoz való hozzáférés elvesztésével" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n új tag hozzáférést kapott egy csapatmappához.", + "%n új tag hozzáférést kapott egy csapatmappához." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Kulcsrotáció befejezve. %n titok újra lett titkosítva az új kulcsával.", + "Kulcsrotáció befejezve. %n titok újra lett titkosítva az új kulcsával." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt.", + "A második csomag visszavonása %n vészhelyzeti hozzáférési kapcsolatot törölt." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "A csomag visszavonása %n vészhozzáférési névjegyet törölt.", + "A csomag visszavonása %n vészhozzáférési névjegyet törölt." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "%n alkalommal szerepelt szivárgásokban", + "%n alkalommal szerepelt szivárgásokban" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "%n titokkal megosztva", + "%n titokkal megosztva" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ez a mappa közvetlenül %n titkot tartalmaz.", + "Ez a mappa közvetlenül %n titkot tartalmaz." + ], + "_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._": [ + "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é.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n módosítás várakozik szinkronizálásra", + "%n módosítás várakozik szinkronizálásra" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "A felhasználó még a(z) {groups} csoportban van, amely egy csapatmappa tagja. Távolítsa el a csoportból, vagy tiltsa le a fiókot.", + "A felhasználó még a(z) {groups} csoportokban van, amelyek csapatmappák tagjai. Távolítsa el a csoportokból, vagy tiltsa le a fiókot." + ], + "Allow approval from another device": "Jóváhagyás engedélyezése másik eszközről", + "App": "Alkalmazás", + "Approve a new device": "Új eszköz jóváhagyása", + "Approve from another device": "Jóváhagyás másik eszközről", + "Asked at": "Kérés ideje", + "Check that the new device shows these words:": "Ellenőrizze, hogy az új eszköz ezeket a szavakat mutatja:", + "Denied. If you did not ask, end your other sessions:": "Elutasítva. Ha nem Ön kérte, zárja le a többi munkamenetét:", + "Device": "Eszköz", + "IP address": "IP-cím", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "A felhasználók feloldhatnak egy új böngészőt, ha jóváhagyják egy olyan eszközről, ahol a Keepiq már fel van oldva.", + "New device approval": "Új eszközök jóváhagyása", + "Nextcloud security settings": "Nextcloud biztonsági beállítások", + "Only approve a device you are using right now.": "Csak olyan eszközt hagyjon jóvá, amelyet éppen most használ.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Nyissa meg a Keepiq-et egy eszközön, ahol fel van oldva, és hagyja jóvá ezt az eszközt. Ellenőrizze, hogy ugyanazokat a szavakat mutatja:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "A jóváhagyó eszköz lepecsételi a feloldókulcsot az új eszköz számára. A szerver csak továbbítja, és nem tudja megnyitni.", + "The master password is not right, or the request has ended.": "A mesterjelszó nem megfelelő, vagy a kérés lezárult.", + "The request expired. Ask again or use your master password.": "A kérés lejárt. Kérje újra, vagy használja a mesterjelszavát.", + "The request was denied.": "A kérés el lett utasítva.", + "Too many requests. Try again in an hour or use your master password.": "Túl sok kérés. Próbálja újra egy óra múlva, vagy használja a mesterjelszavát.", + "Unknown device": "Ismeretlen eszköz", + "Web app": "Webalkalmazás", + "A device": "Egy eszköz", + "A new device asks to open your vault": "Egy új eszköz kéri a széfje megnyitását", + "%s asks to be approved. Only approve a device you are using right now.": "%s jóváhagyást kér. Csak olyan eszközt hagyjon jóvá, amelyet éppen most használ.", + "Access ends on (optional)": "Hozzáférés vége (nem kötelező)", + "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.": "A Keepiq alkalmazásai nem jelenítik meg és nem másolják a jelszót. Műszaki tudással rendelkező személy ettől még kiolvashatja a saját eszközéről. Változtassa meg, amikor a hozzáférése véget ér.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ez a titok csak használható. Jelentkezzen be a Keepiq böngészőbővítményén keresztül.", + "Until {date}": "Eddig: {date}", + "Use only": "Csak használat", + "Use only (can sign in, cannot view or copy)": "Csak használat (bejelentkezhet, nem tekintheti meg és nem másolhatja)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ezzel a bejelentkezéssel a Keepiq böngészőbővítményén keresztül jelentkezhet be. A tulajdonos úgy döntött, hogy nem tekintheti meg és nem másolhatja.", + "Your access ends on {date}": "A hozzáférése ekkor ér véget: {date}", + "Your access to this secret has ended": "A hozzáférése ehhez a titokhoz véget ért", + "Your access to \"%s\" ends tomorrow": "A(z) „%s” elemhez való hozzáférése holnap véget ér", + "Your access to \"%s\" has ended": "A(z) „%s” elemhez való hozzáférése véget ért", + "%1$s no longer has access to \"%2$s\"": "%1$s már nem fér hozzá a(z) „%2$s” elemhez", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s láthatta ezt a jelszót. Változtassa meg, ha %1$s már nem ismerheti.", + "%s could not view this password in Keepiq.": "%s nem tekinthette meg ezt a jelszót a Keepiqben.", + "{approvals} of {threshold} approvals": "{approvals} / {threshold} jóváhagyás", + "a recovery officer": "egy helyreállítási felelős", + "Account recovery": "Fiók-helyreállítás", + "Approvals needed": "Szükséges jóváhagyások", + "Ask {user} which words they see, by phone or in person. They must be:": "Kérdezze meg {user} felhasználót telefonon vagy személyesen, milyen szavakat lát. Ezeknek kell lenniük:", + "Check again": "Újraellenőrzés", + "Create the recovery key": "Helyreállítási kulcs létrehozása", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Hozza létre a helyreállítási kulcsot. A böngészője elkészíti, és minden felelősnek ad egy másolatot, amelyet csak ő nyithat meg.", + "Decline": "Elutasítás", + "Enrol in account recovery": "Jelentkezés a fiók-helyreállításra", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Jelentkezzen, hogy a szervezete segíthessen visszakapni a széfjét, ha elfelejti a mesterjelszavát.", + "Every user is enrolled": "Minden felhasználó jelentkezett", + "Finish the recovery in the browser you asked from.": "Fejezze be a helyreállítást abban a böngészőben, amelyből kérte.", + "Forgot your master password?": "Elfelejtette a mesterjelszavát?", + "Hand the key over": "Kulcs átadása", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "A mesterjelszavukat elfelejtő felhasználók visszakaphatják a széfjüket, az Ön által kijelölt helyreállítási felelősök jóváhagyásával.", + "New master password": "Új mesterjelszó", + "No one is asking to recover their account.": "Senki sem kéri a fiókja helyreállítását.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Még nincs helyreállítási kulcs. Az egyik felelős hozza létre a Keepiq-beállításaiban.", + "Off": "Ki", + "Officer {user} has no encryption set up yet.": "{user} felelősnek még nincs beállítva titkosítás.", + "Officers (user IDs, separated by commas)": "Felelősök (felhasználói azonosítók, vesszővel elválasztva)", + "Policy": "Szabályzat", + "Publish this fingerprint internally, so users can check it before they enrol.": "Tegye közzé ezt az ujjlenyomatot a szervezeten belül, hogy a felhasználók jelentkezés előtt ellenőrizhessék.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Helyreállítva {officer} segítségével. Cserélje le most a széfkulcsát: Beállítások, Biztonság: \"A mesterjelszavam kiszivárgott\".", + "Recovery key fingerprint: {fingerprint}": "Helyreállítási kulcs ujjlenyomata: {fingerprint}", + "Recovery officer": "Helyreállítási felelős", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Az eltávolított felelősök most elveszítik a másolatukat, de korábban megnyithatták. Kérjen meg egy felelőst, hogy hozzon létre új helyreállítási kulcsot.", + "Repeat the new master password": "Ismételje meg az új mesterjelszót", + "Retire this recovery key": "Helyreállítási kulcs kivezetése", + "Set the new master password": "Új mesterjelszó beállítása", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "A helyreállítási tanúsítványt nem ez a Keepiq állította ki. Ne jelentkezzen, és értesítse a rendszergazdát.", + "The words match, approve": "A szavak egyeznek, jóváhagyás", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ez a felhasználó jelentkezett a fiók-helyreállításra. A helyreállítás megtartja a titkait; a visszavonás törli a jelentkezését.", + "Users may enrol": "A felhasználók jelentkezhetnek", + "Withdraw from account recovery": "Kilépés a fiók-helyreállításból", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jelentkezett a fiók-helyreállításra. Helyreállítási kulcs ujjlenyomata: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jelentkezett. Ha elfelejti a mesterjelszavát, a szervezete segíthet visszakapni a széfjét.", + "Your key is back. Choose a new master password.": "A kulcsa visszakerült. Válasszon új mesterjelszót.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "A helyreállítási felelősök értesítést kaptak. Olvassa fel nekik ezeket a szavakat, amikor felhívják vagy találkoznak:", + "You are now an account recovery officer": "Mostantól fiók-helyreállítási felelős", + "%s asks to recover their account. Compare the words with them before you approve.": "%s kéri a fiókja helyreállítását. Jóváhagyás előtt hasonlítsa össze vele a szavakat.", + "A user": "Egy felhasználó", + "Your account recovery request was declined": "A fiók-helyreállítási kérelmét elutasították", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "A fiók-helyreállítás kész. Nyissa meg a Keepiq alkalmazást abban a böngészőben, amelyből kérte.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} egy új eszköz egyszeri feloldását kéri. A mesterjelszó változatlan marad.", + "Ask your organisation instead": "Kérje inkább a szervezetétől", + "The request ended. Ask again or use your master password.": "A kérés lezárult. Kérje újra, vagy használja a mesterjelszavát.", + "Added by {user}": "Hozzáadta: {user}", + "Editor": "Szerkesztő", + "Manager": "Kezelő", + "Role of {member}": "{member} szerepköre", + "Team folders you manage": "Az Ön által kezelt csapatmappák", + "Viewer": "Megtekintő", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Önnek nincs másolata ezekről a titkokról, ezért az új tagok még nem kapták meg őket. A tulajdonos megoszthatja őket: {names}", + "Admin areas": "Felügyeleti területek", + "Give a group only the parts of Keepiq administration it needs.": "Csak azokat a Keepiq-felügyeleti részeket adja egy csoportnak, amelyekre szüksége van.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegáljon egy vagy több területet egy csoportnak a felügyeleti jogosultságok oldalon. A példány rendszergazdái minden területtel rendelkeznek.", + "Open administration privileges": "Felügyeleti jogosultságok megnyitása", + "Policies": "Házirendek", + "Applications and machine access": "Alkalmazások és gépi hozzáférés", + "People and offboarding": "Emberek és kilépés", + "Audit and compliance": "Naplózás és megfelelőség", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzió, hitelesítésszolgáltató, mellékletek, offline gyorsítótár, szivárgásellenőrzés, titoktípusok és biztonsági mentések", + "master password, organisation password, vault policies, rotation, version history and trash": "mesterjelszó, szervezeti jelszó, széfházirendek, rotáció, verzióelőzmények és kuka", + "application queue, application requests and machine leases": "alkalmazássor, alkalmazáskérések és gépi bérletek", + "team offboarding, encryption suites and admin handover": "csapatból való kilépés, titkosítási csomagok és rendszergazdai átvétel", + "audit log, compliance reports, SIEM export and honey alerts": "naplófájl, megfelelőségi jelentések, SIEM-export és csaliriasztások", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Egy titok hány verzióját és mennyi ideig őrizzük meg, és a törölt titkok mennyi ideig maradnak a kukában.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "A titkosított mellékletek korlátai, amelyeket a kiszolgáló a tárolt titkosított bájtokban érvényesít.", + "Type the suite ID again to confirm": "A megerősítéshez írja be újra a csomag azonosítóját", + "This does not match the suite ID.": "Ez nem egyezik a csomag azonosítójával.", + "Confirm with your master password": "Megerősítés a főjelszóval", + "Confirm": "Megerősítés", + "That master password is not right.": "Ez a főjelszó nem helyes.", + "You are sharing with someone new. Enter your master password to confirm.": "Új személlyel oszt meg. A megerősítéshez adja meg a főjelszavát.", + "Enter your master password to confirm this share.": "A megosztás megerősítéséhez adja meg a főjelszavát.", + "Enter your master password to confirm this delegation.": "A delegálás megerősítéséhez adja meg a főjelszavát.", + "Approve {member}": "{member} jóváhagyása", + "Recipient": "Címzett", + "No vault yet": "Még nincs tárolója", + "No matching users": "Nincs egyező felhasználó", + "Partner organisations": "Partnerszervezetek", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Cseréljen titkokat egy másik Keepiqkel. Mindkét rendszergazda hozzáadja a másikat, és mentés előtt telefonon vagy személyesen összeveti a gyökér-ujjlenyomatokat.", + "Federation needs Nextcloud 33 or later.": "A föderációhoz Nextcloud 33 vagy újabb szükséges.", + "Your root fingerprint": "Az Ön gyökér-ujjlenyomata", + "No partners yet.": "Még nincs partner.", + "Users here may share to this partner": "Az itteni felhasználók megoszthatnak ezzel a partnerrel", + "This partner may share to users here": "Ez a partner megoszthat az itteni felhasználókkal", + "Partner address": "A partner címe", + "Check partner": "Partner ellenőrzése", + "Partner root fingerprint": "A partner gyökér-ujjlenyomata", + "I compared this fingerprint with the partner's administrator": "Összevetettem ezt az ujjlenyomatot a partner rendszergazdájával", + "Add partner": "Partner hozzáadása", + "A secret from another organisation": "Titok egy másik szervezettől", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s megosztotta Önnel a következőt: \"%2$s\". Fogadja el a Más szervezetektől érkezett oldalon.", + "Incoming from other organisations": "Más szervezetektől érkezett", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "A partnerszervezetek munkatársai megoszthatnak Önnel egy titkot. Fogadja el, hogy írásvédett másolatot tartson a tárolójában.", + "Nothing shared with you yet": "Még semmit sem osztottak meg Önnel", + "Secrets that people in partner organisations share with you appear here.": "Itt jelennek meg azok a titkok, amelyeket a partnerszervezetek munkatársai megosztanak Önnel.", + "From {sender}": "Feladó: {sender}", + "Accept": "Elfogadás", + "Open in vault": "Megnyitás a tárolóban", + "The other organisation did not hand over the secret. Try again later.": "A másik szervezet nem adta át a titkot. Próbálja újra később.", + "Set up your vault before you accept a shared secret.": "Állítsa be a tárolóját, mielőtt elfogad egy megosztott titkot.", + "Something went wrong. Try again.": "Hiba történt. Próbálja újra.", + "Waiting for your answer": "Az Ön válaszára vár", + "In your vault, read-only": "Az Ön tárolójában, csak olvasható", + "Withdrawn by the sender": "A feladó visszavonta", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ezt egy másik szervezettől osztotta meg. Elolvashatja, de nem módosíthatja és nem oszthatja meg.", + "Someone": "Valaki", + "Share with someone at another organisation": "Megosztás valakivel egy másik szervezetből", + "Their account at the other organisation": "Az illető fiókja a másik szervezetnél", + "Check account": "Fiók ellenőrzése", + "Certificate fingerprint of {account}": "{account} tanúsítványának ujjlenyomata", + "Compare it with them by phone if you want to be sure.": "Ha biztos akar lenni, telefonon egyeztesse az illetővel.", + "Shared. {account} can accept it in their own vault.": "Megosztva. {account} a saját tárolójában fogadhatja el.", + "The certificate could not be verified. Nothing was shared.": "A tanúsítványt nem sikerült ellenőrizni. Semmi sem lett megosztva.", + "That organisation is not one of your partners.": "Ez a szervezet nem tartozik a partnerei közé.", + "No one with that account can receive secrets from you.": "Ezzel a fiókkal senki sem fogadhat Öntől titkokat.", + "The other organisation did not answer. Try again later.": "A másik szervezet nem válaszolt. Próbálja újra később.", + "This secret is already shared with that account.": "Ez a titok már meg van osztva ezzel a fiókkal.", + "Other organisations": "Más szervezetek", + "Receive secrets from other organisations": "Titkok fogadása más szervezetektől", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "A partnerszervezetek munkatársai ekkor megtalálhatják a fiókját, és titkokat oszthatnak meg Önnel. Mindegyiket Ön fogadja el.", + "Shared": "Megosztva", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Szüneteltetve: megváltozott a címzett tanúsítványa vagy a partnerség. Vonja vissza, vagy ossza meg újra.", + "Their organisation did not get the last change. Revoke it or share again.": "A címzett szervezete nem kapta meg az utolsó módosítást. Vonja vissza, vagy ossza meg újra.", + "Being withdrawn": "Visszavonás folyamatban", + "Shared with another organisation": "Megosztva egy másik szervezettel", + "Change sent to another organisation": "Módosítás elküldve egy másik szervezetnek", + "Share with another organisation revoked": "Megosztás egy másik szervezettel visszavonva", + "Share with another organisation paused": "Megosztás egy másik szervezettel szüneteltetve", + "Another organisation did not get a change": "Egy másik szervezet nem kapott meg egy módosítást", + "Secret received from another organisation": "Titok érkezett egy másik szervezettől", + "Secret from another organisation accepted": "Egy másik szervezettől kapott titok elfogadva", + "Secret from another organisation declined": "Egy másik szervezettől kapott titok elutasítva", + "Copy from another organisation updated": "Egy másik szervezettől kapott másolat frissítve", + "Copy from another organisation removed": "Egy másik szervezettől kapott másolat eltávolítva", + "Declined: they removed their copy. Share again if they need it.": "Elutasítva: a címzett eltávolította a másolatát. Ossza meg újra, ha szüksége van rá.", + "Recipient at another organisation removed their copy": "Egy másik szervezet címzettje eltávolította a másolatát", + "Removed the user from %n team folder.": "A felhasználó eltávolítva %n csapatmappából.", + "Removed the user from %n team folders.": "A felhasználó eltávolítva %n csapatmappából.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "A felhasználó eltávolítva %n csapatmappából.", + "A felhasználó eltávolítva %n csapatmappából." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Egy visszaállított másolat egy véget ért megosztásból származik. Csak olvasható marad.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "A visszaállított másolatot megosztó szervezet nem érhető el. A másolat csak olvasható marad, és nem követi a változtatásaikat.", + "Recipient at another organisation restored their copy": "Egy másik szervezet címzettje visszaállította a másolatát" }, "plurals": null } diff --git a/l10n/is.js b/l10n/is.js index 1f65887dd..8008b506e 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Deilt með hópum", + "Not shared with any group yet.": "Ekki enn deilt með neinum hópi.", + "Revoke the share with {group}": "Afturkalla deilingu með {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deilt með {group}: {received} meðlimir fengu það, {skipped} ekki þar sem þeir hafa ekki enn sett upp dulkóðun.", + "Search groups": "Leita að hópum", + "Failed to share": "Ekki tókst að deila", + "Columns": "Dálkar", + "Column {number}": "Dálkur {number}", + "Map one column to Name. Every secret needs a name.": "Tengdu einn dálk við Nafn. Hvert leyndarmál þarf nafn.", + "Notes": "Athugasemdir", + "Do not import": "Ekki flytja inn", + "Hide this value": "Fela þetta gildi", + "Show this value": "Sýna þetta gildi", + "Defaults": "Sjálfgefin gildi", + "New secrets start as this type, and your secret list opens in this view.": "Ný leyndarmál byrja sem þessi tegund og leyndarmálalistinn þinn opnast í þessari sýn.", + "Default item type": "Sjálfgefin tegund atriðis", + "Cards": "Spjöld", + "Table": "Tafla", + "Could not save your default": "Ekki tókst að vista sjálfgefna gildið þitt", + "Recently used": "Nýlega notað", + "Opened": "Opnað", + "You have not opened any secrets yet": "Þú hefur ekki opnað nein leyndarmál enn", + "Could not delete the item type.": "Ekki tókst að eyða tegund atriðis.", + "Could not load the item types.": "Ekki tókst að hlaða tegundum atriða.", + "Could not save the item type.": "Ekki tókst að vista tegund atriðis.", + "Delete item type": "Eyða tegund atriðis", + "Edit item type": "Breyta tegund atriðis", + "Fields": "Svið", + "Fields: {count}": "Svið: {count}", + "Hidden": "Falið", + "Item types": "Tegundir atriða", + "Move up": "Færa upp", + "New item type": "Ný tegund atriðis", + "No item types defined yet.": "Engar tegundir atriða skilgreindar enn.", + "Required": "Nauðsynlegt", + "Text": "Texti", + "This field is required": "Þetta svið er nauðsynlegt", + "Web address": "Veffang", + "{label} (required)": "{label} (nauðsynlegt)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eyða „{name}“? Leyndarmál af þessari tegund verða áfram læsileg og verða Innskráning-atriði.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tegundir atriða sem þú skilgreinir hér birtast öllum í glugganum Nýtt leyndarmál, með þeim sviðum sem þú velur.", + "Secret moved to the trash": "Leyndarmál fært í ruslið", + "Secret restored from the trash": "Leyndarmál endurheimt úr ruslinu", + "Secret deleted for good": "Leyndarmáli eytt endanlega", + "Secret archived": "Leyndarmál sett í safn", + "Secret unarchived": "Leyndarmál tekið úr safni", + "Unarchive": "Taka úr safni", + "Could not archive the secret": "Ekki tókst að setja leyndarmálið í safn", + "Could not unarchive the secret": "Ekki tókst að taka leyndarmálið úr safni", + "Archive {count} secrets": "Setja leyndarmál í safn: {count}", + "Unarchive {count} secrets": "Taka leyndarmál úr safni: {count}", + "Restore {count} secrets": "Endurheimta leyndarmál: {count}", + "Delete {count} secrets for good": "Eyða leyndarmálum endanlega: {count}", + "Done for {ok} of {total} secrets": "Lokið fyrir {ok} af {total} leyndarmálum", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Leyndarmál í safni hverfa úr lista hólfsins, leit, sjálfvirkri útfyllingu og heilsuskýrslunni. Þau halda deilingum sínum. Þú finnur þau undir Safn.", + "These secrets come back to the vault list, search and autofill.": "Þessi leyndarmál koma aftur í lista hólfsins, leit og sjálfvirka útfyllingu.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Þessi leyndarmál koma aftur í lista hólfsins. Gamlar deilingar koma ekki aftur, svo deildu þeim aftur þar sem þarf.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Þetta eyðir leyndarmálunum ásamt viðhengjum og útgáfusögu. Ekki er hægt að afturkalla þetta.", + "Delete for good": "Eyða endanlega", + "Trash": "Rusl", + "The trash is empty": "Ruslið er tómt", + "No archived secrets": "Engin leyndarmál í safni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Eydd leyndarmál bíða hér þar til geymslutímanum lýkur, síðan er þeim eytt endanlega.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Settu leyndarmál í safn úr upplýsingaspjaldi þess til að halda því utan lista hólfsins, leitar og sjálfvirkrar útfyllingar.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Takmörk fyrir dulkóðuð viðhengi (framfylgt á þjóninum í vistuðum dulkóðuðum bætum), geymsla útgáfusögu og hve lengi eydd leyndarmál eru í ruslinu.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagar sem eytt leyndarmál er í ruslinu (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Þetta færir leyndarmálið í ruslið og lýkur deilingum þess strax. Þú getur endurheimt það úr ruslinu þar til geymslutímanum lýkur: 30 dagar nema kerfisstjórinn hafi breytt því.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Þetta færir leyndarmál í ruslið ({count}) og lýkur deilingum þeirra strax. Þú getur endurheimt þau úr ruslinu þar til geymslutímanum lýkur.", + "Remove {name} from favourites": "Fjarlægja {name} úr eftirlætum", + "Add {name} to favourites": "Bæta {name} við eftirlæti", + "Could not change the favourite": "Ekki tókst að breyta eftirlæti", + "Remove from favourites": "Fjarlægja úr eftirlætum", + "Add to favourites": "Bæta við eftirlæti", + "Tags": "Merki", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Merki eru ekki dulrituð. Kerfisstjórar netþjónsins geta lesið þau, eins og möppuheiti.", + "Favourites": "Eftirlæti", + "Filter by tag": "Sía eftir merki", + "All tags": "Öll merki", + "Last used": "Síðast notað", + "Tags for {count} secrets": "Merki fyrir {count} leyndarmál", + "Tag": "Merki", + "Remove tag": "Fjarlægja merki", + "Add tag": "Bæta við merki", + "Could not change the tags. Try again.": "Ekki tókst að breyta merkjunum. Reyndu aftur.", + "Could not approve the application. It is still in the queue.": "Ekki tókst að samþykkja umsóknina. Hún er enn í biðröð.", + "Could not reject the application. It is still in the queue.": "Ekki tókst að hafna umsókninni. Hún er enn í biðröð.", + "Removed the user from {count} team folders.": "Notandinn var fjarlægður úr {count} teymismöppum.", + "Approve a share": "Samþykkja deilingu", + "This approval link is incomplete. Open it again from the notification.": "Þessi samþykktartengill er ófullgerður. Opnaðu hann aftur úr tilkynningunni.", + "Deny": "Hafna", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} gekk í hóp sem þú deilir leyndarmáli með. Deila leyndarmálinu líka með viðkomandi?", + "{requester} asks you to share a secret with {user}.": "{requester} biður þig að deila leyndarmáli með {user}.", + "Shared. The recipient can now open the secret.": "Deilt. Viðtakandinn getur nú opnað leyndarmálið.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Viðtakandinn hefur ekki enn sett upp Keepiq, svo engu var deilt. Reyndu aftur þegar það hefur verið gert.", + "Could not share the secret. Only its owner can approve this.": "Ekki tókst að deila leyndarmálinu. Aðeins eigandi þess getur samþykkt þetta.", + "Could not share the secret. Try again.": "Ekki tókst að deila leyndarmálinu. Reyndu aftur.", + "Denied. Nothing was shared.": "Hafnað. Engu var deilt.", + "Could not deny the request. Try again.": "Ekki tókst að hafna beiðninni. Reyndu aftur.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s biður þig að deila leyndarmálinu \"%2$s\" með %3$s.", + "Expires on (optional)": "Rennur út (valfrjálst)", + "Hand over to": "Afhenda til", + "Choose a recipient": "Veldu viðtakanda", + "Hand over temporarily": "Afhenda tímabundið", + "Expiry rules": "Reglur um gildistíma", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stilltu hve lengi lykilorð af einni gerð atriða eða í einni möppu mega gilda og hvenær á að minna á. Þegar fleiri dagsetningar gilda ræður sú fyrsta.", + "Delete rule": "Eyða reglu", + "Set by your administrator": "Stillt af kerfisstjóra", + "No expiry rules yet.": "Engar reglur um gildistíma enn.", + "Applies to": "Gildir um", + "Item type": "Gerð atriðis", + "Maximum age in days (empty for reminders only)": "Hámarksaldur í dögum (autt fyrir áminningar eingöngu)", + "Remind me this many days before, comma separated": "Minna mig á svona mörgum dögum áður, aðskilið með kommum", + "Save rule": "Vista reglu", + "An item type": "Gerð atriðis", + "A folder": "Mappa", + "Folder {name}": "Mappa {name}", + "Type {name}": "Gerð {name}", + "Expires after {days} days": "Rennur út eftir {days} daga", + "Reminders {days} days before": "Áminningar {days} dögum áður", + "Could not save the expiry rule.": "Ekki tókst að vista regluna um gildistíma.", + "Could not delete the expiry rule.": "Ekki tókst að eyða reglunni um gildistíma.", + "All statuses": "Allar stöður", + "Compromised": "Í hættu", + "Could not load the members.": "Ekki tókst að hlaða inn meðlimum.", + "Emergency contact": "Neyðartengiliður", + "Leaving user": "Notandi sem hættir", + "No": "Nei", + "No users match this filter.": "Engir notendur passa við þessa síu.", + "Not set up": "Ekki sett upp", + "Revoke suite": "Afturkalla svítu", + "Revoked": "Afturkölluð", + "Search users": "Leita að notendum", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Sjáðu hvaða notendur hafa sett upp hólf. Byrjaðu brottför eða afturkallaðu svítu úr röð.", + "Successor": "Arftaki", + "Team folders": "Teymismöppur", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Notandinn er enn í hópnum {groups}, sem er meðlimur teymismöppu. Fjarlægðu hann úr hópnum eða gerðu aðganginn óvirkan.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Notandinn er enn í hópunum {groups}, sem eru meðlimir teymismappa. Fjarlægðu hann úr hópunum eða gerðu aðganginn óvirkan.", + "Vault status": "Staða hólfs", + "Yes": "Já", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-útflutningur er ÓDULKÓÐAÐUR. Hvert lykilorð og innskráningarnafn verður læsilegt sem ódulkóðaður texti í skránni sem er hlaðið niður. Varðveittu hana á öruggum stað og eyddu henni strax eftir notkun.", + "Root certificate expiring soon": "Rótarvottorð rennur brátt út", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Rótarvottorð hólfsins rennur út eftir %1$d dag/daga. Endurnýjaðu það fyrir þann tíma. Endurnýjun undirritar hverja dulkóðunarsvítu upp á nýtt.", + "Compromise recovery aborted": "Endurheimt eftir innbrot hætt", + "Key rotation ended by a compromise revoke": "Lyklaskiptum lokið með afturköllun vegna innbrots", + "Encryption suite revoke refused": "Afturköllun dulkóðunarsvítu hafnað", + "Master password proof refused": "Sönnun aðallykilorðs hafnað", + "Your current master password": "Núverandi aðallykilorð þitt", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n neyðartengiliður var með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu hann. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n neyðartengiliðir voru með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu þá. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Þessir neyðartengiliðir voru ekki fluttir yfir á nýja lykilinn þinn. Neyðaraðgangur þeirra var fjarlægður. Bættu þeim við aftur í Neyðaraðgangi ef þú vilt þá enn.", + "Renew root certificate": "Endurnýja rótarskilríki", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Þetta býr til nýtt rótar- og millistigsskilríki. Hver virk dulkóðunarsvíta er undirrituð aftur. Ekki er hægt að afturkalla þetta.", + "Renew root": "Endurnýja rót", + "Root renewed. {n} encryption suites signed again.": "Rót endurnýjuð. Dulkóðunarsvítur undirritaðar aftur: {n}.", + "Could not renew the root certificate.": "Ekki tókst að endurnýja rótarskilríkið.", + "Lease policy for this application": "Leigustefna fyrir þetta forrit", + "In force now: {default} seconds by default, {max} seconds at most.": "Í gildi núna: sjálfgefið {default} sekúndur, mest {max} sekúndur.", + "Leases are not renewable": "Ekki er hægt að endurnýja leigur", + "Lease policy saved.": "Leigustefna vistuð.", + "Leave a field empty to use the instance value.": "Skildu reit eftir tóman til að nota gildi tilviksins.", + "Instance value: {value}": "Gildi tilviks: {value}", + "Renewal": "Endurnýjun", + "Use the instance value ({value})": "Nota gildi tilviksins ({value})", + "Allowed": "Leyft", + "Not allowed": "Ekki leyft", + "Save lease policy": "Vista leigustefnu", + "Only an administrator can change this policy.": "Aðeins stjórnandi getur breytt þessari stefnu.", + "Could not save the lease policy.": "Ekki tókst að vista leigustefnuna.", + "{member} got access from {confirmer}.": "{member} fékk aðgang frá {confirmer}.", + "Automatically confirm new team folder members": "Staðfesta nýja meðlimi teymismappa sjálfkrafa", + "Gave %n new member access to a team folder.": "%n nýr meðlimur fékk aðgang að teymismöppu.", + "Gave %n new members access to a team folder.": "%n nýir meðlimir fengu aðgang að teymismöppu.", + "Give new team folder members access without waiting for the folder owner.": "Gefðu nýjum meðlimum aðgang án þess að bíða eftir eiganda möppunnar.", + "New team folder members": "Nýir meðlimir teymismappa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Eigandinn eða meðlimur með skrifaðgang staðfestir þá úr opnu hólfi sínu. Keepiq afkóðar aldrei á þjóninum.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Beðið eftir að meðlimur með skrifaðgang opni Keepiq. Þú getur líka deilt núna.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Hluti af viðbrögðum við öryggisbresti mistókst ({failed} skref). Skoðaðu annál þjónsins og afturkallaðu svítuna aftur til að ljúka því.", + "This also revoked suite {suite} and ended key migration {migration}.": "Þetta afturkallaði einnig svítu {suite} og lauk lyklaflutningi {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengilið.", + "Revoking the second suite deleted %n emergency-access contacts.": "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengiliðum.", + "A suite revoked as compromised cannot be reinstated.": "Ekki er hægt að endurheimta svítu sem var afturkölluð sem í hættu.", + "Archives to keep": "Skjalasöfn sem á að geyma", + "Back up every vault automatically": "Taka öryggisafrit af hverju hólfi sjálfkrafa", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Taktu öryggisafrit af hverju hólfi samkvæmt áætlun. Söfnin innihalda aðeins dulkóðaðan texta og eru endurheimt með occ.", + "Back up now": "Taka öryggisafrit núna", + "Backup public key (PEM, optional)": "Opinber lykill öryggisafrits (PEM, valfrjálst)", + "Backup requested for the next cron run": "Öryggisafrit pantað fyrir næstu cron-keyrslu", + "Encrypted": "Dulkóðað", + "Every (hours)": "Á (klukkustunda) fresti", + "Last backup {when} failed: {error}": "Síðasta öryggisafrit {when} mistókst: {error}", + "Last backup {when} succeeded.": "Síðasta öryggisafrit {when} tókst.", + "No archives yet.": "Engin skjalasöfn enn.", + "Size": "Stærð", + "Vault backups": "Öryggisafrit hólfs", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Með lykli er hvert safn dulkóðað fyrir hann. Geymdu einkalykilinn utan þessa þjóns: þú þarft hann til að staðfesta eða endurheimta.", + "Written": "Skrifað", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n notandi innan umfangs er ekki enn með tveggja þátta innskráningu og getur ekki opnað hólfið á meðan þetta er virkt.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n notendur innan umfangs eru ekki enn með tveggja þátta innskráningu og geta ekki opnað hólfið á meðan þetta er virkt.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varakóðar teljast ekki með. Ef notendur skrá sig inn í gegnum auðkennisveitu með eigin annan þátt, slepptu hópum þeirra.", + "Block personal vault export": "Loka á útflutning persónulegs hólfs", + "Keep work logins in team folders": "Geyma vinnuinnskráningar í teymismöppum", + "Move to a team folder": "Færa í teymismöppu", + "Not in a team folder": "Ekki í teymismöppu", + "Only for these groups (empty is everyone)": "Aðeins fyrir þessa hópa (autt þýðir allir)", + "Require two-factor login before the vault opens": "Krefjast tveggja þátta innskráningar áður en hólfið opnast", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglur fyrir hvert hólf. Hver gildir fyrir alla, eða aðeins hópana sem þú velur.", + "Secret types that belong in a team folder": "Tegundir leyndarmála sem eiga heima í teymismöppu", + "Set up two-factor login": "Setja upp tveggja þátta innskráningu", + "Team folder you can write to": "Teymismappa sem þú getur skrifað í", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Notendur geta ekki sótt öryggisafrit, CSV eða flutningsskrá. Persónuupplýsingapakki þeirra er áfram aðgengilegur.", + "Users cannot save these secret types in a personal folder.": "Notendur geta ekki vistað þessar tegundir leyndarmála í persónulegri möppu.", + "Vault policies": "Reglur hólfs", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Fyrirtækið þitt leyfir ekki útflutning á persónulega hólfinu þínu. Persónuupplýsingapakkinn þinn í stillingunum er áfram aðgengilegur.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Fyrirtækið þitt geymir þessi leyndarmál í teymismöppu. Færðu hvert og eitt í teymismöppu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Fyrirtækið þitt geymir þessa tegund leyndarmáls í teymismöppu. Veldu eina af teymismöppunum þínum, eða eina sem þú getur skrifað í.", + "Your organisation requires two-factor login before you can open your vault.": "Fyrirtækið þitt krefst tveggja þátta innskráningar áður en þú getur opnað hólfið þitt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Notendur velja hversu lengi viðbótin er ólæst í aðgerðaleysi. Þú stillir lengsta tímann sem þeir mega velja.", + "Longest idle time before the extension locks": "Lengsti aðgerðaleysistími áður en viðbótin læsist", + "1 minute": "1 mínúta", + "5 minutes": "5 mínútur", + "15 minutes": "15 mínútur", + "1 hour": "1 klukkustund", + "4 hours": "4 klukkustundir", + "Connector": "Tengill", + "Directory (tenant) ID": "Auðkenni möppu (leigjanda)", + "Application (client) ID": "Auðkenni forrits (biðlara)", + "Data collection rule immutable ID": "Óbreytanlegt auðkenni gagnasöfnunarreglu", + "Stream name": "Heiti straums", + "Splunk index (optional)": "Splunk-vísir (valkvætt)", + "Sourcetype (optional)": "Sourcetype (valkvætt)", + "Leave blank to keep the current one": "Skildu eftir autt til að halda núverandi", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF yfir syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endapunktur gagnasöfnunar (https-slóð)", + "HTTP Event Collector URL (https)": "Slóð HTTP Event Collector (https)", + "Client secret (write-only)": "Leyndarmál biðlara (aðeins skrifa)", + "HEC token (write-only)": "HEC-tóki (aðeins skrifa)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Áframsendu leyfða endurskoðunaratburði til Splunk, Microsoft Sentinel, syslog-móttakara eða vefkróks. Skilaboð bera aðeins hreinsuð lýsigögn: ekkert leynigildi, nafn, innskráning eða dulkóðaður texti fer nokkurn tíma af þjóninum.", + "%n change waiting to sync": "%n breyting bíður samstillingar", + "%n changes waiting to sync": "%n breytingar bíða samstillingar", + "Changes that could not sync": "Breytingar sem ekki tókst að samstilla", + "Choose a version": "Veldu útgáfu", + "Copy value": "Afrita gildi", + "Deleted": "Eytt", + "Discard": "Henda", + "Keep my offline change": "Halda breytingunni minni án nettengingar", + "Keep the server version": "Halda útgáfu þjónsins", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er aðeins til lestrar án nettengingar. Kerfisstjóri hefur ekki kveikt á breytingum án nettengingar.", + "Let users edit secrets offline": "Leyfa notendum að breyta leyndarmálum án nettengingar", + "Not synced yet": "Ekki samstillt enn", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Breytingar án nettengingar eru geymdar á tækinu, dulkóðaðar fyrir notandann, og samstilltar við næstu aflæsingu á netinu. Deiling, möppur og viðhengi þurfa enn tengingu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Án nettengingar. Breytingar, færslur og eyðingar haldast á þessu tæki og samstillast þegar þú ert aftur á netinu. Deiling og viðhengi þurfa tengingu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Án nettengingar. Breytingarnar þínar haldast á þessu tæki og samstillast þegar þú ert aftur á netinu. Síðast samstillt {when}.", + "Open my changes": "Opna breytingarnar mínar", + "Sharing needs a connection": "Deiling þarf tengingu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Einhver breytti þessu leyndarmáli á þjóninum eftir að afrit þitt án nettengingar var gert. Veldu hvaða útgáfu á að halda.", + "Sync or discard your offline changes before you rotate your keys.": "Samstilltu eða hentu breytingunum án nettengingar áður en þú skiptir um lykla.", + "That password did not open your changes.": "Þetta lykilorð opnaði ekki breytingarnar þínar.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Skyndimyndin án nettengingar geymir dulkóðuð leyndarmál (aðeins hægt að opna með lyklinum sem leiddur er af aðallykilorði notandans, alveg eins og á þjóninum) og dulkóðar nöfn, vefslóðir og möppunöfn í geymslu. Aðgangur án nettengingar er aðeins til lestrar nema þú leyfir breytingar án nettengingar hér fyrir neðan. Slökktu á þessu fyrir tæki sem mega aldrei geyma innskráningarupplýsingar; ef slökkt er hreinsast skyndiminni við næstu hleðslu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Fyrra afrit hólfsins er horfið, svo ekki er hægt að opna þessar breytingar.", + "The server version": "Útgáfa þjónsins", + "This secret changed while you were offline": "Þessu leyndarmáli var breytt meðan þú varst án nettengingar", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Þú eyddir þessu leyndarmáli án nettengingar, en því hefur síðan verið breytt á þjóninum. Veldu hvaða útgáfu á að halda.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Lyklunum þínum var breytt á öðru tæki. Sláðu inn fyrra aðallykilorðið til að samstilla breytingarnar án nettengingar, eða hentu þeim.", + "Your offline change": "Breytingin þín án nettengingar", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n neyðartengiliður var með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu hann. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.","%n neyðartengiliðir voru með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu þá. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["Ekki er unnt að setja %n atriði fram í CXF og því verður sleppt.","Ekki er unnt að setja %n atriði fram í CXF og þeim verður sleppt."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n eldri útgáfu var sleppt því aðeins nýlega sögu er unnt að flytja með.","%n eldri útgáfum var sleppt því aðeins nýlega sögu er unnt að flytja með."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Enn þarf að dulkóða og deila %n afriti leyndarmáls.","Enn þarf að dulkóða og deila %n afritum leyndarmála."], + "_%n secret could not be decrypted and is not in this export._::_%n secrets could not be decrypted and are not in this export._": ["Ekki tókst að afkóða %n leyndarmál og það er ekki í þessum útflutningi.","Ekki tókst að afkóða %n leyndarmál og þau eru ekki í þessum útflutningi."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["Ekki var unnt að afkóða %n leyndarmál með gamla lyklinum þínum, svo það fluttist ekki.","Ekki var unnt að afkóða %n leyndarmál með gamla lyklinum þínum, svo þau fluttust ekki."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n leyndarmál fluttist ekki.","%n leyndarmál fluttust ekki."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n leyndarmál er enn kóðað með fyrri lyklinum þínum.","%n leyndarmál eru enn kóðuð með fyrri lyklinum þínum."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n leyndarmáli var sleppt því arftakinn hefur ekki afrit enn — bættu arftakanum í möppuna og keyrðu aftur.","%n leyndarmálum var sleppt því arftakinn hefur ekki afrit enn — bættu arftakanum í möppuna og keyrðu aftur."], + "_%n secret_::_%n secrets_": ["%n leyndarmál","%n leyndarmál"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n notandi innan umfangs er ekki enn með tveggja þátta innskráningu og getur ekki opnað hólfið á meðan þetta er virkt.","%n notendur innan umfangs eru ekki enn með tveggja þátta innskráningu og geta ekki opnað hólfið á meðan þetta er virkt."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Ljúka samt og missa aðgang að %n leyndarmáli","Ljúka samt og missa aðgang að %n leyndarmálum"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nýr meðlimur fékk aðgang að teymismöppu.","%n nýir meðlimir fengu aðgang að teymismöppu."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Lyklasnúningi lokið. %n leyndarmál var endurkóðað með nýja lyklinum þínum.","Lyklasnúningi lokið. %n leyndarmál voru endurkóðuð með nýja lyklinum þínum."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengilið.","Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengiliðum."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Afturköllun þessa setts eyddi %n neyðaraðgangstengilið.","Afturköllun þessa setts eyddi %n neyðaraðgangstengiliðum."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["sást %n sinni í lekum","sást %n sinnum í lekum"], + "_shared with %n secret_::_shared with %n secrets_": ["deilt með %n leyndarmáli","deilt með %n leyndarmálum"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Þessi mappa inniheldur %n leyndarmál beint.","Þessi mappa inniheldur %n leyndarmál beint."], + "_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._": ["Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.","Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n breyting bíður samstillingar","%n breytingar bíða samstillingar"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Notandinn er enn í hópnum {groups}, sem er meðlimur teymismöppu. Fjarlægðu hann úr hópnum eða gerðu aðganginn óvirkan.","Notandinn er enn í hópunum {groups}, sem eru meðlimir teymismappa. Fjarlægðu hann úr hópunum eða gerðu aðganginn óvirkan."], + "Allow approval from another device": "Leyfa samþykki úr öðru tæki", + "App": "Forrit", + "Approve a new device": "Samþykkja nýtt tæki", + "Approve from another device": "Samþykkja úr öðru tæki", + "Asked at": "Beðið um kl.", + "Check that the new device shows these words:": "Gakktu úr skugga um að nýja tækið sýni þessi orð:", + "Denied. If you did not ask, end your other sessions:": "Hafnað. Ef þú baðst ekki um þetta skaltu ljúka öðrum setum þínum:", + "Device": "Tæki", + "IP address": "IP-vistfang", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Leyfa notendum að aflæsa nýjum vafra með því að samþykkja hann úr tæki þar sem Keepiq er þegar aflæst.", + "New device approval": "Samþykki nýrra tækja", + "Nextcloud security settings": "Öryggisstillingar Nextcloud", + "Only approve a device you are using right now.": "Samþykktu aðeins tæki sem þú ert að nota núna.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Opnaðu Keepiq í tæki þar sem það er aflæst og samþykktu þetta tæki. Gakktu úr skugga um að það sýni sömu orð:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Tækið sem samþykkir innsiglar aflæsingarlykilinn fyrir nýja tækið. Þjónninn kemur honum aðeins áfram og getur ekki opnað hann.", + "The master password is not right, or the request has ended.": "Aðallykilorðið er ekki rétt, eða beiðninni er lokið.", + "The request expired. Ask again or use your master password.": "Beiðnin rann út. Biddu aftur eða notaðu aðallykilorðið þitt.", + "The request was denied.": "Beiðninni var hafnað.", + "Too many requests. Try again in an hour or use your master password.": "Of margar beiðnir. Reyndu aftur eftir klukkustund eða notaðu aðallykilorðið þitt.", + "Unknown device": "Óþekkt tæki", + "Web app": "Vefforrit", + "A device": "Tæki", + "A new device asks to open your vault": "Nýtt tæki biður um að opna hvelfinguna þína", + "%s asks to be approved. Only approve a device you are using right now.": "%s biður um samþykki. Samþykktu aðeins tæki sem þú ert að nota núna.", + "Access ends on (optional)": "Aðgangi lýkur (valfrjálst)", + "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.": "Forrit Keepiq sýna hvorki né afrita lykilorðið. Einhver með tæknikunnáttu getur samt lesið það úr eigin tæki. Skiptu um það þegar aðgangi viðkomandi lýkur.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Þetta leyndarmál er aðeins til notkunar. Skráðu þig inn í gegnum Keepiq-vafraviðbótina.", + "Until {date}": "Til {date}", + "Use only": "Aðeins notkun", + "Use only (can sign in, cannot view or copy)": "Aðeins notkun (getur skráð sig inn, getur ekki skoðað eða afritað)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Þú getur skráð þig inn með þessari innskráningu í gegnum Keepiq-vafraviðbótina. Eigandinn valdi að leyfa þér ekki að skoða eða afrita hana.", + "Your access ends on {date}": "Aðgangi þínum lýkur {date}", + "Your access to this secret has ended": "Aðgangi þínum að þessu leyndarmáli er lokið", + "Your access to \"%s\" ends tomorrow": "Aðgangi þínum að „%s“ lýkur á morgun", + "Your access to \"%s\" has ended": "Aðgangi þínum að „%s“ er lokið", + "%1$s no longer has access to \"%2$s\"": "%1$s hefur ekki lengur aðgang að „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s gat séð þetta lykilorð. Skiptu um það ef %1$s á ekki lengur að vita það.", + "%s could not view this password in Keepiq.": "%s gat ekki skoðað þetta lykilorð í Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} af {threshold} samþykktum", + "a recovery officer": "endurheimtufulltrúi", + "Account recovery": "Endurheimt aðgangs", + "Approvals needed": "Samþykktir sem þarf", + "Ask {user} which words they see, by phone or in person. They must be:": "Spyrðu {user} hvaða orð birtast, í síma eða í eigin persónu. Þau verða að vera:", + "Check again": "Athuga aftur", + "Create the recovery key": "Búa til endurheimtulykil", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Búðu til endurheimtulykilinn. Vafrinn þinn býr hann til og gefur hverjum fulltrúa afrit sem aðeins hann getur opnað.", + "Decline": "Hafna", + "Enrol in account recovery": "Skrá sig í endurheimt aðgangs", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Skráðu þig svo fyrirtækið þitt geti hjálpað þér að fá hvelfinguna aftur ef þú gleymir aðallykilorðinu.", + "Every user is enrolled": "Allir notendur eru skráðir", + "Finish the recovery in the browser you asked from.": "Ljúktu endurheimtinni í vafranum sem þú baðst um hana úr.", + "Forgot your master password?": "Gleymdirðu aðallykilorðinu?", + "Hand the key over": "Afhenda lykilinn", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Leyfðu notendum sem gleymdu aðallykilorðinu að fá hvelfinguna aftur, með samþykki endurheimtufulltrúa sem þú tilnefnir.", + "New master password": "Nýtt aðallykilorð", + "No one is asking to recover their account.": "Enginn er að biðja um endurheimt aðgangs.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Enginn endurheimtulykill enn. Einn fulltrúanna býr hann til í Keepiq-stillingunum sínum.", + "Off": "Slökkt", + "Officer {user} has no encryption set up yet.": "Fulltrúinn {user} hefur ekki sett upp dulritun enn.", + "Officers (user IDs, separated by commas)": "Fulltrúar (notendaauðkenni, aðskilin með kommum)", + "Policy": "Regla", + "Publish this fingerprint internally, so users can check it before they enrol.": "Birtu þetta fingrafar innanhúss svo notendur geti athugað það áður en þeir skrá sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Endurheimt með hjálp {officer}. Skiptu um lykil hvelfingarinnar núna í Stillingar, Öryggi: \"Aðallykilorðið mitt hefur lekið\".", + "Recovery key fingerprint: {fingerprint}": "Fingrafar endurheimtulykils: {fingerprint}", + "Recovery officer": "Endurheimtufulltrúi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fulltrúar sem voru fjarlægðir missa afritið sitt núna, en gætu hafa opnað það áður. Láttu fulltrúa búa til nýjan endurheimtulykil.", + "Repeat the new master password": "Endurtaktu nýja aðallykilorðið", + "Retire this recovery key": "Taka þennan endurheimtulykil úr notkun", + "Set the new master password": "Setja nýja aðallykilorðið", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Endurheimtuvottorðið er ekki gefið út af þessu Keepiq. Ekki skrá þig og láttu kerfisstjórann vita.", + "The words match, approve": "Orðin passa, samþykkja", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Þessi notandi er skráður í endurheimt aðgangs. Endurheimt heldur leyndarmálum hans; afturköllun eyðir skráningunni.", + "Users may enrol": "Notendur mega skrá sig", + "Withdraw from account recovery": "Afskrá sig úr endurheimt aðgangs", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Þú ert skráð(ur) í endurheimt aðgangs. Fingrafar endurheimtulykils: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Þú ert skráð(ur). Ef þú gleymir aðallykilorðinu getur fyrirtækið þitt hjálpað þér að fá hvelfinguna aftur.", + "Your key is back. Choose a new master password.": "Lykillinn er kominn aftur. Veldu nýtt aðallykilorð.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Endurheimtufulltrúarnir þínir hafa verið látnir vita. Lestu fyrir þá þessi orð þegar þeir hringja eða hitta þig:", + "You are now an account recovery officer": "Þú ert nú endurheimtufulltrúi aðganga", + "%s asks to recover their account. Compare the words with them before you approve.": "%s biður um endurheimt aðgangs. Berðu orðin saman við viðkomandi áður en þú samþykkir.", + "A user": "Notandi", + "Your account recovery request was declined": "Beiðni þinni um endurheimt aðgangs var hafnað", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Endurheimt aðgangsins er tilbúin. Opnaðu Keepiq í vafranum sem þú baðst um hana úr.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} biður um að opna nýtt tæki einu sinni. Aðallykilorðið helst óbreytt.", + "Ask your organisation instead": "Biddu frekar stofnunina þína", + "The request ended. Ask again or use your master password.": "Beiðninni er lokið. Biddu aftur eða notaðu aðallykilorðið þitt.", + "Added by {user}": "Bætt við af {user}", + "Editor": "Ritstjóri", + "Manager": "Stjórnandi", + "Role of {member}": "Hlutverk {member}", + "Team folders you manage": "Teymismöppur sem þú stjórnar", + "Viewer": "Lesandi", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Þú átt ekkert afrit af þessum leyndarmálum, svo nýju meðlimirnir hafa ekki fengið þau enn. Eigandinn getur deilt þeim: {names}", + "Admin areas": "Stjórnunarsvið", + "Give a group only the parts of Keepiq administration it needs.": "Gefðu hópi aðeins þá hluta Keepiq-stjórnunar sem hann þarf.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Úthlutaðu einu eða fleiri sviðum til hóps á síðunni fyrir stjórnunarheimildir. Kerfisstjórar tilviksins hafa öll svið.", + "Open administration privileges": "Opna stjórnunarheimildir", + "Policies": "Reglur", + "Applications and machine access": "Forrit og aðgangur véla", + "People and offboarding": "Fólk og starfslok", + "Audit and compliance": "Endurskoðun og regluvarsla", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "útgáfa, vottunarstöð, viðhengi, ónettengt skyndiminni, lekaathugun, tegundir leyndarmála og afrit", + "master password, organisation password, vault policies, rotation, version history and trash": "aðallykilorð, lykilorð fyrirtækis, reglur hvelfingar, snúningur, útgáfusaga og ruslafata", + "application queue, application requests and machine leases": "forritaröð, beiðnir forrita og vélaleigur", + "team offboarding, encryption suites and admin handover": "starfslok í teymi, dulritunarsvítur og yfirtaka kerfisstjóra", + "audit log, compliance reports, SIEM export and honey alerts": "endurskoðunarskrá, skýrslur um regluvörslu, SIEM-útflutningur og agnviðvaranir", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hversu margar útgáfur leyndarmáls eru geymdar, hve lengi, og hve lengi eydd leyndarmál eru í ruslafötunni.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Takmörk fyrir dulrituð viðhengi, framfylgt á þjóninum í vistuðum dulrituðum bætum.", + "Type the suite ID again to confirm": "Sláðu inn pakkaauðkennið aftur til að staðfesta", + "This does not match the suite ID.": "Þetta passar ekki við pakkaauðkennið.", + "Confirm with your master password": "Staðfestu með aðallykilorðinu þínu", + "Confirm": "Staðfesta", + "That master password is not right.": "Þetta aðallykilorð er ekki rétt.", + "You are sharing with someone new. Enter your master password to confirm.": "Þú ert að deila með nýjum aðila. Sláðu inn aðallykilorðið til að staðfesta.", + "Enter your master password to confirm this share.": "Sláðu inn aðallykilorðið til að staðfesta þessa deilingu.", + "Enter your master password to confirm this delegation.": "Sláðu inn aðallykilorðið til að staðfesta þessa úthlutun.", + "Approve {member}": "Samþykkja {member}", + "Recipient": "Viðtakandi", + "No vault yet": "Engin hvelfing enn", + "No matching users": "Engir notendur passa", + "Partner organisations": "Samstarfsfyrirtæki", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Skiptist á leyndarmálum við annað Keepiq. Báðir stjórnendur bæta hvor öðrum við og bera saman rótarfingraför í síma eða í eigin persónu áður en vistað er.", + "Federation needs Nextcloud 33 or later.": "Samtenging krefst Nextcloud 33 eða nýrra.", + "Your root fingerprint": "Rótarfingrafarið þitt", + "No partners yet.": "Engir samstarfsaðilar enn.", + "Users here may share to this partner": "Notendur hér mega deila með þessum samstarfsaðila", + "This partner may share to users here": "Þessi samstarfsaðili má deila með notendum hér", + "Partner address": "Vistfang samstarfsaðila", + "Check partner": "Athuga samstarfsaðila", + "Partner root fingerprint": "Rótarfingrafar samstarfsaðila", + "I compared this fingerprint with the partner's administrator": "Ég bar þetta fingrafar saman við stjórnanda samstarfsaðilans", + "Add partner": "Bæta við samstarfsaðila", + "A secret from another organisation": "Leyndarmál frá öðru fyrirtæki", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s deildi \"%2$s\" með þér. Samþykktu það undir Móttekið frá öðrum fyrirtækjum.", + "Incoming from other organisations": "Móttekið frá öðrum fyrirtækjum", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Fólk í samstarfsfyrirtækjum getur deilt leyndarmáli með þér. Samþykktu það til að geyma afrit sem er aðeins til lestrar í hvelfingunni þinni.", + "Nothing shared with you yet": "Engu hefur enn verið deilt með þér", + "Secrets that people in partner organisations share with you appear here.": "Leyndarmál sem fólk í samstarfsfyrirtækjum deilir með þér birtast hér.", + "From {sender}": "Frá {sender}", + "Accept": "Samþykkja", + "Open in vault": "Opna í hvelfingu", + "The other organisation did not hand over the secret. Try again later.": "Hitt fyrirtækið afhenti ekki leyndarmálið. Reyndu aftur síðar.", + "Set up your vault before you accept a shared secret.": "Settu upp hvelfinguna þína áður en þú samþykkir deilt leyndarmál.", + "Something went wrong. Try again.": "Eitthvað fór úrskeiðis. Reyndu aftur.", + "Waiting for your answer": "Bíður eftir svari þínu", + "In your vault, read-only": "Í hvelfingunni þinni, aðeins til lestrar", + "Withdrawn by the sender": "Sendandi dró til baka", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} deildi þessu frá öðru fyrirtæki. Þú getur lesið það en ekki breytt því eða deilt því.", + "Someone": "Einhver", + "Share with someone at another organisation": "Deila með einhverjum hjá öðru fyrirtæki", + "Their account at the other organisation": "Aðgangur viðkomandi hjá hinu fyrirtækinu", + "Check account": "Athuga aðgang", + "Certificate fingerprint of {account}": "Fingrafar skilríkis fyrir {account}", + "Compare it with them by phone if you want to be sure.": "Berðu það saman við viðkomandi í síma ef þú vilt vera viss.", + "Shared. {account} can accept it in their own vault.": "Deilt. {account} getur samþykkt það í sinni eigin hvelfingu.", + "The certificate could not be verified. Nothing was shared.": "Ekki tókst að staðfesta skilríkið. Engu var deilt.", + "That organisation is not one of your partners.": "Það fyrirtæki er ekki eitt af samstarfsfyrirtækjum þínum.", + "No one with that account can receive secrets from you.": "Enginn með þann aðgang getur tekið við leyndarmálum frá þér.", + "The other organisation did not answer. Try again later.": "Hitt fyrirtækið svaraði ekki. Reyndu aftur síðar.", + "This secret is already shared with that account.": "Þessu leyndarmáli er þegar deilt með þeim aðgangi.", + "Other organisations": "Önnur fyrirtæki", + "Receive secrets from other organisations": "Taka við leyndarmálum frá öðrum fyrirtækjum", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Fólk í samstarfsfyrirtækjum getur þá fundið aðganginn þinn og deilt leyndarmálum með þér. Þú samþykkir hvert þeirra fyrir sig.", + "Shared": "Deilt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Í bið: skilríki viðtakanda eða samstarfið breyttist. Afturkallaðu deilinguna eða deildu aftur.", + "Their organisation did not get the last change. Revoke it or share again.": "Fyrirtæki viðtakanda fékk ekki síðustu breytingu. Afturkallaðu deilinguna eða deildu aftur.", + "Being withdrawn": "Verið að afturkalla", + "Shared with another organisation": "Deilt með öðru fyrirtæki", + "Change sent to another organisation": "Breyting send til annars fyrirtækis", + "Share with another organisation revoked": "Sameign með öðru fyrirtæki afturkölluð", + "Share with another organisation paused": "Sameign með öðru fyrirtæki sett í bið", + "Another organisation did not get a change": "Annað fyrirtæki fékk ekki breytingu", + "Secret received from another organisation": "Leyndarmál móttekið frá öðru fyrirtæki", + "Secret from another organisation accepted": "Leyndarmál frá öðru fyrirtæki samþykkt", + "Secret from another organisation declined": "Leyndarmáli frá öðru fyrirtæki hafnað", + "Copy from another organisation updated": "Afrit frá öðru fyrirtæki uppfært", + "Copy from another organisation removed": "Afrit frá öðru fyrirtæki fjarlægt", + "Declined: they removed their copy. Share again if they need it.": "Hafnað: viðtakandi fjarlægði afritið sitt. Deildu aftur ef hann þarf það.", + "Recipient at another organisation removed their copy": "Viðtakandi hjá öðru fyrirtæki fjarlægði afritið sitt", + "Removed the user from %n team folder.": "Notandinn var fjarlægður úr %n teymismöppu.", + "Removed the user from %n team folders.": "Notandinn var fjarlægður úr %n teymismöppum.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Notandinn var fjarlægður úr %n teymismöppu.","Notandinn var fjarlægður úr %n teymismöppum."], + "A restored copy came from a share that has ended. It stays read-only.": "Endurheimt afrit kom úr deilingu sem er lokið. Það er áfram skrifvarið.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Ekki náðist í fyrirtækið sem deildi endurheimtu afriti. Afritið er áfram skrifvarið og fylgir ekki breytingum þeirra.", + "Recipient at another organisation restored their copy": "Viðtakandi hjá öðru fyrirtæki endurheimti afritið sitt" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/is.json b/l10n/is.json index 3b585ac7d..cec4ed0f0 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Deilt með hópum", + "Not shared with any group yet.": "Ekki enn deilt með neinum hópi.", + "Revoke the share with {group}": "Afturkalla deilingu með {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deilt með {group}: {received} meðlimir fengu það, {skipped} ekki þar sem þeir hafa ekki enn sett upp dulkóðun.", + "Search groups": "Leita að hópum", + "Failed to share": "Ekki tókst að deila", + "Columns": "Dálkar", + "Column {number}": "Dálkur {number}", + "Map one column to Name. Every secret needs a name.": "Tengdu einn dálk við Nafn. Hvert leyndarmál þarf nafn.", + "Notes": "Athugasemdir", + "Do not import": "Ekki flytja inn", + "Hide this value": "Fela þetta gildi", + "Show this value": "Sýna þetta gildi", + "Defaults": "Sjálfgefin gildi", + "New secrets start as this type, and your secret list opens in this view.": "Ný leyndarmál byrja sem þessi tegund og leyndarmálalistinn þinn opnast í þessari sýn.", + "Default item type": "Sjálfgefin tegund atriðis", + "Cards": "Spjöld", + "Table": "Tafla", + "Could not save your default": "Ekki tókst að vista sjálfgefna gildið þitt", + "Recently used": "Nýlega notað", + "Opened": "Opnað", + "You have not opened any secrets yet": "Þú hefur ekki opnað nein leyndarmál enn", + "Could not delete the item type.": "Ekki tókst að eyða tegund atriðis.", + "Could not load the item types.": "Ekki tókst að hlaða tegundum atriða.", + "Could not save the item type.": "Ekki tókst að vista tegund atriðis.", + "Delete item type": "Eyða tegund atriðis", + "Edit item type": "Breyta tegund atriðis", + "Fields": "Svið", + "Fields: {count}": "Svið: {count}", + "Hidden": "Falið", + "Item types": "Tegundir atriða", + "Move up": "Færa upp", + "New item type": "Ný tegund atriðis", + "No item types defined yet.": "Engar tegundir atriða skilgreindar enn.", + "Required": "Nauðsynlegt", + "Text": "Texti", + "This field is required": "Þetta svið er nauðsynlegt", + "Web address": "Veffang", + "{label} (required)": "{label} (nauðsynlegt)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eyða „{name}“? Leyndarmál af þessari tegund verða áfram læsileg og verða Innskráning-atriði.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tegundir atriða sem þú skilgreinir hér birtast öllum í glugganum Nýtt leyndarmál, með þeim sviðum sem þú velur.", + "Secret moved to the trash": "Leyndarmál fært í ruslið", + "Secret restored from the trash": "Leyndarmál endurheimt úr ruslinu", + "Secret deleted for good": "Leyndarmáli eytt endanlega", + "Secret archived": "Leyndarmál sett í safn", + "Secret unarchived": "Leyndarmál tekið úr safni", + "Unarchive": "Taka úr safni", + "Could not archive the secret": "Ekki tókst að setja leyndarmálið í safn", + "Could not unarchive the secret": "Ekki tókst að taka leyndarmálið úr safni", + "Archive {count} secrets": "Setja leyndarmál í safn: {count}", + "Unarchive {count} secrets": "Taka leyndarmál úr safni: {count}", + "Restore {count} secrets": "Endurheimta leyndarmál: {count}", + "Delete {count} secrets for good": "Eyða leyndarmálum endanlega: {count}", + "Done for {ok} of {total} secrets": "Lokið fyrir {ok} af {total} leyndarmálum", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Leyndarmál í safni hverfa úr lista hólfsins, leit, sjálfvirkri útfyllingu og heilsuskýrslunni. Þau halda deilingum sínum. Þú finnur þau undir Safn.", + "These secrets come back to the vault list, search and autofill.": "Þessi leyndarmál koma aftur í lista hólfsins, leit og sjálfvirka útfyllingu.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Þessi leyndarmál koma aftur í lista hólfsins. Gamlar deilingar koma ekki aftur, svo deildu þeim aftur þar sem þarf.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Þetta eyðir leyndarmálunum ásamt viðhengjum og útgáfusögu. Ekki er hægt að afturkalla þetta.", + "Delete for good": "Eyða endanlega", + "Trash": "Rusl", + "The trash is empty": "Ruslið er tómt", + "No archived secrets": "Engin leyndarmál í safni", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Eydd leyndarmál bíða hér þar til geymslutímanum lýkur, síðan er þeim eytt endanlega.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Settu leyndarmál í safn úr upplýsingaspjaldi þess til að halda því utan lista hólfsins, leitar og sjálfvirkrar útfyllingar.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Takmörk fyrir dulkóðuð viðhengi (framfylgt á þjóninum í vistuðum dulkóðuðum bætum), geymsla útgáfusögu og hve lengi eydd leyndarmál eru í ruslinu.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagar sem eytt leyndarmál er í ruslinu (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Þetta færir leyndarmálið í ruslið og lýkur deilingum þess strax. Þú getur endurheimt það úr ruslinu þar til geymslutímanum lýkur: 30 dagar nema kerfisstjórinn hafi breytt því.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Þetta færir leyndarmál í ruslið ({count}) og lýkur deilingum þeirra strax. Þú getur endurheimt þau úr ruslinu þar til geymslutímanum lýkur.", + "Remove {name} from favourites": "Fjarlægja {name} úr eftirlætum", + "Add {name} to favourites": "Bæta {name} við eftirlæti", + "Could not change the favourite": "Ekki tókst að breyta eftirlæti", + "Remove from favourites": "Fjarlægja úr eftirlætum", + "Add to favourites": "Bæta við eftirlæti", + "Tags": "Merki", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Merki eru ekki dulrituð. Kerfisstjórar netþjónsins geta lesið þau, eins og möppuheiti.", + "Favourites": "Eftirlæti", + "Filter by tag": "Sía eftir merki", + "All tags": "Öll merki", + "Last used": "Síðast notað", + "Tags for {count} secrets": "Merki fyrir {count} leyndarmál", + "Tag": "Merki", + "Remove tag": "Fjarlægja merki", + "Add tag": "Bæta við merki", + "Could not change the tags. Try again.": "Ekki tókst að breyta merkjunum. Reyndu aftur.", + "Could not approve the application. It is still in the queue.": "Ekki tókst að samþykkja umsóknina. Hún er enn í biðröð.", + "Could not reject the application. It is still in the queue.": "Ekki tókst að hafna umsókninni. Hún er enn í biðröð.", + "Removed the user from {count} team folders.": "Notandinn var fjarlægður úr {count} teymismöppum.", + "Approve a share": "Samþykkja deilingu", + "This approval link is incomplete. Open it again from the notification.": "Þessi samþykktartengill er ófullgerður. Opnaðu hann aftur úr tilkynningunni.", + "Deny": "Hafna", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} gekk í hóp sem þú deilir leyndarmáli með. Deila leyndarmálinu líka með viðkomandi?", + "{requester} asks you to share a secret with {user}.": "{requester} biður þig að deila leyndarmáli með {user}.", + "Shared. The recipient can now open the secret.": "Deilt. Viðtakandinn getur nú opnað leyndarmálið.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Viðtakandinn hefur ekki enn sett upp Keepiq, svo engu var deilt. Reyndu aftur þegar það hefur verið gert.", + "Could not share the secret. Only its owner can approve this.": "Ekki tókst að deila leyndarmálinu. Aðeins eigandi þess getur samþykkt þetta.", + "Could not share the secret. Try again.": "Ekki tókst að deila leyndarmálinu. Reyndu aftur.", + "Denied. Nothing was shared.": "Hafnað. Engu var deilt.", + "Could not deny the request. Try again.": "Ekki tókst að hafna beiðninni. Reyndu aftur.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s biður þig að deila leyndarmálinu \"%2$s\" með %3$s.", + "Expires on (optional)": "Rennur út (valfrjálst)", + "Hand over to": "Afhenda til", + "Choose a recipient": "Veldu viðtakanda", + "Hand over temporarily": "Afhenda tímabundið", + "Expiry rules": "Reglur um gildistíma", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stilltu hve lengi lykilorð af einni gerð atriða eða í einni möppu mega gilda og hvenær á að minna á. Þegar fleiri dagsetningar gilda ræður sú fyrsta.", + "Delete rule": "Eyða reglu", + "Set by your administrator": "Stillt af kerfisstjóra", + "No expiry rules yet.": "Engar reglur um gildistíma enn.", + "Applies to": "Gildir um", + "Item type": "Gerð atriðis", + "Maximum age in days (empty for reminders only)": "Hámarksaldur í dögum (autt fyrir áminningar eingöngu)", + "Remind me this many days before, comma separated": "Minna mig á svona mörgum dögum áður, aðskilið með kommum", + "Save rule": "Vista reglu", + "An item type": "Gerð atriðis", + "A folder": "Mappa", + "Folder {name}": "Mappa {name}", + "Type {name}": "Gerð {name}", + "Expires after {days} days": "Rennur út eftir {days} daga", + "Reminders {days} days before": "Áminningar {days} dögum áður", + "Could not save the expiry rule.": "Ekki tókst að vista regluna um gildistíma.", + "Could not delete the expiry rule.": "Ekki tókst að eyða reglunni um gildistíma.", + "All statuses": "Allar stöður", + "Compromised": "Í hættu", + "Could not load the members.": "Ekki tókst að hlaða inn meðlimum.", + "Emergency contact": "Neyðartengiliður", + "Leaving user": "Notandi sem hættir", + "No": "Nei", + "No users match this filter.": "Engir notendur passa við þessa síu.", + "Not set up": "Ekki sett upp", + "Revoke suite": "Afturkalla svítu", + "Revoked": "Afturkölluð", + "Search users": "Leita að notendum", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Sjáðu hvaða notendur hafa sett upp hólf. Byrjaðu brottför eða afturkallaðu svítu úr röð.", + "Successor": "Arftaki", + "Team folders": "Teymismöppur", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Notandinn er enn í hópnum {groups}, sem er meðlimur teymismöppu. Fjarlægðu hann úr hópnum eða gerðu aðganginn óvirkan.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Notandinn er enn í hópunum {groups}, sem eru meðlimir teymismappa. Fjarlægðu hann úr hópunum eða gerðu aðganginn óvirkan.", + "Vault status": "Staða hólfs", + "Yes": "Já", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF-útflutningur er ÓDULKÓÐAÐUR. Hvert lykilorð og innskráningarnafn verður læsilegt sem ódulkóðaður texti í skránni sem er hlaðið niður. Varðveittu hana á öruggum stað og eyddu henni strax eftir notkun.", + "Root certificate expiring soon": "Rótarvottorð rennur brátt út", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Rótarvottorð hólfsins rennur út eftir %1$d dag/daga. Endurnýjaðu það fyrir þann tíma. Endurnýjun undirritar hverja dulkóðunarsvítu upp á nýtt.", + "Compromise recovery aborted": "Endurheimt eftir innbrot hætt", + "Key rotation ended by a compromise revoke": "Lyklaskiptum lokið með afturköllun vegna innbrots", + "Encryption suite revoke refused": "Afturköllun dulkóðunarsvítu hafnað", + "Master password proof refused": "Sönnun aðallykilorðs hafnað", + "Your current master password": "Núverandi aðallykilorð þitt", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n neyðartengiliður var með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu hann. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n neyðartengiliðir voru með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu þá. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Þessir neyðartengiliðir voru ekki fluttir yfir á nýja lykilinn þinn. Neyðaraðgangur þeirra var fjarlægður. Bættu þeim við aftur í Neyðaraðgangi ef þú vilt þá enn.", + "Renew root certificate": "Endurnýja rótarskilríki", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Þetta býr til nýtt rótar- og millistigsskilríki. Hver virk dulkóðunarsvíta er undirrituð aftur. Ekki er hægt að afturkalla þetta.", + "Renew root": "Endurnýja rót", + "Root renewed. {n} encryption suites signed again.": "Rót endurnýjuð. Dulkóðunarsvítur undirritaðar aftur: {n}.", + "Could not renew the root certificate.": "Ekki tókst að endurnýja rótarskilríkið.", + "Lease policy for this application": "Leigustefna fyrir þetta forrit", + "In force now: {default} seconds by default, {max} seconds at most.": "Í gildi núna: sjálfgefið {default} sekúndur, mest {max} sekúndur.", + "Leases are not renewable": "Ekki er hægt að endurnýja leigur", + "Lease policy saved.": "Leigustefna vistuð.", + "Leave a field empty to use the instance value.": "Skildu reit eftir tóman til að nota gildi tilviksins.", + "Instance value: {value}": "Gildi tilviks: {value}", + "Renewal": "Endurnýjun", + "Use the instance value ({value})": "Nota gildi tilviksins ({value})", + "Allowed": "Leyft", + "Not allowed": "Ekki leyft", + "Save lease policy": "Vista leigustefnu", + "Only an administrator can change this policy.": "Aðeins stjórnandi getur breytt þessari stefnu.", + "Could not save the lease policy.": "Ekki tókst að vista leigustefnuna.", + "{member} got access from {confirmer}.": "{member} fékk aðgang frá {confirmer}.", + "Automatically confirm new team folder members": "Staðfesta nýja meðlimi teymismappa sjálfkrafa", + "Gave %n new member access to a team folder.": "%n nýr meðlimur fékk aðgang að teymismöppu.", + "Gave %n new members access to a team folder.": "%n nýir meðlimir fengu aðgang að teymismöppu.", + "Give new team folder members access without waiting for the folder owner.": "Gefðu nýjum meðlimum aðgang án þess að bíða eftir eiganda möppunnar.", + "New team folder members": "Nýir meðlimir teymismappa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Eigandinn eða meðlimur með skrifaðgang staðfestir þá úr opnu hólfi sínu. Keepiq afkóðar aldrei á þjóninum.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Beðið eftir að meðlimur með skrifaðgang opni Keepiq. Þú getur líka deilt núna.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Hluti af viðbrögðum við öryggisbresti mistókst ({failed} skref). Skoðaðu annál þjónsins og afturkallaðu svítuna aftur til að ljúka því.", + "This also revoked suite {suite} and ended key migration {migration}.": "Þetta afturkallaði einnig svítu {suite} og lauk lyklaflutningi {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengilið.", + "Revoking the second suite deleted %n emergency-access contacts.": "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengiliðum.", + "A suite revoked as compromised cannot be reinstated.": "Ekki er hægt að endurheimta svítu sem var afturkölluð sem í hættu.", + "Archives to keep": "Skjalasöfn sem á að geyma", + "Back up every vault automatically": "Taka öryggisafrit af hverju hólfi sjálfkrafa", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Taktu öryggisafrit af hverju hólfi samkvæmt áætlun. Söfnin innihalda aðeins dulkóðaðan texta og eru endurheimt með occ.", + "Back up now": "Taka öryggisafrit núna", + "Backup public key (PEM, optional)": "Opinber lykill öryggisafrits (PEM, valfrjálst)", + "Backup requested for the next cron run": "Öryggisafrit pantað fyrir næstu cron-keyrslu", + "Encrypted": "Dulkóðað", + "Every (hours)": "Á (klukkustunda) fresti", + "Last backup {when} failed: {error}": "Síðasta öryggisafrit {when} mistókst: {error}", + "Last backup {when} succeeded.": "Síðasta öryggisafrit {when} tókst.", + "No archives yet.": "Engin skjalasöfn enn.", + "Size": "Stærð", + "Vault backups": "Öryggisafrit hólfs", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Með lykli er hvert safn dulkóðað fyrir hann. Geymdu einkalykilinn utan þessa þjóns: þú þarft hann til að staðfesta eða endurheimta.", + "Written": "Skrifað", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n notandi innan umfangs er ekki enn með tveggja þátta innskráningu og getur ekki opnað hólfið á meðan þetta er virkt.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n notendur innan umfangs eru ekki enn með tveggja þátta innskráningu og geta ekki opnað hólfið á meðan þetta er virkt.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Varakóðar teljast ekki með. Ef notendur skrá sig inn í gegnum auðkennisveitu með eigin annan þátt, slepptu hópum þeirra.", + "Block personal vault export": "Loka á útflutning persónulegs hólfs", + "Keep work logins in team folders": "Geyma vinnuinnskráningar í teymismöppum", + "Move to a team folder": "Færa í teymismöppu", + "Not in a team folder": "Ekki í teymismöppu", + "Only for these groups (empty is everyone)": "Aðeins fyrir þessa hópa (autt þýðir allir)", + "Require two-factor login before the vault opens": "Krefjast tveggja þátta innskráningar áður en hólfið opnast", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglur fyrir hvert hólf. Hver gildir fyrir alla, eða aðeins hópana sem þú velur.", + "Secret types that belong in a team folder": "Tegundir leyndarmála sem eiga heima í teymismöppu", + "Set up two-factor login": "Setja upp tveggja þátta innskráningu", + "Team folder you can write to": "Teymismappa sem þú getur skrifað í", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Notendur geta ekki sótt öryggisafrit, CSV eða flutningsskrá. Persónuupplýsingapakki þeirra er áfram aðgengilegur.", + "Users cannot save these secret types in a personal folder.": "Notendur geta ekki vistað þessar tegundir leyndarmála í persónulegri möppu.", + "Vault policies": "Reglur hólfs", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Fyrirtækið þitt leyfir ekki útflutning á persónulega hólfinu þínu. Persónuupplýsingapakkinn þinn í stillingunum er áfram aðgengilegur.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Fyrirtækið þitt geymir þessi leyndarmál í teymismöppu. Færðu hvert og eitt í teymismöppu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Fyrirtækið þitt geymir þessa tegund leyndarmáls í teymismöppu. Veldu eina af teymismöppunum þínum, eða eina sem þú getur skrifað í.", + "Your organisation requires two-factor login before you can open your vault.": "Fyrirtækið þitt krefst tveggja þátta innskráningar áður en þú getur opnað hólfið þitt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Notendur velja hversu lengi viðbótin er ólæst í aðgerðaleysi. Þú stillir lengsta tímann sem þeir mega velja.", + "Longest idle time before the extension locks": "Lengsti aðgerðaleysistími áður en viðbótin læsist", + "1 minute": "1 mínúta", + "5 minutes": "5 mínútur", + "15 minutes": "15 mínútur", + "1 hour": "1 klukkustund", + "4 hours": "4 klukkustundir", + "Connector": "Tengill", + "Directory (tenant) ID": "Auðkenni möppu (leigjanda)", + "Application (client) ID": "Auðkenni forrits (biðlara)", + "Data collection rule immutable ID": "Óbreytanlegt auðkenni gagnasöfnunarreglu", + "Stream name": "Heiti straums", + "Splunk index (optional)": "Splunk-vísir (valkvætt)", + "Sourcetype (optional)": "Sourcetype (valkvætt)", + "Leave blank to keep the current one": "Skildu eftir autt til að halda núverandi", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF yfir syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endapunktur gagnasöfnunar (https-slóð)", + "HTTP Event Collector URL (https)": "Slóð HTTP Event Collector (https)", + "Client secret (write-only)": "Leyndarmál biðlara (aðeins skrifa)", + "HEC token (write-only)": "HEC-tóki (aðeins skrifa)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Áframsendu leyfða endurskoðunaratburði til Splunk, Microsoft Sentinel, syslog-móttakara eða vefkróks. Skilaboð bera aðeins hreinsuð lýsigögn: ekkert leynigildi, nafn, innskráning eða dulkóðaður texti fer nokkurn tíma af þjóninum.", + "%n change waiting to sync": "%n breyting bíður samstillingar", + "%n changes waiting to sync": "%n breytingar bíða samstillingar", + "Changes that could not sync": "Breytingar sem ekki tókst að samstilla", + "Choose a version": "Veldu útgáfu", + "Copy value": "Afrita gildi", + "Deleted": "Eytt", + "Discard": "Henda", + "Keep my offline change": "Halda breytingunni minni án nettengingar", + "Keep the server version": "Halda útgáfu þjónsins", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er aðeins til lestrar án nettengingar. Kerfisstjóri hefur ekki kveikt á breytingum án nettengingar.", + "Let users edit secrets offline": "Leyfa notendum að breyta leyndarmálum án nettengingar", + "Not synced yet": "Ekki samstillt enn", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Breytingar án nettengingar eru geymdar á tækinu, dulkóðaðar fyrir notandann, og samstilltar við næstu aflæsingu á netinu. Deiling, möppur og viðhengi þurfa enn tengingu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Án nettengingar. Breytingar, færslur og eyðingar haldast á þessu tæki og samstillast þegar þú ert aftur á netinu. Deiling og viðhengi þurfa tengingu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Án nettengingar. Breytingarnar þínar haldast á þessu tæki og samstillast þegar þú ert aftur á netinu. Síðast samstillt {when}.", + "Open my changes": "Opna breytingarnar mínar", + "Sharing needs a connection": "Deiling þarf tengingu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Einhver breytti þessu leyndarmáli á þjóninum eftir að afrit þitt án nettengingar var gert. Veldu hvaða útgáfu á að halda.", + "Sync or discard your offline changes before you rotate your keys.": "Samstilltu eða hentu breytingunum án nettengingar áður en þú skiptir um lykla.", + "That password did not open your changes.": "Þetta lykilorð opnaði ekki breytingarnar þínar.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Skyndimyndin án nettengingar geymir dulkóðuð leyndarmál (aðeins hægt að opna með lyklinum sem leiddur er af aðallykilorði notandans, alveg eins og á þjóninum) og dulkóðar nöfn, vefslóðir og möppunöfn í geymslu. Aðgangur án nettengingar er aðeins til lestrar nema þú leyfir breytingar án nettengingar hér fyrir neðan. Slökktu á þessu fyrir tæki sem mega aldrei geyma innskráningarupplýsingar; ef slökkt er hreinsast skyndiminni við næstu hleðslu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Fyrra afrit hólfsins er horfið, svo ekki er hægt að opna þessar breytingar.", + "The server version": "Útgáfa þjónsins", + "This secret changed while you were offline": "Þessu leyndarmáli var breytt meðan þú varst án nettengingar", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Þú eyddir þessu leyndarmáli án nettengingar, en því hefur síðan verið breytt á þjóninum. Veldu hvaða útgáfu á að halda.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Lyklunum þínum var breytt á öðru tæki. Sláðu inn fyrra aðallykilorðið til að samstilla breytingarnar án nettengingar, eða hentu þeim.", + "Your offline change": "Breytingin þín án nettengingar", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n neyðartengiliður var með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu hann. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur.", + "%n neyðartengiliðir voru með opna aðgangsbeiðni þegar lyklaskiptin fjarlægðu þá. Athugaðu hver bað um aðgang áður en þú bætir einhverjum við aftur." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "Ekki er unnt að setja %n atriði fram í CXF og því verður sleppt.", + "Ekki er unnt að setja %n atriði fram í CXF og þeim verður sleppt." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n eldri útgáfu var sleppt því aðeins nýlega sögu er unnt að flytja með.", + "%n eldri útgáfum var sleppt því aðeins nýlega sögu er unnt að flytja með." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Enn þarf að dulkóða og deila %n afriti leyndarmáls.", + "Enn þarf að dulkóða og deila %n afritum leyndarmála." + ], + "_%n secret could not be decrypted and is not in this export._::_%n secrets could not be decrypted and are not in this export._": [ + "Ekki tókst að afkóða %n leyndarmál og það er ekki í þessum útflutningi.", + "Ekki tókst að afkóða %n leyndarmál og þau eru ekki í þessum útflutningi." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "Ekki var unnt að afkóða %n leyndarmál með gamla lyklinum þínum, svo það fluttist ekki.", + "Ekki var unnt að afkóða %n leyndarmál með gamla lyklinum þínum, svo þau fluttust ekki." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n leyndarmál fluttist ekki.", + "%n leyndarmál fluttust ekki." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n leyndarmál er enn kóðað með fyrri lyklinum þínum.", + "%n leyndarmál eru enn kóðuð með fyrri lyklinum þínum." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n leyndarmáli var sleppt því arftakinn hefur ekki afrit enn — bættu arftakanum í möppuna og keyrðu aftur.", + "%n leyndarmálum var sleppt því arftakinn hefur ekki afrit enn — bættu arftakanum í möppuna og keyrðu aftur." + ], + "_%n secret_::_%n secrets_": [ + "%n leyndarmál", + "%n leyndarmál" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n notandi innan umfangs er ekki enn með tveggja þátta innskráningu og getur ekki opnað hólfið á meðan þetta er virkt.", + "%n notendur innan umfangs eru ekki enn með tveggja þátta innskráningu og geta ekki opnað hólfið á meðan þetta er virkt." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Ljúka samt og missa aðgang að %n leyndarmáli", + "Ljúka samt og missa aðgang að %n leyndarmálum" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nýr meðlimur fékk aðgang að teymismöppu.", + "%n nýir meðlimir fengu aðgang að teymismöppu." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Lyklasnúningi lokið. %n leyndarmál var endurkóðað með nýja lyklinum þínum.", + "Lyklasnúningi lokið. %n leyndarmál voru endurkóðuð með nýja lyklinum þínum." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengilið.", + "Afturköllun seinni svítunnar eyddi %n neyðaraðgangstengiliðum." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Afturköllun þessa setts eyddi %n neyðaraðgangstengilið.", + "Afturköllun þessa setts eyddi %n neyðaraðgangstengiliðum." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "sást %n sinni í lekum", + "sást %n sinnum í lekum" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "deilt með %n leyndarmáli", + "deilt með %n leyndarmálum" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Þessi mappa inniheldur %n leyndarmál beint.", + "Þessi mappa inniheldur %n leyndarmál beint." + ], + "_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._": [ + "Lyklasnúningurinn fjarlægði %n neyðartengilið. Skoðaðu Neyðaraðgang og bættu honum aftur við ef þú vilt hann enn.", + "Lyklasnúningurinn fjarlægði %n neyðartengiliði. Skoðaðu Neyðaraðgang og bættu þeim aftur við ef þú vilt þá enn." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n breyting bíður samstillingar", + "%n breytingar bíða samstillingar" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Notandinn er enn í hópnum {groups}, sem er meðlimur teymismöppu. Fjarlægðu hann úr hópnum eða gerðu aðganginn óvirkan.", + "Notandinn er enn í hópunum {groups}, sem eru meðlimir teymismappa. Fjarlægðu hann úr hópunum eða gerðu aðganginn óvirkan." + ], + "Allow approval from another device": "Leyfa samþykki úr öðru tæki", + "App": "Forrit", + "Approve a new device": "Samþykkja nýtt tæki", + "Approve from another device": "Samþykkja úr öðru tæki", + "Asked at": "Beðið um kl.", + "Check that the new device shows these words:": "Gakktu úr skugga um að nýja tækið sýni þessi orð:", + "Denied. If you did not ask, end your other sessions:": "Hafnað. Ef þú baðst ekki um þetta skaltu ljúka öðrum setum þínum:", + "Device": "Tæki", + "IP address": "IP-vistfang", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Leyfa notendum að aflæsa nýjum vafra með því að samþykkja hann úr tæki þar sem Keepiq er þegar aflæst.", + "New device approval": "Samþykki nýrra tækja", + "Nextcloud security settings": "Öryggisstillingar Nextcloud", + "Only approve a device you are using right now.": "Samþykktu aðeins tæki sem þú ert að nota núna.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Opnaðu Keepiq í tæki þar sem það er aflæst og samþykktu þetta tæki. Gakktu úr skugga um að það sýni sömu orð:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Tækið sem samþykkir innsiglar aflæsingarlykilinn fyrir nýja tækið. Þjónninn kemur honum aðeins áfram og getur ekki opnað hann.", + "The master password is not right, or the request has ended.": "Aðallykilorðið er ekki rétt, eða beiðninni er lokið.", + "The request expired. Ask again or use your master password.": "Beiðnin rann út. Biddu aftur eða notaðu aðallykilorðið þitt.", + "The request was denied.": "Beiðninni var hafnað.", + "Too many requests. Try again in an hour or use your master password.": "Of margar beiðnir. Reyndu aftur eftir klukkustund eða notaðu aðallykilorðið þitt.", + "Unknown device": "Óþekkt tæki", + "Web app": "Vefforrit", + "A device": "Tæki", + "A new device asks to open your vault": "Nýtt tæki biður um að opna hvelfinguna þína", + "%s asks to be approved. Only approve a device you are using right now.": "%s biður um samþykki. Samþykktu aðeins tæki sem þú ert að nota núna.", + "Access ends on (optional)": "Aðgangi lýkur (valfrjálst)", + "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.": "Forrit Keepiq sýna hvorki né afrita lykilorðið. Einhver með tæknikunnáttu getur samt lesið það úr eigin tæki. Skiptu um það þegar aðgangi viðkomandi lýkur.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Þetta leyndarmál er aðeins til notkunar. Skráðu þig inn í gegnum Keepiq-vafraviðbótina.", + "Until {date}": "Til {date}", + "Use only": "Aðeins notkun", + "Use only (can sign in, cannot view or copy)": "Aðeins notkun (getur skráð sig inn, getur ekki skoðað eða afritað)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Þú getur skráð þig inn með þessari innskráningu í gegnum Keepiq-vafraviðbótina. Eigandinn valdi að leyfa þér ekki að skoða eða afrita hana.", + "Your access ends on {date}": "Aðgangi þínum lýkur {date}", + "Your access to this secret has ended": "Aðgangi þínum að þessu leyndarmáli er lokið", + "Your access to \"%s\" ends tomorrow": "Aðgangi þínum að „%s“ lýkur á morgun", + "Your access to \"%s\" has ended": "Aðgangi þínum að „%s“ er lokið", + "%1$s no longer has access to \"%2$s\"": "%1$s hefur ekki lengur aðgang að „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s gat séð þetta lykilorð. Skiptu um það ef %1$s á ekki lengur að vita það.", + "%s could not view this password in Keepiq.": "%s gat ekki skoðað þetta lykilorð í Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} af {threshold} samþykktum", + "a recovery officer": "endurheimtufulltrúi", + "Account recovery": "Endurheimt aðgangs", + "Approvals needed": "Samþykktir sem þarf", + "Ask {user} which words they see, by phone or in person. They must be:": "Spyrðu {user} hvaða orð birtast, í síma eða í eigin persónu. Þau verða að vera:", + "Check again": "Athuga aftur", + "Create the recovery key": "Búa til endurheimtulykil", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Búðu til endurheimtulykilinn. Vafrinn þinn býr hann til og gefur hverjum fulltrúa afrit sem aðeins hann getur opnað.", + "Decline": "Hafna", + "Enrol in account recovery": "Skrá sig í endurheimt aðgangs", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Skráðu þig svo fyrirtækið þitt geti hjálpað þér að fá hvelfinguna aftur ef þú gleymir aðallykilorðinu.", + "Every user is enrolled": "Allir notendur eru skráðir", + "Finish the recovery in the browser you asked from.": "Ljúktu endurheimtinni í vafranum sem þú baðst um hana úr.", + "Forgot your master password?": "Gleymdirðu aðallykilorðinu?", + "Hand the key over": "Afhenda lykilinn", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Leyfðu notendum sem gleymdu aðallykilorðinu að fá hvelfinguna aftur, með samþykki endurheimtufulltrúa sem þú tilnefnir.", + "New master password": "Nýtt aðallykilorð", + "No one is asking to recover their account.": "Enginn er að biðja um endurheimt aðgangs.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Enginn endurheimtulykill enn. Einn fulltrúanna býr hann til í Keepiq-stillingunum sínum.", + "Off": "Slökkt", + "Officer {user} has no encryption set up yet.": "Fulltrúinn {user} hefur ekki sett upp dulritun enn.", + "Officers (user IDs, separated by commas)": "Fulltrúar (notendaauðkenni, aðskilin með kommum)", + "Policy": "Regla", + "Publish this fingerprint internally, so users can check it before they enrol.": "Birtu þetta fingrafar innanhúss svo notendur geti athugað það áður en þeir skrá sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Endurheimt með hjálp {officer}. Skiptu um lykil hvelfingarinnar núna í Stillingar, Öryggi: \"Aðallykilorðið mitt hefur lekið\".", + "Recovery key fingerprint: {fingerprint}": "Fingrafar endurheimtulykils: {fingerprint}", + "Recovery officer": "Endurheimtufulltrúi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fulltrúar sem voru fjarlægðir missa afritið sitt núna, en gætu hafa opnað það áður. Láttu fulltrúa búa til nýjan endurheimtulykil.", + "Repeat the new master password": "Endurtaktu nýja aðallykilorðið", + "Retire this recovery key": "Taka þennan endurheimtulykil úr notkun", + "Set the new master password": "Setja nýja aðallykilorðið", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Endurheimtuvottorðið er ekki gefið út af þessu Keepiq. Ekki skrá þig og láttu kerfisstjórann vita.", + "The words match, approve": "Orðin passa, samþykkja", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Þessi notandi er skráður í endurheimt aðgangs. Endurheimt heldur leyndarmálum hans; afturköllun eyðir skráningunni.", + "Users may enrol": "Notendur mega skrá sig", + "Withdraw from account recovery": "Afskrá sig úr endurheimt aðgangs", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Þú ert skráð(ur) í endurheimt aðgangs. Fingrafar endurheimtulykils: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Þú ert skráð(ur). Ef þú gleymir aðallykilorðinu getur fyrirtækið þitt hjálpað þér að fá hvelfinguna aftur.", + "Your key is back. Choose a new master password.": "Lykillinn er kominn aftur. Veldu nýtt aðallykilorð.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Endurheimtufulltrúarnir þínir hafa verið látnir vita. Lestu fyrir þá þessi orð þegar þeir hringja eða hitta þig:", + "You are now an account recovery officer": "Þú ert nú endurheimtufulltrúi aðganga", + "%s asks to recover their account. Compare the words with them before you approve.": "%s biður um endurheimt aðgangs. Berðu orðin saman við viðkomandi áður en þú samþykkir.", + "A user": "Notandi", + "Your account recovery request was declined": "Beiðni þinni um endurheimt aðgangs var hafnað", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Endurheimt aðgangsins er tilbúin. Opnaðu Keepiq í vafranum sem þú baðst um hana úr.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} biður um að opna nýtt tæki einu sinni. Aðallykilorðið helst óbreytt.", + "Ask your organisation instead": "Biddu frekar stofnunina þína", + "The request ended. Ask again or use your master password.": "Beiðninni er lokið. Biddu aftur eða notaðu aðallykilorðið þitt.", + "Added by {user}": "Bætt við af {user}", + "Editor": "Ritstjóri", + "Manager": "Stjórnandi", + "Role of {member}": "Hlutverk {member}", + "Team folders you manage": "Teymismöppur sem þú stjórnar", + "Viewer": "Lesandi", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Þú átt ekkert afrit af þessum leyndarmálum, svo nýju meðlimirnir hafa ekki fengið þau enn. Eigandinn getur deilt þeim: {names}", + "Admin areas": "Stjórnunarsvið", + "Give a group only the parts of Keepiq administration it needs.": "Gefðu hópi aðeins þá hluta Keepiq-stjórnunar sem hann þarf.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Úthlutaðu einu eða fleiri sviðum til hóps á síðunni fyrir stjórnunarheimildir. Kerfisstjórar tilviksins hafa öll svið.", + "Open administration privileges": "Opna stjórnunarheimildir", + "Policies": "Reglur", + "Applications and machine access": "Forrit og aðgangur véla", + "People and offboarding": "Fólk og starfslok", + "Audit and compliance": "Endurskoðun og regluvarsla", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "útgáfa, vottunarstöð, viðhengi, ónettengt skyndiminni, lekaathugun, tegundir leyndarmála og afrit", + "master password, organisation password, vault policies, rotation, version history and trash": "aðallykilorð, lykilorð fyrirtækis, reglur hvelfingar, snúningur, útgáfusaga og ruslafata", + "application queue, application requests and machine leases": "forritaröð, beiðnir forrita og vélaleigur", + "team offboarding, encryption suites and admin handover": "starfslok í teymi, dulritunarsvítur og yfirtaka kerfisstjóra", + "audit log, compliance reports, SIEM export and honey alerts": "endurskoðunarskrá, skýrslur um regluvörslu, SIEM-útflutningur og agnviðvaranir", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hversu margar útgáfur leyndarmáls eru geymdar, hve lengi, og hve lengi eydd leyndarmál eru í ruslafötunni.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Takmörk fyrir dulrituð viðhengi, framfylgt á þjóninum í vistuðum dulrituðum bætum.", + "Type the suite ID again to confirm": "Sláðu inn pakkaauðkennið aftur til að staðfesta", + "This does not match the suite ID.": "Þetta passar ekki við pakkaauðkennið.", + "Confirm with your master password": "Staðfestu með aðallykilorðinu þínu", + "Confirm": "Staðfesta", + "That master password is not right.": "Þetta aðallykilorð er ekki rétt.", + "You are sharing with someone new. Enter your master password to confirm.": "Þú ert að deila með nýjum aðila. Sláðu inn aðallykilorðið til að staðfesta.", + "Enter your master password to confirm this share.": "Sláðu inn aðallykilorðið til að staðfesta þessa deilingu.", + "Enter your master password to confirm this delegation.": "Sláðu inn aðallykilorðið til að staðfesta þessa úthlutun.", + "Approve {member}": "Samþykkja {member}", + "Recipient": "Viðtakandi", + "No vault yet": "Engin hvelfing enn", + "No matching users": "Engir notendur passa", + "Partner organisations": "Samstarfsfyrirtæki", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Skiptist á leyndarmálum við annað Keepiq. Báðir stjórnendur bæta hvor öðrum við og bera saman rótarfingraför í síma eða í eigin persónu áður en vistað er.", + "Federation needs Nextcloud 33 or later.": "Samtenging krefst Nextcloud 33 eða nýrra.", + "Your root fingerprint": "Rótarfingrafarið þitt", + "No partners yet.": "Engir samstarfsaðilar enn.", + "Users here may share to this partner": "Notendur hér mega deila með þessum samstarfsaðila", + "This partner may share to users here": "Þessi samstarfsaðili má deila með notendum hér", + "Partner address": "Vistfang samstarfsaðila", + "Check partner": "Athuga samstarfsaðila", + "Partner root fingerprint": "Rótarfingrafar samstarfsaðila", + "I compared this fingerprint with the partner's administrator": "Ég bar þetta fingrafar saman við stjórnanda samstarfsaðilans", + "Add partner": "Bæta við samstarfsaðila", + "A secret from another organisation": "Leyndarmál frá öðru fyrirtæki", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s deildi \"%2$s\" með þér. Samþykktu það undir Móttekið frá öðrum fyrirtækjum.", + "Incoming from other organisations": "Móttekið frá öðrum fyrirtækjum", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Fólk í samstarfsfyrirtækjum getur deilt leyndarmáli með þér. Samþykktu það til að geyma afrit sem er aðeins til lestrar í hvelfingunni þinni.", + "Nothing shared with you yet": "Engu hefur enn verið deilt með þér", + "Secrets that people in partner organisations share with you appear here.": "Leyndarmál sem fólk í samstarfsfyrirtækjum deilir með þér birtast hér.", + "From {sender}": "Frá {sender}", + "Accept": "Samþykkja", + "Open in vault": "Opna í hvelfingu", + "The other organisation did not hand over the secret. Try again later.": "Hitt fyrirtækið afhenti ekki leyndarmálið. Reyndu aftur síðar.", + "Set up your vault before you accept a shared secret.": "Settu upp hvelfinguna þína áður en þú samþykkir deilt leyndarmál.", + "Something went wrong. Try again.": "Eitthvað fór úrskeiðis. Reyndu aftur.", + "Waiting for your answer": "Bíður eftir svari þínu", + "In your vault, read-only": "Í hvelfingunni þinni, aðeins til lestrar", + "Withdrawn by the sender": "Sendandi dró til baka", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} deildi þessu frá öðru fyrirtæki. Þú getur lesið það en ekki breytt því eða deilt því.", + "Someone": "Einhver", + "Share with someone at another organisation": "Deila með einhverjum hjá öðru fyrirtæki", + "Their account at the other organisation": "Aðgangur viðkomandi hjá hinu fyrirtækinu", + "Check account": "Athuga aðgang", + "Certificate fingerprint of {account}": "Fingrafar skilríkis fyrir {account}", + "Compare it with them by phone if you want to be sure.": "Berðu það saman við viðkomandi í síma ef þú vilt vera viss.", + "Shared. {account} can accept it in their own vault.": "Deilt. {account} getur samþykkt það í sinni eigin hvelfingu.", + "The certificate could not be verified. Nothing was shared.": "Ekki tókst að staðfesta skilríkið. Engu var deilt.", + "That organisation is not one of your partners.": "Það fyrirtæki er ekki eitt af samstarfsfyrirtækjum þínum.", + "No one with that account can receive secrets from you.": "Enginn með þann aðgang getur tekið við leyndarmálum frá þér.", + "The other organisation did not answer. Try again later.": "Hitt fyrirtækið svaraði ekki. Reyndu aftur síðar.", + "This secret is already shared with that account.": "Þessu leyndarmáli er þegar deilt með þeim aðgangi.", + "Other organisations": "Önnur fyrirtæki", + "Receive secrets from other organisations": "Taka við leyndarmálum frá öðrum fyrirtækjum", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Fólk í samstarfsfyrirtækjum getur þá fundið aðganginn þinn og deilt leyndarmálum með þér. Þú samþykkir hvert þeirra fyrir sig.", + "Shared": "Deilt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Í bið: skilríki viðtakanda eða samstarfið breyttist. Afturkallaðu deilinguna eða deildu aftur.", + "Their organisation did not get the last change. Revoke it or share again.": "Fyrirtæki viðtakanda fékk ekki síðustu breytingu. Afturkallaðu deilinguna eða deildu aftur.", + "Being withdrawn": "Verið að afturkalla", + "Shared with another organisation": "Deilt með öðru fyrirtæki", + "Change sent to another organisation": "Breyting send til annars fyrirtækis", + "Share with another organisation revoked": "Sameign með öðru fyrirtæki afturkölluð", + "Share with another organisation paused": "Sameign með öðru fyrirtæki sett í bið", + "Another organisation did not get a change": "Annað fyrirtæki fékk ekki breytingu", + "Secret received from another organisation": "Leyndarmál móttekið frá öðru fyrirtæki", + "Secret from another organisation accepted": "Leyndarmál frá öðru fyrirtæki samþykkt", + "Secret from another organisation declined": "Leyndarmáli frá öðru fyrirtæki hafnað", + "Copy from another organisation updated": "Afrit frá öðru fyrirtæki uppfært", + "Copy from another organisation removed": "Afrit frá öðru fyrirtæki fjarlægt", + "Declined: they removed their copy. Share again if they need it.": "Hafnað: viðtakandi fjarlægði afritið sitt. Deildu aftur ef hann þarf það.", + "Recipient at another organisation removed their copy": "Viðtakandi hjá öðru fyrirtæki fjarlægði afritið sitt", + "Removed the user from %n team folder.": "Notandinn var fjarlægður úr %n teymismöppu.", + "Removed the user from %n team folders.": "Notandinn var fjarlægður úr %n teymismöppum.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Notandinn var fjarlægður úr %n teymismöppu.", + "Notandinn var fjarlægður úr %n teymismöppum." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Endurheimt afrit kom úr deilingu sem er lokið. Það er áfram skrifvarið.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Ekki náðist í fyrirtækið sem deildi endurheimtu afriti. Afritið er áfram skrifvarið og fylgir ekki breytingum þeirra.", + "Recipient at another organisation restored their copy": "Viðtakandi hjá öðru fyrirtæki endurheimti afritið sitt" }, "plurals": null } diff --git a/l10n/it.js b/l10n/it.js index 0ec8d27a6..ec422c961 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Condiviso con gruppi", + "Not shared with any group yet.": "Non ancora condiviso con alcun gruppo.", + "Revoke the share with {group}": "Revoca la condivisione con {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Condiviso con {group}: {received} membri lo hanno ricevuto, {skipped} no perché non hanno ancora configurato la crittografia.", + "Search groups": "Cerca gruppi", + "Failed to share": "Condivisione non riuscita", + "Columns": "Colonne", + "Column {number}": "Colonna {number}", + "Map one column to Name. Every secret needs a name.": "Associa una colonna al nome. Ogni segreto ha bisogno di un nome.", + "Notes": "Note", + "Do not import": "Non importare", + "Hide this value": "Nascondi questo valore", + "Show this value": "Mostra questo valore", + "Defaults": "Predefiniti", + "New secrets start as this type, and your secret list opens in this view.": "I nuovi segreti iniziano con questo tipo e il tuo elenco di segreti si apre in questa vista.", + "Default item type": "Tipo di elemento predefinito", + "Cards": "Schede", + "Table": "Tabella", + "Could not save your default": "Impossibile salvare il valore predefinito", + "Recently used": "Usati di recente", + "Opened": "Aperto", + "You have not opened any secrets yet": "Non hai ancora aperto alcun segreto", + "Could not delete the item type.": "Impossibile eliminare il tipo di elemento.", + "Could not load the item types.": "Impossibile caricare i tipi di elemento.", + "Could not save the item type.": "Impossibile salvare il tipo di elemento.", + "Delete item type": "Elimina tipo di elemento", + "Edit item type": "Modifica tipo di elemento", + "Fields": "Campi", + "Fields: {count}": "Campi: {count}", + "Hidden": "Nascosto", + "Item types": "Tipi di elemento", + "Move up": "Sposta su", + "New item type": "Nuovo tipo di elemento", + "No item types defined yet.": "Nessun tipo di elemento definito.", + "Required": "Obbligatorio", + "Text": "Testo", + "This field is required": "Questo campo è obbligatorio", + "Web address": "Indirizzo web", + "{label} (required)": "{label} (obbligatorio)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eliminare «{name}»? I segreti di questo tipo restano leggibili e diventano elementi Accesso.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "I tipi di elemento che definisci qui compaiono per tutti nella finestra Nuovo segreto, con i campi che scegli.", + "Secret moved to the trash": "Segreto spostato nel cestino", + "Secret restored from the trash": "Segreto ripristinato dal cestino", + "Secret deleted for good": "Segreto eliminato definitivamente", + "Secret archived": "Segreto archiviato", + "Secret unarchived": "Segreto rimosso dall'archivio", + "Unarchive": "Rimuovi dall'archivio", + "Could not archive the secret": "Impossibile archiviare il segreto", + "Could not unarchive the secret": "Impossibile rimuovere il segreto dall'archivio", + "Archive {count} secrets": "Archivia {count} segreti", + "Unarchive {count} secrets": "Rimuovi {count} segreti dall'archivio", + "Restore {count} secrets": "Ripristina {count} segreti", + "Delete {count} secrets for good": "Elimina definitivamente {count} segreti", + "Done for {ok} of {total} secrets": "Fatto per {ok} segreti su {total}", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "I segreti archiviati escono dall'elenco della cassaforte, dalla ricerca, dalla compilazione automatica e dal rapporto sulla salute. Restano condivisi. Li trovi in Archivio.", + "These secrets come back to the vault list, search and autofill.": "Questi segreti tornano nell'elenco della cassaforte, nella ricerca e nella compilazione automatica.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Questi segreti tornano nell'elenco della cassaforte. Le vecchie condivisioni non tornano, quindi condividili di nuovo dove serve.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Questo elimina i segreti con i loro allegati e la cronologia delle versioni. L'operazione non può essere annullata.", + "Delete for good": "Elimina definitivamente", + "Trash": "Cestino", + "The trash is empty": "Il cestino è vuoto", + "No archived secrets": "Nessun segreto archiviato", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "I segreti eliminati restano qui fino alla fine del periodo di conservazione, poi vengono eliminati definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivia un segreto dal suo pannello dei dettagli per tenerlo fuori dall'elenco della cassaforte, dalla ricerca e dalla compilazione automatica.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limiti per gli allegati cifrati (applicati sul server ai byte cifrati salvati), conservazione della cronologia delle versioni e per quanto tempo i segreti eliminati restano nel cestino.", + "Days a deleted secret stays in the trash (1 to 365)": "Giorni in cui un segreto eliminato resta nel cestino (da 1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Questo sposta il segreto nel cestino e termina subito le sue condivisioni. Puoi ripristinarlo dal cestino fino alla fine del periodo di conservazione: 30 giorni, salvo modifiche del tuo amministratore.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Questo sposta {count} segreti nel cestino e termina subito le loro condivisioni. Puoi ripristinarli dal cestino fino alla fine del periodo di conservazione.", + "Remove {name} from favourites": "Rimuovi {name} dai preferiti", + "Add {name} to favourites": "Aggiungi {name} ai preferiti", + "Could not change the favourite": "Impossibile modificare il preferito", + "Remove from favourites": "Rimuovi dai preferiti", + "Add to favourites": "Aggiungi ai preferiti", + "Tags": "Etichette", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Le etichette non sono cifrate. Gli amministratori del server possono leggerle, come i nomi delle cartelle.", + "Favourites": "Preferiti", + "Filter by tag": "Filtra per etichetta", + "All tags": "Tutte le etichette", + "Last used": "Ultimo utilizzo", + "Tags for {count} secrets": "Etichette per {count} segreti", + "Tag": "Etichetta", + "Remove tag": "Rimuovi etichetta", + "Add tag": "Aggiungi etichetta", + "Could not change the tags. Try again.": "Impossibile modificare le etichette. Riprova.", + "Could not approve the application. It is still in the queue.": "Impossibile approvare la richiesta. È ancora in coda.", + "Could not reject the application. It is still in the queue.": "Impossibile rifiutare la richiesta. È ancora in coda.", + "Removed the user from {count} team folders.": "Utente rimosso da {count} cartelle di team.", + "Approve a share": "Approva una condivisione", + "This approval link is incomplete. Open it again from the notification.": "Questo link di approvazione è incompleto. Aprilo di nuovo dalla notifica.", + "Deny": "Nega", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} si è unito a un gruppo con cui condividi un segreto. Condividere il segreto anche con questa persona?", + "{requester} asks you to share a secret with {user}.": "{requester} ti chiede di condividere un segreto con {user}.", + "Shared. The recipient can now open the secret.": "Condiviso. Il destinatario ora può aprire il segreto.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Il destinatario non ha ancora configurato Keepiq, quindi non è stato condiviso nulla. Riprova quando lo avrà fatto.", + "Could not share the secret. Only its owner can approve this.": "Impossibile condividere il segreto. Solo il proprietario può approvarlo.", + "Could not share the secret. Try again.": "Impossibile condividere il segreto. Riprova.", + "Denied. Nothing was shared.": "Negato. Non è stato condiviso nulla.", + "Could not deny the request. Try again.": "Impossibile negare la richiesta. Riprova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ti chiede di condividere il segreto \"%2$s\" con %3$s.", + "Expires on (optional)": "Scade il (facoltativo)", + "Hand over to": "Affida a", + "Choose a recipient": "Scegli un destinatario", + "Hand over temporarily": "Affida temporaneamente", + "Expiry rules": "Regole di scadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Imposta per quanto tempo possono durare le password di un tipo di elemento o in una cartella, e quando ricevere un promemoria. Se valgono più date, conta la prima.", + "Delete rule": "Elimina regola", + "Set by your administrator": "Impostato dal tuo amministratore", + "No expiry rules yet.": "Ancora nessuna regola di scadenza.", + "Applies to": "Si applica a", + "Item type": "Tipo di elemento", + "Maximum age in days (empty for reminders only)": "Età massima in giorni (vuoto per solo promemoria)", + "Remind me this many days before, comma separated": "Ricordamelo con questi giorni di anticipo, separati da virgole", + "Save rule": "Salva regola", + "An item type": "Un tipo di elemento", + "A folder": "Una cartella", + "Folder {name}": "Cartella {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Scade dopo {days} giorni", + "Reminders {days} days before": "Promemoria {days} giorni prima", + "Could not save the expiry rule.": "Impossibile salvare la regola di scadenza.", + "Could not delete the expiry rule.": "Impossibile eliminare la regola di scadenza.", + "All statuses": "Tutti gli stati", + "Compromised": "Compromessa", + "Could not load the members.": "Impossibile caricare i membri.", + "Emergency contact": "Contatto di emergenza", + "Leaving user": "Utente in uscita", + "No": "No", + "No users match this filter.": "Nessun utente corrisponde a questo filtro.", + "Not set up": "Non configurato", + "Revoke suite": "Revoca suite", + "Revoked": "Revocata", + "Search users": "Cerca utenti", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vedi quali utenti hanno configurato una cassaforte. Avvia l'offboarding o revoca una suite da una riga.", + "Successor": "Successore", + "Team folders": "Cartelle del team", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utente è ancora nel gruppo {groups}, membro di una cartella del team. Rimuovilo dal gruppo o disattiva l'account.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utente è ancora nei gruppi {groups}, membri di cartelle del team. Rimuovilo dai gruppi o disattiva l'account.", + "Vault status": "Stato della cassaforte", + "Yes": "Sì", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un'esportazione CXF NON È CIFRATA. Ogni password e ogni nome utente sarà leggibile in chiaro nel file scaricato. Conservalo in modo sicuro ed eliminalo subito dopo l'uso.", + "Root certificate expiring soon": "Il certificato radice scade a breve", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Il certificato radice della cassaforte scade tra %1$d giorno/i. Rinnovalo prima di allora. Il rinnovo firma di nuovo ogni suite di cifratura.", + "Compromise recovery aborted": "Ripristino dopo compromissione annullato", + "Key rotation ended by a compromise revoke": "Rotazione della chiave terminata da una revoca per compromissione", + "Encryption suite revoke refused": "Revoca della suite di crittografia rifiutata", + "Master password proof refused": "Prova della password principale rifiutata", + "Your current master password": "La tua password principale attuale", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contatto di emergenza aveva una richiesta di accesso in sospeso quando la rotazione della chiave lo ha rimosso. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contatti di emergenza avevano una richiesta di accesso in sospeso quando la rotazione della chiave li ha rimossi. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Questi contatti di emergenza non sono stati trasferiti alla tua nuova chiave. Il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.", + "Renew root certificate": "Rinnova il certificato radice", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Viene creato un nuovo certificato radice e uno intermedio. Ogni suite di cifratura attiva viene firmata di nuovo. L'operazione non è reversibile.", + "Renew root": "Rinnova radice", + "Root renewed. {n} encryption suites signed again.": "Radice rinnovata. Suite di cifratura firmate di nuovo: {n}.", + "Could not renew the root certificate.": "Impossibile rinnovare il certificato radice.", + "Lease policy for this application": "Criterio di lease per questa applicazione", + "In force now: {default} seconds by default, {max} seconds at most.": "In vigore ora: {default} secondi per impostazione predefinita, al massimo {max} secondi.", + "Leases are not renewable": "I lease non sono rinnovabili", + "Lease policy saved.": "Criterio di lease salvato.", + "Leave a field empty to use the instance value.": "Lascia vuoto un campo per usare il valore dell'istanza.", + "Instance value: {value}": "Valore dell'istanza: {value}", + "Renewal": "Rinnovo", + "Use the instance value ({value})": "Usa il valore dell'istanza ({value})", + "Allowed": "Consentito", + "Not allowed": "Non consentito", + "Save lease policy": "Salva criterio di lease", + "Only an administrator can change this policy.": "Solo un amministratore può modificare questo criterio.", + "Could not save the lease policy.": "Impossibile salvare il criterio di lease.", + "{member} got access from {confirmer}.": "{member} ha ricevuto l'accesso da {confirmer}.", + "Automatically confirm new team folder members": "Conferma automaticamente i nuovi membri delle cartelle del team", + "Gave %n new member access to a team folder.": "%n nuovo membro ha ricevuto l'accesso a una cartella del team.", + "Gave %n new members access to a team folder.": "%n nuovi membri hanno ricevuto l'accesso a una cartella del team.", + "Give new team folder members access without waiting for the folder owner.": "Dai accesso ai nuovi membri senza aspettare il proprietario della cartella.", + "New team folder members": "Nuovi membri delle cartelle del team", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Il proprietario o un membro con permesso di scrittura li conferma dalla cassaforte aperta. Keepiq non decifra mai sul server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "In attesa che un membro con permesso di scrittura apra Keepiq. Puoi anche condividere ora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte della risposta alla compromissione non è riuscita ({failed} passaggio/i). Controlla il log del server, poi revoca di nuovo la suite per completarla.", + "This also revoked suite {suite} and ended key migration {migration}.": "Questo ha revocato anche la suite {suite} e terminato la migrazione delle chiavi {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La revoca della seconda suite ha eliminato %n contatto di accesso di emergenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "La revoca della seconda suite ha eliminato %n contatti di accesso di emergenza.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocata come compromessa non può essere ripristinata.", + "Archives to keep": "Archivi da conservare", + "Back up every vault automatically": "Esegui il backup di ogni cassaforte automaticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Esegui il backup di ogni cassaforte secondo una pianificazione. Gli archivi contengono solo testo cifrato e si ripristinano con occ.", + "Back up now": "Esegui il backup ora", + "Backup public key (PEM, optional)": "Chiave pubblica di backup (PEM, facoltativa)", + "Backup requested for the next cron run": "Backup richiesto per la prossima esecuzione di cron", + "Encrypted": "Cifrato", + "Every (hours)": "Ogni (ore)", + "Last backup {when} failed: {error}": "Ultimo backup {when} non riuscito: {error}", + "Last backup {when} succeeded.": "Ultimo backup {when} riuscito.", + "No archives yet.": "Ancora nessun archivio.", + "Size": "Dimensione", + "Vault backups": "Backup della cassaforte", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Con una chiave, ogni archivio viene cifrato per essa. Tieni la chiave privata fuori da questo server: serve per verificare o ripristinare.", + "Written": "Scritto", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utente nell'ambito non ha ancora l'accesso a due fattori e non può aprire la cassaforte finché questa opzione è attiva.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utenti nell'ambito non hanno ancora l'accesso a due fattori e non possono aprire la cassaforte finché questa opzione è attiva.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "I codici di backup non contano. Se i tuoi utenti accedono tramite un provider di identità con un proprio secondo fattore, escludi i loro gruppi.", + "Block personal vault export": "Blocca l'esportazione della cassaforte personale", + "Keep work logins in team folders": "Tieni gli accessi di lavoro nelle cartelle del team", + "Move to a team folder": "Sposta in una cartella del team", + "Not in a team folder": "Non in una cartella del team", + "Only for these groups (empty is everyone)": "Solo per questi gruppi (vuoto significa tutti)", + "Require two-factor login before the vault opens": "Richiedi l'accesso a due fattori prima che la cassaforte si apra", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regole per ogni cassaforte. Ognuna vale per tutti, o solo per i gruppi che scegli.", + "Secret types that belong in a team folder": "Tipi di segreto che vanno in una cartella del team", + "Set up two-factor login": "Configura l'accesso a due fattori", + "Team folder you can write to": "Cartella del team in cui puoi scrivere", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Gli utenti non possono scaricare un backup, un CSV o un file di trasferimento. Il loro pacchetto di dati personali resta disponibile.", + "Users cannot save these secret types in a personal folder.": "Gli utenti non possono salvare questi tipi di segreto in una cartella personale.", + "Vault policies": "Criteri della cassaforte", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "La tua organizzazione non consente di esportare la tua cassaforte personale. Il tuo pacchetto di dati personali nelle impostazioni resta disponibile.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "La tua organizzazione tiene questi segreti in una cartella del team. Sposta ciascuno in una cartella del team.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "La tua organizzazione tiene questo tipo di segreto in una cartella del team. Scegli una delle tue cartelle del team, o una in cui puoi scrivere.", + "Your organisation requires two-factor login before you can open your vault.": "La tua organizzazione richiede l'accesso a due fattori prima che tu possa aprire la cassaforte.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Gli utenti scelgono per quanto tempo l'estensione resta sbloccata in caso di inattività. Tu imposti il tempo massimo che possono scegliere.", + "Longest idle time before the extension locks": "Tempo massimo di inattività prima che l'estensione si blocchi", + "1 minute": "1 minuto", + "5 minutes": "5 minuti", + "15 minutes": "15 minuti", + "1 hour": "1 ora", + "4 hours": "4 ore", + "Connector": "Connettore", + "Directory (tenant) ID": "ID directory (tenant)", + "Application (client) ID": "ID applicazione (client)", + "Data collection rule immutable ID": "ID immutabile della regola di raccolta dati", + "Stream name": "Nome del flusso", + "Splunk index (optional)": "Indice Splunk (facoltativo)", + "Sourcetype (optional)": "Sourcetype (facoltativo)", + "Leave blank to keep the current one": "Lascia vuoto per mantenere quello attuale", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF su syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endpoint di raccolta dati (URL https)", + "HTTP Event Collector URL (https)": "URL dell'HTTP Event Collector (https)", + "Client secret (write-only)": "Segreto client (sola scrittura)", + "HEC token (write-only)": "Token HEC (sola scrittura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Inoltra gli eventi di audit consentiti a Splunk, Microsoft Sentinel, un ricevitore syslog o un webhook. I messaggi contengono solo metadati ripuliti: nessun valore segreto, nome, login o testo cifrato lascia mai il server.", + "%n change waiting to sync": "%n modifica in attesa di sincronizzazione", + "%n changes waiting to sync": "%n modifiche in attesa di sincronizzazione", + "Changes that could not sync": "Modifiche che non è stato possibile sincronizzare", + "Choose a version": "Scegli una versione", + "Copy value": "Copia valore", + "Deleted": "Eliminato", + "Discard": "Scarta", + "Keep my offline change": "Mantieni la mia modifica offline", + "Keep the server version": "Mantieni la versione del server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq è di sola lettura offline. L'amministratore non ha attivato le modifiche offline.", + "Let users edit secrets offline": "Consenti agli utenti di modificare i segreti offline", + "Not synced yet": "Non ancora sincronizzato", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Le modifiche offline restano sul dispositivo, cifrate per l'utente, e si sincronizzano al successivo sblocco online. Condivisione, cartelle e allegati richiedono comunque una connessione.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Modifiche, spostamenti ed eliminazioni restano su questo dispositivo e si sincronizzano quando torni online. Condivisione e allegati richiedono una connessione.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Le tue modifiche restano su questo dispositivo e si sincronizzano quando torni online. Ultima sincronizzazione {when}.", + "Open my changes": "Apri le mie modifiche", + "Sharing needs a connection": "La condivisione richiede una connessione", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Qualcuno ha modificato questo segreto sul server dopo la creazione della tua copia offline. Scegli quale versione mantenere.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronizza o scarta le modifiche offline prima di rinnovare le chiavi.", + "That password did not open your changes.": "Quella password non ha aperto le tue modifiche.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "L'istantanea offline conserva i segreti cifrati (apribili solo con la chiave derivata dalla password principale dell'utente, esattamente come sul server) e cifra a riposo nomi, URL e nomi delle cartelle. L'accesso offline è di sola lettura, a meno che tu non consenta qui sotto le modifiche offline. Disattivalo per i dispositivi che non devono mai conservare credenziali; la disattivazione elimina le cache esistenti al caricamento successivo.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia precedente della cassaforte non c'è più, quindi queste modifiche non possono essere aperte.", + "The server version": "La versione del server", + "This secret changed while you were offline": "Questo segreto è cambiato mentre eri offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Hai eliminato questo segreto offline, ma nel frattempo è stato modificato sul server. Scegli quale versione mantenere.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Le tue chiavi sono state cambiate su un altro dispositivo. Inserisci la password principale precedente per sincronizzare le modifiche offline, oppure scartale.", + "Your offline change": "La tua modifica offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contatto di emergenza aveva una richiesta di accesso in sospeso quando la rotazione della chiave lo ha rimosso. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.","%n contatti di emergenza avevano una richiesta di accesso in sospeso quando la rotazione della chiave li ha rimossi. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n elemento non può essere rappresentato in CXF e verrà ignorato.","%n elementi non possono essere rappresentati in CXF e verranno ignorati."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n versione più vecchia è stata scartata perché è possibile trasferire solo la cronologia recente.","%n versioni più vecchie sono state scartate perché è possibile trasferire solo la cronologia recente."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n copia del segreto deve ancora essere cifrata e condivisa.","%n copie del segreto devono ancora essere cifrate e condivise."], + "_%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 segreto non è stato decifrato e non è in questa esportazione.","%n segreti non sono stati decifrati e non sono in questa esportazione."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n segreto non è stato possibile decifrarlo con la tua vecchia chiave, quindi non è stato migrato.","%n segreti non è stato possibile decifrarli con la tua vecchia chiave, quindi non sono stati migrati."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n segreto non è stato migrato.","%n segreti non sono stati migrati."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n segreto è ancora cifrato con la tua chiave precedente.","%n segreti sono ancora cifrati con la tua chiave precedente."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n segreto è stato ignorato perché il successore non ne possiede ancora una copia: aggiungi il successore alla cartella ed esegui di nuovo l'operazione.","%n segreti sono stati ignorati perché il successore non ne possiede ancora una copia: aggiungi il successore alla cartella ed esegui di nuovo l'operazione."], + "_%n secret_::_%n secrets_": ["%n segreto","%n segreti"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n utente nell'ambito non ha ancora l'accesso a due fattori e non può aprire la cassaforte finché questa opzione è attiva.","%n utenti nell'ambito non hanno ancora l'accesso a due fattori e non possono aprire la cassaforte finché questa opzione è attiva."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Termina comunque, perdendo l'accesso a %n segreto","Termina comunque, perdendo l'accesso a %n segreti"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nuovo membro ha ricevuto l'accesso a una cartella del team.","%n nuovi membri hanno ricevuto l'accesso a una cartella del team."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotazione della chiave completata. %n segreto è stato ricifrato con la tua nuova chiave.","Rotazione della chiave completata. %n segreti sono stati ricifrati con la tua nuova chiave."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["La revoca della seconda suite ha eliminato %n contatto di accesso di emergenza.","La revoca della seconda suite ha eliminato %n contatti di accesso di emergenza."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["La revoca di questa suite ha eliminato %n contatto di accesso di emergenza.","La revoca di questa suite ha eliminato %n contatti di accesso di emergenza."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["visto %n volta nelle violazioni","visto %n volte nelle violazioni"], + "_shared with %n secret_::_shared with %n secrets_": ["condiviso con %n segreto","condiviso con %n segreti"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Questa cartella contiene direttamente %n segreto.","Questa cartella contiene direttamente %n segreti."], + "_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._": ["La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.","La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n modifica in attesa di sincronizzazione","%n modifiche in attesa di sincronizzazione"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["L'utente è ancora nel gruppo {groups}, membro di una cartella del team. Rimuovilo dal gruppo o disattiva l'account.","L'utente è ancora nei gruppi {groups}, membri di cartelle del team. Rimuovilo dai gruppi o disattiva l'account."], + "Allow approval from another device": "Consenti l'approvazione da un altro dispositivo", + "App": "App", + "Approve a new device": "Approva un nuovo dispositivo", + "Approve from another device": "Approva da un altro dispositivo", + "Asked at": "Richiesto alle", + "Check that the new device shows these words:": "Verifica che il nuovo dispositivo mostri queste parole:", + "Denied. If you did not ask, end your other sessions:": "Rifiutato. Se non l'hai richiesto, termina le altre sessioni:", + "Device": "Dispositivo", + "IP address": "Indirizzo IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Consenti agli utenti di sbloccare un nuovo browser approvandolo da un dispositivo su cui Keepiq è già sbloccato.", + "New device approval": "Approvazione nuovi dispositivi", + "Nextcloud security settings": "Impostazioni di sicurezza di Nextcloud", + "Only approve a device you are using right now.": "Approva solo un dispositivo che stai usando in questo momento.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Apri Keepiq su un dispositivo su cui è sbloccato e approva questo. Verifica che mostri le stesse parole:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Il dispositivo che approva sigilla la chiave di sblocco per il nuovo dispositivo. Il server la inoltra soltanto e non può aprirla.", + "The master password is not right, or the request has ended.": "La password principale non è corretta oppure la richiesta è terminata.", + "The request expired. Ask again or use your master password.": "La richiesta è scaduta. Richiedi di nuovo o usa la password principale.", + "The request was denied.": "La richiesta è stata rifiutata.", + "Too many requests. Try again in an hour or use your master password.": "Troppe richieste. Riprova tra un'ora o usa la password principale.", + "Unknown device": "Dispositivo sconosciuto", + "Web app": "App web", + "A device": "Un dispositivo", + "A new device asks to open your vault": "Un nuovo dispositivo chiede di aprire la tua cassaforte", + "%s asks to be approved. Only approve a device you are using right now.": "%s chiede di essere approvato. Approva solo un dispositivo che stai usando in questo momento.", + "Access ends on (optional)": "L'accesso termina il (facoltativo)", + "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.": "Le app di Keepiq non mostreranno né copieranno la password. Chi ha competenze tecniche può comunque leggerla dal proprio dispositivo. Cambiala quando il suo accesso termina.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Questo segreto è solo da usare. Accedi tramite l'estensione del browser di Keepiq.", + "Until {date}": "Fino al {date}", + "Use only": "Solo uso", + "Use only (can sign in, cannot view or copy)": "Solo uso (può accedere, non può vedere né copiare)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Puoi accedere con queste credenziali tramite l'estensione del browser di Keepiq. Il proprietario ha scelto di non permetterti di vederle o copiarle.", + "Your access ends on {date}": "Il tuo accesso termina il {date}", + "Your access to this secret has ended": "Il tuo accesso a questo segreto è terminato", + "Your access to \"%s\" ends tomorrow": "Il tuo accesso a \"%s\" termina domani", + "Your access to \"%s\" has ended": "Il tuo accesso a \"%s\" è terminato", + "%1$s no longer has access to \"%2$s\"": "%1$s non ha più accesso a \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s poteva vedere questa password. Cambiala se %1$s non deve più conoscerla.", + "%s could not view this password in Keepiq.": "%s non ha potuto vedere questa password in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} di {threshold} approvazioni", + "a recovery officer": "un responsabile del recupero", + "Account recovery": "Recupero dell'account", + "Approvals needed": "Approvazioni necessarie", + "Ask {user} which words they see, by phone or in person. They must be:": "Chiedi a {user} quali parole vede, al telefono o di persona. Devono essere:", + "Check again": "Controlla di nuovo", + "Create the recovery key": "Crea la chiave di recupero", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Crea la chiave di recupero. Il tuo browser la genera e dà a ogni responsabile una copia che solo lui può aprire.", + "Decline": "Rifiuta", + "Enrol in account recovery": "Iscriviti al recupero dell'account", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Iscriviti così la tua organizzazione può aiutarti a riavere la tua cassaforte se dimentichi la password principale.", + "Every user is enrolled": "Tutti gli utenti sono iscritti", + "Finish the recovery in the browser you asked from.": "Completa il recupero nel browser da cui l'hai chiesto.", + "Forgot your master password?": "Hai dimenticato la password principale?", + "Hand the key over": "Consegna la chiave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Consenti agli utenti che hanno dimenticato la password principale di riavere la cassaforte, con l'approvazione dei responsabili del recupero che nomini.", + "New master password": "Nuova password principale", + "No one is asking to recover their account.": "Nessuno sta chiedendo di recuperare il proprio account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nessuna chiave di recupero ancora. Uno dei responsabili la crea nelle sue impostazioni di Keepiq.", + "Off": "Disattivato", + "Officer {user} has no encryption set up yet.": "Il responsabile {user} non ha ancora configurato la cifratura.", + "Officers (user IDs, separated by commas)": "Responsabili (ID utente, separati da virgole)", + "Policy": "Criterio", + "Publish this fingerprint internally, so users can check it before they enrol.": "Pubblica questa impronta internamente, così gli utenti possono verificarla prima di iscriversi.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperato con l'aiuto di {officer}. Cambia ora la chiave della cassaforte in Impostazioni, Sicurezza: \"La mia password principale è stata compromessa\".", + "Recovery key fingerprint: {fingerprint}": "Impronta della chiave di recupero: {fingerprint}", + "Recovery officer": "Responsabile del recupero", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "I responsabili rimossi perdono ora la loro copia, ma potrebbero averla aperta prima. Fai creare a un responsabile una nuova chiave di recupero.", + "Repeat the new master password": "Ripeti la nuova password principale", + "Retire this recovery key": "Ritira questa chiave di recupero", + "Set the new master password": "Imposta la nuova password principale", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Il certificato di recupero non è stato emesso da questo Keepiq. Non iscriverti e avvisa l'amministratore.", + "The words match, approve": "Le parole corrispondono, approva", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Questo utente è iscritto al recupero dell'account. Il recupero conserva i suoi segreti; la revoca elimina la sua iscrizione.", + "Users may enrol": "Gli utenti possono iscriversi", + "Withdraw from account recovery": "Ritirati dal recupero dell'account", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sei iscritto al recupero dell'account. Impronta della chiave di recupero: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sei iscritto. Se dimentichi la password principale, la tua organizzazione può aiutarti a riavere la cassaforte.", + "Your key is back. Choose a new master password.": "La tua chiave è tornata. Scegli una nuova password principale.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "I tuoi responsabili del recupero sono stati avvisati. Leggi loro queste parole quando ti chiamano o ti incontrano:", + "You are now an account recovery officer": "Ora sei un responsabile del recupero degli account", + "%s asks to recover their account. Compare the words with them before you approve.": "%s chiede di recuperare il proprio account. Confronta le parole con questa persona prima di approvare.", + "A user": "Un utente", + "Your account recovery request was declined": "La tua richiesta di recupero dell'account è stata rifiutata", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Il recupero del tuo account è pronto. Apri Keepiq nel browser da cui l'hai chiesto.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} chiede di sbloccare un nuovo dispositivo una sola volta. La password principale resta invariata.", + "Ask your organisation instead": "Chiedi invece alla tua organizzazione", + "The request ended. Ask again or use your master password.": "La richiesta è terminata. Chiedi di nuovo o usa la tua password principale.", + "Added by {user}": "Aggiunto da {user}", + "Editor": "Editor", + "Manager": "Gestore", + "Role of {member}": "Ruolo di {member}", + "Team folders you manage": "Cartelle di team che gestisci", + "Viewer": "Lettore", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Non hai una copia di questi segreti, quindi i nuovi membri non li hanno ancora ricevuti. Il proprietario può condividerli: {names}", + "Admin areas": "Aree di amministrazione", + "Give a group only the parts of Keepiq administration it needs.": "Assegna a un gruppo solo le parti dell'amministrazione di Keepiq di cui ha bisogno.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delega una o più aree a un gruppo nella pagina dei privilegi di amministrazione. Gli amministratori dell'istanza hanno tutte le aree.", + "Open administration privileges": "Apri i privilegi di amministrazione", + "Policies": "Criteri", + "Applications and machine access": "Applicazioni e accesso macchina", + "People and offboarding": "Persone e uscite", + "Audit and compliance": "Audit e conformità", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versione, autorità di certificazione, allegati, cache offline, controllo delle violazioni, tipi di segreti e backup", + "master password, organisation password, vault policies, rotation, version history and trash": "password principale, password dell'organizzazione, criteri della cassaforte, rotazione, cronologia delle versioni e cestino", + "application queue, application requests and machine leases": "coda delle applicazioni, richieste delle applicazioni e lease delle macchine", + "team offboarding, encryption suites and admin handover": "uscite dal team, suite di cifratura e subentro dell'amministratore", + "audit log, compliance reports, SIEM export and honey alerts": "registro di audit, report di conformità, esportazione SIEM e avvisi esca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quante versioni di un segreto vengono conservate, per quanto tempo e per quanto tempo i segreti eliminati restano nel cestino.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limiti per gli allegati cifrati, applicati sul server in byte cifrati memorizzati.", + "Type the suite ID again to confirm": "Digita di nuovo l'ID del set per confermare", + "This does not match the suite ID.": "Non corrisponde all'ID del set.", + "Confirm with your master password": "Conferma con la password master", + "Confirm": "Conferma", + "That master password is not right.": "Questa password master non è corretta.", + "You are sharing with someone new. Enter your master password to confirm.": "Stai condividendo con una persona nuova. Inserisci la password master per confermare.", + "Enter your master password to confirm this share.": "Inserisci la password master per confermare questa condivisione.", + "Enter your master password to confirm this delegation.": "Inserisci la password master per confermare questa delega.", + "Approve {member}": "Approva {member}", + "Recipient": "Destinatario", + "No vault yet": "Ancora nessuna cassaforte", + "No matching users": "Nessun utente corrispondente", + "Partner organisations": "Organizzazioni partner", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Scambia segreti con un altro Keepiq. Entrambi gli amministratori si aggiungono a vicenda e confrontano le impronte radice al telefono o di persona prima di salvare.", + "Federation needs Nextcloud 33 or later.": "La federazione richiede Nextcloud 33 o successivo.", + "Your root fingerprint": "La tua impronta radice", + "No partners yet.": "Ancora nessun partner.", + "Users here may share to this partner": "Gli utenti qui possono condividere con questo partner", + "This partner may share to users here": "Questo partner può condividere con gli utenti qui", + "Partner address": "Indirizzo del partner", + "Check partner": "Verifica partner", + "Partner root fingerprint": "Impronta radice del partner", + "I compared this fingerprint with the partner's administrator": "Ho confrontato questa impronta con l’amministratore del partner", + "Add partner": "Aggiungi partner", + "A secret from another organisation": "Un segreto da un’altra organizzazione", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha condiviso \"%2$s\" con te. Accettalo in Ricevuti da altre organizzazioni.", + "Incoming from other organisations": "Ricevuti da altre organizzazioni", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Le persone delle organizzazioni partner possono condividere un segreto con te. Accettalo per conservarne una copia di sola lettura nella tua cassaforte.", + "Nothing shared with you yet": "Ancora nulla condiviso con te", + "Secrets that people in partner organisations share with you appear here.": "I segreti che le persone delle organizzazioni partner condividono con te appaiono qui.", + "From {sender}": "Da {sender}", + "Accept": "Accetta", + "Open in vault": "Apri nella cassaforte", + "The other organisation did not hand over the secret. Try again later.": "L’altra organizzazione non ha consegnato il segreto. Riprova più tardi.", + "Set up your vault before you accept a shared secret.": "Configura la tua cassaforte prima di accettare un segreto condiviso.", + "Something went wrong. Try again.": "Qualcosa è andato storto. Riprova.", + "Waiting for your answer": "In attesa della tua risposta", + "In your vault, read-only": "Nella tua cassaforte, sola lettura", + "Withdrawn by the sender": "Ritirato dal mittente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} lo ha condiviso da un’altra organizzazione. Puoi leggerlo, ma non modificarlo né condividerlo.", + "Someone": "Qualcuno", + "Share with someone at another organisation": "Condividi con qualcuno di un’altra organizzazione", + "Their account at the other organisation": "Il suo account nell’altra organizzazione", + "Check account": "Verifica account", + "Certificate fingerprint of {account}": "Impronta del certificato di {account}", + "Compare it with them by phone if you want to be sure.": "Confrontala con quella persona al telefono se vuoi esserne sicuro.", + "Shared. {account} can accept it in their own vault.": "Condiviso. {account} può accettarlo nella propria cassaforte.", + "The certificate could not be verified. Nothing was shared.": "Impossibile verificare il certificato. Non è stato condiviso nulla.", + "That organisation is not one of your partners.": "Quell’organizzazione non è tra i tuoi partner.", + "No one with that account can receive secrets from you.": "Nessuno con quell’account può ricevere segreti da te.", + "The other organisation did not answer. Try again later.": "L’altra organizzazione non ha risposto. Riprova più tardi.", + "This secret is already shared with that account.": "Questo segreto è già condiviso con quell’account.", + "Other organisations": "Altre organizzazioni", + "Receive secrets from other organisations": "Ricevi segreti da altre organizzazioni", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Le persone delle organizzazioni partner potranno così trovare il tuo account e condividere segreti con te. Sei tu ad accettarli uno per uno.", + "Shared": "Condiviso", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "In pausa: il loro certificato o la partnership è cambiato. Revoca la condivisione o condividi di nuovo.", + "Their organisation did not get the last change. Revoke it or share again.": "La loro organizzazione non ha ricevuto l’ultima modifica. Revoca la condivisione o condividi di nuovo.", + "Being withdrawn": "Revoca in corso", + "Shared with another organisation": "Condiviso con un’altra organizzazione", + "Change sent to another organisation": "Modifica inviata a un’altra organizzazione", + "Share with another organisation revoked": "Condivisione con un’altra organizzazione revocata", + "Share with another organisation paused": "Condivisione con un’altra organizzazione in pausa", + "Another organisation did not get a change": "Un’altra organizzazione non ha ricevuto una modifica", + "Secret received from another organisation": "Segreto ricevuto da un’altra organizzazione", + "Secret from another organisation accepted": "Segreto da un’altra organizzazione accettato", + "Secret from another organisation declined": "Segreto da un’altra organizzazione rifiutato", + "Copy from another organisation updated": "Copia da un’altra organizzazione aggiornata", + "Copy from another organisation removed": "Copia da un’altra organizzazione rimossa", + "Declined: they removed their copy. Share again if they need it.": "Rifiutato: il destinatario ha rimosso la sua copia. Condividi di nuovo se ne ha bisogno.", + "Recipient at another organisation removed their copy": "Un destinatario di un’altra organizzazione ha rimosso la sua copia", + "Removed the user from %n team folder.": "Utente rimosso da %n cartella di team.", + "Removed the user from %n team folders.": "Utente rimosso da %n cartelle di team.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Utente rimosso da %n cartella di team.","Utente rimosso da %n cartelle di team."], + "A restored copy came from a share that has ended. It stays read-only.": "Una copia ripristinata proviene da una condivisione terminata. Resta in sola lettura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Non è stato possibile raggiungere l’organizzazione che ha condiviso una copia ripristinata. La copia resta in sola lettura e non segue le loro modifiche.", + "Recipient at another organisation restored their copy": "Un destinatario di un’altra organizzazione ha ripristinato la sua copia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/it.json b/l10n/it.json index f83c34aee..a86941dc9 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Condiviso con gruppi", + "Not shared with any group yet.": "Non ancora condiviso con alcun gruppo.", + "Revoke the share with {group}": "Revoca la condivisione con {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Condiviso con {group}: {received} membri lo hanno ricevuto, {skipped} no perché non hanno ancora configurato la crittografia.", + "Search groups": "Cerca gruppi", + "Failed to share": "Condivisione non riuscita", + "Columns": "Colonne", + "Column {number}": "Colonna {number}", + "Map one column to Name. Every secret needs a name.": "Associa una colonna al nome. Ogni segreto ha bisogno di un nome.", + "Notes": "Note", + "Do not import": "Non importare", + "Hide this value": "Nascondi questo valore", + "Show this value": "Mostra questo valore", + "Defaults": "Predefiniti", + "New secrets start as this type, and your secret list opens in this view.": "I nuovi segreti iniziano con questo tipo e il tuo elenco di segreti si apre in questa vista.", + "Default item type": "Tipo di elemento predefinito", + "Cards": "Schede", + "Table": "Tabella", + "Could not save your default": "Impossibile salvare il valore predefinito", + "Recently used": "Usati di recente", + "Opened": "Aperto", + "You have not opened any secrets yet": "Non hai ancora aperto alcun segreto", + "Could not delete the item type.": "Impossibile eliminare il tipo di elemento.", + "Could not load the item types.": "Impossibile caricare i tipi di elemento.", + "Could not save the item type.": "Impossibile salvare il tipo di elemento.", + "Delete item type": "Elimina tipo di elemento", + "Edit item type": "Modifica tipo di elemento", + "Fields": "Campi", + "Fields: {count}": "Campi: {count}", + "Hidden": "Nascosto", + "Item types": "Tipi di elemento", + "Move up": "Sposta su", + "New item type": "Nuovo tipo di elemento", + "No item types defined yet.": "Nessun tipo di elemento definito.", + "Required": "Obbligatorio", + "Text": "Testo", + "This field is required": "Questo campo è obbligatorio", + "Web address": "Indirizzo web", + "{label} (required)": "{label} (obbligatorio)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eliminare «{name}»? I segreti di questo tipo restano leggibili e diventano elementi Accesso.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "I tipi di elemento che definisci qui compaiono per tutti nella finestra Nuovo segreto, con i campi che scegli.", + "Secret moved to the trash": "Segreto spostato nel cestino", + "Secret restored from the trash": "Segreto ripristinato dal cestino", + "Secret deleted for good": "Segreto eliminato definitivamente", + "Secret archived": "Segreto archiviato", + "Secret unarchived": "Segreto rimosso dall'archivio", + "Unarchive": "Rimuovi dall'archivio", + "Could not archive the secret": "Impossibile archiviare il segreto", + "Could not unarchive the secret": "Impossibile rimuovere il segreto dall'archivio", + "Archive {count} secrets": "Archivia {count} segreti", + "Unarchive {count} secrets": "Rimuovi {count} segreti dall'archivio", + "Restore {count} secrets": "Ripristina {count} segreti", + "Delete {count} secrets for good": "Elimina definitivamente {count} segreti", + "Done for {ok} of {total} secrets": "Fatto per {ok} segreti su {total}", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "I segreti archiviati escono dall'elenco della cassaforte, dalla ricerca, dalla compilazione automatica e dal rapporto sulla salute. Restano condivisi. Li trovi in Archivio.", + "These secrets come back to the vault list, search and autofill.": "Questi segreti tornano nell'elenco della cassaforte, nella ricerca e nella compilazione automatica.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Questi segreti tornano nell'elenco della cassaforte. Le vecchie condivisioni non tornano, quindi condividili di nuovo dove serve.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Questo elimina i segreti con i loro allegati e la cronologia delle versioni. L'operazione non può essere annullata.", + "Delete for good": "Elimina definitivamente", + "Trash": "Cestino", + "The trash is empty": "Il cestino è vuoto", + "No archived secrets": "Nessun segreto archiviato", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "I segreti eliminati restano qui fino alla fine del periodo di conservazione, poi vengono eliminati definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivia un segreto dal suo pannello dei dettagli per tenerlo fuori dall'elenco della cassaforte, dalla ricerca e dalla compilazione automatica.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limiti per gli allegati cifrati (applicati sul server ai byte cifrati salvati), conservazione della cronologia delle versioni e per quanto tempo i segreti eliminati restano nel cestino.", + "Days a deleted secret stays in the trash (1 to 365)": "Giorni in cui un segreto eliminato resta nel cestino (da 1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Questo sposta il segreto nel cestino e termina subito le sue condivisioni. Puoi ripristinarlo dal cestino fino alla fine del periodo di conservazione: 30 giorni, salvo modifiche del tuo amministratore.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Questo sposta {count} segreti nel cestino e termina subito le loro condivisioni. Puoi ripristinarli dal cestino fino alla fine del periodo di conservazione.", + "Remove {name} from favourites": "Rimuovi {name} dai preferiti", + "Add {name} to favourites": "Aggiungi {name} ai preferiti", + "Could not change the favourite": "Impossibile modificare il preferito", + "Remove from favourites": "Rimuovi dai preferiti", + "Add to favourites": "Aggiungi ai preferiti", + "Tags": "Etichette", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Le etichette non sono cifrate. Gli amministratori del server possono leggerle, come i nomi delle cartelle.", + "Favourites": "Preferiti", + "Filter by tag": "Filtra per etichetta", + "All tags": "Tutte le etichette", + "Last used": "Ultimo utilizzo", + "Tags for {count} secrets": "Etichette per {count} segreti", + "Tag": "Etichetta", + "Remove tag": "Rimuovi etichetta", + "Add tag": "Aggiungi etichetta", + "Could not change the tags. Try again.": "Impossibile modificare le etichette. Riprova.", + "Could not approve the application. It is still in the queue.": "Impossibile approvare la richiesta. È ancora in coda.", + "Could not reject the application. It is still in the queue.": "Impossibile rifiutare la richiesta. È ancora in coda.", + "Removed the user from {count} team folders.": "Utente rimosso da {count} cartelle di team.", + "Approve a share": "Approva una condivisione", + "This approval link is incomplete. Open it again from the notification.": "Questo link di approvazione è incompleto. Aprilo di nuovo dalla notifica.", + "Deny": "Nega", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} si è unito a un gruppo con cui condividi un segreto. Condividere il segreto anche con questa persona?", + "{requester} asks you to share a secret with {user}.": "{requester} ti chiede di condividere un segreto con {user}.", + "Shared. The recipient can now open the secret.": "Condiviso. Il destinatario ora può aprire il segreto.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Il destinatario non ha ancora configurato Keepiq, quindi non è stato condiviso nulla. Riprova quando lo avrà fatto.", + "Could not share the secret. Only its owner can approve this.": "Impossibile condividere il segreto. Solo il proprietario può approvarlo.", + "Could not share the secret. Try again.": "Impossibile condividere il segreto. Riprova.", + "Denied. Nothing was shared.": "Negato. Non è stato condiviso nulla.", + "Could not deny the request. Try again.": "Impossibile negare la richiesta. Riprova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ti chiede di condividere il segreto \"%2$s\" con %3$s.", + "Expires on (optional)": "Scade il (facoltativo)", + "Hand over to": "Affida a", + "Choose a recipient": "Scegli un destinatario", + "Hand over temporarily": "Affida temporaneamente", + "Expiry rules": "Regole di scadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Imposta per quanto tempo possono durare le password di un tipo di elemento o in una cartella, e quando ricevere un promemoria. Se valgono più date, conta la prima.", + "Delete rule": "Elimina regola", + "Set by your administrator": "Impostato dal tuo amministratore", + "No expiry rules yet.": "Ancora nessuna regola di scadenza.", + "Applies to": "Si applica a", + "Item type": "Tipo di elemento", + "Maximum age in days (empty for reminders only)": "Età massima in giorni (vuoto per solo promemoria)", + "Remind me this many days before, comma separated": "Ricordamelo con questi giorni di anticipo, separati da virgole", + "Save rule": "Salva regola", + "An item type": "Un tipo di elemento", + "A folder": "Una cartella", + "Folder {name}": "Cartella {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Scade dopo {days} giorni", + "Reminders {days} days before": "Promemoria {days} giorni prima", + "Could not save the expiry rule.": "Impossibile salvare la regola di scadenza.", + "Could not delete the expiry rule.": "Impossibile eliminare la regola di scadenza.", + "All statuses": "Tutti gli stati", + "Compromised": "Compromessa", + "Could not load the members.": "Impossibile caricare i membri.", + "Emergency contact": "Contatto di emergenza", + "Leaving user": "Utente in uscita", + "No": "No", + "No users match this filter.": "Nessun utente corrisponde a questo filtro.", + "Not set up": "Non configurato", + "Revoke suite": "Revoca suite", + "Revoked": "Revocata", + "Search users": "Cerca utenti", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vedi quali utenti hanno configurato una cassaforte. Avvia l'offboarding o revoca una suite da una riga.", + "Successor": "Successore", + "Team folders": "Cartelle del team", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utente è ancora nel gruppo {groups}, membro di una cartella del team. Rimuovilo dal gruppo o disattiva l'account.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utente è ancora nei gruppi {groups}, membri di cartelle del team. Rimuovilo dai gruppi o disattiva l'account.", + "Vault status": "Stato della cassaforte", + "Yes": "Sì", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un'esportazione CXF NON È CIFRATA. Ogni password e ogni nome utente sarà leggibile in chiaro nel file scaricato. Conservalo in modo sicuro ed eliminalo subito dopo l'uso.", + "Root certificate expiring soon": "Il certificato radice scade a breve", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Il certificato radice della cassaforte scade tra %1$d giorno/i. Rinnovalo prima di allora. Il rinnovo firma di nuovo ogni suite di cifratura.", + "Compromise recovery aborted": "Ripristino dopo compromissione annullato", + "Key rotation ended by a compromise revoke": "Rotazione della chiave terminata da una revoca per compromissione", + "Encryption suite revoke refused": "Revoca della suite di crittografia rifiutata", + "Master password proof refused": "Prova della password principale rifiutata", + "Your current master password": "La tua password principale attuale", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contatto di emergenza aveva una richiesta di accesso in sospeso quando la rotazione della chiave lo ha rimosso. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contatti di emergenza avevano una richiesta di accesso in sospeso quando la rotazione della chiave li ha rimossi. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Questi contatti di emergenza non sono stati trasferiti alla tua nuova chiave. Il loro accesso di emergenza è stato rimosso. Aggiungili di nuovo da Accesso di emergenza se li vuoi ancora.", + "Renew root certificate": "Rinnova il certificato radice", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Viene creato un nuovo certificato radice e uno intermedio. Ogni suite di cifratura attiva viene firmata di nuovo. L'operazione non è reversibile.", + "Renew root": "Rinnova radice", + "Root renewed. {n} encryption suites signed again.": "Radice rinnovata. Suite di cifratura firmate di nuovo: {n}.", + "Could not renew the root certificate.": "Impossibile rinnovare il certificato radice.", + "Lease policy for this application": "Criterio di lease per questa applicazione", + "In force now: {default} seconds by default, {max} seconds at most.": "In vigore ora: {default} secondi per impostazione predefinita, al massimo {max} secondi.", + "Leases are not renewable": "I lease non sono rinnovabili", + "Lease policy saved.": "Criterio di lease salvato.", + "Leave a field empty to use the instance value.": "Lascia vuoto un campo per usare il valore dell'istanza.", + "Instance value: {value}": "Valore dell'istanza: {value}", + "Renewal": "Rinnovo", + "Use the instance value ({value})": "Usa il valore dell'istanza ({value})", + "Allowed": "Consentito", + "Not allowed": "Non consentito", + "Save lease policy": "Salva criterio di lease", + "Only an administrator can change this policy.": "Solo un amministratore può modificare questo criterio.", + "Could not save the lease policy.": "Impossibile salvare il criterio di lease.", + "{member} got access from {confirmer}.": "{member} ha ricevuto l'accesso da {confirmer}.", + "Automatically confirm new team folder members": "Conferma automaticamente i nuovi membri delle cartelle del team", + "Gave %n new member access to a team folder.": "%n nuovo membro ha ricevuto l'accesso a una cartella del team.", + "Gave %n new members access to a team folder.": "%n nuovi membri hanno ricevuto l'accesso a una cartella del team.", + "Give new team folder members access without waiting for the folder owner.": "Dai accesso ai nuovi membri senza aspettare il proprietario della cartella.", + "New team folder members": "Nuovi membri delle cartelle del team", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Il proprietario o un membro con permesso di scrittura li conferma dalla cassaforte aperta. Keepiq non decifra mai sul server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "In attesa che un membro con permesso di scrittura apra Keepiq. Puoi anche condividere ora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte della risposta alla compromissione non è riuscita ({failed} passaggio/i). Controlla il log del server, poi revoca di nuovo la suite per completarla.", + "This also revoked suite {suite} and ended key migration {migration}.": "Questo ha revocato anche la suite {suite} e terminato la migrazione delle chiavi {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La revoca della seconda suite ha eliminato %n contatto di accesso di emergenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "La revoca della seconda suite ha eliminato %n contatti di accesso di emergenza.", + "A suite revoked as compromised cannot be reinstated.": "Una suite revocata come compromessa non può essere ripristinata.", + "Archives to keep": "Archivi da conservare", + "Back up every vault automatically": "Esegui il backup di ogni cassaforte automaticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Esegui il backup di ogni cassaforte secondo una pianificazione. Gli archivi contengono solo testo cifrato e si ripristinano con occ.", + "Back up now": "Esegui il backup ora", + "Backup public key (PEM, optional)": "Chiave pubblica di backup (PEM, facoltativa)", + "Backup requested for the next cron run": "Backup richiesto per la prossima esecuzione di cron", + "Encrypted": "Cifrato", + "Every (hours)": "Ogni (ore)", + "Last backup {when} failed: {error}": "Ultimo backup {when} non riuscito: {error}", + "Last backup {when} succeeded.": "Ultimo backup {when} riuscito.", + "No archives yet.": "Ancora nessun archivio.", + "Size": "Dimensione", + "Vault backups": "Backup della cassaforte", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Con una chiave, ogni archivio viene cifrato per essa. Tieni la chiave privata fuori da questo server: serve per verificare o ripristinare.", + "Written": "Scritto", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utente nell'ambito non ha ancora l'accesso a due fattori e non può aprire la cassaforte finché questa opzione è attiva.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utenti nell'ambito non hanno ancora l'accesso a due fattori e non possono aprire la cassaforte finché questa opzione è attiva.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "I codici di backup non contano. Se i tuoi utenti accedono tramite un provider di identità con un proprio secondo fattore, escludi i loro gruppi.", + "Block personal vault export": "Blocca l'esportazione della cassaforte personale", + "Keep work logins in team folders": "Tieni gli accessi di lavoro nelle cartelle del team", + "Move to a team folder": "Sposta in una cartella del team", + "Not in a team folder": "Non in una cartella del team", + "Only for these groups (empty is everyone)": "Solo per questi gruppi (vuoto significa tutti)", + "Require two-factor login before the vault opens": "Richiedi l'accesso a due fattori prima che la cassaforte si apra", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regole per ogni cassaforte. Ognuna vale per tutti, o solo per i gruppi che scegli.", + "Secret types that belong in a team folder": "Tipi di segreto che vanno in una cartella del team", + "Set up two-factor login": "Configura l'accesso a due fattori", + "Team folder you can write to": "Cartella del team in cui puoi scrivere", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Gli utenti non possono scaricare un backup, un CSV o un file di trasferimento. Il loro pacchetto di dati personali resta disponibile.", + "Users cannot save these secret types in a personal folder.": "Gli utenti non possono salvare questi tipi di segreto in una cartella personale.", + "Vault policies": "Criteri della cassaforte", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "La tua organizzazione non consente di esportare la tua cassaforte personale. Il tuo pacchetto di dati personali nelle impostazioni resta disponibile.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "La tua organizzazione tiene questi segreti in una cartella del team. Sposta ciascuno in una cartella del team.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "La tua organizzazione tiene questo tipo di segreto in una cartella del team. Scegli una delle tue cartelle del team, o una in cui puoi scrivere.", + "Your organisation requires two-factor login before you can open your vault.": "La tua organizzazione richiede l'accesso a due fattori prima che tu possa aprire la cassaforte.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Gli utenti scelgono per quanto tempo l'estensione resta sbloccata in caso di inattività. Tu imposti il tempo massimo che possono scegliere.", + "Longest idle time before the extension locks": "Tempo massimo di inattività prima che l'estensione si blocchi", + "1 minute": "1 minuto", + "5 minutes": "5 minuti", + "15 minutes": "15 minuti", + "1 hour": "1 ora", + "4 hours": "4 ore", + "Connector": "Connettore", + "Directory (tenant) ID": "ID directory (tenant)", + "Application (client) ID": "ID applicazione (client)", + "Data collection rule immutable ID": "ID immutabile della regola di raccolta dati", + "Stream name": "Nome del flusso", + "Splunk index (optional)": "Indice Splunk (facoltativo)", + "Sourcetype (optional)": "Sourcetype (facoltativo)", + "Leave blank to keep the current one": "Lascia vuoto per mantenere quello attuale", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF su syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endpoint di raccolta dati (URL https)", + "HTTP Event Collector URL (https)": "URL dell'HTTP Event Collector (https)", + "Client secret (write-only)": "Segreto client (sola scrittura)", + "HEC token (write-only)": "Token HEC (sola scrittura)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Inoltra gli eventi di audit consentiti a Splunk, Microsoft Sentinel, un ricevitore syslog o un webhook. I messaggi contengono solo metadati ripuliti: nessun valore segreto, nome, login o testo cifrato lascia mai il server.", + "%n change waiting to sync": "%n modifica in attesa di sincronizzazione", + "%n changes waiting to sync": "%n modifiche in attesa di sincronizzazione", + "Changes that could not sync": "Modifiche che non è stato possibile sincronizzare", + "Choose a version": "Scegli una versione", + "Copy value": "Copia valore", + "Deleted": "Eliminato", + "Discard": "Scarta", + "Keep my offline change": "Mantieni la mia modifica offline", + "Keep the server version": "Mantieni la versione del server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq è di sola lettura offline. L'amministratore non ha attivato le modifiche offline.", + "Let users edit secrets offline": "Consenti agli utenti di modificare i segreti offline", + "Not synced yet": "Non ancora sincronizzato", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Le modifiche offline restano sul dispositivo, cifrate per l'utente, e si sincronizzano al successivo sblocco online. Condivisione, cartelle e allegati richiedono comunque una connessione.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Modifiche, spostamenti ed eliminazioni restano su questo dispositivo e si sincronizzano quando torni online. Condivisione e allegati richiedono una connessione.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Le tue modifiche restano su questo dispositivo e si sincronizzano quando torni online. Ultima sincronizzazione {when}.", + "Open my changes": "Apri le mie modifiche", + "Sharing needs a connection": "La condivisione richiede una connessione", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Qualcuno ha modificato questo segreto sul server dopo la creazione della tua copia offline. Scegli quale versione mantenere.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronizza o scarta le modifiche offline prima di rinnovare le chiavi.", + "That password did not open your changes.": "Quella password non ha aperto le tue modifiche.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "L'istantanea offline conserva i segreti cifrati (apribili solo con la chiave derivata dalla password principale dell'utente, esattamente come sul server) e cifra a riposo nomi, URL e nomi delle cartelle. L'accesso offline è di sola lettura, a meno che tu non consenta qui sotto le modifiche offline. Disattivalo per i dispositivi che non devono mai conservare credenziali; la disattivazione elimina le cache esistenti al caricamento successivo.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia precedente della cassaforte non c'è più, quindi queste modifiche non possono essere aperte.", + "The server version": "La versione del server", + "This secret changed while you were offline": "Questo segreto è cambiato mentre eri offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Hai eliminato questo segreto offline, ma nel frattempo è stato modificato sul server. Scegli quale versione mantenere.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Le tue chiavi sono state cambiate su un altro dispositivo. Inserisci la password principale precedente per sincronizzare le modifiche offline, oppure scartale.", + "Your offline change": "La tua modifica offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contatto di emergenza aveva una richiesta di accesso in sospeso quando la rotazione della chiave lo ha rimosso. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno.", + "%n contatti di emergenza avevano una richiesta di accesso in sospeso quando la rotazione della chiave li ha rimossi. Controlla chi l'ha chiesta prima di aggiungere di nuovo qualcuno." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n elemento non può essere rappresentato in CXF e verrà ignorato.", + "%n elementi non possono essere rappresentati in CXF e verranno ignorati." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n versione più vecchia è stata scartata perché è possibile trasferire solo la cronologia recente.", + "%n versioni più vecchie sono state scartate perché è possibile trasferire solo la cronologia recente." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n copia del segreto deve ancora essere cifrata e condivisa.", + "%n copie del segreto devono ancora essere cifrate e condivise." + ], + "_%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 segreto non è stato decifrato e non è in questa esportazione.", + "%n segreti non sono stati decifrati e non sono in questa esportazione." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n segreto non è stato possibile decifrarlo con la tua vecchia chiave, quindi non è stato migrato.", + "%n segreti non è stato possibile decifrarli con la tua vecchia chiave, quindi non sono stati migrati." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n segreto non è stato migrato.", + "%n segreti non sono stati migrati." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n segreto è ancora cifrato con la tua chiave precedente.", + "%n segreti sono ancora cifrati con la tua chiave precedente." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n segreto è stato ignorato perché il successore non ne possiede ancora una copia: aggiungi il successore alla cartella ed esegui di nuovo l'operazione.", + "%n segreti sono stati ignorati perché il successore non ne possiede ancora una copia: aggiungi il successore alla cartella ed esegui di nuovo l'operazione." + ], + "_%n secret_::_%n secrets_": [ + "%n segreto", + "%n segreti" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n utente nell'ambito non ha ancora l'accesso a due fattori e non può aprire la cassaforte finché questa opzione è attiva.", + "%n utenti nell'ambito non hanno ancora l'accesso a due fattori e non possono aprire la cassaforte finché questa opzione è attiva." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Termina comunque, perdendo l'accesso a %n segreto", + "Termina comunque, perdendo l'accesso a %n segreti" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nuovo membro ha ricevuto l'accesso a una cartella del team.", + "%n nuovi membri hanno ricevuto l'accesso a una cartella del team." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotazione della chiave completata. %n segreto è stato ricifrato con la tua nuova chiave.", + "Rotazione della chiave completata. %n segreti sono stati ricifrati con la tua nuova chiave." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "La revoca della seconda suite ha eliminato %n contatto di accesso di emergenza.", + "La revoca della seconda suite ha eliminato %n contatti di accesso di emergenza." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "La revoca di questa suite ha eliminato %n contatto di accesso di emergenza.", + "La revoca di questa suite ha eliminato %n contatti di accesso di emergenza." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "visto %n volta nelle violazioni", + "visto %n volte nelle violazioni" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "condiviso con %n segreto", + "condiviso con %n segreti" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Questa cartella contiene direttamente %n segreto.", + "Questa cartella contiene direttamente %n segreti." + ], + "_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._": [ + "La rotazione della chiave ha rimosso %n contatto di emergenza. Controlla Accesso di emergenza e aggiungilo di nuovo se lo vuoi ancora.", + "La rotazione della chiave ha rimosso %n contatti di emergenza. Controlla Accesso di emergenza e aggiungili di nuovo se li vuoi ancora." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n modifica in attesa di sincronizzazione", + "%n modifiche in attesa di sincronizzazione" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "L'utente è ancora nel gruppo {groups}, membro di una cartella del team. Rimuovilo dal gruppo o disattiva l'account.", + "L'utente è ancora nei gruppi {groups}, membri di cartelle del team. Rimuovilo dai gruppi o disattiva l'account." + ], + "Allow approval from another device": "Consenti l'approvazione da un altro dispositivo", + "App": "App", + "Approve a new device": "Approva un nuovo dispositivo", + "Approve from another device": "Approva da un altro dispositivo", + "Asked at": "Richiesto alle", + "Check that the new device shows these words:": "Verifica che il nuovo dispositivo mostri queste parole:", + "Denied. If you did not ask, end your other sessions:": "Rifiutato. Se non l'hai richiesto, termina le altre sessioni:", + "Device": "Dispositivo", + "IP address": "Indirizzo IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Consenti agli utenti di sbloccare un nuovo browser approvandolo da un dispositivo su cui Keepiq è già sbloccato.", + "New device approval": "Approvazione nuovi dispositivi", + "Nextcloud security settings": "Impostazioni di sicurezza di Nextcloud", + "Only approve a device you are using right now.": "Approva solo un dispositivo che stai usando in questo momento.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Apri Keepiq su un dispositivo su cui è sbloccato e approva questo. Verifica che mostri le stesse parole:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Il dispositivo che approva sigilla la chiave di sblocco per il nuovo dispositivo. Il server la inoltra soltanto e non può aprirla.", + "The master password is not right, or the request has ended.": "La password principale non è corretta oppure la richiesta è terminata.", + "The request expired. Ask again or use your master password.": "La richiesta è scaduta. Richiedi di nuovo o usa la password principale.", + "The request was denied.": "La richiesta è stata rifiutata.", + "Too many requests. Try again in an hour or use your master password.": "Troppe richieste. Riprova tra un'ora o usa la password principale.", + "Unknown device": "Dispositivo sconosciuto", + "Web app": "App web", + "A device": "Un dispositivo", + "A new device asks to open your vault": "Un nuovo dispositivo chiede di aprire la tua cassaforte", + "%s asks to be approved. Only approve a device you are using right now.": "%s chiede di essere approvato. Approva solo un dispositivo che stai usando in questo momento.", + "Access ends on (optional)": "L'accesso termina il (facoltativo)", + "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.": "Le app di Keepiq non mostreranno né copieranno la password. Chi ha competenze tecniche può comunque leggerla dal proprio dispositivo. Cambiala quando il suo accesso termina.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Questo segreto è solo da usare. Accedi tramite l'estensione del browser di Keepiq.", + "Until {date}": "Fino al {date}", + "Use only": "Solo uso", + "Use only (can sign in, cannot view or copy)": "Solo uso (può accedere, non può vedere né copiare)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Puoi accedere con queste credenziali tramite l'estensione del browser di Keepiq. Il proprietario ha scelto di non permetterti di vederle o copiarle.", + "Your access ends on {date}": "Il tuo accesso termina il {date}", + "Your access to this secret has ended": "Il tuo accesso a questo segreto è terminato", + "Your access to \"%s\" ends tomorrow": "Il tuo accesso a \"%s\" termina domani", + "Your access to \"%s\" has ended": "Il tuo accesso a \"%s\" è terminato", + "%1$s no longer has access to \"%2$s\"": "%1$s non ha più accesso a \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s poteva vedere questa password. Cambiala se %1$s non deve più conoscerla.", + "%s could not view this password in Keepiq.": "%s non ha potuto vedere questa password in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} di {threshold} approvazioni", + "a recovery officer": "un responsabile del recupero", + "Account recovery": "Recupero dell'account", + "Approvals needed": "Approvazioni necessarie", + "Ask {user} which words they see, by phone or in person. They must be:": "Chiedi a {user} quali parole vede, al telefono o di persona. Devono essere:", + "Check again": "Controlla di nuovo", + "Create the recovery key": "Crea la chiave di recupero", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Crea la chiave di recupero. Il tuo browser la genera e dà a ogni responsabile una copia che solo lui può aprire.", + "Decline": "Rifiuta", + "Enrol in account recovery": "Iscriviti al recupero dell'account", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Iscriviti così la tua organizzazione può aiutarti a riavere la tua cassaforte se dimentichi la password principale.", + "Every user is enrolled": "Tutti gli utenti sono iscritti", + "Finish the recovery in the browser you asked from.": "Completa il recupero nel browser da cui l'hai chiesto.", + "Forgot your master password?": "Hai dimenticato la password principale?", + "Hand the key over": "Consegna la chiave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Consenti agli utenti che hanno dimenticato la password principale di riavere la cassaforte, con l'approvazione dei responsabili del recupero che nomini.", + "New master password": "Nuova password principale", + "No one is asking to recover their account.": "Nessuno sta chiedendo di recuperare il proprio account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nessuna chiave di recupero ancora. Uno dei responsabili la crea nelle sue impostazioni di Keepiq.", + "Off": "Disattivato", + "Officer {user} has no encryption set up yet.": "Il responsabile {user} non ha ancora configurato la cifratura.", + "Officers (user IDs, separated by commas)": "Responsabili (ID utente, separati da virgole)", + "Policy": "Criterio", + "Publish this fingerprint internally, so users can check it before they enrol.": "Pubblica questa impronta internamente, così gli utenti possono verificarla prima di iscriversi.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperato con l'aiuto di {officer}. Cambia ora la chiave della cassaforte in Impostazioni, Sicurezza: \"La mia password principale è stata compromessa\".", + "Recovery key fingerprint: {fingerprint}": "Impronta della chiave di recupero: {fingerprint}", + "Recovery officer": "Responsabile del recupero", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "I responsabili rimossi perdono ora la loro copia, ma potrebbero averla aperta prima. Fai creare a un responsabile una nuova chiave di recupero.", + "Repeat the new master password": "Ripeti la nuova password principale", + "Retire this recovery key": "Ritira questa chiave di recupero", + "Set the new master password": "Imposta la nuova password principale", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Il certificato di recupero non è stato emesso da questo Keepiq. Non iscriverti e avvisa l'amministratore.", + "The words match, approve": "Le parole corrispondono, approva", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Questo utente è iscritto al recupero dell'account. Il recupero conserva i suoi segreti; la revoca elimina la sua iscrizione.", + "Users may enrol": "Gli utenti possono iscriversi", + "Withdraw from account recovery": "Ritirati dal recupero dell'account", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sei iscritto al recupero dell'account. Impronta della chiave di recupero: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sei iscritto. Se dimentichi la password principale, la tua organizzazione può aiutarti a riavere la cassaforte.", + "Your key is back. Choose a new master password.": "La tua chiave è tornata. Scegli una nuova password principale.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "I tuoi responsabili del recupero sono stati avvisati. Leggi loro queste parole quando ti chiamano o ti incontrano:", + "You are now an account recovery officer": "Ora sei un responsabile del recupero degli account", + "%s asks to recover their account. Compare the words with them before you approve.": "%s chiede di recuperare il proprio account. Confronta le parole con questa persona prima di approvare.", + "A user": "Un utente", + "Your account recovery request was declined": "La tua richiesta di recupero dell'account è stata rifiutata", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Il recupero del tuo account è pronto. Apri Keepiq nel browser da cui l'hai chiesto.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} chiede di sbloccare un nuovo dispositivo una sola volta. La password principale resta invariata.", + "Ask your organisation instead": "Chiedi invece alla tua organizzazione", + "The request ended. Ask again or use your master password.": "La richiesta è terminata. Chiedi di nuovo o usa la tua password principale.", + "Added by {user}": "Aggiunto da {user}", + "Editor": "Editor", + "Manager": "Gestore", + "Role of {member}": "Ruolo di {member}", + "Team folders you manage": "Cartelle di team che gestisci", + "Viewer": "Lettore", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Non hai una copia di questi segreti, quindi i nuovi membri non li hanno ancora ricevuti. Il proprietario può condividerli: {names}", + "Admin areas": "Aree di amministrazione", + "Give a group only the parts of Keepiq administration it needs.": "Assegna a un gruppo solo le parti dell'amministrazione di Keepiq di cui ha bisogno.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delega una o più aree a un gruppo nella pagina dei privilegi di amministrazione. Gli amministratori dell'istanza hanno tutte le aree.", + "Open administration privileges": "Apri i privilegi di amministrazione", + "Policies": "Criteri", + "Applications and machine access": "Applicazioni e accesso macchina", + "People and offboarding": "Persone e uscite", + "Audit and compliance": "Audit e conformità", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versione, autorità di certificazione, allegati, cache offline, controllo delle violazioni, tipi di segreti e backup", + "master password, organisation password, vault policies, rotation, version history and trash": "password principale, password dell'organizzazione, criteri della cassaforte, rotazione, cronologia delle versioni e cestino", + "application queue, application requests and machine leases": "coda delle applicazioni, richieste delle applicazioni e lease delle macchine", + "team offboarding, encryption suites and admin handover": "uscite dal team, suite di cifratura e subentro dell'amministratore", + "audit log, compliance reports, SIEM export and honey alerts": "registro di audit, report di conformità, esportazione SIEM e avvisi esca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quante versioni di un segreto vengono conservate, per quanto tempo e per quanto tempo i segreti eliminati restano nel cestino.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limiti per gli allegati cifrati, applicati sul server in byte cifrati memorizzati.", + "Type the suite ID again to confirm": "Digita di nuovo l'ID del set per confermare", + "This does not match the suite ID.": "Non corrisponde all'ID del set.", + "Confirm with your master password": "Conferma con la password master", + "Confirm": "Conferma", + "That master password is not right.": "Questa password master non è corretta.", + "You are sharing with someone new. Enter your master password to confirm.": "Stai condividendo con una persona nuova. Inserisci la password master per confermare.", + "Enter your master password to confirm this share.": "Inserisci la password master per confermare questa condivisione.", + "Enter your master password to confirm this delegation.": "Inserisci la password master per confermare questa delega.", + "Approve {member}": "Approva {member}", + "Recipient": "Destinatario", + "No vault yet": "Ancora nessuna cassaforte", + "No matching users": "Nessun utente corrispondente", + "Partner organisations": "Organizzazioni partner", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Scambia segreti con un altro Keepiq. Entrambi gli amministratori si aggiungono a vicenda e confrontano le impronte radice al telefono o di persona prima di salvare.", + "Federation needs Nextcloud 33 or later.": "La federazione richiede Nextcloud 33 o successivo.", + "Your root fingerprint": "La tua impronta radice", + "No partners yet.": "Ancora nessun partner.", + "Users here may share to this partner": "Gli utenti qui possono condividere con questo partner", + "This partner may share to users here": "Questo partner può condividere con gli utenti qui", + "Partner address": "Indirizzo del partner", + "Check partner": "Verifica partner", + "Partner root fingerprint": "Impronta radice del partner", + "I compared this fingerprint with the partner's administrator": "Ho confrontato questa impronta con l’amministratore del partner", + "Add partner": "Aggiungi partner", + "A secret from another organisation": "Un segreto da un’altra organizzazione", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha condiviso \"%2$s\" con te. Accettalo in Ricevuti da altre organizzazioni.", + "Incoming from other organisations": "Ricevuti da altre organizzazioni", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Le persone delle organizzazioni partner possono condividere un segreto con te. Accettalo per conservarne una copia di sola lettura nella tua cassaforte.", + "Nothing shared with you yet": "Ancora nulla condiviso con te", + "Secrets that people in partner organisations share with you appear here.": "I segreti che le persone delle organizzazioni partner condividono con te appaiono qui.", + "From {sender}": "Da {sender}", + "Accept": "Accetta", + "Open in vault": "Apri nella cassaforte", + "The other organisation did not hand over the secret. Try again later.": "L’altra organizzazione non ha consegnato il segreto. Riprova più tardi.", + "Set up your vault before you accept a shared secret.": "Configura la tua cassaforte prima di accettare un segreto condiviso.", + "Something went wrong. Try again.": "Qualcosa è andato storto. Riprova.", + "Waiting for your answer": "In attesa della tua risposta", + "In your vault, read-only": "Nella tua cassaforte, sola lettura", + "Withdrawn by the sender": "Ritirato dal mittente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} lo ha condiviso da un’altra organizzazione. Puoi leggerlo, ma non modificarlo né condividerlo.", + "Someone": "Qualcuno", + "Share with someone at another organisation": "Condividi con qualcuno di un’altra organizzazione", + "Their account at the other organisation": "Il suo account nell’altra organizzazione", + "Check account": "Verifica account", + "Certificate fingerprint of {account}": "Impronta del certificato di {account}", + "Compare it with them by phone if you want to be sure.": "Confrontala con quella persona al telefono se vuoi esserne sicuro.", + "Shared. {account} can accept it in their own vault.": "Condiviso. {account} può accettarlo nella propria cassaforte.", + "The certificate could not be verified. Nothing was shared.": "Impossibile verificare il certificato. Non è stato condiviso nulla.", + "That organisation is not one of your partners.": "Quell’organizzazione non è tra i tuoi partner.", + "No one with that account can receive secrets from you.": "Nessuno con quell’account può ricevere segreti da te.", + "The other organisation did not answer. Try again later.": "L’altra organizzazione non ha risposto. Riprova più tardi.", + "This secret is already shared with that account.": "Questo segreto è già condiviso con quell’account.", + "Other organisations": "Altre organizzazioni", + "Receive secrets from other organisations": "Ricevi segreti da altre organizzazioni", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Le persone delle organizzazioni partner potranno così trovare il tuo account e condividere segreti con te. Sei tu ad accettarli uno per uno.", + "Shared": "Condiviso", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "In pausa: il loro certificato o la partnership è cambiato. Revoca la condivisione o condividi di nuovo.", + "Their organisation did not get the last change. Revoke it or share again.": "La loro organizzazione non ha ricevuto l’ultima modifica. Revoca la condivisione o condividi di nuovo.", + "Being withdrawn": "Revoca in corso", + "Shared with another organisation": "Condiviso con un’altra organizzazione", + "Change sent to another organisation": "Modifica inviata a un’altra organizzazione", + "Share with another organisation revoked": "Condivisione con un’altra organizzazione revocata", + "Share with another organisation paused": "Condivisione con un’altra organizzazione in pausa", + "Another organisation did not get a change": "Un’altra organizzazione non ha ricevuto una modifica", + "Secret received from another organisation": "Segreto ricevuto da un’altra organizzazione", + "Secret from another organisation accepted": "Segreto da un’altra organizzazione accettato", + "Secret from another organisation declined": "Segreto da un’altra organizzazione rifiutato", + "Copy from another organisation updated": "Copia da un’altra organizzazione aggiornata", + "Copy from another organisation removed": "Copia da un’altra organizzazione rimossa", + "Declined: they removed their copy. Share again if they need it.": "Rifiutato: il destinatario ha rimosso la sua copia. Condividi di nuovo se ne ha bisogno.", + "Recipient at another organisation removed their copy": "Un destinatario di un’altra organizzazione ha rimosso la sua copia", + "Removed the user from %n team folder.": "Utente rimosso da %n cartella di team.", + "Removed the user from %n team folders.": "Utente rimosso da %n cartelle di team.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Utente rimosso da %n cartella di team.", + "Utente rimosso da %n cartelle di team." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Una copia ripristinata proviene da una condivisione terminata. Resta in sola lettura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Non è stato possibile raggiungere l’organizzazione che ha condiviso una copia ripristinata. La copia resta in sola lettura e non segue le loro modifiche.", + "Recipient at another organisation restored their copy": "Un destinatario di un’altra organizzazione ha ripristinato la sua copia" }, "plurals": null } diff --git a/l10n/lb.js b/l10n/lb.js index 14afc9239..e592d2bc4 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Mat Gruppen gedeelt", + "Not shared with any group yet.": "Nach mat kenger Grupp gedeelt.", + "Revoke the share with {group}": "Deele mat {group} zréckzéien", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mat {group} gedeelt: {received} Memberen hunn et kritt, {skipped} net, well se nach keng Verschlësselung ageriicht hunn.", + "Search groups": "Gruppe sichen", + "Failed to share": "Deelen ass feelgeschloen", + "Columns": "Kolonnen", + "Column {number}": "Kolonn {number}", + "Map one column to Name. Every secret needs a name.": "Verbannt eng Kolonn mam Numm. All Geheimnis brauch en Numm.", + "Notes": "Notizen", + "Do not import": "Net importéieren", + "Hide this value": "Dëse Wäert verstoppen", + "Show this value": "Dëse Wäert weisen", + "Defaults": "Standardwäerter", + "New secrets start as this type, and your secret list opens in this view.": "Nei Geheimnisser fänken als dësen Typ un, an deng Geheimnislëscht mécht sech an dëser Usiicht op.", + "Default item type": "Standard-Elementtyp", + "Cards": "Kaarten", + "Table": "Tabell", + "Could not save your default": "Däi Standardwäert konnt net gespäichert ginn", + "Recently used": "Rezent benotzt", + "Opened": "Opgemaach", + "You have not opened any secrets yet": "Du hues nach keng Geheimnisser opgemaach", + "Could not delete the item type.": "Den Elementtyp konnt net geläscht ginn.", + "Could not load the item types.": "D'Elementtypen konnten net gelueden ginn.", + "Could not save the item type.": "Den Elementtyp konnt net gespäichert ginn.", + "Delete item type": "Elementtyp läschen", + "Edit item type": "Elementtyp änneren", + "Fields": "Felder", + "Fields: {count}": "Felder: {count}", + "Hidden": "Verstoppt", + "Item types": "Elementtypen", + "Move up": "Erop réckelen", + "New item type": "Neien Elementtyp", + "No item types defined yet.": "Nach keng Elementtypen definéiert.", + "Required": "Obligatoresch", + "Text": "Text", + "This field is required": "Dëst Feld ass obligatoresch", + "Web address": "Webadress", + "{label} (required)": "{label} (obligatoresch)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "„{name}“ läschen? Geheimnisser vun dësem Typ bleiwe liesbar a ginn zu Login-Elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtypen, déi s du hei definéiers, erschéngen bei jidderengem am Dialog Neit Geheimnis, mat de Felder, déi s du wiels.", + "Secret moved to the trash": "Geheimnis an den Pabeierkuerf geréckelt", + "Secret restored from the trash": "Geheimnis aus dem Pabeierkuerf erëmgestallt", + "Secret deleted for good": "Geheimnis definitiv geläscht", + "Secret archived": "Geheimnis archivéiert", + "Secret unarchived": "Geheimnis aus dem Archiv geholl", + "Unarchive": "Aus dem Archiv huelen", + "Could not archive the secret": "D'Geheimnis konnt net archivéiert ginn", + "Could not unarchive the secret": "D'Geheimnis konnt net aus dem Archiv geholl ginn", + "Archive {count} secrets": "Geheimnisser archivéieren: {count}", + "Unarchive {count} secrets": "Geheimnisser aus dem Archiv huelen: {count}", + "Restore {count} secrets": "Geheimnisser erëmstellen: {count}", + "Delete {count} secrets for good": "Geheimnisser definitiv läschen: {count}", + "Done for {ok} of {total} secrets": "Fäerdeg fir {ok} vun {total} Geheimnisser", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivéiert Geheimnisser verschwannen aus der Tresorlëscht, der Sich, dem automateschen Ausfëllen an dem Gesondheetsrapport. Si behalen hir Deelungen. Du fënns se ënner Archiv.", + "These secrets come back to the vault list, search and autofill.": "Dës Geheimnisser kommen zréck an d'Tresorlëscht, d'Sich an d'automatescht Ausfëllen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Dës Geheimnisser kommen zréck an d'Tresorlëscht. Hir al Deelunge kommen net zréck, deel se also nees, wou et néideg ass.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dëst läscht d'Geheimnisser mat hiren Unhäng an hirer Versiounsgeschicht. Dat kann net réckgängeg gemaach ginn.", + "Delete for good": "Definitiv läschen", + "Trash": "Pabeierkuerf", + "The trash is empty": "De Pabeierkuerf ass eidel", + "No archived secrets": "Keng archivéiert Geheimnisser", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Geläscht Geheimnisser waarden hei, bis d'Oprechterhalungszäit eriwwer ass, duerno gi se definitiv geläscht.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivéier e Geheimnis a sengem Detailberäich, fir et aus der Tresorlëscht, der Sich an dem automateschen Ausfëllen erauszehalen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenze fir verschlësselt Unhäng (um Server op de gespäicherte verschlësselte Bytes duerchgesat), Oprechterhalung vun der Versiounsgeschicht a wéi laang geläscht Geheimnisser am Pabeierkuerf bleiwen.", + "Days a deleted secret stays in the trash (1 to 365)": "Deeg, déi e geläscht Geheimnis am Pabeierkuerf bleift (1 bis 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dëst réckelt d'Geheimnis an de Pabeierkuerf an hält seng Deelunge elo op. Du kanns et aus dem Pabeierkuerf erëmstellen, bis d'Oprechterhalungszäit eriwwer ass: 30 Deeg, ausser däin Administrateur huet dat geännert.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dëst réckelt Geheimnisser an de Pabeierkuerf ({count}) an hält hir Deelunge elo op. Du kanns se aus dem Pabeierkuerf erëmstellen, bis d'Oprechterhalungszäit eriwwer ass.", + "Remove {name} from favourites": "{name} aus de Favoritten ewechhuelen", + "Add {name} to favourites": "{name} bei d'Favoritten derbäisetzen", + "Could not change the favourite": "De Favorit konnt net geännert ginn", + "Remove from favourites": "Aus de Favoritten ewechhuelen", + "Add to favourites": "Bei d'Favoritten derbäisetzen", + "Tags": "Tags", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tags sinn net verschlësselt. Serveradministrateure kënne se liesen, wéi Dossiersnimm.", + "Favourites": "Favoritten", + "Filter by tag": "No Tag filteren", + "All tags": "All Tags", + "Last used": "Fir d'lescht benotzt", + "Tags for {count} secrets": "Tags fir {count} Geheimnisser", + "Tag": "Tag", + "Remove tag": "Tag ewechhuelen", + "Add tag": "Tag derbäisetzen", + "Could not change the tags. Try again.": "D'Tags konnten net geännert ginn. Probéiert nach eng Kéier.", + "Could not approve the application. It is still in the queue.": "D'Ufro konnt net guttgeheescht ginn. Si ass nach ëmmer an der Schlaang.", + "Could not reject the application. It is still in the queue.": "D'Ufro konnt net ofgeleent ginn. Si ass nach ëmmer an der Schlaang.", + "Removed the user from {count} team folders.": "De Benotzer gouf aus {count} Teamuerdner ewechgeholl.", + "Approve a share": "Eng Deelung guttheeschen", + "This approval link is incomplete. Open it again from the notification.": "Dëse Link fir d'Guttheeschen ass net komplett. Maach en nach eng Kéier aus der Notifikatioun op.", + "Deny": "Ofleenen", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ass enger Grupp bäigetrueden, mat där s du e Geheimnis deels. Dat Geheimnis och mat him deelen?", + "{requester} asks you to share a secret with {user}.": "{requester} freet dech, e Geheimnis mat {user} ze deelen.", + "Shared. The recipient can now open the secret.": "Gedeelt. Den Empfänger kann d'Geheimnis elo opmaachen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Den Empfänger huet Keepiq nach net ageriicht, dofir gouf näischt gedeelt. Probéier et nach eng Kéier, wann hien et gemaach huet.", + "Could not share the secret. Only its owner can approve this.": "D'Geheimnis konnt net gedeelt ginn. Nëmmen säi Besëtzer kann dat guttheeschen.", + "Could not share the secret. Try again.": "D'Geheimnis konnt net gedeelt ginn. Probéier et nach eng Kéier.", + "Denied. Nothing was shared.": "Ofgeleent. Et gouf näischt gedeelt.", + "Could not deny the request. Try again.": "D'Ufro konnt net ofgeleent ginn. Probéier et nach eng Kéier.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s freet dech, d'Geheimnis \"%2$s\" mat %3$s ze deelen.", + "Expires on (optional)": "Leeft of den (fakultativ)", + "Hand over to": "Iwwerginn un", + "Choose a recipient": "En Empfänger auswielen", + "Hand over temporarily": "Temporär iwwerginn", + "Expiry rules": "Oflafregelen", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Leeë fest, wéi laang Passwierder vun engem Elementtyp oder an engem Uerdner gëlle kënnen, a wéini s du erënnert gëss. Wa méi Datumer gëllen, zielt dee fréisten.", + "Delete rule": "Regel läschen", + "Set by your administrator": "Vun dengem Administrateur festgeluecht", + "No expiry rules yet.": "Nach keng Oflafregelen.", + "Applies to": "Gëllt fir", + "Item type": "Elementtyp", + "Maximum age in days (empty for reminders only)": "Maximalt Alter an Deeg (eidel nëmme fir Erënnerungen)", + "Remind me this many days before, comma separated": "Erënner mech sou vill Deeg virdrun, mat Komma getrennt", + "Save rule": "Regel späicheren", + "An item type": "En Elementtyp", + "A folder": "En Uerdner", + "Folder {name}": "Uerdner {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Leeft no {days} Deeg of", + "Reminders {days} days before": "Erënnerungen {days} Deeg virdrun", + "Could not save the expiry rule.": "D'Oflafregel konnt net gespäichert ginn.", + "Could not delete the expiry rule.": "D'Oflafregel konnt net geläscht ginn.", + "All statuses": "All Statussen", + "Compromised": "Kompromittéiert", + "Could not load the members.": "D'Memberen konnten net geluede ginn.", + "Emergency contact": "Noutkontakt", + "Leaving user": "Fortgoende Benotzer", + "No": "Nee", + "No users match this filter.": "Keng Benotzer passen op dëse Filter.", + "Not set up": "Net ageriicht", + "Revoke suite": "Suite zréckruffen", + "Revoked": "Zréckgeruff", + "Search users": "Benotzer sichen", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Kuckt, wéi eng Benotzer en Tresor ageriicht hunn. Start den Offboarding oder rufft eng Suite aus enger Zeil zréck.", + "Successor": "Nofolger", + "Team folders": "Teamdossieren", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "De Benotzer ass nach am Grupp {groups}, deen Member vun engem Teamdossier ass. Huelt hien aus dem Grupp eraus oder desaktivéiert de Kont.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "De Benotzer ass nach an de Gruppen {groups}, déi Member vun Teamdossiere sinn. Huelt hien aus de Gruppen eraus oder desaktivéiert de Kont.", + "Vault status": "Tresorstatus", + "Yes": "Jo", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-Export ass NET VERSCHLËSSELT. All Passwuert an all Login sinn am erofgeluedene Fichier als Kloertext ze liesen. Späichert e sécher a läscht en direkt no der Notzung.", + "Root certificate expiring soon": "De Root-Zertifikat leeft geschwënn of", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "De Root-Zertifikat vum Tresor leeft an %1$d Dag/Deeg of. Erneiert en virdrun. Beim Erneieren gëtt all Verschlësselungssuite nei ënnerschriwwen.", + "Compromise recovery aborted": "Erhuelung no Kompromittéierung ofgebrach", + "Key rotation ended by a compromise revoke": "Schlësselrotatioun duerch e Kompromittéierungs-Widderruff ofgeschloss", + "Encryption suite revoke refused": "Widderruff vun der Verschlësselungssuite refuséiert", + "Master password proof refused": "Beweis vum Masterpasswuert refuséiert", + "Your current master password": "Äert aktuellt Masterpasswuert", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n Noutkontakt hat eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun en ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n Noutkontakter haten eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun se ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Dës Noutkontakter goufen net op Ären neie Schlëssel iwwerholl. Hiren Noutzougrëff gouf ewechgeholl. Setzt se nees ënner Noutzougrëff derbäi, wann Dir se nach wëllt.", + "Renew root certificate": "Root-Zertifikat erneieren", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dëst erstellt en neit Root- an Zwëschenzertifikat. All aktiv Verschlësselungssuite gëtt nei ënnerschriwwen. Dat kann net réckgängeg gemaach ginn.", + "Renew root": "Root erneieren", + "Root renewed. {n} encryption suites signed again.": "Root erneiert. {n} Verschlësselungssuitten nei ënnerschriwwen.", + "Could not renew the root certificate.": "D'Root-Zertifikat konnt net erneiert ginn.", + "Lease policy for this application": "Lease-Richtlinn fir dës Applikatioun", + "In force now: {default} seconds by default, {max} seconds at most.": "Elo gëlteg: standardméisseg {default} Sekonnen, héchstens {max} Sekonnen.", + "Leases are not renewable": "Leases kënnen net verlängert ginn", + "Lease policy saved.": "Lease-Richtlinn gespäichert.", + "Leave a field empty to use the instance value.": "Loss e Feld eidel, fir de Wäert vun der Instanz ze benotzen.", + "Instance value: {value}": "Wäert vun der Instanz: {value}", + "Renewal": "Verlängerung", + "Use the instance value ({value})": "Wäert vun der Instanz benotzen ({value})", + "Allowed": "Erlaabt", + "Not allowed": "Net erlaabt", + "Save lease policy": "Lease-Richtlinn späicheren", + "Only an administrator can change this policy.": "Nëmmen en Administrateur kann dës Richtlinn änneren.", + "Could not save the lease policy.": "D'Lease-Richtlinn konnt net gespäichert ginn.", + "{member} got access from {confirmer}.": "{member} krut Zougang vun {confirmer}.", + "Automatically confirm new team folder members": "Nei Memberen vun Teamdossieren automatesch bestätegen", + "Gave %n new member access to a team folder.": "%n neie Member krut Zougang zu engem Teamdossier.", + "Gave %n new members access to a team folder.": "%n nei Memberen kruten Zougang zu engem Teamdossier.", + "Give new team folder members access without waiting for the folder owner.": "Gitt neie Memberen Zougang ouni op de Besëtzer vum Dossier ze waarden.", + "New team folder members": "Nei Memberen vun Teamdossieren", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "De Besëtzer oder e Member mat Schreifrecht bestätegt se aus sengem oppenen Tresor. Keepiq entschlësselt ni um Server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Et gëtt gewaart, bis e Member mat Schreifrecht Keepiq opmécht. Dir kënnt och elo deelen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En Deel vun der Reaktioun op d’Kompromittéierung ass feelgeschloen ({failed} Schrëtt). Kontrolléiert de Serverprotokoll a widderrufft d’Suite dann nach eng Kéier, fir se ofzeschléissen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Domat gouf och d’Suite {suite} widderruff an d’Schlësselmigratioun {migration} ofgeschloss.", + "Revoking the second suite deleted %n emergency-access contact.": "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakt geläscht.", + "Revoking the second suite deleted %n emergency-access contacts.": "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakter geläscht.", + "A suite revoked as compromised cannot be reinstated.": "Eng Suite, déi als kompromittéiert widderruff gouf, kann net erëm hiergestallt ginn.", + "Archives to keep": "Archiven fir ze halen", + "Back up every vault automatically": "All Tresor automatesch sécheren", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Séchert all Tresor no engem Plang. D'Archiven enthalen nëmme verschlësselten Text a ginn mat occ restauréiert.", + "Back up now": "Elo sécheren", + "Backup public key (PEM, optional)": "Ëffentleche Sécherungsschlëssel (PEM, fakultativ)", + "Backup requested for the next cron run": "Sécherung fir den nächste Cron-Laf ugefrot", + "Encrypted": "Verschlësselt", + "Every (hours)": "All (Stonnen)", + "Last backup {when} failed: {error}": "Lescht Sécherung {when} ass feelgeschloen: {error}", + "Last backup {when} succeeded.": "Lescht Sécherung {when} ass gelongen.", + "No archives yet.": "Nach keng Archiven.", + "Size": "Gréisst", + "Vault backups": "Tresorsécherungen", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Mat engem Schlëssel gëtt all Archiv fir hie verschlësselt. Hält de private Schlëssel ausserhalb vun dësem Server: Dir braucht en fir ze kontrolléieren oder ze restauréieren.", + "Written": "Geschriwwen", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n Benotzer am Beräich huet nach keng Zwee-Faktor-Umeldung a kann den Tresor net opmaachen, soulaang dëst un ass.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n Benotzer am Beräich hunn nach keng Zwee-Faktor-Umeldung a kënnen den Tresor net opmaachen, soulaang dëst un ass.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup-Coden zielen net. Wann Är Benotzer sech iwwer en Identitéitsubidder mat engem eegene zweete Faktor umellen, loosst hir Gruppen ewech.", + "Block personal vault export": "Export vum perséinlechen Tresor spären", + "Keep work logins in team folders": "Aarbechtsumeldungen an Teamdossieren halen", + "Move to a team folder": "An en Teamdossier réckelen", + "Not in a team folder": "Net an engem Teamdossier", + "Only for these groups (empty is everyone)": "Nëmme fir dës Gruppen (eidel heescht jiddereen)", + "Require two-factor login before the vault opens": "Zwee-Faktor-Umeldung ufroen, ier den Tresor opgeet", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reegele fir all Tresor. All Reegel gëllt fir jiddereen oder nëmme fir déi Gruppen, déi Dir wielt.", + "Secret types that belong in a team folder": "Geheimnistypen, déi an en Teamdossier gehéieren", + "Set up two-factor login": "Zwee-Faktor-Umeldung ariichten", + "Team folder you can write to": "Teamdossier, an deen Dir schreiwe kënnt", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Benotzer kënne keng Sécherung, CSV oder Transferdatei eroflueden. Hire perséinlechen Datepak bleift disponibel.", + "Users cannot save these secret types in a personal folder.": "Benotzer kënnen dës Geheimnistypen net an engem perséinlechen Dossier späicheren.", + "Vault policies": "Tresorreegelen", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Är Organisatioun erlaabt keen Export vun Ärem perséinlechen Tresor. Äre perséinlechen Datepak an den Astellunge bleift disponibel.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Är Organisatioun hält dës Geheimnisser an engem Teamdossier. Réckelt all eenzelt an en Teamdossier.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Är Organisatioun hält dësen Typ vu Geheimnis an engem Teamdossier. Wielt ee vun Ären Teamdossieren oder een, an deen Dir schreiwe kënnt.", + "Your organisation requires two-factor login before you can open your vault.": "Är Organisatioun verlaangt eng Zwee-Faktor-Umeldung, ier Dir Ären Tresor opmaache kënnt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "D'Benotzer wielen, wéi laang d'Erweiderung bei Inaktivitéit entspaart bleift. Dir leet déi längst Zäit fest, déi se wiele kënnen.", + "Longest idle time before the extension locks": "Längst Inaktivitéitszäit, ier d'Erweiderung spaart", + "1 minute": "1 Minutt", + "5 minutes": "5 Minutten", + "15 minutes": "15 Minutten", + "1 hour": "1 Stonn", + "4 hours": "4 Stonnen", + "Connector": "Connecteur", + "Directory (tenant) ID": "Verzeechnis-ID (Mandant)", + "Application (client) ID": "Applikatiouns-ID (Client)", + "Data collection rule immutable ID": "Onverännerlech ID vun der Datesammlungsregel", + "Stream name": "Stream-Numm", + "Splunk index (optional)": "Splunk-Index (fakultativ)", + "Sourcetype (optional)": "Sourcetype (fakultativ)", + "Leave blank to keep the current one": "Eidel loossen, fir den aktuelle Wäert ze halen", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF iwwer Syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datesammlungs-Endpunkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Client-Geheimnis (nëmme schreiwen)", + "HEC token (write-only)": "HEC-Token (nëmme schreiwen)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Erlaabten Audit-Evenementer un Splunk, Microsoft Sentinel, en Syslog-Empfänger oder e Webhook weiderginn. Messagen enthalen nëmme gebotzt Metadaten: kee geheime Wäert, Numm, Login oder verschlësselten Text verléisst jee de Server.", + "%n change waiting to sync": "%n Ännerung waart op d'Synchronisatioun", + "%n changes waiting to sync": "%n Ännerunge waarden op d'Synchronisatioun", + "Changes that could not sync": "Ännerungen, déi net synchroniséiert konnte ginn", + "Choose a version": "Versioun wielen", + "Copy value": "Wäert kopéieren", + "Deleted": "Geläscht", + "Discard": "Verwerfen", + "Keep my offline change": "Meng Offline-Ännerung behalen", + "Keep the server version": "D'Serverversioun behalen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq ass offline nëmme liesbar. Är Administratioun huet d'Offline-Beaarbechtung net ageschalt.", + "Let users edit secrets offline": "Benotzer erlaben, Geheimnisser offline ze beaarbechten", + "Not synced yet": "Nach net synchroniséiert", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-Ännerunge bleiwen um Apparat, fir de Benotzer verschlësselt, a gi beim nächsten Online-Entspären synchroniséiert. Deelen, Ordner an Unhäng brauchen ëmmer nach eng Verbindung.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Beaarbechtungen, Verréckelungen a Läschunge bleiwen op dësem Apparat a gi synchroniséiert, soubal Dir erëm online sidd. Deelen an Unhäng brauchen eng Verbindung.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Är Ännerunge bleiwen op dësem Apparat a gi synchroniséiert, soubal Dir erëm online sidd. Lescht synchroniséiert {when}.", + "Open my changes": "Meng Ännerungen opmaachen", + "Sharing needs a connection": "Deelen brauch eng Verbindung", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Een huet dëst Geheimnis um Server geännert, nodeems Är Offline-Kopie gemaach gouf. Wielt, wéi eng Versioun Dir behaalt.", + "Sync or discard your offline changes before you rotate your keys.": "Synchroniséiert oder verwerft Är Offline-Ännerungen, ier Dir Är Schlësselen erneiert.", + "That password did not open your changes.": "Mat dësem Passwuert konnten Är Ännerungen net opgemaach ginn.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "De Offline-Schnappschoss späichert verschlësselt Geheimnisser (nëmme mat dem Schlëssel opzemaachen, deen aus dem Master-Passwuert vum Benotzer ofgeleet ass, genee wéi um Server) a verschlësselt Nimm, URLen an Ordnernimm am Späicher. Offline-Zougang ass nëmme liesbar, ausser Dir erlaabt hei ënnen d'Offline-Beaarbechtung. Schalt dat fir Apparater aus, déi ni Umeldungsdaten zwëschespäichere däerfen; d'Ausschalte läscht bestoend Cachen beim nächste Lueden.", + "The previous vault copy is gone, so these changes cannot be opened.": "Déi viregt Kopie vum Tresor ass fort, dofir kënnen dës Ännerungen net opgemaach ginn.", + "The server version": "D'Serverversioun", + "This secret changed while you were offline": "Dëst Geheimnis gouf geännert, wärend Dir offline waart", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Dir hutt dëst Geheimnis offline geläscht, mee et gouf zënterhier um Server geännert. Wielt, wéi eng Versioun Dir behaalt.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Är Schlëssele goufen op engem aneren Apparat geännert. Gitt Äert viregt Master-Passwuert an, fir Är Offline-Ännerungen ze synchroniséieren, oder verwerft se.", + "Your offline change": "Är Offline-Ännerung", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n Noutkontakt hat eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun en ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.","%n Noutkontakter haten eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun se ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n Element kann net an CXF duergestallt ginn a gëtt iwwersprongen.","%n Elementer kënnen net an CXF duergestallt ginn a gi iwwersprongen."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n eeler Versioun gouf verworf, well nëmmen déi rezent Historie mat iwwerdroe ka ginn.","%n eeler Versioune goufe verworf, well nëmmen déi rezent Historie mat iwwerdroe ka ginn."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n Geheimniskopie muss nach verschlësselt a gedeelt ginn.","%n Geheimniskopie mussen nach verschlësselt a gedeelt ginn."], + "_%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 secret could not be decrypted and is not in this export.","%n secrets could not be decrypted and are not in this export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n Geheimnis konnt net mat Ärem alen Schlëssel entschlësselt ginn, dofir gouf et net migréiert.","%n Geheimnisse konnten net mat Ärem alen Schlëssel entschlësselt ginn, dofir goufe si net migréiert."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n Geheimnis gouf net migréiert.","%n Geheimnisse goufen net migréiert."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n Geheimnis ass nach mat Ärem viregen Schlëssel verschlësselt.","%n Geheimnisser sinn nach mat Ärem viregen Schlëssel verschlësselt."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n Geheimnis gouf iwwersprongen, well den Nofolger nach keng Kopie huet — setzt den Nofolger an den Dossier a féiert et nach eemol aus.","%n Geheimnisser goufen iwwersprongen, well den Nofolger nach keng Kopie huet — setzt den Nofolger an den Dossier a féiert et nach eemol aus."], + "_%n secret_::_%n secrets_": ["%n Geheimnis","%n Geheimnisser"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n Benotzer am Beräich huet nach keng Zwee-Faktor-Umeldung a kann den Tresor net opmaachen, soulaang dëst un ass.","%n Benotzer am Beräich hunn nach keng Zwee-Faktor-Umeldung a kënnen den Tresor net opmaachen, soulaang dëst un ass."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Trotzdem ofschléissen an den Zougang zu %n Geheimnis verléieren","Trotzdem ofschléissen an den Zougang zu %n Geheimnisser verléieren"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n neie Member krut Zougang zu engem Teamdossier.","%n nei Memberen kruten Zougang zu engem Teamdossier."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Schlësselrotatioun ofgeschloss. %n Geheimnis gouf mat Ärem neien Schlëssel nei verschlësselt.","Schlësselrotatioun ofgeschloss. %n Geheimnisser goufe mat Ärem neien Schlëssel nei verschlësselt."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["De Widderruff vun der zweeter Suite huet %n Noutzougangskontakt geläscht.","De Widderruff vun der zweeter Suite huet %n Noutzougangskontakter geläscht."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revoking this suite deleted %n emergency-access contact.","Revoking this suite deleted %n emergency-access contacts."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["%n Mol a Lecke gesinn","%n Mol a Lecke gesinn"], + "_shared with %n secret_::_shared with %n secrets_": ["mat %n Geheimnis gedeelt","mat %n Geheimnisser gedeelt"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Dësen Dossier enthält direkt %n Geheimnis.","Dësen Dossier enthält direkt %n Geheimnisser."], + "_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._": ["Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.","Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n Ännerung waart op d'Synchronisatioun","%n Ännerunge waarden op d'Synchronisatioun"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["De Benotzer ass nach am Grupp {groups}, deen Member vun engem Teamdossier ass. Huelt hien aus dem Grupp eraus oder desaktivéiert de Kont.","De Benotzer ass nach an de Gruppen {groups}, déi Member vun Teamdossiere sinn. Huelt hien aus de Gruppen eraus oder desaktivéiert de Kont."], + "Allow approval from another device": "Fräigab vun engem aneren Apparat erlaben", + "App": "App", + "Approve a new device": "En neien Apparat fräiginn", + "Approve from another device": "Vun engem aneren Apparat fräiginn", + "Asked at": "Ugefrot um", + "Check that the new device shows these words:": "Kuckt no, ob den neien Apparat dës Wierder weist:", + "Denied. If you did not ask, end your other sessions:": "Refuséiert. Wann Dir dat net ugefrot hutt, beent Är aner Sessiounen:", + "Device": "Apparat", + "IP address": "IP-Adress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Benotzer kënnen en neie Browser entspären, andeems se en op engem Apparat fräiginn, op deem Keepiq schonn entspaart ass.", + "New device approval": "Fräigab vun neien Apparater", + "Nextcloud security settings": "Nextcloud-Sécherheetsastellungen", + "Only approve a device you are using right now.": "Gitt nëmmen en Apparat fräi, deen Dir grad elo benotzt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Maacht Keepiq op engem Apparat op, op deem et entspaart ass, a gitt dësen Apparat fräi. Kuckt no, ob et déiselwecht Wierder weist:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den Apparat, deen fräigëtt, versigelt den Entspärschlëssel fir den neien Apparat. De Server gëtt en nëmme weider a kann en net opmaachen.", + "The master password is not right, or the request has ended.": "D'Master-Passwuert ass net richteg, oder d'Ufro ass eriwwer.", + "The request expired. Ask again or use your master password.": "D'Ufro ass ofgelaf. Frot nach eng Kéier oder benotzt Äert Master-Passwuert.", + "The request was denied.": "D'Ufro gouf refuséiert.", + "Too many requests. Try again in an hour or use your master password.": "Ze vill Ufroen. Probéiert et an enger Stonn nach eng Kéier oder benotzt Äert Master-Passwuert.", + "Unknown device": "Onbekannten Apparat", + "Web app": "Web-App", + "A device": "En Apparat", + "A new device asks to open your vault": "En neien Apparat freet, Äre Coffre opzemaachen", + "%s asks to be approved. Only approve a device you are using right now.": "%s freet ëm Fräigab. Gitt nëmmen en Apparat fräi, deen Dir grad elo benotzt.", + "Access ends on (optional)": "Den Zougang endet den (optional)", + "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.": "D'Apps vu Keepiq weisen a kopéieren d'Passwuert net. Een mat technesche Kenntnisser kann et awer ëmmer nach op sengem eegenen Apparat liesen. Ännert et, wann den Zougang ofleeft.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dëst Geheimnis ass nëmme fir ze benotzen. Mellt Iech iwwer d'Keepiq-Browserextensioun un.", + "Until {date}": "Bis den {date}", + "Use only": "Nëmme benotzen", + "Use only (can sign in, cannot view or copy)": "Nëmme benotzen (kann sech umellen, kann et net gesinn oder kopéieren)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Dir kënnt Iech mat dësem Login iwwer d'Keepiq-Browserextensioun umellen. De Besëtzer huet decidéiert, datt Dir en net gesitt oder kopéiere kënnt.", + "Your access ends on {date}": "Ären Zougang endet den {date}", + "Your access to this secret has ended": "Ären Zougang zu dësem Geheimnis ass ofgelaf", + "Your access to \"%s\" ends tomorrow": "Ären Zougang zu „%s“ leeft muer of", + "Your access to \"%s\" has ended": "Ären Zougang zu „%s“ ass ofgelaf", + "%1$s no longer has access to \"%2$s\"": "%1$s huet keen Zougang méi zu „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s konnt dëst Passwuert gesinn. Ännert et, wann %1$s et net méi kenne soll.", + "%s could not view this password in Keepiq.": "%s konnt dëst Passwuert a Keepiq net gesinn.", + "{approvals} of {threshold} approvals": "{approvals} vun {threshold} Fräigaben", + "a recovery officer": "eng Persoun fir d'Erëmhierstellung", + "Account recovery": "Kont-Erëmhierstellung", + "Approvals needed": "Néideg Fräigaben", + "Ask {user} which words they see, by phone or in person. They must be:": "Frot {user} um Telefon oder perséinlech, wéi eng Wierder ugewise ginn. Et mussen dës sinn:", + "Check again": "Nach eng Kéier kontrolléieren", + "Create the recovery key": "Erëmhierstellungsschlëssel erstellen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Erstellt den Erëmhierstellungsschlëssel. Äre Browser mécht en a gëtt all Beoptraagter eng Kopie, déi nëmmen si opmaache kann.", + "Decline": "Refuséieren", + "Enrol in account recovery": "Fir d'Kont-Erëmhierstellung umellen", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Mellt Iech un, fir datt Är Organisatioun Iech hëllefe kann, Ären Tresor zeréckzekréien, wann Dir Äert Master-Passwuert vergiesst.", + "Every user is enrolled": "All Benotzer sinn ugemellt", + "Finish the recovery in the browser you asked from.": "Schléisst d'Erëmhierstellung an dem Browser of, aus deem Dir se ugefrot hutt.", + "Forgot your master password?": "Master-Passwuert vergiess?", + "Hand the key over": "Schlëssel iwwerginn", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Loosst Benotzer, déi hiert Master-Passwuert vergiess hunn, hiren Tresor zeréckkréien, fräiginn duerch Beoptraagter, déi Dir nennt.", + "New master password": "Neit Master-Passwuert", + "No one is asking to recover their account.": "Keen freet no der Erëmhierstellung vu sengem Kont.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nach keen Erëmhierstellungsschlëssel. Ee vun de Beoptraagten erstellt en a sengen Keepiq-Astellungen.", + "Off": "Aus", + "Officer {user} has no encryption set up yet.": "De Beoptraagten {user} huet nach keng Verschlësselung ageriicht.", + "Officers (user IDs, separated by commas)": "Beoptraagter (Benotzer-IDen, mat Kommaen getrennt)", + "Policy": "Richtlinn", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publizéiert dësen Fangerofdrock intern, fir datt d'Benotzer e virum Umellen iwwerpréiwe kënnen.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Mat der Hëllef vu {officer} erëmhiergestallt. Ännert elo Ären Tresorschlëssel ënner Astellungen, Sécherheet: \"Mäi Master-Passwuert gouf kompromittéiert\".", + "Recovery key fingerprint: {fingerprint}": "Fangerofdrock vum Erëmhierstellungsschlëssel: {fingerprint}", + "Recovery officer": "Beoptraagten fir d'Erëmhierstellung", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Ewechgeholl Beoptraagter verléieren elo hir Kopie, kéinten se awer virdru opgemaach hunn. Loosst e Beoptraagten en neien Erëmhierstellungsschlëssel erstellen.", + "Repeat the new master password": "Neit Master-Passwuert widderhuelen", + "Retire this recovery key": "Dësen Erëmhierstellungsschlëssel ausser Betrib huelen", + "Set the new master password": "Neit Master-Passwuert setzen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "D'Erëmhierstellungszertifikat gouf net vun dësem Keepiq ausgestallt. Mellt Iech net un a sot Ärem Administrateur Bescheed.", + "The words match, approve": "D'Wierder passen, fräiginn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dëse Benotzer ass fir d'Kont-Erëmhierstellung ugemellt. Erëmhierstellen behält seng Geheimnisser; Zréckzéien läscht seng Umeldung.", + "Users may enrol": "Benotzer däerfen sech umellen", + "Withdraw from account recovery": "Vun der Kont-Erëmhierstellung ofmellen", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Dir sidd fir d'Kont-Erëmhierstellung ugemellt. Fangerofdrock vum Erëmhierstellungsschlëssel: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Dir sidd ugemellt. Wann Dir Äert Master-Passwuert vergiesst, kann Är Organisatioun Iech hëllefen, Ären Tresor zeréckzekréien.", + "Your key is back. Choose a new master password.": "Äre Schlëssel ass zeréck. Wielt en neit Master-Passwuert.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Är Beoptraagter fir d'Erëmhierstellung goufen informéiert. Liest hinnen dës Wierder vir, wann se Iech uruffen oder treffen:", + "You are now an account recovery officer": "Dir sidd elo Beoptraagten fir d'Kont-Erëmhierstellung", + "%s asks to recover their account. Compare the words with them before you approve.": "%s freet no der Erëmhierstellung vum Kont. Vergläicht d'Wierder mat der Persoun, ier Dir fräigitt.", + "A user": "E Benotzer", + "Your account recovery request was declined": "Är Ufro fir d'Kont-Erëmhierstellung gouf refuséiert", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Är Kont-Erëmhierstellung ass prett. Maacht Keepiq an dem Browser op, aus deem Dir se ugefrot hutt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} freet, en neien Apparat eemol z'entspären. D'Master-Passwuert bleift d'selwecht.", + "Ask your organisation instead": "Frot amplaz Är Organisatioun", + "The request ended. Ask again or use your master password.": "D'Ufro ass eriwwer. Frot nach eng Kéier oder benotzt Äert Master-Passwuert.", + "Added by {user}": "Bäigesat vun {user}", + "Editor": "Editeur", + "Manager": "Manager", + "Role of {member}": "Roll vun {member}", + "Team folders you manage": "Teamdossieren, déi Dir verwalt", + "Viewer": "Lieser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Dir hutt keng Kopie vun dëse Geheimnisser, dofir hunn déi nei Memberen se nach net kritt. De Besëtzer kann se deelen: {names}", + "Admin areas": "Verwaltungsberäicher", + "Give a group only the parts of Keepiq administration it needs.": "Gitt engem Grupp nëmmen déi Deeler vun der Keepiq-Verwaltung, déi e brauch.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegéiert een oder méi Beräicher un e Grupp op der Säit vun de Verwaltungsrechter. Instanzadministrateure hunn all Beräich.", + "Open administration privileges": "Verwaltungsrechter opmaachen", + "Policies": "Richtlinnen", + "Applications and machine access": "Applikatiounen a Maschinnenzougang", + "People and offboarding": "Leit an Austrëtt", + "Audit and compliance": "Audit a Konformitéit", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "Versioun, Zertifizéierungsstell, Unhäng, Offline-Cache, Leckkontroll, Geheimnistypen a Sécherungskopien", + "master password, organisation password, vault policies, rotation, version history and trash": "Masterpasswuert, Organisatiounspasswuert, Tresorrichtlinnen, Rotatioun, Versiounsverlaf a Poubelle", + "application queue, application requests and machine leases": "Applikatiounsschlaang, Applikatiounsufroen a Maschinnen-Leasen", + "team offboarding, encryption suites and admin handover": "Team-Austrëtt, Verschlësselungssuiten an Iwwerhuele vum Administrateur", + "audit log, compliance reports, SIEM export and honey alerts": "Auditprotokoll, Konformitéitsberichter, SIEM-Export an Hunneg-Alarmer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Wéi vill Versioune vun engem Geheimnis wéi laang gehale ginn, a wéi laang geläschte Geheimnisser an der Poubelle bleiwen.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenze fir verschlësselt Unhäng, um Server a gespäicherte verschlësselte Bytes duerchgesat.", + "Type the suite ID again to confirm": "Gitt d'Suite-ID nach eng Kéier an fir ze confirméieren", + "This does not match the suite ID.": "Dat entsprécht net der Suite-ID.", + "Confirm with your master password": "Mat Ärem Masterpasswuert confirméieren", + "Confirm": "Confirméieren", + "That master password is not right.": "Dëst Masterpasswuert ass net richteg.", + "You are sharing with someone new. Enter your master password to confirm.": "Dir deelt mat enger neier Persoun. Gitt Äert Masterpasswuert an fir ze confirméieren.", + "Enter your master password to confirm this share.": "Gitt Äert Masterpasswuert an fir dës Deelung ze confirméieren.", + "Enter your master password to confirm this delegation.": "Gitt Äert Masterpasswuert an fir dës Delegatioun ze confirméieren.", + "Approve {member}": "{member} guttheeschen", + "Recipient": "Empfänger", + "No vault yet": "Nach keen Tresor", + "No matching users": "Keng passend Benotzer", + "Partner organisations": "Partnerorganisatiounen", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Tauscht Geheimnisser mat engem anere Keepiq aus. Béid Administrateure fügen sech géigesäiteg derbäi a vergläichen d’Root-Fangerofdréck um Telefon oder perséinlech, ier se späicheren.", + "Federation needs Nextcloud 33 or later.": "Föderatioun brauch Nextcloud 33 oder méi nei.", + "Your root fingerprint": "Ären Root-Fangerofdrock", + "No partners yet.": "Nach keng Partner.", + "Users here may share to this partner": "Benotzer hei däerfe mat dësem Partner deelen", + "This partner may share to users here": "Dëse Partner däerf mat Benotzer hei deelen", + "Partner address": "Adress vum Partner", + "Check partner": "Partner iwwerpréiwen", + "Partner root fingerprint": "Root-Fangerofdrock vum Partner", + "I compared this fingerprint with the partner's administrator": "Ech hunn dëse Fangerofdrock mam Administrateur vum Partner verglach", + "Add partner": "Partner derbäisetzen", + "A secret from another organisation": "E Geheimnis vun enger anerer Organisatioun", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s huet \"%2$s\" mat Iech gedeelt. Acceptéiert et ënner Erakommend vun anere Organisatiounen.", + "Incoming from other organisations": "Erakommend vun anere Organisatiounen", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Leit a Partnerorganisatiounen kënnen e Geheimnis mat Iech deelen. Acceptéiert et, fir eng schreifgeschützt Kopie an Ärem Tresor ze halen.", + "Nothing shared with you yet": "Nach näischt mat Iech gedeelt", + "Secrets that people in partner organisations share with you appear here.": "Geheimnisser, déi Leit a Partnerorganisatiounen mat Iech deelen, erschéngen hei.", + "From {sender}": "Vun {sender}", + "Accept": "Acceptéieren", + "Open in vault": "Am Tresor opmaachen", + "The other organisation did not hand over the secret. Try again later.": "Déi aner Organisatioun huet d’Geheimnis net iwwerginn. Probéiert et méi spéit nach eng Kéier.", + "Set up your vault before you accept a shared secret.": "Riicht Ären Tresor an, ier Dir e gedeelt Geheimnis acceptéiert.", + "Something went wrong. Try again.": "Eppes ass schifgaang. Probéiert et nach eng Kéier.", + "Waiting for your answer": "Waart op Är Äntwert", + "In your vault, read-only": "An Ärem Tresor, nëmme liesen", + "Withdrawn by the sender": "Vum Ofsender zréckgezunn", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} huet dëst vun enger anerer Organisatioun gedeelt. Dir kënnt et liesen, mee net änneren oder deelen.", + "Someone": "Iergendeen", + "Share with someone at another organisation": "Mat engem vun enger anerer Organisatioun deelen", + "Their account at the other organisation": "D’Konto vun der Persoun bei der anerer Organisatioun", + "Check account": "Konto préiwen", + "Certificate fingerprint of {account}": "Fangerofdrock vum Zertifikat vun {account}", + "Compare it with them by phone if you want to be sure.": "Vergläicht en mat der Persoun um Telefon, wann Dir sécher wëllt sinn.", + "Shared. {account} can accept it in their own vault.": "Gedeelt. {account} kann et am eegenen Tresor acceptéieren.", + "The certificate could not be verified. Nothing was shared.": "D’Zertifikat konnt net verifizéiert ginn. Et gouf näischt gedeelt.", + "That organisation is not one of your partners.": "Déi Organisatioun ass keen vun Äre Partner.", + "No one with that account can receive secrets from you.": "Keen mat deem Konto ka Geheimnisser vun Iech kréien.", + "The other organisation did not answer. Try again later.": "Déi aner Organisatioun huet net geäntwert. Probéiert et méi spéit nach eng Kéier.", + "This secret is already shared with that account.": "Dëst Geheimnis ass schonn mat deem Konto gedeelt.", + "Other organisations": "Aner Organisatiounen", + "Receive secrets from other organisations": "Geheimnisser vun anere Organisatiounen kréien", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Leit a Partnerorganisatiounen kënnen dann Äert Konto fannen a Geheimnisser mat Iech deelen. Dir acceptéiert all eenzelt selwer.", + "Shared": "Gedeelt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauséiert: hiert Zertifikat oder de Partenariat huet geännert. Zitt et zréck oder deelt et nach eng Kéier.", + "Their organisation did not get the last change. Revoke it or share again.": "Hir Organisatioun huet déi lescht Ännerung net kritt. Zitt et zréck oder deelt et nach eng Kéier.", + "Being withdrawn": "Gëtt zréckgezunn", + "Shared with another organisation": "Mat enger anerer Organisatioun gedeelt", + "Change sent to another organisation": "Ännerung un eng aner Organisatioun geschéckt", + "Share with another organisation revoked": "Deelen mat enger anerer Organisatioun zréckgezunn", + "Share with another organisation paused": "Deelen mat enger anerer Organisatioun pauséiert", + "Another organisation did not get a change": "Eng aner Organisatioun huet eng Ännerung net kritt", + "Secret received from another organisation": "Geheimnis vun enger anerer Organisatioun kritt", + "Secret from another organisation accepted": "Geheimnis vun enger anerer Organisatioun acceptéiert", + "Secret from another organisation declined": "Geheimnis vun enger anerer Organisatioun refuséiert", + "Copy from another organisation updated": "Kopie vun enger anerer Organisatioun aktualiséiert", + "Copy from another organisation removed": "Kopie vun enger anerer Organisatioun ewechgeholl", + "Declined: they removed their copy. Share again if they need it.": "Refuséiert: den Empfänger huet seng Kopie ewechgeholl. Deelt et nach eng Kéier, wann hien se brauch.", + "Recipient at another organisation removed their copy": "Empfänger vun enger anerer Organisatioun huet seng Kopie ewechgeholl", + "Removed the user from %n team folder.": "De Benotzer gouf aus %n Teamuerdner ewechgeholl.", + "Removed the user from %n team folders.": "De Benotzer gouf aus %n Teamuerdner ewechgeholl.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["De Benotzer gouf aus %n Teamuerdner ewechgeholl.","De Benotzer gouf aus %n Teamuerdner ewechgeholl."], + "A restored copy came from a share that has ended. It stays read-only.": "Eng restauréiert Kopie kënnt vun enger Deelung, déi eriwwer ass. Si bleift nëmmen ze liesen.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "D’Organisatioun, déi eng restauréiert Kopie gedeelt huet, ass net z’erreechen. D’Kopie bleift nëmmen ze liesen a follegt hiren Ännerungen net.", + "Recipient at another organisation restored their copy": "Empfänger vun enger anerer Organisatioun huet seng Kopie restauréiert" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/lb.json b/l10n/lb.json index 2866ed47f..aa0752267 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Mat Gruppen gedeelt", + "Not shared with any group yet.": "Nach mat kenger Grupp gedeelt.", + "Revoke the share with {group}": "Deele mat {group} zréckzéien", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Mat {group} gedeelt: {received} Memberen hunn et kritt, {skipped} net, well se nach keng Verschlësselung ageriicht hunn.", + "Search groups": "Gruppe sichen", + "Failed to share": "Deelen ass feelgeschloen", + "Columns": "Kolonnen", + "Column {number}": "Kolonn {number}", + "Map one column to Name. Every secret needs a name.": "Verbannt eng Kolonn mam Numm. All Geheimnis brauch en Numm.", + "Notes": "Notizen", + "Do not import": "Net importéieren", + "Hide this value": "Dëse Wäert verstoppen", + "Show this value": "Dëse Wäert weisen", + "Defaults": "Standardwäerter", + "New secrets start as this type, and your secret list opens in this view.": "Nei Geheimnisser fänken als dësen Typ un, an deng Geheimnislëscht mécht sech an dëser Usiicht op.", + "Default item type": "Standard-Elementtyp", + "Cards": "Kaarten", + "Table": "Tabell", + "Could not save your default": "Däi Standardwäert konnt net gespäichert ginn", + "Recently used": "Rezent benotzt", + "Opened": "Opgemaach", + "You have not opened any secrets yet": "Du hues nach keng Geheimnisser opgemaach", + "Could not delete the item type.": "Den Elementtyp konnt net geläscht ginn.", + "Could not load the item types.": "D'Elementtypen konnten net gelueden ginn.", + "Could not save the item type.": "Den Elementtyp konnt net gespäichert ginn.", + "Delete item type": "Elementtyp läschen", + "Edit item type": "Elementtyp änneren", + "Fields": "Felder", + "Fields: {count}": "Felder: {count}", + "Hidden": "Verstoppt", + "Item types": "Elementtypen", + "Move up": "Erop réckelen", + "New item type": "Neien Elementtyp", + "No item types defined yet.": "Nach keng Elementtypen definéiert.", + "Required": "Obligatoresch", + "Text": "Text", + "This field is required": "Dëst Feld ass obligatoresch", + "Web address": "Webadress", + "{label} (required)": "{label} (obligatoresch)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "„{name}“ läschen? Geheimnisser vun dësem Typ bleiwe liesbar a ginn zu Login-Elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtypen, déi s du hei definéiers, erschéngen bei jidderengem am Dialog Neit Geheimnis, mat de Felder, déi s du wiels.", + "Secret moved to the trash": "Geheimnis an den Pabeierkuerf geréckelt", + "Secret restored from the trash": "Geheimnis aus dem Pabeierkuerf erëmgestallt", + "Secret deleted for good": "Geheimnis definitiv geläscht", + "Secret archived": "Geheimnis archivéiert", + "Secret unarchived": "Geheimnis aus dem Archiv geholl", + "Unarchive": "Aus dem Archiv huelen", + "Could not archive the secret": "D'Geheimnis konnt net archivéiert ginn", + "Could not unarchive the secret": "D'Geheimnis konnt net aus dem Archiv geholl ginn", + "Archive {count} secrets": "Geheimnisser archivéieren: {count}", + "Unarchive {count} secrets": "Geheimnisser aus dem Archiv huelen: {count}", + "Restore {count} secrets": "Geheimnisser erëmstellen: {count}", + "Delete {count} secrets for good": "Geheimnisser definitiv läschen: {count}", + "Done for {ok} of {total} secrets": "Fäerdeg fir {ok} vun {total} Geheimnisser", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivéiert Geheimnisser verschwannen aus der Tresorlëscht, der Sich, dem automateschen Ausfëllen an dem Gesondheetsrapport. Si behalen hir Deelungen. Du fënns se ënner Archiv.", + "These secrets come back to the vault list, search and autofill.": "Dës Geheimnisser kommen zréck an d'Tresorlëscht, d'Sich an d'automatescht Ausfëllen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Dës Geheimnisser kommen zréck an d'Tresorlëscht. Hir al Deelunge kommen net zréck, deel se also nees, wou et néideg ass.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dëst läscht d'Geheimnisser mat hiren Unhäng an hirer Versiounsgeschicht. Dat kann net réckgängeg gemaach ginn.", + "Delete for good": "Definitiv läschen", + "Trash": "Pabeierkuerf", + "The trash is empty": "De Pabeierkuerf ass eidel", + "No archived secrets": "Keng archivéiert Geheimnisser", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Geläscht Geheimnisser waarden hei, bis d'Oprechterhalungszäit eriwwer ass, duerno gi se definitiv geläscht.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivéier e Geheimnis a sengem Detailberäich, fir et aus der Tresorlëscht, der Sich an dem automateschen Ausfëllen erauszehalen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenze fir verschlësselt Unhäng (um Server op de gespäicherte verschlësselte Bytes duerchgesat), Oprechterhalung vun der Versiounsgeschicht a wéi laang geläscht Geheimnisser am Pabeierkuerf bleiwen.", + "Days a deleted secret stays in the trash (1 to 365)": "Deeg, déi e geläscht Geheimnis am Pabeierkuerf bleift (1 bis 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dëst réckelt d'Geheimnis an de Pabeierkuerf an hält seng Deelunge elo op. Du kanns et aus dem Pabeierkuerf erëmstellen, bis d'Oprechterhalungszäit eriwwer ass: 30 Deeg, ausser däin Administrateur huet dat geännert.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dëst réckelt Geheimnisser an de Pabeierkuerf ({count}) an hält hir Deelunge elo op. Du kanns se aus dem Pabeierkuerf erëmstellen, bis d'Oprechterhalungszäit eriwwer ass.", + "Remove {name} from favourites": "{name} aus de Favoritten ewechhuelen", + "Add {name} to favourites": "{name} bei d'Favoritten derbäisetzen", + "Could not change the favourite": "De Favorit konnt net geännert ginn", + "Remove from favourites": "Aus de Favoritten ewechhuelen", + "Add to favourites": "Bei d'Favoritten derbäisetzen", + "Tags": "Tags", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tags sinn net verschlësselt. Serveradministrateure kënne se liesen, wéi Dossiersnimm.", + "Favourites": "Favoritten", + "Filter by tag": "No Tag filteren", + "All tags": "All Tags", + "Last used": "Fir d'lescht benotzt", + "Tags for {count} secrets": "Tags fir {count} Geheimnisser", + "Tag": "Tag", + "Remove tag": "Tag ewechhuelen", + "Add tag": "Tag derbäisetzen", + "Could not change the tags. Try again.": "D'Tags konnten net geännert ginn. Probéiert nach eng Kéier.", + "Could not approve the application. It is still in the queue.": "D'Ufro konnt net guttgeheescht ginn. Si ass nach ëmmer an der Schlaang.", + "Could not reject the application. It is still in the queue.": "D'Ufro konnt net ofgeleent ginn. Si ass nach ëmmer an der Schlaang.", + "Removed the user from {count} team folders.": "De Benotzer gouf aus {count} Teamuerdner ewechgeholl.", + "Approve a share": "Eng Deelung guttheeschen", + "This approval link is incomplete. Open it again from the notification.": "Dëse Link fir d'Guttheeschen ass net komplett. Maach en nach eng Kéier aus der Notifikatioun op.", + "Deny": "Ofleenen", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ass enger Grupp bäigetrueden, mat där s du e Geheimnis deels. Dat Geheimnis och mat him deelen?", + "{requester} asks you to share a secret with {user}.": "{requester} freet dech, e Geheimnis mat {user} ze deelen.", + "Shared. The recipient can now open the secret.": "Gedeelt. Den Empfänger kann d'Geheimnis elo opmaachen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Den Empfänger huet Keepiq nach net ageriicht, dofir gouf näischt gedeelt. Probéier et nach eng Kéier, wann hien et gemaach huet.", + "Could not share the secret. Only its owner can approve this.": "D'Geheimnis konnt net gedeelt ginn. Nëmmen säi Besëtzer kann dat guttheeschen.", + "Could not share the secret. Try again.": "D'Geheimnis konnt net gedeelt ginn. Probéier et nach eng Kéier.", + "Denied. Nothing was shared.": "Ofgeleent. Et gouf näischt gedeelt.", + "Could not deny the request. Try again.": "D'Ufro konnt net ofgeleent ginn. Probéier et nach eng Kéier.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s freet dech, d'Geheimnis \"%2$s\" mat %3$s ze deelen.", + "Expires on (optional)": "Leeft of den (fakultativ)", + "Hand over to": "Iwwerginn un", + "Choose a recipient": "En Empfänger auswielen", + "Hand over temporarily": "Temporär iwwerginn", + "Expiry rules": "Oflafregelen", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Leeë fest, wéi laang Passwierder vun engem Elementtyp oder an engem Uerdner gëlle kënnen, a wéini s du erënnert gëss. Wa méi Datumer gëllen, zielt dee fréisten.", + "Delete rule": "Regel läschen", + "Set by your administrator": "Vun dengem Administrateur festgeluecht", + "No expiry rules yet.": "Nach keng Oflafregelen.", + "Applies to": "Gëllt fir", + "Item type": "Elementtyp", + "Maximum age in days (empty for reminders only)": "Maximalt Alter an Deeg (eidel nëmme fir Erënnerungen)", + "Remind me this many days before, comma separated": "Erënner mech sou vill Deeg virdrun, mat Komma getrennt", + "Save rule": "Regel späicheren", + "An item type": "En Elementtyp", + "A folder": "En Uerdner", + "Folder {name}": "Uerdner {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Leeft no {days} Deeg of", + "Reminders {days} days before": "Erënnerungen {days} Deeg virdrun", + "Could not save the expiry rule.": "D'Oflafregel konnt net gespäichert ginn.", + "Could not delete the expiry rule.": "D'Oflafregel konnt net geläscht ginn.", + "All statuses": "All Statussen", + "Compromised": "Kompromittéiert", + "Could not load the members.": "D'Memberen konnten net geluede ginn.", + "Emergency contact": "Noutkontakt", + "Leaving user": "Fortgoende Benotzer", + "No": "Nee", + "No users match this filter.": "Keng Benotzer passen op dëse Filter.", + "Not set up": "Net ageriicht", + "Revoke suite": "Suite zréckruffen", + "Revoked": "Zréckgeruff", + "Search users": "Benotzer sichen", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Kuckt, wéi eng Benotzer en Tresor ageriicht hunn. Start den Offboarding oder rufft eng Suite aus enger Zeil zréck.", + "Successor": "Nofolger", + "Team folders": "Teamdossieren", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "De Benotzer ass nach am Grupp {groups}, deen Member vun engem Teamdossier ass. Huelt hien aus dem Grupp eraus oder desaktivéiert de Kont.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "De Benotzer ass nach an de Gruppen {groups}, déi Member vun Teamdossiere sinn. Huelt hien aus de Gruppen eraus oder desaktivéiert de Kont.", + "Vault status": "Tresorstatus", + "Yes": "Jo", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-Export ass NET VERSCHLËSSELT. All Passwuert an all Login sinn am erofgeluedene Fichier als Kloertext ze liesen. Späichert e sécher a läscht en direkt no der Notzung.", + "Root certificate expiring soon": "De Root-Zertifikat leeft geschwënn of", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "De Root-Zertifikat vum Tresor leeft an %1$d Dag/Deeg of. Erneiert en virdrun. Beim Erneieren gëtt all Verschlësselungssuite nei ënnerschriwwen.", + "Compromise recovery aborted": "Erhuelung no Kompromittéierung ofgebrach", + "Key rotation ended by a compromise revoke": "Schlësselrotatioun duerch e Kompromittéierungs-Widderruff ofgeschloss", + "Encryption suite revoke refused": "Widderruff vun der Verschlësselungssuite refuséiert", + "Master password proof refused": "Beweis vum Masterpasswuert refuséiert", + "Your current master password": "Äert aktuellt Masterpasswuert", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n Noutkontakt hat eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun en ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n Noutkontakter haten eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun se ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Dës Noutkontakter goufen net op Ären neie Schlëssel iwwerholl. Hiren Noutzougrëff gouf ewechgeholl. Setzt se nees ënner Noutzougrëff derbäi, wann Dir se nach wëllt.", + "Renew root certificate": "Root-Zertifikat erneieren", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dëst erstellt en neit Root- an Zwëschenzertifikat. All aktiv Verschlësselungssuite gëtt nei ënnerschriwwen. Dat kann net réckgängeg gemaach ginn.", + "Renew root": "Root erneieren", + "Root renewed. {n} encryption suites signed again.": "Root erneiert. {n} Verschlësselungssuitten nei ënnerschriwwen.", + "Could not renew the root certificate.": "D'Root-Zertifikat konnt net erneiert ginn.", + "Lease policy for this application": "Lease-Richtlinn fir dës Applikatioun", + "In force now: {default} seconds by default, {max} seconds at most.": "Elo gëlteg: standardméisseg {default} Sekonnen, héchstens {max} Sekonnen.", + "Leases are not renewable": "Leases kënnen net verlängert ginn", + "Lease policy saved.": "Lease-Richtlinn gespäichert.", + "Leave a field empty to use the instance value.": "Loss e Feld eidel, fir de Wäert vun der Instanz ze benotzen.", + "Instance value: {value}": "Wäert vun der Instanz: {value}", + "Renewal": "Verlängerung", + "Use the instance value ({value})": "Wäert vun der Instanz benotzen ({value})", + "Allowed": "Erlaabt", + "Not allowed": "Net erlaabt", + "Save lease policy": "Lease-Richtlinn späicheren", + "Only an administrator can change this policy.": "Nëmmen en Administrateur kann dës Richtlinn änneren.", + "Could not save the lease policy.": "D'Lease-Richtlinn konnt net gespäichert ginn.", + "{member} got access from {confirmer}.": "{member} krut Zougang vun {confirmer}.", + "Automatically confirm new team folder members": "Nei Memberen vun Teamdossieren automatesch bestätegen", + "Gave %n new member access to a team folder.": "%n neie Member krut Zougang zu engem Teamdossier.", + "Gave %n new members access to a team folder.": "%n nei Memberen kruten Zougang zu engem Teamdossier.", + "Give new team folder members access without waiting for the folder owner.": "Gitt neie Memberen Zougang ouni op de Besëtzer vum Dossier ze waarden.", + "New team folder members": "Nei Memberen vun Teamdossieren", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "De Besëtzer oder e Member mat Schreifrecht bestätegt se aus sengem oppenen Tresor. Keepiq entschlësselt ni um Server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Et gëtt gewaart, bis e Member mat Schreifrecht Keepiq opmécht. Dir kënnt och elo deelen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En Deel vun der Reaktioun op d’Kompromittéierung ass feelgeschloen ({failed} Schrëtt). Kontrolléiert de Serverprotokoll a widderrufft d’Suite dann nach eng Kéier, fir se ofzeschléissen.", + "This also revoked suite {suite} and ended key migration {migration}.": "Domat gouf och d’Suite {suite} widderruff an d’Schlësselmigratioun {migration} ofgeschloss.", + "Revoking the second suite deleted %n emergency-access contact.": "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakt geläscht.", + "Revoking the second suite deleted %n emergency-access contacts.": "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakter geläscht.", + "A suite revoked as compromised cannot be reinstated.": "Eng Suite, déi als kompromittéiert widderruff gouf, kann net erëm hiergestallt ginn.", + "Archives to keep": "Archiven fir ze halen", + "Back up every vault automatically": "All Tresor automatesch sécheren", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Séchert all Tresor no engem Plang. D'Archiven enthalen nëmme verschlësselten Text a ginn mat occ restauréiert.", + "Back up now": "Elo sécheren", + "Backup public key (PEM, optional)": "Ëffentleche Sécherungsschlëssel (PEM, fakultativ)", + "Backup requested for the next cron run": "Sécherung fir den nächste Cron-Laf ugefrot", + "Encrypted": "Verschlësselt", + "Every (hours)": "All (Stonnen)", + "Last backup {when} failed: {error}": "Lescht Sécherung {when} ass feelgeschloen: {error}", + "Last backup {when} succeeded.": "Lescht Sécherung {when} ass gelongen.", + "No archives yet.": "Nach keng Archiven.", + "Size": "Gréisst", + "Vault backups": "Tresorsécherungen", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Mat engem Schlëssel gëtt all Archiv fir hie verschlësselt. Hält de private Schlëssel ausserhalb vun dësem Server: Dir braucht en fir ze kontrolléieren oder ze restauréieren.", + "Written": "Geschriwwen", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n Benotzer am Beräich huet nach keng Zwee-Faktor-Umeldung a kann den Tresor net opmaachen, soulaang dëst un ass.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n Benotzer am Beräich hunn nach keng Zwee-Faktor-Umeldung a kënnen den Tresor net opmaachen, soulaang dëst un ass.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Backup-Coden zielen net. Wann Är Benotzer sech iwwer en Identitéitsubidder mat engem eegene zweete Faktor umellen, loosst hir Gruppen ewech.", + "Block personal vault export": "Export vum perséinlechen Tresor spären", + "Keep work logins in team folders": "Aarbechtsumeldungen an Teamdossieren halen", + "Move to a team folder": "An en Teamdossier réckelen", + "Not in a team folder": "Net an engem Teamdossier", + "Only for these groups (empty is everyone)": "Nëmme fir dës Gruppen (eidel heescht jiddereen)", + "Require two-factor login before the vault opens": "Zwee-Faktor-Umeldung ufroen, ier den Tresor opgeet", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reegele fir all Tresor. All Reegel gëllt fir jiddereen oder nëmme fir déi Gruppen, déi Dir wielt.", + "Secret types that belong in a team folder": "Geheimnistypen, déi an en Teamdossier gehéieren", + "Set up two-factor login": "Zwee-Faktor-Umeldung ariichten", + "Team folder you can write to": "Teamdossier, an deen Dir schreiwe kënnt", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Benotzer kënne keng Sécherung, CSV oder Transferdatei eroflueden. Hire perséinlechen Datepak bleift disponibel.", + "Users cannot save these secret types in a personal folder.": "Benotzer kënnen dës Geheimnistypen net an engem perséinlechen Dossier späicheren.", + "Vault policies": "Tresorreegelen", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Är Organisatioun erlaabt keen Export vun Ärem perséinlechen Tresor. Äre perséinlechen Datepak an den Astellunge bleift disponibel.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Är Organisatioun hält dës Geheimnisser an engem Teamdossier. Réckelt all eenzelt an en Teamdossier.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Är Organisatioun hält dësen Typ vu Geheimnis an engem Teamdossier. Wielt ee vun Ären Teamdossieren oder een, an deen Dir schreiwe kënnt.", + "Your organisation requires two-factor login before you can open your vault.": "Är Organisatioun verlaangt eng Zwee-Faktor-Umeldung, ier Dir Ären Tresor opmaache kënnt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "D'Benotzer wielen, wéi laang d'Erweiderung bei Inaktivitéit entspaart bleift. Dir leet déi längst Zäit fest, déi se wiele kënnen.", + "Longest idle time before the extension locks": "Längst Inaktivitéitszäit, ier d'Erweiderung spaart", + "1 minute": "1 Minutt", + "5 minutes": "5 Minutten", + "15 minutes": "15 Minutten", + "1 hour": "1 Stonn", + "4 hours": "4 Stonnen", + "Connector": "Connecteur", + "Directory (tenant) ID": "Verzeechnis-ID (Mandant)", + "Application (client) ID": "Applikatiouns-ID (Client)", + "Data collection rule immutable ID": "Onverännerlech ID vun der Datesammlungsregel", + "Stream name": "Stream-Numm", + "Splunk index (optional)": "Splunk-Index (fakultativ)", + "Sourcetype (optional)": "Sourcetype (fakultativ)", + "Leave blank to keep the current one": "Eidel loossen, fir den aktuelle Wäert ze halen", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF iwwer Syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datesammlungs-Endpunkt (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Client-Geheimnis (nëmme schreiwen)", + "HEC token (write-only)": "HEC-Token (nëmme schreiwen)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Erlaabten Audit-Evenementer un Splunk, Microsoft Sentinel, en Syslog-Empfänger oder e Webhook weiderginn. Messagen enthalen nëmme gebotzt Metadaten: kee geheime Wäert, Numm, Login oder verschlësselten Text verléisst jee de Server.", + "%n change waiting to sync": "%n Ännerung waart op d'Synchronisatioun", + "%n changes waiting to sync": "%n Ännerunge waarden op d'Synchronisatioun", + "Changes that could not sync": "Ännerungen, déi net synchroniséiert konnte ginn", + "Choose a version": "Versioun wielen", + "Copy value": "Wäert kopéieren", + "Deleted": "Geläscht", + "Discard": "Verwerfen", + "Keep my offline change": "Meng Offline-Ännerung behalen", + "Keep the server version": "D'Serverversioun behalen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq ass offline nëmme liesbar. Är Administratioun huet d'Offline-Beaarbechtung net ageschalt.", + "Let users edit secrets offline": "Benotzer erlaben, Geheimnisser offline ze beaarbechten", + "Not synced yet": "Nach net synchroniséiert", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline-Ännerunge bleiwen um Apparat, fir de Benotzer verschlësselt, a gi beim nächsten Online-Entspären synchroniséiert. Deelen, Ordner an Unhäng brauchen ëmmer nach eng Verbindung.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Beaarbechtungen, Verréckelungen a Läschunge bleiwen op dësem Apparat a gi synchroniséiert, soubal Dir erëm online sidd. Deelen an Unhäng brauchen eng Verbindung.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Är Ännerunge bleiwen op dësem Apparat a gi synchroniséiert, soubal Dir erëm online sidd. Lescht synchroniséiert {when}.", + "Open my changes": "Meng Ännerungen opmaachen", + "Sharing needs a connection": "Deelen brauch eng Verbindung", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Een huet dëst Geheimnis um Server geännert, nodeems Är Offline-Kopie gemaach gouf. Wielt, wéi eng Versioun Dir behaalt.", + "Sync or discard your offline changes before you rotate your keys.": "Synchroniséiert oder verwerft Är Offline-Ännerungen, ier Dir Är Schlësselen erneiert.", + "That password did not open your changes.": "Mat dësem Passwuert konnten Är Ännerungen net opgemaach ginn.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "De Offline-Schnappschoss späichert verschlësselt Geheimnisser (nëmme mat dem Schlëssel opzemaachen, deen aus dem Master-Passwuert vum Benotzer ofgeleet ass, genee wéi um Server) a verschlësselt Nimm, URLen an Ordnernimm am Späicher. Offline-Zougang ass nëmme liesbar, ausser Dir erlaabt hei ënnen d'Offline-Beaarbechtung. Schalt dat fir Apparater aus, déi ni Umeldungsdaten zwëschespäichere däerfen; d'Ausschalte läscht bestoend Cachen beim nächste Lueden.", + "The previous vault copy is gone, so these changes cannot be opened.": "Déi viregt Kopie vum Tresor ass fort, dofir kënnen dës Ännerungen net opgemaach ginn.", + "The server version": "D'Serverversioun", + "This secret changed while you were offline": "Dëst Geheimnis gouf geännert, wärend Dir offline waart", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Dir hutt dëst Geheimnis offline geläscht, mee et gouf zënterhier um Server geännert. Wielt, wéi eng Versioun Dir behaalt.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Är Schlëssele goufen op engem aneren Apparat geännert. Gitt Äert viregt Master-Passwuert an, fir Är Offline-Ännerungen ze synchroniséieren, oder verwerft se.", + "Your offline change": "Är Offline-Ännerung", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n Noutkontakt hat eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun en ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt.", + "%n Noutkontakter haten eng oppen Zougrëffsufro, wéi Är Schlësselrotatioun se ewechgeholl huet. Kuckt, wien gefrot huet, ier Dir iergendeen nees derbäisetzt." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n Element kann net an CXF duergestallt ginn a gëtt iwwersprongen.", + "%n Elementer kënnen net an CXF duergestallt ginn a gi iwwersprongen." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n eeler Versioun gouf verworf, well nëmmen déi rezent Historie mat iwwerdroe ka ginn.", + "%n eeler Versioune goufe verworf, well nëmmen déi rezent Historie mat iwwerdroe ka ginn." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n Geheimniskopie muss nach verschlësselt a gedeelt ginn.", + "%n Geheimniskopie mussen nach verschlësselt a gedeelt ginn." + ], + "_%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 secret could not be decrypted and is not in this export.", + "%n secrets could not be decrypted and are not in this export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n Geheimnis konnt net mat Ärem alen Schlëssel entschlësselt ginn, dofir gouf et net migréiert.", + "%n Geheimnisse konnten net mat Ärem alen Schlëssel entschlësselt ginn, dofir goufe si net migréiert." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n Geheimnis gouf net migréiert.", + "%n Geheimnisse goufen net migréiert." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n Geheimnis ass nach mat Ärem viregen Schlëssel verschlësselt.", + "%n Geheimnisser sinn nach mat Ärem viregen Schlëssel verschlësselt." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n Geheimnis gouf iwwersprongen, well den Nofolger nach keng Kopie huet — setzt den Nofolger an den Dossier a féiert et nach eemol aus.", + "%n Geheimnisser goufen iwwersprongen, well den Nofolger nach keng Kopie huet — setzt den Nofolger an den Dossier a féiert et nach eemol aus." + ], + "_%n secret_::_%n secrets_": [ + "%n Geheimnis", + "%n Geheimnisser" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n Benotzer am Beräich huet nach keng Zwee-Faktor-Umeldung a kann den Tresor net opmaachen, soulaang dëst un ass.", + "%n Benotzer am Beräich hunn nach keng Zwee-Faktor-Umeldung a kënnen den Tresor net opmaachen, soulaang dëst un ass." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Trotzdem ofschléissen an den Zougang zu %n Geheimnis verléieren", + "Trotzdem ofschléissen an den Zougang zu %n Geheimnisser verléieren" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n neie Member krut Zougang zu engem Teamdossier.", + "%n nei Memberen kruten Zougang zu engem Teamdossier." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Schlësselrotatioun ofgeschloss. %n Geheimnis gouf mat Ärem neien Schlëssel nei verschlësselt.", + "Schlësselrotatioun ofgeschloss. %n Geheimnisser goufe mat Ärem neien Schlëssel nei verschlësselt." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakt geläscht.", + "De Widderruff vun der zweeter Suite huet %n Noutzougangskontakter geläscht." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "%n Mol a Lecke gesinn", + "%n Mol a Lecke gesinn" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "mat %n Geheimnis gedeelt", + "mat %n Geheimnisser gedeelt" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Dësen Dossier enthält direkt %n Geheimnis.", + "Dësen Dossier enthält direkt %n Geheimnisser." + ], + "_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._": [ + "Är Schlësselrotatioun huet %n Noutfallkontakt ewechgeholl. Kuckt den Noutfallzougrëff a setzt en nees bäi, wann Dir en nach wëllt.", + "Är Schlësselrotatioun huet %n Noutfallkontakter ewechgeholl. Kuckt den Noutfallzougrëff a setzt se nees bäi, wann Dir se nach wëllt." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n Ännerung waart op d'Synchronisatioun", + "%n Ännerunge waarden op d'Synchronisatioun" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "De Benotzer ass nach am Grupp {groups}, deen Member vun engem Teamdossier ass. Huelt hien aus dem Grupp eraus oder desaktivéiert de Kont.", + "De Benotzer ass nach an de Gruppen {groups}, déi Member vun Teamdossiere sinn. Huelt hien aus de Gruppen eraus oder desaktivéiert de Kont." + ], + "Allow approval from another device": "Fräigab vun engem aneren Apparat erlaben", + "App": "App", + "Approve a new device": "En neien Apparat fräiginn", + "Approve from another device": "Vun engem aneren Apparat fräiginn", + "Asked at": "Ugefrot um", + "Check that the new device shows these words:": "Kuckt no, ob den neien Apparat dës Wierder weist:", + "Denied. If you did not ask, end your other sessions:": "Refuséiert. Wann Dir dat net ugefrot hutt, beent Är aner Sessiounen:", + "Device": "Apparat", + "IP address": "IP-Adress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Benotzer kënnen en neie Browser entspären, andeems se en op engem Apparat fräiginn, op deem Keepiq schonn entspaart ass.", + "New device approval": "Fräigab vun neien Apparater", + "Nextcloud security settings": "Nextcloud-Sécherheetsastellungen", + "Only approve a device you are using right now.": "Gitt nëmmen en Apparat fräi, deen Dir grad elo benotzt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Maacht Keepiq op engem Apparat op, op deem et entspaart ass, a gitt dësen Apparat fräi. Kuckt no, ob et déiselwecht Wierder weist:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den Apparat, deen fräigëtt, versigelt den Entspärschlëssel fir den neien Apparat. De Server gëtt en nëmme weider a kann en net opmaachen.", + "The master password is not right, or the request has ended.": "D'Master-Passwuert ass net richteg, oder d'Ufro ass eriwwer.", + "The request expired. Ask again or use your master password.": "D'Ufro ass ofgelaf. Frot nach eng Kéier oder benotzt Äert Master-Passwuert.", + "The request was denied.": "D'Ufro gouf refuséiert.", + "Too many requests. Try again in an hour or use your master password.": "Ze vill Ufroen. Probéiert et an enger Stonn nach eng Kéier oder benotzt Äert Master-Passwuert.", + "Unknown device": "Onbekannten Apparat", + "Web app": "Web-App", + "A device": "En Apparat", + "A new device asks to open your vault": "En neien Apparat freet, Äre Coffre opzemaachen", + "%s asks to be approved. Only approve a device you are using right now.": "%s freet ëm Fräigab. Gitt nëmmen en Apparat fräi, deen Dir grad elo benotzt.", + "Access ends on (optional)": "Den Zougang endet den (optional)", + "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.": "D'Apps vu Keepiq weisen a kopéieren d'Passwuert net. Een mat technesche Kenntnisser kann et awer ëmmer nach op sengem eegenen Apparat liesen. Ännert et, wann den Zougang ofleeft.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dëst Geheimnis ass nëmme fir ze benotzen. Mellt Iech iwwer d'Keepiq-Browserextensioun un.", + "Until {date}": "Bis den {date}", + "Use only": "Nëmme benotzen", + "Use only (can sign in, cannot view or copy)": "Nëmme benotzen (kann sech umellen, kann et net gesinn oder kopéieren)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Dir kënnt Iech mat dësem Login iwwer d'Keepiq-Browserextensioun umellen. De Besëtzer huet decidéiert, datt Dir en net gesitt oder kopéiere kënnt.", + "Your access ends on {date}": "Ären Zougang endet den {date}", + "Your access to this secret has ended": "Ären Zougang zu dësem Geheimnis ass ofgelaf", + "Your access to \"%s\" ends tomorrow": "Ären Zougang zu „%s“ leeft muer of", + "Your access to \"%s\" has ended": "Ären Zougang zu „%s“ ass ofgelaf", + "%1$s no longer has access to \"%2$s\"": "%1$s huet keen Zougang méi zu „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s konnt dëst Passwuert gesinn. Ännert et, wann %1$s et net méi kenne soll.", + "%s could not view this password in Keepiq.": "%s konnt dëst Passwuert a Keepiq net gesinn.", + "{approvals} of {threshold} approvals": "{approvals} vun {threshold} Fräigaben", + "a recovery officer": "eng Persoun fir d'Erëmhierstellung", + "Account recovery": "Kont-Erëmhierstellung", + "Approvals needed": "Néideg Fräigaben", + "Ask {user} which words they see, by phone or in person. They must be:": "Frot {user} um Telefon oder perséinlech, wéi eng Wierder ugewise ginn. Et mussen dës sinn:", + "Check again": "Nach eng Kéier kontrolléieren", + "Create the recovery key": "Erëmhierstellungsschlëssel erstellen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Erstellt den Erëmhierstellungsschlëssel. Äre Browser mécht en a gëtt all Beoptraagter eng Kopie, déi nëmmen si opmaache kann.", + "Decline": "Refuséieren", + "Enrol in account recovery": "Fir d'Kont-Erëmhierstellung umellen", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Mellt Iech un, fir datt Är Organisatioun Iech hëllefe kann, Ären Tresor zeréckzekréien, wann Dir Äert Master-Passwuert vergiesst.", + "Every user is enrolled": "All Benotzer sinn ugemellt", + "Finish the recovery in the browser you asked from.": "Schléisst d'Erëmhierstellung an dem Browser of, aus deem Dir se ugefrot hutt.", + "Forgot your master password?": "Master-Passwuert vergiess?", + "Hand the key over": "Schlëssel iwwerginn", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Loosst Benotzer, déi hiert Master-Passwuert vergiess hunn, hiren Tresor zeréckkréien, fräiginn duerch Beoptraagter, déi Dir nennt.", + "New master password": "Neit Master-Passwuert", + "No one is asking to recover their account.": "Keen freet no der Erëmhierstellung vu sengem Kont.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nach keen Erëmhierstellungsschlëssel. Ee vun de Beoptraagten erstellt en a sengen Keepiq-Astellungen.", + "Off": "Aus", + "Officer {user} has no encryption set up yet.": "De Beoptraagten {user} huet nach keng Verschlësselung ageriicht.", + "Officers (user IDs, separated by commas)": "Beoptraagter (Benotzer-IDen, mat Kommaen getrennt)", + "Policy": "Richtlinn", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publizéiert dësen Fangerofdrock intern, fir datt d'Benotzer e virum Umellen iwwerpréiwe kënnen.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Mat der Hëllef vu {officer} erëmhiergestallt. Ännert elo Ären Tresorschlëssel ënner Astellungen, Sécherheet: \"Mäi Master-Passwuert gouf kompromittéiert\".", + "Recovery key fingerprint: {fingerprint}": "Fangerofdrock vum Erëmhierstellungsschlëssel: {fingerprint}", + "Recovery officer": "Beoptraagten fir d'Erëmhierstellung", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Ewechgeholl Beoptraagter verléieren elo hir Kopie, kéinten se awer virdru opgemaach hunn. Loosst e Beoptraagten en neien Erëmhierstellungsschlëssel erstellen.", + "Repeat the new master password": "Neit Master-Passwuert widderhuelen", + "Retire this recovery key": "Dësen Erëmhierstellungsschlëssel ausser Betrib huelen", + "Set the new master password": "Neit Master-Passwuert setzen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "D'Erëmhierstellungszertifikat gouf net vun dësem Keepiq ausgestallt. Mellt Iech net un a sot Ärem Administrateur Bescheed.", + "The words match, approve": "D'Wierder passen, fräiginn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dëse Benotzer ass fir d'Kont-Erëmhierstellung ugemellt. Erëmhierstellen behält seng Geheimnisser; Zréckzéien läscht seng Umeldung.", + "Users may enrol": "Benotzer däerfen sech umellen", + "Withdraw from account recovery": "Vun der Kont-Erëmhierstellung ofmellen", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Dir sidd fir d'Kont-Erëmhierstellung ugemellt. Fangerofdrock vum Erëmhierstellungsschlëssel: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Dir sidd ugemellt. Wann Dir Äert Master-Passwuert vergiesst, kann Är Organisatioun Iech hëllefen, Ären Tresor zeréckzekréien.", + "Your key is back. Choose a new master password.": "Äre Schlëssel ass zeréck. Wielt en neit Master-Passwuert.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Är Beoptraagter fir d'Erëmhierstellung goufen informéiert. Liest hinnen dës Wierder vir, wann se Iech uruffen oder treffen:", + "You are now an account recovery officer": "Dir sidd elo Beoptraagten fir d'Kont-Erëmhierstellung", + "%s asks to recover their account. Compare the words with them before you approve.": "%s freet no der Erëmhierstellung vum Kont. Vergläicht d'Wierder mat der Persoun, ier Dir fräigitt.", + "A user": "E Benotzer", + "Your account recovery request was declined": "Är Ufro fir d'Kont-Erëmhierstellung gouf refuséiert", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Är Kont-Erëmhierstellung ass prett. Maacht Keepiq an dem Browser op, aus deem Dir se ugefrot hutt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} freet, en neien Apparat eemol z'entspären. D'Master-Passwuert bleift d'selwecht.", + "Ask your organisation instead": "Frot amplaz Är Organisatioun", + "The request ended. Ask again or use your master password.": "D'Ufro ass eriwwer. Frot nach eng Kéier oder benotzt Äert Master-Passwuert.", + "Added by {user}": "Bäigesat vun {user}", + "Editor": "Editeur", + "Manager": "Manager", + "Role of {member}": "Roll vun {member}", + "Team folders you manage": "Teamdossieren, déi Dir verwalt", + "Viewer": "Lieser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Dir hutt keng Kopie vun dëse Geheimnisser, dofir hunn déi nei Memberen se nach net kritt. De Besëtzer kann se deelen: {names}", + "Admin areas": "Verwaltungsberäicher", + "Give a group only the parts of Keepiq administration it needs.": "Gitt engem Grupp nëmmen déi Deeler vun der Keepiq-Verwaltung, déi e brauch.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegéiert een oder méi Beräicher un e Grupp op der Säit vun de Verwaltungsrechter. Instanzadministrateure hunn all Beräich.", + "Open administration privileges": "Verwaltungsrechter opmaachen", + "Policies": "Richtlinnen", + "Applications and machine access": "Applikatiounen a Maschinnenzougang", + "People and offboarding": "Leit an Austrëtt", + "Audit and compliance": "Audit a Konformitéit", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "Versioun, Zertifizéierungsstell, Unhäng, Offline-Cache, Leckkontroll, Geheimnistypen a Sécherungskopien", + "master password, organisation password, vault policies, rotation, version history and trash": "Masterpasswuert, Organisatiounspasswuert, Tresorrichtlinnen, Rotatioun, Versiounsverlaf a Poubelle", + "application queue, application requests and machine leases": "Applikatiounsschlaang, Applikatiounsufroen a Maschinnen-Leasen", + "team offboarding, encryption suites and admin handover": "Team-Austrëtt, Verschlësselungssuiten an Iwwerhuele vum Administrateur", + "audit log, compliance reports, SIEM export and honey alerts": "Auditprotokoll, Konformitéitsberichter, SIEM-Export an Hunneg-Alarmer", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Wéi vill Versioune vun engem Geheimnis wéi laang gehale ginn, a wéi laang geläschte Geheimnisser an der Poubelle bleiwen.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenze fir verschlësselt Unhäng, um Server a gespäicherte verschlësselte Bytes duerchgesat.", + "Type the suite ID again to confirm": "Gitt d'Suite-ID nach eng Kéier an fir ze confirméieren", + "This does not match the suite ID.": "Dat entsprécht net der Suite-ID.", + "Confirm with your master password": "Mat Ärem Masterpasswuert confirméieren", + "Confirm": "Confirméieren", + "That master password is not right.": "Dëst Masterpasswuert ass net richteg.", + "You are sharing with someone new. Enter your master password to confirm.": "Dir deelt mat enger neier Persoun. Gitt Äert Masterpasswuert an fir ze confirméieren.", + "Enter your master password to confirm this share.": "Gitt Äert Masterpasswuert an fir dës Deelung ze confirméieren.", + "Enter your master password to confirm this delegation.": "Gitt Äert Masterpasswuert an fir dës Delegatioun ze confirméieren.", + "Approve {member}": "{member} guttheeschen", + "Recipient": "Empfänger", + "No vault yet": "Nach keen Tresor", + "No matching users": "Keng passend Benotzer", + "Partner organisations": "Partnerorganisatiounen", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Tauscht Geheimnisser mat engem anere Keepiq aus. Béid Administrateure fügen sech géigesäiteg derbäi a vergläichen d’Root-Fangerofdréck um Telefon oder perséinlech, ier se späicheren.", + "Federation needs Nextcloud 33 or later.": "Föderatioun brauch Nextcloud 33 oder méi nei.", + "Your root fingerprint": "Ären Root-Fangerofdrock", + "No partners yet.": "Nach keng Partner.", + "Users here may share to this partner": "Benotzer hei däerfe mat dësem Partner deelen", + "This partner may share to users here": "Dëse Partner däerf mat Benotzer hei deelen", + "Partner address": "Adress vum Partner", + "Check partner": "Partner iwwerpréiwen", + "Partner root fingerprint": "Root-Fangerofdrock vum Partner", + "I compared this fingerprint with the partner's administrator": "Ech hunn dëse Fangerofdrock mam Administrateur vum Partner verglach", + "Add partner": "Partner derbäisetzen", + "A secret from another organisation": "E Geheimnis vun enger anerer Organisatioun", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s huet \"%2$s\" mat Iech gedeelt. Acceptéiert et ënner Erakommend vun anere Organisatiounen.", + "Incoming from other organisations": "Erakommend vun anere Organisatiounen", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Leit a Partnerorganisatiounen kënnen e Geheimnis mat Iech deelen. Acceptéiert et, fir eng schreifgeschützt Kopie an Ärem Tresor ze halen.", + "Nothing shared with you yet": "Nach näischt mat Iech gedeelt", + "Secrets that people in partner organisations share with you appear here.": "Geheimnisser, déi Leit a Partnerorganisatiounen mat Iech deelen, erschéngen hei.", + "From {sender}": "Vun {sender}", + "Accept": "Acceptéieren", + "Open in vault": "Am Tresor opmaachen", + "The other organisation did not hand over the secret. Try again later.": "Déi aner Organisatioun huet d’Geheimnis net iwwerginn. Probéiert et méi spéit nach eng Kéier.", + "Set up your vault before you accept a shared secret.": "Riicht Ären Tresor an, ier Dir e gedeelt Geheimnis acceptéiert.", + "Something went wrong. Try again.": "Eppes ass schifgaang. Probéiert et nach eng Kéier.", + "Waiting for your answer": "Waart op Är Äntwert", + "In your vault, read-only": "An Ärem Tresor, nëmme liesen", + "Withdrawn by the sender": "Vum Ofsender zréckgezunn", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} huet dëst vun enger anerer Organisatioun gedeelt. Dir kënnt et liesen, mee net änneren oder deelen.", + "Someone": "Iergendeen", + "Share with someone at another organisation": "Mat engem vun enger anerer Organisatioun deelen", + "Their account at the other organisation": "D’Konto vun der Persoun bei der anerer Organisatioun", + "Check account": "Konto préiwen", + "Certificate fingerprint of {account}": "Fangerofdrock vum Zertifikat vun {account}", + "Compare it with them by phone if you want to be sure.": "Vergläicht en mat der Persoun um Telefon, wann Dir sécher wëllt sinn.", + "Shared. {account} can accept it in their own vault.": "Gedeelt. {account} kann et am eegenen Tresor acceptéieren.", + "The certificate could not be verified. Nothing was shared.": "D’Zertifikat konnt net verifizéiert ginn. Et gouf näischt gedeelt.", + "That organisation is not one of your partners.": "Déi Organisatioun ass keen vun Äre Partner.", + "No one with that account can receive secrets from you.": "Keen mat deem Konto ka Geheimnisser vun Iech kréien.", + "The other organisation did not answer. Try again later.": "Déi aner Organisatioun huet net geäntwert. Probéiert et méi spéit nach eng Kéier.", + "This secret is already shared with that account.": "Dëst Geheimnis ass schonn mat deem Konto gedeelt.", + "Other organisations": "Aner Organisatiounen", + "Receive secrets from other organisations": "Geheimnisser vun anere Organisatiounen kréien", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Leit a Partnerorganisatiounen kënnen dann Äert Konto fannen a Geheimnisser mat Iech deelen. Dir acceptéiert all eenzelt selwer.", + "Shared": "Gedeelt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pauséiert: hiert Zertifikat oder de Partenariat huet geännert. Zitt et zréck oder deelt et nach eng Kéier.", + "Their organisation did not get the last change. Revoke it or share again.": "Hir Organisatioun huet déi lescht Ännerung net kritt. Zitt et zréck oder deelt et nach eng Kéier.", + "Being withdrawn": "Gëtt zréckgezunn", + "Shared with another organisation": "Mat enger anerer Organisatioun gedeelt", + "Change sent to another organisation": "Ännerung un eng aner Organisatioun geschéckt", + "Share with another organisation revoked": "Deelen mat enger anerer Organisatioun zréckgezunn", + "Share with another organisation paused": "Deelen mat enger anerer Organisatioun pauséiert", + "Another organisation did not get a change": "Eng aner Organisatioun huet eng Ännerung net kritt", + "Secret received from another organisation": "Geheimnis vun enger anerer Organisatioun kritt", + "Secret from another organisation accepted": "Geheimnis vun enger anerer Organisatioun acceptéiert", + "Secret from another organisation declined": "Geheimnis vun enger anerer Organisatioun refuséiert", + "Copy from another organisation updated": "Kopie vun enger anerer Organisatioun aktualiséiert", + "Copy from another organisation removed": "Kopie vun enger anerer Organisatioun ewechgeholl", + "Declined: they removed their copy. Share again if they need it.": "Refuséiert: den Empfänger huet seng Kopie ewechgeholl. Deelt et nach eng Kéier, wann hien se brauch.", + "Recipient at another organisation removed their copy": "Empfänger vun enger anerer Organisatioun huet seng Kopie ewechgeholl", + "Removed the user from %n team folder.": "De Benotzer gouf aus %n Teamuerdner ewechgeholl.", + "Removed the user from %n team folders.": "De Benotzer gouf aus %n Teamuerdner ewechgeholl.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "De Benotzer gouf aus %n Teamuerdner ewechgeholl.", + "De Benotzer gouf aus %n Teamuerdner ewechgeholl." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Eng restauréiert Kopie kënnt vun enger Deelung, déi eriwwer ass. Si bleift nëmmen ze liesen.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "D’Organisatioun, déi eng restauréiert Kopie gedeelt huet, ass net z’erreechen. D’Kopie bleift nëmmen ze liesen a follegt hiren Ännerungen net.", + "Recipient at another organisation restored their copy": "Empfänger vun enger anerer Organisatioun huet seng Kopie restauréiert" }, "plurals": null } diff --git a/l10n/lt.js b/l10n/lt.js index 7ffe59c07..e0d1e5753 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Bendrinama su grupėmis", + "Not shared with any group yet.": "Dar nebendrinama su jokia grupe.", + "Revoke the share with {group}": "Atšaukti bendrinimą su {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Bendrinama su {group}: {received} nariai tai gavo, {skipped} negavo, nes dar nenustatė šifravimo.", + "Search groups": "Ieškoti grupių", + "Failed to share": "Nepavyko bendrinti", + "Columns": "Stulpeliai", + "Column {number}": "Stulpelis {number}", + "Map one column to Name. Every secret needs a name.": "Susiekite vieną stulpelį su pavadinimu. Kiekviena paslaptis turi turėti pavadinimą.", + "Notes": "Pastabos", + "Do not import": "Neimportuoti", + "Hide this value": "Slėpti šią reikšmę", + "Show this value": "Rodyti šią reikšmę", + "Defaults": "Numatytieji", + "New secrets start as this type, and your secret list opens in this view.": "Nauji slaptažodžiai prasideda šiuo tipu, o tavo slaptažodžių sąrašas atsidaro šiame rodinyje.", + "Default item type": "Numatytasis elemento tipas", + "Cards": "Kortelės", + "Table": "Lentelė", + "Could not save your default": "Nepavyko išsaugoti numatytosios reikšmės", + "Recently used": "Neseniai naudoti", + "Opened": "Atidaryta", + "You have not opened any secrets yet": "Dar neatidarei jokių slaptažodžių", + "Could not delete the item type.": "Nepavyko ištrinti elemento tipo.", + "Could not load the item types.": "Nepavyko įkelti elementų tipų.", + "Could not save the item type.": "Nepavyko išsaugoti elemento tipo.", + "Delete item type": "Ištrinti elemento tipą", + "Edit item type": "Redaguoti elemento tipą", + "Fields": "Laukai", + "Fields: {count}": "Laukai: {count}", + "Hidden": "Paslėptas", + "Item types": "Elementų tipai", + "Move up": "Perkelti aukštyn", + "New item type": "Naujas elemento tipas", + "No item types defined yet.": "Elementų tipų dar neapibrėžta.", + "Required": "Privalomas", + "Text": "Tekstas", + "This field is required": "Šis laukas privalomas", + "Web address": "Žiniatinklio adresas", + "{label} (required)": "{label} (privalomas)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ištrinti „{name}“? Šio tipo paslaptys lieka skaitomos ir tampa Prisijungimo elementais.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Čia apibrėžti elementų tipai visiems rodomi lange Nauja paslaptis su jūsų pasirinktais laukais.", + "Secret moved to the trash": "Paslaptis perkelta į šiukšlinę", + "Secret restored from the trash": "Paslaptis atkurta iš šiukšlinės", + "Secret deleted for good": "Paslaptis ištrinta visam laikui", + "Secret archived": "Paslaptis suarchyvuota", + "Secret unarchived": "Paslaptis išimta iš archyvo", + "Unarchive": "Išimti iš archyvo", + "Could not archive the secret": "Nepavyko suarchyvuoti paslapties", + "Could not unarchive the secret": "Nepavyko išimti paslapties iš archyvo", + "Archive {count} secrets": "Suarchyvuoti paslaptis: {count}", + "Unarchive {count} secrets": "Išimti iš archyvo paslaptis: {count}", + "Restore {count} secrets": "Atkurti paslaptis: {count}", + "Delete {count} secrets for good": "Ištrinti visam laikui paslaptis: {count}", + "Done for {ok} of {total} secrets": "Atlikta {ok} iš {total} paslapčių", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Suarchyvuotos paslaptys dingsta iš saugyklos sąrašo, paieškos, automatinio pildymo ir būklės ataskaitos. Jų bendrinimai išlieka. Jas rasite skiltyje Archyvas.", + "These secrets come back to the vault list, search and autofill.": "Šios paslaptys grįžta į saugyklos sąrašą, paiešką ir automatinį pildymą.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Šios paslaptys grįžta į saugyklos sąrašą. Senieji bendrinimai negrįžta, todėl prireikus pasidalykite jomis iš naujo.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tai ištrins paslaptis kartu su priedais ir versijų istorija. To atšaukti negalima.", + "Delete for good": "Ištrinti visam laikui", + "Trash": "Šiukšlinė", + "The trash is empty": "Šiukšlinė tuščia", + "No archived secrets": "Suarchyvuotų paslapčių nėra", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Ištrintos paslaptys laukia čia, kol baigsis saugojimo laikotarpis, tada jos ištrinamos visam laikui.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Suarchyvuokite paslaptį jos informacijos skydelyje, kad ji nebūtų saugyklos sąraše, paieškoje ir automatiniame pildyme.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Šifruotų priedų ribos (taikomos serveryje saugomiems šifruotiems baitams), versijų istorijos saugojimas ir kiek laiko ištrintos paslaptys lieka šiukšlinėje.", + "Days a deleted secret stays in the trash (1 to 365)": "Dienos, kiek ištrinta paslaptis lieka šiukšlinėje (nuo 1 iki 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tai perkelia paslaptį į šiukšlinę ir iš karto nutraukia jos bendrinimus. Ją galite atkurti iš šiukšlinės, kol baigsis saugojimo laikotarpis: 30 dienų, nebent administratorius jį pakeitė.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tai perkelia paslaptis į šiukšlinę ({count}) ir iš karto nutraukia jų bendrinimus. Jas galite atkurti iš šiukšlinės, kol baigsis saugojimo laikotarpis.", + "Remove {name} from favourites": "Pašalinti {name} iš mėgstamų", + "Add {name} to favourites": "Pridėti {name} prie mėgstamų", + "Could not change the favourite": "Nepavyko pakeisti mėgstamo", + "Remove from favourites": "Pašalinti iš mėgstamų", + "Add to favourites": "Pridėti prie mėgstamų", + "Tags": "Žymės", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Žymės nėra šifruojamos. Serverio administratoriai gali jas skaityti, kaip ir aplankų pavadinimus.", + "Favourites": "Mėgstami", + "Filter by tag": "Filtruoti pagal žymę", + "All tags": "Visos žymės", + "Last used": "Paskutinį kartą naudota", + "Tags for {count} secrets": "Žymės {count} paslaptims", + "Tag": "Žymė", + "Remove tag": "Pašalinti žymę", + "Add tag": "Pridėti žymę", + "Could not change the tags. Try again.": "Nepavyko pakeisti žymių. Bandykite dar kartą.", + "Could not approve the application. It is still in the queue.": "Nepavyko patvirtinti paraiškos. Ji vis dar eilėje.", + "Could not reject the application. It is still in the queue.": "Nepavyko atmesti paraiškos. Ji vis dar eilėje.", + "Removed the user from {count} team folders.": "Naudotojas pašalintas iš {count} komandos aplankų.", + "Approve a share": "Patvirtinti bendrinimą", + "This approval link is incomplete. Open it again from the notification.": "Ši patvirtinimo nuoroda neišsami. Atverkite ją dar kartą iš pranešimo.", + "Deny": "Atmesti", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} prisijungė prie grupės, su kuria bendrinate paslaptį. Bendrinti paslaptį ir su juo?", + "{requester} asks you to share a secret with {user}.": "{requester} prašo jūsų bendrinti paslaptį su {user}.", + "Shared. The recipient can now open the secret.": "Bendrinama. Gavėjas dabar gali atverti paslaptį.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Gavėjas dar nenustatė Keepiq, todėl niekas nebuvo bendrinta. Bandykite dar kartą, kai jis tai padarys.", + "Could not share the secret. Only its owner can approve this.": "Nepavyko bendrinti paslapties. Tai gali patvirtinti tik jos savininkas.", + "Could not share the secret. Try again.": "Nepavyko bendrinti paslapties. Bandykite dar kartą.", + "Denied. Nothing was shared.": "Atmesta. Niekas nebuvo bendrinta.", + "Could not deny the request. Try again.": "Nepavyko atmesti užklausos. Bandykite dar kartą.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s prašo jūsų bendrinti paslaptį \"%2$s\" su %3$s.", + "Expires on (optional)": "Galioja iki (neprivaloma)", + "Hand over to": "Perduoti", + "Choose a recipient": "Pasirinkite gavėją", + "Hand over temporarily": "Perduoti laikinai", + "Expiry rules": "Galiojimo taisyklės", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nustatykite, kiek laiko gali galioti vieno elemento tipo ar vieno aplanko slaptažodžiai ir kada jums priminti. Kai taikomos kelios datos, galioja ankstyviausia.", + "Delete rule": "Ištrinti taisyklę", + "Set by your administrator": "Nustatė jūsų administratorius", + "No expiry rules yet.": "Galiojimo taisyklių dar nėra.", + "Applies to": "Taikoma", + "Item type": "Elemento tipas", + "Maximum age in days (empty for reminders only)": "Didžiausias amžius dienomis (tuščia, jei tik priminimai)", + "Remind me this many days before, comma separated": "Priminti tiek dienų prieš, atskirti kableliais", + "Save rule": "Įrašyti taisyklę", + "An item type": "Elemento tipas", + "A folder": "Aplankas", + "Folder {name}": "Aplankas {name}", + "Type {name}": "Tipas {name}", + "Expires after {days} days": "Baigia galioti po {days} d.", + "Reminders {days} days before": "Priminimai likus {days} d.", + "Could not save the expiry rule.": "Nepavyko įrašyti galiojimo taisyklės.", + "Could not delete the expiry rule.": "Nepavyko ištrinti galiojimo taisyklės.", + "All statuses": "Visos būsenos", + "Compromised": "Pažeistas", + "Could not load the members.": "Nepavyko įkelti narių.", + "Emergency contact": "Avarinis kontaktas", + "Leaving user": "Išeinantis naudotojas", + "No": "Ne", + "No users match this filter.": "Joks naudotojas neatitinka šio filtro.", + "Not set up": "Nenustatyta", + "Revoke suite": "Atšaukti rinkinį", + "Revoked": "Atšauktas", + "Search users": "Ieškoti naudotojų", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pažiūrėkite, kurie naudotojai nustatė saugyklą. Pradėkite išėjimą arba atšaukite rinkinį iš eilutės.", + "Successor": "Įpėdinis", + "Team folders": "Komandos aplankai", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Naudotojas vis dar yra grupėje {groups}, kuri yra komandos aplanko narė. Pašalinkite jį iš grupės arba išjunkite paskyrą.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą.", + "Vault status": "Saugyklos būsena", + "Yes": "Taip", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF eksportas NĖRA ŠIFRUOTAS. Kiekvienas slaptažodis ir prisijungimo vardas atsisiųstame faile bus perskaitomi kaip paprastas tekstas. Saugokite jį saugiai ir iškart po naudojimo ištrinkite.", + "Root certificate expiring soon": "Šakninio sertifikato galiojimas netrukus baigsis", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Saugyklos šakninio sertifikato galiojimas baigsis po %1$d d. Atnaujinkite jį iki tol. Atnaujinant iš naujo pasirašomas kiekvienas šifravimo rinkinys.", + "Compromise recovery aborted": "Atkūrimas po kompromitavimo nutrauktas", + "Key rotation ended by a compromise revoke": "Rakto keitimą užbaigė atšaukimas dėl kompromitavimo", + "Encryption suite revoke refused": "Šifravimo rinkinio atšaukimas atmestas", + "Master password proof refused": "Pagrindinio slaptažodžio įrodymas atmestas", + "Your current master password": "Jūsų dabartinis pagrindinis slaptažodis", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n skubios pagalbos kontaktas turėjo laukiančią prieigos užklausą, kai rakto keitimas jį pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Šie skubios pagalbos kontaktai nebuvo perkelti į jūsų naują raktą. Jų skubi prieiga pašalinta. Vėl pridėkite juos skiltyje Skubi prieiga, jei jų vis dar norite.", + "Renew root certificate": "Atnaujinti šakninį sertifikatą", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Sukuriamas naujas šakninis ir tarpinis sertifikatas. Kiekvienas aktyvus šifravimo rinkinys pasirašomas iš naujo. To negalima atšaukti.", + "Renew root": "Atnaujinti šaknį", + "Root renewed. {n} encryption suites signed again.": "Šaknis atnaujinta. Iš naujo pasirašytų šifravimo rinkinių: {n}.", + "Could not renew the root certificate.": "Nepavyko atnaujinti šakninio sertifikato.", + "Lease policy for this application": "Šios programos nuomos politika", + "In force now: {default} seconds by default, {max} seconds at most.": "Dabar galioja: numatytai {default} sekundžių, daugiausia {max} sekundžių.", + "Leases are not renewable": "Nuomos negalima pratęsti", + "Lease policy saved.": "Nuomos politika išsaugota.", + "Leave a field empty to use the instance value.": "Palikite lauką tuščią, kad būtų naudojama egzemplioriaus reikšmė.", + "Instance value: {value}": "Egzemplioriaus reikšmė: {value}", + "Renewal": "Pratęsimas", + "Use the instance value ({value})": "Naudoti egzemplioriaus reikšmę ({value})", + "Allowed": "Leidžiama", + "Not allowed": "Neleidžiama", + "Save lease policy": "Išsaugoti nuomos politiką", + "Only an administrator can change this policy.": "Šią politiką gali pakeisti tik administratorius.", + "Could not save the lease policy.": "Nepavyko išsaugoti nuomos politikos.", + "{member} got access from {confirmer}.": "{member} gavo prieigą iš {confirmer}.", + "Automatically confirm new team folder members": "Automatiškai patvirtinti naujus komandos aplankų narius", + "Gave %n new member access to a team folder.": "%n naujas narys gavo prieigą prie komandos aplanko.", + "Gave %n new members access to a team folder.": "Nauji nariai (%n) gavo prieigą prie komandos aplanko.", + "Give new team folder members access without waiting for the folder owner.": "Suteikite prieigą naujiems nariams nelaukdami aplanko savininko.", + "New team folder members": "Nauji komandos aplankų nariai", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Savininkas arba narys su rašymo teise juos patvirtina iš atidarytos saugyklos. Keepiq niekada neiššifruoja serveryje.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Laukiama, kol narys su rašymo teise atidarys Keepiq. Taip pat galite bendrinti dabar.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dalis reakcijos į kompromitavimą nepavyko ({failed} žingsnis(-iai)). Patikrinkite serverio žurnalą ir vėl atšaukite rinkinį, kad ją užbaigtumėte.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tai taip pat atšaukė rinkinį {suite} ir užbaigė raktų perkėlimą {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktą.", + "Revoking the second suite deleted %n emergency-access contacts.": "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų.", + "A suite revoked as compromised cannot be reinstated.": "Rinkinio, atšaukto kaip kompromituoto, atkurti negalima.", + "Archives to keep": "Saugomi archyvai", + "Back up every vault automatically": "Automatiškai daryti kiekvienos saugyklos kopiją", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Darykite kiekvienos saugyklos kopiją pagal tvarkaraštį. Archyvuose yra tik šifruotas tekstas, jie atkuriami su occ.", + "Back up now": "Daryti kopiją dabar", + "Backup public key (PEM, optional)": "Kopijos viešasis raktas (PEM, neprivaloma)", + "Backup requested for the next cron run": "Kopija užsakyta kitam cron paleidimui", + "Encrypted": "Šifruota", + "Every (hours)": "Kas (valandų)", + "Last backup {when} failed: {error}": "Paskutinė kopija {when} nepavyko: {error}", + "Last backup {when} succeeded.": "Paskutinė kopija {when} pavyko.", + "No archives yet.": "Archyvų dar nėra.", + "Size": "Dydis", + "Vault backups": "Saugyklos kopijos", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Su raktu kiekvienas archyvas šifruojamas jam. Laikykite privatųjį raktą už šio serverio ribų: jo reikia tikrinimui ar atkūrimui.", + "Written": "Įrašyta", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n naudotojas taikymo srityje dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Atsarginiai kodai neskaičiuojami. Jei jūsų naudotojai jungiasi per tapatybės teikėją su savo antru veiksniu, praleiskite jų grupes.", + "Block personal vault export": "Blokuoti asmeninės saugyklos eksportą", + "Keep work logins in team folders": "Laikyti darbo prisijungimus komandos aplankuose", + "Move to a team folder": "Perkelti į komandos aplanką", + "Not in a team folder": "Ne komandos aplanke", + "Only for these groups (empty is everyone)": "Tik šioms grupėms (tuščia reiškia visus)", + "Require two-factor login before the vault opens": "Reikalauti dviejų veiksnių prisijungimo prieš atidarant saugyklą", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Taisyklės kiekvienai saugyklai. Kiekviena taikoma visiems arba tik pasirinktoms grupėms.", + "Secret types that belong in a team folder": "Paslapčių tipai, kurie priklauso komandos aplankui", + "Set up two-factor login": "Nustatyti dviejų veiksnių prisijungimą", + "Team folder you can write to": "Komandos aplankas, į kurį galite rašyti", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Naudotojai negali atsisiųsti atsarginės kopijos, CSV ar perkėlimo failo. Jų asmens duomenų paketas lieka pasiekiamas.", + "Users cannot save these secret types in a personal folder.": "Naudotojai negali saugoti šių paslapčių tipų asmeniniame aplanke.", + "Vault policies": "Saugyklos taisyklės", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Jūsų organizacija neleidžia eksportuoti jūsų asmeninės saugyklos. Jūsų asmens duomenų paketas nustatymuose lieka pasiekiamas.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Jūsų organizacija laiko šias paslaptis komandos aplanke. Perkelkite kiekvieną į komandos aplanką.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Jūsų organizacija laiko šio tipo paslaptį komandos aplanke. Pasirinkite vieną iš savo komandos aplankų arba tokį, į kurį galite rašyti.", + "Your organisation requires two-factor login before you can open your vault.": "Jūsų organizacija reikalauja dviejų veiksnių prisijungimo, kad galėtumėte atidaryti saugyklą.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Naudotojai pasirenka, kiek laiko plėtinys lieka atrakintas neveikimo metu. Jūs nustatote ilgiausią laiką, kurį jie gali pasirinkti.", + "Longest idle time before the extension locks": "Ilgiausias neveikimo laikas, kol plėtinys užrakinamas", + "1 minute": "1 minutė", + "5 minutes": "5 minutės", + "15 minutes": "15 minučių", + "1 hour": "1 valanda", + "4 hours": "4 valandos", + "Connector": "Jungtis", + "Directory (tenant) ID": "Katalogo (nuomininko) ID", + "Application (client) ID": "Programos (kliento) ID", + "Data collection rule immutable ID": "Duomenų rinkimo taisyklės nekintamas ID", + "Stream name": "Srauto pavadinimas", + "Splunk index (optional)": "Splunk indeksas (neprivaloma)", + "Sourcetype (optional)": "Sourcetype (neprivaloma)", + "Leave blank to keep the current one": "Palikite tuščią, kad liktų dabartinis", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF per syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Duomenų rinkimo galinis taškas (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Kliento paslaptis (tik rašymui)", + "HEC token (write-only)": "HEC prieigos raktas (tik rašymui)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Persiųskite leidžiamus audito įvykius į Splunk, Microsoft Sentinel, syslog gavėją arba žiniatinklio kabliuką. Pranešimuose yra tik išvalyti metaduomenys: jokia slapta reikšmė, vardas, prisijungimas ar šifruotas tekstas niekada nepalieka serverio.", + "%n change waiting to sync": "%n pakeitimas laukia sinchronizavimo", + "%n changes waiting to sync": "%n pakeitimai laukia sinchronizavimo", + "Changes that could not sync": "Pakeitimai, kurių nepavyko sinchronizuoti", + "Choose a version": "Pasirinkti versiją", + "Copy value": "Kopijuoti reikšmę", + "Deleted": "Ištrinta", + "Discard": "Atmesti", + "Keep my offline change": "Palikti mano neprisijungus atliktą pakeitimą", + "Keep the server version": "Palikti serverio versiją", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq neprisijungus yra tik skaitymui. Administratorius neįjungė redagavimo neprisijungus.", + "Let users edit secrets offline": "Leisti naudotojams redaguoti paslaptis neprisijungus", + "Not synced yet": "Dar nesinchronizuota", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Pakeitimai neprisijungus lieka įrenginyje, užšifruoti naudotojui, ir sinchronizuojami kito atrakinimo prisijungus metu. Bendrinimui, aplankams ir priedams vis dar reikia ryšio.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Neprisijungus. Redagavimai, perkėlimai ir ištrynimai lieka šiame įrenginyje ir sinchronizuojami, kai vėl prisijungsite. Bendrinimui ir priedams reikia ryšio.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Neprisijungus. Jūsų pakeitimai lieka šiame įrenginyje ir sinchronizuojami, kai vėl prisijungsite. Paskutinį kartą sinchronizuota {when}.", + "Open my changes": "Atverti mano pakeitimus", + "Sharing needs a connection": "Bendrinimui reikia ryšio", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Kažkas pakeitė šią paslaptį serveryje po to, kai buvo sukurta jūsų kopija neprisijungus. Pasirinkite, kurią versiją palikti.", + "Sync or discard your offline changes before you rotate your keys.": "Prieš keisdami raktus sinchronizuokite arba atmeskite pakeitimus neprisijungus.", + "That password did not open your changes.": "Šiuo slaptažodžiu nepavyko atverti jūsų pakeitimų.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Neprisijungus naudojama momentinė kopija saugo užšifruotas paslaptis (atveriamas tik raktu, išvestu iš naudotojo pagrindinio slaptažodžio, lygiai kaip serveryje) ir šifruoja pavadinimus, URL ir aplankų pavadinimus saugojimo metu. Prieiga neprisijungus yra tik skaitymui, nebent žemiau leisite redaguoti neprisijungus. Išjunkite tai įrenginiams, kurie niekada neturi kaupti prisijungimo duomenų; išjungus esamos talpyklos išvalomos kito įkėlimo metu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Ankstesnės saugyklos kopijos nebėra, todėl šių pakeitimų atverti negalima.", + "The server version": "Serverio versija", + "This secret changed while you were offline": "Ši paslaptis pasikeitė, kol buvote neprisijungę", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ištrynėte šią paslaptį neprisijungę, bet nuo to laiko ji buvo pakeista serveryje. Pasirinkite, kurią versiją palikti.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Jūsų raktai buvo pakeisti kitame įrenginyje. Įveskite ankstesnį pagrindinį slaptažodį, kad sinchronizuotumėte pakeitimus neprisijungus, arba juos atmeskite.", + "Your offline change": "Jūsų pakeitimas neprisijungus", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n skubios pagalbos kontaktas turėjo laukiančią prieigos užklausą, kai rakto keitimas jį pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.","%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.","%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n elemento negalima pateikti CXF formatu, jis bus praleistas.","%n elementų negalima pateikti CXF formatu, jie bus praleisti.","%n elementų negalima pateikti CXF formatu, jie bus praleisti."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n senesnė versija buvo atmesta, nes galima perkelti tik naujausią istoriją.","%n senesnės versijos buvo atmestos, nes galima perkelti tik naujausią istoriją.","%n senesnės versijos buvo atmestos, nes galima perkelti tik naujausią istoriją."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Dar reikia užšifruoti ir bendrinti %n paslapties kopiją.","Dar reikia užšifruoti ir bendrinti %n paslapčių kopijas.","Dar reikia užšifruoti ir bendrinti %n paslapčių kopijas."], + "_%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 paslapties nepavyko iššifruoti, ir jos nėra šiame eksporte.","%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte.","%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n paslapties nepavyko iššifruoti jūsų senuoju raktu, todėl ji nebuvo perkelta.","%n paslapčių nepavyko iššifruoti jūsų senuoju raktu, todėl jos nebuvo perkeltos.","%n paslapčių nepavyko iššifruoti jūsų senuoju raktu, todėl jos nebuvo perkeltos."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n paslaptis nebuvo perkelta.","%n paslapčių nebuvo perkelta.","%n paslapčių nebuvo perkelta."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n paslaptis vis dar užšifruota jūsų ankstesniuoju raktu.","%n paslapčių vis dar užšifruota jūsų ankstesniuoju raktu.","%n paslapčių vis dar užšifruota jūsų ankstesniuoju raktu."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n paslaptis praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai.","%n paslapčių praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai.","%n paslapčių praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai."], + "_%n secret_::_%n secrets_": ["%n paslaptis","%n paslaptys","%n paslaptys"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n naudotojas taikymo srityje dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.","Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.","Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Vis tiek užbaigti, prarandant prieigą prie %n paslapties","Vis tiek užbaigti, prarandant prieigą prie %n paslapčių","Vis tiek užbaigti, prarandant prieigą prie %n paslapčių"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n naujas narys gavo prieigą prie komandos aplanko.","Nauji nariai (%n) gavo prieigą prie komandos aplanko.","Nauji nariai (%n) gavo prieigą prie komandos aplanko."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rakto rotacija užbaigta. %n paslaptis buvo iš naujo užšifruota jūsų naujuoju raktu.","Rakto rotacija užbaigta. %n paslapčių buvo iš naujo užšifruota jūsų naujuoju raktu.","Rakto rotacija užbaigta. %n paslapčių buvo iš naujo užšifruota jūsų naujuoju raktu."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktą.","Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų.","Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktą.","Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų.","Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["pastebėta nuotėkiuose %n kartą","pastebėta nuotėkiuose %n kartus","pastebėta nuotėkiuose %n kartus"], + "_shared with %n secret_::_shared with %n secrets_": ["bendrinama su %n paslaptimi","bendrinama su %n paslaptimis","bendrinama su %n paslaptimis"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Šiame aplanke tiesiogiai yra %n paslaptis.","Šiame aplanke tiesiogiai yra %n paslapčių.","Šiame aplanke tiesiogiai yra %n paslapčių."], + "_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._": ["Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.","Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.","Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n pakeitimas laukia sinchronizavimo","%n pakeitimai laukia sinchronizavimo","%n pakeitimai laukia sinchronizavimo"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Naudotojas vis dar yra grupėje {groups}, kuri yra komandos aplanko narė. Pašalinkite jį iš grupės arba išjunkite paskyrą.","Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą.","Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą."], + "Allow approval from another device": "Leisti patvirtinti iš kito įrenginio", + "App": "Programėlė", + "Approve a new device": "Patvirtinti naują įrenginį", + "Approve from another device": "Patvirtinti iš kito įrenginio", + "Asked at": "Paprašyta", + "Check that the new device shows these words:": "Patikrinkite, ar naujasis įrenginys rodo šiuos žodžius:", + "Denied. If you did not ask, end your other sessions:": "Atmesta. Jei to neprašėte, užbaikite kitas savo sesijas:", + "Device": "Įrenginys", + "IP address": "IP adresas", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Leisti naudotojams atrakinti naują naršyklę, patvirtinant ją iš įrenginio, kuriame Keepiq jau atrakintas.", + "New device approval": "Naujų įrenginių patvirtinimas", + "Nextcloud security settings": "Nextcloud saugumo nustatymai", + "Only approve a device you are using right now.": "Tvirtinkite tik įrenginį, kurį naudojate būtent dabar.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Atidarykite Keepiq įrenginyje, kuriame jis atrakintas, ir patvirtinkite šį įrenginį. Patikrinkite, ar jis rodo tuos pačius žodžius:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Tvirtinantis įrenginys užantspauduoja atrakinimo raktą naujajam įrenginiui. Serveris jį tik perduoda ir negali jo atidaryti.", + "The master password is not right, or the request has ended.": "Pagrindinis slaptažodis neteisingas arba užklausa baigėsi.", + "The request expired. Ask again or use your master password.": "Užklausos laikas baigėsi. Paprašykite dar kartą arba naudokite pagrindinį slaptažodį.", + "The request was denied.": "Užklausa atmesta.", + "Too many requests. Try again in an hour or use your master password.": "Per daug užklausų. Bandykite dar kartą po valandos arba naudokite pagrindinį slaptažodį.", + "Unknown device": "Nežinomas įrenginys", + "Web app": "Žiniatinklio programėlė", + "A device": "Įrenginys", + "A new device asks to open your vault": "Naujas įrenginys prašo atidaryti jūsų saugyklą", + "%s asks to be approved. Only approve a device you are using right now.": "%s prašo patvirtinimo. Tvirtinkite tik įrenginį, kurį naudojate būtent dabar.", + "Access ends on (optional)": "Prieiga baigiasi (neprivaloma)", + "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.": "Keepiq programėlės nerodys ir nekopijuos slaptažodžio. Techninių žinių turintis asmuo vis tiek gali jį perskaityti savo įrenginyje. Pakeiskite jį, kai prieiga baigsis.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Šią paslaptį galima tik naudoti. Prisijunkite per Keepiq naršyklės plėtinį.", + "Until {date}": "Iki {date}", + "Use only": "Tik naudoti", + "Use only (can sign in, cannot view or copy)": "Tik naudoti (gali prisijungti, negali peržiūrėti ar kopijuoti)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Galite prisijungti šiais prisijungimo duomenimis per Keepiq naršyklės plėtinį. Savininkas nusprendė neleisti jums jų peržiūrėti ar kopijuoti.", + "Your access ends on {date}": "Jūsų prieiga baigiasi {date}", + "Your access to this secret has ended": "Jūsų prieiga prie šios paslapties baigėsi", + "Your access to \"%s\" ends tomorrow": "Jūsų prieiga prie „%s“ baigiasi rytoj", + "Your access to \"%s\" has ended": "Jūsų prieiga prie „%s“ baigėsi", + "%1$s no longer has access to \"%2$s\"": "%1$s nebeturi prieigos prie „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s galėjo matyti šį slaptažodį. Pakeiskite jį, jei %1$s nebeturėtų jo žinoti.", + "%s could not view this password in Keepiq.": "%s negalėjo peržiūrėti šio slaptažodžio Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} iš {threshold} patvirtinimų", + "a recovery officer": "atkūrimo pareigūnas", + "Account recovery": "Paskyros atkūrimas", + "Approvals needed": "Reikia patvirtinimų", + "Ask {user} which words they see, by phone or in person. They must be:": "Paklauskite {user}, kokius žodžius jis mato, telefonu arba asmeniškai. Jie turi būti:", + "Check again": "Tikrinti dar kartą", + "Create the recovery key": "Sukurti atkūrimo raktą", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Sukurkite atkūrimo raktą. Jūsų naršyklė jį sukuria ir kiekvienam pareigūnui duoda kopiją, kurią atidaryti gali tik jis.", + "Decline": "Atmesti", + "Enrol in account recovery": "Užsiregistruoti paskyros atkūrimui", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Užsiregistruokite, kad jūsų organizacija galėtų padėti susigrąžinti saugyklą, jei pamiršite pagrindinį slaptažodį.", + "Every user is enrolled": "Visi naudotojai užsiregistravę", + "Finish the recovery in the browser you asked from.": "Užbaikite atkūrimą naršyklėje, iš kurios jo prašėte.", + "Forgot your master password?": "Pamiršote pagrindinį slaptažodį?", + "Hand the key over": "Perduoti raktą", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Leiskite pagrindinį slaptažodį pamiršusiems naudotojams susigrąžinti saugyklą, patvirtinus jūsų paskirtiems atkūrimo pareigūnams.", + "New master password": "Naujas pagrindinis slaptažodis", + "No one is asking to recover their account.": "Niekas neprašo atkurti savo paskyros.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Atkūrimo rakto dar nėra. Vienas iš pareigūnų jį sukuria savo Keepiq nustatymuose.", + "Off": "Išjungta", + "Officer {user} has no encryption set up yet.": "Pareigūnas {user} dar nenustatė šifravimo.", + "Officers (user IDs, separated by commas)": "Pareigūnai (naudotojų ID, atskirti kableliais)", + "Policy": "Taisyklės", + "Publish this fingerprint internally, so users can check it before they enrol.": "Paskelbkite šį kontrolinį kodą organizacijos viduje, kad naudotojai galėtų jį patikrinti prieš registruodamiesi.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Atkurta padedant {officer}. Dabar pakeiskite saugyklos raktą: Nustatymai, Saugumas: \"Mano pagrindinis slaptažodis buvo atskleistas\".", + "Recovery key fingerprint: {fingerprint}": "Atkūrimo rakto kontrolinis kodas: {fingerprint}", + "Recovery officer": "Atkūrimo pareigūnas", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Pašalinti pareigūnai dabar praranda savo kopiją, bet galėjo ją atidaryti anksčiau. Paprašykite pareigūno sukurti naują atkūrimo raktą.", + "Repeat the new master password": "Pakartokite naują pagrindinį slaptažodį", + "Retire this recovery key": "Nebenaudoti šio atkūrimo rakto", + "Set the new master password": "Nustatyti naują pagrindinį slaptažodį", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Atkūrimo sertifikato išdavė ne šis Keepiq. Nesiregistruokite ir praneškite administratoriui.", + "The words match, approve": "Žodžiai sutampa, patvirtinti", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Šis naudotojas užsiregistravęs paskyros atkūrimui. Atkūrimas išsaugo jo paslaptis; atšaukimas ištrina jo registraciją.", + "Users may enrol": "Naudotojai gali registruotis", + "Withdraw from account recovery": "Atsisakyti paskyros atkūrimo", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Esate užsiregistravę paskyros atkūrimui. Atkūrimo rakto kontrolinis kodas: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Esate užsiregistravę. Jei pamiršite pagrindinį slaptažodį, jūsų organizacija gali padėti susigrąžinti saugyklą.", + "Your key is back. Choose a new master password.": "Jūsų raktas grąžintas. Pasirinkite naują pagrindinį slaptažodį.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Jūsų atkūrimo pareigūnai informuoti. Perskaitykite jiems šiuos žodžius, kai jie paskambins ar susitiksite:", + "You are now an account recovery officer": "Dabar esate paskyrų atkūrimo pareigūnas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s prašo atkurti savo paskyrą. Prieš patvirtindami palyginkite su juo žodžius.", + "A user": "Naudotojas", + "Your account recovery request was declined": "Jūsų paskyros atkūrimo užklausa atmesta", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Jūsų paskyros atkūrimas paruoštas. Atidarykite Keepiq naršyklėje, iš kurios jo prašėte.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prašo vieną kartą atrakinti naują įrenginį. Pagrindinis slaptažodis lieka tas pats.", + "Ask your organisation instead": "Verčiau kreipkitės į savo organizaciją", + "The request ended. Ask again or use your master password.": "Užklausa baigėsi. Paprašykite dar kartą arba naudokite pagrindinį slaptažodį.", + "Added by {user}": "Pridėjo {user}", + "Editor": "Redaktorius", + "Manager": "Vadovas", + "Role of {member}": "{member} vaidmuo", + "Team folders you manage": "Jūsų valdomi komandos aplankai", + "Viewer": "Peržiūrėtojas", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Neturite šių paslapčių kopijos, todėl nauji nariai jų dar negavo. Savininkas gali jomis pasidalyti: {names}", + "Admin areas": "Administravimo sritys", + "Give a group only the parts of Keepiq administration it needs.": "Suteikite grupei tik tas Keepiq administravimo dalis, kurių jai reikia.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleguokite vieną ar daugiau sričių grupei administravimo teisių puslapyje. Egzemplioriaus administratoriai turi visas sritis.", + "Open administration privileges": "Atverti administravimo teises", + "Policies": "Politikos", + "Applications and machine access": "Programos ir mašinų prieiga", + "People and offboarding": "Žmonės ir išėjimas", + "Audit and compliance": "Auditas ir atitiktis", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versija, sertifikavimo institucija, priedai, neprisijungus naudojama talpykla, nutekėjimų patikra, paslapčių tipai ir atsarginės kopijos", + "master password, organisation password, vault policies, rotation, version history and trash": "pagrindinis slaptažodis, organizacijos slaptažodis, saugyklos politikos, rotacija, versijų istorija ir šiukšlinė", + "application queue, application requests and machine leases": "programų eilė, programų užklausos ir mašinų nuomos", + "team offboarding, encryption suites and admin handover": "išėjimas iš komandos, šifravimo rinkiniai ir administratoriaus perėmimas", + "audit log, compliance reports, SIEM export and honey alerts": "audito žurnalas, atitikties ataskaitos, SIEM eksportas ir masalo įspėjimai", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kiek paslapties versijų saugoma, kiek laiko, ir kiek laiko ištrintos paslaptys lieka šiukšlinėje.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Šifruotų priedų ribos, kurias serveris taiko saugomiems šifruotiems baitams.", + "Type the suite ID again to confirm": "Patvirtinkite dar kartą įvesdami rinkinio ID", + "This does not match the suite ID.": "Tai nesutampa su rinkinio ID.", + "Confirm with your master password": "Patvirtinkite pagrindiniu slaptažodžiu", + "Confirm": "Patvirtinti", + "That master password is not right.": "Šis pagrindinis slaptažodis neteisingas.", + "You are sharing with someone new. Enter your master password to confirm.": "Bendrinate su nauju asmeniu. Patvirtinkite įvesdami pagrindinį slaptažodį.", + "Enter your master password to confirm this share.": "Įveskite pagrindinį slaptažodį, kad patvirtintumėte šį bendrinimą.", + "Enter your master password to confirm this delegation.": "Įveskite pagrindinį slaptažodį, kad patvirtintumėte šį delegavimą.", + "Approve {member}": "Patvirtinti {member}", + "Recipient": "Gavėjas", + "No vault yet": "Dar neturi saugyklos", + "No matching users": "Atitinkančių vartotojų nėra", + "Partner organisations": "Partnerių organizacijos", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Keiskitės paslaptimis su kitu Keepiq. Abu administratoriai prideda vienas kitą ir prieš įrašydami palygina šakninius piršto atspaudus telefonu arba asmeniškai.", + "Federation needs Nextcloud 33 or later.": "Federacijai reikia Nextcloud 33 arba naujesnės.", + "Your root fingerprint": "Jūsų šakninis piršto atspaudas", + "No partners yet.": "Partnerių dar nėra.", + "Users here may share to this partner": "Čia esantys vartotojai gali bendrinti su šiuo partneriu", + "This partner may share to users here": "Šis partneris gali bendrinti su čia esančiais vartotojais", + "Partner address": "Partnerio adresas", + "Check partner": "Patikrinti partnerį", + "Partner root fingerprint": "Partnerio šakninis piršto atspaudas", + "I compared this fingerprint with the partner's administrator": "Palyginau šį piršto atspaudą su partnerio administratoriumi", + "Add partner": "Pridėti partnerį", + "A secret from another organisation": "Paslaptis iš kitos organizacijos", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s bendrina su jumis \"%2$s\". Priimkite ją skiltyje Gauta iš kitų organizacijų.", + "Incoming from other organisations": "Gauta iš kitų organizacijų", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partnerių organizacijų žmonės gali bendrinti su jumis paslaptį. Priimkite ją, kad savo saugykloje laikytumėte kopiją tik skaitymui.", + "Nothing shared with you yet": "Su jumis dar niekas nebendrinta", + "Secrets that people in partner organisations share with you appear here.": "Čia rodomos paslaptys, kurias su jumis bendrina partnerių organizacijų žmonės.", + "From {sender}": "Nuo {sender}", + "Accept": "Priimti", + "Open in vault": "Atidaryti saugykloje", + "The other organisation did not hand over the secret. Try again later.": "Kita organizacija neperdavė paslapties. Bandykite dar kartą vėliau.", + "Set up your vault before you accept a shared secret.": "Prieš priimdami bendrinamą paslaptį, nustatykite savo saugyklą.", + "Something went wrong. Try again.": "Kažkas nepavyko. Bandykite dar kartą.", + "Waiting for your answer": "Laukiama jūsų atsakymo", + "In your vault, read-only": "Jūsų saugykloje, tik skaitymui", + "Withdrawn by the sender": "Siuntėjas atšaukė", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} bendrino tai iš kitos organizacijos. Galite tai skaityti, bet negalite keisti ar bendrinti.", + "Someone": "Kažkas", + "Share with someone at another organisation": "Bendrinti su kuo nors iš kitos organizacijos", + "Their account at the other organisation": "Šio asmens paskyra kitoje organizacijoje", + "Check account": "Patikrinti paskyrą", + "Certificate fingerprint of {account}": "Paskyros {account} sertifikato kontrolinis kodas", + "Compare it with them by phone if you want to be sure.": "Jei norite būti tikri, palyginkite jį su tuo asmeniu telefonu.", + "Shared. {account} can accept it in their own vault.": "Pabendrinta. {account} gali tai priimti savo saugykloje.", + "The certificate could not be verified. Nothing was shared.": "Nepavyko patikrinti sertifikato. Niekas nebuvo bendrinta.", + "That organisation is not one of your partners.": "Ši organizacija nėra jūsų partnerė.", + "No one with that account can receive secrets from you.": "Niekas su šia paskyra negali gauti iš jūsų paslapčių.", + "The other organisation did not answer. Try again later.": "Kita organizacija neatsakė. Bandykite dar kartą vėliau.", + "This secret is already shared with that account.": "Ši paslaptis jau bendrinama su šia paskyra.", + "Other organisations": "Kitos organizacijos", + "Receive secrets from other organisations": "Gauti paslaptis iš kitų organizacijų", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Tada partnerių organizacijų žmonės galės rasti jūsų paskyrą ir bendrinti su jumis paslaptis. Kiekvieną jų priimate patys.", + "Shared": "Bendrinama", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pristabdyta: pasikeitė jų sertifikatas arba partnerystė. Galite atšaukti bendrinimą arba bendrinti iš naujo.", + "Their organisation did not get the last change. Revoke it or share again.": "Jų organizacija negavo paskutinio pakeitimo. Galite atšaukti bendrinimą arba bendrinti iš naujo.", + "Being withdrawn": "Atšaukiama", + "Shared with another organisation": "Bendrinta su kita organizacija", + "Change sent to another organisation": "Pakeitimas išsiųstas kitai organizacijai", + "Share with another organisation revoked": "Bendrinimas su kita organizacija atšauktas", + "Share with another organisation paused": "Bendrinimas su kita organizacija pristabdytas", + "Another organisation did not get a change": "Kita organizacija negavo pakeitimo", + "Secret received from another organisation": "Gauta paslaptis iš kitos organizacijos", + "Secret from another organisation accepted": "Paslaptis iš kitos organizacijos priimta", + "Secret from another organisation declined": "Paslaptis iš kitos organizacijos atmesta", + "Copy from another organisation updated": "Kopija iš kitos organizacijos atnaujinta", + "Copy from another organisation removed": "Kopija iš kitos organizacijos pašalinta", + "Declined: they removed their copy. Share again if they need it.": "Atmesta: gavėjas pašalino savo kopiją. Bendrinkite iš naujo, jei jos reikia.", + "Recipient at another organisation removed their copy": "Kitos organizacijos gavėjas pašalino savo kopiją", + "Removed the user from %n team folder.": "Naudotojas pašalintas iš %n komandos aplanko.", + "Removed the user from %n team folders.": "Naudotojas pašalintas iš %n komandos aplankų.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Naudotojas pašalintas iš %n komandos aplanko.","Naudotojas pašalintas iš %n komandos aplankų.","Naudotojas pašalintas iš %n komandos aplankų."], + "A restored copy came from a share that has ended. It stays read-only.": "Atkurta kopija gauta iš pasibaigusio bendrinimo. Ji lieka tik skaitymui.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Nepavyko susisiekti su organizacija, kuri bendrino atkurtą kopiją. Kopija lieka tik skaitymui ir neseka jų pakeitimų.", + "Recipient at another organisation restored their copy": "Kitos organizacijos gavėjas atkūrė savo kopiją" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/lt.json b/l10n/lt.json index ae7bd2804..155381774 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Bendrinama su grupėmis", + "Not shared with any group yet.": "Dar nebendrinama su jokia grupe.", + "Revoke the share with {group}": "Atšaukti bendrinimą su {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Bendrinama su {group}: {received} nariai tai gavo, {skipped} negavo, nes dar nenustatė šifravimo.", + "Search groups": "Ieškoti grupių", + "Failed to share": "Nepavyko bendrinti", + "Columns": "Stulpeliai", + "Column {number}": "Stulpelis {number}", + "Map one column to Name. Every secret needs a name.": "Susiekite vieną stulpelį su pavadinimu. Kiekviena paslaptis turi turėti pavadinimą.", + "Notes": "Pastabos", + "Do not import": "Neimportuoti", + "Hide this value": "Slėpti šią reikšmę", + "Show this value": "Rodyti šią reikšmę", + "Defaults": "Numatytieji", + "New secrets start as this type, and your secret list opens in this view.": "Nauji slaptažodžiai prasideda šiuo tipu, o tavo slaptažodžių sąrašas atsidaro šiame rodinyje.", + "Default item type": "Numatytasis elemento tipas", + "Cards": "Kortelės", + "Table": "Lentelė", + "Could not save your default": "Nepavyko išsaugoti numatytosios reikšmės", + "Recently used": "Neseniai naudoti", + "Opened": "Atidaryta", + "You have not opened any secrets yet": "Dar neatidarei jokių slaptažodžių", + "Could not delete the item type.": "Nepavyko ištrinti elemento tipo.", + "Could not load the item types.": "Nepavyko įkelti elementų tipų.", + "Could not save the item type.": "Nepavyko išsaugoti elemento tipo.", + "Delete item type": "Ištrinti elemento tipą", + "Edit item type": "Redaguoti elemento tipą", + "Fields": "Laukai", + "Fields: {count}": "Laukai: {count}", + "Hidden": "Paslėptas", + "Item types": "Elementų tipai", + "Move up": "Perkelti aukštyn", + "New item type": "Naujas elemento tipas", + "No item types defined yet.": "Elementų tipų dar neapibrėžta.", + "Required": "Privalomas", + "Text": "Tekstas", + "This field is required": "Šis laukas privalomas", + "Web address": "Žiniatinklio adresas", + "{label} (required)": "{label} (privalomas)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ištrinti „{name}“? Šio tipo paslaptys lieka skaitomos ir tampa Prisijungimo elementais.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Čia apibrėžti elementų tipai visiems rodomi lange Nauja paslaptis su jūsų pasirinktais laukais.", + "Secret moved to the trash": "Paslaptis perkelta į šiukšlinę", + "Secret restored from the trash": "Paslaptis atkurta iš šiukšlinės", + "Secret deleted for good": "Paslaptis ištrinta visam laikui", + "Secret archived": "Paslaptis suarchyvuota", + "Secret unarchived": "Paslaptis išimta iš archyvo", + "Unarchive": "Išimti iš archyvo", + "Could not archive the secret": "Nepavyko suarchyvuoti paslapties", + "Could not unarchive the secret": "Nepavyko išimti paslapties iš archyvo", + "Archive {count} secrets": "Suarchyvuoti paslaptis: {count}", + "Unarchive {count} secrets": "Išimti iš archyvo paslaptis: {count}", + "Restore {count} secrets": "Atkurti paslaptis: {count}", + "Delete {count} secrets for good": "Ištrinti visam laikui paslaptis: {count}", + "Done for {ok} of {total} secrets": "Atlikta {ok} iš {total} paslapčių", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Suarchyvuotos paslaptys dingsta iš saugyklos sąrašo, paieškos, automatinio pildymo ir būklės ataskaitos. Jų bendrinimai išlieka. Jas rasite skiltyje Archyvas.", + "These secrets come back to the vault list, search and autofill.": "Šios paslaptys grįžta į saugyklos sąrašą, paiešką ir automatinį pildymą.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Šios paslaptys grįžta į saugyklos sąrašą. Senieji bendrinimai negrįžta, todėl prireikus pasidalykite jomis iš naujo.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tai ištrins paslaptis kartu su priedais ir versijų istorija. To atšaukti negalima.", + "Delete for good": "Ištrinti visam laikui", + "Trash": "Šiukšlinė", + "The trash is empty": "Šiukšlinė tuščia", + "No archived secrets": "Suarchyvuotų paslapčių nėra", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Ištrintos paslaptys laukia čia, kol baigsis saugojimo laikotarpis, tada jos ištrinamos visam laikui.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Suarchyvuokite paslaptį jos informacijos skydelyje, kad ji nebūtų saugyklos sąraše, paieškoje ir automatiniame pildyme.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Šifruotų priedų ribos (taikomos serveryje saugomiems šifruotiems baitams), versijų istorijos saugojimas ir kiek laiko ištrintos paslaptys lieka šiukšlinėje.", + "Days a deleted secret stays in the trash (1 to 365)": "Dienos, kiek ištrinta paslaptis lieka šiukšlinėje (nuo 1 iki 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tai perkelia paslaptį į šiukšlinę ir iš karto nutraukia jos bendrinimus. Ją galite atkurti iš šiukšlinės, kol baigsis saugojimo laikotarpis: 30 dienų, nebent administratorius jį pakeitė.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tai perkelia paslaptis į šiukšlinę ({count}) ir iš karto nutraukia jų bendrinimus. Jas galite atkurti iš šiukšlinės, kol baigsis saugojimo laikotarpis.", + "Remove {name} from favourites": "Pašalinti {name} iš mėgstamų", + "Add {name} to favourites": "Pridėti {name} prie mėgstamų", + "Could not change the favourite": "Nepavyko pakeisti mėgstamo", + "Remove from favourites": "Pašalinti iš mėgstamų", + "Add to favourites": "Pridėti prie mėgstamų", + "Tags": "Žymės", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Žymės nėra šifruojamos. Serverio administratoriai gali jas skaityti, kaip ir aplankų pavadinimus.", + "Favourites": "Mėgstami", + "Filter by tag": "Filtruoti pagal žymę", + "All tags": "Visos žymės", + "Last used": "Paskutinį kartą naudota", + "Tags for {count} secrets": "Žymės {count} paslaptims", + "Tag": "Žymė", + "Remove tag": "Pašalinti žymę", + "Add tag": "Pridėti žymę", + "Could not change the tags. Try again.": "Nepavyko pakeisti žymių. Bandykite dar kartą.", + "Could not approve the application. It is still in the queue.": "Nepavyko patvirtinti paraiškos. Ji vis dar eilėje.", + "Could not reject the application. It is still in the queue.": "Nepavyko atmesti paraiškos. Ji vis dar eilėje.", + "Removed the user from {count} team folders.": "Naudotojas pašalintas iš {count} komandos aplankų.", + "Approve a share": "Patvirtinti bendrinimą", + "This approval link is incomplete. Open it again from the notification.": "Ši patvirtinimo nuoroda neišsami. Atverkite ją dar kartą iš pranešimo.", + "Deny": "Atmesti", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} prisijungė prie grupės, su kuria bendrinate paslaptį. Bendrinti paslaptį ir su juo?", + "{requester} asks you to share a secret with {user}.": "{requester} prašo jūsų bendrinti paslaptį su {user}.", + "Shared. The recipient can now open the secret.": "Bendrinama. Gavėjas dabar gali atverti paslaptį.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Gavėjas dar nenustatė Keepiq, todėl niekas nebuvo bendrinta. Bandykite dar kartą, kai jis tai padarys.", + "Could not share the secret. Only its owner can approve this.": "Nepavyko bendrinti paslapties. Tai gali patvirtinti tik jos savininkas.", + "Could not share the secret. Try again.": "Nepavyko bendrinti paslapties. Bandykite dar kartą.", + "Denied. Nothing was shared.": "Atmesta. Niekas nebuvo bendrinta.", + "Could not deny the request. Try again.": "Nepavyko atmesti užklausos. Bandykite dar kartą.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s prašo jūsų bendrinti paslaptį \"%2$s\" su %3$s.", + "Expires on (optional)": "Galioja iki (neprivaloma)", + "Hand over to": "Perduoti", + "Choose a recipient": "Pasirinkite gavėją", + "Hand over temporarily": "Perduoti laikinai", + "Expiry rules": "Galiojimo taisyklės", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nustatykite, kiek laiko gali galioti vieno elemento tipo ar vieno aplanko slaptažodžiai ir kada jums priminti. Kai taikomos kelios datos, galioja ankstyviausia.", + "Delete rule": "Ištrinti taisyklę", + "Set by your administrator": "Nustatė jūsų administratorius", + "No expiry rules yet.": "Galiojimo taisyklių dar nėra.", + "Applies to": "Taikoma", + "Item type": "Elemento tipas", + "Maximum age in days (empty for reminders only)": "Didžiausias amžius dienomis (tuščia, jei tik priminimai)", + "Remind me this many days before, comma separated": "Priminti tiek dienų prieš, atskirti kableliais", + "Save rule": "Įrašyti taisyklę", + "An item type": "Elemento tipas", + "A folder": "Aplankas", + "Folder {name}": "Aplankas {name}", + "Type {name}": "Tipas {name}", + "Expires after {days} days": "Baigia galioti po {days} d.", + "Reminders {days} days before": "Priminimai likus {days} d.", + "Could not save the expiry rule.": "Nepavyko įrašyti galiojimo taisyklės.", + "Could not delete the expiry rule.": "Nepavyko ištrinti galiojimo taisyklės.", + "All statuses": "Visos būsenos", + "Compromised": "Pažeistas", + "Could not load the members.": "Nepavyko įkelti narių.", + "Emergency contact": "Avarinis kontaktas", + "Leaving user": "Išeinantis naudotojas", + "No": "Ne", + "No users match this filter.": "Joks naudotojas neatitinka šio filtro.", + "Not set up": "Nenustatyta", + "Revoke suite": "Atšaukti rinkinį", + "Revoked": "Atšauktas", + "Search users": "Ieškoti naudotojų", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pažiūrėkite, kurie naudotojai nustatė saugyklą. Pradėkite išėjimą arba atšaukite rinkinį iš eilutės.", + "Successor": "Įpėdinis", + "Team folders": "Komandos aplankai", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Naudotojas vis dar yra grupėje {groups}, kuri yra komandos aplanko narė. Pašalinkite jį iš grupės arba išjunkite paskyrą.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą.", + "Vault status": "Saugyklos būsena", + "Yes": "Taip", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF eksportas NĖRA ŠIFRUOTAS. Kiekvienas slaptažodis ir prisijungimo vardas atsisiųstame faile bus perskaitomi kaip paprastas tekstas. Saugokite jį saugiai ir iškart po naudojimo ištrinkite.", + "Root certificate expiring soon": "Šakninio sertifikato galiojimas netrukus baigsis", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Saugyklos šakninio sertifikato galiojimas baigsis po %1$d d. Atnaujinkite jį iki tol. Atnaujinant iš naujo pasirašomas kiekvienas šifravimo rinkinys.", + "Compromise recovery aborted": "Atkūrimas po kompromitavimo nutrauktas", + "Key rotation ended by a compromise revoke": "Rakto keitimą užbaigė atšaukimas dėl kompromitavimo", + "Encryption suite revoke refused": "Šifravimo rinkinio atšaukimas atmestas", + "Master password proof refused": "Pagrindinio slaptažodžio įrodymas atmestas", + "Your current master password": "Jūsų dabartinis pagrindinis slaptažodis", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n skubios pagalbos kontaktas turėjo laukiančią prieigos užklausą, kai rakto keitimas jį pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Šie skubios pagalbos kontaktai nebuvo perkelti į jūsų naują raktą. Jų skubi prieiga pašalinta. Vėl pridėkite juos skiltyje Skubi prieiga, jei jų vis dar norite.", + "Renew root certificate": "Atnaujinti šakninį sertifikatą", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Sukuriamas naujas šakninis ir tarpinis sertifikatas. Kiekvienas aktyvus šifravimo rinkinys pasirašomas iš naujo. To negalima atšaukti.", + "Renew root": "Atnaujinti šaknį", + "Root renewed. {n} encryption suites signed again.": "Šaknis atnaujinta. Iš naujo pasirašytų šifravimo rinkinių: {n}.", + "Could not renew the root certificate.": "Nepavyko atnaujinti šakninio sertifikato.", + "Lease policy for this application": "Šios programos nuomos politika", + "In force now: {default} seconds by default, {max} seconds at most.": "Dabar galioja: numatytai {default} sekundžių, daugiausia {max} sekundžių.", + "Leases are not renewable": "Nuomos negalima pratęsti", + "Lease policy saved.": "Nuomos politika išsaugota.", + "Leave a field empty to use the instance value.": "Palikite lauką tuščią, kad būtų naudojama egzemplioriaus reikšmė.", + "Instance value: {value}": "Egzemplioriaus reikšmė: {value}", + "Renewal": "Pratęsimas", + "Use the instance value ({value})": "Naudoti egzemplioriaus reikšmę ({value})", + "Allowed": "Leidžiama", + "Not allowed": "Neleidžiama", + "Save lease policy": "Išsaugoti nuomos politiką", + "Only an administrator can change this policy.": "Šią politiką gali pakeisti tik administratorius.", + "Could not save the lease policy.": "Nepavyko išsaugoti nuomos politikos.", + "{member} got access from {confirmer}.": "{member} gavo prieigą iš {confirmer}.", + "Automatically confirm new team folder members": "Automatiškai patvirtinti naujus komandos aplankų narius", + "Gave %n new member access to a team folder.": "%n naujas narys gavo prieigą prie komandos aplanko.", + "Gave %n new members access to a team folder.": "Nauji nariai (%n) gavo prieigą prie komandos aplanko.", + "Give new team folder members access without waiting for the folder owner.": "Suteikite prieigą naujiems nariams nelaukdami aplanko savininko.", + "New team folder members": "Nauji komandos aplankų nariai", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Savininkas arba narys su rašymo teise juos patvirtina iš atidarytos saugyklos. Keepiq niekada neiššifruoja serveryje.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Laukiama, kol narys su rašymo teise atidarys Keepiq. Taip pat galite bendrinti dabar.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Dalis reakcijos į kompromitavimą nepavyko ({failed} žingsnis(-iai)). Patikrinkite serverio žurnalą ir vėl atšaukite rinkinį, kad ją užbaigtumėte.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tai taip pat atšaukė rinkinį {suite} ir užbaigė raktų perkėlimą {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktą.", + "Revoking the second suite deleted %n emergency-access contacts.": "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų.", + "A suite revoked as compromised cannot be reinstated.": "Rinkinio, atšaukto kaip kompromituoto, atkurti negalima.", + "Archives to keep": "Saugomi archyvai", + "Back up every vault automatically": "Automatiškai daryti kiekvienos saugyklos kopiją", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Darykite kiekvienos saugyklos kopiją pagal tvarkaraštį. Archyvuose yra tik šifruotas tekstas, jie atkuriami su occ.", + "Back up now": "Daryti kopiją dabar", + "Backup public key (PEM, optional)": "Kopijos viešasis raktas (PEM, neprivaloma)", + "Backup requested for the next cron run": "Kopija užsakyta kitam cron paleidimui", + "Encrypted": "Šifruota", + "Every (hours)": "Kas (valandų)", + "Last backup {when} failed: {error}": "Paskutinė kopija {when} nepavyko: {error}", + "Last backup {when} succeeded.": "Paskutinė kopija {when} pavyko.", + "No archives yet.": "Archyvų dar nėra.", + "Size": "Dydis", + "Vault backups": "Saugyklos kopijos", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Su raktu kiekvienas archyvas šifruojamas jam. Laikykite privatųjį raktą už šio serverio ribų: jo reikia tikrinimui ar atkūrimui.", + "Written": "Įrašyta", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n naudotojas taikymo srityje dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Atsarginiai kodai neskaičiuojami. Jei jūsų naudotojai jungiasi per tapatybės teikėją su savo antru veiksniu, praleiskite jų grupes.", + "Block personal vault export": "Blokuoti asmeninės saugyklos eksportą", + "Keep work logins in team folders": "Laikyti darbo prisijungimus komandos aplankuose", + "Move to a team folder": "Perkelti į komandos aplanką", + "Not in a team folder": "Ne komandos aplanke", + "Only for these groups (empty is everyone)": "Tik šioms grupėms (tuščia reiškia visus)", + "Require two-factor login before the vault opens": "Reikalauti dviejų veiksnių prisijungimo prieš atidarant saugyklą", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Taisyklės kiekvienai saugyklai. Kiekviena taikoma visiems arba tik pasirinktoms grupėms.", + "Secret types that belong in a team folder": "Paslapčių tipai, kurie priklauso komandos aplankui", + "Set up two-factor login": "Nustatyti dviejų veiksnių prisijungimą", + "Team folder you can write to": "Komandos aplankas, į kurį galite rašyti", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Naudotojai negali atsisiųsti atsarginės kopijos, CSV ar perkėlimo failo. Jų asmens duomenų paketas lieka pasiekiamas.", + "Users cannot save these secret types in a personal folder.": "Naudotojai negali saugoti šių paslapčių tipų asmeniniame aplanke.", + "Vault policies": "Saugyklos taisyklės", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Jūsų organizacija neleidžia eksportuoti jūsų asmeninės saugyklos. Jūsų asmens duomenų paketas nustatymuose lieka pasiekiamas.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Jūsų organizacija laiko šias paslaptis komandos aplanke. Perkelkite kiekvieną į komandos aplanką.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Jūsų organizacija laiko šio tipo paslaptį komandos aplanke. Pasirinkite vieną iš savo komandos aplankų arba tokį, į kurį galite rašyti.", + "Your organisation requires two-factor login before you can open your vault.": "Jūsų organizacija reikalauja dviejų veiksnių prisijungimo, kad galėtumėte atidaryti saugyklą.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Naudotojai pasirenka, kiek laiko plėtinys lieka atrakintas neveikimo metu. Jūs nustatote ilgiausią laiką, kurį jie gali pasirinkti.", + "Longest idle time before the extension locks": "Ilgiausias neveikimo laikas, kol plėtinys užrakinamas", + "1 minute": "1 minutė", + "5 minutes": "5 minutės", + "15 minutes": "15 minučių", + "1 hour": "1 valanda", + "4 hours": "4 valandos", + "Connector": "Jungtis", + "Directory (tenant) ID": "Katalogo (nuomininko) ID", + "Application (client) ID": "Programos (kliento) ID", + "Data collection rule immutable ID": "Duomenų rinkimo taisyklės nekintamas ID", + "Stream name": "Srauto pavadinimas", + "Splunk index (optional)": "Splunk indeksas (neprivaloma)", + "Sourcetype (optional)": "Sourcetype (neprivaloma)", + "Leave blank to keep the current one": "Palikite tuščią, kad liktų dabartinis", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF per syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Duomenų rinkimo galinis taškas (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Kliento paslaptis (tik rašymui)", + "HEC token (write-only)": "HEC prieigos raktas (tik rašymui)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Persiųskite leidžiamus audito įvykius į Splunk, Microsoft Sentinel, syslog gavėją arba žiniatinklio kabliuką. Pranešimuose yra tik išvalyti metaduomenys: jokia slapta reikšmė, vardas, prisijungimas ar šifruotas tekstas niekada nepalieka serverio.", + "%n change waiting to sync": "%n pakeitimas laukia sinchronizavimo", + "%n changes waiting to sync": "%n pakeitimai laukia sinchronizavimo", + "Changes that could not sync": "Pakeitimai, kurių nepavyko sinchronizuoti", + "Choose a version": "Pasirinkti versiją", + "Copy value": "Kopijuoti reikšmę", + "Deleted": "Ištrinta", + "Discard": "Atmesti", + "Keep my offline change": "Palikti mano neprisijungus atliktą pakeitimą", + "Keep the server version": "Palikti serverio versiją", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq neprisijungus yra tik skaitymui. Administratorius neįjungė redagavimo neprisijungus.", + "Let users edit secrets offline": "Leisti naudotojams redaguoti paslaptis neprisijungus", + "Not synced yet": "Dar nesinchronizuota", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Pakeitimai neprisijungus lieka įrenginyje, užšifruoti naudotojui, ir sinchronizuojami kito atrakinimo prisijungus metu. Bendrinimui, aplankams ir priedams vis dar reikia ryšio.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Neprisijungus. Redagavimai, perkėlimai ir ištrynimai lieka šiame įrenginyje ir sinchronizuojami, kai vėl prisijungsite. Bendrinimui ir priedams reikia ryšio.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Neprisijungus. Jūsų pakeitimai lieka šiame įrenginyje ir sinchronizuojami, kai vėl prisijungsite. Paskutinį kartą sinchronizuota {when}.", + "Open my changes": "Atverti mano pakeitimus", + "Sharing needs a connection": "Bendrinimui reikia ryšio", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Kažkas pakeitė šią paslaptį serveryje po to, kai buvo sukurta jūsų kopija neprisijungus. Pasirinkite, kurią versiją palikti.", + "Sync or discard your offline changes before you rotate your keys.": "Prieš keisdami raktus sinchronizuokite arba atmeskite pakeitimus neprisijungus.", + "That password did not open your changes.": "Šiuo slaptažodžiu nepavyko atverti jūsų pakeitimų.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Neprisijungus naudojama momentinė kopija saugo užšifruotas paslaptis (atveriamas tik raktu, išvestu iš naudotojo pagrindinio slaptažodžio, lygiai kaip serveryje) ir šifruoja pavadinimus, URL ir aplankų pavadinimus saugojimo metu. Prieiga neprisijungus yra tik skaitymui, nebent žemiau leisite redaguoti neprisijungus. Išjunkite tai įrenginiams, kurie niekada neturi kaupti prisijungimo duomenų; išjungus esamos talpyklos išvalomos kito įkėlimo metu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Ankstesnės saugyklos kopijos nebėra, todėl šių pakeitimų atverti negalima.", + "The server version": "Serverio versija", + "This secret changed while you were offline": "Ši paslaptis pasikeitė, kol buvote neprisijungę", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ištrynėte šią paslaptį neprisijungę, bet nuo to laiko ji buvo pakeista serveryje. Pasirinkite, kurią versiją palikti.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Jūsų raktai buvo pakeisti kitame įrenginyje. Įveskite ankstesnį pagrindinį slaptažodį, kad sinchronizuotumėte pakeitimus neprisijungus, arba juos atmeskite.", + "Your offline change": "Jūsų pakeitimas neprisijungus", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n skubios pagalbos kontaktas turėjo laukiančią prieigos užklausą, kai rakto keitimas jį pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami.", + "%n skubios pagalbos kontaktai turėjo laukiančią prieigos užklausą, kai rakto keitimas juos pašalino. Patikrinkite, kas prašė, prieš vėl ką nors pridėdami." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n elemento negalima pateikti CXF formatu, jis bus praleistas.", + "%n elementų negalima pateikti CXF formatu, jie bus praleisti.", + "%n elementų negalima pateikti CXF formatu, jie bus praleisti." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n senesnė versija buvo atmesta, nes galima perkelti tik naujausią istoriją.", + "%n senesnės versijos buvo atmestos, nes galima perkelti tik naujausią istoriją.", + "%n senesnės versijos buvo atmestos, nes galima perkelti tik naujausią istoriją." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Dar reikia užšifruoti ir bendrinti %n paslapties kopiją.", + "Dar reikia užšifruoti ir bendrinti %n paslapčių kopijas.", + "Dar reikia užšifruoti ir bendrinti %n paslapčių kopijas." + ], + "_%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 paslapties nepavyko iššifruoti, ir jos nėra šiame eksporte.", + "%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte.", + "%n paslapčių nepavyko iššifruoti, ir jų nėra šiame eksporte." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n paslapties nepavyko iššifruoti jūsų senuoju raktu, todėl ji nebuvo perkelta.", + "%n paslapčių nepavyko iššifruoti jūsų senuoju raktu, todėl jos nebuvo perkeltos.", + "%n paslapčių nepavyko iššifruoti jūsų senuoju raktu, todėl jos nebuvo perkeltos." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n paslaptis nebuvo perkelta.", + "%n paslapčių nebuvo perkelta.", + "%n paslapčių nebuvo perkelta." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n paslaptis vis dar užšifruota jūsų ankstesniuoju raktu.", + "%n paslapčių vis dar užšifruota jūsų ankstesniuoju raktu.", + "%n paslapčių vis dar užšifruota jūsų ankstesniuoju raktu." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n paslaptis praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai.", + "%n paslapčių praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai.", + "%n paslapčių praleista, nes perėmėjas dar neturi kopijos — įtraukite perėmėją į aplanką ir vykdykite pakartotinai." + ], + "_%n secret_::_%n secrets_": [ + "%n paslaptis", + "%n paslaptys", + "%n paslaptys" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n naudotojas taikymo srityje dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta.", + "Naudotojai taikymo srityje (%n) dar neturi dviejų veiksnių prisijungimo ir negali atidaryti saugyklos, kol tai įjungta." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Vis tiek užbaigti, prarandant prieigą prie %n paslapties", + "Vis tiek užbaigti, prarandant prieigą prie %n paslapčių", + "Vis tiek užbaigti, prarandant prieigą prie %n paslapčių" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n naujas narys gavo prieigą prie komandos aplanko.", + "Nauji nariai (%n) gavo prieigą prie komandos aplanko.", + "Nauji nariai (%n) gavo prieigą prie komandos aplanko." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rakto rotacija užbaigta. %n paslaptis buvo iš naujo užšifruota jūsų naujuoju raktu.", + "Rakto rotacija užbaigta. %n paslapčių buvo iš naujo užšifruota jūsų naujuoju raktu.", + "Rakto rotacija užbaigta. %n paslapčių buvo iš naujo užšifruota jūsų naujuoju raktu." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktą.", + "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų.", + "Antrojo rinkinio atšaukimas ištrynė %n avarinės prieigos kontaktų." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktą.", + "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų.", + "Šio rinkinio atšaukimas pašalino %n avarinės prieigos kontaktų." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "pastebėta nuotėkiuose %n kartą", + "pastebėta nuotėkiuose %n kartus", + "pastebėta nuotėkiuose %n kartus" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "bendrinama su %n paslaptimi", + "bendrinama su %n paslaptimis", + "bendrinama su %n paslaptimis" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Šiame aplanke tiesiogiai yra %n paslaptis.", + "Šiame aplanke tiesiogiai yra %n paslapčių.", + "Šiame aplanke tiesiogiai yra %n paslapčių." + ], + "_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._": [ + "Rakto rotacija pašalino %n skubios prieigos kontaktą. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl jį pridėkite, jei jo vis dar norite.", + "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite.", + "Rakto rotacija pašalino %n skubios prieigos kontaktus. Patikrinkite „Prieiga nenumatytais atvejais“ ir vėl juos pridėkite, jei jų vis dar norite." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n pakeitimas laukia sinchronizavimo", + "%n pakeitimai laukia sinchronizavimo", + "%n pakeitimai laukia sinchronizavimo" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Naudotojas vis dar yra grupėje {groups}, kuri yra komandos aplanko narė. Pašalinkite jį iš grupės arba išjunkite paskyrą.", + "Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą.", + "Naudotojas vis dar yra grupėse {groups}, kurios yra komandos aplankų narės. Pašalinkite jį iš grupių arba išjunkite paskyrą." + ], + "Allow approval from another device": "Leisti patvirtinti iš kito įrenginio", + "App": "Programėlė", + "Approve a new device": "Patvirtinti naują įrenginį", + "Approve from another device": "Patvirtinti iš kito įrenginio", + "Asked at": "Paprašyta", + "Check that the new device shows these words:": "Patikrinkite, ar naujasis įrenginys rodo šiuos žodžius:", + "Denied. If you did not ask, end your other sessions:": "Atmesta. Jei to neprašėte, užbaikite kitas savo sesijas:", + "Device": "Įrenginys", + "IP address": "IP adresas", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Leisti naudotojams atrakinti naują naršyklę, patvirtinant ją iš įrenginio, kuriame Keepiq jau atrakintas.", + "New device approval": "Naujų įrenginių patvirtinimas", + "Nextcloud security settings": "Nextcloud saugumo nustatymai", + "Only approve a device you are using right now.": "Tvirtinkite tik įrenginį, kurį naudojate būtent dabar.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Atidarykite Keepiq įrenginyje, kuriame jis atrakintas, ir patvirtinkite šį įrenginį. Patikrinkite, ar jis rodo tuos pačius žodžius:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Tvirtinantis įrenginys užantspauduoja atrakinimo raktą naujajam įrenginiui. Serveris jį tik perduoda ir negali jo atidaryti.", + "The master password is not right, or the request has ended.": "Pagrindinis slaptažodis neteisingas arba užklausa baigėsi.", + "The request expired. Ask again or use your master password.": "Užklausos laikas baigėsi. Paprašykite dar kartą arba naudokite pagrindinį slaptažodį.", + "The request was denied.": "Užklausa atmesta.", + "Too many requests. Try again in an hour or use your master password.": "Per daug užklausų. Bandykite dar kartą po valandos arba naudokite pagrindinį slaptažodį.", + "Unknown device": "Nežinomas įrenginys", + "Web app": "Žiniatinklio programėlė", + "A device": "Įrenginys", + "A new device asks to open your vault": "Naujas įrenginys prašo atidaryti jūsų saugyklą", + "%s asks to be approved. Only approve a device you are using right now.": "%s prašo patvirtinimo. Tvirtinkite tik įrenginį, kurį naudojate būtent dabar.", + "Access ends on (optional)": "Prieiga baigiasi (neprivaloma)", + "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.": "Keepiq programėlės nerodys ir nekopijuos slaptažodžio. Techninių žinių turintis asmuo vis tiek gali jį perskaityti savo įrenginyje. Pakeiskite jį, kai prieiga baigsis.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Šią paslaptį galima tik naudoti. Prisijunkite per Keepiq naršyklės plėtinį.", + "Until {date}": "Iki {date}", + "Use only": "Tik naudoti", + "Use only (can sign in, cannot view or copy)": "Tik naudoti (gali prisijungti, negali peržiūrėti ar kopijuoti)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Galite prisijungti šiais prisijungimo duomenimis per Keepiq naršyklės plėtinį. Savininkas nusprendė neleisti jums jų peržiūrėti ar kopijuoti.", + "Your access ends on {date}": "Jūsų prieiga baigiasi {date}", + "Your access to this secret has ended": "Jūsų prieiga prie šios paslapties baigėsi", + "Your access to \"%s\" ends tomorrow": "Jūsų prieiga prie „%s“ baigiasi rytoj", + "Your access to \"%s\" has ended": "Jūsų prieiga prie „%s“ baigėsi", + "%1$s no longer has access to \"%2$s\"": "%1$s nebeturi prieigos prie „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s galėjo matyti šį slaptažodį. Pakeiskite jį, jei %1$s nebeturėtų jo žinoti.", + "%s could not view this password in Keepiq.": "%s negalėjo peržiūrėti šio slaptažodžio Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} iš {threshold} patvirtinimų", + "a recovery officer": "atkūrimo pareigūnas", + "Account recovery": "Paskyros atkūrimas", + "Approvals needed": "Reikia patvirtinimų", + "Ask {user} which words they see, by phone or in person. They must be:": "Paklauskite {user}, kokius žodžius jis mato, telefonu arba asmeniškai. Jie turi būti:", + "Check again": "Tikrinti dar kartą", + "Create the recovery key": "Sukurti atkūrimo raktą", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Sukurkite atkūrimo raktą. Jūsų naršyklė jį sukuria ir kiekvienam pareigūnui duoda kopiją, kurią atidaryti gali tik jis.", + "Decline": "Atmesti", + "Enrol in account recovery": "Užsiregistruoti paskyros atkūrimui", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Užsiregistruokite, kad jūsų organizacija galėtų padėti susigrąžinti saugyklą, jei pamiršite pagrindinį slaptažodį.", + "Every user is enrolled": "Visi naudotojai užsiregistravę", + "Finish the recovery in the browser you asked from.": "Užbaikite atkūrimą naršyklėje, iš kurios jo prašėte.", + "Forgot your master password?": "Pamiršote pagrindinį slaptažodį?", + "Hand the key over": "Perduoti raktą", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Leiskite pagrindinį slaptažodį pamiršusiems naudotojams susigrąžinti saugyklą, patvirtinus jūsų paskirtiems atkūrimo pareigūnams.", + "New master password": "Naujas pagrindinis slaptažodis", + "No one is asking to recover their account.": "Niekas neprašo atkurti savo paskyros.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Atkūrimo rakto dar nėra. Vienas iš pareigūnų jį sukuria savo Keepiq nustatymuose.", + "Off": "Išjungta", + "Officer {user} has no encryption set up yet.": "Pareigūnas {user} dar nenustatė šifravimo.", + "Officers (user IDs, separated by commas)": "Pareigūnai (naudotojų ID, atskirti kableliais)", + "Policy": "Taisyklės", + "Publish this fingerprint internally, so users can check it before they enrol.": "Paskelbkite šį kontrolinį kodą organizacijos viduje, kad naudotojai galėtų jį patikrinti prieš registruodamiesi.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Atkurta padedant {officer}. Dabar pakeiskite saugyklos raktą: Nustatymai, Saugumas: \"Mano pagrindinis slaptažodis buvo atskleistas\".", + "Recovery key fingerprint: {fingerprint}": "Atkūrimo rakto kontrolinis kodas: {fingerprint}", + "Recovery officer": "Atkūrimo pareigūnas", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Pašalinti pareigūnai dabar praranda savo kopiją, bet galėjo ją atidaryti anksčiau. Paprašykite pareigūno sukurti naują atkūrimo raktą.", + "Repeat the new master password": "Pakartokite naują pagrindinį slaptažodį", + "Retire this recovery key": "Nebenaudoti šio atkūrimo rakto", + "Set the new master password": "Nustatyti naują pagrindinį slaptažodį", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Atkūrimo sertifikato išdavė ne šis Keepiq. Nesiregistruokite ir praneškite administratoriui.", + "The words match, approve": "Žodžiai sutampa, patvirtinti", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Šis naudotojas užsiregistravęs paskyros atkūrimui. Atkūrimas išsaugo jo paslaptis; atšaukimas ištrina jo registraciją.", + "Users may enrol": "Naudotojai gali registruotis", + "Withdraw from account recovery": "Atsisakyti paskyros atkūrimo", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Esate užsiregistravę paskyros atkūrimui. Atkūrimo rakto kontrolinis kodas: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Esate užsiregistravę. Jei pamiršite pagrindinį slaptažodį, jūsų organizacija gali padėti susigrąžinti saugyklą.", + "Your key is back. Choose a new master password.": "Jūsų raktas grąžintas. Pasirinkite naują pagrindinį slaptažodį.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Jūsų atkūrimo pareigūnai informuoti. Perskaitykite jiems šiuos žodžius, kai jie paskambins ar susitiksite:", + "You are now an account recovery officer": "Dabar esate paskyrų atkūrimo pareigūnas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s prašo atkurti savo paskyrą. Prieš patvirtindami palyginkite su juo žodžius.", + "A user": "Naudotojas", + "Your account recovery request was declined": "Jūsų paskyros atkūrimo užklausa atmesta", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Jūsų paskyros atkūrimas paruoštas. Atidarykite Keepiq naršyklėje, iš kurios jo prašėte.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prašo vieną kartą atrakinti naują įrenginį. Pagrindinis slaptažodis lieka tas pats.", + "Ask your organisation instead": "Verčiau kreipkitės į savo organizaciją", + "The request ended. Ask again or use your master password.": "Užklausa baigėsi. Paprašykite dar kartą arba naudokite pagrindinį slaptažodį.", + "Added by {user}": "Pridėjo {user}", + "Editor": "Redaktorius", + "Manager": "Vadovas", + "Role of {member}": "{member} vaidmuo", + "Team folders you manage": "Jūsų valdomi komandos aplankai", + "Viewer": "Peržiūrėtojas", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Neturite šių paslapčių kopijos, todėl nauji nariai jų dar negavo. Savininkas gali jomis pasidalyti: {names}", + "Admin areas": "Administravimo sritys", + "Give a group only the parts of Keepiq administration it needs.": "Suteikite grupei tik tas Keepiq administravimo dalis, kurių jai reikia.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleguokite vieną ar daugiau sričių grupei administravimo teisių puslapyje. Egzemplioriaus administratoriai turi visas sritis.", + "Open administration privileges": "Atverti administravimo teises", + "Policies": "Politikos", + "Applications and machine access": "Programos ir mašinų prieiga", + "People and offboarding": "Žmonės ir išėjimas", + "Audit and compliance": "Auditas ir atitiktis", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versija, sertifikavimo institucija, priedai, neprisijungus naudojama talpykla, nutekėjimų patikra, paslapčių tipai ir atsarginės kopijos", + "master password, organisation password, vault policies, rotation, version history and trash": "pagrindinis slaptažodis, organizacijos slaptažodis, saugyklos politikos, rotacija, versijų istorija ir šiukšlinė", + "application queue, application requests and machine leases": "programų eilė, programų užklausos ir mašinų nuomos", + "team offboarding, encryption suites and admin handover": "išėjimas iš komandos, šifravimo rinkiniai ir administratoriaus perėmimas", + "audit log, compliance reports, SIEM export and honey alerts": "audito žurnalas, atitikties ataskaitos, SIEM eksportas ir masalo įspėjimai", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kiek paslapties versijų saugoma, kiek laiko, ir kiek laiko ištrintos paslaptys lieka šiukšlinėje.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Šifruotų priedų ribos, kurias serveris taiko saugomiems šifruotiems baitams.", + "Type the suite ID again to confirm": "Patvirtinkite dar kartą įvesdami rinkinio ID", + "This does not match the suite ID.": "Tai nesutampa su rinkinio ID.", + "Confirm with your master password": "Patvirtinkite pagrindiniu slaptažodžiu", + "Confirm": "Patvirtinti", + "That master password is not right.": "Šis pagrindinis slaptažodis neteisingas.", + "You are sharing with someone new. Enter your master password to confirm.": "Bendrinate su nauju asmeniu. Patvirtinkite įvesdami pagrindinį slaptažodį.", + "Enter your master password to confirm this share.": "Įveskite pagrindinį slaptažodį, kad patvirtintumėte šį bendrinimą.", + "Enter your master password to confirm this delegation.": "Įveskite pagrindinį slaptažodį, kad patvirtintumėte šį delegavimą.", + "Approve {member}": "Patvirtinti {member}", + "Recipient": "Gavėjas", + "No vault yet": "Dar neturi saugyklos", + "No matching users": "Atitinkančių vartotojų nėra", + "Partner organisations": "Partnerių organizacijos", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Keiskitės paslaptimis su kitu Keepiq. Abu administratoriai prideda vienas kitą ir prieš įrašydami palygina šakninius piršto atspaudus telefonu arba asmeniškai.", + "Federation needs Nextcloud 33 or later.": "Federacijai reikia Nextcloud 33 arba naujesnės.", + "Your root fingerprint": "Jūsų šakninis piršto atspaudas", + "No partners yet.": "Partnerių dar nėra.", + "Users here may share to this partner": "Čia esantys vartotojai gali bendrinti su šiuo partneriu", + "This partner may share to users here": "Šis partneris gali bendrinti su čia esančiais vartotojais", + "Partner address": "Partnerio adresas", + "Check partner": "Patikrinti partnerį", + "Partner root fingerprint": "Partnerio šakninis piršto atspaudas", + "I compared this fingerprint with the partner's administrator": "Palyginau šį piršto atspaudą su partnerio administratoriumi", + "Add partner": "Pridėti partnerį", + "A secret from another organisation": "Paslaptis iš kitos organizacijos", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s bendrina su jumis \"%2$s\". Priimkite ją skiltyje Gauta iš kitų organizacijų.", + "Incoming from other organisations": "Gauta iš kitų organizacijų", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partnerių organizacijų žmonės gali bendrinti su jumis paslaptį. Priimkite ją, kad savo saugykloje laikytumėte kopiją tik skaitymui.", + "Nothing shared with you yet": "Su jumis dar niekas nebendrinta", + "Secrets that people in partner organisations share with you appear here.": "Čia rodomos paslaptys, kurias su jumis bendrina partnerių organizacijų žmonės.", + "From {sender}": "Nuo {sender}", + "Accept": "Priimti", + "Open in vault": "Atidaryti saugykloje", + "The other organisation did not hand over the secret. Try again later.": "Kita organizacija neperdavė paslapties. Bandykite dar kartą vėliau.", + "Set up your vault before you accept a shared secret.": "Prieš priimdami bendrinamą paslaptį, nustatykite savo saugyklą.", + "Something went wrong. Try again.": "Kažkas nepavyko. Bandykite dar kartą.", + "Waiting for your answer": "Laukiama jūsų atsakymo", + "In your vault, read-only": "Jūsų saugykloje, tik skaitymui", + "Withdrawn by the sender": "Siuntėjas atšaukė", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} bendrino tai iš kitos organizacijos. Galite tai skaityti, bet negalite keisti ar bendrinti.", + "Someone": "Kažkas", + "Share with someone at another organisation": "Bendrinti su kuo nors iš kitos organizacijos", + "Their account at the other organisation": "Šio asmens paskyra kitoje organizacijoje", + "Check account": "Patikrinti paskyrą", + "Certificate fingerprint of {account}": "Paskyros {account} sertifikato kontrolinis kodas", + "Compare it with them by phone if you want to be sure.": "Jei norite būti tikri, palyginkite jį su tuo asmeniu telefonu.", + "Shared. {account} can accept it in their own vault.": "Pabendrinta. {account} gali tai priimti savo saugykloje.", + "The certificate could not be verified. Nothing was shared.": "Nepavyko patikrinti sertifikato. Niekas nebuvo bendrinta.", + "That organisation is not one of your partners.": "Ši organizacija nėra jūsų partnerė.", + "No one with that account can receive secrets from you.": "Niekas su šia paskyra negali gauti iš jūsų paslapčių.", + "The other organisation did not answer. Try again later.": "Kita organizacija neatsakė. Bandykite dar kartą vėliau.", + "This secret is already shared with that account.": "Ši paslaptis jau bendrinama su šia paskyra.", + "Other organisations": "Kitos organizacijos", + "Receive secrets from other organisations": "Gauti paslaptis iš kitų organizacijų", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Tada partnerių organizacijų žmonės galės rasti jūsų paskyrą ir bendrinti su jumis paslaptis. Kiekvieną jų priimate patys.", + "Shared": "Bendrinama", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pristabdyta: pasikeitė jų sertifikatas arba partnerystė. Galite atšaukti bendrinimą arba bendrinti iš naujo.", + "Their organisation did not get the last change. Revoke it or share again.": "Jų organizacija negavo paskutinio pakeitimo. Galite atšaukti bendrinimą arba bendrinti iš naujo.", + "Being withdrawn": "Atšaukiama", + "Shared with another organisation": "Bendrinta su kita organizacija", + "Change sent to another organisation": "Pakeitimas išsiųstas kitai organizacijai", + "Share with another organisation revoked": "Bendrinimas su kita organizacija atšauktas", + "Share with another organisation paused": "Bendrinimas su kita organizacija pristabdytas", + "Another organisation did not get a change": "Kita organizacija negavo pakeitimo", + "Secret received from another organisation": "Gauta paslaptis iš kitos organizacijos", + "Secret from another organisation accepted": "Paslaptis iš kitos organizacijos priimta", + "Secret from another organisation declined": "Paslaptis iš kitos organizacijos atmesta", + "Copy from another organisation updated": "Kopija iš kitos organizacijos atnaujinta", + "Copy from another organisation removed": "Kopija iš kitos organizacijos pašalinta", + "Declined: they removed their copy. Share again if they need it.": "Atmesta: gavėjas pašalino savo kopiją. Bendrinkite iš naujo, jei jos reikia.", + "Recipient at another organisation removed their copy": "Kitos organizacijos gavėjas pašalino savo kopiją", + "Removed the user from %n team folder.": "Naudotojas pašalintas iš %n komandos aplanko.", + "Removed the user from %n team folders.": "Naudotojas pašalintas iš %n komandos aplankų.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Naudotojas pašalintas iš %n komandos aplanko.", + "Naudotojas pašalintas iš %n komandos aplankų.", + "Naudotojas pašalintas iš %n komandos aplankų." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Atkurta kopija gauta iš pasibaigusio bendrinimo. Ji lieka tik skaitymui.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Nepavyko susisiekti su organizacija, kuri bendrino atkurtą kopiją. Kopija lieka tik skaitymui ir neseka jų pakeitimų.", + "Recipient at another organisation restored their copy": "Kitos organizacijos gavėjas atkūrė savo kopiją" }, "plurals": null } diff --git a/l10n/lv.js b/l10n/lv.js index 3227fb0ff..94da9d230 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Kopīgots ar grupām", + "Not shared with any group yet.": "Vēl nav kopīgots ne ar vienu grupu.", + "Revoke the share with {group}": "Atsaukt kopīgošanu ar {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Kopīgots ar {group}: {received} dalībnieki to saņēma, {skipped} nesaņēma, jo viņiem vēl nav iestatīta šifrēšana.", + "Search groups": "Meklēt grupas", + "Failed to share": "Kopīgošana neizdevās", + "Columns": "Kolonnas", + "Column {number}": "Kolonna {number}", + "Map one column to Name. Every secret needs a name.": "Piesaistiet vienu kolonnu nosaukumam. Katram noslēpumam vajag nosaukumu.", + "Notes": "Piezīmes", + "Do not import": "Neimportēt", + "Hide this value": "Paslēpt šo vērtību", + "Show this value": "Rādīt šo vērtību", + "Defaults": "Noklusējumi", + "New secrets start as this type, and your secret list opens in this view.": "Jauni noslēpumi sākas kā šis tips, un tavs noslēpumu saraksts atveras šajā skatā.", + "Default item type": "Noklusējuma vienuma tips", + "Cards": "Kartītes", + "Table": "Tabula", + "Could not save your default": "Neizdevās saglabāt tavu noklusējumu", + "Recently used": "Nesen izmantotie", + "Opened": "Atvērts", + "You have not opened any secrets yet": "Tu vēl neesi atvēris nevienu noslēpumu", + "Could not delete the item type.": "Neizdevās dzēst vienuma tipu.", + "Could not load the item types.": "Neizdevās ielādēt vienumu tipus.", + "Could not save the item type.": "Neizdevās saglabāt vienuma tipu.", + "Delete item type": "Dzēst vienuma tipu", + "Edit item type": "Rediģēt vienuma tipu", + "Fields": "Lauki", + "Fields: {count}": "Lauki: {count}", + "Hidden": "Slēpts", + "Item types": "Vienumu tipi", + "Move up": "Pārvietot augšup", + "New item type": "Jauns vienuma tips", + "No item types defined yet.": "Vēl nav definēts neviens vienuma tips.", + "Required": "Obligāts", + "Text": "Teksts", + "This field is required": "Šis lauks ir obligāts", + "Web address": "Tīmekļa adrese", + "{label} (required)": "{label} (obligāts)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Dzēst “{name}”? Šī tipa noslēpumi paliek lasāmi un kļūst par Pieteikšanās vienumiem.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Šeit definētie vienumu tipi visiem parādās logā Jauns noslēpums ar tevis izvēlētajiem laukiem.", + "Secret moved to the trash": "Noslēpums pārvietots uz miskasti", + "Secret restored from the trash": "Noslēpums atjaunots no miskastes", + "Secret deleted for good": "Noslēpums neatgriezeniski izdzēsts", + "Secret archived": "Noslēpums arhivēts", + "Secret unarchived": "Noslēpums izņemts no arhīva", + "Unarchive": "Izņemt no arhīva", + "Could not archive the secret": "Neizdevās arhivēt noslēpumu", + "Could not unarchive the secret": "Neizdevās izņemt noslēpumu no arhīva", + "Archive {count} secrets": "Arhivēt noslēpumus: {count}", + "Unarchive {count} secrets": "Izņemt no arhīva noslēpumus: {count}", + "Restore {count} secrets": "Atjaunot noslēpumus: {count}", + "Delete {count} secrets for good": "Neatgriezeniski izdzēst noslēpumus: {count}", + "Done for {ok} of {total} secrets": "Pabeigts {ok} no {total} noslēpumiem", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivētie noslēpumi pazūd no glabātavas saraksta, meklēšanas, automātiskās aizpildes un veselības pārskata. To kopīgošana saglabājas. Tos atradīsiet sadaļā Arhīvs.", + "These secrets come back to the vault list, search and autofill.": "Šie noslēpumi atgriežas glabātavas sarakstā, meklēšanā un automātiskajā aizpildē.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Šie noslēpumi atgriežas glabātavas sarakstā. Vecā kopīgošana neatgriežas, tāpēc vajadzības gadījumā kopīgojiet tos no jauna.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tas izdzēš noslēpumus kopā ar pielikumiem un versiju vēsturi. To nevar atsaukt.", + "Delete for good": "Neatgriezeniski izdzēst", + "Trash": "Miskaste", + "The trash is empty": "Miskaste ir tukša", + "No archived secrets": "Nav arhivētu noslēpumu", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izdzēstie noslēpumi gaida šeit līdz glabāšanas perioda beigām, pēc tam tie tiek neatgriezeniski izdzēsti.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivējiet noslēpumu tā informācijas panelī, lai tas nebūtu glabātavas sarakstā, meklēšanā un automātiskajā aizpildē.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Šifrēto pielikumu ierobežojumi (tiek piemēroti serverī saglabātajiem šifrētajiem baitiem), versiju vēstures glabāšana un cik ilgi izdzēstie noslēpumi paliek miskastē.", + "Days a deleted secret stays in the trash (1 to 365)": "Dienas, cik ilgi izdzēsts noslēpums paliek miskastē (no 1 līdz 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tas pārvieto noslēpumu uz miskasti un uzreiz izbeidz tā kopīgošanu. To var atjaunot no miskastes līdz glabāšanas perioda beigām: 30 dienas, ja vien administrators to nav mainījis.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tas pārvieto noslēpumus uz miskasti ({count}) un uzreiz izbeidz to kopīgošanu. Tos var atjaunot no miskastes līdz glabāšanas perioda beigām.", + "Remove {name} from favourites": "Noņemt {name} no izlases", + "Add {name} to favourites": "Pievienot {name} izlasei", + "Could not change the favourite": "Neizdevās mainīt izlasi", + "Remove from favourites": "Noņemt no izlases", + "Add to favourites": "Pievienot izlasei", + "Tags": "Birkas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Birkas nav šifrētas. Servera administratori tās var lasīt, tāpat kā mapju nosaukumus.", + "Favourites": "Izlase", + "Filter by tag": "Filtrēt pēc birkas", + "All tags": "Visas birkas", + "Last used": "Pēdējoreiz lietots", + "Tags for {count} secrets": "Birkas {count} noslēpumiem", + "Tag": "Birka", + "Remove tag": "Noņemt birku", + "Add tag": "Pievienot birku", + "Could not change the tags. Try again.": "Neizdevās mainīt birkas. Mēģiniet vēlreiz.", + "Could not approve the application. It is still in the queue.": "Neizdevās apstiprināt pieteikumu. Tas joprojām ir rindā.", + "Could not reject the application. It is still in the queue.": "Neizdevās noraidīt pieteikumu. Tas joprojām ir rindā.", + "Removed the user from {count} team folders.": "Lietotājs noņemts no {count} komandas mapēm.", + "Approve a share": "Apstiprināt kopīgošanu", + "This approval link is incomplete. Open it again from the notification.": "Šī apstiprināšanas saite ir nepilnīga. Atver to vēlreiz no paziņojuma.", + "Deny": "Noraidīt", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} pievienojās grupai, ar kuru tu kopīgo noslēpumu. Kopīgot noslēpumu arī ar viņu?", + "{requester} asks you to share a secret with {user}.": "{requester} lūdz tevi kopīgot noslēpumu ar {user}.", + "Shared. The recipient can now open the secret.": "Kopīgots. Saņēmējs tagad var atvērt noslēpumu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Saņēmējs vēl nav iestatījis Keepiq, tāpēc nekas netika kopīgots. Mēģini vēlreiz, kad viņš to būs izdarījis.", + "Could not share the secret. Only its owner can approve this.": "Neizdevās kopīgot noslēpumu. To var apstiprināt tikai tā īpašnieks.", + "Could not share the secret. Try again.": "Neizdevās kopīgot noslēpumu. Mēģini vēlreiz.", + "Denied. Nothing was shared.": "Noraidīts. Nekas netika kopīgots.", + "Could not deny the request. Try again.": "Neizdevās noraidīt pieprasījumu. Mēģini vēlreiz.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s lūdz tevi kopīgot noslēpumu \"%2$s\" ar %3$s.", + "Expires on (optional)": "Derīgs līdz (neobligāti)", + "Hand over to": "Nodot", + "Choose a recipient": "Izvēlies saņēmēju", + "Hand over temporarily": "Nodot uz laiku", + "Expiry rules": "Derīguma noteikumi", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Iestati, cik ilgi viena vienuma veida vai vienas mapes paroles drīkst pastāvēt un kad tev atgādināt. Ja attiecas vairāki datumi, tiek ņemts agrākais.", + "Delete rule": "Dzēst noteikumu", + "Set by your administrator": "Iestatījis tavs administrators", + "No expiry rules yet.": "Vēl nav derīguma noteikumu.", + "Applies to": "Attiecas uz", + "Item type": "Vienuma veids", + "Maximum age in days (empty for reminders only)": "Maksimālais vecums dienās (tukšs tikai atgādinājumiem)", + "Remind me this many days before, comma separated": "Atgādināt tik dienas iepriekš, atdalot ar komatiem", + "Save rule": "Saglabāt noteikumu", + "An item type": "Vienuma veids", + "A folder": "Mape", + "Folder {name}": "Mape {name}", + "Type {name}": "Veids {name}", + "Expires after {days} days": "Beidzas pēc {days} dienām", + "Reminders {days} days before": "Atgādinājumi {days} dienas iepriekš", + "Could not save the expiry rule.": "Neizdevās saglabāt derīguma noteikumu.", + "Could not delete the expiry rule.": "Neizdevās dzēst derīguma noteikumu.", + "All statuses": "Visi statusi", + "Compromised": "Kompromitēts", + "Could not load the members.": "Neizdevās ielādēt dalībniekus.", + "Emergency contact": "Ārkārtas kontakts", + "Leaving user": "Aizejošais lietotājs", + "No": "Nē", + "No users match this filter.": "Neviens lietotājs neatbilst šim filtram.", + "Not set up": "Nav iestatīts", + "Revoke suite": "Atsaukt komplektu", + "Revoked": "Atsaukts", + "Search users": "Meklēt lietotājus", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Skatiet, kuri lietotāji ir iestatījuši glabātavu. Sāciet aiziešanu vai atsauciet komplektu no rindas.", + "Successor": "Pēctecis", + "Team folders": "Komandas mapes", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Lietotājs joprojām ir grupā {groups}, kas ir komandas mapes dalībniece. Noņemiet viņu no grupas vai atspējojiet kontu.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu.", + "Vault status": "Glabātavas statuss", + "Yes": "Jā", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF eksports NAV ŠIFRĒTS. Katra parole un lietotājvārds lejupielādētajā datnē būs lasāmi kā vienkāršs teksts. Glabājiet to drošā vietā un izdzēsiet tūlīt pēc lietošanas.", + "Root certificate expiring soon": "Saknes sertifikāta derīgums drīz beigsies", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Glabātuves saknes sertifikāta derīgums beigsies pēc %1$d dienas(-ām). Atjaunojiet to pirms tam. Atjaunošana no jauna paraksta katru šifrēšanas komplektu.", + "Compromise recovery aborted": "Atkopšana pēc kompromitēšanas pārtraukta", + "Key rotation ended by a compromise revoke": "Atslēgas maiņu pārtrauca atsaukšana kompromitēšanas dēļ", + "Encryption suite revoke refused": "Šifrēšanas komplekta atsaukšana noraidīta", + "Master password proof refused": "Galvenās paroles pierādījums noraidīts", + "Your current master password": "Jūsu pašreizējā galvenā parole", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n ārkārtas kontaktpersonai bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa to noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Šīs ārkārtas kontaktpersonas netika pārnestas uz jūsu jauno atslēgu. 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.", + "Renew root certificate": "Atjaunot saknes sertifikātu", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Tiek izveidots jauns saknes un starpsertifikāts. Katrs aktīvais šifrēšanas komplekts tiek parakstīts vēlreiz. To nevar atsaukt.", + "Renew root": "Atjaunot sakni", + "Root renewed. {n} encryption suites signed again.": "Sakne atjaunota. Vēlreiz parakstīti šifrēšanas komplekti: {n}.", + "Could not renew the root certificate.": "Neizdevās atjaunot saknes sertifikātu.", + "Lease policy for this application": "Šīs lietotnes nomas politika", + "In force now: {default} seconds by default, {max} seconds at most.": "Pašlaik spēkā: pēc noklusējuma {default} sekundes, ne vairāk kā {max} sekundes.", + "Leases are not renewable": "Nomas nevar atjaunot", + "Lease policy saved.": "Nomas politika saglabāta.", + "Leave a field empty to use the instance value.": "Atstājiet lauku tukšu, lai izmantotu instances vērtību.", + "Instance value: {value}": "Instances vērtība: {value}", + "Renewal": "Atjaunošana", + "Use the instance value ({value})": "Izmantot instances vērtību ({value})", + "Allowed": "Atļauts", + "Not allowed": "Nav atļauts", + "Save lease policy": "Saglabāt nomas politiku", + "Only an administrator can change this policy.": "Šo politiku var mainīt tikai administrators.", + "Could not save the lease policy.": "Neizdevās saglabāt nomas politiku.", + "{member} got access from {confirmer}.": "{member} saņēma piekļuvi no {confirmer}.", + "Automatically confirm new team folder members": "Automātiski apstiprināt jaunus komandas mapju dalībniekus", + "Gave %n new member access to a team folder.": "%n jauns dalībnieks saņēma piekļuvi komandas mapei.", + "Gave %n new members access to a team folder.": "%n jauni dalībnieki saņēma piekļuvi komandas mapei.", + "Give new team folder members access without waiting for the folder owner.": "Dodiet piekļuvi jaunajiem dalībniekiem, negaidot mapes īpašnieku.", + "New team folder members": "Jauni komandas mapju dalībnieki", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Īpašnieks vai dalībnieks ar rakstīšanas tiesībām tos apstiprina no atvērtās glabātavas. Keepiq nekad neatšifrē serverī.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Gaida, kamēr dalībnieks ar rakstīšanas tiesībām atvērs Keepiq. Varat kopīgot arī tūlīt.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Daļa no reakcijas uz kompromitēšanu neizdevās ({failed} solis(-ļi)). Pārbaudiet servera žurnālu un pēc tam atsauciet komplektu vēlreiz, lai to pabeigtu.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tas atsauca arī komplektu {suite} un pabeidza atslēgu migrāciju {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus.", + "A suite revoked as compromised cannot be reinstated.": "Komplektu, kas atsaukts kā kompromitēts, nevar atjaunot.", + "Archives to keep": "Glabājamie arhīvi", + "Back up every vault automatically": "Automātiski dublēt katru glabātavu", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Dublējiet katru glabātavu pēc grafika. Arhīvos ir tikai šifrēts teksts, un tos atjauno ar occ.", + "Back up now": "Dublēt tagad", + "Backup public key (PEM, optional)": "Dublējuma publiskā atslēga (PEM, neobligāta)", + "Backup requested for the next cron run": "Dublējums pieprasīts nākamajai cron palaišanai", + "Encrypted": "Šifrēts", + "Every (hours)": "Ik pēc (stundām)", + "Last backup {when} failed: {error}": "Pēdējais dublējums {when} neizdevās: {error}", + "Last backup {when} succeeded.": "Pēdējais dublējums {when} izdevās.", + "No archives yet.": "Vēl nav arhīvu.", + "Size": "Izmērs", + "Vault backups": "Glabātavas dublējumi", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Ar atslēgu katrs arhīvs tiek šifrēts tai. Glabājiet privāto atslēgu ārpus šī servera: tā vajadzīga pārbaudei vai atjaunošanai.", + "Written": "Rakstīts", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n lietotājam darbības jomā vēl nav divpakāpju pieteikšanās, un viņš nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezerves kodi neskaitās. Ja jūsu lietotāji piesakās caur identitātes nodrošinātāju ar savu otro faktoru, izlaidiet viņu grupas.", + "Block personal vault export": "Bloķēt personiskās glabātavas eksportu", + "Keep work logins in team folders": "Glabāt darba pieteikšanās datus komandas mapēs", + "Move to a team folder": "Pārvietot uz komandas mapi", + "Not in a team folder": "Nav komandas mapē", + "Only for these groups (empty is everyone)": "Tikai šīm grupām (tukšs nozīmē visus)", + "Require two-factor login before the vault opens": "Pieprasīt divpakāpju pieteikšanos pirms glabātavas atvēršanas", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Noteikumi katrai glabātavai. Katrs attiecas uz visiem vai tikai uz izvēlētajām grupām.", + "Secret types that belong in a team folder": "Noslēpumu veidi, kas pieder komandas mapei", + "Set up two-factor login": "Iestatīt divpakāpju pieteikšanos", + "Team folder you can write to": "Komandas mape, kurā varat rakstīt", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Lietotāji nevar lejupielādēt dublējumu, CSV vai pārsūtīšanas failu. Viņu personas datu pakotne paliek pieejama.", + "Users cannot save these secret types in a personal folder.": "Lietotāji nevar saglabāt šos noslēpumu veidus personiskā mapē.", + "Vault policies": "Glabātavas politikas", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Jūsu organizācija neatļauj eksportēt jūsu personisko glabātavu. Jūsu personas datu pakotne iestatījumos paliek pieejama.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Jūsu organizācija glabā šos noslēpumus komandas mapē. Pārvietojiet katru uz komandas mapi.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Jūsu organizācija glabā šāda veida noslēpumu komandas mapē. Izvēlieties vienu no savām komandas mapēm vai tādu, kurā varat rakstīt.", + "Your organisation requires two-factor login before you can open your vault.": "Jūsu organizācija pieprasa divpakāpju pieteikšanos, pirms varat atvērt savu glabātavu.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Lietotāji izvēlas, cik ilgi paplašinājums paliek atbloķēts bezdarbības laikā. Jūs nosakāt garāko laiku, ko viņi drīkst izvēlēties.", + "Longest idle time before the extension locks": "Garākais bezdarbības laiks, pirms paplašinājums tiek bloķēts", + "1 minute": "1 minūte", + "5 minutes": "5 minūtes", + "15 minutes": "15 minūtes", + "1 hour": "1 stunda", + "4 hours": "4 stundas", + "Connector": "Savienotājs", + "Directory (tenant) ID": "Direktorija (nomnieka) ID", + "Application (client) ID": "Lietotnes (klienta) ID", + "Data collection rule immutable ID": "Datu vākšanas kārtulas nemainīgais ID", + "Stream name": "Straumes nosaukums", + "Splunk index (optional)": "Splunk indekss (neobligāts)", + "Sourcetype (optional)": "Sourcetype (neobligāts)", + "Leave blank to keep the current one": "Atstājiet tukšu, lai saglabātu pašreizējo", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF caur syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datu vākšanas galapunkts (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Klienta noslēpums (tikai rakstīšanai)", + "HEC token (write-only)": "HEC marķieris (tikai rakstīšanai)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Pārsūtiet atļautos audita notikumus uz Splunk, Microsoft Sentinel, syslog uztvērēju vai tīmekļa aizķeri. Ziņojumos ir tikai attīrīti metadati: neviena slepena vērtība, vārds, pieteikšanās vai šifrēts teksts nekad neatstāj serveri.", + "%n change waiting to sync": "%n izmaiņa gaida sinhronizāciju", + "%n changes waiting to sync": "%n izmaiņas gaida sinhronizāciju", + "Changes that could not sync": "Izmaiņas, kuras neizdevās sinhronizēt", + "Choose a version": "Izvēlēties versiju", + "Copy value": "Kopēt vērtību", + "Deleted": "Dzēsts", + "Discard": "Atmest", + "Keep my offline change": "Paturēt manu bezsaistes izmaiņu", + "Keep the server version": "Paturēt servera versiju", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq bezsaistē ir tikai lasāms. Administrators nav ieslēdzis rediģēšanu bezsaistē.", + "Let users edit secrets offline": "Ļaut lietotājiem rediģēt noslēpumus bezsaistē", + "Not synced yet": "Vēl nav sinhronizēts", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Bezsaistes izmaiņas paliek ierīcē, šifrētas lietotājam, un tiek sinhronizētas nākamajā tiešsaistes atbloķēšanā. Kopīgošanai, mapēm un pielikumiem joprojām vajag savienojumu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Bezsaistē. Rediģējumi, pārvietošana un dzēšana paliek šajā ierīcē un tiek sinhronizēti, kad atkal esat tiešsaistē. Kopīgošanai un pielikumiem vajag savienojumu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Bezsaistē. Jūsu izmaiņas paliek šajā ierīcē un tiek sinhronizētas, kad atkal esat tiešsaistē. Pēdējā sinhronizācija {when}.", + "Open my changes": "Atvērt manas izmaiņas", + "Sharing needs a connection": "Kopīgošanai vajag savienojumu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Kāds mainīja šo noslēpumu serverī pēc tam, kad tika izveidota jūsu bezsaistes kopija. Izvēlieties, kuru versiju paturēt.", + "Sync or discard your offline changes before you rotate your keys.": "Sinhronizējiet vai atmetiet bezsaistes izmaiņas, pirms nomaināt atslēgas.", + "That password did not open your changes.": "Ar šo paroli neizdevās atvērt jūsu izmaiņas.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Bezsaistes momentuzņēmums glabā šifrētus noslēpumus (atverami tikai ar atslēgu, kas atvasināta no lietotāja galvenās paroles, tieši kā serverī) un šifrē nosaukumus, URL un mapju nosaukumus glabāšanā. Bezsaistes piekļuve ir tikai lasāma, ja vien zemāk neatļaujat rediģēšanu bezsaistē. Izslēdziet to ierīcēm, kurām nekad nedrīkst kešot akreditācijas datus; izslēdzot esošās kešatmiņas tiek iztīrītas nākamajā ielādē.", + "The previous vault copy is gone, so these changes cannot be opened.": "Iepriekšējā glabātuves kopija vairs nav pieejama, tāpēc šīs izmaiņas nevar atvērt.", + "The server version": "Servera versija", + "This secret changed while you were offline": "Šis noslēpums tika mainīts, kamēr bijāt bezsaistē", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Jūs izdzēsāt šo noslēpumu bezsaistē, bet kopš tā laika tas ir mainīts serverī. Izvēlieties, kuru versiju paturēt.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Jūsu atslēgas tika nomainītas citā ierīcē. Ievadiet iepriekšējo galveno paroli, lai sinhronizētu bezsaistes izmaiņas, vai atmetiet tās.", + "Your offline change": "Jūsu bezsaistes izmaiņa", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n ārkārtas kontaktpersonai bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa to noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.","%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.","%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n vienumu nevar attēlot CXF formātā, un tas tiks izlaists.","%n vienumus nevar attēlot CXF formātā, un tie tiks izlaisti.","%n vienumus nevar attēlot CXF formātā, un tie tiks izlaisti."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n vecāka versija tika atmesta, jo var pārnest tikai neseno vēsturi.","%n vecākas versijas tika atmestas, jo var pārnest tikai neseno vēsturi.","%n vecākas versijas tika atmestas, jo var pārnest tikai neseno vēsturi."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Vēl jāšifrē un jākopīgo %n noslēpuma kopija.","Vēl jāšifrē un jākopīgo %n noslēpumu kopijas.","Vēl jāšifrē un jākopīgo %n noslēpumu kopijas."], + "_%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 noslēpumu neizdevās atšifrēt, un tas nav šajā eksportā.","%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā.","%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n noslēpumu nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tas netika migrēts.","%n noslēpumus nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tie netika migrēti.","%n noslēpumus nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tie netika migrēti."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n noslēpums netika migrēts.","%n noslēpumi netika migrēti.","%n noslēpumi netika migrēti."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n noslēpums joprojām ir šifrēts ar jūsu iepriekšējo atslēgu.","%n noslēpumi joprojām ir šifrēti ar jūsu iepriekšējo atslēgu.","%n noslēpumi joprojām ir šifrēti ar jūsu iepriekšējo atslēgu."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n noslēpums tika izlaists, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz.","%n noslēpumi tika izlaisti, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz.","%n noslēpumi tika izlaisti, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz."], + "_%n secret_::_%n secrets_": ["%n noslēpums","%n noslēpumi","%n noslēpumi"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n lietotājam darbības jomā vēl nav divpakāpju pieteikšanās, un viņš nevar atvērt glabātavu, kamēr tas ir ieslēgts.","%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts.","%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Tomēr pabeigt, zaudējot piekļuvi %n noslēpumam","Tomēr pabeigt, zaudējot piekļuvi %n noslēpumiem","Tomēr pabeigt, zaudējot piekļuvi %n noslēpumiem"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n jauns dalībnieks saņēma piekļuvi komandas mapei.","%n jauni dalībnieki saņēma piekļuvi komandas mapei.","%n jauni dalībnieki saņēma piekļuvi komandas mapei."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Atslēgas rotācija pabeigta. %n noslēpums tika atkārtoti šifrēts ar jūsu jauno atslēgu.","Atslēgas rotācija pabeigta. %n noslēpumi tika atkārtoti šifrēti ar jūsu jauno atslēgu.","Atslēgas rotācija pabeigta. %n noslēpumi tika atkārtoti šifrēti ar jūsu jauno atslēgu."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktu.","Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus.","Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktu.","Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus.","Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["konstatēts noplūdēs %n reizi","konstatēts noplūdēs %n reizes","konstatēts noplūdēs %n reizes"], + "_shared with %n secret_::_shared with %n secrets_": ["kopīgots ar %n noslēpumu","kopīgots ar %n noslēpumiem","kopīgots ar %n noslēpumiem"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Šajā mapē tieši ir %n noslēpums.","Šajā mapē tieši ir %n noslēpumi.","Šajā mapē tieši ir %n noslēpumi."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n izmaiņa gaida sinhronizāciju","%n izmaiņas gaida sinhronizāciju","%n izmaiņas gaida sinhronizāciju"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Lietotājs joprojām ir grupā {groups}, kas ir komandas mapes dalībniece. Noņemiet viņu no grupas vai atspējojiet kontu.","Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu.","Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu."], + "Allow approval from another device": "Atļaut apstiprināšanu no citas ierīces", + "App": "Lietotne", + "Approve a new device": "Apstiprināt jaunu ierīci", + "Approve from another device": "Apstiprināt no citas ierīces", + "Asked at": "Pieprasīts", + "Check that the new device shows these words:": "Pārbaudiet, vai jaunā ierīce rāda šos vārdus:", + "Denied. If you did not ask, end your other sessions:": "Noraidīts. Ja jūs to nepieprasījāt, beidziet citas sesijas:", + "Device": "Ierīce", + "IP address": "IP adrese", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Ļaut lietotājiem atbloķēt jaunu pārlūku, apstiprinot to no ierīces, kurā Keepiq jau ir atbloķēts.", + "New device approval": "Jaunu ierīču apstiprināšana", + "Nextcloud security settings": "Nextcloud drošības iestatījumi", + "Only approve a device you are using right now.": "Apstipriniet tikai ierīci, kuru izmantojat tieši tagad.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Atveriet Keepiq ierīcē, kurā tas ir atbloķēts, un apstipriniet šo ierīci. Pārbaudiet, vai tā rāda tos pašus vārdus:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Apstiprinošā ierīce aizzīmogo atbloķēšanas atslēgu jaunajai ierīcei. Serveris to tikai nodod tālāk un nevar to atvērt.", + "The master password is not right, or the request has ended.": "Galvenā parole nav pareiza, vai pieprasījums ir beidzies.", + "The request expired. Ask again or use your master password.": "Pieprasījuma derīgums beidzās. Pieprasiet vēlreiz vai izmantojiet galveno paroli.", + "The request was denied.": "Pieprasījums tika noraidīts.", + "Too many requests. Try again in an hour or use your master password.": "Pārāk daudz pieprasījumu. Mēģiniet vēlreiz pēc stundas vai izmantojiet galveno paroli.", + "Unknown device": "Nezināma ierīce", + "Web app": "Tīmekļa lietotne", + "A device": "Ierīce", + "A new device asks to open your vault": "Jauna ierīce lūdz atvērt jūsu glabātuvi", + "%s asks to be approved. Only approve a device you are using right now.": "%s lūdz apstiprinājumu. Apstipriniet tikai ierīci, kuru izmantojat tieši tagad.", + "Access ends on (optional)": "Piekļuve beidzas (neobligāti)", + "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.": "Keepiq lietotnes nerādīs un nekopēs paroli. Kāds ar tehniskām zināšanām to tomēr var nolasīt savā ierīcē. Nomainiet to, kad piekļuve beidzas.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Šo noslēpumu var tikai izmantot. Piesakieties, izmantojot Keepiq pārlūka paplašinājumu.", + "Until {date}": "Līdz {date}", + "Use only": "Tikai lietošana", + "Use only (can sign in, cannot view or copy)": "Tikai lietošana (var pieteikties, nevar skatīt vai kopēt)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ar šiem pieteikšanās datiem varat pieteikties, izmantojot Keepiq pārlūka paplašinājumu. Īpašnieks izvēlējās neļaut jums tos skatīt vai kopēt.", + "Your access ends on {date}": "Jūsu piekļuve beidzas {date}", + "Your access to this secret has ended": "Jūsu piekļuve šim noslēpumam ir beigusies", + "Your access to \"%s\" ends tomorrow": "Jūsu piekļuve “%s” beidzas rīt", + "Your access to \"%s\" has ended": "Jūsu piekļuve “%s” ir beigusies", + "%1$s no longer has access to \"%2$s\"": "%1$s vairs nav piekļuves “%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s varēja redzēt šo paroli. Nomainiet to, ja %1$s to vairs nedrīkst zināt.", + "%s could not view this password in Keepiq.": "%s nevarēja skatīt šo paroli Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} no {threshold} apstiprinājumiem", + "a recovery officer": "atkopšanas atbildīgais", + "Account recovery": "Konta atkopšana", + "Approvals needed": "Nepieciešamie apstiprinājumi", + "Ask {user} which words they see, by phone or in person. They must be:": "Pajautājiet {user} pa tālruni vai klātienē, kādus vārdus viņš redz. Tiem jābūt:", + "Check again": "Pārbaudīt vēlreiz", + "Create the recovery key": "Izveidot atkopšanas atslēgu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Izveidojiet atkopšanas atslēgu. Jūsu pārlūks to izveido un katram atbildīgajam dod kopiju, ko var atvērt tikai viņš.", + "Decline": "Noraidīt", + "Enrol in account recovery": "Pieteikties konta atkopšanai", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Piesakieties, lai jūsu organizācija varētu palīdzēt atgūt glabātavu, ja aizmirstat galveno paroli.", + "Every user is enrolled": "Visi lietotāji ir pieteikušies", + "Finish the recovery in the browser you asked from.": "Pabeidziet atkopšanu pārlūkā, no kura to pieprasījāt.", + "Forgot your master password?": "Aizmirsāt galveno paroli?", + "Hand the key over": "Nodot atslēgu", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ļaujiet lietotājiem, kuri aizmirsa galveno paroli, atgūt glabātavu ar jūsu norīkoto atkopšanas atbildīgo apstiprinājumu.", + "New master password": "Jaunā galvenā parole", + "No one is asking to recover their account.": "Neviens neprasa atkopt savu kontu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Atkopšanas atslēgas vēl nav. Viens no atbildīgajiem to izveido savos Keepiq iestatījumos.", + "Off": "Izslēgts", + "Officer {user} has no encryption set up yet.": "Atbildīgajam {user} vēl nav iestatīta šifrēšana.", + "Officers (user IDs, separated by commas)": "Atbildīgie (lietotāju ID, atdalīti ar komatiem)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicējiet šo pirkstu nospiedumu iekšēji, lai lietotāji to varētu pārbaudīt pirms pieteikšanās.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Atkopts ar {officer} palīdzību. Tagad nomainiet glabātavas atslēgu sadaļā Iestatījumi, Drošība: \"Mana galvenā parole ir kompromitēta\".", + "Recovery key fingerprint: {fingerprint}": "Atkopšanas atslēgas pirkstu nospiedums: {fingerprint}", + "Recovery officer": "Atkopšanas atbildīgais", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Noņemtie atbildīgie tagad zaudē savu kopiju, bet varēja to atvērt agrāk. Palūdziet atbildīgajam izveidot jaunu atkopšanas atslēgu.", + "Repeat the new master password": "Atkārtojiet jauno galveno paroli", + "Retire this recovery key": "Izņemt šo atkopšanas atslēgu no lietošanas", + "Set the new master password": "Iestatīt jauno galveno paroli", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Atkopšanas sertifikātu nav izsniedzis šis Keepiq. Nepiesakieties un informējiet administratoru.", + "The words match, approve": "Vārdi sakrīt, apstiprināt", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Šis lietotājs ir pieteicies konta atkopšanai. Atkopšana saglabā viņa noslēpumus; atsaukšana dzēš viņa pieteikumu.", + "Users may enrol": "Lietotāji var pieteikties", + "Withdraw from account recovery": "Atteikties no konta atkopšanas", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jūs esat pieteicies konta atkopšanai. Atkopšanas atslēgas pirkstu nospiedums: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jūs esat pieteicies. Ja aizmirsīsiet galveno paroli, jūsu organizācija var palīdzēt atgūt glabātavu.", + "Your key is back. Choose a new master password.": "Jūsu atslēga ir atgūta. Izvēlieties jaunu galveno paroli.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Jūsu atkopšanas atbildīgie ir informēti. Nolasiet viņiem šos vārdus, kad viņi zvana vai tiekas ar jums:", + "You are now an account recovery officer": "Tagad esat konta atkopšanas atbildīgais", + "%s asks to recover their account. Compare the words with them before you approve.": "%s lūdz atkopt savu kontu. Pirms apstiprināšanas salīdziniet ar viņu vārdus.", + "A user": "Lietotājs", + "Your account recovery request was declined": "Jūsu konta atkopšanas pieprasījums tika noraidīts", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Jūsu konta atkopšana ir gatava. Atveriet Keepiq pārlūkā, no kura to pieprasījāt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} lūdz vienreiz atbloķēt jaunu ierīci. Galvenā parole paliek nemainīga.", + "Ask your organisation instead": "Tā vietā jautājiet savai organizācijai", + "The request ended. Ask again or use your master password.": "Pieprasījums beidzās. Lūdziet vēlreiz vai izmantojiet savu galveno paroli.", + "Added by {user}": "Pievienoja {user}", + "Editor": "Redaktors", + "Manager": "Pārvaldnieks", + "Role of {member}": "{member} loma", + "Team folders you manage": "Jūsu pārvaldītās komandas mapes", + "Viewer": "Skatītājs", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Jums nav šo noslēpumu kopijas, tāpēc jaunie dalībnieki tos vēl nav saņēmuši. Īpašnieks var tos kopīgot: {names}", + "Admin areas": "Pārvaldības jomas", + "Give a group only the parts of Keepiq administration it needs.": "Piešķiriet grupai tikai tās Keepiq pārvaldības daļas, kas tai vajadzīgas.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleģējiet vienu vai vairākas jomas grupai pārvaldības tiesību lapā. Instances administratoriem ir visas jomas.", + "Open administration privileges": "Atvērt pārvaldības tiesības", + "Policies": "Politikas", + "Applications and machine access": "Lietotnes un mašīnu piekļuve", + "People and offboarding": "Cilvēki un aiziešana", + "Audit and compliance": "Audits un atbilstība", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versija, sertificēšanas iestāde, pielikumi, bezsaistes kešatmiņa, noplūžu pārbaude, noslēpumu tipi un rezerves kopijas", + "master password, organisation password, vault policies, rotation, version history and trash": "galvenā parole, organizācijas parole, glabātuves politikas, rotācija, versiju vēsture un miskaste", + "application queue, application requests and machine leases": "lietotņu rinda, lietotņu pieprasījumi un mašīnu nomas", + "team offboarding, encryption suites and admin handover": "aiziešana no komandas, šifrēšanas komplekti un administratora pārņemšana", + "audit log, compliance reports, SIEM export and honey alerts": "audita žurnāls, atbilstības atskaites, SIEM eksports un ēsmas brīdinājumi", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cik noslēpuma versiju tiek glabātas, cik ilgi, un cik ilgi dzēsti noslēpumi paliek miskastē.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Šifrētu pielikumu ierobežojumi, ko serveris piemēro saglabātos šifrētos baitos.", + "Type the suite ID again to confirm": "Lai apstiprinātu, vēlreiz ievadiet komplekta ID", + "This does not match the suite ID.": "Tas nesakrīt ar komplekta ID.", + "Confirm with your master password": "Apstipriniet ar galveno paroli", + "Confirm": "Apstiprināt", + "That master password is not right.": "Šī galvenā parole nav pareiza.", + "You are sharing with someone new. Enter your master password to confirm.": "Jūs kopīgojat ar jaunu personu. Lai apstiprinātu, ievadiet galveno paroli.", + "Enter your master password to confirm this share.": "Lai apstiprinātu šo kopīgošanu, ievadiet galveno paroli.", + "Enter your master password to confirm this delegation.": "Lai apstiprinātu šo deleģēšanu, ievadiet galveno paroli.", + "Approve {member}": "Apstiprināt {member}", + "Recipient": "Saņēmējs", + "No vault yet": "Vēl nav glabātuves", + "No matching users": "Nav atbilstošu lietotāju", + "Partner organisations": "Partneru organizācijas", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Apmainieties ar noslēpumiem ar citu Keepiq. Abi administratori pievieno viens otru un pirms saglabāšanas salīdzina saknes pirkstu nospiedumus pa tālruni vai klātienē.", + "Federation needs Nextcloud 33 or later.": "Federācijai nepieciešams Nextcloud 33 vai jaunāks.", + "Your root fingerprint": "Jūsu saknes pirkstu nospiedums", + "No partners yet.": "Vēl nav partneru.", + "Users here may share to this partner": "Šejienes lietotāji drīkst kopīgot ar šo partneri", + "This partner may share to users here": "Šis partneris drīkst kopīgot ar šejienes lietotājiem", + "Partner address": "Partnera adrese", + "Check partner": "Pārbaudīt partneri", + "Partner root fingerprint": "Partnera saknes pirkstu nospiedums", + "I compared this fingerprint with the partner's administrator": "Es salīdzināju šo pirkstu nospiedumu ar partnera administratoru", + "Add partner": "Pievienot partneri", + "A secret from another organisation": "Noslēpums no citas organizācijas", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s kopīgoja ar jums \"%2$s\". Pieņemiet to sadaļā Ienākošie no citām organizācijām.", + "Incoming from other organisations": "Ienākošie no citām organizācijām", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partneru organizāciju cilvēki var kopīgot ar jums noslēpumu. Pieņemiet to, lai savā glabātuvē paturētu tikai lasāmu kopiju.", + "Nothing shared with you yet": "Ar jums vēl nekas nav kopīgots", + "Secrets that people in partner organisations share with you appear here.": "Šeit parādās noslēpumi, ko ar jums kopīgo partneru organizāciju cilvēki.", + "From {sender}": "No {sender}", + "Accept": "Pieņemt", + "Open in vault": "Atvērt glabātuvē", + "The other organisation did not hand over the secret. Try again later.": "Otra organizācija nenodeva noslēpumu. Mēģiniet vēlreiz vēlāk.", + "Set up your vault before you accept a shared secret.": "Pirms pieņemat kopīgotu noslēpumu, iestatiet savu glabātuvi.", + "Something went wrong. Try again.": "Kaut kas nogāja greizi. Mēģiniet vēlreiz.", + "Waiting for your answer": "Gaida jūsu atbildi", + "In your vault, read-only": "Jūsu glabātuvē, tikai lasāms", + "Withdrawn by the sender": "Sūtītājs atsauca", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} kopīgoja šo no citas organizācijas. Jūs to varat lasīt, bet ne mainīt vai kopīgot.", + "Someone": "Kāds", + "Share with someone at another organisation": "Kopīgot ar kādu no citas organizācijas", + "Their account at the other organisation": "Šīs personas konts otrā organizācijā", + "Check account": "Pārbaudīt kontu", + "Certificate fingerprint of {account}": "Konta {account} sertifikāta pirksta nospiedums", + "Compare it with them by phone if you want to be sure.": "Ja vēlaties būt droši, salīdziniet to ar šo personu pa tālruni.", + "Shared. {account} can accept it in their own vault.": "Kopīgots. {account} to var pieņemt savā glabātuvē.", + "The certificate could not be verified. Nothing was shared.": "Sertifikātu neizdevās pārbaudīt. Nekas netika kopīgots.", + "That organisation is not one of your partners.": "Šī organizācija nav viens no jūsu partneriem.", + "No one with that account can receive secrets from you.": "Neviens ar šo kontu nevar saņemt no jums noslēpumus.", + "The other organisation did not answer. Try again later.": "Otra organizācija neatbildēja. Mēģiniet vēlreiz vēlāk.", + "This secret is already shared with that account.": "Šis noslēpums jau ir kopīgots ar šo kontu.", + "Other organisations": "Citas organizācijas", + "Receive secrets from other organisations": "Saņemt noslēpumus no citām organizācijām", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Tad partneru organizāciju cilvēki var atrast jūsu kontu un kopīgot ar jums noslēpumus. Katru no tiem jūs pieņemat paši.", + "Shared": "Kopīgots", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Apturēts: mainījās viņu sertifikāts vai partnerība. Varat atsaukt kopīgošanu vai kopīgot vēlreiz.", + "Their organisation did not get the last change. Revoke it or share again.": "Viņu organizācija nesaņēma pēdējās izmaiņas. Varat atsaukt kopīgošanu vai kopīgot vēlreiz.", + "Being withdrawn": "Tiek atsaukts", + "Shared with another organisation": "Kopīgots ar citu organizāciju", + "Change sent to another organisation": "Izmaiņas nosūtītas citai organizācijai", + "Share with another organisation revoked": "Kopīgošana ar citu organizāciju atsaukta", + "Share with another organisation paused": "Kopīgošana ar citu organizāciju apturēta", + "Another organisation did not get a change": "Cita organizācija nesaņēma izmaiņas", + "Secret received from another organisation": "Saņemts noslēpums no citas organizācijas", + "Secret from another organisation accepted": "Noslēpums no citas organizācijas pieņemts", + "Secret from another organisation declined": "Noslēpums no citas organizācijas noraidīts", + "Copy from another organisation updated": "Kopija no citas organizācijas atjaunināta", + "Copy from another organisation removed": "Kopija no citas organizācijas noņemta", + "Declined: they removed their copy. Share again if they need it.": "Noraidīts: saņēmējs noņēma savu kopiju. Kopīgojiet vēlreiz, ja tā ir vajadzīga.", + "Recipient at another organisation removed their copy": "Citas organizācijas saņēmējs noņēma savu kopiju", + "Removed the user from %n team folder.": "Lietotājs noņemts no %n komandas mapes.", + "Removed the user from %n team folders.": "Lietotājs noņemts no %n komandas mapēm.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Lietotājs noņemts no %n komandas mapes.","Lietotājs noņemts no %n komandas mapēm.","Lietotājs noņemts no %n komandas mapēm."], + "A restored copy came from a share that has ended. It stays read-only.": "Atjaunotā kopija nāk no koplietojuma, kas ir beidzies. Tā paliek tikai lasāma.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizāciju, kas kopīgoja atjaunoto kopiju, neizdevās sasniegt. Kopija paliek tikai lasāma un neseko viņu izmaiņām.", + "Recipient at another organisation restored their copy": "Citas organizācijas saņēmējs atjaunoja savu kopiju" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==0 ? 0 : n%10==1 && n%100!=11 ? 1 : 2);" ) diff --git a/l10n/lv.json b/l10n/lv.json index 4e3f3fa42..1fd4436d2 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Kopīgots ar grupām", + "Not shared with any group yet.": "Vēl nav kopīgots ne ar vienu grupu.", + "Revoke the share with {group}": "Atsaukt kopīgošanu ar {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Kopīgots ar {group}: {received} dalībnieki to saņēma, {skipped} nesaņēma, jo viņiem vēl nav iestatīta šifrēšana.", + "Search groups": "Meklēt grupas", + "Failed to share": "Kopīgošana neizdevās", + "Columns": "Kolonnas", + "Column {number}": "Kolonna {number}", + "Map one column to Name. Every secret needs a name.": "Piesaistiet vienu kolonnu nosaukumam. Katram noslēpumam vajag nosaukumu.", + "Notes": "Piezīmes", + "Do not import": "Neimportēt", + "Hide this value": "Paslēpt šo vērtību", + "Show this value": "Rādīt šo vērtību", + "Defaults": "Noklusējumi", + "New secrets start as this type, and your secret list opens in this view.": "Jauni noslēpumi sākas kā šis tips, un tavs noslēpumu saraksts atveras šajā skatā.", + "Default item type": "Noklusējuma vienuma tips", + "Cards": "Kartītes", + "Table": "Tabula", + "Could not save your default": "Neizdevās saglabāt tavu noklusējumu", + "Recently used": "Nesen izmantotie", + "Opened": "Atvērts", + "You have not opened any secrets yet": "Tu vēl neesi atvēris nevienu noslēpumu", + "Could not delete the item type.": "Neizdevās dzēst vienuma tipu.", + "Could not load the item types.": "Neizdevās ielādēt vienumu tipus.", + "Could not save the item type.": "Neizdevās saglabāt vienuma tipu.", + "Delete item type": "Dzēst vienuma tipu", + "Edit item type": "Rediģēt vienuma tipu", + "Fields": "Lauki", + "Fields: {count}": "Lauki: {count}", + "Hidden": "Slēpts", + "Item types": "Vienumu tipi", + "Move up": "Pārvietot augšup", + "New item type": "Jauns vienuma tips", + "No item types defined yet.": "Vēl nav definēts neviens vienuma tips.", + "Required": "Obligāts", + "Text": "Teksts", + "This field is required": "Šis lauks ir obligāts", + "Web address": "Tīmekļa adrese", + "{label} (required)": "{label} (obligāts)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Dzēst “{name}”? Šī tipa noslēpumi paliek lasāmi un kļūst par Pieteikšanās vienumiem.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Šeit definētie vienumu tipi visiem parādās logā Jauns noslēpums ar tevis izvēlētajiem laukiem.", + "Secret moved to the trash": "Noslēpums pārvietots uz miskasti", + "Secret restored from the trash": "Noslēpums atjaunots no miskastes", + "Secret deleted for good": "Noslēpums neatgriezeniski izdzēsts", + "Secret archived": "Noslēpums arhivēts", + "Secret unarchived": "Noslēpums izņemts no arhīva", + "Unarchive": "Izņemt no arhīva", + "Could not archive the secret": "Neizdevās arhivēt noslēpumu", + "Could not unarchive the secret": "Neizdevās izņemt noslēpumu no arhīva", + "Archive {count} secrets": "Arhivēt noslēpumus: {count}", + "Unarchive {count} secrets": "Izņemt no arhīva noslēpumus: {count}", + "Restore {count} secrets": "Atjaunot noslēpumus: {count}", + "Delete {count} secrets for good": "Neatgriezeniski izdzēst noslēpumus: {count}", + "Done for {ok} of {total} secrets": "Pabeigts {ok} no {total} noslēpumiem", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivētie noslēpumi pazūd no glabātavas saraksta, meklēšanas, automātiskās aizpildes un veselības pārskata. To kopīgošana saglabājas. Tos atradīsiet sadaļā Arhīvs.", + "These secrets come back to the vault list, search and autofill.": "Šie noslēpumi atgriežas glabātavas sarakstā, meklēšanā un automātiskajā aizpildē.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Šie noslēpumi atgriežas glabātavas sarakstā. Vecā kopīgošana neatgriežas, tāpēc vajadzības gadījumā kopīgojiet tos no jauna.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Tas izdzēš noslēpumus kopā ar pielikumiem un versiju vēsturi. To nevar atsaukt.", + "Delete for good": "Neatgriezeniski izdzēst", + "Trash": "Miskaste", + "The trash is empty": "Miskaste ir tukša", + "No archived secrets": "Nav arhivētu noslēpumu", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izdzēstie noslēpumi gaida šeit līdz glabāšanas perioda beigām, pēc tam tie tiek neatgriezeniski izdzēsti.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivējiet noslēpumu tā informācijas panelī, lai tas nebūtu glabātavas sarakstā, meklēšanā un automātiskajā aizpildē.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Šifrēto pielikumu ierobežojumi (tiek piemēroti serverī saglabātajiem šifrētajiem baitiem), versiju vēstures glabāšana un cik ilgi izdzēstie noslēpumi paliek miskastē.", + "Days a deleted secret stays in the trash (1 to 365)": "Dienas, cik ilgi izdzēsts noslēpums paliek miskastē (no 1 līdz 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Tas pārvieto noslēpumu uz miskasti un uzreiz izbeidz tā kopīgošanu. To var atjaunot no miskastes līdz glabāšanas perioda beigām: 30 dienas, ja vien administrators to nav mainījis.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Tas pārvieto noslēpumus uz miskasti ({count}) un uzreiz izbeidz to kopīgošanu. Tos var atjaunot no miskastes līdz glabāšanas perioda beigām.", + "Remove {name} from favourites": "Noņemt {name} no izlases", + "Add {name} to favourites": "Pievienot {name} izlasei", + "Could not change the favourite": "Neizdevās mainīt izlasi", + "Remove from favourites": "Noņemt no izlases", + "Add to favourites": "Pievienot izlasei", + "Tags": "Birkas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Birkas nav šifrētas. Servera administratori tās var lasīt, tāpat kā mapju nosaukumus.", + "Favourites": "Izlase", + "Filter by tag": "Filtrēt pēc birkas", + "All tags": "Visas birkas", + "Last used": "Pēdējoreiz lietots", + "Tags for {count} secrets": "Birkas {count} noslēpumiem", + "Tag": "Birka", + "Remove tag": "Noņemt birku", + "Add tag": "Pievienot birku", + "Could not change the tags. Try again.": "Neizdevās mainīt birkas. Mēģiniet vēlreiz.", + "Could not approve the application. It is still in the queue.": "Neizdevās apstiprināt pieteikumu. Tas joprojām ir rindā.", + "Could not reject the application. It is still in the queue.": "Neizdevās noraidīt pieteikumu. Tas joprojām ir rindā.", + "Removed the user from {count} team folders.": "Lietotājs noņemts no {count} komandas mapēm.", + "Approve a share": "Apstiprināt kopīgošanu", + "This approval link is incomplete. Open it again from the notification.": "Šī apstiprināšanas saite ir nepilnīga. Atver to vēlreiz no paziņojuma.", + "Deny": "Noraidīt", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} pievienojās grupai, ar kuru tu kopīgo noslēpumu. Kopīgot noslēpumu arī ar viņu?", + "{requester} asks you to share a secret with {user}.": "{requester} lūdz tevi kopīgot noslēpumu ar {user}.", + "Shared. The recipient can now open the secret.": "Kopīgots. Saņēmējs tagad var atvērt noslēpumu.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Saņēmējs vēl nav iestatījis Keepiq, tāpēc nekas netika kopīgots. Mēģini vēlreiz, kad viņš to būs izdarījis.", + "Could not share the secret. Only its owner can approve this.": "Neizdevās kopīgot noslēpumu. To var apstiprināt tikai tā īpašnieks.", + "Could not share the secret. Try again.": "Neizdevās kopīgot noslēpumu. Mēģini vēlreiz.", + "Denied. Nothing was shared.": "Noraidīts. Nekas netika kopīgots.", + "Could not deny the request. Try again.": "Neizdevās noraidīt pieprasījumu. Mēģini vēlreiz.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s lūdz tevi kopīgot noslēpumu \"%2$s\" ar %3$s.", + "Expires on (optional)": "Derīgs līdz (neobligāti)", + "Hand over to": "Nodot", + "Choose a recipient": "Izvēlies saņēmēju", + "Hand over temporarily": "Nodot uz laiku", + "Expiry rules": "Derīguma noteikumi", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Iestati, cik ilgi viena vienuma veida vai vienas mapes paroles drīkst pastāvēt un kad tev atgādināt. Ja attiecas vairāki datumi, tiek ņemts agrākais.", + "Delete rule": "Dzēst noteikumu", + "Set by your administrator": "Iestatījis tavs administrators", + "No expiry rules yet.": "Vēl nav derīguma noteikumu.", + "Applies to": "Attiecas uz", + "Item type": "Vienuma veids", + "Maximum age in days (empty for reminders only)": "Maksimālais vecums dienās (tukšs tikai atgādinājumiem)", + "Remind me this many days before, comma separated": "Atgādināt tik dienas iepriekš, atdalot ar komatiem", + "Save rule": "Saglabāt noteikumu", + "An item type": "Vienuma veids", + "A folder": "Mape", + "Folder {name}": "Mape {name}", + "Type {name}": "Veids {name}", + "Expires after {days} days": "Beidzas pēc {days} dienām", + "Reminders {days} days before": "Atgādinājumi {days} dienas iepriekš", + "Could not save the expiry rule.": "Neizdevās saglabāt derīguma noteikumu.", + "Could not delete the expiry rule.": "Neizdevās dzēst derīguma noteikumu.", + "All statuses": "Visi statusi", + "Compromised": "Kompromitēts", + "Could not load the members.": "Neizdevās ielādēt dalībniekus.", + "Emergency contact": "Ārkārtas kontakts", + "Leaving user": "Aizejošais lietotājs", + "No": "Nē", + "No users match this filter.": "Neviens lietotājs neatbilst šim filtram.", + "Not set up": "Nav iestatīts", + "Revoke suite": "Atsaukt komplektu", + "Revoked": "Atsaukts", + "Search users": "Meklēt lietotājus", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Skatiet, kuri lietotāji ir iestatījuši glabātavu. Sāciet aiziešanu vai atsauciet komplektu no rindas.", + "Successor": "Pēctecis", + "Team folders": "Komandas mapes", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Lietotājs joprojām ir grupā {groups}, kas ir komandas mapes dalībniece. Noņemiet viņu no grupas vai atspējojiet kontu.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu.", + "Vault status": "Glabātavas statuss", + "Yes": "Jā", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF eksports NAV ŠIFRĒTS. Katra parole un lietotājvārds lejupielādētajā datnē būs lasāmi kā vienkāršs teksts. Glabājiet to drošā vietā un izdzēsiet tūlīt pēc lietošanas.", + "Root certificate expiring soon": "Saknes sertifikāta derīgums drīz beigsies", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Glabātuves saknes sertifikāta derīgums beigsies pēc %1$d dienas(-ām). Atjaunojiet to pirms tam. Atjaunošana no jauna paraksta katru šifrēšanas komplektu.", + "Compromise recovery aborted": "Atkopšana pēc kompromitēšanas pārtraukta", + "Key rotation ended by a compromise revoke": "Atslēgas maiņu pārtrauca atsaukšana kompromitēšanas dēļ", + "Encryption suite revoke refused": "Šifrēšanas komplekta atsaukšana noraidīta", + "Master password proof refused": "Galvenās paroles pierādījums noraidīts", + "Your current master password": "Jūsu pašreizējā galvenā parole", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n ārkārtas kontaktpersonai bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa to noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Šīs ārkārtas kontaktpersonas netika pārnestas uz jūsu jauno atslēgu. 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.", + "Renew root certificate": "Atjaunot saknes sertifikātu", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Tiek izveidots jauns saknes un starpsertifikāts. Katrs aktīvais šifrēšanas komplekts tiek parakstīts vēlreiz. To nevar atsaukt.", + "Renew root": "Atjaunot sakni", + "Root renewed. {n} encryption suites signed again.": "Sakne atjaunota. Vēlreiz parakstīti šifrēšanas komplekti: {n}.", + "Could not renew the root certificate.": "Neizdevās atjaunot saknes sertifikātu.", + "Lease policy for this application": "Šīs lietotnes nomas politika", + "In force now: {default} seconds by default, {max} seconds at most.": "Pašlaik spēkā: pēc noklusējuma {default} sekundes, ne vairāk kā {max} sekundes.", + "Leases are not renewable": "Nomas nevar atjaunot", + "Lease policy saved.": "Nomas politika saglabāta.", + "Leave a field empty to use the instance value.": "Atstājiet lauku tukšu, lai izmantotu instances vērtību.", + "Instance value: {value}": "Instances vērtība: {value}", + "Renewal": "Atjaunošana", + "Use the instance value ({value})": "Izmantot instances vērtību ({value})", + "Allowed": "Atļauts", + "Not allowed": "Nav atļauts", + "Save lease policy": "Saglabāt nomas politiku", + "Only an administrator can change this policy.": "Šo politiku var mainīt tikai administrators.", + "Could not save the lease policy.": "Neizdevās saglabāt nomas politiku.", + "{member} got access from {confirmer}.": "{member} saņēma piekļuvi no {confirmer}.", + "Automatically confirm new team folder members": "Automātiski apstiprināt jaunus komandas mapju dalībniekus", + "Gave %n new member access to a team folder.": "%n jauns dalībnieks saņēma piekļuvi komandas mapei.", + "Gave %n new members access to a team folder.": "%n jauni dalībnieki saņēma piekļuvi komandas mapei.", + "Give new team folder members access without waiting for the folder owner.": "Dodiet piekļuvi jaunajiem dalībniekiem, negaidot mapes īpašnieku.", + "New team folder members": "Jauni komandas mapju dalībnieki", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Īpašnieks vai dalībnieks ar rakstīšanas tiesībām tos apstiprina no atvērtās glabātavas. Keepiq nekad neatšifrē serverī.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Gaida, kamēr dalībnieks ar rakstīšanas tiesībām atvērs Keepiq. Varat kopīgot arī tūlīt.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Daļa no reakcijas uz kompromitēšanu neizdevās ({failed} solis(-ļi)). Pārbaudiet servera žurnālu un pēc tam atsauciet komplektu vēlreiz, lai to pabeigtu.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tas atsauca arī komplektu {suite} un pabeidza atslēgu migrāciju {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus.", + "A suite revoked as compromised cannot be reinstated.": "Komplektu, kas atsaukts kā kompromitēts, nevar atjaunot.", + "Archives to keep": "Glabājamie arhīvi", + "Back up every vault automatically": "Automātiski dublēt katru glabātavu", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Dublējiet katru glabātavu pēc grafika. Arhīvos ir tikai šifrēts teksts, un tos atjauno ar occ.", + "Back up now": "Dublēt tagad", + "Backup public key (PEM, optional)": "Dublējuma publiskā atslēga (PEM, neobligāta)", + "Backup requested for the next cron run": "Dublējums pieprasīts nākamajai cron palaišanai", + "Encrypted": "Šifrēts", + "Every (hours)": "Ik pēc (stundām)", + "Last backup {when} failed: {error}": "Pēdējais dublējums {when} neizdevās: {error}", + "Last backup {when} succeeded.": "Pēdējais dublējums {when} izdevās.", + "No archives yet.": "Vēl nav arhīvu.", + "Size": "Izmērs", + "Vault backups": "Glabātavas dublējumi", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Ar atslēgu katrs arhīvs tiek šifrēts tai. Glabājiet privāto atslēgu ārpus šī servera: tā vajadzīga pārbaudei vai atjaunošanai.", + "Written": "Rakstīts", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n lietotājam darbības jomā vēl nav divpakāpju pieteikšanās, un viņš nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezerves kodi neskaitās. Ja jūsu lietotāji piesakās caur identitātes nodrošinātāju ar savu otro faktoru, izlaidiet viņu grupas.", + "Block personal vault export": "Bloķēt personiskās glabātavas eksportu", + "Keep work logins in team folders": "Glabāt darba pieteikšanās datus komandas mapēs", + "Move to a team folder": "Pārvietot uz komandas mapi", + "Not in a team folder": "Nav komandas mapē", + "Only for these groups (empty is everyone)": "Tikai šīm grupām (tukšs nozīmē visus)", + "Require two-factor login before the vault opens": "Pieprasīt divpakāpju pieteikšanos pirms glabātavas atvēršanas", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Noteikumi katrai glabātavai. Katrs attiecas uz visiem vai tikai uz izvēlētajām grupām.", + "Secret types that belong in a team folder": "Noslēpumu veidi, kas pieder komandas mapei", + "Set up two-factor login": "Iestatīt divpakāpju pieteikšanos", + "Team folder you can write to": "Komandas mape, kurā varat rakstīt", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Lietotāji nevar lejupielādēt dublējumu, CSV vai pārsūtīšanas failu. Viņu personas datu pakotne paliek pieejama.", + "Users cannot save these secret types in a personal folder.": "Lietotāji nevar saglabāt šos noslēpumu veidus personiskā mapē.", + "Vault policies": "Glabātavas politikas", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Jūsu organizācija neatļauj eksportēt jūsu personisko glabātavu. Jūsu personas datu pakotne iestatījumos paliek pieejama.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Jūsu organizācija glabā šos noslēpumus komandas mapē. Pārvietojiet katru uz komandas mapi.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Jūsu organizācija glabā šāda veida noslēpumu komandas mapē. Izvēlieties vienu no savām komandas mapēm vai tādu, kurā varat rakstīt.", + "Your organisation requires two-factor login before you can open your vault.": "Jūsu organizācija pieprasa divpakāpju pieteikšanos, pirms varat atvērt savu glabātavu.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Lietotāji izvēlas, cik ilgi paplašinājums paliek atbloķēts bezdarbības laikā. Jūs nosakāt garāko laiku, ko viņi drīkst izvēlēties.", + "Longest idle time before the extension locks": "Garākais bezdarbības laiks, pirms paplašinājums tiek bloķēts", + "1 minute": "1 minūte", + "5 minutes": "5 minūtes", + "15 minutes": "15 minūtes", + "1 hour": "1 stunda", + "4 hours": "4 stundas", + "Connector": "Savienotājs", + "Directory (tenant) ID": "Direktorija (nomnieka) ID", + "Application (client) ID": "Lietotnes (klienta) ID", + "Data collection rule immutable ID": "Datu vākšanas kārtulas nemainīgais ID", + "Stream name": "Straumes nosaukums", + "Splunk index (optional)": "Splunk indekss (neobligāts)", + "Sourcetype (optional)": "Sourcetype (neobligāts)", + "Leave blank to keep the current one": "Atstājiet tukšu, lai saglabātu pašreizējo", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF caur syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Datu vākšanas galapunkts (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL (https)", + "Client secret (write-only)": "Klienta noslēpums (tikai rakstīšanai)", + "HEC token (write-only)": "HEC marķieris (tikai rakstīšanai)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Pārsūtiet atļautos audita notikumus uz Splunk, Microsoft Sentinel, syslog uztvērēju vai tīmekļa aizķeri. Ziņojumos ir tikai attīrīti metadati: neviena slepena vērtība, vārds, pieteikšanās vai šifrēts teksts nekad neatstāj serveri.", + "%n change waiting to sync": "%n izmaiņa gaida sinhronizāciju", + "%n changes waiting to sync": "%n izmaiņas gaida sinhronizāciju", + "Changes that could not sync": "Izmaiņas, kuras neizdevās sinhronizēt", + "Choose a version": "Izvēlēties versiju", + "Copy value": "Kopēt vērtību", + "Deleted": "Dzēsts", + "Discard": "Atmest", + "Keep my offline change": "Paturēt manu bezsaistes izmaiņu", + "Keep the server version": "Paturēt servera versiju", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq bezsaistē ir tikai lasāms. Administrators nav ieslēdzis rediģēšanu bezsaistē.", + "Let users edit secrets offline": "Ļaut lietotājiem rediģēt noslēpumus bezsaistē", + "Not synced yet": "Vēl nav sinhronizēts", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Bezsaistes izmaiņas paliek ierīcē, šifrētas lietotājam, un tiek sinhronizētas nākamajā tiešsaistes atbloķēšanā. Kopīgošanai, mapēm un pielikumiem joprojām vajag savienojumu.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Bezsaistē. Rediģējumi, pārvietošana un dzēšana paliek šajā ierīcē un tiek sinhronizēti, kad atkal esat tiešsaistē. Kopīgošanai un pielikumiem vajag savienojumu.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Bezsaistē. Jūsu izmaiņas paliek šajā ierīcē un tiek sinhronizētas, kad atkal esat tiešsaistē. Pēdējā sinhronizācija {when}.", + "Open my changes": "Atvērt manas izmaiņas", + "Sharing needs a connection": "Kopīgošanai vajag savienojumu", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Kāds mainīja šo noslēpumu serverī pēc tam, kad tika izveidota jūsu bezsaistes kopija. Izvēlieties, kuru versiju paturēt.", + "Sync or discard your offline changes before you rotate your keys.": "Sinhronizējiet vai atmetiet bezsaistes izmaiņas, pirms nomaināt atslēgas.", + "That password did not open your changes.": "Ar šo paroli neizdevās atvērt jūsu izmaiņas.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Bezsaistes momentuzņēmums glabā šifrētus noslēpumus (atverami tikai ar atslēgu, kas atvasināta no lietotāja galvenās paroles, tieši kā serverī) un šifrē nosaukumus, URL un mapju nosaukumus glabāšanā. Bezsaistes piekļuve ir tikai lasāma, ja vien zemāk neatļaujat rediģēšanu bezsaistē. Izslēdziet to ierīcēm, kurām nekad nedrīkst kešot akreditācijas datus; izslēdzot esošās kešatmiņas tiek iztīrītas nākamajā ielādē.", + "The previous vault copy is gone, so these changes cannot be opened.": "Iepriekšējā glabātuves kopija vairs nav pieejama, tāpēc šīs izmaiņas nevar atvērt.", + "The server version": "Servera versija", + "This secret changed while you were offline": "Šis noslēpums tika mainīts, kamēr bijāt bezsaistē", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Jūs izdzēsāt šo noslēpumu bezsaistē, bet kopš tā laika tas ir mainīts serverī. Izvēlieties, kuru versiju paturēt.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Jūsu atslēgas tika nomainītas citā ierīcē. Ievadiet iepriekšējo galveno paroli, lai sinhronizētu bezsaistes izmaiņas, vai atmetiet tās.", + "Your offline change": "Jūsu bezsaistes izmaiņa", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n ārkārtas kontaktpersonai bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa to noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti.", + "%n ārkārtas kontaktpersonām bija neizskatīts piekļuves pieprasījums, kad atslēgas maiņa tās noņēma. Pārbaudiet, kas to pieprasīja, pirms kādu pievienojat atkārtoti." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n vienumu nevar attēlot CXF formātā, un tas tiks izlaists.", + "%n vienumus nevar attēlot CXF formātā, un tie tiks izlaisti.", + "%n vienumus nevar attēlot CXF formātā, un tie tiks izlaisti." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n vecāka versija tika atmesta, jo var pārnest tikai neseno vēsturi.", + "%n vecākas versijas tika atmestas, jo var pārnest tikai neseno vēsturi.", + "%n vecākas versijas tika atmestas, jo var pārnest tikai neseno vēsturi." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Vēl jāšifrē un jākopīgo %n noslēpuma kopija.", + "Vēl jāšifrē un jākopīgo %n noslēpumu kopijas.", + "Vēl jāšifrē un jākopīgo %n noslēpumu kopijas." + ], + "_%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 noslēpumu neizdevās atšifrēt, un tas nav šajā eksportā.", + "%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā.", + "%n noslēpumus neizdevās atšifrēt, un tie nav šajā eksportā." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n noslēpumu nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tas netika migrēts.", + "%n noslēpumus nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tie netika migrēti.", + "%n noslēpumus nevarēja atšifrēt ar jūsu veco atslēgu, tāpēc tie netika migrēti." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n noslēpums netika migrēts.", + "%n noslēpumi netika migrēti.", + "%n noslēpumi netika migrēti." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n noslēpums joprojām ir šifrēts ar jūsu iepriekšējo atslēgu.", + "%n noslēpumi joprojām ir šifrēti ar jūsu iepriekšējo atslēgu.", + "%n noslēpumi joprojām ir šifrēti ar jūsu iepriekšējo atslēgu." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n noslēpums tika izlaists, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz.", + "%n noslēpumi tika izlaisti, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz.", + "%n noslēpumi tika izlaisti, jo pēctecim vēl nav kopijas — pievienojiet pēcteci mapei un palaidiet vēlreiz." + ], + "_%n secret_::_%n secrets_": [ + "%n noslēpums", + "%n noslēpumi", + "%n noslēpumi" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n lietotājam darbības jomā vēl nav divpakāpju pieteikšanās, un viņš nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts.", + "%n lietotājiem darbības jomā vēl nav divpakāpju pieteikšanās, un viņi nevar atvērt glabātavu, kamēr tas ir ieslēgts." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Tomēr pabeigt, zaudējot piekļuvi %n noslēpumam", + "Tomēr pabeigt, zaudējot piekļuvi %n noslēpumiem", + "Tomēr pabeigt, zaudējot piekļuvi %n noslēpumiem" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n jauns dalībnieks saņēma piekļuvi komandas mapei.", + "%n jauni dalībnieki saņēma piekļuvi komandas mapei.", + "%n jauni dalībnieki saņēma piekļuvi komandas mapei." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Atslēgas rotācija pabeigta. %n noslēpums tika atkārtoti šifrēts ar jūsu jauno atslēgu.", + "Atslēgas rotācija pabeigta. %n noslēpumi tika atkārtoti šifrēti ar jūsu jauno atslēgu.", + "Atslēgas rotācija pabeigta. %n noslēpumi tika atkārtoti šifrēti ar jūsu jauno atslēgu." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktu.", + "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus.", + "Otrā komplekta atsaukšana izdzēsa %n ārkārtas piekļuves kontaktus." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktu.", + "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus.", + "Šī komplekta atsaukšana dzēsa %n ārkārtas piekļuves kontaktus." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "konstatēts noplūdēs %n reizi", + "konstatēts noplūdēs %n reizes", + "konstatēts noplūdēs %n reizes" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "kopīgots ar %n noslēpumu", + "kopīgots ar %n noslēpumiem", + "kopīgots ar %n noslēpumiem" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Šajā mapē tieši ir %n noslēpums.", + "Šajā mapē tieši ir %n noslēpumi.", + "Šajā mapē tieši ir %n noslēpumi." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n izmaiņa gaida sinhronizāciju", + "%n izmaiņas gaida sinhronizāciju", + "%n izmaiņas gaida sinhronizāciju" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Lietotājs joprojām ir grupā {groups}, kas ir komandas mapes dalībniece. Noņemiet viņu no grupas vai atspējojiet kontu.", + "Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu.", + "Lietotājs joprojām ir grupās {groups}, kas ir komandas mapju dalībnieces. Noņemiet viņu no grupām vai atspējojiet kontu." + ], + "Allow approval from another device": "Atļaut apstiprināšanu no citas ierīces", + "App": "Lietotne", + "Approve a new device": "Apstiprināt jaunu ierīci", + "Approve from another device": "Apstiprināt no citas ierīces", + "Asked at": "Pieprasīts", + "Check that the new device shows these words:": "Pārbaudiet, vai jaunā ierīce rāda šos vārdus:", + "Denied. If you did not ask, end your other sessions:": "Noraidīts. Ja jūs to nepieprasījāt, beidziet citas sesijas:", + "Device": "Ierīce", + "IP address": "IP adrese", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Ļaut lietotājiem atbloķēt jaunu pārlūku, apstiprinot to no ierīces, kurā Keepiq jau ir atbloķēts.", + "New device approval": "Jaunu ierīču apstiprināšana", + "Nextcloud security settings": "Nextcloud drošības iestatījumi", + "Only approve a device you are using right now.": "Apstipriniet tikai ierīci, kuru izmantojat tieši tagad.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Atveriet Keepiq ierīcē, kurā tas ir atbloķēts, un apstipriniet šo ierīci. Pārbaudiet, vai tā rāda tos pašus vārdus:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Apstiprinošā ierīce aizzīmogo atbloķēšanas atslēgu jaunajai ierīcei. Serveris to tikai nodod tālāk un nevar to atvērt.", + "The master password is not right, or the request has ended.": "Galvenā parole nav pareiza, vai pieprasījums ir beidzies.", + "The request expired. Ask again or use your master password.": "Pieprasījuma derīgums beidzās. Pieprasiet vēlreiz vai izmantojiet galveno paroli.", + "The request was denied.": "Pieprasījums tika noraidīts.", + "Too many requests. Try again in an hour or use your master password.": "Pārāk daudz pieprasījumu. Mēģiniet vēlreiz pēc stundas vai izmantojiet galveno paroli.", + "Unknown device": "Nezināma ierīce", + "Web app": "Tīmekļa lietotne", + "A device": "Ierīce", + "A new device asks to open your vault": "Jauna ierīce lūdz atvērt jūsu glabātuvi", + "%s asks to be approved. Only approve a device you are using right now.": "%s lūdz apstiprinājumu. Apstipriniet tikai ierīci, kuru izmantojat tieši tagad.", + "Access ends on (optional)": "Piekļuve beidzas (neobligāti)", + "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.": "Keepiq lietotnes nerādīs un nekopēs paroli. Kāds ar tehniskām zināšanām to tomēr var nolasīt savā ierīcē. Nomainiet to, kad piekļuve beidzas.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Šo noslēpumu var tikai izmantot. Piesakieties, izmantojot Keepiq pārlūka paplašinājumu.", + "Until {date}": "Līdz {date}", + "Use only": "Tikai lietošana", + "Use only (can sign in, cannot view or copy)": "Tikai lietošana (var pieteikties, nevar skatīt vai kopēt)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ar šiem pieteikšanās datiem varat pieteikties, izmantojot Keepiq pārlūka paplašinājumu. Īpašnieks izvēlējās neļaut jums tos skatīt vai kopēt.", + "Your access ends on {date}": "Jūsu piekļuve beidzas {date}", + "Your access to this secret has ended": "Jūsu piekļuve šim noslēpumam ir beigusies", + "Your access to \"%s\" ends tomorrow": "Jūsu piekļuve “%s” beidzas rīt", + "Your access to \"%s\" has ended": "Jūsu piekļuve “%s” ir beigusies", + "%1$s no longer has access to \"%2$s\"": "%1$s vairs nav piekļuves “%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s varēja redzēt šo paroli. Nomainiet to, ja %1$s to vairs nedrīkst zināt.", + "%s could not view this password in Keepiq.": "%s nevarēja skatīt šo paroli Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} no {threshold} apstiprinājumiem", + "a recovery officer": "atkopšanas atbildīgais", + "Account recovery": "Konta atkopšana", + "Approvals needed": "Nepieciešamie apstiprinājumi", + "Ask {user} which words they see, by phone or in person. They must be:": "Pajautājiet {user} pa tālruni vai klātienē, kādus vārdus viņš redz. Tiem jābūt:", + "Check again": "Pārbaudīt vēlreiz", + "Create the recovery key": "Izveidot atkopšanas atslēgu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Izveidojiet atkopšanas atslēgu. Jūsu pārlūks to izveido un katram atbildīgajam dod kopiju, ko var atvērt tikai viņš.", + "Decline": "Noraidīt", + "Enrol in account recovery": "Pieteikties konta atkopšanai", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Piesakieties, lai jūsu organizācija varētu palīdzēt atgūt glabātavu, ja aizmirstat galveno paroli.", + "Every user is enrolled": "Visi lietotāji ir pieteikušies", + "Finish the recovery in the browser you asked from.": "Pabeidziet atkopšanu pārlūkā, no kura to pieprasījāt.", + "Forgot your master password?": "Aizmirsāt galveno paroli?", + "Hand the key over": "Nodot atslēgu", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ļaujiet lietotājiem, kuri aizmirsa galveno paroli, atgūt glabātavu ar jūsu norīkoto atkopšanas atbildīgo apstiprinājumu.", + "New master password": "Jaunā galvenā parole", + "No one is asking to recover their account.": "Neviens neprasa atkopt savu kontu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Atkopšanas atslēgas vēl nav. Viens no atbildīgajiem to izveido savos Keepiq iestatījumos.", + "Off": "Izslēgts", + "Officer {user} has no encryption set up yet.": "Atbildīgajam {user} vēl nav iestatīta šifrēšana.", + "Officers (user IDs, separated by commas)": "Atbildīgie (lietotāju ID, atdalīti ar komatiem)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicējiet šo pirkstu nospiedumu iekšēji, lai lietotāji to varētu pārbaudīt pirms pieteikšanās.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Atkopts ar {officer} palīdzību. Tagad nomainiet glabātavas atslēgu sadaļā Iestatījumi, Drošība: \"Mana galvenā parole ir kompromitēta\".", + "Recovery key fingerprint: {fingerprint}": "Atkopšanas atslēgas pirkstu nospiedums: {fingerprint}", + "Recovery officer": "Atkopšanas atbildīgais", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Noņemtie atbildīgie tagad zaudē savu kopiju, bet varēja to atvērt agrāk. Palūdziet atbildīgajam izveidot jaunu atkopšanas atslēgu.", + "Repeat the new master password": "Atkārtojiet jauno galveno paroli", + "Retire this recovery key": "Izņemt šo atkopšanas atslēgu no lietošanas", + "Set the new master password": "Iestatīt jauno galveno paroli", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Atkopšanas sertifikātu nav izsniedzis šis Keepiq. Nepiesakieties un informējiet administratoru.", + "The words match, approve": "Vārdi sakrīt, apstiprināt", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Šis lietotājs ir pieteicies konta atkopšanai. Atkopšana saglabā viņa noslēpumus; atsaukšana dzēš viņa pieteikumu.", + "Users may enrol": "Lietotāji var pieteikties", + "Withdraw from account recovery": "Atteikties no konta atkopšanas", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jūs esat pieteicies konta atkopšanai. Atkopšanas atslēgas pirkstu nospiedums: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jūs esat pieteicies. Ja aizmirsīsiet galveno paroli, jūsu organizācija var palīdzēt atgūt glabātavu.", + "Your key is back. Choose a new master password.": "Jūsu atslēga ir atgūta. Izvēlieties jaunu galveno paroli.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Jūsu atkopšanas atbildīgie ir informēti. Nolasiet viņiem šos vārdus, kad viņi zvana vai tiekas ar jums:", + "You are now an account recovery officer": "Tagad esat konta atkopšanas atbildīgais", + "%s asks to recover their account. Compare the words with them before you approve.": "%s lūdz atkopt savu kontu. Pirms apstiprināšanas salīdziniet ar viņu vārdus.", + "A user": "Lietotājs", + "Your account recovery request was declined": "Jūsu konta atkopšanas pieprasījums tika noraidīts", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Jūsu konta atkopšana ir gatava. Atveriet Keepiq pārlūkā, no kura to pieprasījāt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} lūdz vienreiz atbloķēt jaunu ierīci. Galvenā parole paliek nemainīga.", + "Ask your organisation instead": "Tā vietā jautājiet savai organizācijai", + "The request ended. Ask again or use your master password.": "Pieprasījums beidzās. Lūdziet vēlreiz vai izmantojiet savu galveno paroli.", + "Added by {user}": "Pievienoja {user}", + "Editor": "Redaktors", + "Manager": "Pārvaldnieks", + "Role of {member}": "{member} loma", + "Team folders you manage": "Jūsu pārvaldītās komandas mapes", + "Viewer": "Skatītājs", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Jums nav šo noslēpumu kopijas, tāpēc jaunie dalībnieki tos vēl nav saņēmuši. Īpašnieks var tos kopīgot: {names}", + "Admin areas": "Pārvaldības jomas", + "Give a group only the parts of Keepiq administration it needs.": "Piešķiriet grupai tikai tās Keepiq pārvaldības daļas, kas tai vajadzīgas.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleģējiet vienu vai vairākas jomas grupai pārvaldības tiesību lapā. Instances administratoriem ir visas jomas.", + "Open administration privileges": "Atvērt pārvaldības tiesības", + "Policies": "Politikas", + "Applications and machine access": "Lietotnes un mašīnu piekļuve", + "People and offboarding": "Cilvēki un aiziešana", + "Audit and compliance": "Audits un atbilstība", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versija, sertificēšanas iestāde, pielikumi, bezsaistes kešatmiņa, noplūžu pārbaude, noslēpumu tipi un rezerves kopijas", + "master password, organisation password, vault policies, rotation, version history and trash": "galvenā parole, organizācijas parole, glabātuves politikas, rotācija, versiju vēsture un miskaste", + "application queue, application requests and machine leases": "lietotņu rinda, lietotņu pieprasījumi un mašīnu nomas", + "team offboarding, encryption suites and admin handover": "aiziešana no komandas, šifrēšanas komplekti un administratora pārņemšana", + "audit log, compliance reports, SIEM export and honey alerts": "audita žurnāls, atbilstības atskaites, SIEM eksports un ēsmas brīdinājumi", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Cik noslēpuma versiju tiek glabātas, cik ilgi, un cik ilgi dzēsti noslēpumi paliek miskastē.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Šifrētu pielikumu ierobežojumi, ko serveris piemēro saglabātos šifrētos baitos.", + "Type the suite ID again to confirm": "Lai apstiprinātu, vēlreiz ievadiet komplekta ID", + "This does not match the suite ID.": "Tas nesakrīt ar komplekta ID.", + "Confirm with your master password": "Apstipriniet ar galveno paroli", + "Confirm": "Apstiprināt", + "That master password is not right.": "Šī galvenā parole nav pareiza.", + "You are sharing with someone new. Enter your master password to confirm.": "Jūs kopīgojat ar jaunu personu. Lai apstiprinātu, ievadiet galveno paroli.", + "Enter your master password to confirm this share.": "Lai apstiprinātu šo kopīgošanu, ievadiet galveno paroli.", + "Enter your master password to confirm this delegation.": "Lai apstiprinātu šo deleģēšanu, ievadiet galveno paroli.", + "Approve {member}": "Apstiprināt {member}", + "Recipient": "Saņēmējs", + "No vault yet": "Vēl nav glabātuves", + "No matching users": "Nav atbilstošu lietotāju", + "Partner organisations": "Partneru organizācijas", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Apmainieties ar noslēpumiem ar citu Keepiq. Abi administratori pievieno viens otru un pirms saglabāšanas salīdzina saknes pirkstu nospiedumus pa tālruni vai klātienē.", + "Federation needs Nextcloud 33 or later.": "Federācijai nepieciešams Nextcloud 33 vai jaunāks.", + "Your root fingerprint": "Jūsu saknes pirkstu nospiedums", + "No partners yet.": "Vēl nav partneru.", + "Users here may share to this partner": "Šejienes lietotāji drīkst kopīgot ar šo partneri", + "This partner may share to users here": "Šis partneris drīkst kopīgot ar šejienes lietotājiem", + "Partner address": "Partnera adrese", + "Check partner": "Pārbaudīt partneri", + "Partner root fingerprint": "Partnera saknes pirkstu nospiedums", + "I compared this fingerprint with the partner's administrator": "Es salīdzināju šo pirkstu nospiedumu ar partnera administratoru", + "Add partner": "Pievienot partneri", + "A secret from another organisation": "Noslēpums no citas organizācijas", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s kopīgoja ar jums \"%2$s\". Pieņemiet to sadaļā Ienākošie no citām organizācijām.", + "Incoming from other organisations": "Ienākošie no citām organizācijām", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Partneru organizāciju cilvēki var kopīgot ar jums noslēpumu. Pieņemiet to, lai savā glabātuvē paturētu tikai lasāmu kopiju.", + "Nothing shared with you yet": "Ar jums vēl nekas nav kopīgots", + "Secrets that people in partner organisations share with you appear here.": "Šeit parādās noslēpumi, ko ar jums kopīgo partneru organizāciju cilvēki.", + "From {sender}": "No {sender}", + "Accept": "Pieņemt", + "Open in vault": "Atvērt glabātuvē", + "The other organisation did not hand over the secret. Try again later.": "Otra organizācija nenodeva noslēpumu. Mēģiniet vēlreiz vēlāk.", + "Set up your vault before you accept a shared secret.": "Pirms pieņemat kopīgotu noslēpumu, iestatiet savu glabātuvi.", + "Something went wrong. Try again.": "Kaut kas nogāja greizi. Mēģiniet vēlreiz.", + "Waiting for your answer": "Gaida jūsu atbildi", + "In your vault, read-only": "Jūsu glabātuvē, tikai lasāms", + "Withdrawn by the sender": "Sūtītājs atsauca", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} kopīgoja šo no citas organizācijas. Jūs to varat lasīt, bet ne mainīt vai kopīgot.", + "Someone": "Kāds", + "Share with someone at another organisation": "Kopīgot ar kādu no citas organizācijas", + "Their account at the other organisation": "Šīs personas konts otrā organizācijā", + "Check account": "Pārbaudīt kontu", + "Certificate fingerprint of {account}": "Konta {account} sertifikāta pirksta nospiedums", + "Compare it with them by phone if you want to be sure.": "Ja vēlaties būt droši, salīdziniet to ar šo personu pa tālruni.", + "Shared. {account} can accept it in their own vault.": "Kopīgots. {account} to var pieņemt savā glabātuvē.", + "The certificate could not be verified. Nothing was shared.": "Sertifikātu neizdevās pārbaudīt. Nekas netika kopīgots.", + "That organisation is not one of your partners.": "Šī organizācija nav viens no jūsu partneriem.", + "No one with that account can receive secrets from you.": "Neviens ar šo kontu nevar saņemt no jums noslēpumus.", + "The other organisation did not answer. Try again later.": "Otra organizācija neatbildēja. Mēģiniet vēlreiz vēlāk.", + "This secret is already shared with that account.": "Šis noslēpums jau ir kopīgots ar šo kontu.", + "Other organisations": "Citas organizācijas", + "Receive secrets from other organisations": "Saņemt noslēpumus no citām organizācijām", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Tad partneru organizāciju cilvēki var atrast jūsu kontu un kopīgot ar jums noslēpumus. Katru no tiem jūs pieņemat paši.", + "Shared": "Kopīgots", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Apturēts: mainījās viņu sertifikāts vai partnerība. Varat atsaukt kopīgošanu vai kopīgot vēlreiz.", + "Their organisation did not get the last change. Revoke it or share again.": "Viņu organizācija nesaņēma pēdējās izmaiņas. Varat atsaukt kopīgošanu vai kopīgot vēlreiz.", + "Being withdrawn": "Tiek atsaukts", + "Shared with another organisation": "Kopīgots ar citu organizāciju", + "Change sent to another organisation": "Izmaiņas nosūtītas citai organizācijai", + "Share with another organisation revoked": "Kopīgošana ar citu organizāciju atsaukta", + "Share with another organisation paused": "Kopīgošana ar citu organizāciju apturēta", + "Another organisation did not get a change": "Cita organizācija nesaņēma izmaiņas", + "Secret received from another organisation": "Saņemts noslēpums no citas organizācijas", + "Secret from another organisation accepted": "Noslēpums no citas organizācijas pieņemts", + "Secret from another organisation declined": "Noslēpums no citas organizācijas noraidīts", + "Copy from another organisation updated": "Kopija no citas organizācijas atjaunināta", + "Copy from another organisation removed": "Kopija no citas organizācijas noņemta", + "Declined: they removed their copy. Share again if they need it.": "Noraidīts: saņēmējs noņēma savu kopiju. Kopīgojiet vēlreiz, ja tā ir vajadzīga.", + "Recipient at another organisation removed their copy": "Citas organizācijas saņēmējs noņēma savu kopiju", + "Removed the user from %n team folder.": "Lietotājs noņemts no %n komandas mapes.", + "Removed the user from %n team folders.": "Lietotājs noņemts no %n komandas mapēm.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Lietotājs noņemts no %n komandas mapes.", + "Lietotājs noņemts no %n komandas mapēm.", + "Lietotājs noņemts no %n komandas mapēm." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Atjaunotā kopija nāk no koplietojuma, kas ir beidzies. Tā paliek tikai lasāma.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizāciju, kas kopīgoja atjaunoto kopiju, neizdevās sasniegt. Kopija paliek tikai lasāma un neseko viņu izmaiņām.", + "Recipient at another organisation restored their copy": "Citas organizācijas saņēmējs atjaunoja savu kopiju" }, "plurals": null } diff --git a/l10n/mk.js b/l10n/mk.js index 21574658b..73366e4ee 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате.", + "Shared with groups": "Споделено со групи", + "Not shared with any group yet.": "Сè уште не е споделено со ниедна група.", + "Revoke the share with {group}": "Отповикај го споделувањето со {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено со {group}: {received} членови го примија, {skipped} не, бидејќи сè уште немаат поставено шифрирање.", + "Search groups": "Пребарај групи", + "Failed to share": "Споделувањето не успеа", + "Columns": "Колони", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Поврзете една колона со името. Секоја тајна треба да има име.", + "Notes": "Белешки", + "Do not import": "Не увезувај", + "Hide this value": "Скриј ја оваа вредност", + "Show this value": "Прикажи ја оваа вредност", + "Defaults": "Стандардни", + "New secrets start as this type, and your secret list opens in this view.": "Новите тајни започнуваат како овој тип, а твојата листа на тајни се отвора во овој приказ.", + "Default item type": "Стандарден тип на ставка", + "Cards": "Картички", + "Table": "Табела", + "Could not save your default": "Не можеше да се зачува стандардната вредност", + "Recently used": "Неодамна користени", + "Opened": "Отворено", + "You have not opened any secrets yet": "Сè уште немаш отворено ниту една тајна", + "Could not delete the item type.": "Типот на ставка не можеше да се избрише.", + "Could not load the item types.": "Типовите на ставки не можеа да се вчитаат.", + "Could not save the item type.": "Типот на ставка не можеше да се зачува.", + "Delete item type": "Избриши тип на ставка", + "Edit item type": "Уреди тип на ставка", + "Fields": "Полиња", + "Fields: {count}": "Полиња: {count}", + "Hidden": "Скриено", + "Item types": "Типови на ставки", + "Move up": "Помести нагоре", + "New item type": "Нов тип на ставка", + "No item types defined yet.": "Сè уште нема дефинирани типови на ставки.", + "Required": "Задолжително", + "Text": "Текст", + "This field is required": "Ова поле е задолжително", + "Web address": "Веб адреса", + "{label} (required)": "{label} (задолжително)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Да се избрише „{name}“? Тајните од овој тип остануваат читливи и стануваат ставки Најава.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типовите на ставки што ги дефинираш тука им се прикажуваат на сите во дијалогот Нова тајна, со полињата што ќе ги избереш.", + "Secret moved to the trash": "Тајната е преместена во корпата", + "Secret restored from the trash": "Тајната е вратена од корпата", + "Secret deleted for good": "Тајната е трајно избришана", + "Secret archived": "Тајната е архивирана", + "Secret unarchived": "Тајната е вратена од архивата", + "Unarchive": "Врати од архивата", + "Could not archive the secret": "Тајната не можеше да се архивира", + "Could not unarchive the secret": "Тајната не можеше да се врати од архивата", + "Archive {count} secrets": "Архивирај тајни: {count}", + "Unarchive {count} secrets": "Врати од архивата тајни: {count}", + "Restore {count} secrets": "Врати тајни: {count}", + "Delete {count} secrets for good": "Трајно избриши тајни: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} од {total} тајни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивираните тајни исчезнуваат од листата на трезорот, пребарувањето, автоматското пополнување и извештајот за состојбата. Ги задржуваат споделувањата. Ќе ги најдете во Архива.", + "These secrets come back to the vault list, search and autofill.": "Овие тајни се враќаат во листата на трезорот, пребарувањето и автоматското пополнување.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Овие тајни се враќаат во листата на трезорот. Старите споделувања не се враќаат, па споделете ги повторно каде што треба.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ова ги брише тајните заедно со прилозите и историјата на верзии. Ова не може да се врати.", + "Delete for good": "Трајно избриши", + "Trash": "Корпа", + "The trash is empty": "Корпата е празна", + "No archived secrets": "Нема архивирани тајни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Избришаните тајни чекаат тука до крајот на периодот на чување, потоа се бришат трајно.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирајте тајна од нејзиниот панел со детали за да остане надвор од листата на трезорот, пребарувањето и автоматското пополнување.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничувања за шифрирани прилози (се применуваат на серверот во зачуваните шифрирани бајти), чување на историјата на верзии и колку долго избришаните тајни остануваат во корпата.", + "Days a deleted secret stays in the trash (1 to 365)": "Денови колку што избришана тајна останува во корпата (од 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ова ја преместува тајната во корпата и веднаш ги завршува нејзините споделувања. Може да ја вратите од корпата до крајот на периодот на чување: 30 дена, освен ако администраторот не го сменил.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ова ги преместува тајните во корпата ({count}) и веднаш ги завршува нивните споделувања. Може да ги вратите од корпата до крајот на периодот на чување.", + "Remove {name} from favourites": "Отстрани {name} од омилени", + "Add {name} to favourites": "Додај {name} во омилени", + "Could not change the favourite": "Омиленото не можеше да се промени", + "Remove from favourites": "Отстрани од омилени", + "Add to favourites": "Додај во омилени", + "Tags": "Ознаки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Ознаките не се шифрирани. Администраторите на серверот можат да ги читаат, како и имињата на папките.", + "Favourites": "Омилени", + "Filter by tag": "Филтрирај по ознака", + "All tags": "Сите ознаки", + "Last used": "Последно користено", + "Tags for {count} secrets": "Ознаки за {count} тајни", + "Tag": "Ознака", + "Remove tag": "Отстрани ознака", + "Add tag": "Додај ознака", + "Could not change the tags. Try again.": "Ознаките не можеа да се променат. Обидете се повторно.", + "Could not approve the application. It is still in the queue.": "Апликацијата не може да се одобри. Сè уште е во редицата.", + "Could not reject the application. It is still in the queue.": "Апликацијата не може да се одбие. Сè уште е во редицата.", + "Removed the user from {count} team folders.": "Корисникот е отстранет од {count} тимски папки.", + "Approve a share": "Одобри споделување", + "This approval link is incomplete. Open it again from the notification.": "Оваа врска за одобрување е нецелосна. Отворете ја повторно од известувањето.", + "Deny": "Одбиј", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се приклучи на група со која споделувате тајна. Да ја споделите тајната и со него?", + "{requester} asks you to share a secret with {user}.": "{requester} бара од вас да споделите тајна со {user}.", + "Shared. The recipient can now open the secret.": "Споделено. Примачот сега може да ја отвори тајната.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Примачот сè уште не го поставил Keepiq, па ништо не е споделено. Обидете се повторно откако ќе го постави.", + "Could not share the secret. Only its owner can approve this.": "Тајната не може да се сподели. Само нејзиниот сопственик може да го одобри ова.", + "Could not share the secret. Try again.": "Тајната не може да се сподели. Обидете се повторно.", + "Denied. Nothing was shared.": "Одбиено. Ништо не е споделено.", + "Could not deny the request. Try again.": "Барањето не може да се одбие. Обидете се повторно.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s бара од вас да ја споделите тајната \"%2$s\" со %3$s.", + "Expires on (optional)": "Истекува на (по избор)", + "Hand over to": "Предај на", + "Choose a recipient": "Изберете примач", + "Hand over temporarily": "Предај привремено", + "Expiry rules": "Правила за истекување", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Поставете колку долго смеат да важат лозинките од еден тип ставка или во една папка и кога да добиете потсетник. Кога важат повеќе датуми, се смета најраниот.", + "Delete rule": "Избриши правило", + "Set by your administrator": "Поставено од вашиот администратор", + "No expiry rules yet.": "Сè уште нема правила за истекување.", + "Applies to": "Се однесува на", + "Item type": "Тип на ставка", + "Maximum age in days (empty for reminders only)": "Максимална старост во денови (празно само за потсетници)", + "Remind me this many days before, comma separated": "Потсети ме толку денови пред, одвоени со запирка", + "Save rule": "Зачувај правило", + "An item type": "Тип на ставка", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Истекува по {days} дена", + "Reminders {days} days before": "Потсетници {days} дена пред", + "Could not save the expiry rule.": "Правилото за истекување не може да се зачува.", + "Could not delete the expiry rule.": "Правилото за истекување не може да се избрише.", + "All statuses": "Сите статуси", + "Compromised": "Компромитиран", + "Could not load the members.": "Членовите не може да се вчитаат.", + "Emergency contact": "Контакт за итни случаи", + "Leaving user": "Корисник што заминува", + "No": "Не", + "No users match this filter.": "Ниеден корисник не одговара на овој филтер.", + "Not set up": "Не е поставено", + "Revoke suite": "Отповикај пакет", + "Revoked": "Отповикан", + "Search users": "Пребарај корисници", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Видете кои корисници поставиле сеф. Започнете одјавување или отповикајте пакет од ред.", + "Successor": "Наследник", + "Team folders": "Тимски папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Корисникот е сè уште во групата {groups}, која е членка на тимска папка. Отстранете го од групата или оневозможете ја сметката.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Корисникот е сè уште во групите {groups}, кои се членки на тимски папки. Отстранете го од групите или оневозможете ја сметката.", + "Vault status": "Статус на сефот", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Извозот во CXF НЕ Е ШИФРИРАН. Секоја лозинка и најава ќе биде читлива како отворен текст во преземената датотека. Чувајте ја безбедно и избришете ја веднаш по употребата.", + "Root certificate expiring soon": "Коренскиот сертификат наскоро истекува", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Коренскиот сертификат на сефот истекува за %1$d ден(а). Обновете го пред тоа. Обновувањето повторно го потпишува секој пакет за шифрирање.", + "Compromise recovery aborted": "Обновувањето по компромитирање е прекинато", + "Key rotation ended by a compromise revoke": "Ротацијата на клучот е завршена со отповикување поради компромитирање", + "Encryption suite revoke refused": "Отповикувањето на пакетот за шифрирање е одбиено", + "Master password proof refused": "Доказот за главната лозинка е одбиен", + "Your current master password": "Вашата тековна главна лозинка", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за итни случаи имаше барање за пристап на чекање кога ротацијата на клучот го отстрани. Проверете кој побарал пред повторно да додадете некого.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контактите за итни случаи (%n) имаа барање за пристап на чекање кога ротацијата на клучот ги отстрани. Проверете кој побарал пред повторно да додадете некого.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Овие контакти за итни случаи не се пренесени на вашиот нов клуч. Нивниот итен пристап е отстранет. Додајте ги повторно во Итен пристап ако сè уште ги сакате.", + "Renew root certificate": "Обнови го коренскиот сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ова создава нов коренски и посреднички сертификат. Секој активен пакет за шифрирање повторно се потпишува. Ова не може да се врати.", + "Renew root": "Обнови корен", + "Root renewed. {n} encryption suites signed again.": "Коренот е обновен. Повторно потпишани пакети за шифрирање: {n}.", + "Could not renew the root certificate.": "Коренскиот сертификат не може да се обнови.", + "Lease policy for this application": "Политика за закуп за оваа апликација", + "In force now: {default} seconds by default, {max} seconds at most.": "Сега важи: стандардно {default} секунди, најмногу {max} секунди.", + "Leases are not renewable": "Закупите не можат да се обновуваат", + "Lease policy saved.": "Политиката за закуп е зачувана.", + "Leave a field empty to use the instance value.": "Оставете поле празно за да се користи вредноста на инстанцата.", + "Instance value: {value}": "Вредност на инстанцата: {value}", + "Renewal": "Обновување", + "Use the instance value ({value})": "Користи ја вредноста на инстанцата ({value})", + "Allowed": "Дозволено", + "Not allowed": "Не е дозволено", + "Save lease policy": "Зачувај политика за закуп", + "Only an administrator can change this policy.": "Само администратор може да ја промени оваа политика.", + "Could not save the lease policy.": "Политиката за закуп не може да се зачува.", + "{member} got access from {confirmer}.": "{member} доби пристап од {confirmer}.", + "Automatically confirm new team folder members": "Автоматски потврди нови членови на тимски папки", + "Gave %n new member access to a team folder.": "%n нов член доби пристап до тимска папка.", + "Gave %n new members access to a team folder.": "Нови членови (%n) добија пристап до тимска папка.", + "Give new team folder members access without waiting for the folder owner.": "Дајте им пристап на новите членови без да чекате на сопственикот на папката.", + "New team folder members": "Нови членови на тимски папки", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Сопственикот или член со право на запишување ги потврдува од отворениот сеф. Keepiq никогаш не дешифрира на серверот.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Се чека член со право на запишување да го отвори Keepiq. Може да споделите и сега.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Дел од одговорот на компромитирање не успеа ({failed} чекор(и)). Проверете го дневникот на серверот, а потоа повторно отповикајте го пакетот за да го завршите.", + "This also revoked suite {suite} and ended key migration {migration}.": "Со ова е отповикан и пакетот {suite} и завршена миграцијата на клучеви {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отповикувањето на вториот пакет избриша %n контакт за итен пристап.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отповикувањето на вториот пакет избриша %n контакти за итен пристап.", + "A suite revoked as compromised cannot be reinstated.": "Пакет отповикан како компромитиран не може да се врати.", + "Archives to keep": "Архиви за чување", + "Back up every vault automatically": "Автоматски направи резервна копија на секој сеф", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Правете резервна копија на секој сеф според распоред. Архивите содржат само шифриран текст и се враќаат со occ.", + "Back up now": "Направи копија сега", + "Backup public key (PEM, optional)": "Јавен клуч за резервна копија (PEM, опционално)", + "Backup requested for the next cron run": "Копија побарана за следното извршување на cron", + "Encrypted": "Шифрирано", + "Every (hours)": "На секои (часа)", + "Last backup {when} failed: {error}": "Последната копија {when} не успеа: {error}", + "Last backup {when} succeeded.": "Последната копија {when} успеа.", + "No archives yet.": "Сè уште нема архиви.", + "Size": "Големина", + "Vault backups": "Резервни копии на сефот", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Со клуч секоја архива се шифрира за него. Чувајте го приватниот клуч надвор од овој сервер: ви треба за проверка или враќање.", + "Written": "Запишано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n корисник во опсегот сè уште нема најава во два чекора и не може да го отвори сефот додека ова е вклучено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Корисниците во опсегот (%n) сè уште немаат најава во два чекора и не можат да го отворат сефот додека ова е вклучено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервните кодови не се сметаат. Ако вашите корисници се најавуваат преку давател на идентитет со сопствен втор фактор, изоставете ги нивните групи.", + "Block personal vault export": "Блокирај извоз на личен сеф", + "Keep work logins in team folders": "Чувај ги работните најави во тимски папки", + "Move to a team folder": "Премести во тимска папка", + "Not in a team folder": "Не е во тимска папка", + "Only for these groups (empty is everyone)": "Само за овие групи (празно значи сите)", + "Require two-factor login before the vault opens": "Барај најава во два чекора пред да се отвори сефот", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за секој сеф. Секое важи за сите или само за групите што ќе ги изберете.", + "Secret types that belong in a team folder": "Типови тајни што припаѓаат во тимска папка", + "Set up two-factor login": "Постави најава во два чекора", + "Team folder you can write to": "Тимска папка во која можете да пишувате", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Корисниците не можат да преземат резервна копија, CSV или датотека за пренос. Нивниот пакет лични податоци останува достапен.", + "Users cannot save these secret types in a personal folder.": "Корисниците не можат да ги зачуваат овие типови тајни во лична папка.", + "Vault policies": "Правила на сефот", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Вашата организација не дозволува извоз на вашиот личен сеф. Вашиот пакет лични податоци во поставките останува достапен.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Вашата организација ги чува овие тајни во тимска папка. Преместете ја секоја во тимска папка.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Вашата организација го чува овој тип тајна во тимска папка. Изберете една од вашите тимски папки или една во која можете да пишувате.", + "Your organisation requires two-factor login before you can open your vault.": "Вашата организација бара најава во два чекора пред да можете да го отворите сефот.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Корисниците избираат колку долго проширувањето останува отклучено при неактивност. Вие го поставувате најдолгото време што смеат да го изберат.", + "Longest idle time before the extension locks": "Најдолго време на неактивност пред проширувањето да се заклучи", + "1 minute": "1 минута", + "5 minutes": "5 минути", + "15 minutes": "15 минути", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД на директориум (закупец)", + "Application (client) ID": "ИД на апликација (клиент)", + "Data collection rule immutable ID": "Непроменлив ИД на правилото за собирање податоци", + "Stream name": "Име на тек", + "Splunk index (optional)": "Splunk индекс (опционално)", + "Sourcetype (optional)": "Sourcetype (опционално)", + "Leave blank to keep the current one": "Оставете празно за да ја задржите тековната", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF преку syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крајна точка за собирање податоци (https URL)", + "HTTP Event Collector URL (https)": "URL на HTTP Event Collector (https)", + "Client secret (write-only)": "Тајна на клиентот (само запишување)", + "HEC token (write-only)": "HEC токен (само запишување)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Препраќајте дозволени ревизорски настани до Splunk, Microsoft Sentinel, syslog приемник или webhook. Пораките носат само исчистени метаподатоци: ниедна тајна вредност, име, најава или шифриран текст никогаш не го напушта серверот.", + "%n change waiting to sync": "%n промена чека синхронизација", + "%n changes waiting to sync": "%n промени чекаат синхронизација", + "Changes that could not sync": "Промени што не можеа да се синхронизираат", + "Choose a version": "Изберете верзија", + "Copy value": "Копирај вредност", + "Deleted": "Избришано", + "Discard": "Отфрли", + "Keep my offline change": "Задржи ја мојата промена без мрежа", + "Keep the server version": "Задржи ја верзијата од серверот", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq е само за читање без мрежа. Администраторот не го вклучил уредувањето без мрежа.", + "Let users edit secrets offline": "Дозволи им на корисниците да уредуваат тајни без мрежа", + "Not synced yet": "Сè уште не е синхронизирано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Промените без мрежа остануваат на уредот, шифрирани за корисникот, и се синхронизираат при следното отклучување на мрежа. Споделувањето, папките и прилозите сè уште бараат врска.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без мрежа. Уредувањата, преместувањата и бришењата остануваат на овој уред и се синхронизираат кога повторно ќе бидете на мрежа. Споделувањето и прилозите бараат врска.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без мрежа. Вашите промени остануваат на овој уред и се синхронизираат кога повторно ќе бидете на мрежа. Последна синхронизација {when}.", + "Open my changes": "Отвори ги моите промени", + "Sharing needs a connection": "Споделувањето бара врска", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Некој ја промени оваа тајна на серверот откако беше направена вашата копија без мрежа. Изберете која верзија да ја задржите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизирајте ги или отфрлете ги промените без мрежа пред да ги замените клучевите.", + "That password did not open your changes.": "Таа лозинка не ги отвори вашите промени.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Снимката без мрежа чува шифрирани тајни (се отвораат само со клучот изведен од главната лозинка на корисникот, точно како на серверот) и ги шифрира имињата, URL-адресите и имињата на папките при складирање. Пристапот без мрежа е само за читање, освен ако подолу не дозволите уредување без мрежа. Исклучете го ова за уреди што никогаш не смеат да кешираат акредитиви; исклучувањето ги брише постоечките кешови при следното вчитување.", + "The previous vault copy is gone, so these changes cannot be opened.": "Претходната копија на трезорот ја нема, па овие промени не може да се отворат.", + "The server version": "Верзијата од серверот", + "This secret changed while you were offline": "Оваа тајна се промени додека бевте без мрежа", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ја избришавте оваа тајна без мрежа, но оттогаш е променета на серверот. Изберете која верзија да ја задржите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Вашите клучеви беа променети на друг уред. Внесете ја претходната главна лозинка за да ги синхронизирате промените без мрежа, или отфрлете ги.", + "Your offline change": "Вашата промена без мрежа", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n контакт за итни случаи имаше барање за пристап на чекање кога ротацијата на клучот го отстрани. Проверете кој побарал пред повторно да додадете некого.","Контактите за итни случаи (%n) имаа барање за пристап на чекање кога ротацијата на клучот ги отстрани. Проверете кој побарал пред повторно да додадете некого."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n елемент не може да се претстави во CXF и ќе биде прескокнат.","%n елементи не можат да се претстават во CXF и ќе бидат прескокнати."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n постара верзија беше отфрлена, бидејќи може да се пренесе само неодамнешната историја.","%n постари верзии беа отфрлени, бидејќи може да се пренесе само неодамнешната историја."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n копија од тајна сè уште треба да се шифрира и сподели.","%n копии од тајни сè уште треба да се шифрираат и споделат."], + "_%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 тајна не можеше да се дешифрира и не е во овој извоз.","%n тајни не можеа да се дешифрираат и не се во овој извоз."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n тајна не можеше да се дешифрира со вашиот стар клуч, па затоа не беше пренесена.","%n тајни не можеа да се дешифрираат со вашиот стар клуч, па затоа не беа пренесени."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n тајна не беше пренесена.","%n тајни не беа пренесени."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n тајна сè уште е шифрирана со вашиот претходен клуч.","%n тајни сè уште се шифрирани со вашиот претходен клуч."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n тајна беше прескокната бидејќи наследникот сè уште нема копија — додајте го наследникот во папката и извршете повторно.","%n тајни беа прескокнати бидејќи наследникот сè уште нема копија — додајте го наследникот во папката и извршете повторно."], + "_%n secret_::_%n secrets_": ["%n тајна","%n тајни"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n корисник во опсегот сè уште нема најава во два чекора и не може да го отвори сефот додека ова е вклучено.","Корисниците во опсегот (%n) сè уште немаат најава во два чекора и не можат да го отворат сефот додека ова е вклучено."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Сепак заврши, губејќи пристап до %n тајна","Сепак заврши, губејќи пристап до %n тајни"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n нов член доби пристап до тимска папка.","Нови членови (%n) добија пристап до тимска папка."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ротацијата на клучот е завршена. %n тајна беше повторно шифрирана со вашиот нов клуч.","Ротацијата на клучот е завршена. %n тајни беа повторно шифрирани со вашиот нов клуч."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Отповикувањето на вториот пакет избриша %n контакт за итен пристап.","Отповикувањето на вториот пакет избриша %n контакти за итен пристап."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Отповикувањето на овој комплет избриша %n контакт за итен пристап.","Отповикувањето на овој комплет избриша %n контакти за итен пристап."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["забележано %n пат во протечени податоци","забележано %n пати во протечени податоци"], + "_shared with %n secret_::_shared with %n secrets_": ["споделено со %n тајна","споделено со %n тајни"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Оваа папка содржи директно %n тајна.","Оваа папка содржи директно %n тајни."], + "_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._": ["Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.","Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n промена чека синхронизација","%n промени чекаат синхронизација"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Корисникот е сè уште во групата {groups}, која е членка на тимска папка. Отстранете го од групата или оневозможете ја сметката.","Корисникот е сè уште во групите {groups}, кои се членки на тимски папки. Отстранете го од групите или оневозможете ја сметката."], + "Allow approval from another device": "Дозволи одобрување од друг уред", + "App": "Апликација", + "Approve a new device": "Одобри нов уред", + "Approve from another device": "Одобри од друг уред", + "Asked at": "Побарано во", + "Check that the new device shows these words:": "Проверете дали новиот уред ги прикажува овие зборови:", + "Denied. If you did not ask, end your other sessions:": "Одбиено. Ако не го побаравте ова, завршете ги другите сесии:", + "Device": "Уред", + "IP address": "IP адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Овозможи корисниците да отклучат нов прелистувач со одобрување од уред на кој Keepiq е веќе отклучен.", + "New device approval": "Одобрување нови уреди", + "Nextcloud security settings": "Безбедносни поставки на Nextcloud", + "Only approve a device you are using right now.": "Одобрете само уред што го користите токму сега.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворете го Keepiq на уред на кој е отклучен и одобрете го овој уред. Проверете дали ги прикажува истите зборови:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Уредот што одобрува го запечатува клучот за отклучување за новиот уред. Серверот само го препраќа и не може да го отвори.", + "The master password is not right, or the request has ended.": "Главната лозинка не е точна или барањето заврши.", + "The request expired. Ask again or use your master password.": "Барањето истече. Побарајте повторно или користете ја главната лозинка.", + "The request was denied.": "Барањето беше одбиено.", + "Too many requests. Try again in an hour or use your master password.": "Премногу барања. Обидете се повторно за еден час или користете ја главната лозинка.", + "Unknown device": "Непознат уред", + "Web app": "Веб-апликација", + "A device": "Уред", + "A new device asks to open your vault": "Нов уред бара да го отвори вашиот сеф", + "%s asks to be approved. Only approve a device you are using right now.": "%s бара одобрување. Одобрете само уред што го користите токму сега.", + "Access ends on (optional)": "Пристапот завршува на (опционално)", + "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.": "Апликациите на Keepiq нема да ја прикажат ниту копираат лозинката. Некој со технички знаења сепак може да ја прочита од својот уред. Сменете ја кога пристапот ќе заврши.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Оваа тајна е само за користење. Најавете се преку проширувањето за прелистувач на Keepiq.", + "Until {date}": "До {date}", + "Use only": "Само користење", + "Use only (can sign in, cannot view or copy)": "Само користење (може да се најави, не може да види ниту копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Можете да се најавите со оваа најава преку проширувањето за прелистувач на Keepiq. Сопственикот одлучи да не ви дозволи да ја видите или копирате.", + "Your access ends on {date}": "Вашиот пристап завршува на {date}", + "Your access to this secret has ended": "Вашиот пристап до оваа тајна заврши", + "Your access to \"%s\" ends tomorrow": "Вашиот пристап до „%s“ завршува утре", + "Your access to \"%s\" has ended": "Вашиот пристап до „%s“ заврши", + "%1$s no longer has access to \"%2$s\"": "%1$s повеќе нема пристап до „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s можеше да ја види оваа лозинка. Сменете ја ако %1$s повеќе не треба да ја знае.", + "%s could not view this password in Keepiq.": "%s не можеше да ја види оваа лозинка во Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} од {threshold} одобрувања", + "a recovery officer": "службеник за враќање", + "Account recovery": "Враќање на сметка", + "Approvals needed": "Потребни одобрувања", + "Ask {user} which words they see, by phone or in person. They must be:": "Прашајте го {user} кои зборови ги гледа, по телефон или лично. Тие мора да бидат:", + "Check again": "Провери повторно", + "Create the recovery key": "Создај клуч за враќање", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Создајте го клучот за враќање. Вашиот прелистувач го прави и му дава на секој службеник копија што само тој може да ја отвори.", + "Decline": "Одбиј", + "Enrol in account recovery": "Пријавете се за враќање на сметка", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Пријавете се за вашата организација да може да ви помогне да го вратите трезорот ако ја заборавите главната лозинка.", + "Every user is enrolled": "Сите корисници се пријавени", + "Finish the recovery in the browser you asked from.": "Завршете го враќањето во прелистувачот од кој побаравте.", + "Forgot your master password?": "Ја заборавивте главната лозинка?", + "Hand the key over": "Предај го клучот", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дозволете корисниците што ја заборавиле главната лозинка да го вратат трезорот, со одобрување од службениците за враќање што ги именувате.", + "New master password": "Нова главна лозинка", + "No one is asking to recover their account.": "Никој не бара враќање на својата сметка.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Сè уште нема клуч за враќање. Еден од службениците го создава во своите поставки на Keepiq.", + "Off": "Исклучено", + "Officer {user} has no encryption set up yet.": "Службеникот {user} сè уште нема поставено шифрирање.", + "Officers (user IDs, separated by commas)": "Службеници (кориснички ID, одделени со запирки)", + "Policy": "Политика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Објавете го овој отпечаток интерно, за корисниците да можат да го проверат пред да се пријават.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Вратено со помош од {officer}. Сменете го клучот на трезорот сега во Поставки, Безбедност: \"Мојата главна лозинка е компромитирана\".", + "Recovery key fingerprint: {fingerprint}": "Отпечаток на клучот за враќање: {fingerprint}", + "Recovery officer": "Службеник за враќање", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Отстранетите службеници сега ја губат својата копија, но можеби ја отвориле претходно. Нека службеник создаде нов клуч за враќање.", + "Repeat the new master password": "Повторете ја новата главна лозинка", + "Retire this recovery key": "Повлечи го овој клуч за враќање", + "Set the new master password": "Постави ја новата главна лозинка", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификатот за враќање не е издаден од овој Keepiq. Не се пријавувајте и известете го администраторот.", + "The words match, approve": "Зборовите се совпаѓаат, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Овој корисник е пријавен за враќање на сметка. Враќањето ги чува неговите тајни; отповикувањето ја брише неговата пријава.", + "Users may enrol": "Корисниците можат да се пријават", + "Withdraw from account recovery": "Откажи се од враќање на сметка", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Пријавени сте за враќање на сметка. Отпечаток на клучот за враќање: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Пријавени сте. Ако ја заборавите главната лозинка, вашата организација може да ви помогне да го вратите трезорот.", + "Your key is back. Choose a new master password.": "Клучот ви е вратен. Изберете нова главна лозинка.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Вашите службеници за враќање се известени. Прочитајте им ги овие зборови кога ќе ви се јават или ќе се сретнете:", + "You are now an account recovery officer": "Сега сте службеник за враќање на сметки", + "%s asks to recover their account. Compare the words with them before you approve.": "%s бара враќање на својата сметка. Споредете ги зборовите со него пред да одобрите.", + "A user": "Корисник", + "Your account recovery request was declined": "Вашето барање за враќање на сметка е одбиено", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Враќањето на вашата сметка е подготвено. Отворете го Keepiq во прелистувачот од кој побаравте.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} бара еднократно отклучување на нов уред. Главната лозинка останува иста.", + "Ask your organisation instead": "Наместо тоа, прашајте ја вашата организација", + "The request ended. Ask again or use your master password.": "Барањето заврши. Побарајте повторно или користете ја главната лозинка.", + "Added by {user}": "Додадено од {user}", + "Editor": "Уредник", + "Manager": "Менаџер", + "Role of {member}": "Улога на {member}", + "Team folders you manage": "Тимски папки со кои управувате", + "Viewer": "Прегледувач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Немате копија од овие тајни, па новите членови сè уште не ги добиле. Сопственикот може да ги сподели: {names}", + "Admin areas": "Области на администрација", + "Give a group only the parts of Keepiq administration it needs.": "Дајте ѝ на групата само делови од администрацијата на Keepiq што ѝ се потребни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирајте една или повеќе области на група на страницата за административни привилегии. Администраторите на инстанцата ги имаат сите области.", + "Open administration privileges": "Отвори административни привилегии", + "Policies": "Политики", + "Applications and machine access": "Апликации и машински пристап", + "People and offboarding": "Луѓе и заминување", + "Audit and compliance": "Ревизија и усогласеност", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "верзија, издавач на сертификати, прилози, офлајн кеш, проверка на протекување, типови тајни и резервни копии", + "master password, organisation password, vault policies, rotation, version history and trash": "главна лозинка, лозинка на организацијата, политики на трезорот, ротација, историја на верзии и корпа", + "application queue, application requests and machine leases": "редица на апликации, барања на апликации и машински закупи", + "team offboarding, encryption suites and admin handover": "заминување од тимот, пакети за шифрирање и преземање од администратор", + "audit log, compliance reports, SIEM export and honey alerts": "ревизорски дневник, извештаи за усогласеност, SIEM извоз и предупредувања за мамки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колку верзии од тајна се чуваат, колку долго, и колку долго избришаните тајни остануваат во корпата.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничувања за шифрирани прилози, што серверот ги применува на зачуваните шифрирани бајти.", + "Type the suite ID again to confirm": "Повторно внесете го ID на пакетот за потврда", + "This does not match the suite ID.": "Ова не се совпаѓа со ID на пакетот.", + "Confirm with your master password": "Потврдете со главната лозинка", + "Confirm": "Потврди", + "That master password is not right.": "Таа главна лозинка не е точна.", + "You are sharing with someone new. Enter your master password to confirm.": "Споделувате со ново лице. Внесете ја главната лозинка за потврда.", + "Enter your master password to confirm this share.": "Внесете ја главната лозинка за да го потврдите ова споделување.", + "Enter your master password to confirm this delegation.": "Внесете ја главната лозинка за да го потврдите ова делегирање.", + "Approve {member}": "Одобри {member}", + "Recipient": "Примач", + "No vault yet": "Сè уште нема трезор", + "No matching users": "Нема соодветни корисници", + "Partner organisations": "Партнерски организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Разменувајте тајни со друг Keepiq. Двајцата администратори се додаваат меѓусебно и пред зачувување ги споредуваат корените отпечатоци по телефон или лично.", + "Federation needs Nextcloud 33 or later.": "Федерацијата бара Nextcloud 33 или понов.", + "Your root fingerprint": "Вашиот корен отпечаток", + "No partners yet.": "Сè уште нема партнери.", + "Users here may share to this partner": "Корисниците тука смеат да споделуваат со овој партнер", + "This partner may share to users here": "Овој партнер смее да споделува со корисниците тука", + "Partner address": "Адреса на партнерот", + "Check partner": "Провери партнер", + "Partner root fingerprint": "Корен отпечаток на партнерот", + "I compared this fingerprint with the partner's administrator": "Го споредив овој отпечаток со администраторот на партнерот", + "Add partner": "Додај партнер", + "A secret from another organisation": "Тајна од друга организација", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ја сподели \"%2$s\" со вас. Прифатете ја во Дојдовни од други организации.", + "Incoming from other organisations": "Дојдовни од други организации", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Луѓето во партнерски организации можат да споделат тајна со вас. Прифатете ја за да чувате копија само за читање во вашиот трезор.", + "Nothing shared with you yet": "Сè уште ништо не е споделено со вас", + "Secrets that people in partner organisations share with you appear here.": "Тајните што луѓето во партнерски организации ги споделуваат со вас се појавуваат тука.", + "From {sender}": "Од {sender}", + "Accept": "Прифати", + "Open in vault": "Отвори во трезорот", + "The other organisation did not hand over the secret. Try again later.": "Другата организација не ја предаде тајната. Обидете се повторно подоцна.", + "Set up your vault before you accept a shared secret.": "Поставете го вашиот трезор пред да прифатите споделена тајна.", + "Something went wrong. Try again.": "Нешто тргна наопаку. Обидете се повторно.", + "Waiting for your answer": "Чека ваш одговор", + "In your vault, read-only": "Во вашиот трезор, само за читање", + "Withdrawn by the sender": "Повлечено од испраќачот", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} го сподели ова од друга организација. Можете да го читате, но не и да го менувате или споделувате.", + "Someone": "Некој", + "Share with someone at another organisation": "Сподели со некого од друга организација", + "Their account at the other organisation": "Сметката на лицето во другата организација", + "Check account": "Провери сметка", + "Certificate fingerprint of {account}": "Отпечаток на сертификатот за {account}", + "Compare it with them by phone if you want to be sure.": "Споредете го со лицето по телефон ако сакате да бидете сигурни.", + "Shared. {account} can accept it in their own vault.": "Споделено. {account} може да го прифати во својот трезор.", + "The certificate could not be verified. Nothing was shared.": "Сертификатот не можеше да се провери. Ништо не е споделено.", + "That organisation is not one of your partners.": "Таа организација не е еден од вашите партнери.", + "No one with that account can receive secrets from you.": "Никој со таа сметка не може да прима тајни од вас.", + "The other organisation did not answer. Try again later.": "Другата организација не одговори. Обидете се повторно подоцна.", + "This secret is already shared with that account.": "Оваа тајна веќе е споделена со таа сметка.", + "Other organisations": "Други организации", + "Receive secrets from other organisations": "Примај тајни од други организации", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Луѓето во партнерски организации тогаш можат да ја најдат вашата сметка и да споделуваат тајни со вас. Секоја од нив ја прифаќате сами.", + "Shared": "Споделено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Паузирано: се промени нивниот сертификат или партнерството. Отповикајте или споделете повторно.", + "Their organisation did not get the last change. Revoke it or share again.": "Нивната организација не ја доби последната промена. Отповикајте или споделете повторно.", + "Being withdrawn": "Се повлекува", + "Shared with another organisation": "Споделено со друга организација", + "Change sent to another organisation": "Промената е испратена до друга организација", + "Share with another organisation revoked": "Споделувањето со друга организација е укинато", + "Share with another organisation paused": "Споделувањето со друга организација е паузирано", + "Another organisation did not get a change": "Друга организација не доби промена", + "Secret received from another organisation": "Примена е тајна од друга организација", + "Secret from another organisation accepted": "Тајната од друга организација е прифатена", + "Secret from another organisation declined": "Тајната од друга организација е одбиена", + "Copy from another organisation updated": "Копијата од друга организација е ажурирана", + "Copy from another organisation removed": "Копијата од друга организација е отстранета", + "Declined: they removed their copy. Share again if they need it.": "Одбиено: примачот ја отстрани својата копија. Споделете повторно ако му треба.", + "Recipient at another organisation removed their copy": "Примач од друга организација ја отстрани својата копија", + "Removed the user from %n team folder.": "Корисникот е отстранет од %n тимска папка.", + "Removed the user from %n team folders.": "Корисникот е отстранет од %n тимски папки.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Корисникот е отстранет од %n тимска папка.","Корисникот е отстранет од %n тимски папки."], + "A restored copy came from a share that has ended. It stays read-only.": "Вратената копија потекнува од споделување што заврши. Останува само за читање.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организацијата што сподели вратена копија не е достапна. Копијата останува само за читање и не ги следи нивните промени.", + "Recipient at another organisation restored their copy": "Примач од друга организација ја врати својата копија" }, - "nplurals=2; plural=(n != 1);" + "nplurals=2; plural=(n%10==1 ? 0 : 1);" ) diff --git a/l10n/mk.json b/l10n/mk.json index 23ac65470..c4984403c 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1181,7 +1181,567 @@ "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.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротацијата на клучот го отстрани пристапот во итни случаи на овој контакт. Одредете го повторно ако сè уште го сакате.", + "Shared with groups": "Споделено со групи", + "Not shared with any group yet.": "Сè уште не е споделено со ниедна група.", + "Revoke the share with {group}": "Отповикај го споделувањето со {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Споделено со {group}: {received} членови го примија, {skipped} не, бидејќи сè уште немаат поставено шифрирање.", + "Search groups": "Пребарај групи", + "Failed to share": "Споделувањето не успеа", + "Columns": "Колони", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Поврзете една колона со името. Секоја тајна треба да има име.", + "Notes": "Белешки", + "Do not import": "Не увезувај", + "Hide this value": "Скриј ја оваа вредност", + "Show this value": "Прикажи ја оваа вредност", + "Defaults": "Стандардни", + "New secrets start as this type, and your secret list opens in this view.": "Новите тајни започнуваат како овој тип, а твојата листа на тајни се отвора во овој приказ.", + "Default item type": "Стандарден тип на ставка", + "Cards": "Картички", + "Table": "Табела", + "Could not save your default": "Не можеше да се зачува стандардната вредност", + "Recently used": "Неодамна користени", + "Opened": "Отворено", + "You have not opened any secrets yet": "Сè уште немаш отворено ниту една тајна", + "Could not delete the item type.": "Типот на ставка не можеше да се избрише.", + "Could not load the item types.": "Типовите на ставки не можеа да се вчитаат.", + "Could not save the item type.": "Типот на ставка не можеше да се зачува.", + "Delete item type": "Избриши тип на ставка", + "Edit item type": "Уреди тип на ставка", + "Fields": "Полиња", + "Fields: {count}": "Полиња: {count}", + "Hidden": "Скриено", + "Item types": "Типови на ставки", + "Move up": "Помести нагоре", + "New item type": "Нов тип на ставка", + "No item types defined yet.": "Сè уште нема дефинирани типови на ставки.", + "Required": "Задолжително", + "Text": "Текст", + "This field is required": "Ова поле е задолжително", + "Web address": "Веб адреса", + "{label} (required)": "{label} (задолжително)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Да се избрише „{name}“? Тајните од овој тип остануваат читливи и стануваат ставки Најава.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типовите на ставки што ги дефинираш тука им се прикажуваат на сите во дијалогот Нова тајна, со полињата што ќе ги избереш.", + "Secret moved to the trash": "Тајната е преместена во корпата", + "Secret restored from the trash": "Тајната е вратена од корпата", + "Secret deleted for good": "Тајната е трајно избришана", + "Secret archived": "Тајната е архивирана", + "Secret unarchived": "Тајната е вратена од архивата", + "Unarchive": "Врати од архивата", + "Could not archive the secret": "Тајната не можеше да се архивира", + "Could not unarchive the secret": "Тајната не можеше да се врати од архивата", + "Archive {count} secrets": "Архивирај тајни: {count}", + "Unarchive {count} secrets": "Врати од архивата тајни: {count}", + "Restore {count} secrets": "Врати тајни: {count}", + "Delete {count} secrets for good": "Трајно избриши тајни: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} од {total} тајни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивираните тајни исчезнуваат од листата на трезорот, пребарувањето, автоматското пополнување и извештајот за состојбата. Ги задржуваат споделувањата. Ќе ги најдете во Архива.", + "These secrets come back to the vault list, search and autofill.": "Овие тајни се враќаат во листата на трезорот, пребарувањето и автоматското пополнување.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Овие тајни се враќаат во листата на трезорот. Старите споделувања не се враќаат, па споделете ги повторно каде што треба.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Ова ги брише тајните заедно со прилозите и историјата на верзии. Ова не може да се врати.", + "Delete for good": "Трајно избриши", + "Trash": "Корпа", + "The trash is empty": "Корпата е празна", + "No archived secrets": "Нема архивирани тајни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Избришаните тајни чекаат тука до крајот на периодот на чување, потоа се бришат трајно.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирајте тајна од нејзиниот панел со детали за да остане надвор од листата на трезорот, пребарувањето и автоматското пополнување.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничувања за шифрирани прилози (се применуваат на серверот во зачуваните шифрирани бајти), чување на историјата на верзии и колку долго избришаните тајни остануваат во корпата.", + "Days a deleted secret stays in the trash (1 to 365)": "Денови колку што избришана тајна останува во корпата (од 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Ова ја преместува тајната во корпата и веднаш ги завршува нејзините споделувања. Може да ја вратите од корпата до крајот на периодот на чување: 30 дена, освен ако администраторот не го сменил.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Ова ги преместува тајните во корпата ({count}) и веднаш ги завршува нивните споделувања. Може да ги вратите од корпата до крајот на периодот на чување.", + "Remove {name} from favourites": "Отстрани {name} од омилени", + "Add {name} to favourites": "Додај {name} во омилени", + "Could not change the favourite": "Омиленото не можеше да се промени", + "Remove from favourites": "Отстрани од омилени", + "Add to favourites": "Додај во омилени", + "Tags": "Ознаки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Ознаките не се шифрирани. Администраторите на серверот можат да ги читаат, како и имињата на папките.", + "Favourites": "Омилени", + "Filter by tag": "Филтрирај по ознака", + "All tags": "Сите ознаки", + "Last used": "Последно користено", + "Tags for {count} secrets": "Ознаки за {count} тајни", + "Tag": "Ознака", + "Remove tag": "Отстрани ознака", + "Add tag": "Додај ознака", + "Could not change the tags. Try again.": "Ознаките не можеа да се променат. Обидете се повторно.", + "Could not approve the application. It is still in the queue.": "Апликацијата не може да се одобри. Сè уште е во редицата.", + "Could not reject the application. It is still in the queue.": "Апликацијата не може да се одбие. Сè уште е во редицата.", + "Removed the user from {count} team folders.": "Корисникот е отстранет од {count} тимски папки.", + "Approve a share": "Одобри споделување", + "This approval link is incomplete. Open it again from the notification.": "Оваа врска за одобрување е нецелосна. Отворете ја повторно од известувањето.", + "Deny": "Одбиј", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се приклучи на група со која споделувате тајна. Да ја споделите тајната и со него?", + "{requester} asks you to share a secret with {user}.": "{requester} бара од вас да споделите тајна со {user}.", + "Shared. The recipient can now open the secret.": "Споделено. Примачот сега може да ја отвори тајната.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Примачот сè уште не го поставил Keepiq, па ништо не е споделено. Обидете се повторно откако ќе го постави.", + "Could not share the secret. Only its owner can approve this.": "Тајната не може да се сподели. Само нејзиниот сопственик може да го одобри ова.", + "Could not share the secret. Try again.": "Тајната не може да се сподели. Обидете се повторно.", + "Denied. Nothing was shared.": "Одбиено. Ништо не е споделено.", + "Could not deny the request. Try again.": "Барањето не може да се одбие. Обидете се повторно.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s бара од вас да ја споделите тајната \"%2$s\" со %3$s.", + "Expires on (optional)": "Истекува на (по избор)", + "Hand over to": "Предај на", + "Choose a recipient": "Изберете примач", + "Hand over temporarily": "Предај привремено", + "Expiry rules": "Правила за истекување", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Поставете колку долго смеат да важат лозинките од еден тип ставка или во една папка и кога да добиете потсетник. Кога важат повеќе датуми, се смета најраниот.", + "Delete rule": "Избриши правило", + "Set by your administrator": "Поставено од вашиот администратор", + "No expiry rules yet.": "Сè уште нема правила за истекување.", + "Applies to": "Се однесува на", + "Item type": "Тип на ставка", + "Maximum age in days (empty for reminders only)": "Максимална старост во денови (празно само за потсетници)", + "Remind me this many days before, comma separated": "Потсети ме толку денови пред, одвоени со запирка", + "Save rule": "Зачувај правило", + "An item type": "Тип на ставка", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Истекува по {days} дена", + "Reminders {days} days before": "Потсетници {days} дена пред", + "Could not save the expiry rule.": "Правилото за истекување не може да се зачува.", + "Could not delete the expiry rule.": "Правилото за истекување не може да се избрише.", + "All statuses": "Сите статуси", + "Compromised": "Компромитиран", + "Could not load the members.": "Членовите не може да се вчитаат.", + "Emergency contact": "Контакт за итни случаи", + "Leaving user": "Корисник што заминува", + "No": "Не", + "No users match this filter.": "Ниеден корисник не одговара на овој филтер.", + "Not set up": "Не е поставено", + "Revoke suite": "Отповикај пакет", + "Revoked": "Отповикан", + "Search users": "Пребарај корисници", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Видете кои корисници поставиле сеф. Започнете одјавување или отповикајте пакет од ред.", + "Successor": "Наследник", + "Team folders": "Тимски папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Корисникот е сè уште во групата {groups}, која е членка на тимска папка. Отстранете го од групата или оневозможете ја сметката.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Корисникот е сè уште во групите {groups}, кои се членки на тимски папки. Отстранете го од групите или оневозможете ја сметката.", + "Vault status": "Статус на сефот", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Извозот во CXF НЕ Е ШИФРИРАН. Секоја лозинка и најава ќе биде читлива како отворен текст во преземената датотека. Чувајте ја безбедно и избришете ја веднаш по употребата.", + "Root certificate expiring soon": "Коренскиот сертификат наскоро истекува", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Коренскиот сертификат на сефот истекува за %1$d ден(а). Обновете го пред тоа. Обновувањето повторно го потпишува секој пакет за шифрирање.", + "Compromise recovery aborted": "Обновувањето по компромитирање е прекинато", + "Key rotation ended by a compromise revoke": "Ротацијата на клучот е завршена со отповикување поради компромитирање", + "Encryption suite revoke refused": "Отповикувањето на пакетот за шифрирање е одбиено", + "Master password proof refused": "Доказот за главната лозинка е одбиен", + "Your current master password": "Вашата тековна главна лозинка", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за итни случаи имаше барање за пристап на чекање кога ротацијата на клучот го отстрани. Проверете кој побарал пред повторно да додадете некого.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контактите за итни случаи (%n) имаа барање за пристап на чекање кога ротацијата на клучот ги отстрани. Проверете кој побарал пред повторно да додадете некого.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Овие контакти за итни случаи не се пренесени на вашиот нов клуч. Нивниот итен пристап е отстранет. Додајте ги повторно во Итен пристап ако сè уште ги сакате.", + "Renew root certificate": "Обнови го коренскиот сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ова создава нов коренски и посреднички сертификат. Секој активен пакет за шифрирање повторно се потпишува. Ова не може да се врати.", + "Renew root": "Обнови корен", + "Root renewed. {n} encryption suites signed again.": "Коренот е обновен. Повторно потпишани пакети за шифрирање: {n}.", + "Could not renew the root certificate.": "Коренскиот сертификат не може да се обнови.", + "Lease policy for this application": "Политика за закуп за оваа апликација", + "In force now: {default} seconds by default, {max} seconds at most.": "Сега важи: стандардно {default} секунди, најмногу {max} секунди.", + "Leases are not renewable": "Закупите не можат да се обновуваат", + "Lease policy saved.": "Политиката за закуп е зачувана.", + "Leave a field empty to use the instance value.": "Оставете поле празно за да се користи вредноста на инстанцата.", + "Instance value: {value}": "Вредност на инстанцата: {value}", + "Renewal": "Обновување", + "Use the instance value ({value})": "Користи ја вредноста на инстанцата ({value})", + "Allowed": "Дозволено", + "Not allowed": "Не е дозволено", + "Save lease policy": "Зачувај политика за закуп", + "Only an administrator can change this policy.": "Само администратор може да ја промени оваа политика.", + "Could not save the lease policy.": "Политиката за закуп не може да се зачува.", + "{member} got access from {confirmer}.": "{member} доби пристап од {confirmer}.", + "Automatically confirm new team folder members": "Автоматски потврди нови членови на тимски папки", + "Gave %n new member access to a team folder.": "%n нов член доби пристап до тимска папка.", + "Gave %n new members access to a team folder.": "Нови членови (%n) добија пристап до тимска папка.", + "Give new team folder members access without waiting for the folder owner.": "Дајте им пристап на новите членови без да чекате на сопственикот на папката.", + "New team folder members": "Нови членови на тимски папки", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Сопственикот или член со право на запишување ги потврдува од отворениот сеф. Keepiq никогаш не дешифрира на серверот.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Се чека член со право на запишување да го отвори Keepiq. Може да споделите и сега.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Дел од одговорот на компромитирање не успеа ({failed} чекор(и)). Проверете го дневникот на серверот, а потоа повторно отповикајте го пакетот за да го завршите.", + "This also revoked suite {suite} and ended key migration {migration}.": "Со ова е отповикан и пакетот {suite} и завршена миграцијата на клучеви {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отповикувањето на вториот пакет избриша %n контакт за итен пристап.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отповикувањето на вториот пакет избриша %n контакти за итен пристап.", + "A suite revoked as compromised cannot be reinstated.": "Пакет отповикан како компромитиран не може да се врати.", + "Archives to keep": "Архиви за чување", + "Back up every vault automatically": "Автоматски направи резервна копија на секој сеф", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Правете резервна копија на секој сеф според распоред. Архивите содржат само шифриран текст и се враќаат со occ.", + "Back up now": "Направи копија сега", + "Backup public key (PEM, optional)": "Јавен клуч за резервна копија (PEM, опционално)", + "Backup requested for the next cron run": "Копија побарана за следното извршување на cron", + "Encrypted": "Шифрирано", + "Every (hours)": "На секои (часа)", + "Last backup {when} failed: {error}": "Последната копија {when} не успеа: {error}", + "Last backup {when} succeeded.": "Последната копија {when} успеа.", + "No archives yet.": "Сè уште нема архиви.", + "Size": "Големина", + "Vault backups": "Резервни копии на сефот", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Со клуч секоја архива се шифрира за него. Чувајте го приватниот клуч надвор од овој сервер: ви треба за проверка или враќање.", + "Written": "Запишано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n корисник во опсегот сè уште нема најава во два чекора и не може да го отвори сефот додека ова е вклучено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Корисниците во опсегот (%n) сè уште немаат најава во два чекора и не можат да го отворат сефот додека ова е вклучено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервните кодови не се сметаат. Ако вашите корисници се најавуваат преку давател на идентитет со сопствен втор фактор, изоставете ги нивните групи.", + "Block personal vault export": "Блокирај извоз на личен сеф", + "Keep work logins in team folders": "Чувај ги работните најави во тимски папки", + "Move to a team folder": "Премести во тимска папка", + "Not in a team folder": "Не е во тимска папка", + "Only for these groups (empty is everyone)": "Само за овие групи (празно значи сите)", + "Require two-factor login before the vault opens": "Барај најава во два чекора пред да се отвори сефот", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за секој сеф. Секое важи за сите или само за групите што ќе ги изберете.", + "Secret types that belong in a team folder": "Типови тајни што припаѓаат во тимска папка", + "Set up two-factor login": "Постави најава во два чекора", + "Team folder you can write to": "Тимска папка во која можете да пишувате", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Корисниците не можат да преземат резервна копија, CSV или датотека за пренос. Нивниот пакет лични податоци останува достапен.", + "Users cannot save these secret types in a personal folder.": "Корисниците не можат да ги зачуваат овие типови тајни во лична папка.", + "Vault policies": "Правила на сефот", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Вашата организација не дозволува извоз на вашиот личен сеф. Вашиот пакет лични податоци во поставките останува достапен.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Вашата организација ги чува овие тајни во тимска папка. Преместете ја секоја во тимска папка.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Вашата организација го чува овој тип тајна во тимска папка. Изберете една од вашите тимски папки или една во која можете да пишувате.", + "Your organisation requires two-factor login before you can open your vault.": "Вашата организација бара најава во два чекора пред да можете да го отворите сефот.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Корисниците избираат колку долго проширувањето останува отклучено при неактивност. Вие го поставувате најдолгото време што смеат да го изберат.", + "Longest idle time before the extension locks": "Најдолго време на неактивност пред проширувањето да се заклучи", + "1 minute": "1 минута", + "5 minutes": "5 минути", + "15 minutes": "15 минути", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД на директориум (закупец)", + "Application (client) ID": "ИД на апликација (клиент)", + "Data collection rule immutable ID": "Непроменлив ИД на правилото за собирање податоци", + "Stream name": "Име на тек", + "Splunk index (optional)": "Splunk индекс (опционално)", + "Sourcetype (optional)": "Sourcetype (опционално)", + "Leave blank to keep the current one": "Оставете празно за да ја задржите тековната", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF преку syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крајна точка за собирање податоци (https URL)", + "HTTP Event Collector URL (https)": "URL на HTTP Event Collector (https)", + "Client secret (write-only)": "Тајна на клиентот (само запишување)", + "HEC token (write-only)": "HEC токен (само запишување)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Препраќајте дозволени ревизорски настани до Splunk, Microsoft Sentinel, syslog приемник или webhook. Пораките носат само исчистени метаподатоци: ниедна тајна вредност, име, најава или шифриран текст никогаш не го напушта серверот.", + "%n change waiting to sync": "%n промена чека синхронизација", + "%n changes waiting to sync": "%n промени чекаат синхронизација", + "Changes that could not sync": "Промени што не можеа да се синхронизираат", + "Choose a version": "Изберете верзија", + "Copy value": "Копирај вредност", + "Deleted": "Избришано", + "Discard": "Отфрли", + "Keep my offline change": "Задржи ја мојата промена без мрежа", + "Keep the server version": "Задржи ја верзијата од серверот", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq е само за читање без мрежа. Администраторот не го вклучил уредувањето без мрежа.", + "Let users edit secrets offline": "Дозволи им на корисниците да уредуваат тајни без мрежа", + "Not synced yet": "Сè уште не е синхронизирано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Промените без мрежа остануваат на уредот, шифрирани за корисникот, и се синхронизираат при следното отклучување на мрежа. Споделувањето, папките и прилозите сè уште бараат врска.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без мрежа. Уредувањата, преместувањата и бришењата остануваат на овој уред и се синхронизираат кога повторно ќе бидете на мрежа. Споделувањето и прилозите бараат врска.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без мрежа. Вашите промени остануваат на овој уред и се синхронизираат кога повторно ќе бидете на мрежа. Последна синхронизација {when}.", + "Open my changes": "Отвори ги моите промени", + "Sharing needs a connection": "Споделувањето бара врска", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Некој ја промени оваа тајна на серверот откако беше направена вашата копија без мрежа. Изберете која верзија да ја задржите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизирајте ги или отфрлете ги промените без мрежа пред да ги замените клучевите.", + "That password did not open your changes.": "Таа лозинка не ги отвори вашите промени.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Снимката без мрежа чува шифрирани тајни (се отвораат само со клучот изведен од главната лозинка на корисникот, точно како на серверот) и ги шифрира имињата, URL-адресите и имињата на папките при складирање. Пристапот без мрежа е само за читање, освен ако подолу не дозволите уредување без мрежа. Исклучете го ова за уреди што никогаш не смеат да кешираат акредитиви; исклучувањето ги брише постоечките кешови при следното вчитување.", + "The previous vault copy is gone, so these changes cannot be opened.": "Претходната копија на трезорот ја нема, па овие промени не може да се отворат.", + "The server version": "Верзијата од серверот", + "This secret changed while you were offline": "Оваа тајна се промени додека бевте без мрежа", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ја избришавте оваа тајна без мрежа, но оттогаш е променета на серверот. Изберете која верзија да ја задржите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Вашите клучеви беа променети на друг уред. Внесете ја претходната главна лозинка за да ги синхронизирате промените без мрежа, или отфрлете ги.", + "Your offline change": "Вашата промена без мрежа", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n контакт за итни случаи имаше барање за пристап на чекање кога ротацијата на клучот го отстрани. Проверете кој побарал пред повторно да додадете некого.", + "Контактите за итни случаи (%n) имаа барање за пристап на чекање кога ротацијата на клучот ги отстрани. Проверете кој побарал пред повторно да додадете некого." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n елемент не може да се претстави во CXF и ќе биде прескокнат.", + "%n елементи не можат да се претстават во CXF и ќе бидат прескокнати." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n постара верзија беше отфрлена, бидејќи може да се пренесе само неодамнешната историја.", + "%n постари верзии беа отфрлени, бидејќи може да се пренесе само неодамнешната историја." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n копија од тајна сè уште треба да се шифрира и сподели.", + "%n копии од тајни сè уште треба да се шифрираат и споделат." + ], + "_%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 тајна не можеше да се дешифрира и не е во овој извоз.", + "%n тајни не можеа да се дешифрираат и не се во овој извоз." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n тајна не можеше да се дешифрира со вашиот стар клуч, па затоа не беше пренесена.", + "%n тајни не можеа да се дешифрираат со вашиот стар клуч, па затоа не беа пренесени." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n тајна не беше пренесена.", + "%n тајни не беа пренесени." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n тајна сè уште е шифрирана со вашиот претходен клуч.", + "%n тајни сè уште се шифрирани со вашиот претходен клуч." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n тајна беше прескокната бидејќи наследникот сè уште нема копија — додајте го наследникот во папката и извршете повторно.", + "%n тајни беа прескокнати бидејќи наследникот сè уште нема копија — додајте го наследникот во папката и извршете повторно." + ], + "_%n secret_::_%n secrets_": [ + "%n тајна", + "%n тајни" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n корисник во опсегот сè уште нема најава во два чекора и не може да го отвори сефот додека ова е вклучено.", + "Корисниците во опсегот (%n) сè уште немаат најава во два чекора и не можат да го отворат сефот додека ова е вклучено." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Сепак заврши, губејќи пристап до %n тајна", + "Сепак заврши, губејќи пристап до %n тајни" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n нов член доби пристап до тимска папка.", + "Нови членови (%n) добија пристап до тимска папка." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ротацијата на клучот е завршена. %n тајна беше повторно шифрирана со вашиот нов клуч.", + "Ротацијата на клучот е завршена. %n тајни беа повторно шифрирани со вашиот нов клуч." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Отповикувањето на вториот пакет избриша %n контакт за итен пристап.", + "Отповикувањето на вториот пакет избриша %n контакти за итен пристап." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Отповикувањето на овој комплет избриша %n контакт за итен пристап.", + "Отповикувањето на овој комплет избриша %n контакти за итен пристап." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "забележано %n пат во протечени податоци", + "забележано %n пати во протечени податоци" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "споделено со %n тајна", + "споделено со %n тајни" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Оваа папка содржи директно %n тајна.", + "Оваа папка содржи директно %n тајни." + ], + "_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._": [ + "Ротацијата на клучот отстрани %n контакт за итни случаи. Проверете „Пристап во итни случаи“ и додајте го повторно ако сè уште го сакате.", + "Ротацијата на клучот отстрани %n контакти за итни случаи. Проверете „Пристап во итни случаи“ и додајте ги повторно ако сè уште ги сакате." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n промена чека синхронизација", + "%n промени чекаат синхронизација" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Корисникот е сè уште во групата {groups}, која е членка на тимска папка. Отстранете го од групата или оневозможете ја сметката.", + "Корисникот е сè уште во групите {groups}, кои се членки на тимски папки. Отстранете го од групите или оневозможете ја сметката." + ], + "Allow approval from another device": "Дозволи одобрување од друг уред", + "App": "Апликација", + "Approve a new device": "Одобри нов уред", + "Approve from another device": "Одобри од друг уред", + "Asked at": "Побарано во", + "Check that the new device shows these words:": "Проверете дали новиот уред ги прикажува овие зборови:", + "Denied. If you did not ask, end your other sessions:": "Одбиено. Ако не го побаравте ова, завршете ги другите сесии:", + "Device": "Уред", + "IP address": "IP адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Овозможи корисниците да отклучат нов прелистувач со одобрување од уред на кој Keepiq е веќе отклучен.", + "New device approval": "Одобрување нови уреди", + "Nextcloud security settings": "Безбедносни поставки на Nextcloud", + "Only approve a device you are using right now.": "Одобрете само уред што го користите токму сега.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворете го Keepiq на уред на кој е отклучен и одобрете го овој уред. Проверете дали ги прикажува истите зборови:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Уредот што одобрува го запечатува клучот за отклучување за новиот уред. Серверот само го препраќа и не може да го отвори.", + "The master password is not right, or the request has ended.": "Главната лозинка не е точна или барањето заврши.", + "The request expired. Ask again or use your master password.": "Барањето истече. Побарајте повторно или користете ја главната лозинка.", + "The request was denied.": "Барањето беше одбиено.", + "Too many requests. Try again in an hour or use your master password.": "Премногу барања. Обидете се повторно за еден час или користете ја главната лозинка.", + "Unknown device": "Непознат уред", + "Web app": "Веб-апликација", + "A device": "Уред", + "A new device asks to open your vault": "Нов уред бара да го отвори вашиот сеф", + "%s asks to be approved. Only approve a device you are using right now.": "%s бара одобрување. Одобрете само уред што го користите токму сега.", + "Access ends on (optional)": "Пристапот завршува на (опционално)", + "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.": "Апликациите на Keepiq нема да ја прикажат ниту копираат лозинката. Некој со технички знаења сепак може да ја прочита од својот уред. Сменете ја кога пристапот ќе заврши.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Оваа тајна е само за користење. Најавете се преку проширувањето за прелистувач на Keepiq.", + "Until {date}": "До {date}", + "Use only": "Само користење", + "Use only (can sign in, cannot view or copy)": "Само користење (може да се најави, не може да види ниту копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Можете да се најавите со оваа најава преку проширувањето за прелистувач на Keepiq. Сопственикот одлучи да не ви дозволи да ја видите или копирате.", + "Your access ends on {date}": "Вашиот пристап завршува на {date}", + "Your access to this secret has ended": "Вашиот пристап до оваа тајна заврши", + "Your access to \"%s\" ends tomorrow": "Вашиот пристап до „%s“ завршува утре", + "Your access to \"%s\" has ended": "Вашиот пристап до „%s“ заврши", + "%1$s no longer has access to \"%2$s\"": "%1$s повеќе нема пристап до „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s можеше да ја види оваа лозинка. Сменете ја ако %1$s повеќе не треба да ја знае.", + "%s could not view this password in Keepiq.": "%s не можеше да ја види оваа лозинка во Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} од {threshold} одобрувања", + "a recovery officer": "службеник за враќање", + "Account recovery": "Враќање на сметка", + "Approvals needed": "Потребни одобрувања", + "Ask {user} which words they see, by phone or in person. They must be:": "Прашајте го {user} кои зборови ги гледа, по телефон или лично. Тие мора да бидат:", + "Check again": "Провери повторно", + "Create the recovery key": "Создај клуч за враќање", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Создајте го клучот за враќање. Вашиот прелистувач го прави и му дава на секој службеник копија што само тој може да ја отвори.", + "Decline": "Одбиј", + "Enrol in account recovery": "Пријавете се за враќање на сметка", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Пријавете се за вашата организација да може да ви помогне да го вратите трезорот ако ја заборавите главната лозинка.", + "Every user is enrolled": "Сите корисници се пријавени", + "Finish the recovery in the browser you asked from.": "Завршете го враќањето во прелистувачот од кој побаравте.", + "Forgot your master password?": "Ја заборавивте главната лозинка?", + "Hand the key over": "Предај го клучот", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дозволете корисниците што ја заборавиле главната лозинка да го вратат трезорот, со одобрување од службениците за враќање што ги именувате.", + "New master password": "Нова главна лозинка", + "No one is asking to recover their account.": "Никој не бара враќање на својата сметка.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Сè уште нема клуч за враќање. Еден од службениците го создава во своите поставки на Keepiq.", + "Off": "Исклучено", + "Officer {user} has no encryption set up yet.": "Службеникот {user} сè уште нема поставено шифрирање.", + "Officers (user IDs, separated by commas)": "Службеници (кориснички ID, одделени со запирки)", + "Policy": "Политика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Објавете го овој отпечаток интерно, за корисниците да можат да го проверат пред да се пријават.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Вратено со помош од {officer}. Сменете го клучот на трезорот сега во Поставки, Безбедност: \"Мојата главна лозинка е компромитирана\".", + "Recovery key fingerprint: {fingerprint}": "Отпечаток на клучот за враќање: {fingerprint}", + "Recovery officer": "Службеник за враќање", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Отстранетите службеници сега ја губат својата копија, но можеби ја отвориле претходно. Нека службеник создаде нов клуч за враќање.", + "Repeat the new master password": "Повторете ја новата главна лозинка", + "Retire this recovery key": "Повлечи го овој клуч за враќање", + "Set the new master password": "Постави ја новата главна лозинка", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификатот за враќање не е издаден од овој Keepiq. Не се пријавувајте и известете го администраторот.", + "The words match, approve": "Зборовите се совпаѓаат, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Овој корисник е пријавен за враќање на сметка. Враќањето ги чува неговите тајни; отповикувањето ја брише неговата пријава.", + "Users may enrol": "Корисниците можат да се пријават", + "Withdraw from account recovery": "Откажи се од враќање на сметка", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Пријавени сте за враќање на сметка. Отпечаток на клучот за враќање: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Пријавени сте. Ако ја заборавите главната лозинка, вашата организација може да ви помогне да го вратите трезорот.", + "Your key is back. Choose a new master password.": "Клучот ви е вратен. Изберете нова главна лозинка.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Вашите службеници за враќање се известени. Прочитајте им ги овие зборови кога ќе ви се јават или ќе се сретнете:", + "You are now an account recovery officer": "Сега сте службеник за враќање на сметки", + "%s asks to recover their account. Compare the words with them before you approve.": "%s бара враќање на својата сметка. Споредете ги зборовите со него пред да одобрите.", + "A user": "Корисник", + "Your account recovery request was declined": "Вашето барање за враќање на сметка е одбиено", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Враќањето на вашата сметка е подготвено. Отворете го Keepiq во прелистувачот од кој побаравте.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} бара еднократно отклучување на нов уред. Главната лозинка останува иста.", + "Ask your organisation instead": "Наместо тоа, прашајте ја вашата организација", + "The request ended. Ask again or use your master password.": "Барањето заврши. Побарајте повторно или користете ја главната лозинка.", + "Added by {user}": "Додадено од {user}", + "Editor": "Уредник", + "Manager": "Менаџер", + "Role of {member}": "Улога на {member}", + "Team folders you manage": "Тимски папки со кои управувате", + "Viewer": "Прегледувач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Немате копија од овие тајни, па новите членови сè уште не ги добиле. Сопственикот може да ги сподели: {names}", + "Admin areas": "Области на администрација", + "Give a group only the parts of Keepiq administration it needs.": "Дајте ѝ на групата само делови од администрацијата на Keepiq што ѝ се потребни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирајте една или повеќе области на група на страницата за административни привилегии. Администраторите на инстанцата ги имаат сите области.", + "Open administration privileges": "Отвори административни привилегии", + "Policies": "Политики", + "Applications and machine access": "Апликации и машински пристап", + "People and offboarding": "Луѓе и заминување", + "Audit and compliance": "Ревизија и усогласеност", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "верзија, издавач на сертификати, прилози, офлајн кеш, проверка на протекување, типови тајни и резервни копии", + "master password, organisation password, vault policies, rotation, version history and trash": "главна лозинка, лозинка на организацијата, политики на трезорот, ротација, историја на верзии и корпа", + "application queue, application requests and machine leases": "редица на апликации, барања на апликации и машински закупи", + "team offboarding, encryption suites and admin handover": "заминување од тимот, пакети за шифрирање и преземање од администратор", + "audit log, compliance reports, SIEM export and honey alerts": "ревизорски дневник, извештаи за усогласеност, SIEM извоз и предупредувања за мамки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колку верзии од тајна се чуваат, колку долго, и колку долго избришаните тајни остануваат во корпата.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничувања за шифрирани прилози, што серверот ги применува на зачуваните шифрирани бајти.", + "Type the suite ID again to confirm": "Повторно внесете го ID на пакетот за потврда", + "This does not match the suite ID.": "Ова не се совпаѓа со ID на пакетот.", + "Confirm with your master password": "Потврдете со главната лозинка", + "Confirm": "Потврди", + "That master password is not right.": "Таа главна лозинка не е точна.", + "You are sharing with someone new. Enter your master password to confirm.": "Споделувате со ново лице. Внесете ја главната лозинка за потврда.", + "Enter your master password to confirm this share.": "Внесете ја главната лозинка за да го потврдите ова споделување.", + "Enter your master password to confirm this delegation.": "Внесете ја главната лозинка за да го потврдите ова делегирање.", + "Approve {member}": "Одобри {member}", + "Recipient": "Примач", + "No vault yet": "Сè уште нема трезор", + "No matching users": "Нема соодветни корисници", + "Partner organisations": "Партнерски организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Разменувајте тајни со друг Keepiq. Двајцата администратори се додаваат меѓусебно и пред зачувување ги споредуваат корените отпечатоци по телефон или лично.", + "Federation needs Nextcloud 33 or later.": "Федерацијата бара Nextcloud 33 или понов.", + "Your root fingerprint": "Вашиот корен отпечаток", + "No partners yet.": "Сè уште нема партнери.", + "Users here may share to this partner": "Корисниците тука смеат да споделуваат со овој партнер", + "This partner may share to users here": "Овој партнер смее да споделува со корисниците тука", + "Partner address": "Адреса на партнерот", + "Check partner": "Провери партнер", + "Partner root fingerprint": "Корен отпечаток на партнерот", + "I compared this fingerprint with the partner's administrator": "Го споредив овој отпечаток со администраторот на партнерот", + "Add partner": "Додај партнер", + "A secret from another organisation": "Тајна од друга организација", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ја сподели \"%2$s\" со вас. Прифатете ја во Дојдовни од други организации.", + "Incoming from other organisations": "Дојдовни од други организации", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Луѓето во партнерски организации можат да споделат тајна со вас. Прифатете ја за да чувате копија само за читање во вашиот трезор.", + "Nothing shared with you yet": "Сè уште ништо не е споделено со вас", + "Secrets that people in partner organisations share with you appear here.": "Тајните што луѓето во партнерски организации ги споделуваат со вас се појавуваат тука.", + "From {sender}": "Од {sender}", + "Accept": "Прифати", + "Open in vault": "Отвори во трезорот", + "The other organisation did not hand over the secret. Try again later.": "Другата организација не ја предаде тајната. Обидете се повторно подоцна.", + "Set up your vault before you accept a shared secret.": "Поставете го вашиот трезор пред да прифатите споделена тајна.", + "Something went wrong. Try again.": "Нешто тргна наопаку. Обидете се повторно.", + "Waiting for your answer": "Чека ваш одговор", + "In your vault, read-only": "Во вашиот трезор, само за читање", + "Withdrawn by the sender": "Повлечено од испраќачот", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} го сподели ова од друга организација. Можете да го читате, но не и да го менувате или споделувате.", + "Someone": "Некој", + "Share with someone at another organisation": "Сподели со некого од друга организација", + "Their account at the other organisation": "Сметката на лицето во другата организација", + "Check account": "Провери сметка", + "Certificate fingerprint of {account}": "Отпечаток на сертификатот за {account}", + "Compare it with them by phone if you want to be sure.": "Споредете го со лицето по телефон ако сакате да бидете сигурни.", + "Shared. {account} can accept it in their own vault.": "Споделено. {account} може да го прифати во својот трезор.", + "The certificate could not be verified. Nothing was shared.": "Сертификатот не можеше да се провери. Ништо не е споделено.", + "That organisation is not one of your partners.": "Таа организација не е еден од вашите партнери.", + "No one with that account can receive secrets from you.": "Никој со таа сметка не може да прима тајни од вас.", + "The other organisation did not answer. Try again later.": "Другата организација не одговори. Обидете се повторно подоцна.", + "This secret is already shared with that account.": "Оваа тајна веќе е споделена со таа сметка.", + "Other organisations": "Други организации", + "Receive secrets from other organisations": "Примај тајни од други организации", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Луѓето во партнерски организации тогаш можат да ја најдат вашата сметка и да споделуваат тајни со вас. Секоја од нив ја прифаќате сами.", + "Shared": "Споделено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Паузирано: се промени нивниот сертификат или партнерството. Отповикајте или споделете повторно.", + "Their organisation did not get the last change. Revoke it or share again.": "Нивната организација не ја доби последната промена. Отповикајте или споделете повторно.", + "Being withdrawn": "Се повлекува", + "Shared with another organisation": "Споделено со друга организација", + "Change sent to another organisation": "Промената е испратена до друга организација", + "Share with another organisation revoked": "Споделувањето со друга организација е укинато", + "Share with another organisation paused": "Споделувањето со друга организација е паузирано", + "Another organisation did not get a change": "Друга организација не доби промена", + "Secret received from another organisation": "Примена е тајна од друга организација", + "Secret from another organisation accepted": "Тајната од друга организација е прифатена", + "Secret from another organisation declined": "Тајната од друга организација е одбиена", + "Copy from another organisation updated": "Копијата од друга организација е ажурирана", + "Copy from another organisation removed": "Копијата од друга организација е отстранета", + "Declined: they removed their copy. Share again if they need it.": "Одбиено: примачот ја отстрани својата копија. Споделете повторно ако му треба.", + "Recipient at another organisation removed their copy": "Примач од друга организација ја отстрани својата копија", + "Removed the user from %n team folder.": "Корисникот е отстранет од %n тимска папка.", + "Removed the user from %n team folders.": "Корисникот е отстранет од %n тимски папки.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Корисникот е отстранет од %n тимска папка.", + "Корисникот е отстранет од %n тимски папки." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Вратената копија потекнува од споделување што заврши. Останува само за читање.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организацијата што сподели вратена копија не е достапна. Копијата останува само за читање и не ги следи нивните промени.", + "Recipient at another organisation restored their copy": "Примач од друга организација ја врати својата копија" }, "plurals": null } diff --git a/l10n/mt.js b/l10n/mt.js index 18400c936..5d68ac3a0 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Maqsum ma' gruppi", + "Not shared with any group yet.": "Għadu mhux maqsum ma' ebda grupp.", + "Revoke the share with {group}": "Irrevoka l-qsim ma' {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Maqsum ma' {group}: {received} membri rċevewh, {skipped} le għax għadhom ma waqqfux il-kriptaġġ.", + "Search groups": "Fittex gruppi", + "Failed to share": "Il-qsim ma rnexxiex", + "Columns": "Kolonni", + "Column {number}": "Kolonna {number}", + "Map one column to Name. Every secret needs a name.": "Orbot kolonna waħda mal-Isem. Kull sigriet għandu bżonn isem.", + "Notes": "Noti", + "Do not import": "Timportax", + "Hide this value": "Aħbi dan il-valur", + "Show this value": "Uri dan il-valur", + "Defaults": "Awtomatiċi", + "New secrets start as this type, and your secret list opens in this view.": "Is-sigrieti l-ġodda jibdew b'dan it-tip, u l-lista tas-sigrieti tiegħek tiftaħ f'din il-viżta.", + "Default item type": "Tip ta' oġġett awtomatiku", + "Cards": "Karti", + "Table": "Tabella", + "Could not save your default": "Ma setax jiġi ssejvjat l-awtomatiku tiegħek", + "Recently used": "Użati reċentement", + "Opened": "Miftuħ", + "You have not opened any secrets yet": "Għadek ma ftaħt l-ebda sigriet", + "Could not delete the item type.": "Ma setax jitħassar it-tip ta' oġġett.", + "Could not load the item types.": "Ma setgħux jittellgħu t-tipi ta' oġġetti.", + "Could not save the item type.": "Ma setax jiġi ssejvjat it-tip ta' oġġett.", + "Delete item type": "Ħassar it-tip ta' oġġett", + "Edit item type": "Editja t-tip ta' oġġett", + "Fields": "Oqsma", + "Fields: {count}": "Oqsma: {count}", + "Hidden": "Moħbi", + "Item types": "Tipi ta' oġġetti", + "Move up": "Ċaqlaq 'il fuq", + "New item type": "Tip ġdid ta' oġġett", + "No item types defined yet.": "Għad m'hemm l-ebda tip ta' oġġett definit.", + "Required": "Obbligatorju", + "Text": "Test", + "This field is required": "Dan il-qasam huwa obbligatorju", + "Web address": "Indirizz tal-web", + "{label} (required)": "{label} (obbligatorju)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Tħassar “{name}”? Is-sigrieti ta' dan it-tip jibqgħu jinqraw u jsiru oġġetti Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "It-tipi ta' oġġetti li tiddefinixxi hawn jidhru għal kulħadd fid-djalogu Sigriet ġdid, bl-oqsma li tagħżel.", + "Secret moved to the trash": "Is-sigriet tmexxa fil-barmil", + "Secret restored from the trash": "Is-sigriet ġie rrestawrat mill-barmil", + "Secret deleted for good": "Is-sigriet tħassar għal kollox", + "Secret archived": "Is-sigriet ġie arkivjat", + "Secret unarchived": "Is-sigriet tneħħa mill-arkivju", + "Unarchive": "Neħħi mill-arkivju", + "Could not archive the secret": "Ma setax jiġi arkivjat is-sigriet", + "Could not unarchive the secret": "Ma setax jitneħħa s-sigriet mill-arkivju", + "Archive {count} secrets": "Arkivja sigrieti: {count}", + "Unarchive {count} secrets": "Neħħi sigrieti mill-arkivju: {count}", + "Restore {count} secrets": "Irrestawra sigrieti: {count}", + "Delete {count} secrets for good": "Ħassar għal kollox sigrieti: {count}", + "Done for {ok} of {total} secrets": "Lest għal {ok} minn {total} sigrieti", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Is-sigrieti arkivjati joħorġu mil-lista tal-kaxxaforti, mit-tfittxija, mill-mili awtomatiku u mir-rapport tas-saħħa. Iżommu l-qsim tagħhom. Issibhom taħt Arkivju.", + "These secrets come back to the vault list, search and autofill.": "Dawn is-sigrieti jerġgħu lura fil-lista tal-kaxxaforti, fit-tfittxija u fil-mili awtomatiku.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Dawn is-sigrieti jerġgħu lura fil-lista tal-kaxxaforti. Il-qsim il-qadim ma jerġax lura, allura erġa' aqsamhom fejn meħtieġ.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dan iħassar is-sigrieti flimkien mal-annessi u l-istorja tal-verżjonijiet. Dan ma jistax jitreġġa' lura.", + "Delete for good": "Ħassar għal kollox", + "Trash": "Barmil", + "The trash is empty": "Il-barmil huwa vojt", + "No archived secrets": "L-ebda sigriet arkivjat", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Is-sigrieti mħassra jistennew hawn sakemm jintemm il-perjodu taż-żamma, imbagħad jitħassru għal kollox.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivja sigriet mill-pannell tad-dettalji tiegħu biex iżżommu barra mil-lista tal-kaxxaforti, mit-tfittxija u mill-mili awtomatiku.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limiti għall-annessi kriptati (infurzati fuq is-server fuq il-bytes kriptati maħżuna), żamma tal-istorja tal-verżjonijiet u kemm idumu s-sigrieti mħassra fil-barmil.", + "Days a deleted secret stays in the trash (1 to 365)": "Jiem li sigriet imħassar jibqa' fil-barmil (minn 1 sa 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dan imexxi s-sigriet fil-barmil u jtemm il-qsim tiegħu issa. Tista' tirrestawrah mill-barmil sakemm jintemm il-perjodu taż-żamma: 30 jum, sakemm l-amministratur tiegħek ma bidlux.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dan imexxi sigrieti fil-barmil ({count}) u jtemm il-qsim tagħhom issa. Tista' tirrestawrahom mill-barmil sakemm jintemm il-perjodu taż-żamma.", + "Remove {name} from favourites": "Neħħi {name} mill-favoriti", + "Add {name} to favourites": "Żid {name} mal-favoriti", + "Could not change the favourite": "Ma setax jinbidel il-favorit", + "Remove from favourites": "Neħħi mill-favoriti", + "Add to favourites": "Żid mal-favoriti", + "Tags": "Tikketti", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "It-tikketti mhumiex kriptati. L-amministraturi tas-server jistgħu jaqrawhom, bħall-ismijiet tal-folders.", + "Favourites": "Favoriti", + "Filter by tag": "Iffiltra skont it-tikketta", + "All tags": "It-tikketti kollha", + "Last used": "L-aħħar użu", + "Tags for {count} secrets": "Tikketti għal {count} sigrieti", + "Tag": "Tikketta", + "Remove tag": "Neħħi t-tikketta", + "Add tag": "Żid tikketta", + "Could not change the tags. Try again.": "Ma setgħux jinbidlu t-tikketti. Erġa' pprova.", + "Could not approve the application. It is still in the queue.": "L-applikazzjoni ma setgħetx tiġi approvata. Għadha fil-kju.", + "Could not reject the application. It is still in the queue.": "L-applikazzjoni ma setgħetx tiġi miċħuda. Għadha fil-kju.", + "Removed the user from {count} team folders.": "L-utent tneħħa minn {count} folders tat-tim.", + "Approve a share": "Approva qsim", + "This approval link is incomplete. Open it again from the notification.": "Din il-link tal-approvazzjoni mhix kompluta. Erġa' iftaħha min-notifika.", + "Deny": "Iċħad", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ingħaqad ma' grupp li miegħu taqsam sigriet. Trid taqsam is-sigriet miegħu wkoll?", + "{requester} asks you to share a secret with {user}.": "{requester} qed jitolbok taqsam sigriet ma' {user}.", + "Shared. The recipient can now open the secret.": "Inqasam. Ir-riċevitur issa jista' jiftaħ is-sigriet.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Ir-riċevitur għadu ma waqqafx Keepiq, għalhekk xejn ma nqasam. Erġa' pprova meta jkun waqqfu.", + "Could not share the secret. Only its owner can approve this.": "Is-sigriet ma setax jinqasam. Is-sid tiegħu biss jista' japprova dan.", + "Could not share the secret. Try again.": "Is-sigriet ma setax jinqasam. Erġa' pprova.", + "Denied. Nothing was shared.": "Miċħud. Xejn ma nqasam.", + "Could not deny the request. Try again.": "It-talba ma setgħetx tiġi miċħuda. Erġa' pprova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s qed jitolbok taqsam is-sigriet \"%2$s\" ma' %3$s.", + "Expires on (optional)": "Jiskadi fi (mhux obbligatorju)", + "Hand over to": "Għaddi lil", + "Choose a recipient": "Agħżel riċevitur", + "Hand over temporarily": "Għaddi temporanjament", + "Expiry rules": "Regoli tal-iskadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Issettja kemm jistgħu jdumu l-passwords ta' tip wieħed ta' oġġett jew f'folder wieħed, u meta tiġi mfakkar. Meta japplikaw diversi dati, jgħodd l-iktar wieħed kmieni.", + "Delete rule": "Ħassar ir-regola", + "Set by your administrator": "Issettjat mill-amministratur tiegħek", + "No expiry rules yet.": "Għad m'hemmx regoli tal-iskadenza.", + "Applies to": "Japplika għal", + "Item type": "Tip ta' oġġett", + "Maximum age in days (empty for reminders only)": "Età massima f'jiem (vojt għal tfakkiriet biss)", + "Remind me this many days before, comma separated": "Fakkarni dawn il-jiem qabel, separati b'virgola", + "Save rule": "Issejvja r-regola", + "An item type": "Tip ta' oġġett", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Jiskadi wara {days} jiem", + "Reminders {days} days before": "Tfakkiriet {days} jiem qabel", + "Could not save the expiry rule.": "Ir-regola tal-iskadenza ma setgħetx tiġi ssejvjata.", + "Could not delete the expiry rule.": "Ir-regola tal-iskadenza ma setgħetx titħassar.", + "All statuses": "L-istati kollha", + "Compromised": "Kompromess", + "Could not load the members.": "Ma setgħux jitgħabbew il-membri.", + "Emergency contact": "Kuntatt ta' emerġenza", + "Leaving user": "Utent li qed jitlaq", + "No": "Le", + "No users match this filter.": "L-ebda utent ma jaqbel ma' dan il-filtru.", + "Not set up": "Mhux issettjat", + "Revoke suite": "Irrevoka s-suite", + "Revoked": "Irrevokata", + "Search users": "Fittex utenti", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Ara liema utenti ssettjaw kaxxaforti. Ibda t-tluq jew irrevoka suite minn ringiela.", + "Successor": "Suċċessur", + "Team folders": "Folders tat-tim", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L-utent għadu fil-grupp {groups}, li huwa membru ta' folder tat-tim. Neħħih mill-grupp jew iddiżattiva l-kont.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.", + "Vault status": "Status tal-kaxxaforti", + "Yes": "Iva", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Esportazzjoni CXF MHIJIEX ENKRIPTATA. Kull password u login se jkunu jinqraw bħala test sempliċi fil-fajl imniżżel. Aħżnu b'mod sigur u ħassru minnufih wara li tużah.", + "Root certificate expiring soon": "Iċ-ċertifikat għerq jiskadi dalwaqt", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Iċ-ċertifikat għerq tal-kaxxaforti jiskadi fi żmien %1$d jum/ijiem. Ġeddu qabel dak iż-żmien. It-tiġdid jerġa' jiffirma kull suite tal-encryption.", + "Compromise recovery aborted": "L-irkupru wara kompromess twaqqaf", + "Key rotation ended by a compromise revoke": "Ir-rotazzjoni taċ-ċavetta ntemmet b'revoka minħabba kompromess", + "Encryption suite revoke refused": "Ir-revoka tas-suite tal-encryption ġiet miċħuda", + "Master password proof refused": "Il-prova tal-password ewlenija ġiet miċħuda", + "Your current master password": "Il-password ewlenija attwali tiegħek", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kuntatt ta' emerġenza kellu talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħietu. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Dawn il-kuntatti ta' emerġenza ma ġewx trasferiti għaċ-ċavetta l-ġdida tiegħek. L-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek trid.", + "Renew root certificate": "Iġġedded iċ-ċertifikat ewlieni", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dan joħloq ċertifikat ewlieni u intermedju ġodda. Kull suite ta' encryption attiva terġa' tiġi ffirmata. Ma tistax tħassar dan.", + "Renew root": "Iġġedded l-għerq", + "Root renewed. {n} encryption suites signed again.": "L-għerq ġie mġedded. Suites ta' encryption iffirmati mill-ġdid: {n}.", + "Could not renew the root certificate.": "Ma setax jiġi mġedded iċ-ċertifikat ewlieni.", + "Lease policy for this application": "Politika tal-kiri għal din l-applikazzjoni", + "In force now: {default} seconds by default, {max} seconds at most.": "Fis-seħħ issa: {default} sekonda b'mod awtomatiku, l-iktar {max} sekonda.", + "Leases are not renewable": "Il-kiri ma jistax jiġġedded", + "Lease policy saved.": "Il-politika tal-kiri ġiet salvata.", + "Leave a field empty to use the instance value.": "Ħalli qasam vojt biex tuża l-valur tal-istanza.", + "Instance value: {value}": "Valur tal-istanza: {value}", + "Renewal": "Tiġdid", + "Use the instance value ({value})": "Uża l-valur tal-istanza ({value})", + "Allowed": "Permess", + "Not allowed": "Mhux permess", + "Save lease policy": "Issalva l-politika tal-kiri", + "Only an administrator can change this policy.": "Amministratur biss jista' jibdel din il-politika.", + "Could not save the lease policy.": "Il-politika tal-kiri ma setgħetx tiġi salvata.", + "{member} got access from {confirmer}.": "{member} irċieva aċċess minn {confirmer}.", + "Automatically confirm new team folder members": "Ikkonferma awtomatikament membri ġodda tal-folders tat-tim", + "Gave %n new member access to a team folder.": "%n membru ġdid irċieva aċċess għal folder tat-tim.", + "Gave %n new members access to a team folder.": "%n membri ġodda rċevew aċċess għal folder tat-tim.", + "Give new team folder members access without waiting for the folder owner.": "Agħti aċċess lill-membri l-ġodda mingħajr ma tistenna lis-sid tal-folder.", + "New team folder members": "Membri ġodda tal-folders tat-tim", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Is-sid jew membru bi dritt tal-kitba jikkonfermahom mill-kaxxaforti miftuħa tiegħu. Keepiq qatt ma jiddekripta fuq is-server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Qed nistennew membru bi dritt tal-kitba jiftaħ Keepiq. Tista' wkoll taqsam issa.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parti mir-rispons għall-kompromess falliet ({failed} pass(i)). Iċċekkja l-log tas-server, imbagħad erġa' irrevoka s-suite biex tlestih.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dan irrevoka wkoll is-suite {suite} u temm il-migrazzjoni taċ-ċwievet {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Ir-revoka tat-tieni suite ħassret %n kuntatt ta' aċċess ta' emerġenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.", + "A suite revoked as compromised cannot be reinstated.": "Suite irrevokata bħala kompromessa ma tistax terġa' tiddaħħal.", + "Archives to keep": "Arkivji li għandhom jinżammu", + "Back up every vault automatically": "Agħmel backup ta' kull kaxxaforti awtomatikament", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Agħmel backup ta' kull kaxxaforti skont skeda. L-arkivji fihom biss test kriptat u jiġu rrestawrati b'occ.", + "Back up now": "Agħmel backup issa", + "Backup public key (PEM, optional)": "Ċavetta pubblika tal-backup (PEM, mhux obbligatorja)", + "Backup requested for the next cron run": "Backup mitlub għall-ġirja li jmiss ta' cron", + "Encrypted": "Kriptat", + "Every (hours)": "Kull (sigħat)", + "Last backup {when} failed: {error}": "L-aħħar backup {when} falla: {error}", + "Last backup {when} succeeded.": "L-aħħar backup {when} irnexxa.", + "No archives yet.": "Għad m'hemmx arkivji.", + "Size": "Daqs", + "Vault backups": "Backups tal-kaxxaforti", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "B'ċavetta, kull arkivju jiġi kriptat għaliha. Żomm iċ-ċavetta privata barra minn dan is-server: għandek bżonnha biex tivverifika jew tirrestawra.", + "Written": "Miktub", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utent fil-kamp għad m'għandux dħul b'żewġ fatturi u ma jistax jiftaħ il-kaxxaforti waqt li dan ikun mixgħul.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Il-kodiċijiet ta' riżerva ma jgħoddux. Jekk l-utenti tiegħek jidħlu permezz ta' fornitur tal-identità b'fattur ieħor tiegħu, ħalli barra l-gruppi tagħhom.", + "Block personal vault export": "Imblokka l-esportazzjoni tal-kaxxaforti personali", + "Keep work logins in team folders": "Żomm il-kredenzjali tax-xogħol f'folders tat-tim", + "Move to a team folder": "Mexxi għal folder tat-tim", + "Not in a team folder": "Mhux f'folder tat-tim", + "Only for these groups (empty is everyone)": "Biss għal dawn il-gruppi (vojt ifisser kulħadd)", + "Require two-factor login before the vault opens": "Itlob dħul b'żewġ fatturi qabel ma tinfetaħ il-kaxxaforti", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regoli għal kull kaxxaforti. Kull waħda tapplika għal kulħadd, jew biss għall-gruppi li tagħżel.", + "Secret types that belong in a team folder": "Tipi ta' sigrieti li jappartjenu f'folder tat-tim", + "Set up two-factor login": "Issettja d-dħul b'żewġ fatturi", + "Team folder you can write to": "Folder tat-tim fejn tista' tikteb", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "L-utenti ma jistgħux iniżżlu backup, CSV jew fajl ta' trasferiment. Il-pakkett tad-data personali tagħhom jibqa' disponibbli.", + "Users cannot save these secret types in a personal folder.": "L-utenti ma jistgħux jissejvjaw dawn it-tipi ta' sigrieti f'folder personali.", + "Vault policies": "Politiki tal-kaxxaforti", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "L-organizzazzjoni tiegħek ma tippermettix l-esportazzjoni tal-kaxxaforti personali tiegħek. Il-pakkett tad-data personali fis-settings jibqa' disponibbli.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "L-organizzazzjoni tiegħek iżżomm dawn is-sigrieti f'folder tat-tim. Mexxi kull wieħed għal folder tat-tim.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "L-organizzazzjoni tiegħek iżżomm dan it-tip ta' sigriet f'folder tat-tim. Agħżel wieħed mill-folders tat-tim tiegħek, jew wieħed fejn tista' tikteb.", + "Your organisation requires two-factor login before you can open your vault.": "L-organizzazzjoni tiegħek titlob dħul b'żewġ fatturi qabel ma tkun tista' tiftaħ il-kaxxaforti tiegħek.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "L-utenti jagħżlu kemm iddum l-estensjoni miftuħa meta ma tintużax. Int tissettja l-itwal żmien li jistgħu jagħżlu.", + "Longest idle time before the extension locks": "L-itwal żmien mingħajr attività qabel ma l-estensjoni tissakkar", + "1 minute": "1 minuta", + "5 minutes": "5 minuti", + "15 minutes": "15-il minuta", + "1 hour": "siegħa", + "4 hours": "4 sigħat", + "Connector": "Konnettur", + "Directory (tenant) ID": "ID tad-direttorju (kerrej)", + "Application (client) ID": "ID tal-applikazzjoni (klijent)", + "Data collection rule immutable ID": "ID immutabbli tar-regola tal-ġbir tad-dejta", + "Stream name": "Isem tal-fluss", + "Splunk index (optional)": "Indiċi Splunk (mhux obbligatorju)", + "Sourcetype (optional)": "Sourcetype (mhux obbligatorju)", + "Leave blank to keep the current one": "Ħallih vojt biex iżżomm dak attwali", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF permezz ta' syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punt finali tal-ġbir tad-dejta (URL https)", + "HTTP Event Collector URL (https)": "URL tal-HTTP Event Collector (https)", + "Client secret (write-only)": "Sigriet tal-klijent (kitba biss)", + "HEC token (write-only)": "Token HEC (kitba biss)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Ibgħat l-avvenimenti tal-awditjar permessi lil Splunk, Microsoft Sentinel, riċevitur syslog jew webhook. Il-messaġġi jġorru biss metadejta mnaddfa: l-ebda valur sigriet, isem, login jew test kriptat ma jitlaq qatt mis-server.", + "%n change waiting to sync": "%n bidla qed tistenna s-sinkronizzazzjoni", + "%n changes waiting to sync": "%n bidliet qed jistennew is-sinkronizzazzjoni", + "Changes that could not sync": "Bidliet li ma setgħux jiġu sinkronizzati", + "Choose a version": "Agħżel verżjoni", + "Copy value": "Ikkopja l-valur", + "Deleted": "Imħassar", + "Discard": "Armi", + "Keep my offline change": "Żomm il-bidla tiegħi offline", + "Keep the server version": "Żomm il-verżjoni tas-server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq huwa għall-qari biss offline. L-amministratur ma xegħelx l-editjar offline.", + "Let users edit secrets offline": "Ħalli lill-utenti jeditjaw sigrieti offline", + "Not synced yet": "Għadu mhux sinkronizzat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Il-bidliet offline jibqgħu fuq l-apparat, ikkriptati għall-utent, u jiġu sinkronizzati fil-ftuħ online li jmiss. Il-qsim, il-folders u l-annessi xorta jeħtieġu konnessjoni.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. L-editjar, iċ-ċaqliq u t-tħassir jibqgħu fuq dan l-apparat u jiġu sinkronizzati meta terġa' tkun online. Il-qsim u l-annessi jeħtieġu konnessjoni.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Il-bidliet tiegħek jibqgħu fuq dan l-apparat u jiġu sinkronizzati meta terġa' tkun online. L-aħħar sinkronizzazzjoni {when}.", + "Open my changes": "Iftaħ il-bidliet tiegħi", + "Sharing needs a connection": "Il-qsim jeħtieġ konnessjoni", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Xi ħadd biddel dan is-sigriet fuq is-server wara li saret il-kopja offline tiegħek. Agħżel liema verżjoni żżomm.", + "Sync or discard your offline changes before you rotate your keys.": "Issinkronizza jew armi l-bidliet offline qabel ma tbiddel iċ-ċwievet.", + "That password did not open your changes.": "Dik il-password ma fetħitx il-bidliet tiegħek.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Ir-ritratt offline jaħżen sigrieti kkriptati (jinfetħu biss biċ-ċavetta derivata mill-password ewlenija tal-utent, eżatt bħal fuq is-server) u jikkripta l-ismijiet, il-URLs u l-ismijiet tal-folders fil-ħażna. L-aċċess offline huwa għall-qari biss sakemm ma tippermettix l-editjar offline hawn taħt. Itfi dan għal apparati li qatt m'għandhom iżommu kredenzjali; meta jintefa, il-caches eżistenti jitnaddfu fit-tagħbija li jmiss.", + "The previous vault copy is gone, so these changes cannot be opened.": "Il-kopja preċedenti tal-kaxxaforti m'għadhiex hemm, għalhekk dawn il-bidliet ma jistgħux jinfetħu.", + "The server version": "Il-verżjoni tas-server", + "This secret changed while you were offline": "Dan is-sigriet inbidel waqt li kont offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ħassart dan is-sigriet offline, iżda minn dakinhar inbidel fuq is-server. Agħżel liema verżjoni żżomm.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Iċ-ċwievet tiegħek inbidlu fuq apparat ieħor. Daħħal il-password ewlenija preċedenti biex tissinkronizza l-bidliet offline, jew armihom.", + "Your offline change": "Il-bidla tiegħek offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n kuntatt ta' emerġenza kellu talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħietu. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.","%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.","%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.","%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n oġġett ma jistax jiġi rappreżentat f'CXF u se jinqabeż.","%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu.","%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu.","%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n verżjoni antika tneħħiet, għax tista' tinġarr biss l-istorja riċenti.","%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti.","%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti.","%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopja ta' sigriet għad trid tiġi enkriptata u maqsuma.","%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma.","%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma.","%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma."], + "_%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 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.","%n secrets could not be decrypted and are not in this export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n sigriet ma stax jiġi dekriptat bl-aċċess antik tiegħek, għalhekk ma ġiex migrat.","%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati.","%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati.","%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n sigriet ma ġiex migrat.","%n sigrieti ma ġewx migrati.","%n sigrieti ma ġewx migrati.","%n sigrieti ma ġewx migrati."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n sigriet għadu kkriptat biċ-ċavetta preċedenti tiegħek.","%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek.","%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek.","%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n sigriet inqabeż għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.","%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.","%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.","%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova."], + "_%n secret_::_%n secrets_": ["%n sigriet","%n sigriet","%n sigriet","%n sigriet"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n utent fil-kamp għad m'għandux dħul b'żewġ fatturi u ma jistax jiftaħ il-kaxxaforti waqt li dan ikun mixgħul.","%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.","%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.","%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul."], + "_Emergency access for %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 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.","Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Temm xorta waħda, u titlef l-aċċess għal %n sigriet","Temm xorta waħda, u titlef l-aċċess għal %n sigrieti","Temm xorta waħda, u titlef l-aċċess għal %n sigrieti","Temm xorta waħda, u titlef l-aċċess għal %n sigrieti"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n membru ġdid irċieva aċċess għal folder tat-tim.","%n membri ġodda rċevew aċċess għal folder tat-tim.","%n membri ġodda rċevew aċċess għal folder tat-tim.","%n membri ġodda rċevew aċċess għal folder tat-tim."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigriet ġie kkriptat mill-ġdid biċ-ċavetta ġdida tiegħek.","Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek.","Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek.","Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Ir-revoka tat-tieni suite ħassret %n kuntatt ta' aċċess ta' emerġenza.","Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.","Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.","Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revoking this suite deleted %n emergency-access contact.","Revoking this suite deleted %n emergency-access contacts.","Revoking this suite deleted %n emergency-access contacts.","Revoking this suite deleted %n emergency-access contacts."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["dehret %n darba fi ksur","dehret %n darbiet fi ksur","dehret %n darbiet fi ksur","dehret %n darbiet fi ksur"], + "_shared with %n secret_::_shared with %n secrets_": ["maqsum ma' %n sigriet","maqsum ma' %n sigrieti","maqsum ma' %n sigrieti","maqsum ma' %n sigrieti"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Dan il-folder fih %n sigriet direttament.","Dan il-folder fih %n sigriet direttament.","Dan il-folder fih %n sigriet direttament.","Dan il-folder fih %n sigriet direttament."], + "_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._": ["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.","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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n bidla qed tistenna s-sinkronizzazzjoni","%n bidliet qed jistennew is-sinkronizzazzjoni","%n bidliet qed jistennew is-sinkronizzazzjoni","%n bidliet qed jistennew is-sinkronizzazzjoni"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["L-utent għadu fil-grupp {groups}, li huwa membru ta' folder tat-tim. Neħħih mill-grupp jew iddiżattiva l-kont.","L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.","L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.","L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont."], + "Allow approval from another device": "Ippermetti l-approvazzjoni minn apparat ieħor", + "App": "App", + "Approve a new device": "Approva apparat ġdid", + "Approve from another device": "Approva minn apparat ieħor", + "Asked at": "Mitlub fi", + "Check that the new device shows these words:": "Iċċekkja li l-apparat il-ġdid juri dawn il-kliem:", + "Denied. If you did not ask, end your other sessions:": "Miċħud. Jekk ma tlabtx int, temm is-sessjonijiet l-oħra tiegħek:", + "Device": "Apparat", + "IP address": "Indirizz IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Ħalli lill-utenti jiftħu browser ġdid billi japprovawh minn apparat fejn Keepiq diġà miftuħ.", + "New device approval": "Approvazzjoni ta' apparat ġdid", + "Nextcloud security settings": "Settings tas-sigurtà ta' Nextcloud", + "Only approve a device you are using right now.": "Approva biss apparat li qed tuża bħalissa.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Iftaħ Keepiq fuq apparat fejn huwa miftuħ u approva dan. Iċċekkja li juri l-istess kliem:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L-apparat li japprova jissiġilla ċ-ċavetta tal-ftuħ għall-apparat il-ġdid. Is-server jgħaddiha biss u ma jistax jiftaħha.", + "The master password is not right, or the request has ended.": "Il-password prinċipali mhix korretta, jew it-talba spiċċat.", + "The request expired. Ask again or use your master password.": "It-talba skadiet. Erġa' itlob jew uża l-password prinċipali tiegħek.", + "The request was denied.": "It-talba ġiet miċħuda.", + "Too many requests. Try again in an hour or use your master password.": "Wisq talbiet. Erġa' pprova fi żmien siegħa jew uża l-password prinċipali tiegħek.", + "Unknown device": "Apparat mhux magħruf", + "Web app": "App tal-web", + "A device": "Apparat", + "A new device asks to open your vault": "Apparat ġdid qed jitlob li jiftaħ il-kaxxa-forti tiegħek", + "%s asks to be approved. Only approve a device you are using right now.": "%s qed jitlob li jiġi approvat. Approva biss apparat li qed tuża bħalissa.", + "Access ends on (optional)": "L-aċċess jintemm fi (mhux obbligatorju)", + "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.": "L-apps ta' Keepiq mhux se juru jew jikkupjaw il-password. Xi ħadd b'ħiliet tekniċi xorta jista' jaqraha mill-apparat tiegħu. Biddilha meta l-aċċess tiegħu jintemm.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dan is-sigriet huwa għall-użu biss. Idħol permezz tal-estensjoni tal-browser ta' Keepiq.", + "Until {date}": "Sa {date}", + "Use only": "Użu biss", + "Use only (can sign in, cannot view or copy)": "Użu biss (jista' jidħol, ma jistax jara jew jikkopja)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Tista' tidħol b'dan il-login permezz tal-estensjoni tal-browser ta' Keepiq. Is-sid għażel li ma jħallikx tarah jew tikkopjah.", + "Your access ends on {date}": "L-aċċess tiegħek jintemm fi {date}", + "Your access to this secret has ended": "L-aċċess tiegħek għal dan is-sigriet intemm", + "Your access to \"%s\" ends tomorrow": "L-aċċess tiegħek għal \"%s\" jintemm għada", + "Your access to \"%s\" has ended": "L-aċċess tiegħek għal \"%s\" intemm", + "%1$s no longer has access to \"%2$s\"": "%1$s m'għadx għandu aċċess għal \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s seta' jara din il-password. Biddilha jekk %1$s m'għandux ikun jafha aktar.", + "%s could not view this password in Keepiq.": "%s ma setax jara din il-password f'Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} minn {threshold} approvazzjonijiet", + "a recovery officer": "uffiċjal tal-irkupru", + "Account recovery": "Irkupru tal-kont", + "Approvals needed": "Approvazzjonijiet meħtieġa", + "Ask {user} which words they see, by phone or in person. They must be:": "Staqsi lil {user} liema kliem jara, bit-telefon jew personalment. Iridu jkunu:", + "Check again": "Iċċekkja mill-ġdid", + "Create the recovery key": "Oħloq iċ-ċavetta tal-irkupru", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Oħloq iċ-ċavetta tal-irkupru. Il-browser tiegħek jagħmilha u jagħti lil kull uffiċjal kopja li jista' jiftaħ hu biss.", + "Decline": "Iċħad", + "Enrol in account recovery": "Irreġistra għall-irkupru tal-kont", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Irreġistra biex l-organizzazzjoni tiegħek tkun tista' tgħinek terġa' tikseb il-kaxxaforti tiegħek jekk tinsa l-password prinċipali.", + "Every user is enrolled": "L-utenti kollha huma rreġistrati", + "Finish the recovery in the browser you asked from.": "Lesti l-irkupru fil-browser li minnu tlabt.", + "Forgot your master password?": "Insejt il-password prinċipali?", + "Hand the key over": "Għaddi ċ-ċavetta", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ħalli lill-utenti li nsew il-password prinċipali jerġgħu jiksbu l-kaxxaforti tagħhom, approvat minn uffiċjali tal-irkupru li tinnomina int.", + "New master password": "Password prinċipali ġdida", + "No one is asking to recover their account.": "Ħadd mhu qed jitlob li jirkupra l-kont tiegħu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Għad m'hemmx ċavetta tal-irkupru. Wieħed mill-uffiċjali joħloqha fl-issettjar ta' Keepiq tiegħu.", + "Off": "Mitfi", + "Officer {user} has no encryption set up yet.": "L-uffiċjal {user} għadu ma ssettjax il-kriptaġġ.", + "Officers (user IDs, separated by commas)": "Uffiċjali (IDs tal-utenti, separati b'virgoli)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Ippubblika din il-marka tas-saba' internament, biex l-utenti jkunu jistgħu jiċċekkjawha qabel ma jirreġistraw.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Irkuprat bl-għajnuna ta' {officer}. Ibdel iċ-ċavetta tal-kaxxaforti issa f'Issettjar, Sigurtà: \"Il-password prinċipali tiegħi ġiet kompromessa\".", + "Recovery key fingerprint: {fingerprint}": "Marka tas-saba' taċ-ċavetta tal-irkupru: {fingerprint}", + "Recovery officer": "Uffiċjal tal-irkupru", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "L-uffiċjali mneħħija jitilfu l-kopja tagħhom issa, imma setgħu fetħuha qabel. Ħalli uffiċjal joħloq ċavetta tal-irkupru ġdida.", + "Repeat the new master password": "Erġa' daħħal il-password prinċipali l-ġdida", + "Retire this recovery key": "Irtira din iċ-ċavetta tal-irkupru", + "Set the new master password": "Issettja l-password prinċipali l-ġdida", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Iċ-ċertifikat tal-irkupru mhuwiex maħruġ minn dan il-Keepiq. Tirreġistrax u għid lill-amministratur tiegħek.", + "The words match, approve": "Il-kliem jaqbel, approva", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dan l-utent huwa rreġistrat għall-irkupru tal-kont. L-irkupru jżomm is-sigrieti tiegħu; ir-revoka tħassar ir-reġistrazzjoni tiegħu.", + "Users may enrol": "L-utenti jistgħu jirreġistraw", + "Withdraw from account recovery": "Irtira mill-irkupru tal-kont", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Int irreġistrat għall-irkupru tal-kont. Marka tas-saba' taċ-ċavetta tal-irkupru: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Int irreġistrat. Jekk tinsa l-password prinċipali, l-organizzazzjoni tiegħek tista' tgħinek terġa' tikseb il-kaxxaforti.", + "Your key is back. Choose a new master password.": "Iċ-ċavetta tiegħek reġgħet lura. Agħżel password prinċipali ġdida.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "L-uffiċjali tal-irkupru tiegħek ġew infurmati. Aqralhom dan il-kliem meta jċemplulek jew jiltaqgħu miegħek:", + "You are now an account recovery officer": "Issa int uffiċjal tal-irkupru tal-kontijiet", + "%s asks to recover their account. Compare the words with them before you approve.": "%s qed jitlob li jirkupra l-kont tiegħu. Qabbel il-kliem miegħu qabel ma tapprova.", + "A user": "Utent", + "Your account recovery request was declined": "It-talba tiegħek għall-irkupru tal-kont ġiet miċħuda", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "L-irkupru tal-kont tiegħek huwa lest. Iftaħ Keepiq fil-browser li minnu tlabt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} qed jitlob li jiftaħ apparat ġdid darba waħda. Il-password ewlenija tibqa' l-istess.", + "Ask your organisation instead": "Minflok, staqsi lill-organizzazzjoni tiegħek", + "The request ended. Ask again or use your master password.": "It-talba ntemmet. Erġa' itlob jew uża l-password ewlenija tiegħek.", + "Added by {user}": "Miżjud minn {user}", + "Editor": "Editur", + "Manager": "Maniġer", + "Role of {member}": "Ir-rwol ta' {member}", + "Team folders you manage": "Folders tat-tim li timmaniġġja", + "Viewer": "Qarrej", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "M'għandek l-ebda kopja ta' dawn is-sigrieti, għalhekk il-membri l-ġodda għadhom ma rċevewhomx. Is-sid jista' jaqsamhom: {names}", + "Admin areas": "Oqsma ta' amministrazzjoni", + "Give a group only the parts of Keepiq administration it needs.": "Agħti lil grupp biss il-partijiet tal-amministrazzjoni ta' Keepiq li għandu bżonn.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Iddelega qasam wieħed jew aktar lil grupp fuq il-paġna tal-privileġġi tal-amministrazzjoni. L-amministraturi tal-istanza għandhom kull qasam.", + "Open administration privileges": "Iftaħ il-privileġġi tal-amministrazzjoni", + "Policies": "Politiki", + "Applications and machine access": "Applikazzjonijiet u aċċess tal-magni", + "People and offboarding": "Nies u tluq", + "Audit and compliance": "Verifika u konformità", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verżjoni, awtorità taċ-ċertifikazzjoni, annessi, cache offline, kontroll tat-tnixxija, tipi ta' sigrieti u backups", + "master password, organisation password, vault policies, rotation, version history and trash": "password ewlenija, password tal-organizzazzjoni, politiki tal-kaxxa-forti, rotazzjoni, storja tal-verżjonijiet u skart", + "application queue, application requests and machine leases": "kju tal-applikazzjonijiet, talbiet tal-applikazzjonijiet u kiri tal-magni", + "team offboarding, encryption suites and admin handover": "tluq mit-tim, suites tal-kriptaġġ u teħid mill-amministratur", + "audit log, compliance reports, SIEM export and honey alerts": "reġistru tal-verifika, rapporti tal-konformità, esportazzjoni SIEM u twissijiet tal-lixka", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kemm verżjonijiet ta' sigriet jinżammu, għal kemm żmien, u kemm idumu s-sigrieti mħassra fl-iskart.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limiti għall-annessi kriptati, infurzati fuq is-server f'bytes kriptati maħżuna.", + "Type the suite ID again to confirm": "Erġa' ikteb l-ID tas-suite biex tikkonferma", + "This does not match the suite ID.": "Dan ma jaqbilx mal-ID tas-suite.", + "Confirm with your master password": "Ikkonferma bil-password master tiegħek", + "Confirm": "Ikkonferma", + "That master password is not right.": "Dik il-password master mhix korretta.", + "You are sharing with someone new. Enter your master password to confirm.": "Qed taqsam ma' persuna ġdida. Daħħal il-password master tiegħek biex tikkonferma.", + "Enter your master password to confirm this share.": "Daħħal il-password master tiegħek biex tikkonferma dan il-qsim.", + "Enter your master password to confirm this delegation.": "Daħħal il-password master tiegħek biex tikkonferma din id-delega.", + "Approve {member}": "Approva lil {member}", + "Recipient": "Benefiċjarju", + "No vault yet": "Għad m’għandux vault", + "No matching users": "L-ebda utent ma jaqbel", + "Partner organisations": "Organizzazzjonijiet sħab", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Skambja sigrieti ma’ Keepiq ieħor. Iż-żewġ amministraturi jżidu lil xulxin u jqabblu l-marki tas-swaba’ ewlenin bit-telefon jew personalment qabel ma jissejvjaw.", + "Federation needs Nextcloud 33 or later.": "Il-federazzjoni teħtieġ Nextcloud 33 jew aktar ġdid.", + "Your root fingerprint": "Il-marka ewlenija tiegħek", + "No partners yet.": "Għad m’hemmx sħab.", + "Users here may share to this partner": "L-utenti hawn jistgħu jaqsmu ma’ dan is-sieħeb", + "This partner may share to users here": "Dan is-sieħeb jista’ jaqsam mal-utenti hawn", + "Partner address": "L-indirizz tas-sieħeb", + "Check partner": "Iċċekkja s-sieħeb", + "Partner root fingerprint": "Il-marka ewlenija tas-sieħeb", + "I compared this fingerprint with the partner's administrator": "Qabbilt din il-marka mal-amministratur tas-sieħeb", + "Add partner": "Żid sieħeb", + "A secret from another organisation": "Sigriet minn organizzazzjoni oħra", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s qasam \"%2$s\" miegħek. Aċċettah taħt Deħlin minn organizzazzjonijiet oħra.", + "Incoming from other organisations": "Deħlin minn organizzazzjonijiet oħra", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Nies f’organizzazzjonijiet sħab jistgħu jaqsmu sigriet miegħek. Aċċettah biex iżżomm kopja għall-qari biss fil-vault tiegħek.", + "Nothing shared with you yet": "Għadu ma nqasam xejn miegħek", + "Secrets that people in partner organisations share with you appear here.": "Is-sigrieti li n-nies f’organizzazzjonijiet sħab jaqsmu miegħek jidhru hawn.", + "From {sender}": "Minn {sender}", + "Accept": "Aċċetta", + "Open in vault": "Iftaħ fil-vault", + "The other organisation did not hand over the secret. Try again later.": "L-organizzazzjoni l-oħra ma għaddietx is-sigriet. Erġa’ pprova aktar tard.", + "Set up your vault before you accept a shared secret.": "Issettja l-vault tiegħek qabel ma taċċetta sigriet maqsum.", + "Something went wrong. Try again.": "Xi ħaġa marret ħażin. Erġa’ pprova.", + "Waiting for your answer": "Qed jistenna t-tweġiba tiegħek", + "In your vault, read-only": "Fil-vault tiegħek, għall-qari biss", + "Withdrawn by the sender": "Irtirat mill-mittent", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} qasam dan minn organizzazzjoni oħra. Tista’ taqrah, iżda ma tistax tibdlu jew taqsmu.", + "Someone": "Xi ħadd", + "Share with someone at another organisation": "Aqsam ma’ xi ħadd f’organizzazzjoni oħra", + "Their account at the other organisation": "Il-kont tal-persuna fl-organizzazzjoni l-oħra", + "Check account": "Iċċekkja l-kont", + "Certificate fingerprint of {account}": "Il-marka tas-swaba’ taċ-ċertifikat ta’ {account}", + "Compare it with them by phone if you want to be sure.": "Qabblu magħhom bit-telefon jekk trid tkun ċert.", + "Shared. {account} can accept it in their own vault.": "Maqsum. {account} jista’ jaċċettah fil-vault tiegħu stess.", + "The certificate could not be verified. Nothing was shared.": "Iċ-ċertifikat ma setax jiġi vverifikat. Xejn ma ġie maqsum.", + "That organisation is not one of your partners.": "Dik l-organizzazzjoni mhix waħda mis-sħab tiegħek.", + "No one with that account can receive secrets from you.": "Ħadd b’dak il-kont ma jista’ jirċievi sigrieti mingħandek.", + "The other organisation did not answer. Try again later.": "L-organizzazzjoni l-oħra ma wieġbitx. Erġa’ pprova aktar tard.", + "This secret is already shared with that account.": "Dan is-sigriet diġà maqsum ma’ dak il-kont.", + "Other organisations": "Organizzazzjonijiet oħra", + "Receive secrets from other organisations": "Irċievi sigrieti minn organizzazzjonijiet oħra", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Nies f’organizzazzjonijiet sħab imbagħad jistgħu jsibu l-kont tiegħek u jaqsmu sigrieti miegħek. Inti taċċetta kull wieħed minnhom.", + "Shared": "Maqsum", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Fuq pawża: iċ-ċertifikat tagħhom jew is-sħubija nbidlu. Irrevoka jew erġa’ aqsam.", + "Their organisation did not get the last change. Revoke it or share again.": "L-organizzazzjoni tagħhom ma rċevietx l-aħħar bidla. Irrevoka jew erġa’ aqsam.", + "Being withdrawn": "Qed jiġi rtirat", + "Shared with another organisation": "Maqsum ma’ organizzazzjoni oħra", + "Change sent to another organisation": "Bidla mibgħuta lil organizzazzjoni oħra", + "Share with another organisation revoked": "Qsim ma’ organizzazzjoni oħra irrevokat", + "Share with another organisation paused": "Qsim ma’ organizzazzjoni oħra fuq pawża", + "Another organisation did not get a change": "Organizzazzjoni oħra ma rċevietx bidla", + "Secret received from another organisation": "Sigriet riċevut minn organizzazzjoni oħra", + "Secret from another organisation accepted": "Sigriet minn organizzazzjoni oħra aċċettat", + "Secret from another organisation declined": "Sigriet minn organizzazzjoni oħra miċħud", + "Copy from another organisation updated": "Kopja minn organizzazzjoni oħra aġġornata", + "Copy from another organisation removed": "Kopja minn organizzazzjoni oħra mneħħija", + "Declined: they removed their copy. Share again if they need it.": "Miċħud: ir-riċevitur neħħa l-kopja tiegħu. Erġa’ aqsam jekk jeħtieġha.", + "Recipient at another organisation removed their copy": "Riċevitur f’organizzazzjoni oħra neħħa l-kopja tiegħu", + "Removed the user from %n team folder.": "L-utent tneħħa minn %n folder tat-tim.", + "Removed the user from %n team folders.": "L-utent tneħħa minn %n folders tat-tim.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["L-utent tneħħa minn %n folder tat-tim.","L-utent tneħħa minn %n folders tat-tim.","L-utent tneħħa minn %n folders tat-tim.","L-utent tneħħa minn %n folders tat-tim."], + "A restored copy came from a share that has ended. It stays read-only.": "Kopja rrestawrata ġiet minn qsim li ntemm. Tibqa’ għall-qari biss.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L-organizzazzjoni li qasmet kopja rrestawrata ma setgħetx tintlaħaq. Il-kopja tibqa’ għall-qari biss u ma ssegwix il-bidliet tagħhom.", + "Recipient at another organisation restored their copy": "Riċevitur f’organizzazzjoni oħra rrestawra l-kopja tiegħu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n==1 ? 0 : n==0 || (n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20) ? 2 : 3);" ) diff --git a/l10n/mt.json b/l10n/mt.json index 0eec20332..5bc6fa12b 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1181,7 +1181,617 @@ "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." + "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.", + "Shared with groups": "Maqsum ma' gruppi", + "Not shared with any group yet.": "Għadu mhux maqsum ma' ebda grupp.", + "Revoke the share with {group}": "Irrevoka l-qsim ma' {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Maqsum ma' {group}: {received} membri rċevewh, {skipped} le għax għadhom ma waqqfux il-kriptaġġ.", + "Search groups": "Fittex gruppi", + "Failed to share": "Il-qsim ma rnexxiex", + "Columns": "Kolonni", + "Column {number}": "Kolonna {number}", + "Map one column to Name. Every secret needs a name.": "Orbot kolonna waħda mal-Isem. Kull sigriet għandu bżonn isem.", + "Notes": "Noti", + "Do not import": "Timportax", + "Hide this value": "Aħbi dan il-valur", + "Show this value": "Uri dan il-valur", + "Defaults": "Awtomatiċi", + "New secrets start as this type, and your secret list opens in this view.": "Is-sigrieti l-ġodda jibdew b'dan it-tip, u l-lista tas-sigrieti tiegħek tiftaħ f'din il-viżta.", + "Default item type": "Tip ta' oġġett awtomatiku", + "Cards": "Karti", + "Table": "Tabella", + "Could not save your default": "Ma setax jiġi ssejvjat l-awtomatiku tiegħek", + "Recently used": "Użati reċentement", + "Opened": "Miftuħ", + "You have not opened any secrets yet": "Għadek ma ftaħt l-ebda sigriet", + "Could not delete the item type.": "Ma setax jitħassar it-tip ta' oġġett.", + "Could not load the item types.": "Ma setgħux jittellgħu t-tipi ta' oġġetti.", + "Could not save the item type.": "Ma setax jiġi ssejvjat it-tip ta' oġġett.", + "Delete item type": "Ħassar it-tip ta' oġġett", + "Edit item type": "Editja t-tip ta' oġġett", + "Fields": "Oqsma", + "Fields: {count}": "Oqsma: {count}", + "Hidden": "Moħbi", + "Item types": "Tipi ta' oġġetti", + "Move up": "Ċaqlaq 'il fuq", + "New item type": "Tip ġdid ta' oġġett", + "No item types defined yet.": "Għad m'hemm l-ebda tip ta' oġġett definit.", + "Required": "Obbligatorju", + "Text": "Test", + "This field is required": "Dan il-qasam huwa obbligatorju", + "Web address": "Indirizz tal-web", + "{label} (required)": "{label} (obbligatorju)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Tħassar “{name}”? Is-sigrieti ta' dan it-tip jibqgħu jinqraw u jsiru oġġetti Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "It-tipi ta' oġġetti li tiddefinixxi hawn jidhru għal kulħadd fid-djalogu Sigriet ġdid, bl-oqsma li tagħżel.", + "Secret moved to the trash": "Is-sigriet tmexxa fil-barmil", + "Secret restored from the trash": "Is-sigriet ġie rrestawrat mill-barmil", + "Secret deleted for good": "Is-sigriet tħassar għal kollox", + "Secret archived": "Is-sigriet ġie arkivjat", + "Secret unarchived": "Is-sigriet tneħħa mill-arkivju", + "Unarchive": "Neħħi mill-arkivju", + "Could not archive the secret": "Ma setax jiġi arkivjat is-sigriet", + "Could not unarchive the secret": "Ma setax jitneħħa s-sigriet mill-arkivju", + "Archive {count} secrets": "Arkivja sigrieti: {count}", + "Unarchive {count} secrets": "Neħħi sigrieti mill-arkivju: {count}", + "Restore {count} secrets": "Irrestawra sigrieti: {count}", + "Delete {count} secrets for good": "Ħassar għal kollox sigrieti: {count}", + "Done for {ok} of {total} secrets": "Lest għal {ok} minn {total} sigrieti", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Is-sigrieti arkivjati joħorġu mil-lista tal-kaxxaforti, mit-tfittxija, mill-mili awtomatiku u mir-rapport tas-saħħa. Iżommu l-qsim tagħhom. Issibhom taħt Arkivju.", + "These secrets come back to the vault list, search and autofill.": "Dawn is-sigrieti jerġgħu lura fil-lista tal-kaxxaforti, fit-tfittxija u fil-mili awtomatiku.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Dawn is-sigrieti jerġgħu lura fil-lista tal-kaxxaforti. Il-qsim il-qadim ma jerġax lura, allura erġa' aqsamhom fejn meħtieġ.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dan iħassar is-sigrieti flimkien mal-annessi u l-istorja tal-verżjonijiet. Dan ma jistax jitreġġa' lura.", + "Delete for good": "Ħassar għal kollox", + "Trash": "Barmil", + "The trash is empty": "Il-barmil huwa vojt", + "No archived secrets": "L-ebda sigriet arkivjat", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Is-sigrieti mħassra jistennew hawn sakemm jintemm il-perjodu taż-żamma, imbagħad jitħassru għal kollox.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivja sigriet mill-pannell tad-dettalji tiegħu biex iżżommu barra mil-lista tal-kaxxaforti, mit-tfittxija u mill-mili awtomatiku.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limiti għall-annessi kriptati (infurzati fuq is-server fuq il-bytes kriptati maħżuna), żamma tal-istorja tal-verżjonijiet u kemm idumu s-sigrieti mħassra fil-barmil.", + "Days a deleted secret stays in the trash (1 to 365)": "Jiem li sigriet imħassar jibqa' fil-barmil (minn 1 sa 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dan imexxi s-sigriet fil-barmil u jtemm il-qsim tiegħu issa. Tista' tirrestawrah mill-barmil sakemm jintemm il-perjodu taż-żamma: 30 jum, sakemm l-amministratur tiegħek ma bidlux.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dan imexxi sigrieti fil-barmil ({count}) u jtemm il-qsim tagħhom issa. Tista' tirrestawrahom mill-barmil sakemm jintemm il-perjodu taż-żamma.", + "Remove {name} from favourites": "Neħħi {name} mill-favoriti", + "Add {name} to favourites": "Żid {name} mal-favoriti", + "Could not change the favourite": "Ma setax jinbidel il-favorit", + "Remove from favourites": "Neħħi mill-favoriti", + "Add to favourites": "Żid mal-favoriti", + "Tags": "Tikketti", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "It-tikketti mhumiex kriptati. L-amministraturi tas-server jistgħu jaqrawhom, bħall-ismijiet tal-folders.", + "Favourites": "Favoriti", + "Filter by tag": "Iffiltra skont it-tikketta", + "All tags": "It-tikketti kollha", + "Last used": "L-aħħar użu", + "Tags for {count} secrets": "Tikketti għal {count} sigrieti", + "Tag": "Tikketta", + "Remove tag": "Neħħi t-tikketta", + "Add tag": "Żid tikketta", + "Could not change the tags. Try again.": "Ma setgħux jinbidlu t-tikketti. Erġa' pprova.", + "Could not approve the application. It is still in the queue.": "L-applikazzjoni ma setgħetx tiġi approvata. Għadha fil-kju.", + "Could not reject the application. It is still in the queue.": "L-applikazzjoni ma setgħetx tiġi miċħuda. Għadha fil-kju.", + "Removed the user from {count} team folders.": "L-utent tneħħa minn {count} folders tat-tim.", + "Approve a share": "Approva qsim", + "This approval link is incomplete. Open it again from the notification.": "Din il-link tal-approvazzjoni mhix kompluta. Erġa' iftaħha min-notifika.", + "Deny": "Iċħad", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ingħaqad ma' grupp li miegħu taqsam sigriet. Trid taqsam is-sigriet miegħu wkoll?", + "{requester} asks you to share a secret with {user}.": "{requester} qed jitolbok taqsam sigriet ma' {user}.", + "Shared. The recipient can now open the secret.": "Inqasam. Ir-riċevitur issa jista' jiftaħ is-sigriet.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Ir-riċevitur għadu ma waqqafx Keepiq, għalhekk xejn ma nqasam. Erġa' pprova meta jkun waqqfu.", + "Could not share the secret. Only its owner can approve this.": "Is-sigriet ma setax jinqasam. Is-sid tiegħu biss jista' japprova dan.", + "Could not share the secret. Try again.": "Is-sigriet ma setax jinqasam. Erġa' pprova.", + "Denied. Nothing was shared.": "Miċħud. Xejn ma nqasam.", + "Could not deny the request. Try again.": "It-talba ma setgħetx tiġi miċħuda. Erġa' pprova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s qed jitolbok taqsam is-sigriet \"%2$s\" ma' %3$s.", + "Expires on (optional)": "Jiskadi fi (mhux obbligatorju)", + "Hand over to": "Għaddi lil", + "Choose a recipient": "Agħżel riċevitur", + "Hand over temporarily": "Għaddi temporanjament", + "Expiry rules": "Regoli tal-iskadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Issettja kemm jistgħu jdumu l-passwords ta' tip wieħed ta' oġġett jew f'folder wieħed, u meta tiġi mfakkar. Meta japplikaw diversi dati, jgħodd l-iktar wieħed kmieni.", + "Delete rule": "Ħassar ir-regola", + "Set by your administrator": "Issettjat mill-amministratur tiegħek", + "No expiry rules yet.": "Għad m'hemmx regoli tal-iskadenza.", + "Applies to": "Japplika għal", + "Item type": "Tip ta' oġġett", + "Maximum age in days (empty for reminders only)": "Età massima f'jiem (vojt għal tfakkiriet biss)", + "Remind me this many days before, comma separated": "Fakkarni dawn il-jiem qabel, separati b'virgola", + "Save rule": "Issejvja r-regola", + "An item type": "Tip ta' oġġett", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Jiskadi wara {days} jiem", + "Reminders {days} days before": "Tfakkiriet {days} jiem qabel", + "Could not save the expiry rule.": "Ir-regola tal-iskadenza ma setgħetx tiġi ssejvjata.", + "Could not delete the expiry rule.": "Ir-regola tal-iskadenza ma setgħetx titħassar.", + "All statuses": "L-istati kollha", + "Compromised": "Kompromess", + "Could not load the members.": "Ma setgħux jitgħabbew il-membri.", + "Emergency contact": "Kuntatt ta' emerġenza", + "Leaving user": "Utent li qed jitlaq", + "No": "Le", + "No users match this filter.": "L-ebda utent ma jaqbel ma' dan il-filtru.", + "Not set up": "Mhux issettjat", + "Revoke suite": "Irrevoka s-suite", + "Revoked": "Irrevokata", + "Search users": "Fittex utenti", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Ara liema utenti ssettjaw kaxxaforti. Ibda t-tluq jew irrevoka suite minn ringiela.", + "Successor": "Suċċessur", + "Team folders": "Folders tat-tim", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L-utent għadu fil-grupp {groups}, li huwa membru ta' folder tat-tim. Neħħih mill-grupp jew iddiżattiva l-kont.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.", + "Vault status": "Status tal-kaxxaforti", + "Yes": "Iva", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Esportazzjoni CXF MHIJIEX ENKRIPTATA. Kull password u login se jkunu jinqraw bħala test sempliċi fil-fajl imniżżel. Aħżnu b'mod sigur u ħassru minnufih wara li tużah.", + "Root certificate expiring soon": "Iċ-ċertifikat għerq jiskadi dalwaqt", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Iċ-ċertifikat għerq tal-kaxxaforti jiskadi fi żmien %1$d jum/ijiem. Ġeddu qabel dak iż-żmien. It-tiġdid jerġa' jiffirma kull suite tal-encryption.", + "Compromise recovery aborted": "L-irkupru wara kompromess twaqqaf", + "Key rotation ended by a compromise revoke": "Ir-rotazzjoni taċ-ċavetta ntemmet b'revoka minħabba kompromess", + "Encryption suite revoke refused": "Ir-revoka tas-suite tal-encryption ġiet miċħuda", + "Master password proof refused": "Il-prova tal-password ewlenija ġiet miċħuda", + "Your current master password": "Il-password ewlenija attwali tiegħek", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kuntatt ta' emerġenza kellu talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħietu. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Dawn il-kuntatti ta' emerġenza ma ġewx trasferiti għaċ-ċavetta l-ġdida tiegħek. L-aċċess ta' emerġenza tagħhom tneħħa. Erġa' żidhom minn Aċċess ta' emerġenza jekk għadek trid.", + "Renew root certificate": "Iġġedded iċ-ċertifikat ewlieni", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dan joħloq ċertifikat ewlieni u intermedju ġodda. Kull suite ta' encryption attiva terġa' tiġi ffirmata. Ma tistax tħassar dan.", + "Renew root": "Iġġedded l-għerq", + "Root renewed. {n} encryption suites signed again.": "L-għerq ġie mġedded. Suites ta' encryption iffirmati mill-ġdid: {n}.", + "Could not renew the root certificate.": "Ma setax jiġi mġedded iċ-ċertifikat ewlieni.", + "Lease policy for this application": "Politika tal-kiri għal din l-applikazzjoni", + "In force now: {default} seconds by default, {max} seconds at most.": "Fis-seħħ issa: {default} sekonda b'mod awtomatiku, l-iktar {max} sekonda.", + "Leases are not renewable": "Il-kiri ma jistax jiġġedded", + "Lease policy saved.": "Il-politika tal-kiri ġiet salvata.", + "Leave a field empty to use the instance value.": "Ħalli qasam vojt biex tuża l-valur tal-istanza.", + "Instance value: {value}": "Valur tal-istanza: {value}", + "Renewal": "Tiġdid", + "Use the instance value ({value})": "Uża l-valur tal-istanza ({value})", + "Allowed": "Permess", + "Not allowed": "Mhux permess", + "Save lease policy": "Issalva l-politika tal-kiri", + "Only an administrator can change this policy.": "Amministratur biss jista' jibdel din il-politika.", + "Could not save the lease policy.": "Il-politika tal-kiri ma setgħetx tiġi salvata.", + "{member} got access from {confirmer}.": "{member} irċieva aċċess minn {confirmer}.", + "Automatically confirm new team folder members": "Ikkonferma awtomatikament membri ġodda tal-folders tat-tim", + "Gave %n new member access to a team folder.": "%n membru ġdid irċieva aċċess għal folder tat-tim.", + "Gave %n new members access to a team folder.": "%n membri ġodda rċevew aċċess għal folder tat-tim.", + "Give new team folder members access without waiting for the folder owner.": "Agħti aċċess lill-membri l-ġodda mingħajr ma tistenna lis-sid tal-folder.", + "New team folder members": "Membri ġodda tal-folders tat-tim", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Is-sid jew membru bi dritt tal-kitba jikkonfermahom mill-kaxxaforti miftuħa tiegħu. Keepiq qatt ma jiddekripta fuq is-server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Qed nistennew membru bi dritt tal-kitba jiftaħ Keepiq. Tista' wkoll taqsam issa.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parti mir-rispons għall-kompromess falliet ({failed} pass(i)). Iċċekkja l-log tas-server, imbagħad erġa' irrevoka s-suite biex tlestih.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dan irrevoka wkoll is-suite {suite} u temm il-migrazzjoni taċ-ċwievet {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Ir-revoka tat-tieni suite ħassret %n kuntatt ta' aċċess ta' emerġenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.", + "A suite revoked as compromised cannot be reinstated.": "Suite irrevokata bħala kompromessa ma tistax terġa' tiddaħħal.", + "Archives to keep": "Arkivji li għandhom jinżammu", + "Back up every vault automatically": "Agħmel backup ta' kull kaxxaforti awtomatikament", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Agħmel backup ta' kull kaxxaforti skont skeda. L-arkivji fihom biss test kriptat u jiġu rrestawrati b'occ.", + "Back up now": "Agħmel backup issa", + "Backup public key (PEM, optional)": "Ċavetta pubblika tal-backup (PEM, mhux obbligatorja)", + "Backup requested for the next cron run": "Backup mitlub għall-ġirja li jmiss ta' cron", + "Encrypted": "Kriptat", + "Every (hours)": "Kull (sigħat)", + "Last backup {when} failed: {error}": "L-aħħar backup {when} falla: {error}", + "Last backup {when} succeeded.": "L-aħħar backup {when} irnexxa.", + "No archives yet.": "Għad m'hemmx arkivji.", + "Size": "Daqs", + "Vault backups": "Backups tal-kaxxaforti", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "B'ċavetta, kull arkivju jiġi kriptat għaliha. Żomm iċ-ċavetta privata barra minn dan is-server: għandek bżonnha biex tivverifika jew tirrestawra.", + "Written": "Miktub", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utent fil-kamp għad m'għandux dħul b'żewġ fatturi u ma jistax jiftaħ il-kaxxaforti waqt li dan ikun mixgħul.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Il-kodiċijiet ta' riżerva ma jgħoddux. Jekk l-utenti tiegħek jidħlu permezz ta' fornitur tal-identità b'fattur ieħor tiegħu, ħalli barra l-gruppi tagħhom.", + "Block personal vault export": "Imblokka l-esportazzjoni tal-kaxxaforti personali", + "Keep work logins in team folders": "Żomm il-kredenzjali tax-xogħol f'folders tat-tim", + "Move to a team folder": "Mexxi għal folder tat-tim", + "Not in a team folder": "Mhux f'folder tat-tim", + "Only for these groups (empty is everyone)": "Biss għal dawn il-gruppi (vojt ifisser kulħadd)", + "Require two-factor login before the vault opens": "Itlob dħul b'żewġ fatturi qabel ma tinfetaħ il-kaxxaforti", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regoli għal kull kaxxaforti. Kull waħda tapplika għal kulħadd, jew biss għall-gruppi li tagħżel.", + "Secret types that belong in a team folder": "Tipi ta' sigrieti li jappartjenu f'folder tat-tim", + "Set up two-factor login": "Issettja d-dħul b'żewġ fatturi", + "Team folder you can write to": "Folder tat-tim fejn tista' tikteb", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "L-utenti ma jistgħux iniżżlu backup, CSV jew fajl ta' trasferiment. Il-pakkett tad-data personali tagħhom jibqa' disponibbli.", + "Users cannot save these secret types in a personal folder.": "L-utenti ma jistgħux jissejvjaw dawn it-tipi ta' sigrieti f'folder personali.", + "Vault policies": "Politiki tal-kaxxaforti", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "L-organizzazzjoni tiegħek ma tippermettix l-esportazzjoni tal-kaxxaforti personali tiegħek. Il-pakkett tad-data personali fis-settings jibqa' disponibbli.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "L-organizzazzjoni tiegħek iżżomm dawn is-sigrieti f'folder tat-tim. Mexxi kull wieħed għal folder tat-tim.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "L-organizzazzjoni tiegħek iżżomm dan it-tip ta' sigriet f'folder tat-tim. Agħżel wieħed mill-folders tat-tim tiegħek, jew wieħed fejn tista' tikteb.", + "Your organisation requires two-factor login before you can open your vault.": "L-organizzazzjoni tiegħek titlob dħul b'żewġ fatturi qabel ma tkun tista' tiftaħ il-kaxxaforti tiegħek.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "L-utenti jagħżlu kemm iddum l-estensjoni miftuħa meta ma tintużax. Int tissettja l-itwal żmien li jistgħu jagħżlu.", + "Longest idle time before the extension locks": "L-itwal żmien mingħajr attività qabel ma l-estensjoni tissakkar", + "1 minute": "1 minuta", + "5 minutes": "5 minuti", + "15 minutes": "15-il minuta", + "1 hour": "siegħa", + "4 hours": "4 sigħat", + "Connector": "Konnettur", + "Directory (tenant) ID": "ID tad-direttorju (kerrej)", + "Application (client) ID": "ID tal-applikazzjoni (klijent)", + "Data collection rule immutable ID": "ID immutabbli tar-regola tal-ġbir tad-dejta", + "Stream name": "Isem tal-fluss", + "Splunk index (optional)": "Indiċi Splunk (mhux obbligatorju)", + "Sourcetype (optional)": "Sourcetype (mhux obbligatorju)", + "Leave blank to keep the current one": "Ħallih vojt biex iżżomm dak attwali", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF permezz ta' syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punt finali tal-ġbir tad-dejta (URL https)", + "HTTP Event Collector URL (https)": "URL tal-HTTP Event Collector (https)", + "Client secret (write-only)": "Sigriet tal-klijent (kitba biss)", + "HEC token (write-only)": "Token HEC (kitba biss)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Ibgħat l-avvenimenti tal-awditjar permessi lil Splunk, Microsoft Sentinel, riċevitur syslog jew webhook. Il-messaġġi jġorru biss metadejta mnaddfa: l-ebda valur sigriet, isem, login jew test kriptat ma jitlaq qatt mis-server.", + "%n change waiting to sync": "%n bidla qed tistenna s-sinkronizzazzjoni", + "%n changes waiting to sync": "%n bidliet qed jistennew is-sinkronizzazzjoni", + "Changes that could not sync": "Bidliet li ma setgħux jiġu sinkronizzati", + "Choose a version": "Agħżel verżjoni", + "Copy value": "Ikkopja l-valur", + "Deleted": "Imħassar", + "Discard": "Armi", + "Keep my offline change": "Żomm il-bidla tiegħi offline", + "Keep the server version": "Żomm il-verżjoni tas-server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq huwa għall-qari biss offline. L-amministratur ma xegħelx l-editjar offline.", + "Let users edit secrets offline": "Ħalli lill-utenti jeditjaw sigrieti offline", + "Not synced yet": "Għadu mhux sinkronizzat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Il-bidliet offline jibqgħu fuq l-apparat, ikkriptati għall-utent, u jiġu sinkronizzati fil-ftuħ online li jmiss. Il-qsim, il-folders u l-annessi xorta jeħtieġu konnessjoni.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. L-editjar, iċ-ċaqliq u t-tħassir jibqgħu fuq dan l-apparat u jiġu sinkronizzati meta terġa' tkun online. Il-qsim u l-annessi jeħtieġu konnessjoni.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Il-bidliet tiegħek jibqgħu fuq dan l-apparat u jiġu sinkronizzati meta terġa' tkun online. L-aħħar sinkronizzazzjoni {when}.", + "Open my changes": "Iftaħ il-bidliet tiegħi", + "Sharing needs a connection": "Il-qsim jeħtieġ konnessjoni", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Xi ħadd biddel dan is-sigriet fuq is-server wara li saret il-kopja offline tiegħek. Agħżel liema verżjoni żżomm.", + "Sync or discard your offline changes before you rotate your keys.": "Issinkronizza jew armi l-bidliet offline qabel ma tbiddel iċ-ċwievet.", + "That password did not open your changes.": "Dik il-password ma fetħitx il-bidliet tiegħek.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Ir-ritratt offline jaħżen sigrieti kkriptati (jinfetħu biss biċ-ċavetta derivata mill-password ewlenija tal-utent, eżatt bħal fuq is-server) u jikkripta l-ismijiet, il-URLs u l-ismijiet tal-folders fil-ħażna. L-aċċess offline huwa għall-qari biss sakemm ma tippermettix l-editjar offline hawn taħt. Itfi dan għal apparati li qatt m'għandhom iżommu kredenzjali; meta jintefa, il-caches eżistenti jitnaddfu fit-tagħbija li jmiss.", + "The previous vault copy is gone, so these changes cannot be opened.": "Il-kopja preċedenti tal-kaxxaforti m'għadhiex hemm, għalhekk dawn il-bidliet ma jistgħux jinfetħu.", + "The server version": "Il-verżjoni tas-server", + "This secret changed while you were offline": "Dan is-sigriet inbidel waqt li kont offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ħassart dan is-sigriet offline, iżda minn dakinhar inbidel fuq is-server. Agħżel liema verżjoni żżomm.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Iċ-ċwievet tiegħek inbidlu fuq apparat ieħor. Daħħal il-password ewlenija preċedenti biex tissinkronizza l-bidliet offline, jew armihom.", + "Your offline change": "Il-bidla tiegħek offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n kuntatt ta' emerġenza kellu talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħietu. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid.", + "%n kuntatti ta' emerġenza kellhom talba għal aċċess pendenti meta r-rotazzjoni taċ-ċavetta neħħiethom. Iċċekkja min talab qabel ma żżid lil xi ħadd mill-ġdid." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n oġġett ma jistax jiġi rappreżentat f'CXF u se jinqabeż.", + "%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu.", + "%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu.", + "%n oġġetti ma jistgħux jiġu rappreżentati f'CXF u se jinqabżu." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n verżjoni antika tneħħiet, għax tista' tinġarr biss l-istorja riċenti.", + "%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti.", + "%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti.", + "%n verżjonijiet antiki tneħħew, għax tista' tinġarr biss l-istorja riċenti." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopja ta' sigriet għad trid tiġi enkriptata u maqsuma.", + "%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma.", + "%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma.", + "%n kopji ta' sigrieti għad iridu jiġu enkriptati u maqsuma." + ], + "_%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 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.", + "%n secrets could not be decrypted and are not in this export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n sigriet ma stax jiġi dekriptat bl-aċċess antik tiegħek, għalhekk ma ġiex migrat.", + "%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati.", + "%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati.", + "%n sigrieti ma stgħux jiġu dekriptati bl-aċċess antik tiegħek, għalhekk ma ġewx migrati." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n sigriet ma ġiex migrat.", + "%n sigrieti ma ġewx migrati.", + "%n sigrieti ma ġewx migrati.", + "%n sigrieti ma ġewx migrati." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n sigriet għadu kkriptat biċ-ċavetta preċedenti tiegħek.", + "%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek.", + "%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek.", + "%n sigrieti għadhom ikkriptati biċ-ċavetta preċedenti tiegħek." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n sigriet inqabeż għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.", + "%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.", + "%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova.", + "%n sigrieti inqabżu għax is-suċċessur għadu m'għandu l-ebda kopja — żid is-suċċessur mal-folder u erġa' ipprova." + ], + "_%n secret_::_%n secrets_": [ + "%n sigriet", + "%n sigriet", + "%n sigriet", + "%n sigriet" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n utent fil-kamp għad m'għandux dħul b'żewġ fatturi u ma jistax jiftaħ il-kaxxaforti waqt li dan ikun mixgħul.", + "%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.", + "%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul.", + "%n utenti fil-kamp għad m'għandhomx dħul b'żewġ fatturi u ma jistgħux jiftħu l-kaxxaforti waqt li dan ikun mixgħul." + ], + "_Emergency access for %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 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.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Temm xorta waħda, u titlef l-aċċess għal %n sigriet", + "Temm xorta waħda, u titlef l-aċċess għal %n sigrieti", + "Temm xorta waħda, u titlef l-aċċess għal %n sigrieti", + "Temm xorta waħda, u titlef l-aċċess għal %n sigrieti" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n membru ġdid irċieva aċċess għal folder tat-tim.", + "%n membri ġodda rċevew aċċess għal folder tat-tim.", + "%n membri ġodda rċevew aċċess għal folder tat-tim.", + "%n membri ġodda rċevew aċċess għal folder tat-tim." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigriet ġie kkriptat mill-ġdid biċ-ċavetta ġdida tiegħek.", + "Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek.", + "Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek.", + "Ir-rotazzjoni taċ-ċavetta tlestiet. %n sigrieti ġew ikkriptati mill-ġdid biċ-ċavetta ġdida tiegħek." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Ir-revoka tat-tieni suite ħassret %n kuntatt ta' aċċess ta' emerġenza.", + "Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.", + "Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza.", + "Ir-revoka tat-tieni suite ħassret %n kuntatti ta' aċċess ta' emerġenza." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revoking this suite deleted %n emergency-access contact.", + "Revoking this suite deleted %n emergency-access contacts.", + "Revoking this suite deleted %n emergency-access contacts.", + "Revoking this suite deleted %n emergency-access contacts." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "dehret %n darba fi ksur", + "dehret %n darbiet fi ksur", + "dehret %n darbiet fi ksur", + "dehret %n darbiet fi ksur" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "maqsum ma' %n sigriet", + "maqsum ma' %n sigrieti", + "maqsum ma' %n sigrieti", + "maqsum ma' %n sigrieti" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Dan il-folder fih %n sigriet direttament.", + "Dan il-folder fih %n sigriet direttament.", + "Dan il-folder fih %n sigriet direttament.", + "Dan il-folder fih %n sigriet direttament." + ], + "_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._": [ + "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.", + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n bidla qed tistenna s-sinkronizzazzjoni", + "%n bidliet qed jistennew is-sinkronizzazzjoni", + "%n bidliet qed jistennew is-sinkronizzazzjoni", + "%n bidliet qed jistennew is-sinkronizzazzjoni" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "L-utent għadu fil-grupp {groups}, li huwa membru ta' folder tat-tim. Neħħih mill-grupp jew iddiżattiva l-kont.", + "L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.", + "L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont.", + "L-utent għadu fil-gruppi {groups}, li huma membri ta' folders tat-tim. Neħħih mill-gruppi jew iddiżattiva l-kont." + ], + "Allow approval from another device": "Ippermetti l-approvazzjoni minn apparat ieħor", + "App": "App", + "Approve a new device": "Approva apparat ġdid", + "Approve from another device": "Approva minn apparat ieħor", + "Asked at": "Mitlub fi", + "Check that the new device shows these words:": "Iċċekkja li l-apparat il-ġdid juri dawn il-kliem:", + "Denied. If you did not ask, end your other sessions:": "Miċħud. Jekk ma tlabtx int, temm is-sessjonijiet l-oħra tiegħek:", + "Device": "Apparat", + "IP address": "Indirizz IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Ħalli lill-utenti jiftħu browser ġdid billi japprovawh minn apparat fejn Keepiq diġà miftuħ.", + "New device approval": "Approvazzjoni ta' apparat ġdid", + "Nextcloud security settings": "Settings tas-sigurtà ta' Nextcloud", + "Only approve a device you are using right now.": "Approva biss apparat li qed tuża bħalissa.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Iftaħ Keepiq fuq apparat fejn huwa miftuħ u approva dan. Iċċekkja li juri l-istess kliem:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L-apparat li japprova jissiġilla ċ-ċavetta tal-ftuħ għall-apparat il-ġdid. Is-server jgħaddiha biss u ma jistax jiftaħha.", + "The master password is not right, or the request has ended.": "Il-password prinċipali mhix korretta, jew it-talba spiċċat.", + "The request expired. Ask again or use your master password.": "It-talba skadiet. Erġa' itlob jew uża l-password prinċipali tiegħek.", + "The request was denied.": "It-talba ġiet miċħuda.", + "Too many requests. Try again in an hour or use your master password.": "Wisq talbiet. Erġa' pprova fi żmien siegħa jew uża l-password prinċipali tiegħek.", + "Unknown device": "Apparat mhux magħruf", + "Web app": "App tal-web", + "A device": "Apparat", + "A new device asks to open your vault": "Apparat ġdid qed jitlob li jiftaħ il-kaxxa-forti tiegħek", + "%s asks to be approved. Only approve a device you are using right now.": "%s qed jitlob li jiġi approvat. Approva biss apparat li qed tuża bħalissa.", + "Access ends on (optional)": "L-aċċess jintemm fi (mhux obbligatorju)", + "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.": "L-apps ta' Keepiq mhux se juru jew jikkupjaw il-password. Xi ħadd b'ħiliet tekniċi xorta jista' jaqraha mill-apparat tiegħu. Biddilha meta l-aċċess tiegħu jintemm.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dan is-sigriet huwa għall-użu biss. Idħol permezz tal-estensjoni tal-browser ta' Keepiq.", + "Until {date}": "Sa {date}", + "Use only": "Użu biss", + "Use only (can sign in, cannot view or copy)": "Użu biss (jista' jidħol, ma jistax jara jew jikkopja)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Tista' tidħol b'dan il-login permezz tal-estensjoni tal-browser ta' Keepiq. Is-sid għażel li ma jħallikx tarah jew tikkopjah.", + "Your access ends on {date}": "L-aċċess tiegħek jintemm fi {date}", + "Your access to this secret has ended": "L-aċċess tiegħek għal dan is-sigriet intemm", + "Your access to \"%s\" ends tomorrow": "L-aċċess tiegħek għal \"%s\" jintemm għada", + "Your access to \"%s\" has ended": "L-aċċess tiegħek għal \"%s\" intemm", + "%1$s no longer has access to \"%2$s\"": "%1$s m'għadx għandu aċċess għal \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s seta' jara din il-password. Biddilha jekk %1$s m'għandux ikun jafha aktar.", + "%s could not view this password in Keepiq.": "%s ma setax jara din il-password f'Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} minn {threshold} approvazzjonijiet", + "a recovery officer": "uffiċjal tal-irkupru", + "Account recovery": "Irkupru tal-kont", + "Approvals needed": "Approvazzjonijiet meħtieġa", + "Ask {user} which words they see, by phone or in person. They must be:": "Staqsi lil {user} liema kliem jara, bit-telefon jew personalment. Iridu jkunu:", + "Check again": "Iċċekkja mill-ġdid", + "Create the recovery key": "Oħloq iċ-ċavetta tal-irkupru", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Oħloq iċ-ċavetta tal-irkupru. Il-browser tiegħek jagħmilha u jagħti lil kull uffiċjal kopja li jista' jiftaħ hu biss.", + "Decline": "Iċħad", + "Enrol in account recovery": "Irreġistra għall-irkupru tal-kont", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Irreġistra biex l-organizzazzjoni tiegħek tkun tista' tgħinek terġa' tikseb il-kaxxaforti tiegħek jekk tinsa l-password prinċipali.", + "Every user is enrolled": "L-utenti kollha huma rreġistrati", + "Finish the recovery in the browser you asked from.": "Lesti l-irkupru fil-browser li minnu tlabt.", + "Forgot your master password?": "Insejt il-password prinċipali?", + "Hand the key over": "Għaddi ċ-ċavetta", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ħalli lill-utenti li nsew il-password prinċipali jerġgħu jiksbu l-kaxxaforti tagħhom, approvat minn uffiċjali tal-irkupru li tinnomina int.", + "New master password": "Password prinċipali ġdida", + "No one is asking to recover their account.": "Ħadd mhu qed jitlob li jirkupra l-kont tiegħu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Għad m'hemmx ċavetta tal-irkupru. Wieħed mill-uffiċjali joħloqha fl-issettjar ta' Keepiq tiegħu.", + "Off": "Mitfi", + "Officer {user} has no encryption set up yet.": "L-uffiċjal {user} għadu ma ssettjax il-kriptaġġ.", + "Officers (user IDs, separated by commas)": "Uffiċjali (IDs tal-utenti, separati b'virgoli)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Ippubblika din il-marka tas-saba' internament, biex l-utenti jkunu jistgħu jiċċekkjawha qabel ma jirreġistraw.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Irkuprat bl-għajnuna ta' {officer}. Ibdel iċ-ċavetta tal-kaxxaforti issa f'Issettjar, Sigurtà: \"Il-password prinċipali tiegħi ġiet kompromessa\".", + "Recovery key fingerprint: {fingerprint}": "Marka tas-saba' taċ-ċavetta tal-irkupru: {fingerprint}", + "Recovery officer": "Uffiċjal tal-irkupru", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "L-uffiċjali mneħħija jitilfu l-kopja tagħhom issa, imma setgħu fetħuha qabel. Ħalli uffiċjal joħloq ċavetta tal-irkupru ġdida.", + "Repeat the new master password": "Erġa' daħħal il-password prinċipali l-ġdida", + "Retire this recovery key": "Irtira din iċ-ċavetta tal-irkupru", + "Set the new master password": "Issettja l-password prinċipali l-ġdida", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Iċ-ċertifikat tal-irkupru mhuwiex maħruġ minn dan il-Keepiq. Tirreġistrax u għid lill-amministratur tiegħek.", + "The words match, approve": "Il-kliem jaqbel, approva", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Dan l-utent huwa rreġistrat għall-irkupru tal-kont. L-irkupru jżomm is-sigrieti tiegħu; ir-revoka tħassar ir-reġistrazzjoni tiegħu.", + "Users may enrol": "L-utenti jistgħu jirreġistraw", + "Withdraw from account recovery": "Irtira mill-irkupru tal-kont", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Int irreġistrat għall-irkupru tal-kont. Marka tas-saba' taċ-ċavetta tal-irkupru: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Int irreġistrat. Jekk tinsa l-password prinċipali, l-organizzazzjoni tiegħek tista' tgħinek terġa' tikseb il-kaxxaforti.", + "Your key is back. Choose a new master password.": "Iċ-ċavetta tiegħek reġgħet lura. Agħżel password prinċipali ġdida.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "L-uffiċjali tal-irkupru tiegħek ġew infurmati. Aqralhom dan il-kliem meta jċemplulek jew jiltaqgħu miegħek:", + "You are now an account recovery officer": "Issa int uffiċjal tal-irkupru tal-kontijiet", + "%s asks to recover their account. Compare the words with them before you approve.": "%s qed jitlob li jirkupra l-kont tiegħu. Qabbel il-kliem miegħu qabel ma tapprova.", + "A user": "Utent", + "Your account recovery request was declined": "It-talba tiegħek għall-irkupru tal-kont ġiet miċħuda", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "L-irkupru tal-kont tiegħek huwa lest. Iftaħ Keepiq fil-browser li minnu tlabt.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} qed jitlob li jiftaħ apparat ġdid darba waħda. Il-password ewlenija tibqa' l-istess.", + "Ask your organisation instead": "Minflok, staqsi lill-organizzazzjoni tiegħek", + "The request ended. Ask again or use your master password.": "It-talba ntemmet. Erġa' itlob jew uża l-password ewlenija tiegħek.", + "Added by {user}": "Miżjud minn {user}", + "Editor": "Editur", + "Manager": "Maniġer", + "Role of {member}": "Ir-rwol ta' {member}", + "Team folders you manage": "Folders tat-tim li timmaniġġja", + "Viewer": "Qarrej", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "M'għandek l-ebda kopja ta' dawn is-sigrieti, għalhekk il-membri l-ġodda għadhom ma rċevewhomx. Is-sid jista' jaqsamhom: {names}", + "Admin areas": "Oqsma ta' amministrazzjoni", + "Give a group only the parts of Keepiq administration it needs.": "Agħti lil grupp biss il-partijiet tal-amministrazzjoni ta' Keepiq li għandu bżonn.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Iddelega qasam wieħed jew aktar lil grupp fuq il-paġna tal-privileġġi tal-amministrazzjoni. L-amministraturi tal-istanza għandhom kull qasam.", + "Open administration privileges": "Iftaħ il-privileġġi tal-amministrazzjoni", + "Policies": "Politiki", + "Applications and machine access": "Applikazzjonijiet u aċċess tal-magni", + "People and offboarding": "Nies u tluq", + "Audit and compliance": "Verifika u konformità", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verżjoni, awtorità taċ-ċertifikazzjoni, annessi, cache offline, kontroll tat-tnixxija, tipi ta' sigrieti u backups", + "master password, organisation password, vault policies, rotation, version history and trash": "password ewlenija, password tal-organizzazzjoni, politiki tal-kaxxa-forti, rotazzjoni, storja tal-verżjonijiet u skart", + "application queue, application requests and machine leases": "kju tal-applikazzjonijiet, talbiet tal-applikazzjonijiet u kiri tal-magni", + "team offboarding, encryption suites and admin handover": "tluq mit-tim, suites tal-kriptaġġ u teħid mill-amministratur", + "audit log, compliance reports, SIEM export and honey alerts": "reġistru tal-verifika, rapporti tal-konformità, esportazzjoni SIEM u twissijiet tal-lixka", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Kemm verżjonijiet ta' sigriet jinżammu, għal kemm żmien, u kemm idumu s-sigrieti mħassra fl-iskart.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limiti għall-annessi kriptati, infurzati fuq is-server f'bytes kriptati maħżuna.", + "Type the suite ID again to confirm": "Erġa' ikteb l-ID tas-suite biex tikkonferma", + "This does not match the suite ID.": "Dan ma jaqbilx mal-ID tas-suite.", + "Confirm with your master password": "Ikkonferma bil-password master tiegħek", + "Confirm": "Ikkonferma", + "That master password is not right.": "Dik il-password master mhix korretta.", + "You are sharing with someone new. Enter your master password to confirm.": "Qed taqsam ma' persuna ġdida. Daħħal il-password master tiegħek biex tikkonferma.", + "Enter your master password to confirm this share.": "Daħħal il-password master tiegħek biex tikkonferma dan il-qsim.", + "Enter your master password to confirm this delegation.": "Daħħal il-password master tiegħek biex tikkonferma din id-delega.", + "Approve {member}": "Approva lil {member}", + "Recipient": "Benefiċjarju", + "No vault yet": "Għad m’għandux vault", + "No matching users": "L-ebda utent ma jaqbel", + "Partner organisations": "Organizzazzjonijiet sħab", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Skambja sigrieti ma’ Keepiq ieħor. Iż-żewġ amministraturi jżidu lil xulxin u jqabblu l-marki tas-swaba’ ewlenin bit-telefon jew personalment qabel ma jissejvjaw.", + "Federation needs Nextcloud 33 or later.": "Il-federazzjoni teħtieġ Nextcloud 33 jew aktar ġdid.", + "Your root fingerprint": "Il-marka ewlenija tiegħek", + "No partners yet.": "Għad m’hemmx sħab.", + "Users here may share to this partner": "L-utenti hawn jistgħu jaqsmu ma’ dan is-sieħeb", + "This partner may share to users here": "Dan is-sieħeb jista’ jaqsam mal-utenti hawn", + "Partner address": "L-indirizz tas-sieħeb", + "Check partner": "Iċċekkja s-sieħeb", + "Partner root fingerprint": "Il-marka ewlenija tas-sieħeb", + "I compared this fingerprint with the partner's administrator": "Qabbilt din il-marka mal-amministratur tas-sieħeb", + "Add partner": "Żid sieħeb", + "A secret from another organisation": "Sigriet minn organizzazzjoni oħra", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s qasam \"%2$s\" miegħek. Aċċettah taħt Deħlin minn organizzazzjonijiet oħra.", + "Incoming from other organisations": "Deħlin minn organizzazzjonijiet oħra", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Nies f’organizzazzjonijiet sħab jistgħu jaqsmu sigriet miegħek. Aċċettah biex iżżomm kopja għall-qari biss fil-vault tiegħek.", + "Nothing shared with you yet": "Għadu ma nqasam xejn miegħek", + "Secrets that people in partner organisations share with you appear here.": "Is-sigrieti li n-nies f’organizzazzjonijiet sħab jaqsmu miegħek jidhru hawn.", + "From {sender}": "Minn {sender}", + "Accept": "Aċċetta", + "Open in vault": "Iftaħ fil-vault", + "The other organisation did not hand over the secret. Try again later.": "L-organizzazzjoni l-oħra ma għaddietx is-sigriet. Erġa’ pprova aktar tard.", + "Set up your vault before you accept a shared secret.": "Issettja l-vault tiegħek qabel ma taċċetta sigriet maqsum.", + "Something went wrong. Try again.": "Xi ħaġa marret ħażin. Erġa’ pprova.", + "Waiting for your answer": "Qed jistenna t-tweġiba tiegħek", + "In your vault, read-only": "Fil-vault tiegħek, għall-qari biss", + "Withdrawn by the sender": "Irtirat mill-mittent", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} qasam dan minn organizzazzjoni oħra. Tista’ taqrah, iżda ma tistax tibdlu jew taqsmu.", + "Someone": "Xi ħadd", + "Share with someone at another organisation": "Aqsam ma’ xi ħadd f’organizzazzjoni oħra", + "Their account at the other organisation": "Il-kont tal-persuna fl-organizzazzjoni l-oħra", + "Check account": "Iċċekkja l-kont", + "Certificate fingerprint of {account}": "Il-marka tas-swaba’ taċ-ċertifikat ta’ {account}", + "Compare it with them by phone if you want to be sure.": "Qabblu magħhom bit-telefon jekk trid tkun ċert.", + "Shared. {account} can accept it in their own vault.": "Maqsum. {account} jista’ jaċċettah fil-vault tiegħu stess.", + "The certificate could not be verified. Nothing was shared.": "Iċ-ċertifikat ma setax jiġi vverifikat. Xejn ma ġie maqsum.", + "That organisation is not one of your partners.": "Dik l-organizzazzjoni mhix waħda mis-sħab tiegħek.", + "No one with that account can receive secrets from you.": "Ħadd b’dak il-kont ma jista’ jirċievi sigrieti mingħandek.", + "The other organisation did not answer. Try again later.": "L-organizzazzjoni l-oħra ma wieġbitx. Erġa’ pprova aktar tard.", + "This secret is already shared with that account.": "Dan is-sigriet diġà maqsum ma’ dak il-kont.", + "Other organisations": "Organizzazzjonijiet oħra", + "Receive secrets from other organisations": "Irċievi sigrieti minn organizzazzjonijiet oħra", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Nies f’organizzazzjonijiet sħab imbagħad jistgħu jsibu l-kont tiegħek u jaqsmu sigrieti miegħek. Inti taċċetta kull wieħed minnhom.", + "Shared": "Maqsum", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Fuq pawża: iċ-ċertifikat tagħhom jew is-sħubija nbidlu. Irrevoka jew erġa’ aqsam.", + "Their organisation did not get the last change. Revoke it or share again.": "L-organizzazzjoni tagħhom ma rċevietx l-aħħar bidla. Irrevoka jew erġa’ aqsam.", + "Being withdrawn": "Qed jiġi rtirat", + "Shared with another organisation": "Maqsum ma’ organizzazzjoni oħra", + "Change sent to another organisation": "Bidla mibgħuta lil organizzazzjoni oħra", + "Share with another organisation revoked": "Qsim ma’ organizzazzjoni oħra irrevokat", + "Share with another organisation paused": "Qsim ma’ organizzazzjoni oħra fuq pawża", + "Another organisation did not get a change": "Organizzazzjoni oħra ma rċevietx bidla", + "Secret received from another organisation": "Sigriet riċevut minn organizzazzjoni oħra", + "Secret from another organisation accepted": "Sigriet minn organizzazzjoni oħra aċċettat", + "Secret from another organisation declined": "Sigriet minn organizzazzjoni oħra miċħud", + "Copy from another organisation updated": "Kopja minn organizzazzjoni oħra aġġornata", + "Copy from another organisation removed": "Kopja minn organizzazzjoni oħra mneħħija", + "Declined: they removed their copy. Share again if they need it.": "Miċħud: ir-riċevitur neħħa l-kopja tiegħu. Erġa’ aqsam jekk jeħtieġha.", + "Recipient at another organisation removed their copy": "Riċevitur f’organizzazzjoni oħra neħħa l-kopja tiegħu", + "Removed the user from %n team folder.": "L-utent tneħħa minn %n folder tat-tim.", + "Removed the user from %n team folders.": "L-utent tneħħa minn %n folders tat-tim.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "L-utent tneħħa minn %n folder tat-tim.", + "L-utent tneħħa minn %n folders tat-tim.", + "L-utent tneħħa minn %n folders tat-tim.", + "L-utent tneħħa minn %n folders tat-tim." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Kopja rrestawrata ġiet minn qsim li ntemm. Tibqa’ għall-qari biss.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L-organizzazzjoni li qasmet kopja rrestawrata ma setgħetx tintlaħaq. Il-kopja tibqa’ għall-qari biss u ma ssegwix il-bidliet tagħhom.", + "Recipient at another organisation restored their copy": "Riċevitur f’organizzazzjoni oħra rrestawra l-kopja tiegħu" }, "plurals": null } diff --git a/l10n/nb.js b/l10n/nb.js index ee04f8ff6..04e156dff 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Delt med grupper", + "Not shared with any group yet.": "Ikke delt med noen gruppe ennå.", + "Revoke the share with {group}": "Trekk tilbake delingen med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer mottok den, {skipped} gjorde det ikke fordi de ikke har satt opp kryptering ennå.", + "Search groups": "Søk etter grupper", + "Failed to share": "Deling mislyktes", + "Columns": "Kolonner", + "Column {number}": "Kolonne {number}", + "Map one column to Name. Every secret needs a name.": "Knytt én kolonne til Navn. Hver hemmelighet trenger et navn.", + "Notes": "Notater", + "Do not import": "Ikke importer", + "Hide this value": "Skjul denne verdien", + "Show this value": "Vis denne verdien", + "Defaults": "Standarder", + "New secrets start as this type, and your secret list opens in this view.": "Nye hemmeligheter starter som denne typen, og hemmelighetslisten din åpnes i denne visningen.", + "Default item type": "Standard elementtype", + "Cards": "Kort", + "Table": "Tabell", + "Could not save your default": "Kunne ikke lagre standardvalget ditt", + "Recently used": "Nylig brukt", + "Opened": "Åpnet", + "You have not opened any secrets yet": "Du har ikke åpnet noen hemmeligheter ennå", + "Could not delete the item type.": "Kunne ikke slette elementtypen.", + "Could not load the item types.": "Kunne ikke laste inn elementtypene.", + "Could not save the item type.": "Kunne ikke lagre elementtypen.", + "Delete item type": "Slett elementtype", + "Edit item type": "Rediger elementtype", + "Fields": "Felt", + "Fields: {count}": "Felt: {count}", + "Hidden": "Skjult", + "Item types": "Elementtyper", + "Move up": "Flytt opp", + "New item type": "Ny elementtype", + "No item types defined yet.": "Ingen elementtyper er definert ennå.", + "Required": "Påkrevd", + "Text": "Tekst", + "This field is required": "Dette feltet er påkrevd", + "Web address": "Nettadresse", + "{label} (required)": "{label} (påkrevd)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Slette «{name}»? Hemmeligheter av denne typen kan fortsatt leses og blir Innlogging-elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtyper du definerer her, vises for alle i dialogen Ny hemmelighet, med feltene du velger.", + "Secret moved to the trash": "Hemmelighet flyttet til papirkurven", + "Secret restored from the trash": "Hemmelighet gjenopprettet fra papirkurven", + "Secret deleted for good": "Hemmelighet slettet for godt", + "Secret archived": "Hemmelighet arkivert", + "Secret unarchived": "Hemmelighet hentet ut av arkivet", + "Unarchive": "Hent ut av arkivet", + "Could not archive the secret": "Kunne ikke arkivere hemmeligheten", + "Could not unarchive the secret": "Kunne ikke hente hemmeligheten ut av arkivet", + "Archive {count} secrets": "Arkiver {count} hemmeligheter", + "Unarchive {count} secrets": "Hent {count} hemmeligheter ut av arkivet", + "Restore {count} secrets": "Gjenopprett {count} hemmeligheter", + "Delete {count} secrets for good": "Slett {count} hemmeligheter for godt", + "Done for {ok} of {total} secrets": "Ferdig for {ok} av {total} hemmeligheter", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverte hemmeligheter forsvinner fra hvelvlisten, søket, autoutfyllingen og helserapporten. De beholder delingene sine. Du finner dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "Disse hemmelighetene kommer tilbake i hvelvlisten, søket og autoutfyllingen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Disse hemmelighetene kommer tilbake i hvelvlisten. De gamle delingene kommer ikke tilbake, så del dem på nytt der det trengs.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dette sletter hemmelighetene med vedleggene og versjonshistorikken. Det kan ikke angres.", + "Delete for good": "Slett for godt", + "Trash": "Papirkurv", + "The trash is empty": "Papirkurven er tom", + "No archived secrets": "Ingen arkiverte hemmeligheter", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Slettede hemmeligheter venter her til oppbevaringstiden er ute, deretter slettes de for godt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkiver en hemmelighet fra detaljpanelet for å holde den utenfor hvelvlisten, søket og autoutfyllingen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenser for krypterte vedlegg (håndheves på serveren i lagrede krypterte byte), oppbevaring av versjonshistorikk og hvor lenge slettede hemmeligheter blir liggende i papirkurven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dager en slettet hemmelighet blir liggende i papirkurven (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dette flytter hemmeligheten til papirkurven og avslutter delingene nå. Du kan gjenopprette den fra papirkurven til oppbevaringstiden er ute: 30 dager, med mindre administratoren har endret det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dette flytter {count} hemmeligheter til papirkurven og avslutter delingene nå. Du kan gjenopprette dem fra papirkurven til oppbevaringstiden er ute.", + "Remove {name} from favourites": "Fjern {name} fra favoritter", + "Add {name} to favourites": "Legg {name} til i favoritter", + "Could not change the favourite": "Kunne ikke endre favoritten", + "Remove from favourites": "Fjern fra favoritter", + "Add to favourites": "Legg til i favoritter", + "Tags": "Etiketter", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketter er ikke kryptert. Serveradministratorer kan lese dem, som mappenavn.", + "Favourites": "Favoritter", + "Filter by tag": "Filtrer etter etikett", + "All tags": "Alle etiketter", + "Last used": "Sist brukt", + "Tags for {count} secrets": "Etiketter for {count} hemmeligheter", + "Tag": "Etikett", + "Remove tag": "Fjern etikett", + "Add tag": "Legg til etikett", + "Could not change the tags. Try again.": "Kunne ikke endre etikettene. Prøv igjen.", + "Could not approve the application. It is still in the queue.": "Kunne ikke godkjenne søknaden. Den står fortsatt i køen.", + "Could not reject the application. It is still in the queue.": "Kunne ikke avvise søknaden. Den står fortsatt i køen.", + "Removed the user from {count} team folders.": "Fjernet brukeren fra {count} teammapper.", + "Approve a share": "Godkjenn en deling", + "This approval link is incomplete. Open it again from the notification.": "Denne godkjenningslenken er ufullstendig. Åpne den igjen fra varselet.", + "Deny": "Avslå", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ble med i en gruppe du deler en hemmelighet med. Vil du dele hemmeligheten med dem også?", + "{requester} asks you to share a secret with {user}.": "{requester} ber deg dele en hemmelighet med {user}.", + "Shared. The recipient can now open the secret.": "Delt. Mottakeren kan nå åpne hemmeligheten.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Mottakeren har ikke satt opp Keepiq ennå, så ingenting ble delt. Prøv igjen når det er gjort.", + "Could not share the secret. Only its owner can approve this.": "Kunne ikke dele hemmeligheten. Bare eieren kan godkjenne dette.", + "Could not share the secret. Try again.": "Kunne ikke dele hemmeligheten. Prøv igjen.", + "Denied. Nothing was shared.": "Avslått. Ingenting ble delt.", + "Could not deny the request. Try again.": "Kunne ikke avslå forespørselen. Prøv igjen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ber deg dele hemmeligheten \"%2$s\" med %3$s.", + "Expires on (optional)": "Utløper den (valgfritt)", + "Hand over to": "Overlever til", + "Choose a recipient": "Velg en mottaker", + "Hand over temporarily": "Overlever midlertidig", + "Expiry rules": "Utløpsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Angi hvor lenge passord av én elementtype eller i én mappe kan leve, og når du skal få en påminnelse. Når flere datoer gjelder, teller den tidligste.", + "Delete rule": "Slett regel", + "Set by your administrator": "Angitt av administratoren din", + "No expiry rules yet.": "Ingen utløpsregler ennå.", + "Applies to": "Gjelder for", + "Item type": "Elementtype", + "Maximum age in days (empty for reminders only)": "Maksimal alder i dager (tomt for bare påminnelser)", + "Remind me this many days before, comma separated": "Minn meg på så mange dager før, kommaseparert", + "Save rule": "Lagre regel", + "An item type": "En elementtype", + "A folder": "En mappe", + "Folder {name}": "Mappe {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Utløper etter {days} dager", + "Reminders {days} days before": "Påminnelser {days} dager før", + "Could not save the expiry rule.": "Kunne ikke lagre utløpsregelen.", + "Could not delete the expiry rule.": "Kunne ikke slette utløpsregelen.", + "All statuses": "Alle statuser", + "Compromised": "Kompromittert", + "Could not load the members.": "Kunne ikke laste inn medlemmene.", + "Emergency contact": "Nødkontakt", + "Leaving user": "Fratredende bruker", + "No": "Nei", + "No users match this filter.": "Ingen brukere samsvarer med dette filteret.", + "Not set up": "Ikke satt opp", + "Revoke suite": "Tilbakekall suite", + "Revoked": "Tilbakekalt", + "Search users": "Søk etter brukere", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se hvilke brukere som har satt opp et hvelv. Start offboarding eller tilbakekall en suite fra en rad.", + "Successor": "Etterfølger", + "Team folders": "Teammapper", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Brukeren er fortsatt i gruppen {groups}, som er medlem av en teammappe. Fjern brukeren fra gruppen eller deaktiver kontoen.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Brukeren er fortsatt i gruppene {groups}, som er medlemmer av teammapper. Fjern brukeren fra gruppene eller deaktiver kontoen.", + "Vault status": "Hvelvstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-eksport er UKRYPTERT. Alle passord og innlogginger vil være lesbare som klartekst i den nedlastede filen. Oppbevar den trygt, og slett den umiddelbart etter bruk.", + "Root certificate expiring soon": "Rotsertifikatet utløper snart", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Hvelvets rotsertifikat utløper om %1$d dag(er). Forny det før den tid. Fornyelsen signerer hver krypteringssuite på nytt.", + "Compromise recovery aborted": "Gjenoppretting etter kompromittering avbrutt", + "Key rotation ended by a compromise revoke": "Nøkkelrotasjon avsluttet av en tilbakekalling på grunn av kompromittering", + "Encryption suite revoke refused": "Tilbakekalling av krypteringssuite avvist", + "Master password proof refused": "Bevis for hovedpassord avvist", + "Your current master password": "Ditt nåværende hovedpassord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nødkontakt hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet den. Sjekk hvem som spurte før du legger til noen igjen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nødkontakter hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet dem. Sjekk hvem som spurte før du legger til noen igjen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Disse nødkontaktene ble ikke overført til den nye nøkkelen din. Nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.", + "Renew root certificate": "Forny rotsertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dette oppretter et nytt rot- og mellomsertifikat. Hver aktiv krypteringspakke signeres på nytt. Dette kan ikke angres.", + "Renew root": "Forny rot", + "Root renewed. {n} encryption suites signed again.": "Rot fornyet. {n} krypteringspakker signert på nytt.", + "Could not renew the root certificate.": "Kunne ikke fornye rotsertifikatet.", + "Lease policy for this application": "Leiepolicy for denne applikasjonen", + "In force now: {default} seconds by default, {max} seconds at most.": "Gjelder nå: {default} sekunder som standard, høyst {max} sekunder.", + "Leases are not renewable": "Leieavtaler kan ikke fornyes", + "Lease policy saved.": "Leiepolicy lagret.", + "Leave a field empty to use the instance value.": "La et felt stå tomt for å bruke instansens verdi.", + "Instance value: {value}": "Instansens verdi: {value}", + "Renewal": "Fornyelse", + "Use the instance value ({value})": "Bruk instansens verdi ({value})", + "Allowed": "Tillatt", + "Not allowed": "Ikke tillatt", + "Save lease policy": "Lagre leiepolicy", + "Only an administrator can change this policy.": "Bare en administrator kan endre denne policyen.", + "Could not save the lease policy.": "Kunne ikke lagre leiepolicyen.", + "{member} got access from {confirmer}.": "{member} fikk tilgang fra {confirmer}.", + "Automatically confirm new team folder members": "Bekreft nye teammappemedlemmer automatisk", + "Gave %n new member access to a team folder.": "%n nytt medlem fikk tilgang til en teammappe.", + "Gave %n new members access to a team folder.": "%n nye medlemmer fikk tilgang til en teammappe.", + "Give new team folder members access without waiting for the folder owner.": "Gi nye teammappemedlemmer tilgang uten å vente på mappens eier.", + "New team folder members": "Nye teammappemedlemmer", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Eieren eller et medlem med skrivetilgang bekrefter dem fra sitt åpne hvelv. Keepiq dekrypterer aldri på serveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Venter på at et medlem med skrivetilgang åpner Keepiq. Du kan også dele nå.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del av kompromitteringsresponsen mislyktes ({failed} trinn). Sjekk serverloggen, og tilbakekall deretter suiten på nytt for å fullføre.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dette tilbakekalte også suite {suite} og avsluttet nøkkelmigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakter.", + "A suite revoked as compromised cannot be reinstated.": "En suite som er tilbakekalt som kompromittert, kan ikke gjenopprettes.", + "Archives to keep": "Arkiver som skal beholdes", + "Back up every vault automatically": "Sikkerhetskopier hvert hvelv automatisk", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sikkerhetskopier hvert hvelv etter en plan. Arkivene inneholder bare kryptert tekst og gjenopprettes med occ.", + "Back up now": "Sikkerhetskopier nå", + "Backup public key (PEM, optional)": "Offentlig sikkerhetskopinøkkel (PEM, valgfri)", + "Backup requested for the next cron run": "Sikkerhetskopi bestilt til neste cron-kjøring", + "Encrypted": "Kryptert", + "Every (hours)": "Hver (timer)", + "Last backup {when} failed: {error}": "Siste sikkerhetskopi {when} mislyktes: {error}", + "Last backup {when} succeeded.": "Siste sikkerhetskopi {when} lyktes.", + "No archives yet.": "Ingen arkiver ennå.", + "Size": "Størrelse", + "Vault backups": "Hvelvsikkerhetskopier", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nøkkel krypteres hvert arkiv til den. Oppbevar den private nøkkelen utenfor denne serveren: du trenger den for å kontrollere eller gjenopprette.", + "Written": "Skrevet", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n bruker i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n brukere i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Reservekoder teller ikke. Hvis brukerne dine logger på via en identitetsleverandør med egen andre faktor, utelat gruppene deres.", + "Block personal vault export": "Blokker eksport av personlig hvelv", + "Keep work logins in team folders": "Hold jobbpålogginger i teammapper", + "Move to a team folder": "Flytt til en teammappe", + "Not in a team folder": "Ikke i en teammappe", + "Only for these groups (empty is everyone)": "Bare for disse gruppene (tom betyr alle)", + "Require two-factor login before the vault opens": "Krev totrinnspålogging før hvelvet åpnes", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler for hvert hvelv. Hver regel gjelder alle, eller bare gruppene du velger.", + "Secret types that belong in a team folder": "Hemmelighetstyper som hører hjemme i en teammappe", + "Set up two-factor login": "Sett opp totrinnspålogging", + "Team folder you can write to": "Teammappe du kan skrive i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Brukere kan ikke laste ned en sikkerhetskopi, CSV eller overføringsfil. Den personlige datapakken er fortsatt tilgjengelig.", + "Users cannot save these secret types in a personal folder.": "Brukere kan ikke lagre disse hemmelighetstypene i en personlig mappe.", + "Vault policies": "Hvelvregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organisasjonen din tillater ikke eksport av ditt personlige hvelv. Din personlige datapakke i innstillingene er fortsatt tilgjengelig.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organisasjonen din holder disse hemmelighetene i en teammappe. Flytt hver enkelt til en teammappe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organisasjonen din holder denne typen hemmelighet i en teammappe. Velg en av teammappene dine, eller en du kan skrive i.", + "Your organisation requires two-factor login before you can open your vault.": "Organisasjonen din krever totrinnspålogging før du kan åpne hvelvet ditt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Brukerne velger hvor lenge utvidelsen forblir låst opp ved inaktivitet. Du angir det lengste de kan velge.", + "Longest idle time before the extension locks": "Lengste inaktive tid før utvidelsen låses", + "1 minute": "1 minutt", + "5 minutes": "5 minutter", + "15 minutes": "15 minutter", + "1 hour": "1 time", + "4 hours": "4 timer", + "Connector": "Kobling", + "Directory (tenant) ID": "Katalog-ID (leietaker)", + "Application (client) ID": "Program-ID (klient)", + "Data collection rule immutable ID": "Uforanderlig ID for datainnsamlingsregelen", + "Stream name": "Strømnavn", + "Splunk index (optional)": "Splunk-indeks (valgfritt)", + "Sourcetype (optional)": "Sourcetype (valgfritt)", + "Leave blank to keep the current one": "La stå tomt for å beholde den nåværende", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endepunkt for datainnsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemmelighet (kun skriving)", + "HEC token (write-only)": "HEC-token (kun skriving)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Videresend tillatte revisjonshendelser til Splunk, Microsoft Sentinel, en syslog-mottaker eller en webhook. Meldinger inneholder bare rensede metadata: ingen hemmelig verdi, navn, pålogging eller kryptert tekst forlater noen gang serveren.", + "%n change waiting to sync": "%n endring venter på synkronisering", + "%n changes waiting to sync": "%n endringer venter på synkronisering", + "Changes that could not sync": "Endringer som ikke kunne synkroniseres", + "Choose a version": "Velg en versjon", + "Copy value": "Kopier verdi", + "Deleted": "Slettet", + "Discard": "Forkast", + "Keep my offline change": "Behold min frakoblede endring", + "Keep the server version": "Behold serverversjonen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er skrivebeskyttet frakoblet. Administratoren har ikke slått på frakoblet redigering.", + "Let users edit secrets offline": "La brukere redigere hemmeligheter frakoblet", + "Not synced yet": "Ikke synkronisert ennå", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Frakoblede endringer lagres på enheten, kryptert for brukeren, og synkroniseres ved neste tilkoblede opplåsing. Deling, mapper og vedlegg krever fortsatt tilkobling.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Frakoblet. Redigeringer, flyttinger og slettinger blir på denne enheten og synkroniseres når du er tilkoblet igjen. Deling og vedlegg krever tilkobling.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Frakoblet. Endringene dine blir på denne enheten og synkroniseres når du er tilkoblet igjen. Sist synkronisert {when}.", + "Open my changes": "Åpne endringene mine", + "Sharing needs a connection": "Deling krever tilkobling", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Noen endret denne hemmeligheten på serveren etter at den frakoblede kopien din ble laget. Velg hvilken versjon du vil beholde.", + "Sync or discard your offline changes before you rotate your keys.": "Synkroniser eller forkast de frakoblede endringene før du bytter nøkler.", + "That password did not open your changes.": "Det passordet åpnet ikke endringene dine.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Det frakoblede øyeblikksbildet lagrer krypterte hemmeligheter (kan bare åpnes med nøkkelen avledet fra brukerens hovedpassord, akkurat som på serveren) og krypterer navn, URL-er og mappenavn i hvile. Frakoblet tilgang er skrivebeskyttet med mindre du tillater frakoblet redigering nedenfor. Slå dette av for enheter som aldri skal mellomlagre påloggingsdata; når det slås av, tømmes eksisterende hurtigbuffere ved neste innlasting.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den forrige hvelvkopien er borte, så disse endringene kan ikke åpnes.", + "The server version": "Serverversjonen", + "This secret changed while you were offline": "Denne hemmeligheten ble endret mens du var frakoblet", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du slettet denne hemmeligheten frakoblet, men den er endret på serveren siden. Velg hvilken versjon du vil beholde.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Nøklene dine ble endret på en annen enhet. Skriv inn ditt forrige hovedpassord for å synkronisere de frakoblede endringene, eller forkast dem.", + "Your offline change": "Din frakoblede endring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n nødkontakt hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet den. Sjekk hvem som spurte før du legger til noen igjen.","%n nødkontakter hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet dem. Sjekk hvem som spurte før du legger til noen igjen."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n element kan ikke gjengis i CXF og hoppes over.","%n elementer kan ikke gjengis i CXF og hoppes over."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n eldre versjon ble forkastet, fordi bare nyere historikk kan overføres.","%n eldre versjoner ble forkastet, fordi bare nyere historikk kan overføres."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopi av en hemmelighet må fortsatt krypteres og deles.","%n kopier av hemmeligheter må fortsatt krypteres og deles."], + "_%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 hemmelighet kunne ikke dekrypteres og er ikke med i denne eksporten.","%n hemmeligheter kunne ikke dekrypteres og er ikke med i denne eksporten."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n hemmelighet kunne ikke dekrypteres med den gamle nøkkelen din, så den ble ikke migrert.","%n hemmeligheter kunne ikke dekrypteres med den gamle nøkkelen din, så de ble ikke migrert."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n hemmelighet ble ikke migrert.","%n hemmeligheter ble ikke migrert."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n hemmelighet er fortsatt kryptert med den forrige nøkkelen din.","%n hemmeligheter er fortsatt kryptert med den forrige nøkkelen din."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n hemmelighet ble hoppet over fordi etterfølgeren ennå ikke har en kopi — legg etterfølgeren til i mappen, og kjør på nytt.","%n hemmeligheter ble hoppet over fordi etterfølgeren ennå ikke har en kopi — legg etterfølgeren til i mappen, og kjør på nytt."], + "_%n secret_::_%n secrets_": ["%n hemmelighet","%n hemmeligheter"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n bruker i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.","%n brukere i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Fullfør likevel, og mist tilgangen til %n hemmelighet","Fullfør likevel, og mist tilgangen til %n hemmeligheter"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nytt medlem fikk tilgang til en teammappe.","%n nye medlemmer fikk tilgang til en teammappe."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Nøkkelrotasjon fullført. %n hemmelighet ble kryptert på nytt med den nye nøkkelen din.","Nøkkelrotasjon fullført. %n hemmeligheter ble kryptert på nytt med den nye nøkkelen din."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Tilbakekalling av den andre suiten slettet %n nødtilgangskontakt.","Tilbakekalling av den andre suiten slettet %n nødtilgangskontakter."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakt.","Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakter."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["sett %n gang i lekkasjer","sett %n ganger i lekkasjer"], + "_shared with %n secret_::_shared with %n secrets_": ["delt med %n hemmelighet","delt med %n hemmeligheter"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Denne mappen inneholder %n hemmelighet direkte.","Denne mappen inneholder %n hemmeligheter direkte."], + "_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._": ["Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.","Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n endring venter på synkronisering","%n endringer venter på synkronisering"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Brukeren er fortsatt i gruppen {groups}, som er medlem av en teammappe. Fjern brukeren fra gruppen eller deaktiver kontoen.","Brukeren er fortsatt i gruppene {groups}, som er medlemmer av teammapper. Fjern brukeren fra gruppene eller deaktiver kontoen."], + "Allow approval from another device": "Tillat godkjenning fra en annen enhet", + "App": "App", + "Approve a new device": "Godkjenn en ny enhet", + "Approve from another device": "Godkjenn fra en annen enhet", + "Asked at": "Forespurt kl.", + "Check that the new device shows these words:": "Sjekk at den nye enheten viser disse ordene:", + "Denied. If you did not ask, end your other sessions:": "Avvist. Hvis du ikke ba om dette, avslutt de andre øktene dine:", + "Device": "Enhet", + "IP address": "IP-adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "La brukere låse opp en ny nettleser ved å godkjenne den fra en enhet der Keepiq allerede er låst opp.", + "New device approval": "Godkjenning av nye enheter", + "Nextcloud security settings": "Sikkerhetsinnstillinger i Nextcloud", + "Only approve a device you are using right now.": "Godkjenn bare en enhet du bruker akkurat nå.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Åpne Keepiq på en enhet der den er låst opp, og godkjenn denne. Sjekk at den viser de samme ordene:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Enheten som godkjenner, forsegler opplåsingsnøkkelen til den nye enheten. Serveren sender den bare videre og kan ikke åpne den.", + "The master password is not right, or the request has ended.": "Hovedpassordet er feil, eller forespørselen er avsluttet.", + "The request expired. Ask again or use your master password.": "Forespørselen er utløpt. Spør på nytt eller bruk hovedpassordet ditt.", + "The request was denied.": "Forespørselen ble avvist.", + "Too many requests. Try again in an hour or use your master password.": "For mange forespørsler. Prøv igjen om en time eller bruk hovedpassordet ditt.", + "Unknown device": "Ukjent enhet", + "Web app": "Nettapp", + "A device": "En enhet", + "A new device asks to open your vault": "En ny enhet ber om å åpne hvelvet ditt", + "%s asks to be approved. Only approve a device you are using right now.": "%s ber om godkjenning. Godkjenn bare en enhet du bruker akkurat nå.", + "Access ends on (optional)": "Tilgang slutter den (valgfritt)", + "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.": "Keepiq-appene viser eller kopierer ikke passordet. Noen med teknisk kunnskap kan fortsatt lese det fra sin egen enhet. Bytt det når tilgangen deres slutter.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Denne hemmeligheten er kun til bruk. Logg inn via Keepiq-nettleserutvidelsen.", + "Until {date}": "Til {date}", + "Use only": "Kun bruk", + "Use only (can sign in, cannot view or copy)": "Kun bruk (kan logge inn, kan ikke se eller kopiere)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logge inn med denne påloggingen via Keepiq-nettleserutvidelsen. Eieren har valgt å ikke la deg se eller kopiere den.", + "Your access ends on {date}": "Tilgangen din slutter {date}", + "Your access to this secret has ended": "Tilgangen din til denne hemmeligheten er avsluttet", + "Your access to \"%s\" ends tomorrow": "Tilgangen din til «%s» slutter i morgen", + "Your access to \"%s\" has ended": "Tilgangen din til «%s» er avsluttet", + "%1$s no longer has access to \"%2$s\"": "%1$s har ikke lenger tilgang til «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunne se dette passordet. Bytt det hvis %1$s ikke lenger skal kjenne det.", + "%s could not view this password in Keepiq.": "%s kunne ikke se dette passordet i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} av {threshold} godkjenninger", + "a recovery officer": "en gjenopprettingsansvarlig", + "Account recovery": "Kontogjenoppretting", + "Approvals needed": "Godkjenninger som trengs", + "Ask {user} which words they see, by phone or in person. They must be:": "Spør {user} hvilke ord de ser, på telefon eller personlig. De må være:", + "Check again": "Sjekk igjen", + "Create the recovery key": "Opprett gjenopprettingsnøkkelen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Opprett gjenopprettingsnøkkelen. Nettleseren din lager den og gir hver ansvarlig en kopi som bare de kan åpne.", + "Decline": "Avslå", + "Enrol in account recovery": "Meld deg på kontogjenoppretting", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Meld deg på så organisasjonen din kan hjelpe deg med å få hvelvet tilbake hvis du glemmer hovedpassordet.", + "Every user is enrolled": "Alle brukere er påmeldt", + "Finish the recovery in the browser you asked from.": "Fullfør gjenopprettingen i nettleseren du spurte fra.", + "Forgot your master password?": "Glemt hovedpassordet?", + "Hand the key over": "Overlever nøkkelen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "La brukere som har glemt hovedpassordet få hvelvet tilbake, godkjent av gjenopprettingsansvarlige du utnevner.", + "New master password": "Nytt hovedpassord", + "No one is asking to recover their account.": "Ingen ber om å gjenopprette kontoen sin.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen gjenopprettingsnøkkel ennå. En av de ansvarlige oppretter den i sine Keepiq-innstillinger.", + "Off": "Av", + "Officer {user} has no encryption set up yet.": "Den ansvarlige {user} har ikke satt opp kryptering ennå.", + "Officers (user IDs, separated by commas)": "Ansvarlige (bruker-ID-er, adskilt med komma)", + "Policy": "Retningslinje", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiser dette fingeravtrykket internt, så brukerne kan sjekke det før de melder seg på.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Gjenopprettet med hjelp fra {officer}. Bytt hvelvnøkkelen nå under Innstillinger, Sikkerhet: \"Hovedpassordet mitt er kompromittert\".", + "Recovery key fingerprint: {fingerprint}": "Gjenopprettingsnøkkelens fingeravtrykk: {fingerprint}", + "Recovery officer": "Gjenopprettingsansvarlig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fjernede ansvarlige mister kopien sin nå, men kan ha åpnet den tidligere. Få en ansvarlig til å opprette en ny gjenopprettingsnøkkel.", + "Repeat the new master password": "Gjenta det nye hovedpassordet", + "Retire this recovery key": "Pensjoner denne gjenopprettingsnøkkelen", + "Set the new master password": "Angi det nye hovedpassordet", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Gjenopprettingssertifikatet er ikke utstedt av denne Keepiq. Ikke meld deg på, og si fra til administratoren.", + "The words match, approve": "Ordene stemmer, godkjenn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denne brukeren er påmeldt kontogjenoppretting. Gjenoppretting beholder hemmelighetene deres; tilbakekalling sletter påmeldingen.", + "Users may enrol": "Brukere kan melde seg på", + "Withdraw from account recovery": "Meld deg av kontogjenoppretting", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du er påmeldt kontogjenoppretting. Gjenopprettingsnøkkelens fingeravtrykk: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du er påmeldt. Hvis du glemmer hovedpassordet, kan organisasjonen din hjelpe deg med å få hvelvet tilbake.", + "Your key is back. Choose a new master password.": "Nøkkelen din er tilbake. Velg et nytt hovedpassord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "De gjenopprettingsansvarlige har fått beskjed. Les disse ordene for dem når de ringer eller møter deg:", + "You are now an account recovery officer": "Du er nå ansvarlig for kontogjenoppretting", + "%s asks to recover their account. Compare the words with them before you approve.": "%s ber om å gjenopprette kontoen sin. Sammenlign ordene med personen før du godkjenner.", + "A user": "En bruker", + "Your account recovery request was declined": "Forespørselen din om kontogjenoppretting ble avslått", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Kontogjenopprettingen din er klar. Åpne Keepiq i nettleseren du spurte fra.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ber om å låse opp en ny enhet én gang. Hovedpassordet forblir det samme.", + "Ask your organisation instead": "Spør organisasjonen din i stedet", + "The request ended. Ask again or use your master password.": "Forespørselen er avsluttet. Spør igjen eller bruk hovedpassordet ditt.", + "Added by {user}": "Lagt til av {user}", + "Editor": "Redaktør", + "Manager": "Administrator", + "Role of {member}": "Rollen til {member}", + "Team folders you manage": "Teammapper du administrerer", + "Viewer": "Leser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopi av disse hemmelighetene, så de nye medlemmene har ikke fått dem ennå. Eieren kan dele dem: {names}", + "Admin areas": "Administrasjonsområder", + "Give a group only the parts of Keepiq administration it needs.": "Gi en gruppe bare de delene av Keepiq-administrasjonen den trenger.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleger ett eller flere områder til en gruppe på siden for administrasjonsrettigheter. Instansadministratorer har alle områder.", + "Open administration privileges": "Åpne administrasjonsrettigheter", + "Policies": "Retningslinjer", + "Applications and machine access": "Applikasjoner og maskintilgang", + "People and offboarding": "Personer og fratredelse", + "Audit and compliance": "Revisjon og samsvar", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versjon, sertifikatutsteder, vedlegg, frakoblet hurtigbuffer, lekkasjesjekk, hemmelighetstyper og sikkerhetskopier", + "master password, organisation password, vault policies, rotation, version history and trash": "hovedpassord, organisasjonspassord, hvelvets retningslinjer, rotasjon, versjonshistorikk og papirkurv", + "application queue, application requests and machine leases": "applikasjonskø, applikasjonsforespørsler og maskinleieavtaler", + "team offboarding, encryption suites and admin handover": "teamfratredelse, krypteringssuiter og overtakelse av administrator", + "audit log, compliance reports, SIEM export and honey alerts": "revisjonslogg, samsvarsrapporter, SIEM-eksport og lokkevarsler", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hvor mange versjoner av en hemmelighet som beholdes, hvor lenge, og hvor lenge slettede hemmeligheter blir i papirkurven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenser for krypterte vedlegg, håndhevet på serveren i lagrede krypterte byte.", + "Type the suite ID again to confirm": "Skriv inn pakke-ID-en på nytt for å bekrefte", + "This does not match the suite ID.": "Dette samsvarer ikke med pakke-ID-en.", + "Confirm with your master password": "Bekreft med hovedpassordet ditt", + "Confirm": "Bekreft", + "That master password is not right.": "Det hovedpassordet er ikke riktig.", + "You are sharing with someone new. Enter your master password to confirm.": "Du deler med en ny person. Skriv inn hovedpassordet ditt for å bekrefte.", + "Enter your master password to confirm this share.": "Skriv inn hovedpassordet ditt for å bekrefte denne delingen.", + "Enter your master password to confirm this delegation.": "Skriv inn hovedpassordet ditt for å bekrefte denne delegeringen.", + "Approve {member}": "Godkjenn {member}", + "Recipient": "Mottaker", + "No vault yet": "Har ikke hvelv ennå", + "No matching users": "Ingen samsvarende brukere", + "Partner organisations": "Partnerorganisasjoner", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Utveksle hemmeligheter med en annen Keepiq. Begge administratorene legger til hverandre og sammenligner rotfingeravtrykkene på telefon eller ansikt til ansikt før de lagrer.", + "Federation needs Nextcloud 33 or later.": "Føderasjon krever Nextcloud 33 eller nyere.", + "Your root fingerprint": "Ditt rotfingeravtrykk", + "No partners yet.": "Ingen partnere ennå.", + "Users here may share to this partner": "Brukere her kan dele med denne partneren", + "This partner may share to users here": "Denne partneren kan dele med brukere her", + "Partner address": "Partnerens adresse", + "Check partner": "Sjekk partner", + "Partner root fingerprint": "Partnerens rotfingeravtrykk", + "I compared this fingerprint with the partner's administrator": "Jeg har sammenlignet dette fingeravtrykket med partnerens administrator", + "Add partner": "Legg til partner", + "A secret from another organisation": "En hemmelighet fra en annen organisasjon", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delt \"%2$s\" med deg. Godta den under Mottatt fra andre organisasjoner.", + "Incoming from other organisations": "Mottatt fra andre organisasjoner", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisasjoner kan dele en hemmelighet med deg. Godta den for å beholde en skrivebeskyttet kopi i hvelvet ditt.", + "Nothing shared with you yet": "Ingenting delt med deg ennå", + "Secrets that people in partner organisations share with you appear here.": "Hemmeligheter som personer i partnerorganisasjoner deler med deg, vises her.", + "From {sender}": "Fra {sender}", + "Accept": "Godta", + "Open in vault": "Åpne i hvelvet", + "The other organisation did not hand over the secret. Try again later.": "Den andre organisasjonen overleverte ikke hemmeligheten. Prøv igjen senere.", + "Set up your vault before you accept a shared secret.": "Sett opp hvelvet ditt før du godtar en delt hemmelighet.", + "Something went wrong. Try again.": "Noe gikk galt. Prøv igjen.", + "Waiting for your answer": "Venter på svaret ditt", + "In your vault, read-only": "I hvelvet ditt, skrivebeskyttet", + "Withdrawn by the sender": "Trukket tilbake av avsenderen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delte dette fra en annen organisasjon. Du kan lese det, men ikke endre eller dele det.", + "Someone": "Noen", + "Share with someone at another organisation": "Del med noen i en annen organisasjon", + "Their account at the other organisation": "Personens konto i den andre organisasjonen", + "Check account": "Sjekk konto", + "Certificate fingerprint of {account}": "Sertifikatets fingeravtrykk for {account}", + "Compare it with them by phone if you want to be sure.": "Sammenlign det med personen over telefon hvis du vil være sikker.", + "Shared. {account} can accept it in their own vault.": "Delt. {account} kan godta den i sitt eget hvelv.", + "The certificate could not be verified. Nothing was shared.": "Sertifikatet kunne ikke bekreftes. Ingenting ble delt.", + "That organisation is not one of your partners.": "Den organisasjonen er ikke en av partnerne dine.", + "No one with that account can receive secrets from you.": "Ingen med den kontoen kan motta hemmeligheter fra deg.", + "The other organisation did not answer. Try again later.": "Den andre organisasjonen svarte ikke. Prøv igjen senere.", + "This secret is already shared with that account.": "Denne hemmeligheten er allerede delt med den kontoen.", + "Other organisations": "Andre organisasjoner", + "Receive secrets from other organisations": "Motta hemmeligheter fra andre organisasjoner", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisasjoner kan da finne kontoen din og dele hemmeligheter med deg. Du godtar hver enkelt selv.", + "Shared": "Delt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Satt på pause: mottakerens sertifikat eller partnerskapet er endret. Tilbakekall den, eller del på nytt.", + "Their organisation did not get the last change. Revoke it or share again.": "Mottakerens organisasjon fikk ikke den siste endringen. Tilbakekall den, eller del på nytt.", + "Being withdrawn": "Trekkes tilbake", + "Shared with another organisation": "Delt med en annen organisasjon", + "Change sent to another organisation": "Endring sendt til en annen organisasjon", + "Share with another organisation revoked": "Deling med en annen organisasjon tilbakekalt", + "Share with another organisation paused": "Deling med en annen organisasjon satt på pause", + "Another organisation did not get a change": "En annen organisasjon fikk ikke en endring", + "Secret received from another organisation": "Hemmelighet mottatt fra en annen organisasjon", + "Secret from another organisation accepted": "Hemmelighet fra en annen organisasjon godtatt", + "Secret from another organisation declined": "Hemmelighet fra en annen organisasjon avslått", + "Copy from another organisation updated": "Kopi fra en annen organisasjon oppdatert", + "Copy from another organisation removed": "Kopi fra en annen organisasjon fjernet", + "Declined: they removed their copy. Share again if they need it.": "Avslått: mottakeren fjernet kopien sin. Del på nytt hvis de trenger den.", + "Recipient at another organisation removed their copy": "Mottaker i en annen organisasjon fjernet kopien sin", + "Removed the user from %n team folder.": "Fjernet brukeren fra %n teammappe.", + "Removed the user from %n team folders.": "Fjernet brukeren fra %n teammapper.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Fjernet brukeren fra %n teammappe.","Fjernet brukeren fra %n teammapper."], + "A restored copy came from a share that has ended. It stays read-only.": "En gjenopprettet kopi kom fra en deling som er avsluttet. Den forblir skrivebeskyttet.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisasjonen som delte en gjenopprettet kopi, kunne ikke nås. Kopien forblir skrivebeskyttet og følger ikke endringene deres.", + "Recipient at another organisation restored their copy": "Mottaker i en annen organisasjon gjenopprettet kopien sin" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nb.json b/l10n/nb.json index 5d3a21206..03c39d06d 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Delt med grupper", + "Not shared with any group yet.": "Ikke delt med noen gruppe ennå.", + "Revoke the share with {group}": "Trekk tilbake delingen med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delt med {group}: {received} medlemmer mottok den, {skipped} gjorde det ikke fordi de ikke har satt opp kryptering ennå.", + "Search groups": "Søk etter grupper", + "Failed to share": "Deling mislyktes", + "Columns": "Kolonner", + "Column {number}": "Kolonne {number}", + "Map one column to Name. Every secret needs a name.": "Knytt én kolonne til Navn. Hver hemmelighet trenger et navn.", + "Notes": "Notater", + "Do not import": "Ikke importer", + "Hide this value": "Skjul denne verdien", + "Show this value": "Vis denne verdien", + "Defaults": "Standarder", + "New secrets start as this type, and your secret list opens in this view.": "Nye hemmeligheter starter som denne typen, og hemmelighetslisten din åpnes i denne visningen.", + "Default item type": "Standard elementtype", + "Cards": "Kort", + "Table": "Tabell", + "Could not save your default": "Kunne ikke lagre standardvalget ditt", + "Recently used": "Nylig brukt", + "Opened": "Åpnet", + "You have not opened any secrets yet": "Du har ikke åpnet noen hemmeligheter ennå", + "Could not delete the item type.": "Kunne ikke slette elementtypen.", + "Could not load the item types.": "Kunne ikke laste inn elementtypene.", + "Could not save the item type.": "Kunne ikke lagre elementtypen.", + "Delete item type": "Slett elementtype", + "Edit item type": "Rediger elementtype", + "Fields": "Felt", + "Fields: {count}": "Felt: {count}", + "Hidden": "Skjult", + "Item types": "Elementtyper", + "Move up": "Flytt opp", + "New item type": "Ny elementtype", + "No item types defined yet.": "Ingen elementtyper er definert ennå.", + "Required": "Påkrevd", + "Text": "Tekst", + "This field is required": "Dette feltet er påkrevd", + "Web address": "Nettadresse", + "{label} (required)": "{label} (påkrevd)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Slette «{name}»? Hemmeligheter av denne typen kan fortsatt leses og blir Innlogging-elementer.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Elementtyper du definerer her, vises for alle i dialogen Ny hemmelighet, med feltene du velger.", + "Secret moved to the trash": "Hemmelighet flyttet til papirkurven", + "Secret restored from the trash": "Hemmelighet gjenopprettet fra papirkurven", + "Secret deleted for good": "Hemmelighet slettet for godt", + "Secret archived": "Hemmelighet arkivert", + "Secret unarchived": "Hemmelighet hentet ut av arkivet", + "Unarchive": "Hent ut av arkivet", + "Could not archive the secret": "Kunne ikke arkivere hemmeligheten", + "Could not unarchive the secret": "Kunne ikke hente hemmeligheten ut av arkivet", + "Archive {count} secrets": "Arkiver {count} hemmeligheter", + "Unarchive {count} secrets": "Hent {count} hemmeligheter ut av arkivet", + "Restore {count} secrets": "Gjenopprett {count} hemmeligheter", + "Delete {count} secrets for good": "Slett {count} hemmeligheter for godt", + "Done for {ok} of {total} secrets": "Ferdig for {ok} av {total} hemmeligheter", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverte hemmeligheter forsvinner fra hvelvlisten, søket, autoutfyllingen og helserapporten. De beholder delingene sine. Du finner dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "Disse hemmelighetene kommer tilbake i hvelvlisten, søket og autoutfyllingen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Disse hemmelighetene kommer tilbake i hvelvlisten. De gamle delingene kommer ikke tilbake, så del dem på nytt der det trengs.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dette sletter hemmelighetene med vedleggene og versjonshistorikken. Det kan ikke angres.", + "Delete for good": "Slett for godt", + "Trash": "Papirkurv", + "The trash is empty": "Papirkurven er tom", + "No archived secrets": "Ingen arkiverte hemmeligheter", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Slettede hemmeligheter venter her til oppbevaringstiden er ute, deretter slettes de for godt.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkiver en hemmelighet fra detaljpanelet for å holde den utenfor hvelvlisten, søket og autoutfyllingen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Grenser for krypterte vedlegg (håndheves på serveren i lagrede krypterte byte), oppbevaring av versjonshistorikk og hvor lenge slettede hemmeligheter blir liggende i papirkurven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dager en slettet hemmelighet blir liggende i papirkurven (1 til 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dette flytter hemmeligheten til papirkurven og avslutter delingene nå. Du kan gjenopprette den fra papirkurven til oppbevaringstiden er ute: 30 dager, med mindre administratoren har endret det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dette flytter {count} hemmeligheter til papirkurven og avslutter delingene nå. Du kan gjenopprette dem fra papirkurven til oppbevaringstiden er ute.", + "Remove {name} from favourites": "Fjern {name} fra favoritter", + "Add {name} to favourites": "Legg {name} til i favoritter", + "Could not change the favourite": "Kunne ikke endre favoritten", + "Remove from favourites": "Fjern fra favoritter", + "Add to favourites": "Legg til i favoritter", + "Tags": "Etiketter", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketter er ikke kryptert. Serveradministratorer kan lese dem, som mappenavn.", + "Favourites": "Favoritter", + "Filter by tag": "Filtrer etter etikett", + "All tags": "Alle etiketter", + "Last used": "Sist brukt", + "Tags for {count} secrets": "Etiketter for {count} hemmeligheter", + "Tag": "Etikett", + "Remove tag": "Fjern etikett", + "Add tag": "Legg til etikett", + "Could not change the tags. Try again.": "Kunne ikke endre etikettene. Prøv igjen.", + "Could not approve the application. It is still in the queue.": "Kunne ikke godkjenne søknaden. Den står fortsatt i køen.", + "Could not reject the application. It is still in the queue.": "Kunne ikke avvise søknaden. Den står fortsatt i køen.", + "Removed the user from {count} team folders.": "Fjernet brukeren fra {count} teammapper.", + "Approve a share": "Godkjenn en deling", + "This approval link is incomplete. Open it again from the notification.": "Denne godkjenningslenken er ufullstendig. Åpne den igjen fra varselet.", + "Deny": "Avslå", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} ble med i en gruppe du deler en hemmelighet med. Vil du dele hemmeligheten med dem også?", + "{requester} asks you to share a secret with {user}.": "{requester} ber deg dele en hemmelighet med {user}.", + "Shared. The recipient can now open the secret.": "Delt. Mottakeren kan nå åpne hemmeligheten.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Mottakeren har ikke satt opp Keepiq ennå, så ingenting ble delt. Prøv igjen når det er gjort.", + "Could not share the secret. Only its owner can approve this.": "Kunne ikke dele hemmeligheten. Bare eieren kan godkjenne dette.", + "Could not share the secret. Try again.": "Kunne ikke dele hemmeligheten. Prøv igjen.", + "Denied. Nothing was shared.": "Avslått. Ingenting ble delt.", + "Could not deny the request. Try again.": "Kunne ikke avslå forespørselen. Prøv igjen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ber deg dele hemmeligheten \"%2$s\" med %3$s.", + "Expires on (optional)": "Utløper den (valgfritt)", + "Hand over to": "Overlever til", + "Choose a recipient": "Velg en mottaker", + "Hand over temporarily": "Overlever midlertidig", + "Expiry rules": "Utløpsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Angi hvor lenge passord av én elementtype eller i én mappe kan leve, og når du skal få en påminnelse. Når flere datoer gjelder, teller den tidligste.", + "Delete rule": "Slett regel", + "Set by your administrator": "Angitt av administratoren din", + "No expiry rules yet.": "Ingen utløpsregler ennå.", + "Applies to": "Gjelder for", + "Item type": "Elementtype", + "Maximum age in days (empty for reminders only)": "Maksimal alder i dager (tomt for bare påminnelser)", + "Remind me this many days before, comma separated": "Minn meg på så mange dager før, kommaseparert", + "Save rule": "Lagre regel", + "An item type": "En elementtype", + "A folder": "En mappe", + "Folder {name}": "Mappe {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Utløper etter {days} dager", + "Reminders {days} days before": "Påminnelser {days} dager før", + "Could not save the expiry rule.": "Kunne ikke lagre utløpsregelen.", + "Could not delete the expiry rule.": "Kunne ikke slette utløpsregelen.", + "All statuses": "Alle statuser", + "Compromised": "Kompromittert", + "Could not load the members.": "Kunne ikke laste inn medlemmene.", + "Emergency contact": "Nødkontakt", + "Leaving user": "Fratredende bruker", + "No": "Nei", + "No users match this filter.": "Ingen brukere samsvarer med dette filteret.", + "Not set up": "Ikke satt opp", + "Revoke suite": "Tilbakekall suite", + "Revoked": "Tilbakekalt", + "Search users": "Søk etter brukere", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se hvilke brukere som har satt opp et hvelv. Start offboarding eller tilbakekall en suite fra en rad.", + "Successor": "Etterfølger", + "Team folders": "Teammapper", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Brukeren er fortsatt i gruppen {groups}, som er medlem av en teammappe. Fjern brukeren fra gruppen eller deaktiver kontoen.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Brukeren er fortsatt i gruppene {groups}, som er medlemmer av teammapper. Fjern brukeren fra gruppene eller deaktiver kontoen.", + "Vault status": "Hvelvstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-eksport er UKRYPTERT. Alle passord og innlogginger vil være lesbare som klartekst i den nedlastede filen. Oppbevar den trygt, og slett den umiddelbart etter bruk.", + "Root certificate expiring soon": "Rotsertifikatet utløper snart", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Hvelvets rotsertifikat utløper om %1$d dag(er). Forny det før den tid. Fornyelsen signerer hver krypteringssuite på nytt.", + "Compromise recovery aborted": "Gjenoppretting etter kompromittering avbrutt", + "Key rotation ended by a compromise revoke": "Nøkkelrotasjon avsluttet av en tilbakekalling på grunn av kompromittering", + "Encryption suite revoke refused": "Tilbakekalling av krypteringssuite avvist", + "Master password proof refused": "Bevis for hovedpassord avvist", + "Your current master password": "Ditt nåværende hovedpassord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nødkontakt hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet den. Sjekk hvem som spurte før du legger til noen igjen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nødkontakter hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet dem. Sjekk hvem som spurte før du legger til noen igjen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Disse nødkontaktene ble ikke overført til den nye nøkkelen din. Nødtilgangen deres ble fjernet. Legg dem til igjen under Nødtilgang hvis du fortsatt vil ha dem.", + "Renew root certificate": "Forny rotsertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dette oppretter et nytt rot- og mellomsertifikat. Hver aktiv krypteringspakke signeres på nytt. Dette kan ikke angres.", + "Renew root": "Forny rot", + "Root renewed. {n} encryption suites signed again.": "Rot fornyet. {n} krypteringspakker signert på nytt.", + "Could not renew the root certificate.": "Kunne ikke fornye rotsertifikatet.", + "Lease policy for this application": "Leiepolicy for denne applikasjonen", + "In force now: {default} seconds by default, {max} seconds at most.": "Gjelder nå: {default} sekunder som standard, høyst {max} sekunder.", + "Leases are not renewable": "Leieavtaler kan ikke fornyes", + "Lease policy saved.": "Leiepolicy lagret.", + "Leave a field empty to use the instance value.": "La et felt stå tomt for å bruke instansens verdi.", + "Instance value: {value}": "Instansens verdi: {value}", + "Renewal": "Fornyelse", + "Use the instance value ({value})": "Bruk instansens verdi ({value})", + "Allowed": "Tillatt", + "Not allowed": "Ikke tillatt", + "Save lease policy": "Lagre leiepolicy", + "Only an administrator can change this policy.": "Bare en administrator kan endre denne policyen.", + "Could not save the lease policy.": "Kunne ikke lagre leiepolicyen.", + "{member} got access from {confirmer}.": "{member} fikk tilgang fra {confirmer}.", + "Automatically confirm new team folder members": "Bekreft nye teammappemedlemmer automatisk", + "Gave %n new member access to a team folder.": "%n nytt medlem fikk tilgang til en teammappe.", + "Gave %n new members access to a team folder.": "%n nye medlemmer fikk tilgang til en teammappe.", + "Give new team folder members access without waiting for the folder owner.": "Gi nye teammappemedlemmer tilgang uten å vente på mappens eier.", + "New team folder members": "Nye teammappemedlemmer", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Eieren eller et medlem med skrivetilgang bekrefter dem fra sitt åpne hvelv. Keepiq dekrypterer aldri på serveren.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Venter på at et medlem med skrivetilgang åpner Keepiq. Du kan også dele nå.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del av kompromitteringsresponsen mislyktes ({failed} trinn). Sjekk serverloggen, og tilbakekall deretter suiten på nytt for å fullføre.", + "This also revoked suite {suite} and ended key migration {migration}.": "Dette tilbakekalte også suite {suite} og avsluttet nøkkelmigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakter.", + "A suite revoked as compromised cannot be reinstated.": "En suite som er tilbakekalt som kompromittert, kan ikke gjenopprettes.", + "Archives to keep": "Arkiver som skal beholdes", + "Back up every vault automatically": "Sikkerhetskopier hvert hvelv automatisk", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Sikkerhetskopier hvert hvelv etter en plan. Arkivene inneholder bare kryptert tekst og gjenopprettes med occ.", + "Back up now": "Sikkerhetskopier nå", + "Backup public key (PEM, optional)": "Offentlig sikkerhetskopinøkkel (PEM, valgfri)", + "Backup requested for the next cron run": "Sikkerhetskopi bestilt til neste cron-kjøring", + "Encrypted": "Kryptert", + "Every (hours)": "Hver (timer)", + "Last backup {when} failed: {error}": "Siste sikkerhetskopi {when} mislyktes: {error}", + "Last backup {when} succeeded.": "Siste sikkerhetskopi {when} lyktes.", + "No archives yet.": "Ingen arkiver ennå.", + "Size": "Størrelse", + "Vault backups": "Hvelvsikkerhetskopier", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nøkkel krypteres hvert arkiv til den. Oppbevar den private nøkkelen utenfor denne serveren: du trenger den for å kontrollere eller gjenopprette.", + "Written": "Skrevet", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n bruker i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n brukere i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Reservekoder teller ikke. Hvis brukerne dine logger på via en identitetsleverandør med egen andre faktor, utelat gruppene deres.", + "Block personal vault export": "Blokker eksport av personlig hvelv", + "Keep work logins in team folders": "Hold jobbpålogginger i teammapper", + "Move to a team folder": "Flytt til en teammappe", + "Not in a team folder": "Ikke i en teammappe", + "Only for these groups (empty is everyone)": "Bare for disse gruppene (tom betyr alle)", + "Require two-factor login before the vault opens": "Krev totrinnspålogging før hvelvet åpnes", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler for hvert hvelv. Hver regel gjelder alle, eller bare gruppene du velger.", + "Secret types that belong in a team folder": "Hemmelighetstyper som hører hjemme i en teammappe", + "Set up two-factor login": "Sett opp totrinnspålogging", + "Team folder you can write to": "Teammappe du kan skrive i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Brukere kan ikke laste ned en sikkerhetskopi, CSV eller overføringsfil. Den personlige datapakken er fortsatt tilgjengelig.", + "Users cannot save these secret types in a personal folder.": "Brukere kan ikke lagre disse hemmelighetstypene i en personlig mappe.", + "Vault policies": "Hvelvregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organisasjonen din tillater ikke eksport av ditt personlige hvelv. Din personlige datapakke i innstillingene er fortsatt tilgjengelig.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organisasjonen din holder disse hemmelighetene i en teammappe. Flytt hver enkelt til en teammappe.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organisasjonen din holder denne typen hemmelighet i en teammappe. Velg en av teammappene dine, eller en du kan skrive i.", + "Your organisation requires two-factor login before you can open your vault.": "Organisasjonen din krever totrinnspålogging før du kan åpne hvelvet ditt.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Brukerne velger hvor lenge utvidelsen forblir låst opp ved inaktivitet. Du angir det lengste de kan velge.", + "Longest idle time before the extension locks": "Lengste inaktive tid før utvidelsen låses", + "1 minute": "1 minutt", + "5 minutes": "5 minutter", + "15 minutes": "15 minutter", + "1 hour": "1 time", + "4 hours": "4 timer", + "Connector": "Kobling", + "Directory (tenant) ID": "Katalog-ID (leietaker)", + "Application (client) ID": "Program-ID (klient)", + "Data collection rule immutable ID": "Uforanderlig ID for datainnsamlingsregelen", + "Stream name": "Strømnavn", + "Splunk index (optional)": "Splunk-indeks (valgfritt)", + "Sourcetype (optional)": "Sourcetype (valgfritt)", + "Leave blank to keep the current one": "La stå tomt for å beholde den nåværende", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF over syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Endepunkt for datainnsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemmelighet (kun skriving)", + "HEC token (write-only)": "HEC-token (kun skriving)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Videresend tillatte revisjonshendelser til Splunk, Microsoft Sentinel, en syslog-mottaker eller en webhook. Meldinger inneholder bare rensede metadata: ingen hemmelig verdi, navn, pålogging eller kryptert tekst forlater noen gang serveren.", + "%n change waiting to sync": "%n endring venter på synkronisering", + "%n changes waiting to sync": "%n endringer venter på synkronisering", + "Changes that could not sync": "Endringer som ikke kunne synkroniseres", + "Choose a version": "Velg en versjon", + "Copy value": "Kopier verdi", + "Deleted": "Slettet", + "Discard": "Forkast", + "Keep my offline change": "Behold min frakoblede endring", + "Keep the server version": "Behold serverversjonen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq er skrivebeskyttet frakoblet. Administratoren har ikke slått på frakoblet redigering.", + "Let users edit secrets offline": "La brukere redigere hemmeligheter frakoblet", + "Not synced yet": "Ikke synkronisert ennå", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Frakoblede endringer lagres på enheten, kryptert for brukeren, og synkroniseres ved neste tilkoblede opplåsing. Deling, mapper og vedlegg krever fortsatt tilkobling.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Frakoblet. Redigeringer, flyttinger og slettinger blir på denne enheten og synkroniseres når du er tilkoblet igjen. Deling og vedlegg krever tilkobling.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Frakoblet. Endringene dine blir på denne enheten og synkroniseres når du er tilkoblet igjen. Sist synkronisert {when}.", + "Open my changes": "Åpne endringene mine", + "Sharing needs a connection": "Deling krever tilkobling", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Noen endret denne hemmeligheten på serveren etter at den frakoblede kopien din ble laget. Velg hvilken versjon du vil beholde.", + "Sync or discard your offline changes before you rotate your keys.": "Synkroniser eller forkast de frakoblede endringene før du bytter nøkler.", + "That password did not open your changes.": "Det passordet åpnet ikke endringene dine.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Det frakoblede øyeblikksbildet lagrer krypterte hemmeligheter (kan bare åpnes med nøkkelen avledet fra brukerens hovedpassord, akkurat som på serveren) og krypterer navn, URL-er og mappenavn i hvile. Frakoblet tilgang er skrivebeskyttet med mindre du tillater frakoblet redigering nedenfor. Slå dette av for enheter som aldri skal mellomlagre påloggingsdata; når det slås av, tømmes eksisterende hurtigbuffere ved neste innlasting.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den forrige hvelvkopien er borte, så disse endringene kan ikke åpnes.", + "The server version": "Serverversjonen", + "This secret changed while you were offline": "Denne hemmeligheten ble endret mens du var frakoblet", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du slettet denne hemmeligheten frakoblet, men den er endret på serveren siden. Velg hvilken versjon du vil beholde.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Nøklene dine ble endret på en annen enhet. Skriv inn ditt forrige hovedpassord for å synkronisere de frakoblede endringene, eller forkast dem.", + "Your offline change": "Din frakoblede endring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n nødkontakt hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet den. Sjekk hvem som spurte før du legger til noen igjen.", + "%n nødkontakter hadde en ventende tilgangsforespørsel da nøkkelrotasjonen fjernet dem. Sjekk hvem som spurte før du legger til noen igjen." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n element kan ikke gjengis i CXF og hoppes over.", + "%n elementer kan ikke gjengis i CXF og hoppes over." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n eldre versjon ble forkastet, fordi bare nyere historikk kan overføres.", + "%n eldre versjoner ble forkastet, fordi bare nyere historikk kan overføres." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopi av en hemmelighet må fortsatt krypteres og deles.", + "%n kopier av hemmeligheter må fortsatt krypteres og deles." + ], + "_%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 hemmelighet kunne ikke dekrypteres og er ikke med i denne eksporten.", + "%n hemmeligheter kunne ikke dekrypteres og er ikke med i denne eksporten." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n hemmelighet kunne ikke dekrypteres med den gamle nøkkelen din, så den ble ikke migrert.", + "%n hemmeligheter kunne ikke dekrypteres med den gamle nøkkelen din, så de ble ikke migrert." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n hemmelighet ble ikke migrert.", + "%n hemmeligheter ble ikke migrert." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n hemmelighet er fortsatt kryptert med den forrige nøkkelen din.", + "%n hemmeligheter er fortsatt kryptert med den forrige nøkkelen din." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n hemmelighet ble hoppet over fordi etterfølgeren ennå ikke har en kopi — legg etterfølgeren til i mappen, og kjør på nytt.", + "%n hemmeligheter ble hoppet over fordi etterfølgeren ennå ikke har en kopi — legg etterfølgeren til i mappen, og kjør på nytt." + ], + "_%n secret_::_%n secrets_": [ + "%n hemmelighet", + "%n hemmeligheter" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n bruker i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på.", + "%n brukere i omfanget har ennå ikke totrinnspålogging og kan ikke åpne hvelvet mens dette er på." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Fullfør likevel, og mist tilgangen til %n hemmelighet", + "Fullfør likevel, og mist tilgangen til %n hemmeligheter" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nytt medlem fikk tilgang til en teammappe.", + "%n nye medlemmer fikk tilgang til en teammappe." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Nøkkelrotasjon fullført. %n hemmelighet ble kryptert på nytt med den nye nøkkelen din.", + "Nøkkelrotasjon fullført. %n hemmeligheter ble kryptert på nytt med den nye nøkkelen din." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakt.", + "Tilbakekalling av den andre suiten slettet %n nødtilgangskontakter." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakt.", + "Tilbakekallingen av denne suiten fjernet %n nødtilgangskontakter." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "sett %n gang i lekkasjer", + "sett %n ganger i lekkasjer" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "delt med %n hemmelighet", + "delt med %n hemmeligheter" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Denne mappen inneholder %n hemmelighet direkte.", + "Denne mappen inneholder %n hemmeligheter direkte." + ], + "_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._": [ + "Nøkkelrotasjonen fjernet %n nødkontakt. Sjekk Nødtilgang og legg den til igjen hvis du fortsatt vil ha den.", + "Nøkkelrotasjonen fjernet %n nødkontakter. Sjekk Nødtilgang og legg dem til igjen hvis du fortsatt vil ha dem." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n endring venter på synkronisering", + "%n endringer venter på synkronisering" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Brukeren er fortsatt i gruppen {groups}, som er medlem av en teammappe. Fjern brukeren fra gruppen eller deaktiver kontoen.", + "Brukeren er fortsatt i gruppene {groups}, som er medlemmer av teammapper. Fjern brukeren fra gruppene eller deaktiver kontoen." + ], + "Allow approval from another device": "Tillat godkjenning fra en annen enhet", + "App": "App", + "Approve a new device": "Godkjenn en ny enhet", + "Approve from another device": "Godkjenn fra en annen enhet", + "Asked at": "Forespurt kl.", + "Check that the new device shows these words:": "Sjekk at den nye enheten viser disse ordene:", + "Denied. If you did not ask, end your other sessions:": "Avvist. Hvis du ikke ba om dette, avslutt de andre øktene dine:", + "Device": "Enhet", + "IP address": "IP-adresse", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "La brukere låse opp en ny nettleser ved å godkjenne den fra en enhet der Keepiq allerede er låst opp.", + "New device approval": "Godkjenning av nye enheter", + "Nextcloud security settings": "Sikkerhetsinnstillinger i Nextcloud", + "Only approve a device you are using right now.": "Godkjenn bare en enhet du bruker akkurat nå.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Åpne Keepiq på en enhet der den er låst opp, og godkjenn denne. Sjekk at den viser de samme ordene:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Enheten som godkjenner, forsegler opplåsingsnøkkelen til den nye enheten. Serveren sender den bare videre og kan ikke åpne den.", + "The master password is not right, or the request has ended.": "Hovedpassordet er feil, eller forespørselen er avsluttet.", + "The request expired. Ask again or use your master password.": "Forespørselen er utløpt. Spør på nytt eller bruk hovedpassordet ditt.", + "The request was denied.": "Forespørselen ble avvist.", + "Too many requests. Try again in an hour or use your master password.": "For mange forespørsler. Prøv igjen om en time eller bruk hovedpassordet ditt.", + "Unknown device": "Ukjent enhet", + "Web app": "Nettapp", + "A device": "En enhet", + "A new device asks to open your vault": "En ny enhet ber om å åpne hvelvet ditt", + "%s asks to be approved. Only approve a device you are using right now.": "%s ber om godkjenning. Godkjenn bare en enhet du bruker akkurat nå.", + "Access ends on (optional)": "Tilgang slutter den (valgfritt)", + "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.": "Keepiq-appene viser eller kopierer ikke passordet. Noen med teknisk kunnskap kan fortsatt lese det fra sin egen enhet. Bytt det når tilgangen deres slutter.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Denne hemmeligheten er kun til bruk. Logg inn via Keepiq-nettleserutvidelsen.", + "Until {date}": "Til {date}", + "Use only": "Kun bruk", + "Use only (can sign in, cannot view or copy)": "Kun bruk (kan logge inn, kan ikke se eller kopiere)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logge inn med denne påloggingen via Keepiq-nettleserutvidelsen. Eieren har valgt å ikke la deg se eller kopiere den.", + "Your access ends on {date}": "Tilgangen din slutter {date}", + "Your access to this secret has ended": "Tilgangen din til denne hemmeligheten er avsluttet", + "Your access to \"%s\" ends tomorrow": "Tilgangen din til «%s» slutter i morgen", + "Your access to \"%s\" has ended": "Tilgangen din til «%s» er avsluttet", + "%1$s no longer has access to \"%2$s\"": "%1$s har ikke lenger tilgang til «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunne se dette passordet. Bytt det hvis %1$s ikke lenger skal kjenne det.", + "%s could not view this password in Keepiq.": "%s kunne ikke se dette passordet i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} av {threshold} godkjenninger", + "a recovery officer": "en gjenopprettingsansvarlig", + "Account recovery": "Kontogjenoppretting", + "Approvals needed": "Godkjenninger som trengs", + "Ask {user} which words they see, by phone or in person. They must be:": "Spør {user} hvilke ord de ser, på telefon eller personlig. De må være:", + "Check again": "Sjekk igjen", + "Create the recovery key": "Opprett gjenopprettingsnøkkelen", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Opprett gjenopprettingsnøkkelen. Nettleseren din lager den og gir hver ansvarlig en kopi som bare de kan åpne.", + "Decline": "Avslå", + "Enrol in account recovery": "Meld deg på kontogjenoppretting", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Meld deg på så organisasjonen din kan hjelpe deg med å få hvelvet tilbake hvis du glemmer hovedpassordet.", + "Every user is enrolled": "Alle brukere er påmeldt", + "Finish the recovery in the browser you asked from.": "Fullfør gjenopprettingen i nettleseren du spurte fra.", + "Forgot your master password?": "Glemt hovedpassordet?", + "Hand the key over": "Overlever nøkkelen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "La brukere som har glemt hovedpassordet få hvelvet tilbake, godkjent av gjenopprettingsansvarlige du utnevner.", + "New master password": "Nytt hovedpassord", + "No one is asking to recover their account.": "Ingen ber om å gjenopprette kontoen sin.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen gjenopprettingsnøkkel ennå. En av de ansvarlige oppretter den i sine Keepiq-innstillinger.", + "Off": "Av", + "Officer {user} has no encryption set up yet.": "Den ansvarlige {user} har ikke satt opp kryptering ennå.", + "Officers (user IDs, separated by commas)": "Ansvarlige (bruker-ID-er, adskilt med komma)", + "Policy": "Retningslinje", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiser dette fingeravtrykket internt, så brukerne kan sjekke det før de melder seg på.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Gjenopprettet med hjelp fra {officer}. Bytt hvelvnøkkelen nå under Innstillinger, Sikkerhet: \"Hovedpassordet mitt er kompromittert\".", + "Recovery key fingerprint: {fingerprint}": "Gjenopprettingsnøkkelens fingeravtrykk: {fingerprint}", + "Recovery officer": "Gjenopprettingsansvarlig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Fjernede ansvarlige mister kopien sin nå, men kan ha åpnet den tidligere. Få en ansvarlig til å opprette en ny gjenopprettingsnøkkel.", + "Repeat the new master password": "Gjenta det nye hovedpassordet", + "Retire this recovery key": "Pensjoner denne gjenopprettingsnøkkelen", + "Set the new master password": "Angi det nye hovedpassordet", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Gjenopprettingssertifikatet er ikke utstedt av denne Keepiq. Ikke meld deg på, og si fra til administratoren.", + "The words match, approve": "Ordene stemmer, godkjenn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denne brukeren er påmeldt kontogjenoppretting. Gjenoppretting beholder hemmelighetene deres; tilbakekalling sletter påmeldingen.", + "Users may enrol": "Brukere kan melde seg på", + "Withdraw from account recovery": "Meld deg av kontogjenoppretting", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du er påmeldt kontogjenoppretting. Gjenopprettingsnøkkelens fingeravtrykk: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du er påmeldt. Hvis du glemmer hovedpassordet, kan organisasjonen din hjelpe deg med å få hvelvet tilbake.", + "Your key is back. Choose a new master password.": "Nøkkelen din er tilbake. Velg et nytt hovedpassord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "De gjenopprettingsansvarlige har fått beskjed. Les disse ordene for dem når de ringer eller møter deg:", + "You are now an account recovery officer": "Du er nå ansvarlig for kontogjenoppretting", + "%s asks to recover their account. Compare the words with them before you approve.": "%s ber om å gjenopprette kontoen sin. Sammenlign ordene med personen før du godkjenner.", + "A user": "En bruker", + "Your account recovery request was declined": "Forespørselen din om kontogjenoppretting ble avslått", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Kontogjenopprettingen din er klar. Åpne Keepiq i nettleseren du spurte fra.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ber om å låse opp en ny enhet én gang. Hovedpassordet forblir det samme.", + "Ask your organisation instead": "Spør organisasjonen din i stedet", + "The request ended. Ask again or use your master password.": "Forespørselen er avsluttet. Spør igjen eller bruk hovedpassordet ditt.", + "Added by {user}": "Lagt til av {user}", + "Editor": "Redaktør", + "Manager": "Administrator", + "Role of {member}": "Rollen til {member}", + "Team folders you manage": "Teammapper du administrerer", + "Viewer": "Leser", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopi av disse hemmelighetene, så de nye medlemmene har ikke fått dem ennå. Eieren kan dele dem: {names}", + "Admin areas": "Administrasjonsområder", + "Give a group only the parts of Keepiq administration it needs.": "Gi en gruppe bare de delene av Keepiq-administrasjonen den trenger.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Deleger ett eller flere områder til en gruppe på siden for administrasjonsrettigheter. Instansadministratorer har alle områder.", + "Open administration privileges": "Åpne administrasjonsrettigheter", + "Policies": "Retningslinjer", + "Applications and machine access": "Applikasjoner og maskintilgang", + "People and offboarding": "Personer og fratredelse", + "Audit and compliance": "Revisjon og samsvar", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versjon, sertifikatutsteder, vedlegg, frakoblet hurtigbuffer, lekkasjesjekk, hemmelighetstyper og sikkerhetskopier", + "master password, organisation password, vault policies, rotation, version history and trash": "hovedpassord, organisasjonspassord, hvelvets retningslinjer, rotasjon, versjonshistorikk og papirkurv", + "application queue, application requests and machine leases": "applikasjonskø, applikasjonsforespørsler og maskinleieavtaler", + "team offboarding, encryption suites and admin handover": "teamfratredelse, krypteringssuiter og overtakelse av administrator", + "audit log, compliance reports, SIEM export and honey alerts": "revisjonslogg, samsvarsrapporter, SIEM-eksport og lokkevarsler", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hvor mange versjoner av en hemmelighet som beholdes, hvor lenge, og hvor lenge slettede hemmeligheter blir i papirkurven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Grenser for krypterte vedlegg, håndhevet på serveren i lagrede krypterte byte.", + "Type the suite ID again to confirm": "Skriv inn pakke-ID-en på nytt for å bekrefte", + "This does not match the suite ID.": "Dette samsvarer ikke med pakke-ID-en.", + "Confirm with your master password": "Bekreft med hovedpassordet ditt", + "Confirm": "Bekreft", + "That master password is not right.": "Det hovedpassordet er ikke riktig.", + "You are sharing with someone new. Enter your master password to confirm.": "Du deler med en ny person. Skriv inn hovedpassordet ditt for å bekrefte.", + "Enter your master password to confirm this share.": "Skriv inn hovedpassordet ditt for å bekrefte denne delingen.", + "Enter your master password to confirm this delegation.": "Skriv inn hovedpassordet ditt for å bekrefte denne delegeringen.", + "Approve {member}": "Godkjenn {member}", + "Recipient": "Mottaker", + "No vault yet": "Har ikke hvelv ennå", + "No matching users": "Ingen samsvarende brukere", + "Partner organisations": "Partnerorganisasjoner", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Utveksle hemmeligheter med en annen Keepiq. Begge administratorene legger til hverandre og sammenligner rotfingeravtrykkene på telefon eller ansikt til ansikt før de lagrer.", + "Federation needs Nextcloud 33 or later.": "Føderasjon krever Nextcloud 33 eller nyere.", + "Your root fingerprint": "Ditt rotfingeravtrykk", + "No partners yet.": "Ingen partnere ennå.", + "Users here may share to this partner": "Brukere her kan dele med denne partneren", + "This partner may share to users here": "Denne partneren kan dele med brukere her", + "Partner address": "Partnerens adresse", + "Check partner": "Sjekk partner", + "Partner root fingerprint": "Partnerens rotfingeravtrykk", + "I compared this fingerprint with the partner's administrator": "Jeg har sammenlignet dette fingeravtrykket med partnerens administrator", + "Add partner": "Legg til partner", + "A secret from another organisation": "En hemmelighet fra en annen organisasjon", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delt \"%2$s\" med deg. Godta den under Mottatt fra andre organisasjoner.", + "Incoming from other organisations": "Mottatt fra andre organisasjoner", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisasjoner kan dele en hemmelighet med deg. Godta den for å beholde en skrivebeskyttet kopi i hvelvet ditt.", + "Nothing shared with you yet": "Ingenting delt med deg ennå", + "Secrets that people in partner organisations share with you appear here.": "Hemmeligheter som personer i partnerorganisasjoner deler med deg, vises her.", + "From {sender}": "Fra {sender}", + "Accept": "Godta", + "Open in vault": "Åpne i hvelvet", + "The other organisation did not hand over the secret. Try again later.": "Den andre organisasjonen overleverte ikke hemmeligheten. Prøv igjen senere.", + "Set up your vault before you accept a shared secret.": "Sett opp hvelvet ditt før du godtar en delt hemmelighet.", + "Something went wrong. Try again.": "Noe gikk galt. Prøv igjen.", + "Waiting for your answer": "Venter på svaret ditt", + "In your vault, read-only": "I hvelvet ditt, skrivebeskyttet", + "Withdrawn by the sender": "Trukket tilbake av avsenderen", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delte dette fra en annen organisasjon. Du kan lese det, men ikke endre eller dele det.", + "Someone": "Noen", + "Share with someone at another organisation": "Del med noen i en annen organisasjon", + "Their account at the other organisation": "Personens konto i den andre organisasjonen", + "Check account": "Sjekk konto", + "Certificate fingerprint of {account}": "Sertifikatets fingeravtrykk for {account}", + "Compare it with them by phone if you want to be sure.": "Sammenlign det med personen over telefon hvis du vil være sikker.", + "Shared. {account} can accept it in their own vault.": "Delt. {account} kan godta den i sitt eget hvelv.", + "The certificate could not be verified. Nothing was shared.": "Sertifikatet kunne ikke bekreftes. Ingenting ble delt.", + "That organisation is not one of your partners.": "Den organisasjonen er ikke en av partnerne dine.", + "No one with that account can receive secrets from you.": "Ingen med den kontoen kan motta hemmeligheter fra deg.", + "The other organisation did not answer. Try again later.": "Den andre organisasjonen svarte ikke. Prøv igjen senere.", + "This secret is already shared with that account.": "Denne hemmeligheten er allerede delt med den kontoen.", + "Other organisations": "Andre organisasjoner", + "Receive secrets from other organisations": "Motta hemmeligheter fra andre organisasjoner", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisasjoner kan da finne kontoen din og dele hemmeligheter med deg. Du godtar hver enkelt selv.", + "Shared": "Delt", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Satt på pause: mottakerens sertifikat eller partnerskapet er endret. Tilbakekall den, eller del på nytt.", + "Their organisation did not get the last change. Revoke it or share again.": "Mottakerens organisasjon fikk ikke den siste endringen. Tilbakekall den, eller del på nytt.", + "Being withdrawn": "Trekkes tilbake", + "Shared with another organisation": "Delt med en annen organisasjon", + "Change sent to another organisation": "Endring sendt til en annen organisasjon", + "Share with another organisation revoked": "Deling med en annen organisasjon tilbakekalt", + "Share with another organisation paused": "Deling med en annen organisasjon satt på pause", + "Another organisation did not get a change": "En annen organisasjon fikk ikke en endring", + "Secret received from another organisation": "Hemmelighet mottatt fra en annen organisasjon", + "Secret from another organisation accepted": "Hemmelighet fra en annen organisasjon godtatt", + "Secret from another organisation declined": "Hemmelighet fra en annen organisasjon avslått", + "Copy from another organisation updated": "Kopi fra en annen organisasjon oppdatert", + "Copy from another organisation removed": "Kopi fra en annen organisasjon fjernet", + "Declined: they removed their copy. Share again if they need it.": "Avslått: mottakeren fjernet kopien sin. Del på nytt hvis de trenger den.", + "Recipient at another organisation removed their copy": "Mottaker i en annen organisasjon fjernet kopien sin", + "Removed the user from %n team folder.": "Fjernet brukeren fra %n teammappe.", + "Removed the user from %n team folders.": "Fjernet brukeren fra %n teammapper.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Fjernet brukeren fra %n teammappe.", + "Fjernet brukeren fra %n teammapper." + ], + "A restored copy came from a share that has ended. It stays read-only.": "En gjenopprettet kopi kom fra en deling som er avsluttet. Den forblir skrivebeskyttet.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisasjonen som delte en gjenopprettet kopi, kunne ikke nås. Kopien forblir skrivebeskyttet og følger ikke endringene deres.", + "Recipient at another organisation restored their copy": "Mottaker i en annen organisasjon gjenopprettet kopien sin" }, "plurals": null } diff --git a/l10n/nl.js b/l10n/nl.js index 21d00b618..397187069 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1187,7 +1187,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Gedeeld met groepen", + "Not shared with any group yet.": "Nog met geen enkele groep gedeeld.", + "Revoke the share with {group}": "Deling met {group} intrekken", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Gedeeld met {group}: {received} leden hebben het ontvangen, {skipped} niet omdat ze nog geen versleuteling hebben ingesteld.", + "Search groups": "Groepen zoeken", + "Failed to share": "Delen mislukt", + "Columns": "Kolommen", + "Column {number}": "Kolom {number}", + "Map one column to Name. Every secret needs a name.": "Koppel één kolom aan Naam. Elk geheim heeft een naam nodig.", + "Notes": "Notities", + "Do not import": "Niet importeren", + "Hide this value": "Deze waarde verbergen", + "Show this value": "Deze waarde tonen", + "Defaults": "Standaarden", + "New secrets start as this type, and your secret list opens in this view.": "Nieuwe geheimen beginnen als dit type, en je lijst met geheimen opent in deze weergave.", + "Default item type": "Standaard itemtype", + "Cards": "Kaarten", + "Table": "Tabel", + "Could not save your default": "Je standaard kon niet worden opgeslagen", + "Recently used": "Recent gebruikt", + "Opened": "Geopend", + "You have not opened any secrets yet": "Je hebt nog geen geheimen geopend", + "Could not delete the item type.": "Het itemtype kon niet worden verwijderd.", + "Could not load the item types.": "De itemtypes konden niet worden geladen.", + "Could not save the item type.": "Het itemtype kon niet worden opgeslagen.", + "Delete item type": "Itemtype verwijderen", + "Edit item type": "Itemtype bewerken", + "Fields": "Velden", + "Fields: {count}": "Velden: {count}", + "Hidden": "Verborgen", + "Item types": "Itemtypes", + "Move up": "Omhoog", + "New item type": "Nieuw itemtype", + "No item types defined yet.": "Nog geen itemtypes aangemaakt.", + "Required": "Verplicht", + "Text": "Tekst", + "This field is required": "Dit veld is verplicht", + "Web address": "Webadres", + "{label} (required)": "{label} (verplicht)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "“{name}” verwijderen? Geheimen van dit type blijven leesbaar en worden Inlog-items.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Itemtypes die je hier aanmaakt verschijnen bij iedereen in het venster Nieuw geheim, met de velden die je kiest.", + "Secret moved to the trash": "Geheim naar de prullenbak verplaatst", + "Secret restored from the trash": "Geheim uit de prullenbak teruggezet", + "Secret deleted for good": "Geheim definitief verwijderd", + "Secret archived": "Geheim gearchiveerd", + "Secret unarchived": "Geheim uit het archief gehaald", + "Unarchive": "Uit archief halen", + "Could not archive the secret": "Het geheim kon niet worden gearchiveerd", + "Could not unarchive the secret": "Het geheim kon niet uit het archief worden gehaald", + "Archive {count} secrets": "{count} geheimen archiveren", + "Unarchive {count} secrets": "{count} geheimen uit het archief halen", + "Restore {count} secrets": "{count} geheimen terugzetten", + "Delete {count} secrets for good": "{count} geheimen definitief verwijderen", + "Done for {ok} of {total} secrets": "Klaar voor {ok} van {total} geheimen", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Gearchiveerde geheimen verdwijnen uit de kluislijst, het zoeken, automatisch invullen en het gezondheidsrapport. Ze blijven gedeeld. Je vindt ze onder Archief.", + "These secrets come back to the vault list, search and autofill.": "Deze geheimen komen terug in de kluislijst, het zoeken en automatisch invullen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Deze geheimen komen terug in de kluislijst. Hun oude delingen komen niet terug, deel ze dus opnieuw waar nodig.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dit verwijdert de geheimen met hun bijlagen en versiegeschiedenis. Dit kan niet ongedaan worden gemaakt.", + "Delete for good": "Definitief verwijderen", + "Trash": "Prullenbak", + "The trash is empty": "De prullenbak is leeg", + "No archived secrets": "Geen gearchiveerde geheimen", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Verwijderde geheimen wachten hier tot de bewaartermijn afloopt, daarna worden ze definitief verwijderd.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiveer een geheim vanuit het detailpaneel om het buiten de kluislijst, het zoeken en automatisch invullen te houden.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limieten voor versleutelde bijlagen (op de server afgedwongen in opgeslagen versleutelde bytes), bewaartermijn van de versiegeschiedenis en hoe lang verwijderde geheimen in de prullenbak blijven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagen dat een verwijderd geheim in de prullenbak blijft (1 tot 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dit verplaatst het geheim naar de prullenbak en beëindigt de delingen nu. Je kunt het uit de prullenbak terugzetten tot de bewaartermijn afloopt: 30 dagen, tenzij je beheerder dat heeft aangepast.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dit verplaatst {count} geheimen naar de prullenbak en beëindigt hun delingen nu. Je kunt ze uit de prullenbak terugzetten tot de bewaartermijn afloopt.", + "Remove {name} from favourites": "{name} uit favorieten halen", + "Add {name} to favourites": "{name} aan favorieten toevoegen", + "Could not change the favourite": "Kon de favoriet niet wijzigen", + "Remove from favourites": "Uit favorieten halen", + "Add to favourites": "Aan favorieten toevoegen", + "Tags": "Labels", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Labels zijn niet versleuteld. Serverbeheerders kunnen ze lezen, net als mapnamen.", + "Favourites": "Favorieten", + "Filter by tag": "Filteren op label", + "All tags": "Alle labels", + "Last used": "Laatst gebruikt", + "Tags for {count} secrets": "Labels voor {count} geheimen", + "Tag": "Label", + "Remove tag": "Label verwijderen", + "Add tag": "Label toevoegen", + "Could not change the tags. Try again.": "Kon de labels niet wijzigen. Probeer het opnieuw.", + "Could not approve the application. It is still in the queue.": "Kan de aanvraag niet goedkeuren. Deze staat nog in de wachtrij.", + "Could not reject the application. It is still in the queue.": "Kan de aanvraag niet afwijzen. Deze staat nog in de wachtrij.", + "Removed the user from {count} team folders.": "Gebruiker verwijderd uit {count} teammappen.", + "Approve a share": "Een deling goedkeuren", + "This approval link is incomplete. Open it again from the notification.": "Deze goedkeuringslink is onvolledig. Open hem opnieuw vanuit de melding.", + "Deny": "Weigeren", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} is lid geworden van een groep waarmee je een geheim deelt. Het geheim ook met hen delen?", + "{requester} asks you to share a secret with {user}.": "{requester} vraagt je een geheim te delen met {user}.", + "Shared. The recipient can now open the secret.": "Gedeeld. De ontvanger kan het geheim nu openen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "De ontvanger heeft Keepiq nog niet ingesteld, dus er is niets gedeeld. Probeer het opnieuw zodra dat gebeurd is.", + "Could not share the secret. Only its owner can approve this.": "Kan het geheim niet delen. Alleen de eigenaar kan dit goedkeuren.", + "Could not share the secret. Try again.": "Kan het geheim niet delen. Probeer het opnieuw.", + "Denied. Nothing was shared.": "Geweigerd. Er is niets gedeeld.", + "Could not deny the request. Try again.": "Kan het verzoek niet weigeren. Probeer het opnieuw.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vraagt je het geheim \"%2$s\" te delen met %3$s.", + "Expires on (optional)": "Verloopt op (optioneel)", + "Hand over to": "Overdragen aan", + "Choose a recipient": "Kies een ontvanger", + "Hand over temporarily": "Tijdelijk overdragen", + "Expiry rules": "Verloopregels", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stel in hoe lang wachtwoorden van één itemtype of in één map mogen bestaan, en wanneer je een herinnering krijgt. Als er meerdere datums gelden, telt de vroegste.", + "Delete rule": "Regel verwijderen", + "Set by your administrator": "Ingesteld door je beheerder", + "No expiry rules yet.": "Nog geen verloopregels.", + "Applies to": "Geldt voor", + "Item type": "Itemtype", + "Maximum age in days (empty for reminders only)": "Maximale leeftijd in dagen (leeg voor alleen herinneringen)", + "Remind me this many days before, comma separated": "Herinner me zoveel dagen van tevoren, gescheiden door komma's", + "Save rule": "Regel opslaan", + "An item type": "Een itemtype", + "A folder": "Een map", + "Folder {name}": "Map {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Verloopt na {days} dagen", + "Reminders {days} days before": "Herinneringen {days} dagen van tevoren", + "Could not save the expiry rule.": "Kan de verloopregel niet opslaan.", + "Could not delete the expiry rule.": "Kan de verloopregel niet verwijderen.", + "All statuses": "Alle statussen", + "Compromised": "Gecompromitteerd", + "Could not load the members.": "De leden konden niet worden geladen.", + "Emergency contact": "Noodcontact", + "Leaving user": "Vertrekkende gebruiker", + "No": "Nee", + "No users match this filter.": "Geen gebruikers voor dit filter.", + "Not set up": "Niet ingesteld", + "Revoke suite": "Suite intrekken", + "Revoked": "Ingetrokken", + "Search users": "Gebruikers zoeken", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Zie welke gebruikers een kluis hebben ingesteld. Start offboarding of trek een suite in vanuit een rij.", + "Successor": "Opvolger", + "Team folders": "Teammappen", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "De gebruiker zit nog in groep {groups}, die lid is van een teammap. Haal de gebruiker uit de groep of schakel het account uit.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "De gebruiker zit nog in groepen {groups}, die lid zijn van teammappen. Haal de gebruiker uit de groepen of schakel het account uit.", + "Vault status": "Kluisstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Een CXF-export is ONVERSLEUTELD. Elk wachtwoord en elke login is leesbaar als platte tekst in het gedownloade bestand. Bewaar het veilig en verwijder het direct na gebruik.", + "Root certificate expiring soon": "Rootcertificaat verloopt binnenkort", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Het rootcertificaat van de kluis verloopt over %1$d dag(en). Vernieuw het vóór die tijd. Vernieuwen ondertekent elke versleutelingssuite opnieuw.", + "Compromise recovery aborted": "Herstel na compromittering afgebroken", + "Key rotation ended by a compromise revoke": "Sleutelrotatie beëindigd door een intrekking wegens compromittering", + "Encryption suite revoke refused": "Intrekken van versleutelingssuite geweigerd", + "Master password proof refused": "Bewijs van hoofdwachtwoord geweigerd", + "Your current master password": "Je huidige hoofdwachtwoord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n noodcontact had een openstaand toegangsverzoek toen je sleutelrotatie het verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n noodcontacten hadden een openstaand toegangsverzoek toen je sleutelrotatie ze verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Deze noodcontacten zijn niet meegenomen naar je nieuwe sleutel. Hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.", + "Renew root certificate": "Rootcertificaat vernieuwen", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dit maakt een nieuw root- en tussencertificaat. Elke actieve versleutelingssuite wordt opnieuw ondertekend. Je kunt dit niet ongedaan maken.", + "Renew root": "Root vernieuwen", + "Root renewed. {n} encryption suites signed again.": "Root vernieuwd. {n} versleutelingssuites opnieuw ondertekend.", + "Could not renew the root certificate.": "Het rootcertificaat kon niet worden vernieuwd.", + "Lease policy for this application": "Leasebeleid voor deze applicatie", + "In force now: {default} seconds by default, {max} seconds at most.": "Nu van kracht: standaard {default} seconden, hoogstens {max} seconden.", + "Leases are not renewable": "Leases zijn niet te verlengen", + "Lease policy saved.": "Leasebeleid opgeslagen.", + "Leave a field empty to use the instance value.": "Laat een veld leeg om de waarde van de instantie te gebruiken.", + "Instance value: {value}": "Waarde van de instantie: {value}", + "Renewal": "Verlenging", + "Use the instance value ({value})": "Waarde van de instantie gebruiken ({value})", + "Allowed": "Toegestaan", + "Not allowed": "Niet toegestaan", + "Save lease policy": "Leasebeleid opslaan", + "Only an administrator can change this policy.": "Alleen een beheerder kan dit beleid wijzigen.", + "Could not save the lease policy.": "Het leasebeleid kon niet worden opgeslagen.", + "{member} got access from {confirmer}.": "{member} kreeg toegang van {confirmer}.", + "Automatically confirm new team folder members": "Nieuwe teammapleden automatisch bevestigen", + "Gave %n new member access to a team folder.": "%n nieuw lid heeft toegang gekregen tot een teammap.", + "Gave %n new members access to a team folder.": "%n nieuwe leden hebben toegang gekregen tot een teammap.", + "Give new team folder members access without waiting for the folder owner.": "Geef nieuwe teammapleden toegang zonder te wachten op de eigenaar van de map.", + "New team folder members": "Nieuwe teammapleden", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "De eigenaar of een lid met schrijfrechten bevestigt hen vanuit de geopende kluis. Keepiq ontsleutelt nooit op de server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Wacht op een lid met schrijfrechten dat Keepiq opent. Je kunt ook nu delen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Een deel van de compromitteringsreactie is mislukt ({failed} stap(pen)). Controleer het serverlogboek en trek de suite daarna opnieuw geforceerd in om het af te ronden.", + "This also revoked suite {suite} and ended key migration {migration}.": "Hiermee is ook suite {suite} ingetrokken en sleutelmigratie {migration} beëindigd.", + "Revoking the second suite deleted %n emergency-access contact.": "Het intrekken van de tweede suite verwijderde %n noodtoegangscontact.", + "Revoking the second suite deleted %n emergency-access contacts.": "Het intrekken van de tweede suite verwijderde %n noodtoegangscontacten.", + "A suite revoked as compromised cannot be reinstated.": "Een suite die als gecompromitteerd is ingetrokken, kan niet worden hersteld.", + "Archives to keep": "Te bewaren archieven", + "Back up every vault automatically": "Elke kluis automatisch back-uppen", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Maak volgens schema een back-up van elke kluis. Archieven bevatten alleen versleutelde gegevens en worden met occ teruggezet.", + "Back up now": "Nu back-uppen", + "Backup public key (PEM, optional)": "Openbare back-upsleutel (PEM, optioneel)", + "Backup requested for the next cron run": "Back-up aangevraagd voor de volgende cronrun", + "Encrypted": "Versleuteld", + "Every (hours)": "Elke (uren)", + "Last backup {when} failed: {error}": "Laatste back-up {when} mislukt: {error}", + "Last backup {when} succeeded.": "Laatste back-up {when} gelukt.", + "No archives yet.": "Nog geen archieven.", + "Size": "Grootte", + "Vault backups": "Kluisback-ups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Met een sleutel wordt elk archief daarmee versleuteld. Bewaar de privésleutel buiten deze server: je hebt hem nodig om te controleren of terug te zetten.", + "Written": "Geschreven", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n gebruiker binnen het bereik heeft nog geen tweestapsaanmelding en kan de kluis niet openen zolang dit aan staat.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n gebruikers binnen het bereik hebben nog geen tweestapsaanmelding en kunnen de kluis niet openen zolang dit aan staat.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Back-upcodes tellen niet mee. Melden je gebruikers zich aan via een identiteitsprovider met een eigen tweede factor, laat hun groepen dan weg.", + "Block personal vault export": "Export van persoonlijke kluis blokkeren", + "Keep work logins in team folders": "Werkinloggegevens in teammappen bewaren", + "Move to a team folder": "Naar een teammap verplaatsen", + "Not in a team folder": "Niet in een teammap", + "Only for these groups (empty is everyone)": "Alleen voor deze groepen (leeg is iedereen)", + "Require two-factor login before the vault opens": "Tweestapsaanmelding vereisen voordat de kluis opent", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regels voor elke kluis. Elke regel geldt voor iedereen, of alleen voor de groepen die je kiest.", + "Secret types that belong in a team folder": "Geheimtypen die in een teammap horen", + "Set up two-factor login": "Tweestapsaanmelding instellen", + "Team folder you can write to": "Teammap waarin je kunt schrijven", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Gebruikers kunnen geen back-up, CSV of overdrachtsbestand downloaden. Hun persoonlijke gegevenspakket blijft beschikbaar.", + "Users cannot save these secret types in a personal folder.": "Gebruikers kunnen deze geheimtypen niet in een persoonlijke map opslaan.", + "Vault policies": "Kluisbeleid", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Je organisatie staat het exporteren van je persoonlijke kluis niet toe. Je persoonlijke gegevenspakket in je instellingen blijft beschikbaar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Je organisatie bewaart deze geheimen in een teammap. Verplaats ze stuk voor stuk naar een teammap.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Je organisatie bewaart dit type geheim in een teammap. Kies een van je teammappen, of een waarin je kunt schrijven.", + "Your organisation requires two-factor login before you can open your vault.": "Je organisatie vereist tweestapsaanmelding voordat je je kluis kunt openen.", + "Allow passphrases made of words": "Wachtzinnen van woorden toestaan", + "Capitalise each word": "Elk woord met een hoofdletter", + "Include a number": "Een cijfer toevoegen", + "Kind of key": "Soort sleutel", + "Number of words": "Aantal woorden", + "Passphrase": "Wachtzin", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Stel een minimale kwaliteit in voor geheime waarden. De browser controleert die vóór het versleutelen, zodat de server nooit een waarde ziet.", + "Separator": "Scheidingsteken", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Gebruikers kiezen hoe lang de extensie ontgrendeld blijft als ze niets doen. U stelt de langste tijd in die ze mogen kiezen.", + "Longest idle time before the extension locks": "Langste inactieve tijd voordat de extensie vergrendelt", + "1 minute": "1 minuut", + "5 minutes": "5 minuten", + "15 minutes": "15 minuten", + "1 hour": "1 uur", + "4 hours": "4 uur", + "Connector": "Koppeling", + "Directory (tenant) ID": "Directory-ID (tenant)", + "Application (client) ID": "Applicatie-ID (client)", + "Data collection rule immutable ID": "Onveranderlijke ID van de gegevensverzamelingsregel", + "Stream name": "Streamnaam", + "Splunk index (optional)": "Splunk-index (optioneel)", + "Sourcetype (optional)": "Sourcetype (optioneel)", + "Leave blank to keep the current one": "Laat leeg om de huidige te houden", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Eindpunt voor gegevensverzameling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Clientgeheim (alleen schrijven)", + "HEC token (write-only)": "HEC-token (alleen schrijven)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Stuur toegestane auditgebeurtenissen door naar Splunk, Microsoft Sentinel, een syslog-ontvanger of een webhook. Berichten bevatten alleen opgeschoonde metadata: geen geheime waarde, naam, login of versleutelde tekst verlaat ooit de server.", + "%n change waiting to sync": "%n wijziging wacht op synchronisatie", + "%n changes waiting to sync": "%n wijzigingen wachten op synchronisatie", + "Changes that could not sync": "Wijzigingen die niet konden worden gesynchroniseerd", + "Choose a version": "Kies een versie", + "Copy value": "Waarde kopiëren", + "Deleted": "Verwijderd", + "Discard": "Weggooien", + "Keep my offline change": "Mijn offline wijziging behouden", + "Keep the server version": "De serverversie behouden", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq is offline alleen-lezen. Uw beheerder heeft offline bewerken niet aangezet.", + "Let users edit secrets offline": "Gebruikers geheimen offline laten bewerken", + "Not synced yet": "Nog niet gesynchroniseerd", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline wijzigingen blijven op het apparaat, versleuteld voor de gebruiker, en worden gesynchroniseerd bij de volgende online ontgrendeling. Delen, mappen en bijlagen hebben nog steeds een verbinding nodig.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Bewerkingen, verplaatsingen en verwijderingen blijven op dit apparaat en worden gesynchroniseerd zodra u weer online bent. Delen en bijlagen hebben een verbinding nodig.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Uw wijzigingen blijven op dit apparaat en worden gesynchroniseerd zodra u weer online bent. Laatst gesynchroniseerd {when}.", + "Open my changes": "Mijn wijzigingen openen", + "Sharing needs a connection": "Delen heeft een verbinding nodig", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Iemand heeft dit geheim op de server gewijzigd nadat uw offline kopie was gemaakt. Kies welke versie u wilt behouden.", + "Sync or discard your offline changes before you rotate your keys.": "Synchroniseer of verwijder uw offline wijzigingen voordat u uw sleutels vervangt.", + "That password did not open your changes.": "Dat wachtwoord opende uw wijzigingen niet.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "De offline momentopname bewaart versleutelde geheimen (alleen te openen met de sleutel die is afgeleid van het hoofdwachtwoord van de gebruiker, precies zoals op de server) en versleutelt namen, URL's en mapnamen in opslag. Offline toegang is alleen-lezen, tenzij u hieronder offline bewerken toestaat. Zet dit uit voor apparaten die nooit inloggegevens mogen bewaren; uitzetten wist bestaande caches bij de volgende keer laden.", + "The previous vault copy is gone, so these changes cannot be opened.": "De vorige kluiskopie is weg, dus deze wijzigingen kunnen niet worden geopend.", + "The server version": "De serverversie", + "This secret changed while you were offline": "Dit geheim is gewijzigd terwijl u offline was", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "U heeft dit geheim offline verwijderd, maar het is sindsdien op de server gewijzigd. Kies welke versie u wilt behouden.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Uw sleutels zijn op een ander apparaat vervangen. Voer uw vorige hoofdwachtwoord in om de offline wijzigingen te synchroniseren, of gooi ze weg.", + "Your offline change": "Uw offline wijziging", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n noodcontact had een openstaand toegangsverzoek toen je sleutelrotatie het verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.","%n noodcontacten hadden een openstaand toegangsverzoek toen je sleutelrotatie ze verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n item kan niet in CXF worden weergegeven en wordt overgeslagen.","%n items kunnen niet in CXF worden weergegeven en worden overgeslagen."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n oudere versie is verwijderd omdat alleen recente geschiedenis kan worden meegenomen.","%n oudere versies zijn verwijderd omdat alleen recente geschiedenis kan worden meegenomen."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopie van een geheim moet nog worden versleuteld en gedeeld.","%n kopieën van geheimen moeten nog worden versleuteld en gedeeld."], + "_%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 geheim kon niet worden ontsleuteld en zit niet in deze export.","%n geheimen konden niet worden ontsleuteld en zitten niet in deze export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n geheim kon niet worden ontsleuteld met je oude sleutel en is dus niet gemigreerd.","%n geheimen konden niet worden ontsleuteld met je oude sleutel en zijn dus niet gemigreerd."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n geheim is niet gemigreerd.","%n geheimen zijn niet gemigreerd."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n geheim is nog versleuteld met je vorige sleutel.","%n geheimen zijn nog versleuteld met je vorige sleutel."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n geheim is overgeslagen omdat de opvolger nog geen kopie heeft — voeg de opvolger toe aan de map en voer dit opnieuw uit.","%n geheimen zijn overgeslagen omdat de opvolger nog geen kopie heeft — voeg de opvolger toe aan de map en voer dit opnieuw uit."], + "_%n secret_::_%n secrets_": ["%n geheim","%n geheimen"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n gebruiker binnen het bereik heeft nog geen tweestapsaanmelding en kan de kluis niet openen zolang dit aan staat.","%n gebruikers binnen het bereik hebben nog geen tweestapsaanmelding en kunnen de kluis niet openen zolang dit aan staat."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Toch afronden en de toegang tot %n geheim verliezen","Toch afronden en de toegang tot %n geheimen verliezen"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nieuw lid heeft toegang gekregen tot een teammap.","%n nieuwe leden hebben toegang gekregen tot een teammap."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Sleutelrotatie afgerond. %n geheim is opnieuw versleuteld met je nieuwe sleutel.","Sleutelrotatie afgerond. %n geheimen zijn opnieuw versleuteld met je nieuwe sleutel."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Het intrekken van de tweede suite verwijderde %n noodtoegangscontact.","Het intrekken van de tweede suite verwijderde %n noodtoegangscontacten."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Het intrekken van deze suite verwijderde %n noodtoegangscontact.","Het intrekken van deze suite verwijderde %n noodtoegangscontacten."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["%n keer gezien in datalekken","%n keer gezien in datalekken"], + "_shared with %n secret_::_shared with %n secrets_": ["gedeeld met %n geheim","gedeeld met %n geheimen"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Deze map bevat direct %n geheim.","Deze map bevat direct %n geheimen."], + "_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._": ["Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.","Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n wijziging wacht op synchronisatie","%n wijzigingen wachten op synchronisatie"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["De gebruiker zit nog in groep {groups}, die lid is van een teammap. Haal de gebruiker uit de groep of schakel het account uit.","De gebruiker zit nog in groepen {groups}, die lid zijn van teammappen. Haal de gebruiker uit de groepen of schakel het account uit."], + "Allow approval from another device": "Goedkeuring vanaf een ander apparaat toestaan", + "App": "App", + "Approve a new device": "Een nieuw apparaat goedkeuren", + "Approve from another device": "Goedkeuren vanaf een ander apparaat", + "Asked at": "Gevraagd om", + "Check that the new device shows these words:": "Controleer of het nieuwe apparaat deze woorden toont:", + "Denied. If you did not ask, end your other sessions:": "Geweigerd. Heb je dit niet gevraagd, beëindig dan je andere sessies:", + "Device": "Apparaat", + "IP address": "IP-adres", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Laat gebruikers een nieuwe browser ontgrendelen door die goed te keuren vanaf een apparaat waarop Keepiq al ontgrendeld is.", + "New device approval": "Goedkeuring nieuw apparaat", + "Nextcloud security settings": "Nextcloud-beveiligingsinstellingen", + "Only approve a device you are using right now.": "Keur alleen een apparaat goed dat je nu zelf gebruikt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Open Keepiq op een apparaat waarop het ontgrendeld is en keur dit apparaat goed. Controleer of het dezelfde woorden toont:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Het goedkeurende apparaat verzegelt de ontgrendelsleutel voor het nieuwe apparaat. De server geeft hem alleen door en kan hem niet openen.", + "The master password is not right, or the request has ended.": "Het hoofdwachtwoord klopt niet, of het verzoek is beëindigd.", + "The request expired. Ask again or use your master password.": "Het verzoek is verlopen. Vraag het opnieuw of gebruik je hoofdwachtwoord.", + "The request was denied.": "Het verzoek is geweigerd.", + "Too many requests. Try again in an hour or use your master password.": "Te veel verzoeken. Probeer het over een uur opnieuw of gebruik je hoofdwachtwoord.", + "Unknown device": "Onbekend apparaat", + "Web app": "Webapp", + "A device": "Een apparaat", + "A new device asks to open your vault": "Een nieuw apparaat vraagt om je kluis te openen", + "%s asks to be approved. Only approve a device you are using right now.": "%s vraagt om goedkeuring. Keur alleen een apparaat goed dat je nu zelf gebruikt.", + "Access ends on (optional)": "Toegang eindigt op (optioneel)", + "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.": "De apps van Keepiq tonen of kopiëren het wachtwoord niet. Iemand met technische kennis kan het nog steeds op het eigen apparaat uitlezen. Wijzig het wanneer de toegang eindigt.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dit geheim is alleen te gebruiken. Log in via de Keepiq-browserextensie.", + "Until {date}": "Tot {date}", + "Use only": "Alleen gebruiken", + "Use only (can sign in, cannot view or copy)": "Alleen gebruiken (kan inloggen, niet bekijken of kopiëren)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Je kunt met deze login inloggen via de Keepiq-browserextensie. De eigenaar heeft ervoor gekozen dat je hem niet kunt bekijken of kopiëren.", + "Your access ends on {date}": "Je toegang eindigt op {date}", + "Your access to this secret has ended": "Je toegang tot dit geheim is beëindigd", + "Your access to \"%s\" ends tomorrow": "Je toegang tot \"%s\" eindigt morgen", + "Your access to \"%s\" has ended": "Je toegang tot \"%s\" is beëindigd", + "%1$s no longer has access to \"%2$s\"": "%1$s heeft geen toegang meer tot \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kon dit wachtwoord zien. Wijzig het als %1$s het niet meer mag weten.", + "%s could not view this password in Keepiq.": "%s kon dit wachtwoord niet bekijken in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} van {threshold} goedkeuringen", + "a recovery officer": "een herstelfunctionaris", + "Account recovery": "Accountherstel", + "Approvals needed": "Benodigde goedkeuringen", + "Ask {user} which words they see, by phone or in person. They must be:": "Vraag {user} telefonisch of persoonlijk welke woorden die ziet. Het moeten deze zijn:", + "Check again": "Opnieuw controleren", + "Create the recovery key": "Herstelsleutel aanmaken", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Maak de herstelsleutel aan. Je browser maakt hem en geeft elke functionaris een kopie die alleen die persoon kan openen.", + "Decline": "Weigeren", + "Enrol in account recovery": "Aanmelden voor accountherstel", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Meld je aan, zodat je organisatie je kan helpen je kluis terug te krijgen als je je hoofdwachtwoord vergeet.", + "Every user is enrolled": "Iedere gebruiker is aangemeld", + "Finish the recovery in the browser you asked from.": "Rond het herstel af in de browser waarin je het aanvroeg.", + "Forgot your master password?": "Hoofdwachtwoord vergeten?", + "Hand the key over": "Sleutel overdragen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Laat gebruikers die hun hoofdwachtwoord vergeten zijn hun kluis terugkrijgen, goedgekeurd door herstelfunctionarissen die je aanwijst.", + "New master password": "Nieuw hoofdwachtwoord", + "No one is asking to recover their account.": "Niemand vraagt om herstel van zijn account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nog geen herstelsleutel. Een van de functionarissen maakt hem aan in de eigen Keepiq-instellingen.", + "Off": "Uit", + "Officer {user} has no encryption set up yet.": "Functionaris {user} heeft nog geen versleuteling ingesteld.", + "Officers (user IDs, separated by commas)": "Functionarissen (gebruikers-ID's, gescheiden door komma's)", + "Policy": "Beleid", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiceer deze vingerafdruk intern, zodat gebruikers hem kunnen controleren voordat ze zich aanmelden.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Hersteld met hulp van {officer}. Vervang nu je kluissleutel via Instellingen, Beveiliging: \"Mijn hoofdwachtwoord is gelekt\".", + "Recovery key fingerprint: {fingerprint}": "Vingerafdruk herstelsleutel: {fingerprint}", + "Recovery officer": "Herstelfunctionaris", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Verwijderde functionarissen verliezen nu hun kopie, maar kunnen die eerder geopend hebben. Laat een functionaris een nieuwe herstelsleutel aanmaken.", + "Repeat the new master password": "Herhaal het nieuwe hoofdwachtwoord", + "Retire this recovery key": "Deze herstelsleutel buiten gebruik stellen", + "Set the new master password": "Nieuw hoofdwachtwoord instellen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Het herstelcertificaat is niet uitgegeven door deze Keepiq. Meld je niet aan en waarschuw je beheerder.", + "The words match, approve": "De woorden kloppen, goedkeuren", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Deze gebruiker is aangemeld voor accountherstel. Herstellen bewaart de geheimen; intrekken verwijdert de aanmelding.", + "Users may enrol": "Gebruikers mogen zich aanmelden", + "Withdraw from account recovery": "Afmelden voor accountherstel", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Je bent aangemeld voor accountherstel. Vingerafdruk herstelsleutel: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Je bent aangemeld. Als je je hoofdwachtwoord vergeet, kan je organisatie je helpen je kluis terug te krijgen.", + "Your key is back. Choose a new master password.": "Je sleutel is terug. Kies een nieuw hoofdwachtwoord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Je herstelfunctionarissen zijn ingelicht. Lees ze deze woorden voor als ze je bellen of spreken:", + "You are now an account recovery officer": "Je bent nu herstelfunctionaris voor accounts", + "%s asks to recover their account. Compare the words with them before you approve.": "%s vraagt om accountherstel. Vergelijk de woorden met die persoon voordat je goedkeurt.", + "A user": "Een gebruiker", + "Your account recovery request was declined": "Je verzoek om accountherstel is geweigerd", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Je accountherstel staat klaar. Open Keepiq in de browser waarin je het aanvroeg.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} vraagt om een nieuw apparaat eenmalig te ontgrendelen. Het hoofdwachtwoord blijft hetzelfde.", + "Ask your organisation instead": "Vraag het liever aan je organisatie", + "The request ended. Ask again or use your master password.": "Het verzoek is beëindigd. Vraag het opnieuw of gebruik je hoofdwachtwoord.", + "Added by {user}": "Toegevoegd door {user}", + "Editor": "Bewerker", + "Manager": "Beheerder", + "Role of {member}": "Rol van {member}", + "Team folders you manage": "Teammappen die je beheert", + "Viewer": "Lezer", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Je hebt geen kopie van deze geheimen, dus de nieuwe leden hebben ze nog niet gekregen. De eigenaar kan ze delen: {names}", + "Admin areas": "Beheergebieden", + "Give a group only the parts of Keepiq administration it needs.": "Geef een groep alleen de delen van het Keepiq-beheer die ze nodig heeft.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegeer een of meer gebieden aan een groep op de pagina Beheerrechten. Instantiebeheerders hebben elk gebied.", + "Open administration privileges": "Beheerrechten openen", + "Policies": "Beleid", + "Applications and machine access": "Applicaties en machinetoegang", + "People and offboarding": "Personen en uitdiensttreding", + "Audit and compliance": "Audit en compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versie, certificaatautoriteit, bijlagen, offline cache, lekcontrole, geheimtypen en back-ups", + "master password, organisation password, vault policies, rotation, version history and trash": "hoofdwachtwoord, organisatiewachtwoord, kluisbeleid, rotatie, versiegeschiedenis en prullenbak", + "application queue, application requests and machine leases": "applicatiewachtrij, applicatieverzoeken en machineleases", + "team offboarding, encryption suites and admin handover": "team-uitdiensttreding, versleutelingssuites en beheerdersovername", + "audit log, compliance reports, SIEM export and honey alerts": "auditlog, compliancerapporten, SIEM-export en honingmeldingen", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hoeveel versies van een geheim bewaard blijven, hoe lang, en hoe lang verwijderde geheimen in de prullenbak blijven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limieten voor versleutelde bijlagen, op de server afgedwongen in opgeslagen versleutelde bytes.", + "Type the suite ID again to confirm": "Typ de suite-ID nogmaals ter bevestiging", + "This does not match the suite ID.": "Dit komt niet overeen met de suite-ID.", + "Confirm with your master password": "Bevestig met je masterwachtwoord", + "Confirm": "Bevestigen", + "That master password is not right.": "Dat masterwachtwoord klopt niet.", + "You are sharing with someone new. Enter your master password to confirm.": "Je deelt met iemand nieuw. Voer je masterwachtwoord in om te bevestigen.", + "Enter your master password to confirm this share.": "Voer je masterwachtwoord in om deze deling te bevestigen.", + "Enter your master password to confirm this delegation.": "Voer je masterwachtwoord in om deze delegatie te bevestigen.", + "Approve {member}": "{member} goedkeuren", + "Recipient": "Ontvanger", + "No vault yet": "Nog geen kluis", + "No matching users": "Geen gebruikers gevonden", + "Partner organisations": "Partnerorganisaties", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Wissel geheimen uit met een andere Keepiq. Beide beheerders voegen elkaar toe en vergelijken de rootvingerafdrukken telefonisch of persoonlijk voordat ze opslaan.", + "Federation needs Nextcloud 33 or later.": "Federatie vraagt Nextcloud 33 of nieuwer.", + "Your root fingerprint": "Jouw rootvingerafdruk", + "No partners yet.": "Nog geen partners.", + "Users here may share to this partner": "Gebruikers hier mogen delen met deze partner", + "This partner may share to users here": "Deze partner mag delen met gebruikers hier", + "Partner address": "Adres van de partner", + "Check partner": "Partner controleren", + "Partner root fingerprint": "Rootvingerafdruk van de partner", + "I compared this fingerprint with the partner's administrator": "Ik heb deze vingerafdruk vergeleken met de beheerder van de partner", + "Add partner": "Partner toevoegen", + "A secret from another organisation": "Een geheim van een andere organisatie", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s heeft \"%2$s\" met u gedeeld. Accepteer het onder Binnengekomen van andere organisaties.", + "Incoming from other organisations": "Binnengekomen van andere organisaties", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Mensen in partnerorganisaties kunnen een geheim met u delen. Accepteer het om een alleen-lezen kopie in uw kluis te bewaren.", + "Nothing shared with you yet": "Nog niets met u gedeeld", + "Secrets that people in partner organisations share with you appear here.": "Geheimen die mensen in partnerorganisaties met u delen, verschijnen hier.", + "From {sender}": "Van {sender}", + "Accept": "Accepteren", + "Open in vault": "Openen in kluis", + "The other organisation did not hand over the secret. Try again later.": "De andere organisatie heeft het geheim niet overgedragen. Probeer het later opnieuw.", + "Set up your vault before you accept a shared secret.": "Stel uw kluis in voordat u een gedeeld geheim accepteert.", + "Something went wrong. Try again.": "Er is iets misgegaan. Probeer het opnieuw.", + "Waiting for your answer": "Wacht op uw antwoord", + "In your vault, read-only": "In uw kluis, alleen-lezen", + "Withdrawn by the sender": "Ingetrokken door de afzender", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} heeft dit gedeeld vanuit een andere organisatie. U kunt het lezen, maar niet wijzigen of delen.", + "Someone": "Iemand", + "Share with someone at another organisation": "Delen met iemand bij een andere organisatie", + "Their account at the other organisation": "Hun account bij de andere organisatie", + "Check account": "Account controleren", + "Certificate fingerprint of {account}": "Certificaatvingerafdruk van {account}", + "Compare it with them by phone if you want to be sure.": "Vergelijk deze telefonisch met de ander als u zeker wilt zijn.", + "Shared. {account} can accept it in their own vault.": "Gedeeld. {account} kan het in de eigen kluis accepteren.", + "The certificate could not be verified. Nothing was shared.": "Het certificaat kon niet worden geverifieerd. Er is niets gedeeld.", + "That organisation is not one of your partners.": "Die organisatie is geen van uw partners.", + "No one with that account can receive secrets from you.": "Niemand met dat account kan geheimen van u ontvangen.", + "The other organisation did not answer. Try again later.": "De andere organisatie heeft niet geantwoord. Probeer het later opnieuw.", + "This secret is already shared with that account.": "Dit geheim is al gedeeld met dat account.", + "Other organisations": "Andere organisaties", + "Receive secrets from other organisations": "Geheimen van andere organisaties ontvangen", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Mensen in partnerorganisaties kunnen dan uw account vinden en geheimen met u delen. U accepteert elk geheim zelf.", + "Shared": "Gedeeld", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Gepauzeerd: hun certificaat of het partnerschap is gewijzigd. Trek het delen in of deel opnieuw.", + "Their organisation did not get the last change. Revoke it or share again.": "Hun organisatie heeft de laatste wijziging niet ontvangen. Trek het delen in of deel opnieuw.", + "Being withdrawn": "Wordt ingetrokken", + "Shared with another organisation": "Gedeeld met een andere organisatie", + "Change sent to another organisation": "Wijziging verstuurd naar een andere organisatie", + "Share with another organisation revoked": "Delen met een andere organisatie ingetrokken", + "Share with another organisation paused": "Delen met een andere organisatie gepauzeerd", + "Another organisation did not get a change": "Een andere organisatie heeft een wijziging niet ontvangen", + "Secret received from another organisation": "Geheim ontvangen van een andere organisatie", + "Secret from another organisation accepted": "Geheim van een andere organisatie geaccepteerd", + "Secret from another organisation declined": "Geheim van een andere organisatie geweigerd", + "Copy from another organisation updated": "Kopie van een andere organisatie bijgewerkt", + "Copy from another organisation removed": "Kopie van een andere organisatie verwijderd", + "Declined: they removed their copy. Share again if they need it.": "Geweigerd: de ontvanger heeft de kopie verwijderd. Deel opnieuw als die nodig is.", + "Recipient at another organisation removed their copy": "Ontvanger bij een andere organisatie heeft de kopie verwijderd", + "Removed the user from %n team folder.": "Gebruiker verwijderd uit %n teammap.", + "Removed the user from %n team folders.": "Gebruiker verwijderd uit %n teammappen.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Gebruiker verwijderd uit %n teammap.","Gebruiker verwijderd uit %n teammappen."], + "A restored copy came from a share that has ended. It stays read-only.": "Een teruggezette kopie komt uit een deling die is beëindigd. Die blijft alleen-lezen.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "De organisatie die een teruggezette kopie deelde, is niet bereikbaar. De kopie blijft alleen-lezen en volgt hun wijzigingen niet.", + "Recipient at another organisation restored their copy": "Ontvanger bij een andere organisatie heeft de kopie teruggezet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 88c6d4e99..4fe2fe4e5 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1186,7 +1186,567 @@ "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." + "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.", + "Shared with groups": "Gedeeld met groepen", + "Not shared with any group yet.": "Nog met geen enkele groep gedeeld.", + "Revoke the share with {group}": "Deling met {group} intrekken", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Gedeeld met {group}: {received} leden hebben het ontvangen, {skipped} niet omdat ze nog geen versleuteling hebben ingesteld.", + "Search groups": "Groepen zoeken", + "Failed to share": "Delen mislukt", + "Columns": "Kolommen", + "Column {number}": "Kolom {number}", + "Map one column to Name. Every secret needs a name.": "Koppel één kolom aan Naam. Elk geheim heeft een naam nodig.", + "Notes": "Notities", + "Do not import": "Niet importeren", + "Hide this value": "Deze waarde verbergen", + "Show this value": "Deze waarde tonen", + "Defaults": "Standaarden", + "New secrets start as this type, and your secret list opens in this view.": "Nieuwe geheimen beginnen als dit type, en je lijst met geheimen opent in deze weergave.", + "Default item type": "Standaard itemtype", + "Cards": "Kaarten", + "Table": "Tabel", + "Could not save your default": "Je standaard kon niet worden opgeslagen", + "Recently used": "Recent gebruikt", + "Opened": "Geopend", + "You have not opened any secrets yet": "Je hebt nog geen geheimen geopend", + "Could not delete the item type.": "Het itemtype kon niet worden verwijderd.", + "Could not load the item types.": "De itemtypes konden niet worden geladen.", + "Could not save the item type.": "Het itemtype kon niet worden opgeslagen.", + "Delete item type": "Itemtype verwijderen", + "Edit item type": "Itemtype bewerken", + "Fields": "Velden", + "Fields: {count}": "Velden: {count}", + "Hidden": "Verborgen", + "Item types": "Itemtypes", + "Move up": "Omhoog", + "New item type": "Nieuw itemtype", + "No item types defined yet.": "Nog geen itemtypes aangemaakt.", + "Required": "Verplicht", + "Text": "Tekst", + "This field is required": "Dit veld is verplicht", + "Web address": "Webadres", + "{label} (required)": "{label} (verplicht)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "“{name}” verwijderen? Geheimen van dit type blijven leesbaar en worden Inlog-items.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Itemtypes die je hier aanmaakt verschijnen bij iedereen in het venster Nieuw geheim, met de velden die je kiest.", + "Secret moved to the trash": "Geheim naar de prullenbak verplaatst", + "Secret restored from the trash": "Geheim uit de prullenbak teruggezet", + "Secret deleted for good": "Geheim definitief verwijderd", + "Secret archived": "Geheim gearchiveerd", + "Secret unarchived": "Geheim uit het archief gehaald", + "Unarchive": "Uit archief halen", + "Could not archive the secret": "Het geheim kon niet worden gearchiveerd", + "Could not unarchive the secret": "Het geheim kon niet uit het archief worden gehaald", + "Archive {count} secrets": "{count} geheimen archiveren", + "Unarchive {count} secrets": "{count} geheimen uit het archief halen", + "Restore {count} secrets": "{count} geheimen terugzetten", + "Delete {count} secrets for good": "{count} geheimen definitief verwijderen", + "Done for {ok} of {total} secrets": "Klaar voor {ok} van {total} geheimen", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Gearchiveerde geheimen verdwijnen uit de kluislijst, het zoeken, automatisch invullen en het gezondheidsrapport. Ze blijven gedeeld. Je vindt ze onder Archief.", + "These secrets come back to the vault list, search and autofill.": "Deze geheimen komen terug in de kluislijst, het zoeken en automatisch invullen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Deze geheimen komen terug in de kluislijst. Hun oude delingen komen niet terug, deel ze dus opnieuw waar nodig.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Dit verwijdert de geheimen met hun bijlagen en versiegeschiedenis. Dit kan niet ongedaan worden gemaakt.", + "Delete for good": "Definitief verwijderen", + "Trash": "Prullenbak", + "The trash is empty": "De prullenbak is leeg", + "No archived secrets": "Geen gearchiveerde geheimen", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Verwijderde geheimen wachten hier tot de bewaartermijn afloopt, daarna worden ze definitief verwijderd.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archiveer een geheim vanuit het detailpaneel om het buiten de kluislijst, het zoeken en automatisch invullen te houden.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limieten voor versleutelde bijlagen (op de server afgedwongen in opgeslagen versleutelde bytes), bewaartermijn van de versiegeschiedenis en hoe lang verwijderde geheimen in de prullenbak blijven.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagen dat een verwijderd geheim in de prullenbak blijft (1 tot 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Dit verplaatst het geheim naar de prullenbak en beëindigt de delingen nu. Je kunt het uit de prullenbak terugzetten tot de bewaartermijn afloopt: 30 dagen, tenzij je beheerder dat heeft aangepast.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Dit verplaatst {count} geheimen naar de prullenbak en beëindigt hun delingen nu. Je kunt ze uit de prullenbak terugzetten tot de bewaartermijn afloopt.", + "Remove {name} from favourites": "{name} uit favorieten halen", + "Add {name} to favourites": "{name} aan favorieten toevoegen", + "Could not change the favourite": "Kon de favoriet niet wijzigen", + "Remove from favourites": "Uit favorieten halen", + "Add to favourites": "Aan favorieten toevoegen", + "Tags": "Labels", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Labels zijn niet versleuteld. Serverbeheerders kunnen ze lezen, net als mapnamen.", + "Favourites": "Favorieten", + "Filter by tag": "Filteren op label", + "All tags": "Alle labels", + "Last used": "Laatst gebruikt", + "Tags for {count} secrets": "Labels voor {count} geheimen", + "Tag": "Label", + "Remove tag": "Label verwijderen", + "Add tag": "Label toevoegen", + "Could not change the tags. Try again.": "Kon de labels niet wijzigen. Probeer het opnieuw.", + "Could not approve the application. It is still in the queue.": "Kan de aanvraag niet goedkeuren. Deze staat nog in de wachtrij.", + "Could not reject the application. It is still in the queue.": "Kan de aanvraag niet afwijzen. Deze staat nog in de wachtrij.", + "Removed the user from {count} team folders.": "Gebruiker verwijderd uit {count} teammappen.", + "Approve a share": "Een deling goedkeuren", + "This approval link is incomplete. Open it again from the notification.": "Deze goedkeuringslink is onvolledig. Open hem opnieuw vanuit de melding.", + "Deny": "Weigeren", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} is lid geworden van een groep waarmee je een geheim deelt. Het geheim ook met hen delen?", + "{requester} asks you to share a secret with {user}.": "{requester} vraagt je een geheim te delen met {user}.", + "Shared. The recipient can now open the secret.": "Gedeeld. De ontvanger kan het geheim nu openen.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "De ontvanger heeft Keepiq nog niet ingesteld, dus er is niets gedeeld. Probeer het opnieuw zodra dat gebeurd is.", + "Could not share the secret. Only its owner can approve this.": "Kan het geheim niet delen. Alleen de eigenaar kan dit goedkeuren.", + "Could not share the secret. Try again.": "Kan het geheim niet delen. Probeer het opnieuw.", + "Denied. Nothing was shared.": "Geweigerd. Er is niets gedeeld.", + "Could not deny the request. Try again.": "Kan het verzoek niet weigeren. Probeer het opnieuw.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vraagt je het geheim \"%2$s\" te delen met %3$s.", + "Expires on (optional)": "Verloopt op (optioneel)", + "Hand over to": "Overdragen aan", + "Choose a recipient": "Kies een ontvanger", + "Hand over temporarily": "Tijdelijk overdragen", + "Expiry rules": "Verloopregels", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stel in hoe lang wachtwoorden van één itemtype of in één map mogen bestaan, en wanneer je een herinnering krijgt. Als er meerdere datums gelden, telt de vroegste.", + "Delete rule": "Regel verwijderen", + "Set by your administrator": "Ingesteld door je beheerder", + "No expiry rules yet.": "Nog geen verloopregels.", + "Applies to": "Geldt voor", + "Item type": "Itemtype", + "Maximum age in days (empty for reminders only)": "Maximale leeftijd in dagen (leeg voor alleen herinneringen)", + "Remind me this many days before, comma separated": "Herinner me zoveel dagen van tevoren, gescheiden door komma's", + "Save rule": "Regel opslaan", + "An item type": "Een itemtype", + "A folder": "Een map", + "Folder {name}": "Map {name}", + "Type {name}": "Type {name}", + "Expires after {days} days": "Verloopt na {days} dagen", + "Reminders {days} days before": "Herinneringen {days} dagen van tevoren", + "Could not save the expiry rule.": "Kan de verloopregel niet opslaan.", + "Could not delete the expiry rule.": "Kan de verloopregel niet verwijderen.", + "All statuses": "Alle statussen", + "Compromised": "Gecompromitteerd", + "Could not load the members.": "De leden konden niet worden geladen.", + "Emergency contact": "Noodcontact", + "Leaving user": "Vertrekkende gebruiker", + "No": "Nee", + "No users match this filter.": "Geen gebruikers voor dit filter.", + "Not set up": "Niet ingesteld", + "Revoke suite": "Suite intrekken", + "Revoked": "Ingetrokken", + "Search users": "Gebruikers zoeken", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Zie welke gebruikers een kluis hebben ingesteld. Start offboarding of trek een suite in vanuit een rij.", + "Successor": "Opvolger", + "Team folders": "Teammappen", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "De gebruiker zit nog in groep {groups}, die lid is van een teammap. Haal de gebruiker uit de groep of schakel het account uit.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "De gebruiker zit nog in groepen {groups}, die lid zijn van teammappen. Haal de gebruiker uit de groepen of schakel het account uit.", + "Vault status": "Kluisstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Een CXF-export is ONVERSLEUTELD. Elk wachtwoord en elke login is leesbaar als platte tekst in het gedownloade bestand. Bewaar het veilig en verwijder het direct na gebruik.", + "Root certificate expiring soon": "Rootcertificaat verloopt binnenkort", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Het rootcertificaat van de kluis verloopt over %1$d dag(en). Vernieuw het vóór die tijd. Vernieuwen ondertekent elke versleutelingssuite opnieuw.", + "Compromise recovery aborted": "Herstel na compromittering afgebroken", + "Key rotation ended by a compromise revoke": "Sleutelrotatie beëindigd door een intrekking wegens compromittering", + "Encryption suite revoke refused": "Intrekken van versleutelingssuite geweigerd", + "Master password proof refused": "Bewijs van hoofdwachtwoord geweigerd", + "Your current master password": "Je huidige hoofdwachtwoord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n noodcontact had een openstaand toegangsverzoek toen je sleutelrotatie het verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n noodcontacten hadden een openstaand toegangsverzoek toen je sleutelrotatie ze verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Deze noodcontacten zijn niet meegenomen naar je nieuwe sleutel. Hun noodtoegang is verwijderd. Voeg ze opnieuw toe via Noodtoegang als je ze nog wilt.", + "Renew root certificate": "Rootcertificaat vernieuwen", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Dit maakt een nieuw root- en tussencertificaat. Elke actieve versleutelingssuite wordt opnieuw ondertekend. Je kunt dit niet ongedaan maken.", + "Renew root": "Root vernieuwen", + "Root renewed. {n} encryption suites signed again.": "Root vernieuwd. {n} versleutelingssuites opnieuw ondertekend.", + "Could not renew the root certificate.": "Het rootcertificaat kon niet worden vernieuwd.", + "Lease policy for this application": "Leasebeleid voor deze applicatie", + "In force now: {default} seconds by default, {max} seconds at most.": "Nu van kracht: standaard {default} seconden, hoogstens {max} seconden.", + "Leases are not renewable": "Leases zijn niet te verlengen", + "Lease policy saved.": "Leasebeleid opgeslagen.", + "Leave a field empty to use the instance value.": "Laat een veld leeg om de waarde van de instantie te gebruiken.", + "Instance value: {value}": "Waarde van de instantie: {value}", + "Renewal": "Verlenging", + "Use the instance value ({value})": "Waarde van de instantie gebruiken ({value})", + "Allowed": "Toegestaan", + "Not allowed": "Niet toegestaan", + "Save lease policy": "Leasebeleid opslaan", + "Only an administrator can change this policy.": "Alleen een beheerder kan dit beleid wijzigen.", + "Could not save the lease policy.": "Het leasebeleid kon niet worden opgeslagen.", + "{member} got access from {confirmer}.": "{member} kreeg toegang van {confirmer}.", + "Automatically confirm new team folder members": "Nieuwe teammapleden automatisch bevestigen", + "Gave %n new member access to a team folder.": "%n nieuw lid heeft toegang gekregen tot een teammap.", + "Gave %n new members access to a team folder.": "%n nieuwe leden hebben toegang gekregen tot een teammap.", + "Give new team folder members access without waiting for the folder owner.": "Geef nieuwe teammapleden toegang zonder te wachten op de eigenaar van de map.", + "New team folder members": "Nieuwe teammapleden", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "De eigenaar of een lid met schrijfrechten bevestigt hen vanuit de geopende kluis. Keepiq ontsleutelt nooit op de server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Wacht op een lid met schrijfrechten dat Keepiq opent. Je kunt ook nu delen.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Een deel van de compromitteringsreactie is mislukt ({failed} stap(pen)). Controleer het serverlogboek en trek de suite daarna opnieuw geforceerd in om het af te ronden.", + "This also revoked suite {suite} and ended key migration {migration}.": "Hiermee is ook suite {suite} ingetrokken en sleutelmigratie {migration} beëindigd.", + "Revoking the second suite deleted %n emergency-access contact.": "Het intrekken van de tweede suite verwijderde %n noodtoegangscontact.", + "Revoking the second suite deleted %n emergency-access contacts.": "Het intrekken van de tweede suite verwijderde %n noodtoegangscontacten.", + "A suite revoked as compromised cannot be reinstated.": "Een suite die als gecompromitteerd is ingetrokken, kan niet worden hersteld.", + "Archives to keep": "Te bewaren archieven", + "Back up every vault automatically": "Elke kluis automatisch back-uppen", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Maak volgens schema een back-up van elke kluis. Archieven bevatten alleen versleutelde gegevens en worden met occ teruggezet.", + "Back up now": "Nu back-uppen", + "Backup public key (PEM, optional)": "Openbare back-upsleutel (PEM, optioneel)", + "Backup requested for the next cron run": "Back-up aangevraagd voor de volgende cronrun", + "Encrypted": "Versleuteld", + "Every (hours)": "Elke (uren)", + "Last backup {when} failed: {error}": "Laatste back-up {when} mislukt: {error}", + "Last backup {when} succeeded.": "Laatste back-up {when} gelukt.", + "No archives yet.": "Nog geen archieven.", + "Size": "Grootte", + "Vault backups": "Kluisback-ups", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Met een sleutel wordt elk archief daarmee versleuteld. Bewaar de privésleutel buiten deze server: je hebt hem nodig om te controleren of terug te zetten.", + "Written": "Geschreven", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n gebruiker binnen het bereik heeft nog geen tweestapsaanmelding en kan de kluis niet openen zolang dit aan staat.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n gebruikers binnen het bereik hebben nog geen tweestapsaanmelding en kunnen de kluis niet openen zolang dit aan staat.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Back-upcodes tellen niet mee. Melden je gebruikers zich aan via een identiteitsprovider met een eigen tweede factor, laat hun groepen dan weg.", + "Block personal vault export": "Export van persoonlijke kluis blokkeren", + "Keep work logins in team folders": "Werkinloggegevens in teammappen bewaren", + "Move to a team folder": "Naar een teammap verplaatsen", + "Not in a team folder": "Niet in een teammap", + "Only for these groups (empty is everyone)": "Alleen voor deze groepen (leeg is iedereen)", + "Require two-factor login before the vault opens": "Tweestapsaanmelding vereisen voordat de kluis opent", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regels voor elke kluis. Elke regel geldt voor iedereen, of alleen voor de groepen die je kiest.", + "Secret types that belong in a team folder": "Geheimtypen die in een teammap horen", + "Set up two-factor login": "Tweestapsaanmelding instellen", + "Team folder you can write to": "Teammap waarin je kunt schrijven", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Gebruikers kunnen geen back-up, CSV of overdrachtsbestand downloaden. Hun persoonlijke gegevenspakket blijft beschikbaar.", + "Users cannot save these secret types in a personal folder.": "Gebruikers kunnen deze geheimtypen niet in een persoonlijke map opslaan.", + "Vault policies": "Kluisbeleid", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Je organisatie staat het exporteren van je persoonlijke kluis niet toe. Je persoonlijke gegevenspakket in je instellingen blijft beschikbaar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Je organisatie bewaart deze geheimen in een teammap. Verplaats ze stuk voor stuk naar een teammap.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Je organisatie bewaart dit type geheim in een teammap. Kies een van je teammappen, of een waarin je kunt schrijven.", + "Your organisation requires two-factor login before you can open your vault.": "Je organisatie vereist tweestapsaanmelding voordat je je kluis kunt openen.", + "Allow passphrases made of words": "Wachtzinnen van woorden toestaan", + "Capitalise each word": "Elk woord met een hoofdletter", + "Include a number": "Een cijfer toevoegen", + "Kind of key": "Soort sleutel", + "Number of words": "Aantal woorden", + "Passphrase": "Wachtzin", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Stel een minimale kwaliteit in voor geheime waarden. De browser controleert die vóór het versleutelen, zodat de server nooit een waarde ziet.", + "Separator": "Scheidingsteken", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Gebruikers kiezen hoe lang de extensie ontgrendeld blijft als ze niets doen. U stelt de langste tijd in die ze mogen kiezen.", + "Longest idle time before the extension locks": "Langste inactieve tijd voordat de extensie vergrendelt", + "1 minute": "1 minuut", + "5 minutes": "5 minuten", + "15 minutes": "15 minuten", + "1 hour": "1 uur", + "4 hours": "4 uur", + "Connector": "Koppeling", + "Directory (tenant) ID": "Directory-ID (tenant)", + "Application (client) ID": "Applicatie-ID (client)", + "Data collection rule immutable ID": "Onveranderlijke ID van de gegevensverzamelingsregel", + "Stream name": "Streamnaam", + "Splunk index (optional)": "Splunk-index (optioneel)", + "Sourcetype (optional)": "Sourcetype (optioneel)", + "Leave blank to keep the current one": "Laat leeg om de huidige te houden", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Eindpunt voor gegevensverzameling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Clientgeheim (alleen schrijven)", + "HEC token (write-only)": "HEC-token (alleen schrijven)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Stuur toegestane auditgebeurtenissen door naar Splunk, Microsoft Sentinel, een syslog-ontvanger of een webhook. Berichten bevatten alleen opgeschoonde metadata: geen geheime waarde, naam, login of versleutelde tekst verlaat ooit de server.", + "%n change waiting to sync": "%n wijziging wacht op synchronisatie", + "%n changes waiting to sync": "%n wijzigingen wachten op synchronisatie", + "Changes that could not sync": "Wijzigingen die niet konden worden gesynchroniseerd", + "Choose a version": "Kies een versie", + "Copy value": "Waarde kopiëren", + "Deleted": "Verwijderd", + "Discard": "Weggooien", + "Keep my offline change": "Mijn offline wijziging behouden", + "Keep the server version": "De serverversie behouden", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq is offline alleen-lezen. Uw beheerder heeft offline bewerken niet aangezet.", + "Let users edit secrets offline": "Gebruikers geheimen offline laten bewerken", + "Not synced yet": "Nog niet gesynchroniseerd", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline wijzigingen blijven op het apparaat, versleuteld voor de gebruiker, en worden gesynchroniseerd bij de volgende online ontgrendeling. Delen, mappen en bijlagen hebben nog steeds een verbinding nodig.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Bewerkingen, verplaatsingen en verwijderingen blijven op dit apparaat en worden gesynchroniseerd zodra u weer online bent. Delen en bijlagen hebben een verbinding nodig.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Uw wijzigingen blijven op dit apparaat en worden gesynchroniseerd zodra u weer online bent. Laatst gesynchroniseerd {when}.", + "Open my changes": "Mijn wijzigingen openen", + "Sharing needs a connection": "Delen heeft een verbinding nodig", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Iemand heeft dit geheim op de server gewijzigd nadat uw offline kopie was gemaakt. Kies welke versie u wilt behouden.", + "Sync or discard your offline changes before you rotate your keys.": "Synchroniseer of verwijder uw offline wijzigingen voordat u uw sleutels vervangt.", + "That password did not open your changes.": "Dat wachtwoord opende uw wijzigingen niet.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "De offline momentopname bewaart versleutelde geheimen (alleen te openen met de sleutel die is afgeleid van het hoofdwachtwoord van de gebruiker, precies zoals op de server) en versleutelt namen, URL's en mapnamen in opslag. Offline toegang is alleen-lezen, tenzij u hieronder offline bewerken toestaat. Zet dit uit voor apparaten die nooit inloggegevens mogen bewaren; uitzetten wist bestaande caches bij de volgende keer laden.", + "The previous vault copy is gone, so these changes cannot be opened.": "De vorige kluiskopie is weg, dus deze wijzigingen kunnen niet worden geopend.", + "The server version": "De serverversie", + "This secret changed while you were offline": "Dit geheim is gewijzigd terwijl u offline was", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "U heeft dit geheim offline verwijderd, maar het is sindsdien op de server gewijzigd. Kies welke versie u wilt behouden.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Uw sleutels zijn op een ander apparaat vervangen. Voer uw vorige hoofdwachtwoord in om de offline wijzigingen te synchroniseren, of gooi ze weg.", + "Your offline change": "Uw offline wijziging", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n noodcontact had een openstaand toegangsverzoek toen je sleutelrotatie het verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt.", + "%n noodcontacten hadden een openstaand toegangsverzoek toen je sleutelrotatie ze verwijderde. Controleer wie het vroeg voordat je iemand opnieuw toevoegt." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n item kan niet in CXF worden weergegeven en wordt overgeslagen.", + "%n items kunnen niet in CXF worden weergegeven en worden overgeslagen." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n oudere versie is verwijderd omdat alleen recente geschiedenis kan worden meegenomen.", + "%n oudere versies zijn verwijderd omdat alleen recente geschiedenis kan worden meegenomen." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopie van een geheim moet nog worden versleuteld en gedeeld.", + "%n kopieën van geheimen moeten nog worden versleuteld en gedeeld." + ], + "_%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 geheim kon niet worden ontsleuteld en zit niet in deze export.", + "%n geheimen konden niet worden ontsleuteld en zitten niet in deze export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n geheim kon niet worden ontsleuteld met je oude sleutel en is dus niet gemigreerd.", + "%n geheimen konden niet worden ontsleuteld met je oude sleutel en zijn dus niet gemigreerd." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n geheim is niet gemigreerd.", + "%n geheimen zijn niet gemigreerd." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n geheim is nog versleuteld met je vorige sleutel.", + "%n geheimen zijn nog versleuteld met je vorige sleutel." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n geheim is overgeslagen omdat de opvolger nog geen kopie heeft — voeg de opvolger toe aan de map en voer dit opnieuw uit.", + "%n geheimen zijn overgeslagen omdat de opvolger nog geen kopie heeft — voeg de opvolger toe aan de map en voer dit opnieuw uit." + ], + "_%n secret_::_%n secrets_": [ + "%n geheim", + "%n geheimen" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n gebruiker binnen het bereik heeft nog geen tweestapsaanmelding en kan de kluis niet openen zolang dit aan staat.", + "%n gebruikers binnen het bereik hebben nog geen tweestapsaanmelding en kunnen de kluis niet openen zolang dit aan staat." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Toch afronden en de toegang tot %n geheim verliezen", + "Toch afronden en de toegang tot %n geheimen verliezen" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nieuw lid heeft toegang gekregen tot een teammap.", + "%n nieuwe leden hebben toegang gekregen tot een teammap." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Sleutelrotatie afgerond. %n geheim is opnieuw versleuteld met je nieuwe sleutel.", + "Sleutelrotatie afgerond. %n geheimen zijn opnieuw versleuteld met je nieuwe sleutel." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Het intrekken van de tweede suite verwijderde %n noodtoegangscontact.", + "Het intrekken van de tweede suite verwijderde %n noodtoegangscontacten." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Het intrekken van deze suite verwijderde %n noodtoegangscontact.", + "Het intrekken van deze suite verwijderde %n noodtoegangscontacten." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "%n keer gezien in datalekken", + "%n keer gezien in datalekken" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "gedeeld met %n geheim", + "gedeeld met %n geheimen" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Deze map bevat direct %n geheim.", + "Deze map bevat direct %n geheimen." + ], + "_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._": [ + "Je sleutelrotatie heeft %n noodcontact verwijderd. Kijk bij Noodtoegang en voeg het opnieuw toe als je het nog wilt.", + "Je sleutelrotatie heeft %n noodcontacten verwijderd. Kijk bij Noodtoegang en voeg ze opnieuw toe als je ze nog wilt." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n wijziging wacht op synchronisatie", + "%n wijzigingen wachten op synchronisatie" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "De gebruiker zit nog in groep {groups}, die lid is van een teammap. Haal de gebruiker uit de groep of schakel het account uit.", + "De gebruiker zit nog in groepen {groups}, die lid zijn van teammappen. Haal de gebruiker uit de groepen of schakel het account uit." + ], + "Allow approval from another device": "Goedkeuring vanaf een ander apparaat toestaan", + "App": "App", + "Approve a new device": "Een nieuw apparaat goedkeuren", + "Approve from another device": "Goedkeuren vanaf een ander apparaat", + "Asked at": "Gevraagd om", + "Check that the new device shows these words:": "Controleer of het nieuwe apparaat deze woorden toont:", + "Denied. If you did not ask, end your other sessions:": "Geweigerd. Heb je dit niet gevraagd, beëindig dan je andere sessies:", + "Device": "Apparaat", + "IP address": "IP-adres", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Laat gebruikers een nieuwe browser ontgrendelen door die goed te keuren vanaf een apparaat waarop Keepiq al ontgrendeld is.", + "New device approval": "Goedkeuring nieuw apparaat", + "Nextcloud security settings": "Nextcloud-beveiligingsinstellingen", + "Only approve a device you are using right now.": "Keur alleen een apparaat goed dat je nu zelf gebruikt.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Open Keepiq op een apparaat waarop het ontgrendeld is en keur dit apparaat goed. Controleer of het dezelfde woorden toont:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Het goedkeurende apparaat verzegelt de ontgrendelsleutel voor het nieuwe apparaat. De server geeft hem alleen door en kan hem niet openen.", + "The master password is not right, or the request has ended.": "Het hoofdwachtwoord klopt niet, of het verzoek is beëindigd.", + "The request expired. Ask again or use your master password.": "Het verzoek is verlopen. Vraag het opnieuw of gebruik je hoofdwachtwoord.", + "The request was denied.": "Het verzoek is geweigerd.", + "Too many requests. Try again in an hour or use your master password.": "Te veel verzoeken. Probeer het over een uur opnieuw of gebruik je hoofdwachtwoord.", + "Unknown device": "Onbekend apparaat", + "Web app": "Webapp", + "A device": "Een apparaat", + "A new device asks to open your vault": "Een nieuw apparaat vraagt om je kluis te openen", + "%s asks to be approved. Only approve a device you are using right now.": "%s vraagt om goedkeuring. Keur alleen een apparaat goed dat je nu zelf gebruikt.", + "Access ends on (optional)": "Toegang eindigt op (optioneel)", + "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.": "De apps van Keepiq tonen of kopiëren het wachtwoord niet. Iemand met technische kennis kan het nog steeds op het eigen apparaat uitlezen. Wijzig het wanneer de toegang eindigt.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Dit geheim is alleen te gebruiken. Log in via de Keepiq-browserextensie.", + "Until {date}": "Tot {date}", + "Use only": "Alleen gebruiken", + "Use only (can sign in, cannot view or copy)": "Alleen gebruiken (kan inloggen, niet bekijken of kopiëren)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Je kunt met deze login inloggen via de Keepiq-browserextensie. De eigenaar heeft ervoor gekozen dat je hem niet kunt bekijken of kopiëren.", + "Your access ends on {date}": "Je toegang eindigt op {date}", + "Your access to this secret has ended": "Je toegang tot dit geheim is beëindigd", + "Your access to \"%s\" ends tomorrow": "Je toegang tot \"%s\" eindigt morgen", + "Your access to \"%s\" has ended": "Je toegang tot \"%s\" is beëindigd", + "%1$s no longer has access to \"%2$s\"": "%1$s heeft geen toegang meer tot \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kon dit wachtwoord zien. Wijzig het als %1$s het niet meer mag weten.", + "%s could not view this password in Keepiq.": "%s kon dit wachtwoord niet bekijken in Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} van {threshold} goedkeuringen", + "a recovery officer": "een herstelfunctionaris", + "Account recovery": "Accountherstel", + "Approvals needed": "Benodigde goedkeuringen", + "Ask {user} which words they see, by phone or in person. They must be:": "Vraag {user} telefonisch of persoonlijk welke woorden die ziet. Het moeten deze zijn:", + "Check again": "Opnieuw controleren", + "Create the recovery key": "Herstelsleutel aanmaken", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Maak de herstelsleutel aan. Je browser maakt hem en geeft elke functionaris een kopie die alleen die persoon kan openen.", + "Decline": "Weigeren", + "Enrol in account recovery": "Aanmelden voor accountherstel", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Meld je aan, zodat je organisatie je kan helpen je kluis terug te krijgen als je je hoofdwachtwoord vergeet.", + "Every user is enrolled": "Iedere gebruiker is aangemeld", + "Finish the recovery in the browser you asked from.": "Rond het herstel af in de browser waarin je het aanvroeg.", + "Forgot your master password?": "Hoofdwachtwoord vergeten?", + "Hand the key over": "Sleutel overdragen", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Laat gebruikers die hun hoofdwachtwoord vergeten zijn hun kluis terugkrijgen, goedgekeurd door herstelfunctionarissen die je aanwijst.", + "New master password": "Nieuw hoofdwachtwoord", + "No one is asking to recover their account.": "Niemand vraagt om herstel van zijn account.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nog geen herstelsleutel. Een van de functionarissen maakt hem aan in de eigen Keepiq-instellingen.", + "Off": "Uit", + "Officer {user} has no encryption set up yet.": "Functionaris {user} heeft nog geen versleuteling ingesteld.", + "Officers (user IDs, separated by commas)": "Functionarissen (gebruikers-ID's, gescheiden door komma's)", + "Policy": "Beleid", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publiceer deze vingerafdruk intern, zodat gebruikers hem kunnen controleren voordat ze zich aanmelden.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Hersteld met hulp van {officer}. Vervang nu je kluissleutel via Instellingen, Beveiliging: \"Mijn hoofdwachtwoord is gelekt\".", + "Recovery key fingerprint: {fingerprint}": "Vingerafdruk herstelsleutel: {fingerprint}", + "Recovery officer": "Herstelfunctionaris", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Verwijderde functionarissen verliezen nu hun kopie, maar kunnen die eerder geopend hebben. Laat een functionaris een nieuwe herstelsleutel aanmaken.", + "Repeat the new master password": "Herhaal het nieuwe hoofdwachtwoord", + "Retire this recovery key": "Deze herstelsleutel buiten gebruik stellen", + "Set the new master password": "Nieuw hoofdwachtwoord instellen", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Het herstelcertificaat is niet uitgegeven door deze Keepiq. Meld je niet aan en waarschuw je beheerder.", + "The words match, approve": "De woorden kloppen, goedkeuren", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Deze gebruiker is aangemeld voor accountherstel. Herstellen bewaart de geheimen; intrekken verwijdert de aanmelding.", + "Users may enrol": "Gebruikers mogen zich aanmelden", + "Withdraw from account recovery": "Afmelden voor accountherstel", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Je bent aangemeld voor accountherstel. Vingerafdruk herstelsleutel: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Je bent aangemeld. Als je je hoofdwachtwoord vergeet, kan je organisatie je helpen je kluis terug te krijgen.", + "Your key is back. Choose a new master password.": "Je sleutel is terug. Kies een nieuw hoofdwachtwoord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Je herstelfunctionarissen zijn ingelicht. Lees ze deze woorden voor als ze je bellen of spreken:", + "You are now an account recovery officer": "Je bent nu herstelfunctionaris voor accounts", + "%s asks to recover their account. Compare the words with them before you approve.": "%s vraagt om accountherstel. Vergelijk de woorden met die persoon voordat je goedkeurt.", + "A user": "Een gebruiker", + "Your account recovery request was declined": "Je verzoek om accountherstel is geweigerd", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Je accountherstel staat klaar. Open Keepiq in de browser waarin je het aanvroeg.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} vraagt om een nieuw apparaat eenmalig te ontgrendelen. Het hoofdwachtwoord blijft hetzelfde.", + "Ask your organisation instead": "Vraag het liever aan je organisatie", + "The request ended. Ask again or use your master password.": "Het verzoek is beëindigd. Vraag het opnieuw of gebruik je hoofdwachtwoord.", + "Added by {user}": "Toegevoegd door {user}", + "Editor": "Bewerker", + "Manager": "Beheerder", + "Role of {member}": "Rol van {member}", + "Team folders you manage": "Teammappen die je beheert", + "Viewer": "Lezer", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Je hebt geen kopie van deze geheimen, dus de nieuwe leden hebben ze nog niet gekregen. De eigenaar kan ze delen: {names}", + "Admin areas": "Beheergebieden", + "Give a group only the parts of Keepiq administration it needs.": "Geef een groep alleen de delen van het Keepiq-beheer die ze nodig heeft.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegeer een of meer gebieden aan een groep op de pagina Beheerrechten. Instantiebeheerders hebben elk gebied.", + "Open administration privileges": "Beheerrechten openen", + "Policies": "Beleid", + "Applications and machine access": "Applicaties en machinetoegang", + "People and offboarding": "Personen en uitdiensttreding", + "Audit and compliance": "Audit en compliance", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versie, certificaatautoriteit, bijlagen, offline cache, lekcontrole, geheimtypen en back-ups", + "master password, organisation password, vault policies, rotation, version history and trash": "hoofdwachtwoord, organisatiewachtwoord, kluisbeleid, rotatie, versiegeschiedenis en prullenbak", + "application queue, application requests and machine leases": "applicatiewachtrij, applicatieverzoeken en machineleases", + "team offboarding, encryption suites and admin handover": "team-uitdiensttreding, versleutelingssuites en beheerdersovername", + "audit log, compliance reports, SIEM export and honey alerts": "auditlog, compliancerapporten, SIEM-export en honingmeldingen", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hoeveel versies van een geheim bewaard blijven, hoe lang, en hoe lang verwijderde geheimen in de prullenbak blijven.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limieten voor versleutelde bijlagen, op de server afgedwongen in opgeslagen versleutelde bytes.", + "Type the suite ID again to confirm": "Typ de suite-ID nogmaals ter bevestiging", + "This does not match the suite ID.": "Dit komt niet overeen met de suite-ID.", + "Confirm with your master password": "Bevestig met je masterwachtwoord", + "Confirm": "Bevestigen", + "That master password is not right.": "Dat masterwachtwoord klopt niet.", + "You are sharing with someone new. Enter your master password to confirm.": "Je deelt met iemand nieuw. Voer je masterwachtwoord in om te bevestigen.", + "Enter your master password to confirm this share.": "Voer je masterwachtwoord in om deze deling te bevestigen.", + "Enter your master password to confirm this delegation.": "Voer je masterwachtwoord in om deze delegatie te bevestigen.", + "Approve {member}": "{member} goedkeuren", + "Recipient": "Ontvanger", + "No vault yet": "Nog geen kluis", + "No matching users": "Geen gebruikers gevonden", + "Partner organisations": "Partnerorganisaties", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Wissel geheimen uit met een andere Keepiq. Beide beheerders voegen elkaar toe en vergelijken de rootvingerafdrukken telefonisch of persoonlijk voordat ze opslaan.", + "Federation needs Nextcloud 33 or later.": "Federatie vraagt Nextcloud 33 of nieuwer.", + "Your root fingerprint": "Jouw rootvingerafdruk", + "No partners yet.": "Nog geen partners.", + "Users here may share to this partner": "Gebruikers hier mogen delen met deze partner", + "This partner may share to users here": "Deze partner mag delen met gebruikers hier", + "Partner address": "Adres van de partner", + "Check partner": "Partner controleren", + "Partner root fingerprint": "Rootvingerafdruk van de partner", + "I compared this fingerprint with the partner's administrator": "Ik heb deze vingerafdruk vergeleken met de beheerder van de partner", + "Add partner": "Partner toevoegen", + "A secret from another organisation": "Een geheim van een andere organisatie", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s heeft \"%2$s\" met u gedeeld. Accepteer het onder Binnengekomen van andere organisaties.", + "Incoming from other organisations": "Binnengekomen van andere organisaties", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Mensen in partnerorganisaties kunnen een geheim met u delen. Accepteer het om een alleen-lezen kopie in uw kluis te bewaren.", + "Nothing shared with you yet": "Nog niets met u gedeeld", + "Secrets that people in partner organisations share with you appear here.": "Geheimen die mensen in partnerorganisaties met u delen, verschijnen hier.", + "From {sender}": "Van {sender}", + "Accept": "Accepteren", + "Open in vault": "Openen in kluis", + "The other organisation did not hand over the secret. Try again later.": "De andere organisatie heeft het geheim niet overgedragen. Probeer het later opnieuw.", + "Set up your vault before you accept a shared secret.": "Stel uw kluis in voordat u een gedeeld geheim accepteert.", + "Something went wrong. Try again.": "Er is iets misgegaan. Probeer het opnieuw.", + "Waiting for your answer": "Wacht op uw antwoord", + "In your vault, read-only": "In uw kluis, alleen-lezen", + "Withdrawn by the sender": "Ingetrokken door de afzender", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} heeft dit gedeeld vanuit een andere organisatie. U kunt het lezen, maar niet wijzigen of delen.", + "Someone": "Iemand", + "Share with someone at another organisation": "Delen met iemand bij een andere organisatie", + "Their account at the other organisation": "Hun account bij de andere organisatie", + "Check account": "Account controleren", + "Certificate fingerprint of {account}": "Certificaatvingerafdruk van {account}", + "Compare it with them by phone if you want to be sure.": "Vergelijk deze telefonisch met de ander als u zeker wilt zijn.", + "Shared. {account} can accept it in their own vault.": "Gedeeld. {account} kan het in de eigen kluis accepteren.", + "The certificate could not be verified. Nothing was shared.": "Het certificaat kon niet worden geverifieerd. Er is niets gedeeld.", + "That organisation is not one of your partners.": "Die organisatie is geen van uw partners.", + "No one with that account can receive secrets from you.": "Niemand met dat account kan geheimen van u ontvangen.", + "The other organisation did not answer. Try again later.": "De andere organisatie heeft niet geantwoord. Probeer het later opnieuw.", + "This secret is already shared with that account.": "Dit geheim is al gedeeld met dat account.", + "Other organisations": "Andere organisaties", + "Receive secrets from other organisations": "Geheimen van andere organisaties ontvangen", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Mensen in partnerorganisaties kunnen dan uw account vinden en geheimen met u delen. U accepteert elk geheim zelf.", + "Shared": "Gedeeld", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Gepauzeerd: hun certificaat of het partnerschap is gewijzigd. Trek het delen in of deel opnieuw.", + "Their organisation did not get the last change. Revoke it or share again.": "Hun organisatie heeft de laatste wijziging niet ontvangen. Trek het delen in of deel opnieuw.", + "Being withdrawn": "Wordt ingetrokken", + "Shared with another organisation": "Gedeeld met een andere organisatie", + "Change sent to another organisation": "Wijziging verstuurd naar een andere organisatie", + "Share with another organisation revoked": "Delen met een andere organisatie ingetrokken", + "Share with another organisation paused": "Delen met een andere organisatie gepauzeerd", + "Another organisation did not get a change": "Een andere organisatie heeft een wijziging niet ontvangen", + "Secret received from another organisation": "Geheim ontvangen van een andere organisatie", + "Secret from another organisation accepted": "Geheim van een andere organisatie geaccepteerd", + "Secret from another organisation declined": "Geheim van een andere organisatie geweigerd", + "Copy from another organisation updated": "Kopie van een andere organisatie bijgewerkt", + "Copy from another organisation removed": "Kopie van een andere organisatie verwijderd", + "Declined: they removed their copy. Share again if they need it.": "Geweigerd: de ontvanger heeft de kopie verwijderd. Deel opnieuw als die nodig is.", + "Recipient at another organisation removed their copy": "Ontvanger bij een andere organisatie heeft de kopie verwijderd", + "Removed the user from %n team folder.": "Gebruiker verwijderd uit %n teammap.", + "Removed the user from %n team folders.": "Gebruiker verwijderd uit %n teammappen.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Gebruiker verwijderd uit %n teammap.", + "Gebruiker verwijderd uit %n teammappen." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Een teruggezette kopie komt uit een deling die is beëindigd. Die blijft alleen-lezen.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "De organisatie die een teruggezette kopie deelde, is niet bereikbaar. De kopie blijft alleen-lezen en volgt hun wijzigingen niet.", + "Recipient at another organisation restored their copy": "Ontvanger bij een andere organisatie heeft de kopie teruggezet" }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/l10n/pl.js b/l10n/pl.js index e58acc3f2..053ea45d1 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Udostępniono grupom", + "Not shared with any group yet.": "Jeszcze nie udostępniono żadnej grupie.", + "Revoke the share with {group}": "Cofnij udostępnienie dla {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Udostępniono grupie {group}: {received} członków otrzymało, {skipped} nie, ponieważ nie skonfigurowali jeszcze szyfrowania.", + "Search groups": "Szukaj grup", + "Failed to share": "Nie udało się udostępnić", + "Columns": "Kolumny", + "Column {number}": "Kolumna {number}", + "Map one column to Name. Every secret needs a name.": "Przypisz jedną kolumnę do nazwy. Każdy sekret potrzebuje nazwy.", + "Notes": "Notatki", + "Do not import": "Nie importuj", + "Hide this value": "Ukryj tę wartość", + "Show this value": "Pokaż tę wartość", + "Defaults": "Domyślne", + "New secrets start as this type, and your secret list opens in this view.": "Nowe sekrety zaczynają się od tego typu, a lista sekretów otwiera się w tym widoku.", + "Default item type": "Domyślny typ elementu", + "Cards": "Karty", + "Table": "Tabela", + "Could not save your default": "Nie udało się zapisać wartości domyślnej", + "Recently used": "Ostatnio używane", + "Opened": "Otwarto", + "You have not opened any secrets yet": "Nie otwarto jeszcze żadnego sekretu", + "Could not delete the item type.": "Nie udało się usunąć typu elementu.", + "Could not load the item types.": "Nie udało się wczytać typów elementów.", + "Could not save the item type.": "Nie udało się zapisać typu elementu.", + "Delete item type": "Usuń typ elementu", + "Edit item type": "Edytuj typ elementu", + "Fields": "Pola", + "Fields: {count}": "Pola: {count}", + "Hidden": "Ukryte", + "Item types": "Typy elementów", + "Move up": "Przesuń w górę", + "New item type": "Nowy typ elementu", + "No item types defined yet.": "Nie zdefiniowano jeszcze typów elementów.", + "Required": "Wymagane", + "Text": "Tekst", + "This field is required": "To pole jest wymagane", + "Web address": "Adres internetowy", + "{label} (required)": "{label} (wymagane)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Usunąć „{name}”? Sekrety tego typu pozostają czytelne i stają się elementami typu Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy elementów zdefiniowane tutaj pojawiają się u wszystkich w oknie Nowy sekret, z wybranymi przez ciebie polami.", + "Secret moved to the trash": "Sekret przeniesiony do kosza", + "Secret restored from the trash": "Sekret przywrócony z kosza", + "Secret deleted for good": "Sekret usunięty na zawsze", + "Secret archived": "Sekret zarchiwizowany", + "Secret unarchived": "Sekret przywrócony z archiwum", + "Unarchive": "Przywróć z archiwum", + "Could not archive the secret": "Nie udało się zarchiwizować sekretu", + "Could not unarchive the secret": "Nie udało się przywrócić sekretu z archiwum", + "Archive {count} secrets": "Zarchiwizuj sekrety: {count}", + "Unarchive {count} secrets": "Przywróć z archiwum sekrety: {count}", + "Restore {count} secrets": "Przywróć sekrety: {count}", + "Delete {count} secrets for good": "Usuń na zawsze sekrety: {count}", + "Done for {ok} of {total} secrets": "Gotowe dla {ok} z {total} sekretów", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Zarchiwizowane sekrety znikają z listy sejfu, wyszukiwania, autouzupełniania i raportu o stanie haseł. Zachowują udostępnienia. Znajdziesz je w Archiwum.", + "These secrets come back to the vault list, search and autofill.": "Te sekrety wracają na listę sejfu, do wyszukiwania i autouzupełniania.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Te sekrety wracają na listę sejfu. Dawne udostępnienia nie wracają, więc udostępnij je ponownie tam, gdzie trzeba.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "To usuwa sekrety wraz z załącznikami i historią wersji. Tej operacji nie można cofnąć.", + "Delete for good": "Usuń na zawsze", + "Trash": "Kosz", + "The trash is empty": "Kosz jest pusty", + "No archived secrets": "Brak zarchiwizowanych sekretów", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Usunięte sekrety czekają tutaj do końca okresu przechowywania, potem są usuwane na zawsze.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Zarchiwizuj sekret w panelu szczegółów, aby trzymać go poza listą sejfu, wyszukiwaniem i autouzupełnianiem.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity zaszyfrowanych załączników (egzekwowane na serwerze w zapisanych bajtach szyfrogramu), przechowywanie historii wersji i jak długo usunięte sekrety pozostają w koszu.", + "Days a deleted secret stays in the trash (1 to 365)": "Liczba dni, przez które usunięty sekret pozostaje w koszu (od 1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "To przenosi sekret do kosza i od razu kończy jego udostępnienia. Możesz go przywrócić z kosza do końca okresu przechowywania: 30 dni, chyba że administrator to zmienił.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "To przenosi sekrety do kosza ({count}) i od razu kończy ich udostępnienia. Możesz je przywrócić z kosza do końca okresu przechowywania.", + "Remove {name} from favourites": "Usuń {name} z ulubionych", + "Add {name} to favourites": "Dodaj {name} do ulubionych", + "Could not change the favourite": "Nie udało się zmienić ulubionego", + "Remove from favourites": "Usuń z ulubionych", + "Add to favourites": "Dodaj do ulubionych", + "Tags": "Tagi", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tagi nie są szyfrowane. Administratorzy serwera mogą je odczytać, tak jak nazwy folderów.", + "Favourites": "Ulubione", + "Filter by tag": "Filtruj według tagu", + "All tags": "Wszystkie tagi", + "Last used": "Ostatnio używane", + "Tags for {count} secrets": "Tagi dla {count} sekretów", + "Tag": "Tag", + "Remove tag": "Usuń tag", + "Add tag": "Dodaj tag", + "Could not change the tags. Try again.": "Nie udało się zmienić tagów. Spróbuj ponownie.", + "Could not approve the application. It is still in the queue.": "Nie można zatwierdzić wniosku. Nadal jest w kolejce.", + "Could not reject the application. It is still in the queue.": "Nie można odrzucić wniosku. Nadal jest w kolejce.", + "Removed the user from {count} team folders.": "Usunięto użytkownika z {count} folderów zespołu.", + "Approve a share": "Zatwierdź udostępnienie", + "This approval link is incomplete. Open it again from the notification.": "Ten link zatwierdzający jest niekompletny. Otwórz go ponownie z powiadomienia.", + "Deny": "Odmów", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} dołączył(a) do grupy, z którą udostępniasz sekret. Udostępnić sekret także tej osobie?", + "{requester} asks you to share a secret with {user}.": "{requester} prosi o udostępnienie sekretu użytkownikowi {user}.", + "Shared. The recipient can now open the secret.": "Udostępniono. Odbiorca może teraz otworzyć sekret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Odbiorca nie skonfigurował jeszcze Keepiq, więc nic nie zostało udostępnione. Spróbuj ponownie, gdy to zrobi.", + "Could not share the secret. Only its owner can approve this.": "Nie można udostępnić sekretu. Tylko jego właściciel może to zatwierdzić.", + "Could not share the secret. Try again.": "Nie można udostępnić sekretu. Spróbuj ponownie.", + "Denied. Nothing was shared.": "Odmówiono. Nic nie zostało udostępnione.", + "Could not deny the request. Try again.": "Nie można odrzucić prośby. Spróbuj ponownie.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s prosi o udostępnienie sekretu \"%2$s\" użytkownikowi %3$s.", + "Expires on (optional)": "Wygasa (opcjonalnie)", + "Hand over to": "Przekaż osobie", + "Choose a recipient": "Wybierz odbiorcę", + "Hand over temporarily": "Przekaż tymczasowo", + "Expiry rules": "Reguły wygasania", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ustaw, jak długo mogą być ważne hasła jednego typu elementu lub w jednym folderze oraz kiedy otrzymać przypomnienie. Gdy obowiązuje kilka dat, liczy się najwcześniejsza.", + "Delete rule": "Usuń regułę", + "Set by your administrator": "Ustawione przez administratora", + "No expiry rules yet.": "Brak reguł wygasania.", + "Applies to": "Dotyczy", + "Item type": "Typ elementu", + "Maximum age in days (empty for reminders only)": "Maksymalny wiek w dniach (puste tylko dla przypomnień)", + "Remind me this many days before, comma separated": "Przypomnij tyle dni wcześniej, oddzielone przecinkami", + "Save rule": "Zapisz regułę", + "An item type": "Typ elementu", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Wygasa po {days} dniach", + "Reminders {days} days before": "Przypomnienia {days} dni wcześniej", + "Could not save the expiry rule.": "Nie można zapisać reguły wygasania.", + "Could not delete the expiry rule.": "Nie można usunąć reguły wygasania.", + "All statuses": "Wszystkie statusy", + "Compromised": "Naruszony", + "Could not load the members.": "Nie udało się wczytać członków.", + "Emergency contact": "Kontakt awaryjny", + "Leaving user": "Odchodzący użytkownik", + "No": "Nie", + "No users match this filter.": "Żaden użytkownik nie pasuje do tego filtra.", + "Not set up": "Nieskonfigurowany", + "Revoke suite": "Unieważnij pakiet", + "Revoked": "Unieważniony", + "Search users": "Szukaj użytkowników", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Zobacz, którzy użytkownicy skonfigurowali sejf. Rozpocznij offboarding lub unieważnij pakiet z wiersza.", + "Successor": "Następca", + "Team folders": "Foldery zespołu", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Użytkownik nadal jest w grupie {groups}, która jest członkiem folderu zespołu. Usuń go z grupy lub wyłącz konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto.", + "Vault status": "Status sejfu", + "Yes": "Tak", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Eksport CXF jest NIEZASZYFROWANY. Każde hasło i każdy login będą czytelne jako zwykły tekst w pobranym pliku. Przechowuj go bezpiecznie i usuń natychmiast po użyciu.", + "Root certificate expiring soon": "Certyfikat główny wkrótce wygaśnie", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certyfikat główny sejfu wygaśnie za %1$d dni. Odnów go wcześniej. Odnowienie ponownie podpisuje każdy zestaw szyfrowania.", + "Compromise recovery aborted": "Odzyskiwanie po naruszeniu przerwane", + "Key rotation ended by a compromise revoke": "Rotacja klucza zakończona odwołaniem z powodu naruszenia", + "Encryption suite revoke refused": "Odwołanie pakietu szyfrowania odrzucone", + "Master password proof refused": "Dowód hasła głównego odrzucony", + "Your current master password": "Twoje obecne hasło główne", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt awaryjny miał oczekujący wniosek o dostęp, gdy rotacja klucza go usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Te kontakty awaryjne nie zostały przeniesione do nowego klucza. Ich dostęp awaryjny został usunięty. Dodaj je ponownie w Dostępie awaryjnym, jeśli nadal ich chcesz.", + "Renew root certificate": "Odnów certyfikat główny", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Zostanie utworzony nowy certyfikat główny i pośredni. Każdy aktywny zestaw szyfrowania zostanie ponownie podpisany. Tej operacji nie można cofnąć.", + "Renew root": "Odnów główny", + "Root renewed. {n} encryption suites signed again.": "Certyfikat główny odnowiony. Ponownie podpisane zestawy szyfrowania: {n}.", + "Could not renew the root certificate.": "Nie udało się odnowić certyfikatu głównego.", + "Lease policy for this application": "Zasady dzierżawy dla tej aplikacji", + "In force now: {default} seconds by default, {max} seconds at most.": "Obecnie obowiązuje: domyślnie {default} sekund, najwyżej {max} sekund.", + "Leases are not renewable": "Dzierżaw nie można odnawiać", + "Lease policy saved.": "Zasady dzierżawy zapisane.", + "Leave a field empty to use the instance value.": "Pozostaw pole puste, aby użyć wartości instancji.", + "Instance value: {value}": "Wartość instancji: {value}", + "Renewal": "Odnawianie", + "Use the instance value ({value})": "Użyj wartości instancji ({value})", + "Allowed": "Dozwolone", + "Not allowed": "Niedozwolone", + "Save lease policy": "Zapisz zasady dzierżawy", + "Only an administrator can change this policy.": "Tylko administrator może zmienić te zasady.", + "Could not save the lease policy.": "Nie udało się zapisać zasad dzierżawy.", + "{member} got access from {confirmer}.": "{member} otrzymał dostęp od {confirmer}.", + "Automatically confirm new team folder members": "Automatycznie potwierdzaj nowych członków folderów zespołu", + "Gave %n new member access to a team folder.": "%n nowy członek otrzymał dostęp do folderu zespołu.", + "Gave %n new members access to a team folder.": "Nowi członkowie (%n) otrzymali dostęp do folderu zespołu.", + "Give new team folder members access without waiting for the folder owner.": "Daj nowym członkom dostęp bez czekania na właściciela folderu.", + "New team folder members": "Nowi członkowie folderów zespołu", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Właściciel lub członek z prawem zapisu potwierdza ich z otwartego sejfu. Keepiq nigdy nie odszyfrowuje na serwerze.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Czekam, aż członek z prawem zapisu otworzy Keepiq. Możesz też udostępnić teraz.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Część reakcji na naruszenie nie powiodła się ({failed} krok(ów)). Sprawdź dziennik serwera, a następnie ponownie unieważnij zestaw, aby ją dokończyć.", + "This also revoked suite {suite} and ended key migration {migration}.": "Unieważniło to także zestaw {suite} i zakończyło migrację kluczy {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Unieważnienie drugiego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Revoking the second suite deleted %n emergency-access contacts.": "Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "A suite revoked as compromised cannot be reinstated.": "Zestawu unieważnionego jako naruszony nie można przywrócić.", + "Archives to keep": "Archiwa do zachowania", + "Back up every vault automatically": "Automatycznie twórz kopię każdego sejfu", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Twórz kopię każdego sejfu według harmonogramu. Archiwa zawierają tylko szyfrogram i przywraca się je przez occ.", + "Back up now": "Utwórz kopię teraz", + "Backup public key (PEM, optional)": "Klucz publiczny kopii (PEM, opcjonalny)", + "Backup requested for the next cron run": "Kopia zamówiona na następne uruchomienie crona", + "Encrypted": "Zaszyfrowane", + "Every (hours)": "Co (godzin)", + "Last backup {when} failed: {error}": "Ostatnia kopia {when} nieudana: {error}", + "Last backup {when} succeeded.": "Ostatnia kopia {when} udana.", + "No archives yet.": "Brak archiwów.", + "Size": "Rozmiar", + "Vault backups": "Kopie sejfu", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Z kluczem każde archiwum jest dla niego szyfrowane. Trzymaj klucz prywatny poza tym serwerem: jest potrzebny do weryfikacji lub przywrócenia.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n użytkownik w zakresie nie ma jeszcze logowania dwuetapowego i nie może otworzyć sejfu, dopóki to jest włączone.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Kody zapasowe się nie liczą. Jeśli użytkownicy logują się przez dostawcę tożsamości z własnym drugim składnikiem, pomiń ich grupy.", + "Block personal vault export": "Blokuj eksport osobistego sejfu", + "Keep work logins in team folders": "Przechowuj służbowe dane logowania w folderach zespołu", + "Move to a team folder": "Przenieś do folderu zespołu", + "Not in a team folder": "Nie w folderze zespołu", + "Only for these groups (empty is everyone)": "Tylko dla tych grup (puste oznacza wszystkich)", + "Require two-factor login before the vault opens": "Wymagaj logowania dwuetapowego przed otwarciem sejfu", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Zasady dla każdego sejfu. Każda dotyczy wszystkich albo tylko wybranych grup.", + "Secret types that belong in a team folder": "Typy sekretów, które należą do folderu zespołu", + "Set up two-factor login": "Skonfiguruj logowanie dwuetapowe", + "Team folder you can write to": "Folder zespołu, w którym możesz zapisywać", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Użytkownicy nie mogą pobrać kopii zapasowej, pliku CSV ani pliku przeniesienia. Ich pakiet danych osobowych pozostaje dostępny.", + "Users cannot save these secret types in a personal folder.": "Użytkownicy nie mogą zapisywać tych typów sekretów w folderze osobistym.", + "Vault policies": "Zasady sejfu", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Twoja organizacja nie pozwala eksportować osobistego sejfu. Twój pakiet danych osobowych w ustawieniach pozostaje dostępny.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Twoja organizacja przechowuje te sekrety w folderze zespołu. Przenieś każdy do folderu zespołu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Twoja organizacja przechowuje ten typ sekretu w folderze zespołu. Wybierz jeden ze swoich folderów zespołu lub taki, w którym możesz zapisywać.", + "Your organisation requires two-factor login before you can open your vault.": "Twoja organizacja wymaga logowania dwuetapowego, zanim otworzysz sejf.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Użytkownicy wybierają, jak długo rozszerzenie pozostaje odblokowane podczas bezczynności. Ty ustalasz najdłuższy czas, jaki mogą wybrać.", + "Longest idle time before the extension locks": "Najdłuższy czas bezczynności przed zablokowaniem rozszerzenia", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 godzina", + "4 hours": "4 godziny", + "Connector": "Łącznik", + "Directory (tenant) ID": "Identyfikator katalogu (dzierżawcy)", + "Application (client) ID": "Identyfikator aplikacji (klienta)", + "Data collection rule immutable ID": "Niezmienny identyfikator reguły zbierania danych", + "Stream name": "Nazwa strumienia", + "Splunk index (optional)": "Indeks Splunk (opcjonalnie)", + "Sourcetype (optional)": "Sourcetype (opcjonalnie)", + "Leave blank to keep the current one": "Pozostaw puste, aby zachować obecny", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF przez syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punkt końcowy zbierania danych (adres https)", + "HTTP Event Collector URL (https)": "Adres HTTP Event Collector (https)", + "Client secret (write-only)": "Klucz tajny klienta (tylko zapis)", + "HEC token (write-only)": "Token HEC (tylko zapis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Przekazuj dozwolone zdarzenia audytu do Splunk, Microsoft Sentinel, odbiornika syslog lub webhooka. Wiadomości zawierają tylko oczyszczone metadane: żadna tajna wartość, nazwa, login ani zaszyfrowany tekst nigdy nie opuszcza serwera.", + "%n change waiting to sync": "%n zmiana czeka na synchronizację", + "%n changes waiting to sync": "%n zmian czeka na synchronizację", + "Changes that could not sync": "Zmiany, których nie udało się zsynchronizować", + "Choose a version": "Wybierz wersję", + "Copy value": "Kopiuj wartość", + "Deleted": "Usunięto", + "Discard": "Odrzuć", + "Keep my offline change": "Zachowaj moją zmianę offline", + "Keep the server version": "Zachowaj wersję z serwera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq jest offline tylko do odczytu. Administrator nie włączył edycji offline.", + "Let users edit secrets offline": "Pozwól użytkownikom edytować sekrety offline", + "Not synced yet": "Jeszcze niezsynchronizowane", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Zmiany offline zostają na urządzeniu, zaszyfrowane dla użytkownika, i synchronizują się przy następnym odblokowaniu online. Udostępnianie, foldery i załączniki nadal wymagają połączenia.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edycje, przeniesienia i usunięcia zostają na tym urządzeniu i synchronizują się, gdy wrócisz do sieci. Udostępnianie i załączniki wymagają połączenia.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Twoje zmiany zostają na tym urządzeniu i synchronizują się, gdy wrócisz do sieci. Ostatnia synchronizacja {when}.", + "Open my changes": "Otwórz moje zmiany", + "Sharing needs a connection": "Udostępnianie wymaga połączenia", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Ktoś zmienił ten sekret na serwerze po utworzeniu Twojej kopii offline. Wybierz, którą wersję zachować.", + "Sync or discard your offline changes before you rotate your keys.": "Zsynchronizuj lub odrzuć zmiany offline, zanim wymienisz klucze.", + "That password did not open your changes.": "To hasło nie otworzyło Twoich zmian.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Migawka offline przechowuje zaszyfrowane sekrety (do otwarcia tylko kluczem wyprowadzonym z hasła głównego użytkownika, dokładnie jak na serwerze) i szyfruje nazwy, adresy URL i nazwy folderów w spoczynku. Dostęp offline jest tylko do odczytu, chyba że poniżej zezwolisz na edycję offline. Wyłącz to dla urządzeń, które nigdy nie mogą przechowywać danych logowania; wyłączenie czyści istniejące pamięci podręczne przy następnym wczytaniu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Poprzedniej kopii sejfu już nie ma, więc tych zmian nie można otworzyć.", + "The server version": "Wersja z serwera", + "This secret changed while you were offline": "Ten sekret zmienił się, gdy byłeś offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Usunąłeś ten sekret offline, ale od tego czasu zmieniono go na serwerze. Wybierz, którą wersję zachować.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Twoje klucze zostały zmienione na innym urządzeniu. Wpisz poprzednie hasło główne, aby zsynchronizować zmiany offline, lub je odrzuć.", + "Your offline change": "Twoja zmiana offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n kontakt awaryjny miał oczekujący wniosek o dostęp, gdy rotacja klucza go usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.","Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.","Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n element nie może zostać przedstawiony w CXF i zostanie pominięty.","%n elementów nie może zostać przedstawionych w CXF i zostanie pominiętych.","%n elementów nie może zostać przedstawionych w CXF i zostanie pominiętych."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n starsza wersja została odrzucona, ponieważ przenieść można tylko niedawną historię.","%n starszych wersji zostało odrzuconych, ponieważ przenieść można tylko niedawną historię.","%n starszych wersji zostało odrzuconych, ponieważ przenieść można tylko niedawną historię."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopia sekretu wciąż wymaga zaszyfrowania i udostępnienia.","%n kopii sekretu wciąż wymaga zaszyfrowania i udostępnienia.","%n kopii sekretu wciąż wymaga zaszyfrowania i udostępnienia."], + "_%n secret could not be decrypted and is not in this export._::_%n secrets could not be decrypted and are not in this export._": ["Nie udało się odszyfrować %n sekretu i nie ma go w tym eksporcie.","Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie.","Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n sekretu nie udało się odszyfrować starym kluczem, dlatego nie został przeniesiony.","%n sekretów nie udało się odszyfrować starym kluczem, dlatego nie zostały przeniesione.","%n sekretów nie udało się odszyfrować starym kluczem, dlatego nie zostały przeniesione."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n sekret nie został przeniesiony.","%n sekretów nie zostało przeniesionych.","%n sekretów nie zostało przeniesionych."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n sekret jest nadal zaszyfrowany twoim poprzednim kluczem.","%n sekretów jest nadal zaszyfrowanych twoim poprzednim kluczem.","%n sekretów jest nadal zaszyfrowanych twoim poprzednim kluczem."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["Pominięto %n sekret, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie.","Pominięto %n sekretów, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie.","Pominięto %n sekretów, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie."], + "_%n secret_::_%n secrets_": ["%n sekret","%n sekretów","%n sekretów"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n użytkownik w zakresie nie ma jeszcze logowania dwuetapowego i nie może otworzyć sejfu, dopóki to jest włączone.","Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone.","Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Zakończ i tak, tracąc dostęp do %n sekretu","Zakończ i tak, tracąc dostęp do %n sekretów","Zakończ i tak, tracąc dostęp do %n sekretów"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nowy członek otrzymał dostęp do folderu zespołu.","Nowi członkowie (%n) otrzymali dostęp do folderu zespołu.","Nowi członkowie (%n) otrzymali dostęp do folderu zespołu."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotacja klucza zakończona. %n sekret został ponownie zaszyfrowany nowym kluczem.","Rotacja klucza zakończona. %n sekretów zostało ponownie zaszyfrowanych nowym kluczem.","Rotacja klucza zakończona. %n sekretów zostało ponownie zaszyfrowanych nowym kluczem."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Unieważnienie drugiego zestawu usunęło %n kontakt dostępu awaryjnego.","Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego.","Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Unieważnienie tego zestawu usunęło %n kontakt dostępu awaryjnego.","Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego.","Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["widziane %n raz w wyciekach","widziane %n razy w wyciekach","widziane %n razy w wyciekach"], + "_shared with %n secret_::_shared with %n secrets_": ["udostępniony %n sekretowi","udostępniony %n sekretom","udostępniony %n sekretom"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ten folder zawiera bezpośrednio %n sekret.","Ten folder zawiera bezpośrednio %n sekretów.","Ten folder zawiera bezpośrednio %n sekretów."], + "_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._": ["Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.","Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.","Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n zmiana czeka na synchronizację","%n zmian czeka na synchronizację","%n zmian czeka na synchronizację"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Użytkownik nadal jest w grupie {groups}, która jest członkiem folderu zespołu. Usuń go z grupy lub wyłącz konto.","Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto.","Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto."], + "Allow approval from another device": "Zezwalaj na zatwierdzanie z innego urządzenia", + "App": "Aplikacja", + "Approve a new device": "Zatwierdź nowe urządzenie", + "Approve from another device": "Zatwierdź z innego urządzenia", + "Asked at": "Poproszono o", + "Check that the new device shows these words:": "Sprawdź, czy nowe urządzenie wyświetla te słowa:", + "Denied. If you did not ask, end your other sessions:": "Odrzucono. Jeśli to nie Ty prosiłeś, zakończ inne sesje:", + "Device": "Urządzenie", + "IP address": "Adres IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Pozwól użytkownikom odblokować nową przeglądarkę, zatwierdzając ją z urządzenia, na którym Keepiq jest już odblokowany.", + "New device approval": "Zatwierdzanie nowych urządzeń", + "Nextcloud security settings": "Ustawienia bezpieczeństwa Nextcloud", + "Only approve a device you are using right now.": "Zatwierdzaj tylko urządzenie, którego właśnie używasz.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otwórz Keepiq na urządzeniu, na którym jest odblokowany, i zatwierdź to urządzenie. Sprawdź, czy wyświetla te same słowa:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Urządzenie zatwierdzające pieczętuje klucz odblokowania dla nowego urządzenia. Serwer tylko go przekazuje i nie może go otworzyć.", + "The master password is not right, or the request has ended.": "Hasło główne jest nieprawidłowe lub żądanie zostało zakończone.", + "The request expired. Ask again or use your master password.": "Żądanie wygasło. Poproś ponownie lub użyj hasła głównego.", + "The request was denied.": "Żądanie zostało odrzucone.", + "Too many requests. Try again in an hour or use your master password.": "Zbyt wiele żądań. Spróbuj ponownie za godzinę lub użyj hasła głównego.", + "Unknown device": "Nieznane urządzenie", + "Web app": "Aplikacja internetowa", + "A device": "Urządzenie", + "A new device asks to open your vault": "Nowe urządzenie prosi o otwarcie Twojego sejfu", + "%s asks to be approved. Only approve a device you are using right now.": "%s prosi o zatwierdzenie. Zatwierdzaj tylko urządzenie, którego właśnie używasz.", + "Access ends on (optional)": "Dostęp wygasa (opcjonalnie)", + "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.": "Aplikacje Keepiq nie pokażą ani nie skopiują hasła. Osoba z wiedzą techniczną nadal może odczytać je na własnym urządzeniu. Zmień je, gdy jej dostęp wygaśnie.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ten sekret jest tylko do użycia. Zaloguj się przez rozszerzenie przeglądarki Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Tylko użycie", + "Use only (can sign in, cannot view or copy)": "Tylko użycie (może się zalogować, nie może wyświetlić ani skopiować)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Możesz zalogować się tym loginem przez rozszerzenie przeglądarki Keepiq. Właściciel nie pozwolił ci go wyświetlać ani kopiować.", + "Your access ends on {date}": "Twój dostęp wygasa {date}", + "Your access to this secret has ended": "Twój dostęp do tego sekretu wygasł", + "Your access to \"%s\" ends tomorrow": "Twój dostęp do „%s” wygasa jutro", + "Your access to \"%s\" has ended": "Twój dostęp do „%s” wygasł", + "%1$s no longer has access to \"%2$s\"": "%1$s nie ma już dostępu do „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mógł widzieć to hasło. Zmień je, jeśli %1$s nie powinien go już znać.", + "%s could not view this password in Keepiq.": "%s nie mógł wyświetlić tego hasła w Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} zatwierdzeń", + "a recovery officer": "opiekun odzyskiwania", + "Account recovery": "Odzyskiwanie konta", + "Approvals needed": "Wymagane zatwierdzenia", + "Ask {user} which words they see, by phone or in person. They must be:": "Zapytaj {user}, jakie słowa widzi, telefonicznie lub osobiście. Muszą to być:", + "Check again": "Sprawdź ponownie", + "Create the recovery key": "Utwórz klucz odzyskiwania", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Utwórz klucz odzyskiwania. Twoja przeglądarka go tworzy i daje każdemu opiekunowi kopię, którą tylko on może otworzyć.", + "Decline": "Odrzuć", + "Enrol in account recovery": "Zapisz się do odzyskiwania konta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Zapisz się, aby Twoja organizacja mogła pomóc Ci odzyskać sejf, jeśli zapomnisz hasła głównego.", + "Every user is enrolled": "Wszyscy użytkownicy są zapisani", + "Finish the recovery in the browser you asked from.": "Dokończ odzyskiwanie w przeglądarce, z której o nie prosiłeś.", + "Forgot your master password?": "Nie pamiętasz hasła głównego?", + "Hand the key over": "Przekaż klucz", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Pozwól użytkownikom, którzy zapomnieli hasła głównego, odzyskać sejf za zgodą wskazanych przez Ciebie opiekunów odzyskiwania.", + "New master password": "Nowe hasło główne", + "No one is asking to recover their account.": "Nikt nie prosi o odzyskanie konta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nie ma jeszcze klucza odzyskiwania. Jeden z opiekunów tworzy go w swoich ustawieniach Keepiq.", + "Off": "Wyłączone", + "Officer {user} has no encryption set up yet.": "Opiekun {user} nie ma jeszcze skonfigurowanego szyfrowania.", + "Officers (user IDs, separated by commas)": "Opiekunowie (ID użytkowników, oddzielone przecinkami)", + "Policy": "Zasady", + "Publish this fingerprint internally, so users can check it before they enrol.": "Opublikuj ten odcisk wewnętrznie, aby użytkownicy mogli go sprawdzić przed zapisaniem się.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Odzyskano z pomocą {officer}. Zmień teraz klucz sejfu w Ustawieniach, Bezpieczeństwo: \"Moje hasło główne zostało ujawnione\".", + "Recovery key fingerprint: {fingerprint}": "Odcisk klucza odzyskiwania: {fingerprint}", + "Recovery officer": "Opiekun odzyskiwania", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Usunięci opiekunowie tracą teraz swoją kopię, ale mogli ją wcześniej otworzyć. Poproś opiekuna o utworzenie nowego klucza odzyskiwania.", + "Repeat the new master password": "Powtórz nowe hasło główne", + "Retire this recovery key": "Wycofaj ten klucz odzyskiwania", + "Set the new master password": "Ustaw nowe hasło główne", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certyfikat odzyskiwania nie został wydany przez ten Keepiq. Nie zapisuj się i powiadom administratora.", + "The words match, approve": "Słowa się zgadzają, zatwierdź", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ten użytkownik jest zapisany do odzyskiwania konta. Odzyskanie zachowuje jego sekrety; unieważnienie usuwa jego zapis.", + "Users may enrol": "Użytkownicy mogą się zapisywać", + "Withdraw from account recovery": "Wypisz się z odzyskiwania konta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jesteś zapisany do odzyskiwania konta. Odcisk klucza odzyskiwania: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jesteś zapisany. Jeśli zapomnisz hasła głównego, Twoja organizacja może pomóc Ci odzyskać sejf.", + "Your key is back. Choose a new master password.": "Twój klucz wrócił. Wybierz nowe hasło główne.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Twoi opiekunowie odzyskiwania zostali powiadomieni. Przeczytaj im te słowa, gdy zadzwonią lub się spotkacie:", + "You are now an account recovery officer": "Jesteś teraz opiekunem odzyskiwania kont", + "%s asks to recover their account. Compare the words with them before you approve.": "%s prosi o odzyskanie konta. Porównaj z tą osobą słowa przed zatwierdzeniem.", + "A user": "Użytkownik", + "Your account recovery request was declined": "Twoja prośba o odzyskanie konta została odrzucona", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Odzyskiwanie konta jest gotowe. Otwórz Keepiq w przeglądarce, z której o nie prosiłeś.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prosi o jednorazowe odblokowanie nowego urządzenia. Hasło główne pozostaje bez zmian.", + "Ask your organisation instead": "Zamiast tego poproś swoją organizację", + "The request ended. Ask again or use your master password.": "Prośba została zakończona. Poproś ponownie lub użyj hasła głównego.", + "Added by {user}": "Dodane przez {user}", + "Editor": "Edytor", + "Manager": "Menedżer", + "Role of {member}": "Rola użytkownika {member}", + "Team folders you manage": "Foldery zespołowe, którymi zarządzasz", + "Viewer": "Czytelnik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nie masz kopii tych sekretów, więc nowi członkowie jeszcze ich nie otrzymali. Właściciel może je udostępnić: {names}", + "Admin areas": "Obszary administracji", + "Give a group only the parts of Keepiq administration it needs.": "Daj grupie tylko te części administracji Keepiq, których potrzebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Przekaż jeden lub więcej obszarów grupie na stronie uprawnień administracyjnych. Administratorzy instancji mają każdy obszar.", + "Open administration privileges": "Otwórz uprawnienia administracyjne", + "Policies": "Zasady", + "Applications and machine access": "Aplikacje i dostęp maszyn", + "People and offboarding": "Osoby i odejścia", + "Audit and compliance": "Audyt i zgodność", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "wersja, urząd certyfikacji, załączniki, pamięć podręczna offline, sprawdzanie wycieków, typy sekretów i kopie zapasowe", + "master password, organisation password, vault policies, rotation, version history and trash": "hasło główne, hasło organizacji, zasady sejfu, rotacja, historia wersji i kosz", + "application queue, application requests and machine leases": "kolejka aplikacji, żądania aplikacji i dzierżawy maszyn", + "team offboarding, encryption suites and admin handover": "odejścia z zespołu, zestawy szyfrowania i przejęcie przez administratora", + "audit log, compliance reports, SIEM export and honey alerts": "dziennik audytu, raporty zgodności, eksport SIEM i alerty przynęt", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Ile wersji sekretu jest przechowywanych, jak długo, i jak długo usunięte sekrety pozostają w koszu.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity zaszyfrowanych załączników, egzekwowane na serwerze w zapisanych zaszyfrowanych bajtach.", + "Type the suite ID again to confirm": "Wpisz ponownie ID pakietu, aby potwierdzić", + "This does not match the suite ID.": "To nie pasuje do ID pakietu.", + "Confirm with your master password": "Potwierdź hasłem głównym", + "Confirm": "Potwierdź", + "That master password is not right.": "To hasło główne jest nieprawidłowe.", + "You are sharing with someone new. Enter your master password to confirm.": "Udostępniasz nowej osobie. Wpisz hasło główne, aby potwierdzić.", + "Enter your master password to confirm this share.": "Wpisz hasło główne, aby potwierdzić to udostępnienie.", + "Enter your master password to confirm this delegation.": "Wpisz hasło główne, aby potwierdzić to delegowanie.", + "Approve {member}": "Zatwierdź {member}", + "Recipient": "Odbiorca", + "No vault yet": "Jeszcze bez skarbca", + "No matching users": "Brak pasujących użytkowników", + "Partner organisations": "Organizacje partnerskie", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Wymieniaj sekrety z innym Keepiq. Obaj administratorzy dodają się nawzajem i przed zapisaniem porównują odciski główne telefonicznie lub osobiście.", + "Federation needs Nextcloud 33 or later.": "Federacja wymaga Nextcloud 33 lub nowszego.", + "Your root fingerprint": "Twój odcisk główny", + "No partners yet.": "Brak partnerów.", + "Users here may share to this partner": "Użytkownicy tutaj mogą udostępniać temu partnerowi", + "This partner may share to users here": "Ten partner może udostępniać użytkownikom tutaj", + "Partner address": "Adres partnera", + "Check partner": "Sprawdź partnera", + "Partner root fingerprint": "Odcisk główny partnera", + "I compared this fingerprint with the partner's administrator": "Porównałem ten odcisk z administratorem partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Sekret z innej organizacji", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Użytkownik %1$s udostępnił Ci \"%2$s\". Zaakceptuj go w sekcji Przychodzące z innych organizacji.", + "Incoming from other organisations": "Przychodzące z innych organizacji", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osoby z organizacji partnerskich mogą udostępnić Ci sekret. Zaakceptuj go, aby zachować kopię tylko do odczytu w swoim skarbcu.", + "Nothing shared with you yet": "Nic Ci jeszcze nie udostępniono", + "Secrets that people in partner organisations share with you appear here.": "Tutaj pojawiają się sekrety, które udostępniają Ci osoby z organizacji partnerskich.", + "From {sender}": "Od {sender}", + "Accept": "Akceptuj", + "Open in vault": "Otwórz w skarbcu", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacja nie przekazała sekretu. Spróbuj ponownie później.", + "Set up your vault before you accept a shared secret.": "Skonfiguruj swój skarbiec, zanim zaakceptujesz udostępniony sekret.", + "Something went wrong. Try again.": "Coś poszło nie tak. Spróbuj ponownie.", + "Waiting for your answer": "Czeka na Twoją odpowiedź", + "In your vault, read-only": "W Twoim skarbcu, tylko do odczytu", + "Withdrawn by the sender": "Wycofane przez nadawcę", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Użytkownik {sender} udostępnił to z innej organizacji. Możesz to odczytać, ale nie możesz tego zmieniać ani udostępniać.", + "Someone": "Ktoś", + "Share with someone at another organisation": "Udostępnij komuś z innej organizacji", + "Their account at the other organisation": "Konto tej osoby w innej organizacji", + "Check account": "Sprawdź konto", + "Certificate fingerprint of {account}": "Odcisk certyfikatu konta {account}", + "Compare it with them by phone if you want to be sure.": "Porównaj go z tą osobą przez telefon, jeśli chcesz mieć pewność.", + "Shared. {account} can accept it in their own vault.": "Udostępniono. {account} może go zaakceptować we własnym skarbcu.", + "The certificate could not be verified. Nothing was shared.": "Nie udało się zweryfikować certyfikatu. Niczego nie udostępniono.", + "That organisation is not one of your partners.": "Ta organizacja nie należy do Twoich partnerów.", + "No one with that account can receive secrets from you.": "Nikt z tym kontem nie może otrzymywać od Ciebie sekretów.", + "The other organisation did not answer. Try again later.": "Druga organizacja nie odpowiedziała. Spróbuj ponownie później.", + "This secret is already shared with that account.": "Ten sekret jest już udostępniony temu kontu.", + "Other organisations": "Inne organizacje", + "Receive secrets from other organisations": "Odbieraj sekrety z innych organizacji", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osoby z organizacji partnerskich mogą wtedy znaleźć Twoje konto i udostępniać Ci sekrety. Każdy z nich akceptujesz samodzielnie.", + "Shared": "Udostępniono", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Wstrzymano: zmienił się ich certyfikat lub partnerstwo. Odwołaj udostępnienie albo udostępnij ponownie.", + "Their organisation did not get the last change. Revoke it or share again.": "Ich organizacja nie otrzymała ostatniej zmiany. Odwołaj udostępnienie albo udostępnij ponownie.", + "Being withdrawn": "Trwa wycofywanie", + "Shared with another organisation": "Udostępniono innej organizacji", + "Change sent to another organisation": "Zmiana wysłana do innej organizacji", + "Share with another organisation revoked": "Udostępnienie innej organizacji odwołane", + "Share with another organisation paused": "Udostępnienie innej organizacji wstrzymane", + "Another organisation did not get a change": "Inna organizacja nie otrzymała zmiany", + "Secret received from another organisation": "Sekret otrzymany z innej organizacji", + "Secret from another organisation accepted": "Sekret z innej organizacji zaakceptowany", + "Secret from another organisation declined": "Sekret z innej organizacji odrzucony", + "Copy from another organisation updated": "Kopia z innej organizacji zaktualizowana", + "Copy from another organisation removed": "Kopia z innej organizacji usunięta", + "Declined: they removed their copy. Share again if they need it.": "Odrzucono: odbiorca usunął swoją kopię. Udostępnij ponownie, jeśli jej potrzebuje.", + "Recipient at another organisation removed their copy": "Odbiorca z innej organizacji usunął swoją kopię", + "Removed the user from %n team folder.": "Usunięto użytkownika z %n folderu zespołu.", + "Removed the user from %n team folders.": "Usunięto użytkownika z %n folderów zespołu.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Usunięto użytkownika z %n folderu zespołu.","Usunięto użytkownika z %n folderów zespołu.","Usunięto użytkownika z %n folderów zespołu."], + "A restored copy came from a share that has ended. It stays read-only.": "Przywrócona kopia pochodzi z udostępnienia, które się zakończyło. Pozostaje tylko do odczytu.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Nie udało się połączyć z organizacją, która udostępniła przywróconą kopię. Kopia pozostaje tylko do odczytu i nie śledzi ich zmian.", + "Recipient at another organisation restored their copy": "Odbiorca z innej organizacji przywrócił swoją kopię" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);" ) diff --git a/l10n/pl.json b/l10n/pl.json index b4b1c25e2..f37cfd06b 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Udostępniono grupom", + "Not shared with any group yet.": "Jeszcze nie udostępniono żadnej grupie.", + "Revoke the share with {group}": "Cofnij udostępnienie dla {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Udostępniono grupie {group}: {received} członków otrzymało, {skipped} nie, ponieważ nie skonfigurowali jeszcze szyfrowania.", + "Search groups": "Szukaj grup", + "Failed to share": "Nie udało się udostępnić", + "Columns": "Kolumny", + "Column {number}": "Kolumna {number}", + "Map one column to Name. Every secret needs a name.": "Przypisz jedną kolumnę do nazwy. Każdy sekret potrzebuje nazwy.", + "Notes": "Notatki", + "Do not import": "Nie importuj", + "Hide this value": "Ukryj tę wartość", + "Show this value": "Pokaż tę wartość", + "Defaults": "Domyślne", + "New secrets start as this type, and your secret list opens in this view.": "Nowe sekrety zaczynają się od tego typu, a lista sekretów otwiera się w tym widoku.", + "Default item type": "Domyślny typ elementu", + "Cards": "Karty", + "Table": "Tabela", + "Could not save your default": "Nie udało się zapisać wartości domyślnej", + "Recently used": "Ostatnio używane", + "Opened": "Otwarto", + "You have not opened any secrets yet": "Nie otwarto jeszcze żadnego sekretu", + "Could not delete the item type.": "Nie udało się usunąć typu elementu.", + "Could not load the item types.": "Nie udało się wczytać typów elementów.", + "Could not save the item type.": "Nie udało się zapisać typu elementu.", + "Delete item type": "Usuń typ elementu", + "Edit item type": "Edytuj typ elementu", + "Fields": "Pola", + "Fields: {count}": "Pola: {count}", + "Hidden": "Ukryte", + "Item types": "Typy elementów", + "Move up": "Przesuń w górę", + "New item type": "Nowy typ elementu", + "No item types defined yet.": "Nie zdefiniowano jeszcze typów elementów.", + "Required": "Wymagane", + "Text": "Tekst", + "This field is required": "To pole jest wymagane", + "Web address": "Adres internetowy", + "{label} (required)": "{label} (wymagane)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Usunąć „{name}”? Sekrety tego typu pozostają czytelne i stają się elementami typu Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy elementów zdefiniowane tutaj pojawiają się u wszystkich w oknie Nowy sekret, z wybranymi przez ciebie polami.", + "Secret moved to the trash": "Sekret przeniesiony do kosza", + "Secret restored from the trash": "Sekret przywrócony z kosza", + "Secret deleted for good": "Sekret usunięty na zawsze", + "Secret archived": "Sekret zarchiwizowany", + "Secret unarchived": "Sekret przywrócony z archiwum", + "Unarchive": "Przywróć z archiwum", + "Could not archive the secret": "Nie udało się zarchiwizować sekretu", + "Could not unarchive the secret": "Nie udało się przywrócić sekretu z archiwum", + "Archive {count} secrets": "Zarchiwizuj sekrety: {count}", + "Unarchive {count} secrets": "Przywróć z archiwum sekrety: {count}", + "Restore {count} secrets": "Przywróć sekrety: {count}", + "Delete {count} secrets for good": "Usuń na zawsze sekrety: {count}", + "Done for {ok} of {total} secrets": "Gotowe dla {ok} z {total} sekretów", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Zarchiwizowane sekrety znikają z listy sejfu, wyszukiwania, autouzupełniania i raportu o stanie haseł. Zachowują udostępnienia. Znajdziesz je w Archiwum.", + "These secrets come back to the vault list, search and autofill.": "Te sekrety wracają na listę sejfu, do wyszukiwania i autouzupełniania.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Te sekrety wracają na listę sejfu. Dawne udostępnienia nie wracają, więc udostępnij je ponownie tam, gdzie trzeba.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "To usuwa sekrety wraz z załącznikami i historią wersji. Tej operacji nie można cofnąć.", + "Delete for good": "Usuń na zawsze", + "Trash": "Kosz", + "The trash is empty": "Kosz jest pusty", + "No archived secrets": "Brak zarchiwizowanych sekretów", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Usunięte sekrety czekają tutaj do końca okresu przechowywania, potem są usuwane na zawsze.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Zarchiwizuj sekret w panelu szczegółów, aby trzymać go poza listą sejfu, wyszukiwaniem i autouzupełnianiem.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity zaszyfrowanych załączników (egzekwowane na serwerze w zapisanych bajtach szyfrogramu), przechowywanie historii wersji i jak długo usunięte sekrety pozostają w koszu.", + "Days a deleted secret stays in the trash (1 to 365)": "Liczba dni, przez które usunięty sekret pozostaje w koszu (od 1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "To przenosi sekret do kosza i od razu kończy jego udostępnienia. Możesz go przywrócić z kosza do końca okresu przechowywania: 30 dni, chyba że administrator to zmienił.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "To przenosi sekrety do kosza ({count}) i od razu kończy ich udostępnienia. Możesz je przywrócić z kosza do końca okresu przechowywania.", + "Remove {name} from favourites": "Usuń {name} z ulubionych", + "Add {name} to favourites": "Dodaj {name} do ulubionych", + "Could not change the favourite": "Nie udało się zmienić ulubionego", + "Remove from favourites": "Usuń z ulubionych", + "Add to favourites": "Dodaj do ulubionych", + "Tags": "Tagi", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Tagi nie są szyfrowane. Administratorzy serwera mogą je odczytać, tak jak nazwy folderów.", + "Favourites": "Ulubione", + "Filter by tag": "Filtruj według tagu", + "All tags": "Wszystkie tagi", + "Last used": "Ostatnio używane", + "Tags for {count} secrets": "Tagi dla {count} sekretów", + "Tag": "Tag", + "Remove tag": "Usuń tag", + "Add tag": "Dodaj tag", + "Could not change the tags. Try again.": "Nie udało się zmienić tagów. Spróbuj ponownie.", + "Could not approve the application. It is still in the queue.": "Nie można zatwierdzić wniosku. Nadal jest w kolejce.", + "Could not reject the application. It is still in the queue.": "Nie można odrzucić wniosku. Nadal jest w kolejce.", + "Removed the user from {count} team folders.": "Usunięto użytkownika z {count} folderów zespołu.", + "Approve a share": "Zatwierdź udostępnienie", + "This approval link is incomplete. Open it again from the notification.": "Ten link zatwierdzający jest niekompletny. Otwórz go ponownie z powiadomienia.", + "Deny": "Odmów", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} dołączył(a) do grupy, z którą udostępniasz sekret. Udostępnić sekret także tej osobie?", + "{requester} asks you to share a secret with {user}.": "{requester} prosi o udostępnienie sekretu użytkownikowi {user}.", + "Shared. The recipient can now open the secret.": "Udostępniono. Odbiorca może teraz otworzyć sekret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Odbiorca nie skonfigurował jeszcze Keepiq, więc nic nie zostało udostępnione. Spróbuj ponownie, gdy to zrobi.", + "Could not share the secret. Only its owner can approve this.": "Nie można udostępnić sekretu. Tylko jego właściciel może to zatwierdzić.", + "Could not share the secret. Try again.": "Nie można udostępnić sekretu. Spróbuj ponownie.", + "Denied. Nothing was shared.": "Odmówiono. Nic nie zostało udostępnione.", + "Could not deny the request. Try again.": "Nie można odrzucić prośby. Spróbuj ponownie.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s prosi o udostępnienie sekretu \"%2$s\" użytkownikowi %3$s.", + "Expires on (optional)": "Wygasa (opcjonalnie)", + "Hand over to": "Przekaż osobie", + "Choose a recipient": "Wybierz odbiorcę", + "Hand over temporarily": "Przekaż tymczasowo", + "Expiry rules": "Reguły wygasania", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ustaw, jak długo mogą być ważne hasła jednego typu elementu lub w jednym folderze oraz kiedy otrzymać przypomnienie. Gdy obowiązuje kilka dat, liczy się najwcześniejsza.", + "Delete rule": "Usuń regułę", + "Set by your administrator": "Ustawione przez administratora", + "No expiry rules yet.": "Brak reguł wygasania.", + "Applies to": "Dotyczy", + "Item type": "Typ elementu", + "Maximum age in days (empty for reminders only)": "Maksymalny wiek w dniach (puste tylko dla przypomnień)", + "Remind me this many days before, comma separated": "Przypomnij tyle dni wcześniej, oddzielone przecinkami", + "Save rule": "Zapisz regułę", + "An item type": "Typ elementu", + "A folder": "Folder", + "Folder {name}": "Folder {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Wygasa po {days} dniach", + "Reminders {days} days before": "Przypomnienia {days} dni wcześniej", + "Could not save the expiry rule.": "Nie można zapisać reguły wygasania.", + "Could not delete the expiry rule.": "Nie można usunąć reguły wygasania.", + "All statuses": "Wszystkie statusy", + "Compromised": "Naruszony", + "Could not load the members.": "Nie udało się wczytać członków.", + "Emergency contact": "Kontakt awaryjny", + "Leaving user": "Odchodzący użytkownik", + "No": "Nie", + "No users match this filter.": "Żaden użytkownik nie pasuje do tego filtra.", + "Not set up": "Nieskonfigurowany", + "Revoke suite": "Unieważnij pakiet", + "Revoked": "Unieważniony", + "Search users": "Szukaj użytkowników", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Zobacz, którzy użytkownicy skonfigurowali sejf. Rozpocznij offboarding lub unieważnij pakiet z wiersza.", + "Successor": "Następca", + "Team folders": "Foldery zespołu", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Użytkownik nadal jest w grupie {groups}, która jest członkiem folderu zespołu. Usuń go z grupy lub wyłącz konto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto.", + "Vault status": "Status sejfu", + "Yes": "Tak", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Eksport CXF jest NIEZASZYFROWANY. Każde hasło i każdy login będą czytelne jako zwykły tekst w pobranym pliku. Przechowuj go bezpiecznie i usuń natychmiast po użyciu.", + "Root certificate expiring soon": "Certyfikat główny wkrótce wygaśnie", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certyfikat główny sejfu wygaśnie za %1$d dni. Odnów go wcześniej. Odnowienie ponownie podpisuje każdy zestaw szyfrowania.", + "Compromise recovery aborted": "Odzyskiwanie po naruszeniu przerwane", + "Key rotation ended by a compromise revoke": "Rotacja klucza zakończona odwołaniem z powodu naruszenia", + "Encryption suite revoke refused": "Odwołanie pakietu szyfrowania odrzucone", + "Master password proof refused": "Dowód hasła głównego odrzucony", + "Your current master password": "Twoje obecne hasło główne", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt awaryjny miał oczekujący wniosek o dostęp, gdy rotacja klucza go usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Te kontakty awaryjne nie zostały przeniesione do nowego klucza. Ich dostęp awaryjny został usunięty. Dodaj je ponownie w Dostępie awaryjnym, jeśli nadal ich chcesz.", + "Renew root certificate": "Odnów certyfikat główny", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Zostanie utworzony nowy certyfikat główny i pośredni. Każdy aktywny zestaw szyfrowania zostanie ponownie podpisany. Tej operacji nie można cofnąć.", + "Renew root": "Odnów główny", + "Root renewed. {n} encryption suites signed again.": "Certyfikat główny odnowiony. Ponownie podpisane zestawy szyfrowania: {n}.", + "Could not renew the root certificate.": "Nie udało się odnowić certyfikatu głównego.", + "Lease policy for this application": "Zasady dzierżawy dla tej aplikacji", + "In force now: {default} seconds by default, {max} seconds at most.": "Obecnie obowiązuje: domyślnie {default} sekund, najwyżej {max} sekund.", + "Leases are not renewable": "Dzierżaw nie można odnawiać", + "Lease policy saved.": "Zasady dzierżawy zapisane.", + "Leave a field empty to use the instance value.": "Pozostaw pole puste, aby użyć wartości instancji.", + "Instance value: {value}": "Wartość instancji: {value}", + "Renewal": "Odnawianie", + "Use the instance value ({value})": "Użyj wartości instancji ({value})", + "Allowed": "Dozwolone", + "Not allowed": "Niedozwolone", + "Save lease policy": "Zapisz zasady dzierżawy", + "Only an administrator can change this policy.": "Tylko administrator może zmienić te zasady.", + "Could not save the lease policy.": "Nie udało się zapisać zasad dzierżawy.", + "{member} got access from {confirmer}.": "{member} otrzymał dostęp od {confirmer}.", + "Automatically confirm new team folder members": "Automatycznie potwierdzaj nowych członków folderów zespołu", + "Gave %n new member access to a team folder.": "%n nowy członek otrzymał dostęp do folderu zespołu.", + "Gave %n new members access to a team folder.": "Nowi członkowie (%n) otrzymali dostęp do folderu zespołu.", + "Give new team folder members access without waiting for the folder owner.": "Daj nowym członkom dostęp bez czekania na właściciela folderu.", + "New team folder members": "Nowi członkowie folderów zespołu", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Właściciel lub członek z prawem zapisu potwierdza ich z otwartego sejfu. Keepiq nigdy nie odszyfrowuje na serwerze.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Czekam, aż członek z prawem zapisu otworzy Keepiq. Możesz też udostępnić teraz.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Część reakcji na naruszenie nie powiodła się ({failed} krok(ów)). Sprawdź dziennik serwera, a następnie ponownie unieważnij zestaw, aby ją dokończyć.", + "This also revoked suite {suite} and ended key migration {migration}.": "Unieważniło to także zestaw {suite} i zakończyło migrację kluczy {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Unieważnienie drugiego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Revoking the second suite deleted %n emergency-access contacts.": "Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "A suite revoked as compromised cannot be reinstated.": "Zestawu unieważnionego jako naruszony nie można przywrócić.", + "Archives to keep": "Archiwa do zachowania", + "Back up every vault automatically": "Automatycznie twórz kopię każdego sejfu", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Twórz kopię każdego sejfu według harmonogramu. Archiwa zawierają tylko szyfrogram i przywraca się je przez occ.", + "Back up now": "Utwórz kopię teraz", + "Backup public key (PEM, optional)": "Klucz publiczny kopii (PEM, opcjonalny)", + "Backup requested for the next cron run": "Kopia zamówiona na następne uruchomienie crona", + "Encrypted": "Zaszyfrowane", + "Every (hours)": "Co (godzin)", + "Last backup {when} failed: {error}": "Ostatnia kopia {when} nieudana: {error}", + "Last backup {when} succeeded.": "Ostatnia kopia {when} udana.", + "No archives yet.": "Brak archiwów.", + "Size": "Rozmiar", + "Vault backups": "Kopie sejfu", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Z kluczem każde archiwum jest dla niego szyfrowane. Trzymaj klucz prywatny poza tym serwerem: jest potrzebny do weryfikacji lub przywrócenia.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n użytkownik w zakresie nie ma jeszcze logowania dwuetapowego i nie może otworzyć sejfu, dopóki to jest włączone.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Kody zapasowe się nie liczą. Jeśli użytkownicy logują się przez dostawcę tożsamości z własnym drugim składnikiem, pomiń ich grupy.", + "Block personal vault export": "Blokuj eksport osobistego sejfu", + "Keep work logins in team folders": "Przechowuj służbowe dane logowania w folderach zespołu", + "Move to a team folder": "Przenieś do folderu zespołu", + "Not in a team folder": "Nie w folderze zespołu", + "Only for these groups (empty is everyone)": "Tylko dla tych grup (puste oznacza wszystkich)", + "Require two-factor login before the vault opens": "Wymagaj logowania dwuetapowego przed otwarciem sejfu", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Zasady dla każdego sejfu. Każda dotyczy wszystkich albo tylko wybranych grup.", + "Secret types that belong in a team folder": "Typy sekretów, które należą do folderu zespołu", + "Set up two-factor login": "Skonfiguruj logowanie dwuetapowe", + "Team folder you can write to": "Folder zespołu, w którym możesz zapisywać", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Użytkownicy nie mogą pobrać kopii zapasowej, pliku CSV ani pliku przeniesienia. Ich pakiet danych osobowych pozostaje dostępny.", + "Users cannot save these secret types in a personal folder.": "Użytkownicy nie mogą zapisywać tych typów sekretów w folderze osobistym.", + "Vault policies": "Zasady sejfu", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Twoja organizacja nie pozwala eksportować osobistego sejfu. Twój pakiet danych osobowych w ustawieniach pozostaje dostępny.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Twoja organizacja przechowuje te sekrety w folderze zespołu. Przenieś każdy do folderu zespołu.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Twoja organizacja przechowuje ten typ sekretu w folderze zespołu. Wybierz jeden ze swoich folderów zespołu lub taki, w którym możesz zapisywać.", + "Your organisation requires two-factor login before you can open your vault.": "Twoja organizacja wymaga logowania dwuetapowego, zanim otworzysz sejf.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Użytkownicy wybierają, jak długo rozszerzenie pozostaje odblokowane podczas bezczynności. Ty ustalasz najdłuższy czas, jaki mogą wybrać.", + "Longest idle time before the extension locks": "Najdłuższy czas bezczynności przed zablokowaniem rozszerzenia", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 godzina", + "4 hours": "4 godziny", + "Connector": "Łącznik", + "Directory (tenant) ID": "Identyfikator katalogu (dzierżawcy)", + "Application (client) ID": "Identyfikator aplikacji (klienta)", + "Data collection rule immutable ID": "Niezmienny identyfikator reguły zbierania danych", + "Stream name": "Nazwa strumienia", + "Splunk index (optional)": "Indeks Splunk (opcjonalnie)", + "Sourcetype (optional)": "Sourcetype (opcjonalnie)", + "Leave blank to keep the current one": "Pozostaw puste, aby zachować obecny", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF przez syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punkt końcowy zbierania danych (adres https)", + "HTTP Event Collector URL (https)": "Adres HTTP Event Collector (https)", + "Client secret (write-only)": "Klucz tajny klienta (tylko zapis)", + "HEC token (write-only)": "Token HEC (tylko zapis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Przekazuj dozwolone zdarzenia audytu do Splunk, Microsoft Sentinel, odbiornika syslog lub webhooka. Wiadomości zawierają tylko oczyszczone metadane: żadna tajna wartość, nazwa, login ani zaszyfrowany tekst nigdy nie opuszcza serwera.", + "%n change waiting to sync": "%n zmiana czeka na synchronizację", + "%n changes waiting to sync": "%n zmian czeka na synchronizację", + "Changes that could not sync": "Zmiany, których nie udało się zsynchronizować", + "Choose a version": "Wybierz wersję", + "Copy value": "Kopiuj wartość", + "Deleted": "Usunięto", + "Discard": "Odrzuć", + "Keep my offline change": "Zachowaj moją zmianę offline", + "Keep the server version": "Zachowaj wersję z serwera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq jest offline tylko do odczytu. Administrator nie włączył edycji offline.", + "Let users edit secrets offline": "Pozwól użytkownikom edytować sekrety offline", + "Not synced yet": "Jeszcze niezsynchronizowane", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Zmiany offline zostają na urządzeniu, zaszyfrowane dla użytkownika, i synchronizują się przy następnym odblokowaniu online. Udostępnianie, foldery i załączniki nadal wymagają połączenia.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edycje, przeniesienia i usunięcia zostają na tym urządzeniu i synchronizują się, gdy wrócisz do sieci. Udostępnianie i załączniki wymagają połączenia.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Twoje zmiany zostają na tym urządzeniu i synchronizują się, gdy wrócisz do sieci. Ostatnia synchronizacja {when}.", + "Open my changes": "Otwórz moje zmiany", + "Sharing needs a connection": "Udostępnianie wymaga połączenia", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Ktoś zmienił ten sekret na serwerze po utworzeniu Twojej kopii offline. Wybierz, którą wersję zachować.", + "Sync or discard your offline changes before you rotate your keys.": "Zsynchronizuj lub odrzuć zmiany offline, zanim wymienisz klucze.", + "That password did not open your changes.": "To hasło nie otworzyło Twoich zmian.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Migawka offline przechowuje zaszyfrowane sekrety (do otwarcia tylko kluczem wyprowadzonym z hasła głównego użytkownika, dokładnie jak na serwerze) i szyfruje nazwy, adresy URL i nazwy folderów w spoczynku. Dostęp offline jest tylko do odczytu, chyba że poniżej zezwolisz na edycję offline. Wyłącz to dla urządzeń, które nigdy nie mogą przechowywać danych logowania; wyłączenie czyści istniejące pamięci podręczne przy następnym wczytaniu.", + "The previous vault copy is gone, so these changes cannot be opened.": "Poprzedniej kopii sejfu już nie ma, więc tych zmian nie można otworzyć.", + "The server version": "Wersja z serwera", + "This secret changed while you were offline": "Ten sekret zmienił się, gdy byłeś offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Usunąłeś ten sekret offline, ale od tego czasu zmieniono go na serwerze. Wybierz, którą wersję zachować.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Twoje klucze zostały zmienione na innym urządzeniu. Wpisz poprzednie hasło główne, aby zsynchronizować zmiany offline, lub je odrzuć.", + "Your offline change": "Twoja zmiana offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n kontakt awaryjny miał oczekujący wniosek o dostęp, gdy rotacja klucza go usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie.", + "Kontakty awaryjne (%n) miały oczekujący wniosek o dostęp, gdy rotacja klucza je usunęła. Sprawdź, kto prosił, zanim dodasz kogokolwiek ponownie." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n element nie może zostać przedstawiony w CXF i zostanie pominięty.", + "%n elementów nie może zostać przedstawionych w CXF i zostanie pominiętych.", + "%n elementów nie może zostać przedstawionych w CXF i zostanie pominiętych." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n starsza wersja została odrzucona, ponieważ przenieść można tylko niedawną historię.", + "%n starszych wersji zostało odrzuconych, ponieważ przenieść można tylko niedawną historię.", + "%n starszych wersji zostało odrzuconych, ponieważ przenieść można tylko niedawną historię." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopia sekretu wciąż wymaga zaszyfrowania i udostępnienia.", + "%n kopii sekretu wciąż wymaga zaszyfrowania i udostępnienia.", + "%n kopii sekretu wciąż wymaga zaszyfrowania i udostępnienia." + ], + "_%n secret could not be decrypted and is not in this export._::_%n secrets could not be decrypted and are not in this export._": [ + "Nie udało się odszyfrować %n sekretu i nie ma go w tym eksporcie.", + "Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie.", + "Nie udało się odszyfrować %n sekretów i nie ma ich w tym eksporcie." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n sekretu nie udało się odszyfrować starym kluczem, dlatego nie został przeniesiony.", + "%n sekretów nie udało się odszyfrować starym kluczem, dlatego nie zostały przeniesione.", + "%n sekretów nie udało się odszyfrować starym kluczem, dlatego nie zostały przeniesione." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n sekret nie został przeniesiony.", + "%n sekretów nie zostało przeniesionych.", + "%n sekretów nie zostało przeniesionych." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n sekret jest nadal zaszyfrowany twoim poprzednim kluczem.", + "%n sekretów jest nadal zaszyfrowanych twoim poprzednim kluczem.", + "%n sekretów jest nadal zaszyfrowanych twoim poprzednim kluczem." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "Pominięto %n sekret, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie.", + "Pominięto %n sekretów, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie.", + "Pominięto %n sekretów, ponieważ następca nie ma jeszcze kopii — dodaj następcę do folderu i uruchom ponownie." + ], + "_%n secret_::_%n secrets_": [ + "%n sekret", + "%n sekretów", + "%n sekretów" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n użytkownik w zakresie nie ma jeszcze logowania dwuetapowego i nie może otworzyć sejfu, dopóki to jest włączone.", + "Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone.", + "Użytkownicy w zakresie (%n) nie mają jeszcze logowania dwuetapowego i nie mogą otworzyć sejfu, dopóki to jest włączone." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Zakończ i tak, tracąc dostęp do %n sekretu", + "Zakończ i tak, tracąc dostęp do %n sekretów", + "Zakończ i tak, tracąc dostęp do %n sekretów" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nowy członek otrzymał dostęp do folderu zespołu.", + "Nowi członkowie (%n) otrzymali dostęp do folderu zespołu.", + "Nowi członkowie (%n) otrzymali dostęp do folderu zespołu." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotacja klucza zakończona. %n sekret został ponownie zaszyfrowany nowym kluczem.", + "Rotacja klucza zakończona. %n sekretów zostało ponownie zaszyfrowanych nowym kluczem.", + "Rotacja klucza zakończona. %n sekretów zostało ponownie zaszyfrowanych nowym kluczem." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Unieważnienie drugiego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "Unieważnienie drugiego zestawu usunęło %n kontaktów dostępu awaryjnego." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Unieważnienie tego zestawu usunęło %n kontakt dostępu awaryjnego.", + "Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego.", + "Unieważnienie tego zestawu usunęło %n kontaktów dostępu awaryjnego." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "widziane %n raz w wyciekach", + "widziane %n razy w wyciekach", + "widziane %n razy w wyciekach" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "udostępniony %n sekretowi", + "udostępniony %n sekretom", + "udostępniony %n sekretom" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ten folder zawiera bezpośrednio %n sekret.", + "Ten folder zawiera bezpośrednio %n sekretów.", + "Ten folder zawiera bezpośrednio %n sekretów." + ], + "_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._": [ + "Rotacja klucza usunęła %n kontakt awaryjny. Sprawdź Dostęp awaryjny i dodaj go ponownie, jeśli nadal go chcesz.", + "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz.", + "Rotacja klucza usunęła %n kontaktów awaryjnych. Sprawdź Dostęp awaryjny i dodaj je ponownie, jeśli nadal ich chcesz." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n zmiana czeka na synchronizację", + "%n zmian czeka na synchronizację", + "%n zmian czeka na synchronizację" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Użytkownik nadal jest w grupie {groups}, która jest członkiem folderu zespołu. Usuń go z grupy lub wyłącz konto.", + "Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto.", + "Użytkownik nadal jest w grupach {groups}, które są członkami folderów zespołu. Usuń go z grup lub wyłącz konto." + ], + "Allow approval from another device": "Zezwalaj na zatwierdzanie z innego urządzenia", + "App": "Aplikacja", + "Approve a new device": "Zatwierdź nowe urządzenie", + "Approve from another device": "Zatwierdź z innego urządzenia", + "Asked at": "Poproszono o", + "Check that the new device shows these words:": "Sprawdź, czy nowe urządzenie wyświetla te słowa:", + "Denied. If you did not ask, end your other sessions:": "Odrzucono. Jeśli to nie Ty prosiłeś, zakończ inne sesje:", + "Device": "Urządzenie", + "IP address": "Adres IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Pozwól użytkownikom odblokować nową przeglądarkę, zatwierdzając ją z urządzenia, na którym Keepiq jest już odblokowany.", + "New device approval": "Zatwierdzanie nowych urządzeń", + "Nextcloud security settings": "Ustawienia bezpieczeństwa Nextcloud", + "Only approve a device you are using right now.": "Zatwierdzaj tylko urządzenie, którego właśnie używasz.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otwórz Keepiq na urządzeniu, na którym jest odblokowany, i zatwierdź to urządzenie. Sprawdź, czy wyświetla te same słowa:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Urządzenie zatwierdzające pieczętuje klucz odblokowania dla nowego urządzenia. Serwer tylko go przekazuje i nie może go otworzyć.", + "The master password is not right, or the request has ended.": "Hasło główne jest nieprawidłowe lub żądanie zostało zakończone.", + "The request expired. Ask again or use your master password.": "Żądanie wygasło. Poproś ponownie lub użyj hasła głównego.", + "The request was denied.": "Żądanie zostało odrzucone.", + "Too many requests. Try again in an hour or use your master password.": "Zbyt wiele żądań. Spróbuj ponownie za godzinę lub użyj hasła głównego.", + "Unknown device": "Nieznane urządzenie", + "Web app": "Aplikacja internetowa", + "A device": "Urządzenie", + "A new device asks to open your vault": "Nowe urządzenie prosi o otwarcie Twojego sejfu", + "%s asks to be approved. Only approve a device you are using right now.": "%s prosi o zatwierdzenie. Zatwierdzaj tylko urządzenie, którego właśnie używasz.", + "Access ends on (optional)": "Dostęp wygasa (opcjonalnie)", + "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.": "Aplikacje Keepiq nie pokażą ani nie skopiują hasła. Osoba z wiedzą techniczną nadal może odczytać je na własnym urządzeniu. Zmień je, gdy jej dostęp wygaśnie.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ten sekret jest tylko do użycia. Zaloguj się przez rozszerzenie przeglądarki Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Tylko użycie", + "Use only (can sign in, cannot view or copy)": "Tylko użycie (może się zalogować, nie może wyświetlić ani skopiować)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Możesz zalogować się tym loginem przez rozszerzenie przeglądarki Keepiq. Właściciel nie pozwolił ci go wyświetlać ani kopiować.", + "Your access ends on {date}": "Twój dostęp wygasa {date}", + "Your access to this secret has ended": "Twój dostęp do tego sekretu wygasł", + "Your access to \"%s\" ends tomorrow": "Twój dostęp do „%s” wygasa jutro", + "Your access to \"%s\" has ended": "Twój dostęp do „%s” wygasł", + "%1$s no longer has access to \"%2$s\"": "%1$s nie ma już dostępu do „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mógł widzieć to hasło. Zmień je, jeśli %1$s nie powinien go już znać.", + "%s could not view this password in Keepiq.": "%s nie mógł wyświetlić tego hasła w Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} zatwierdzeń", + "a recovery officer": "opiekun odzyskiwania", + "Account recovery": "Odzyskiwanie konta", + "Approvals needed": "Wymagane zatwierdzenia", + "Ask {user} which words they see, by phone or in person. They must be:": "Zapytaj {user}, jakie słowa widzi, telefonicznie lub osobiście. Muszą to być:", + "Check again": "Sprawdź ponownie", + "Create the recovery key": "Utwórz klucz odzyskiwania", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Utwórz klucz odzyskiwania. Twoja przeglądarka go tworzy i daje każdemu opiekunowi kopię, którą tylko on może otworzyć.", + "Decline": "Odrzuć", + "Enrol in account recovery": "Zapisz się do odzyskiwania konta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Zapisz się, aby Twoja organizacja mogła pomóc Ci odzyskać sejf, jeśli zapomnisz hasła głównego.", + "Every user is enrolled": "Wszyscy użytkownicy są zapisani", + "Finish the recovery in the browser you asked from.": "Dokończ odzyskiwanie w przeglądarce, z której o nie prosiłeś.", + "Forgot your master password?": "Nie pamiętasz hasła głównego?", + "Hand the key over": "Przekaż klucz", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Pozwól użytkownikom, którzy zapomnieli hasła głównego, odzyskać sejf za zgodą wskazanych przez Ciebie opiekunów odzyskiwania.", + "New master password": "Nowe hasło główne", + "No one is asking to recover their account.": "Nikt nie prosi o odzyskanie konta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Nie ma jeszcze klucza odzyskiwania. Jeden z opiekunów tworzy go w swoich ustawieniach Keepiq.", + "Off": "Wyłączone", + "Officer {user} has no encryption set up yet.": "Opiekun {user} nie ma jeszcze skonfigurowanego szyfrowania.", + "Officers (user IDs, separated by commas)": "Opiekunowie (ID użytkowników, oddzielone przecinkami)", + "Policy": "Zasady", + "Publish this fingerprint internally, so users can check it before they enrol.": "Opublikuj ten odcisk wewnętrznie, aby użytkownicy mogli go sprawdzić przed zapisaniem się.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Odzyskano z pomocą {officer}. Zmień teraz klucz sejfu w Ustawieniach, Bezpieczeństwo: \"Moje hasło główne zostało ujawnione\".", + "Recovery key fingerprint: {fingerprint}": "Odcisk klucza odzyskiwania: {fingerprint}", + "Recovery officer": "Opiekun odzyskiwania", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Usunięci opiekunowie tracą teraz swoją kopię, ale mogli ją wcześniej otworzyć. Poproś opiekuna o utworzenie nowego klucza odzyskiwania.", + "Repeat the new master password": "Powtórz nowe hasło główne", + "Retire this recovery key": "Wycofaj ten klucz odzyskiwania", + "Set the new master password": "Ustaw nowe hasło główne", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certyfikat odzyskiwania nie został wydany przez ten Keepiq. Nie zapisuj się i powiadom administratora.", + "The words match, approve": "Słowa się zgadzają, zatwierdź", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ten użytkownik jest zapisany do odzyskiwania konta. Odzyskanie zachowuje jego sekrety; unieważnienie usuwa jego zapis.", + "Users may enrol": "Użytkownicy mogą się zapisywać", + "Withdraw from account recovery": "Wypisz się z odzyskiwania konta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jesteś zapisany do odzyskiwania konta. Odcisk klucza odzyskiwania: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jesteś zapisany. Jeśli zapomnisz hasła głównego, Twoja organizacja może pomóc Ci odzyskać sejf.", + "Your key is back. Choose a new master password.": "Twój klucz wrócił. Wybierz nowe hasło główne.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Twoi opiekunowie odzyskiwania zostali powiadomieni. Przeczytaj im te słowa, gdy zadzwonią lub się spotkacie:", + "You are now an account recovery officer": "Jesteś teraz opiekunem odzyskiwania kont", + "%s asks to recover their account. Compare the words with them before you approve.": "%s prosi o odzyskanie konta. Porównaj z tą osobą słowa przed zatwierdzeniem.", + "A user": "Użytkownik", + "Your account recovery request was declined": "Twoja prośba o odzyskanie konta została odrzucona", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Odzyskiwanie konta jest gotowe. Otwórz Keepiq w przeglądarce, z której o nie prosiłeś.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prosi o jednorazowe odblokowanie nowego urządzenia. Hasło główne pozostaje bez zmian.", + "Ask your organisation instead": "Zamiast tego poproś swoją organizację", + "The request ended. Ask again or use your master password.": "Prośba została zakończona. Poproś ponownie lub użyj hasła głównego.", + "Added by {user}": "Dodane przez {user}", + "Editor": "Edytor", + "Manager": "Menedżer", + "Role of {member}": "Rola użytkownika {member}", + "Team folders you manage": "Foldery zespołowe, którymi zarządzasz", + "Viewer": "Czytelnik", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nie masz kopii tych sekretów, więc nowi członkowie jeszcze ich nie otrzymali. Właściciel może je udostępnić: {names}", + "Admin areas": "Obszary administracji", + "Give a group only the parts of Keepiq administration it needs.": "Daj grupie tylko te części administracji Keepiq, których potrzebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Przekaż jeden lub więcej obszarów grupie na stronie uprawnień administracyjnych. Administratorzy instancji mają każdy obszar.", + "Open administration privileges": "Otwórz uprawnienia administracyjne", + "Policies": "Zasady", + "Applications and machine access": "Aplikacje i dostęp maszyn", + "People and offboarding": "Osoby i odejścia", + "Audit and compliance": "Audyt i zgodność", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "wersja, urząd certyfikacji, załączniki, pamięć podręczna offline, sprawdzanie wycieków, typy sekretów i kopie zapasowe", + "master password, organisation password, vault policies, rotation, version history and trash": "hasło główne, hasło organizacji, zasady sejfu, rotacja, historia wersji i kosz", + "application queue, application requests and machine leases": "kolejka aplikacji, żądania aplikacji i dzierżawy maszyn", + "team offboarding, encryption suites and admin handover": "odejścia z zespołu, zestawy szyfrowania i przejęcie przez administratora", + "audit log, compliance reports, SIEM export and honey alerts": "dziennik audytu, raporty zgodności, eksport SIEM i alerty przynęt", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Ile wersji sekretu jest przechowywanych, jak długo, i jak długo usunięte sekrety pozostają w koszu.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity zaszyfrowanych załączników, egzekwowane na serwerze w zapisanych zaszyfrowanych bajtach.", + "Type the suite ID again to confirm": "Wpisz ponownie ID pakietu, aby potwierdzić", + "This does not match the suite ID.": "To nie pasuje do ID pakietu.", + "Confirm with your master password": "Potwierdź hasłem głównym", + "Confirm": "Potwierdź", + "That master password is not right.": "To hasło główne jest nieprawidłowe.", + "You are sharing with someone new. Enter your master password to confirm.": "Udostępniasz nowej osobie. Wpisz hasło główne, aby potwierdzić.", + "Enter your master password to confirm this share.": "Wpisz hasło główne, aby potwierdzić to udostępnienie.", + "Enter your master password to confirm this delegation.": "Wpisz hasło główne, aby potwierdzić to delegowanie.", + "Approve {member}": "Zatwierdź {member}", + "Recipient": "Odbiorca", + "No vault yet": "Jeszcze bez skarbca", + "No matching users": "Brak pasujących użytkowników", + "Partner organisations": "Organizacje partnerskie", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Wymieniaj sekrety z innym Keepiq. Obaj administratorzy dodają się nawzajem i przed zapisaniem porównują odciski główne telefonicznie lub osobiście.", + "Federation needs Nextcloud 33 or later.": "Federacja wymaga Nextcloud 33 lub nowszego.", + "Your root fingerprint": "Twój odcisk główny", + "No partners yet.": "Brak partnerów.", + "Users here may share to this partner": "Użytkownicy tutaj mogą udostępniać temu partnerowi", + "This partner may share to users here": "Ten partner może udostępniać użytkownikom tutaj", + "Partner address": "Adres partnera", + "Check partner": "Sprawdź partnera", + "Partner root fingerprint": "Odcisk główny partnera", + "I compared this fingerprint with the partner's administrator": "Porównałem ten odcisk z administratorem partnera", + "Add partner": "Dodaj partnera", + "A secret from another organisation": "Sekret z innej organizacji", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Użytkownik %1$s udostępnił Ci \"%2$s\". Zaakceptuj go w sekcji Przychodzące z innych organizacji.", + "Incoming from other organisations": "Przychodzące z innych organizacji", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osoby z organizacji partnerskich mogą udostępnić Ci sekret. Zaakceptuj go, aby zachować kopię tylko do odczytu w swoim skarbcu.", + "Nothing shared with you yet": "Nic Ci jeszcze nie udostępniono", + "Secrets that people in partner organisations share with you appear here.": "Tutaj pojawiają się sekrety, które udostępniają Ci osoby z organizacji partnerskich.", + "From {sender}": "Od {sender}", + "Accept": "Akceptuj", + "Open in vault": "Otwórz w skarbcu", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacja nie przekazała sekretu. Spróbuj ponownie później.", + "Set up your vault before you accept a shared secret.": "Skonfiguruj swój skarbiec, zanim zaakceptujesz udostępniony sekret.", + "Something went wrong. Try again.": "Coś poszło nie tak. Spróbuj ponownie.", + "Waiting for your answer": "Czeka na Twoją odpowiedź", + "In your vault, read-only": "W Twoim skarbcu, tylko do odczytu", + "Withdrawn by the sender": "Wycofane przez nadawcę", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Użytkownik {sender} udostępnił to z innej organizacji. Możesz to odczytać, ale nie możesz tego zmieniać ani udostępniać.", + "Someone": "Ktoś", + "Share with someone at another organisation": "Udostępnij komuś z innej organizacji", + "Their account at the other organisation": "Konto tej osoby w innej organizacji", + "Check account": "Sprawdź konto", + "Certificate fingerprint of {account}": "Odcisk certyfikatu konta {account}", + "Compare it with them by phone if you want to be sure.": "Porównaj go z tą osobą przez telefon, jeśli chcesz mieć pewność.", + "Shared. {account} can accept it in their own vault.": "Udostępniono. {account} może go zaakceptować we własnym skarbcu.", + "The certificate could not be verified. Nothing was shared.": "Nie udało się zweryfikować certyfikatu. Niczego nie udostępniono.", + "That organisation is not one of your partners.": "Ta organizacja nie należy do Twoich partnerów.", + "No one with that account can receive secrets from you.": "Nikt z tym kontem nie może otrzymywać od Ciebie sekretów.", + "The other organisation did not answer. Try again later.": "Druga organizacja nie odpowiedziała. Spróbuj ponownie później.", + "This secret is already shared with that account.": "Ten sekret jest już udostępniony temu kontu.", + "Other organisations": "Inne organizacje", + "Receive secrets from other organisations": "Odbieraj sekrety z innych organizacji", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osoby z organizacji partnerskich mogą wtedy znaleźć Twoje konto i udostępniać Ci sekrety. Każdy z nich akceptujesz samodzielnie.", + "Shared": "Udostępniono", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Wstrzymano: zmienił się ich certyfikat lub partnerstwo. Odwołaj udostępnienie albo udostępnij ponownie.", + "Their organisation did not get the last change. Revoke it or share again.": "Ich organizacja nie otrzymała ostatniej zmiany. Odwołaj udostępnienie albo udostępnij ponownie.", + "Being withdrawn": "Trwa wycofywanie", + "Shared with another organisation": "Udostępniono innej organizacji", + "Change sent to another organisation": "Zmiana wysłana do innej organizacji", + "Share with another organisation revoked": "Udostępnienie innej organizacji odwołane", + "Share with another organisation paused": "Udostępnienie innej organizacji wstrzymane", + "Another organisation did not get a change": "Inna organizacja nie otrzymała zmiany", + "Secret received from another organisation": "Sekret otrzymany z innej organizacji", + "Secret from another organisation accepted": "Sekret z innej organizacji zaakceptowany", + "Secret from another organisation declined": "Sekret z innej organizacji odrzucony", + "Copy from another organisation updated": "Kopia z innej organizacji zaktualizowana", + "Copy from another organisation removed": "Kopia z innej organizacji usunięta", + "Declined: they removed their copy. Share again if they need it.": "Odrzucono: odbiorca usunął swoją kopię. Udostępnij ponownie, jeśli jej potrzebuje.", + "Recipient at another organisation removed their copy": "Odbiorca z innej organizacji usunął swoją kopię", + "Removed the user from %n team folder.": "Usunięto użytkownika z %n folderu zespołu.", + "Removed the user from %n team folders.": "Usunięto użytkownika z %n folderów zespołu.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Usunięto użytkownika z %n folderu zespołu.", + "Usunięto użytkownika z %n folderów zespołu.", + "Usunięto użytkownika z %n folderów zespołu." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Przywrócona kopia pochodzi z udostępnienia, które się zakończyło. Pozostaje tylko do odczytu.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Nie udało się połączyć z organizacją, która udostępniła przywróconą kopię. Kopia pozostaje tylko do odczytu i nie śledzi ich zmian.", + "Recipient at another organisation restored their copy": "Odbiorca z innej organizacji przywrócił swoją kopię" }, "plurals": null } diff --git a/l10n/pt.js b/l10n/pt.js index f0d8cf023..edd367625 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Partilhado com grupos", + "Not shared with any group yet.": "Ainda não partilhado com nenhum grupo.", + "Revoke the share with {group}": "Revogar a partilha com {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partilhado com {group}: {received} membros receberam-no, {skipped} não porque ainda não configuraram a encriptação.", + "Search groups": "Pesquisar grupos", + "Failed to share": "Falha ao partilhar", + "Columns": "Colunas", + "Column {number}": "Coluna {number}", + "Map one column to Name. Every secret needs a name.": "Associe uma coluna ao nome. Cada segredo precisa de um nome.", + "Notes": "Notas", + "Do not import": "Não importar", + "Hide this value": "Ocultar este valor", + "Show this value": "Mostrar este valor", + "Defaults": "Predefinições", + "New secrets start as this type, and your secret list opens in this view.": "Os novos segredos começam com este tipo e a sua lista de segredos abre nesta vista.", + "Default item type": "Tipo de item predefinido", + "Cards": "Cartões", + "Table": "Tabela", + "Could not save your default": "Não foi possível guardar a sua predefinição", + "Recently used": "Usados recentemente", + "Opened": "Aberto", + "You have not opened any secrets yet": "Ainda não abriu nenhum segredo", + "Could not delete the item type.": "Não foi possível eliminar o tipo de item.", + "Could not load the item types.": "Não foi possível carregar os tipos de item.", + "Could not save the item type.": "Não foi possível guardar o tipo de item.", + "Delete item type": "Eliminar tipo de item", + "Edit item type": "Editar tipo de item", + "Fields": "Campos", + "Fields: {count}": "Campos: {count}", + "Hidden": "Oculto", + "Item types": "Tipos de item", + "Move up": "Mover para cima", + "New item type": "Novo tipo de item", + "No item types defined yet.": "Ainda não há tipos de item definidos.", + "Required": "Obrigatório", + "Text": "Texto", + "This field is required": "Este campo é obrigatório", + "Web address": "Endereço web", + "{label} (required)": "{label} (obrigatório)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eliminar «{name}»? Os segredos deste tipo continuam legíveis e passam a itens de Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Os tipos de item que definir aqui aparecem para todos na janela Novo segredo, com os campos que escolher.", + "Secret moved to the trash": "Segredo movido para o lixo", + "Secret restored from the trash": "Segredo restaurado do lixo", + "Secret deleted for good": "Segredo eliminado definitivamente", + "Secret archived": "Segredo arquivado", + "Secret unarchived": "Segredo desarquivado", + "Unarchive": "Desarquivar", + "Could not archive the secret": "Não foi possível arquivar o segredo", + "Could not unarchive the secret": "Não foi possível desarquivar o segredo", + "Archive {count} secrets": "Arquivar {count} segredos", + "Unarchive {count} secrets": "Desarquivar {count} segredos", + "Restore {count} secrets": "Restaurar {count} segredos", + "Delete {count} secrets for good": "Eliminar definitivamente {count} segredos", + "Done for {ok} of {total} secrets": "Concluído para {ok} de {total} segredos", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Os segredos arquivados saem da lista do cofre, da pesquisa, do preenchimento automático e do relatório de saúde. Mantêm as partilhas. Encontra-os em Arquivo.", + "These secrets come back to the vault list, search and autofill.": "Estes segredos voltam à lista do cofre, à pesquisa e ao preenchimento automático.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Estes segredos voltam à lista do cofre. As partilhas antigas não voltam, por isso partilhe-os de novo onde for preciso.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Isto elimina os segredos com os anexos e o histórico de versões. Não é possível anular.", + "Delete for good": "Eliminar definitivamente", + "Trash": "Lixo", + "The trash is empty": "O lixo está vazio", + "No archived secrets": "Sem segredos arquivados", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Os segredos eliminados ficam aqui até terminar o período de retenção; depois são eliminados definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arquive um segredo no painel de detalhes para o manter fora da lista do cofre, da pesquisa e do preenchimento automático.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limites para anexos cifrados (aplicados no servidor aos bytes cifrados guardados), retenção do histórico de versões e durante quanto tempo os segredos eliminados ficam no lixo.", + "Days a deleted secret stays in the trash (1 to 365)": "Dias que um segredo eliminado fica no lixo (1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Isto move o segredo para o lixo e termina já as partilhas. Pode restaurá-lo do lixo até terminar o período de retenção: 30 dias, a menos que o administrador o tenha alterado.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Isto move {count} segredos para o lixo e termina já as partilhas. Pode restaurá-los do lixo até terminar o período de retenção.", + "Remove {name} from favourites": "Remover {name} dos favoritos", + "Add {name} to favourites": "Adicionar {name} aos favoritos", + "Could not change the favourite": "Não foi possível alterar o favorito", + "Remove from favourites": "Remover dos favoritos", + "Add to favourites": "Adicionar aos favoritos", + "Tags": "Etiquetas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "As etiquetas não são cifradas. Os administradores do servidor podem lê-las, tal como os nomes das pastas.", + "Favourites": "Favoritos", + "Filter by tag": "Filtrar por etiqueta", + "All tags": "Todas as etiquetas", + "Last used": "Última utilização", + "Tags for {count} secrets": "Etiquetas para {count} segredos", + "Tag": "Etiqueta", + "Remove tag": "Remover etiqueta", + "Add tag": "Adicionar etiqueta", + "Could not change the tags. Try again.": "Não foi possível alterar as etiquetas. Tente novamente.", + "Could not approve the application. It is still in the queue.": "Não foi possível aprovar o pedido. Continua na fila.", + "Could not reject the application. It is still in the queue.": "Não foi possível rejeitar o pedido. Continua na fila.", + "Removed the user from {count} team folders.": "Utilizador removido de {count} pastas de equipa.", + "Approve a share": "Aprovar uma partilha", + "This approval link is incomplete. Open it again from the notification.": "Esta ligação de aprovação está incompleta. Abra-a novamente a partir da notificação.", + "Deny": "Recusar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} juntou-se a um grupo com o qual partilha um segredo. Partilhar também o segredo com esta pessoa?", + "{requester} asks you to share a secret with {user}.": "{requester} pede-lhe que partilhe um segredo com {user}.", + "Shared. The recipient can now open the secret.": "Partilhado. O destinatário já pode abrir o segredo.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "O destinatário ainda não configurou o Keepiq, por isso nada foi partilhado. Tente novamente quando o tiver feito.", + "Could not share the secret. Only its owner can approve this.": "Não foi possível partilhar o segredo. Apenas o proprietário pode aprovar isto.", + "Could not share the secret. Try again.": "Não foi possível partilhar o segredo. Tente novamente.", + "Denied. Nothing was shared.": "Recusado. Nada foi partilhado.", + "Could not deny the request. Try again.": "Não foi possível recusar o pedido. Tente novamente.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s pede-lhe que partilhe o segredo \"%2$s\" com %3$s.", + "Expires on (optional)": "Expira em (opcional)", + "Hand over to": "Entregar a", + "Choose a recipient": "Escolha um destinatário", + "Hand over temporarily": "Entregar temporariamente", + "Expiry rules": "Regras de expiração", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Defina quanto tempo as palavras-passe de um tipo de item ou de uma pasta podem durar, e quando receber um lembrete. Se se aplicarem várias datas, conta a mais próxima.", + "Delete rule": "Eliminar regra", + "Set by your administrator": "Definido pelo seu administrador", + "No expiry rules yet.": "Ainda não há regras de expiração.", + "Applies to": "Aplica-se a", + "Item type": "Tipo de item", + "Maximum age in days (empty for reminders only)": "Idade máxima em dias (vazio para apenas lembretes)", + "Remind me this many days before, comma separated": "Lembrar-me com estes dias de antecedência, separados por vírgulas", + "Save rule": "Guardar regra", + "An item type": "Um tipo de item", + "A folder": "Uma pasta", + "Folder {name}": "Pasta {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Expira após {days} dias", + "Reminders {days} days before": "Lembretes {days} dias antes", + "Could not save the expiry rule.": "Não foi possível guardar a regra de expiração.", + "Could not delete the expiry rule.": "Não foi possível eliminar a regra de expiração.", + "All statuses": "Todos os estados", + "Compromised": "Comprometida", + "Could not load the members.": "Não foi possível carregar os membros.", + "Emergency contact": "Contacto de emergência", + "Leaving user": "Utilizador de saída", + "No": "Não", + "No users match this filter.": "Nenhum utilizador corresponde a este filtro.", + "Not set up": "Não configurado", + "Revoke suite": "Revogar suite", + "Revoked": "Revogada", + "Search users": "Pesquisar utilizadores", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Veja que utilizadores configuraram um cofre. Inicie a saída ou revogue uma suite a partir de uma linha.", + "Successor": "Sucessor", + "Team folders": "Pastas de equipa", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "O utilizador ainda está no grupo {groups}, que é membro de uma pasta de equipa. Remova-o do grupo ou desative a conta.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "O utilizador ainda está nos grupos {groups}, que são membros de pastas de equipa. Remova-o dos grupos ou desative a conta.", + "Vault status": "Estado do cofre", + "Yes": "Sim", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Uma exportação CXF NÃO É CIFRADA. Todas as palavras-passe e credenciais ficarão legíveis em texto simples no ficheiro descarregado. Guarde-o em segurança e elimine-o imediatamente após o uso.", + "Root certificate expiring soon": "O certificado raiz expira em breve", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "O certificado raiz do cofre expira em %1$d dia(s). Renove-o antes disso. A renovação volta a assinar cada suíte de cifragem.", + "Compromise recovery aborted": "Recuperação após comprometimento cancelada", + "Key rotation ended by a compromise revoke": "Rotação de chave terminada por uma revogação por comprometimento", + "Encryption suite revoke refused": "Revogação do conjunto de cifragem recusada", + "Master password proof refused": "Prova da palavra-passe mestra recusada", + "Your current master password": "A sua palavra-passe mestra atual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacto de emergência tinha um pedido de acesso pendente quando a rotação de chave o removeu. Verifique quem o pediu antes de voltar a adicionar alguém.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactos de emergência tinham um pedido de acesso pendente quando a rotação de chave os removeu. Verifique quem o pediu antes de voltar a adicionar alguém.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Estes contactos de emergência não foram transferidos para a sua nova chave. O acesso de emergência deles foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.", + "Renew root certificate": "Renovar o certificado raiz", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Isto cria um novo certificado raiz e um intermédio. Cada conjunto de cifragem ativo é assinado de novo. Não é possível anular.", + "Renew root": "Renovar raiz", + "Root renewed. {n} encryption suites signed again.": "Raiz renovada. Conjuntos de cifragem assinados de novo: {n}.", + "Could not renew the root certificate.": "Não foi possível renovar o certificado raiz.", + "Lease policy for this application": "Política de concessão para esta aplicação", + "In force now: {default} seconds by default, {max} seconds at most.": "Em vigor agora: {default} segundos por omissão, no máximo {max} segundos.", + "Leases are not renewable": "As concessões não são renováveis", + "Lease policy saved.": "Política de concessão guardada.", + "Leave a field empty to use the instance value.": "Deixe um campo vazio para usar o valor da instância.", + "Instance value: {value}": "Valor da instância: {value}", + "Renewal": "Renovação", + "Use the instance value ({value})": "Usar o valor da instância ({value})", + "Allowed": "Permitido", + "Not allowed": "Não permitido", + "Save lease policy": "Guardar política de concessão", + "Only an administrator can change this policy.": "Só um administrador pode alterar esta política.", + "Could not save the lease policy.": "Não foi possível guardar a política de concessão.", + "{member} got access from {confirmer}.": "{member} recebeu acesso de {confirmer}.", + "Automatically confirm new team folder members": "Confirmar automaticamente novos membros das pastas de equipa", + "Gave %n new member access to a team folder.": "%n novo membro recebeu acesso a uma pasta de equipa.", + "Gave %n new members access to a team folder.": "%n novos membros receberam acesso a uma pasta de equipa.", + "Give new team folder members access without waiting for the folder owner.": "Dê acesso aos novos membros sem esperar pelo proprietário da pasta.", + "New team folder members": "Novos membros das pastas de equipa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "O proprietário ou um membro com permissão de escrita confirma-os a partir do cofre aberto. O Keepiq nunca desencripta no servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "A aguardar que um membro com permissão de escrita abra o Keepiq. Também pode partilhar agora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte da resposta ao comprometimento falhou ({failed} passo(s)). Verifique o registo do servidor e revogue novamente a suite para a concluir.", + "This also revoked suite {suite} and ended key migration {migration}.": "Isto também revogou a suite {suite} e terminou a migração de chaves {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revogar a segunda suite eliminou %n contacto de acesso de emergência.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revogar a segunda suite eliminou %n contactos de acesso de emergência.", + "A suite revoked as compromised cannot be reinstated.": "Uma suite revogada como comprometida não pode ser restabelecida.", + "Archives to keep": "Arquivos a manter", + "Back up every vault automatically": "Fazer cópia de cada cofre automaticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faça cópia de cada cofre de acordo com um horário. Os arquivos só contêm texto cifrado e são restaurados com occ.", + "Back up now": "Fazer cópia agora", + "Backup public key (PEM, optional)": "Chave pública de cópia (PEM, opcional)", + "Backup requested for the next cron run": "Cópia pedida para a próxima execução do cron", + "Encrypted": "Cifrado", + "Every (hours)": "A cada (horas)", + "Last backup {when} failed: {error}": "Última cópia {when} falhou: {error}", + "Last backup {when} succeeded.": "Última cópia {when} concluída.", + "No archives yet.": "Ainda não há arquivos.", + "Size": "Tamanho", + "Vault backups": "Cópias do cofre", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Com uma chave, cada arquivo é cifrado para ela. Guarde a chave privada fora deste servidor: precisa dela para verificar ou restaurar.", + "Written": "Escrito", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilizador abrangido ainda não tem autenticação de dois fatores e não pode abrir o cofre enquanto isto estiver ativo.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilizadores abrangidos ainda não têm autenticação de dois fatores e não podem abrir o cofre enquanto isto estiver ativo.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Os códigos de recuperação não contam. Se os seus utilizadores entram através de um fornecedor de identidade com o seu próprio segundo fator, exclua os seus grupos.", + "Block personal vault export": "Bloquear a exportação do cofre pessoal", + "Keep work logins in team folders": "Manter os acessos de trabalho em pastas de equipa", + "Move to a team folder": "Mover para uma pasta de equipa", + "Not in a team folder": "Não está numa pasta de equipa", + "Only for these groups (empty is everyone)": "Apenas para estes grupos (vazio é todos)", + "Require two-factor login before the vault opens": "Exigir autenticação de dois fatores antes de abrir o cofre", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regras para cada cofre. Cada uma aplica-se a todos, ou apenas aos grupos que escolher.", + "Secret types that belong in a team folder": "Tipos de segredo que pertencem a uma pasta de equipa", + "Set up two-factor login": "Configurar a autenticação de dois fatores", + "Team folder you can write to": "Pasta de equipa onde pode escrever", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Os utilizadores não podem descarregar uma cópia de segurança, um CSV ou um ficheiro de transferência. O seu pacote de dados pessoais continua disponível.", + "Users cannot save these secret types in a personal folder.": "Os utilizadores não podem guardar estes tipos de segredo numa pasta pessoal.", + "Vault policies": "Políticas do cofre", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "A sua organização não permite exportar o seu cofre pessoal. O seu pacote de dados pessoais nas definições continua disponível.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "A sua organização guarda estes segredos numa pasta de equipa. Mova cada um para uma pasta de equipa.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "A sua organização guarda este tipo de segredo numa pasta de equipa. Escolha uma das suas pastas de equipa, ou uma onde pode escrever.", + "Your organisation requires two-factor login before you can open your vault.": "A sua organização exige autenticação de dois fatores antes de poder abrir o seu cofre.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Os utilizadores escolhem quanto tempo a extensão fica desbloqueada sem atividade. Você define o máximo que podem escolher.", + "Longest idle time before the extension locks": "Tempo máximo de inatividade antes de a extensão bloquear", + "1 minute": "1 minuto", + "5 minutes": "5 minutos", + "15 minutes": "15 minutos", + "1 hour": "1 hora", + "4 hours": "4 horas", + "Connector": "Conector", + "Directory (tenant) ID": "ID do diretório (inquilino)", + "Application (client) ID": "ID da aplicação (cliente)", + "Data collection rule immutable ID": "ID imutável da regra de recolha de dados", + "Stream name": "Nome do fluxo", + "Splunk index (optional)": "Índice Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Deixe em branco para manter o atual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF por syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Ponto final de recolha de dados (URL https)", + "HTTP Event Collector URL (https)": "URL do HTTP Event Collector (https)", + "Client secret (write-only)": "Segredo do cliente (só escrita)", + "HEC token (write-only)": "Token HEC (só escrita)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reencaminhe os eventos de auditoria permitidos para o Splunk, Microsoft Sentinel, um recetor syslog ou um webhook. As mensagens só levam metadados limpos: nenhum valor secreto, nome, login ou texto cifrado sai alguma vez do servidor.", + "%n change waiting to sync": "%n alteração a aguardar sincronização", + "%n changes waiting to sync": "%n alterações a aguardar sincronização", + "Changes that could not sync": "Alterações que não foi possível sincronizar", + "Choose a version": "Escolher uma versão", + "Copy value": "Copiar valor", + "Deleted": "Eliminado", + "Discard": "Descartar", + "Keep my offline change": "Manter a minha alteração offline", + "Keep the server version": "Manter a versão do servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "O Keepiq é só de leitura offline. O administrador não ativou a edição offline.", + "Let users edit secrets offline": "Permitir que os utilizadores editem segredos offline", + "Not synced yet": "Ainda não sincronizado", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "As alterações offline ficam no dispositivo, cifradas para o utilizador, e sincronizam no próximo desbloqueio online. Partilha, pastas e anexos continuam a precisar de ligação.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edições, movimentos e eliminações ficam neste dispositivo e sincronizam quando voltar a estar online. Partilha e anexos precisam de ligação.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. As suas alterações ficam neste dispositivo e sincronizam quando voltar a estar online. Última sincronização {when}.", + "Open my changes": "Abrir as minhas alterações", + "Sharing needs a connection": "A partilha precisa de ligação", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Alguém alterou este segredo no servidor depois de a sua cópia offline ter sido feita. Escolha que versão manter.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronize ou descarte as alterações offline antes de renovar as chaves.", + "That password did not open your changes.": "Essa palavra-passe não abriu as suas alterações.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "O instantâneo offline guarda segredos cifrados (só podem ser abertos com a chave derivada da palavra-passe mestra do utilizador, tal como no servidor) e cifra em repouso nomes, URL e nomes de pastas. O acesso offline é só de leitura, a menos que permita abaixo a edição offline. Desative isto em dispositivos que nunca devem guardar credenciais; ao desativar, as caches existentes são apagadas no próximo carregamento.", + "The previous vault copy is gone, so these changes cannot be opened.": "A cópia anterior do cofre desapareceu, por isso estas alterações não podem ser abertas.", + "The server version": "A versão do servidor", + "This secret changed while you were offline": "Este segredo mudou enquanto estava offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Eliminou este segredo offline, mas entretanto foi alterado no servidor. Escolha que versão manter.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "As suas chaves foram alteradas noutro dispositivo. Introduza a palavra-passe mestra anterior para sincronizar as alterações offline, ou descarte-as.", + "Your offline change": "A sua alteração offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contacto de emergência tinha um pedido de acesso pendente quando a rotação de chave o removeu. Verifique quem o pediu antes de voltar a adicionar alguém.","%n contactos de emergência tinham um pedido de acesso pendente quando a rotação de chave os removeu. Verifique quem o pediu antes de voltar a adicionar alguém."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n item não pode ser representado em CXF e será ignorado.","%n itens não podem ser representados em CXF e serão ignorados."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n versão mais antiga foi descartada, porque só o histórico recente pode ser transferido.","%n versões mais antigas foram descartadas, porque só o histórico recente pode ser transferido."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Ainda é necessário cifrar e partilhar %n cópia do segredo.","Ainda é necessário cifrar e partilhar %n cópias do segredo."], + "_%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 segredo não pôde ser desencriptado e não está nesta exportação.","%n segredos não puderam ser desencriptados e não estão nesta exportação."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n segredo não pôde ser decifrado com a sua chave antiga, por isso não foi migrado.","%n segredos não puderam ser decifrados com a sua chave antiga, por isso não foram migrados."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n segredo não foi migrado.","%n segredos não foram migrados."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n segredo continua cifrado com a sua chave anterior.","%n segredos continuam cifrados com a sua chave anterior."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n segredo foi ignorado porque o sucessor ainda não possui uma cópia: adicione o sucessor à pasta e execute novamente.","%n segredos foram ignorados porque o sucessor ainda não possui uma cópia: adicione o sucessor à pasta e execute novamente."], + "_%n secret_::_%n secrets_": ["%n segredo","%n segredos"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n utilizador abrangido ainda não tem autenticação de dois fatores e não pode abrir o cofre enquanto isto estiver ativo.","%n utilizadores abrangidos ainda não têm autenticação de dois fatores e não podem abrir o cofre enquanto isto estiver ativo."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Terminar mesmo assim, perdendo o acesso a %n segredo","Terminar mesmo assim, perdendo o acesso a %n segredos"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n novo membro recebeu acesso a uma pasta de equipa.","%n novos membros receberam acesso a uma pasta de equipa."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotação da chave concluída. %n segredo foi novamente cifrado com a sua nova chave.","Rotação da chave concluída. %n segredos foram novamente cifrados com a sua nova chave."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revogar a segunda suite eliminou %n contacto de acesso de emergência.","Revogar a segunda suite eliminou %n contactos de acesso de emergência."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revogar esta suite eliminou %n contacto de acesso de emergência.","Revogar esta suite eliminou %n contactos de acesso de emergência."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["visto %n vez em fugas","visto %n vezes em fugas"], + "_shared with %n secret_::_shared with %n secrets_": ["partilhado com %n segredo","partilhado com %n segredos"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Esta pasta contém %n segredo diretamente.","Esta pasta contém %n segredos diretamente."], + "_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._": ["A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.","A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n alteração a aguardar sincronização","%n alterações a aguardar sincronização"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["O utilizador ainda está no grupo {groups}, que é membro de uma pasta de equipa. Remova-o do grupo ou desative a conta.","O utilizador ainda está nos grupos {groups}, que são membros de pastas de equipa. Remova-o dos grupos ou desative a conta."], + "Allow approval from another device": "Permitir aprovação a partir de outro dispositivo", + "App": "Aplicação", + "Approve a new device": "Aprovar um novo dispositivo", + "Approve from another device": "Aprovar a partir de outro dispositivo", + "Asked at": "Pedido às", + "Check that the new device shows these words:": "Confirme que o novo dispositivo mostra estas palavras:", + "Denied. If you did not ask, end your other sessions:": "Recusado. Se não fez este pedido, termine as suas outras sessões:", + "Device": "Dispositivo", + "IP address": "Endereço IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permitir que os utilizadores desbloqueiem um novo navegador aprovando-o a partir de um dispositivo onde o Keepiq já está desbloqueado.", + "New device approval": "Aprovação de novos dispositivos", + "Nextcloud security settings": "Definições de segurança do Nextcloud", + "Only approve a device you are using right now.": "Aprove apenas um dispositivo que esteja a usar neste momento.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Abra o Keepiq num dispositivo onde esteja desbloqueado e aprove este. Confirme que mostra as mesmas palavras:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "O dispositivo que aprova sela a chave de desbloqueio para o novo dispositivo. O servidor apenas a transmite e não a consegue abrir.", + "The master password is not right, or the request has ended.": "A palavra-passe mestra não está correta ou o pedido terminou.", + "The request expired. Ask again or use your master password.": "O pedido expirou. Peça novamente ou use a sua palavra-passe mestra.", + "The request was denied.": "O pedido foi recusado.", + "Too many requests. Try again in an hour or use your master password.": "Demasiados pedidos. Tente novamente dentro de uma hora ou use a sua palavra-passe mestra.", + "Unknown device": "Dispositivo desconhecido", + "Web app": "Aplicação web", + "A device": "Um dispositivo", + "A new device asks to open your vault": "Um novo dispositivo pede para abrir o seu cofre", + "%s asks to be approved. Only approve a device you are using right now.": "%s pede para ser aprovado. Aprove apenas um dispositivo que esteja a usar neste momento.", + "Access ends on (optional)": "O acesso termina em (opcional)", + "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.": "As aplicações do Keepiq não mostram nem copiam a palavra-passe. Alguém com conhecimentos técnicos pode ainda lê-la a partir do próprio dispositivo. Altere-a quando o acesso terminar.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Este segredo é apenas para utilização. Inicie sessão através da extensão de navegador do Keepiq.", + "Until {date}": "Até {date}", + "Use only": "Apenas utilização", + "Use only (can sign in, cannot view or copy)": "Apenas utilização (pode iniciar sessão, não pode ver nem copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Pode iniciar sessão com estas credenciais através da extensão de navegador do Keepiq. O proprietário optou por não lhe permitir vê-las ou copiá-las.", + "Your access ends on {date}": "O seu acesso termina em {date}", + "Your access to this secret has ended": "O seu acesso a este segredo terminou", + "Your access to \"%s\" ends tomorrow": "O seu acesso a \"%s\" termina amanhã", + "Your access to \"%s\" has ended": "O seu acesso a \"%s\" terminou", + "%1$s no longer has access to \"%2$s\"": "%1$s já não tem acesso a \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podia ver esta palavra-passe. Altere-a se %1$s já não a dever conhecer.", + "%s could not view this password in Keepiq.": "%s não conseguiu ver esta palavra-passe no Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprovações", + "a recovery officer": "um responsável de recuperação", + "Account recovery": "Recuperação de conta", + "Approvals needed": "Aprovações necessárias", + "Ask {user} which words they see, by phone or in person. They must be:": "Pergunte a {user} que palavras vê, por telefone ou pessoalmente. Devem ser:", + "Check again": "Verificar novamente", + "Create the recovery key": "Criar a chave de recuperação", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Crie a chave de recuperação. O seu navegador gera-a e dá a cada responsável uma cópia que só ele pode abrir.", + "Decline": "Recusar", + "Enrol in account recovery": "Inscrever-se na recuperação de conta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscreva-se para que a sua organização o possa ajudar a recuperar o cofre se esquecer a palavra-passe mestra.", + "Every user is enrolled": "Todos os utilizadores estão inscritos", + "Finish the recovery in the browser you asked from.": "Conclua a recuperação no navegador a partir do qual a pediu.", + "Forgot your master password?": "Esqueceu a palavra-passe mestra?", + "Hand the key over": "Entregar a chave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permita que utilizadores que esqueceram a palavra-passe mestra recuperem o cofre, com aprovação dos responsáveis de recuperação que designar.", + "New master password": "Nova palavra-passe mestra", + "No one is asking to recover their account.": "Ninguém está a pedir para recuperar a conta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ainda não há chave de recuperação. Um dos responsáveis cria-a nas suas definições do Keepiq.", + "Off": "Desligado", + "Officer {user} has no encryption set up yet.": "O responsável {user} ainda não configurou a encriptação.", + "Officers (user IDs, separated by commas)": "Responsáveis (IDs de utilizador, separados por vírgulas)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publique esta impressão digital internamente, para que os utilizadores a possam verificar antes de se inscreverem.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperado com a ajuda de {officer}. Mude agora a chave do cofre em Definições, Segurança: \"A minha palavra-passe mestra foi comprometida\".", + "Recovery key fingerprint: {fingerprint}": "Impressão digital da chave de recuperação: {fingerprint}", + "Recovery officer": "Responsável de recuperação", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Os responsáveis removidos perdem agora a sua cópia, mas podem tê-la aberto antes. Peça a um responsável que crie uma nova chave de recuperação.", + "Repeat the new master password": "Repita a nova palavra-passe mestra", + "Retire this recovery key": "Retirar esta chave de recuperação", + "Set the new master password": "Definir a nova palavra-passe mestra", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "O certificado de recuperação não foi emitido por este Keepiq. Não se inscreva e avise o administrador.", + "The words match, approve": "As palavras coincidem, aprovar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Este utilizador está inscrito na recuperação de conta. Recuperar mantém os seus segredos; revogar elimina a sua inscrição.", + "Users may enrol": "Os utilizadores podem inscrever-se", + "Withdraw from account recovery": "Sair da recuperação de conta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Está inscrito na recuperação de conta. Impressão digital da chave de recuperação: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Está inscrito. Se esquecer a palavra-passe mestra, a sua organização pode ajudá-lo a recuperar o cofre.", + "Your key is back. Choose a new master password.": "A sua chave está de volta. Escolha uma nova palavra-passe mestra.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Os seus responsáveis de recuperação foram avisados. Leia-lhes estas palavras quando lhe telefonarem ou se encontrarem consigo:", + "You are now an account recovery officer": "Agora é responsável de recuperação de contas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pede para recuperar a conta. Compare as palavras com essa pessoa antes de aprovar.", + "A user": "Um utilizador", + "Your account recovery request was declined": "O seu pedido de recuperação de conta foi recusado", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "A recuperação da sua conta está pronta. Abra o Keepiq no navegador a partir do qual a pediu.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pede para desbloquear um novo dispositivo uma única vez. A palavra-passe mestra mantém-se.", + "Ask your organisation instead": "Em vez disso, peça à sua organização", + "The request ended. Ask again or use your master password.": "O pedido terminou. Peça novamente ou use a sua palavra-passe mestra.", + "Added by {user}": "Adicionado por {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Função de {member}", + "Team folders you manage": "Pastas de equipa que gere", + "Viewer": "Leitor", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Não tem uma cópia destes segredos, por isso os novos membros ainda não os receberam. O proprietário pode partilhá-los: {names}", + "Admin areas": "Áreas de administração", + "Give a group only the parts of Keepiq administration it needs.": "Dê a um grupo apenas as partes da administração do Keepiq de que precisa.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegue uma ou mais áreas a um grupo na página de privilégios de administração. Os administradores da instância têm todas as áreas.", + "Open administration privileges": "Abrir privilégios de administração", + "Policies": "Políticas", + "Applications and machine access": "Aplicações e acesso de máquinas", + "People and offboarding": "Pessoas e saídas", + "Audit and compliance": "Auditoria e conformidade", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versão, autoridade de certificação, anexos, cache offline, verificação de fugas, tipos de segredos e cópias de segurança", + "master password, organisation password, vault policies, rotation, version history and trash": "palavra-passe mestra, palavra-passe da organização, políticas do cofre, rotação, histórico de versões e lixo", + "application queue, application requests and machine leases": "fila de aplicações, pedidos de aplicações e concessões de máquinas", + "team offboarding, encryption suites and admin handover": "saídas da equipa, suites de cifra e transferência pelo administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registo de auditoria, relatórios de conformidade, exportação SIEM e alertas isco", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantas versões de um segredo são guardadas, durante quanto tempo, e quanto tempo os segredos eliminados ficam no lixo.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limites para anexos cifrados, aplicados no servidor em bytes cifrados armazenados.", + "Type the suite ID again to confirm": "Escreva novamente o ID do conjunto para confirmar", + "This does not match the suite ID.": "Isto não corresponde ao ID do conjunto.", + "Confirm with your master password": "Confirme com a sua palavra-passe mestre", + "Confirm": "Confirmar", + "That master password is not right.": "Essa palavra-passe mestre não está correta.", + "You are sharing with someone new. Enter your master password to confirm.": "Está a partilhar com alguém novo. Introduza a sua palavra-passe mestre para confirmar.", + "Enter your master password to confirm this share.": "Introduza a sua palavra-passe mestre para confirmar esta partilha.", + "Enter your master password to confirm this delegation.": "Introduza a sua palavra-passe mestre para confirmar esta delegação.", + "Approve {member}": "Aprovar {member}", + "Recipient": "Destinatário", + "No vault yet": "Ainda sem cofre", + "No matching users": "Nenhum utilizador correspondente", + "Partner organisations": "Organizações parceiras", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Troque segredos com outro Keepiq. Ambos os administradores adicionam-se mutuamente e comparam as impressões digitais raiz por telefone ou pessoalmente antes de guardar.", + "Federation needs Nextcloud 33 or later.": "A federação requer Nextcloud 33 ou posterior.", + "Your root fingerprint": "A sua impressão digital raiz", + "No partners yet.": "Ainda sem parceiros.", + "Users here may share to this partner": "Os utilizadores daqui podem partilhar com este parceiro", + "This partner may share to users here": "Este parceiro pode partilhar com os utilizadores daqui", + "Partner address": "Endereço do parceiro", + "Check partner": "Verificar parceiro", + "Partner root fingerprint": "Impressão digital raiz do parceiro", + "I compared this fingerprint with the partner's administrator": "Comparei esta impressão digital com o administrador do parceiro", + "Add partner": "Adicionar parceiro", + "A secret from another organisation": "Um segredo de outra organização", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s partilhou \"%2$s\" consigo. Aceite-o em Recebidos de outras organizações.", + "Incoming from other organisations": "Recebidos de outras organizações", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "As pessoas de organizações parceiras podem partilhar um segredo consigo. Aceite-o para guardar uma cópia só de leitura no seu cofre.", + "Nothing shared with you yet": "Ainda nada foi partilhado consigo", + "Secrets that people in partner organisations share with you appear here.": "Os segredos que as pessoas de organizações parceiras partilham consigo aparecem aqui.", + "From {sender}": "De {sender}", + "Accept": "Aceitar", + "Open in vault": "Abrir no cofre", + "The other organisation did not hand over the secret. Try again later.": "A outra organização não entregou o segredo. Tente novamente mais tarde.", + "Set up your vault before you accept a shared secret.": "Configure o seu cofre antes de aceitar um segredo partilhado.", + "Something went wrong. Try again.": "Algo correu mal. Tente novamente.", + "Waiting for your answer": "A aguardar a sua resposta", + "In your vault, read-only": "No seu cofre, só de leitura", + "Withdrawn by the sender": "Retirado pelo remetente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} partilhou isto a partir de outra organização. Pode lê-lo, mas não alterá-lo nem partilhá-lo.", + "Someone": "Alguém", + "Share with someone at another organisation": "Partilhar com alguém de outra organização", + "Their account at the other organisation": "A conta da pessoa na outra organização", + "Check account": "Verificar conta", + "Certificate fingerprint of {account}": "Impressão digital do certificado de {account}", + "Compare it with them by phone if you want to be sure.": "Compare-a com a pessoa por telefone se quiser ter a certeza.", + "Shared. {account} can accept it in their own vault.": "Partilhado. {account} pode aceitá-lo no seu próprio cofre.", + "The certificate could not be verified. Nothing was shared.": "Não foi possível verificar o certificado. Nada foi partilhado.", + "That organisation is not one of your partners.": "Essa organização não é uma das suas parceiras.", + "No one with that account can receive secrets from you.": "Ninguém com essa conta pode receber segredos seus.", + "The other organisation did not answer. Try again later.": "A outra organização não respondeu. Tente novamente mais tarde.", + "This secret is already shared with that account.": "Este segredo já está partilhado com essa conta.", + "Other organisations": "Outras organizações", + "Receive secrets from other organisations": "Receber segredos de outras organizações", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "As pessoas de organizações parceiras podem então encontrar a sua conta e partilhar segredos consigo. Aceita cada um pessoalmente.", + "Shared": "Partilhado", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Em pausa: o certificado do destinatário ou a parceria mudou. Revogue a partilha ou partilhe novamente.", + "Their organisation did not get the last change. Revoke it or share again.": "A organização do destinatário não recebeu a última alteração. Revogue a partilha ou partilhe novamente.", + "Being withdrawn": "A ser retirado", + "Shared with another organisation": "Partilhado com outra organização", + "Change sent to another organisation": "Alteração enviada para outra organização", + "Share with another organisation revoked": "Partilha com outra organização revogada", + "Share with another organisation paused": "Partilha com outra organização em pausa", + "Another organisation did not get a change": "Outra organização não recebeu uma alteração", + "Secret received from another organisation": "Segredo recebido de outra organização", + "Secret from another organisation accepted": "Segredo de outra organização aceite", + "Secret from another organisation declined": "Segredo de outra organização recusado", + "Copy from another organisation updated": "Cópia de outra organização atualizada", + "Copy from another organisation removed": "Cópia de outra organização eliminada", + "Declined: they removed their copy. Share again if they need it.": "Recusado: o destinatário eliminou a sua cópia. Partilhe novamente se precisar dela.", + "Recipient at another organisation removed their copy": "Um destinatário de outra organização eliminou a sua cópia", + "Removed the user from %n team folder.": "Utilizador removido de %n pasta de equipa.", + "Removed the user from %n team folders.": "Utilizador removido de %n pastas de equipa.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Utilizador removido de %n pasta de equipa.","Utilizador removido de %n pastas de equipa."], + "A restored copy came from a share that has ended. It stays read-only.": "Uma cópia restaurada veio de uma partilha que terminou. Continua só de leitura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Não foi possível contactar a organização que partilhou uma cópia restaurada. A cópia continua só de leitura e não acompanha as suas alterações.", + "Recipient at another organisation restored their copy": "Um destinatário de outra organização restaurou a sua cópia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/pt.json b/l10n/pt.json index 62b8983d7..da9eb3aaf 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Partilhado com grupos", + "Not shared with any group yet.": "Ainda não partilhado com nenhum grupo.", + "Revoke the share with {group}": "Revogar a partilha com {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partilhado com {group}: {received} membros receberam-no, {skipped} não porque ainda não configuraram a encriptação.", + "Search groups": "Pesquisar grupos", + "Failed to share": "Falha ao partilhar", + "Columns": "Colunas", + "Column {number}": "Coluna {number}", + "Map one column to Name. Every secret needs a name.": "Associe uma coluna ao nome. Cada segredo precisa de um nome.", + "Notes": "Notas", + "Do not import": "Não importar", + "Hide this value": "Ocultar este valor", + "Show this value": "Mostrar este valor", + "Defaults": "Predefinições", + "New secrets start as this type, and your secret list opens in this view.": "Os novos segredos começam com este tipo e a sua lista de segredos abre nesta vista.", + "Default item type": "Tipo de item predefinido", + "Cards": "Cartões", + "Table": "Tabela", + "Could not save your default": "Não foi possível guardar a sua predefinição", + "Recently used": "Usados recentemente", + "Opened": "Aberto", + "You have not opened any secrets yet": "Ainda não abriu nenhum segredo", + "Could not delete the item type.": "Não foi possível eliminar o tipo de item.", + "Could not load the item types.": "Não foi possível carregar os tipos de item.", + "Could not save the item type.": "Não foi possível guardar o tipo de item.", + "Delete item type": "Eliminar tipo de item", + "Edit item type": "Editar tipo de item", + "Fields": "Campos", + "Fields: {count}": "Campos: {count}", + "Hidden": "Oculto", + "Item types": "Tipos de item", + "Move up": "Mover para cima", + "New item type": "Novo tipo de item", + "No item types defined yet.": "Ainda não há tipos de item definidos.", + "Required": "Obrigatório", + "Text": "Texto", + "This field is required": "Este campo é obrigatório", + "Web address": "Endereço web", + "{label} (required)": "{label} (obrigatório)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Eliminar «{name}»? Os segredos deste tipo continuam legíveis e passam a itens de Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Os tipos de item que definir aqui aparecem para todos na janela Novo segredo, com os campos que escolher.", + "Secret moved to the trash": "Segredo movido para o lixo", + "Secret restored from the trash": "Segredo restaurado do lixo", + "Secret deleted for good": "Segredo eliminado definitivamente", + "Secret archived": "Segredo arquivado", + "Secret unarchived": "Segredo desarquivado", + "Unarchive": "Desarquivar", + "Could not archive the secret": "Não foi possível arquivar o segredo", + "Could not unarchive the secret": "Não foi possível desarquivar o segredo", + "Archive {count} secrets": "Arquivar {count} segredos", + "Unarchive {count} secrets": "Desarquivar {count} segredos", + "Restore {count} secrets": "Restaurar {count} segredos", + "Delete {count} secrets for good": "Eliminar definitivamente {count} segredos", + "Done for {ok} of {total} secrets": "Concluído para {ok} de {total} segredos", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Os segredos arquivados saem da lista do cofre, da pesquisa, do preenchimento automático e do relatório de saúde. Mantêm as partilhas. Encontra-os em Arquivo.", + "These secrets come back to the vault list, search and autofill.": "Estes segredos voltam à lista do cofre, à pesquisa e ao preenchimento automático.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Estes segredos voltam à lista do cofre. As partilhas antigas não voltam, por isso partilhe-os de novo onde for preciso.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Isto elimina os segredos com os anexos e o histórico de versões. Não é possível anular.", + "Delete for good": "Eliminar definitivamente", + "Trash": "Lixo", + "The trash is empty": "O lixo está vazio", + "No archived secrets": "Sem segredos arquivados", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Os segredos eliminados ficam aqui até terminar o período de retenção; depois são eliminados definitivamente.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arquive um segredo no painel de detalhes para o manter fora da lista do cofre, da pesquisa e do preenchimento automático.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limites para anexos cifrados (aplicados no servidor aos bytes cifrados guardados), retenção do histórico de versões e durante quanto tempo os segredos eliminados ficam no lixo.", + "Days a deleted secret stays in the trash (1 to 365)": "Dias que um segredo eliminado fica no lixo (1 a 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Isto move o segredo para o lixo e termina já as partilhas. Pode restaurá-lo do lixo até terminar o período de retenção: 30 dias, a menos que o administrador o tenha alterado.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Isto move {count} segredos para o lixo e termina já as partilhas. Pode restaurá-los do lixo até terminar o período de retenção.", + "Remove {name} from favourites": "Remover {name} dos favoritos", + "Add {name} to favourites": "Adicionar {name} aos favoritos", + "Could not change the favourite": "Não foi possível alterar o favorito", + "Remove from favourites": "Remover dos favoritos", + "Add to favourites": "Adicionar aos favoritos", + "Tags": "Etiquetas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "As etiquetas não são cifradas. Os administradores do servidor podem lê-las, tal como os nomes das pastas.", + "Favourites": "Favoritos", + "Filter by tag": "Filtrar por etiqueta", + "All tags": "Todas as etiquetas", + "Last used": "Última utilização", + "Tags for {count} secrets": "Etiquetas para {count} segredos", + "Tag": "Etiqueta", + "Remove tag": "Remover etiqueta", + "Add tag": "Adicionar etiqueta", + "Could not change the tags. Try again.": "Não foi possível alterar as etiquetas. Tente novamente.", + "Could not approve the application. It is still in the queue.": "Não foi possível aprovar o pedido. Continua na fila.", + "Could not reject the application. It is still in the queue.": "Não foi possível rejeitar o pedido. Continua na fila.", + "Removed the user from {count} team folders.": "Utilizador removido de {count} pastas de equipa.", + "Approve a share": "Aprovar uma partilha", + "This approval link is incomplete. Open it again from the notification.": "Esta ligação de aprovação está incompleta. Abra-a novamente a partir da notificação.", + "Deny": "Recusar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} juntou-se a um grupo com o qual partilha um segredo. Partilhar também o segredo com esta pessoa?", + "{requester} asks you to share a secret with {user}.": "{requester} pede-lhe que partilhe um segredo com {user}.", + "Shared. The recipient can now open the secret.": "Partilhado. O destinatário já pode abrir o segredo.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "O destinatário ainda não configurou o Keepiq, por isso nada foi partilhado. Tente novamente quando o tiver feito.", + "Could not share the secret. Only its owner can approve this.": "Não foi possível partilhar o segredo. Apenas o proprietário pode aprovar isto.", + "Could not share the secret. Try again.": "Não foi possível partilhar o segredo. Tente novamente.", + "Denied. Nothing was shared.": "Recusado. Nada foi partilhado.", + "Could not deny the request. Try again.": "Não foi possível recusar o pedido. Tente novamente.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s pede-lhe que partilhe o segredo \"%2$s\" com %3$s.", + "Expires on (optional)": "Expira em (opcional)", + "Hand over to": "Entregar a", + "Choose a recipient": "Escolha um destinatário", + "Hand over temporarily": "Entregar temporariamente", + "Expiry rules": "Regras de expiração", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Defina quanto tempo as palavras-passe de um tipo de item ou de uma pasta podem durar, e quando receber um lembrete. Se se aplicarem várias datas, conta a mais próxima.", + "Delete rule": "Eliminar regra", + "Set by your administrator": "Definido pelo seu administrador", + "No expiry rules yet.": "Ainda não há regras de expiração.", + "Applies to": "Aplica-se a", + "Item type": "Tipo de item", + "Maximum age in days (empty for reminders only)": "Idade máxima em dias (vazio para apenas lembretes)", + "Remind me this many days before, comma separated": "Lembrar-me com estes dias de antecedência, separados por vírgulas", + "Save rule": "Guardar regra", + "An item type": "Um tipo de item", + "A folder": "Uma pasta", + "Folder {name}": "Pasta {name}", + "Type {name}": "Tipo {name}", + "Expires after {days} days": "Expira após {days} dias", + "Reminders {days} days before": "Lembretes {days} dias antes", + "Could not save the expiry rule.": "Não foi possível guardar a regra de expiração.", + "Could not delete the expiry rule.": "Não foi possível eliminar a regra de expiração.", + "All statuses": "Todos os estados", + "Compromised": "Comprometida", + "Could not load the members.": "Não foi possível carregar os membros.", + "Emergency contact": "Contacto de emergência", + "Leaving user": "Utilizador de saída", + "No": "Não", + "No users match this filter.": "Nenhum utilizador corresponde a este filtro.", + "Not set up": "Não configurado", + "Revoke suite": "Revogar suite", + "Revoked": "Revogada", + "Search users": "Pesquisar utilizadores", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Veja que utilizadores configuraram um cofre. Inicie a saída ou revogue uma suite a partir de uma linha.", + "Successor": "Sucessor", + "Team folders": "Pastas de equipa", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "O utilizador ainda está no grupo {groups}, que é membro de uma pasta de equipa. Remova-o do grupo ou desative a conta.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "O utilizador ainda está nos grupos {groups}, que são membros de pastas de equipa. Remova-o dos grupos ou desative a conta.", + "Vault status": "Estado do cofre", + "Yes": "Sim", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Uma exportação CXF NÃO É CIFRADA. Todas as palavras-passe e credenciais ficarão legíveis em texto simples no ficheiro descarregado. Guarde-o em segurança e elimine-o imediatamente após o uso.", + "Root certificate expiring soon": "O certificado raiz expira em breve", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "O certificado raiz do cofre expira em %1$d dia(s). Renove-o antes disso. A renovação volta a assinar cada suíte de cifragem.", + "Compromise recovery aborted": "Recuperação após comprometimento cancelada", + "Key rotation ended by a compromise revoke": "Rotação de chave terminada por uma revogação por comprometimento", + "Encryption suite revoke refused": "Revogação do conjunto de cifragem recusada", + "Master password proof refused": "Prova da palavra-passe mestra recusada", + "Your current master password": "A sua palavra-passe mestra atual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contacto de emergência tinha um pedido de acesso pendente quando a rotação de chave o removeu. Verifique quem o pediu antes de voltar a adicionar alguém.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contactos de emergência tinham um pedido de acesso pendente quando a rotação de chave os removeu. Verifique quem o pediu antes de voltar a adicionar alguém.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Estes contactos de emergência não foram transferidos para a sua nova chave. O acesso de emergência deles foi removido. Adicione-os novamente em Acesso de emergência se ainda os quiser.", + "Renew root certificate": "Renovar o certificado raiz", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Isto cria um novo certificado raiz e um intermédio. Cada conjunto de cifragem ativo é assinado de novo. Não é possível anular.", + "Renew root": "Renovar raiz", + "Root renewed. {n} encryption suites signed again.": "Raiz renovada. Conjuntos de cifragem assinados de novo: {n}.", + "Could not renew the root certificate.": "Não foi possível renovar o certificado raiz.", + "Lease policy for this application": "Política de concessão para esta aplicação", + "In force now: {default} seconds by default, {max} seconds at most.": "Em vigor agora: {default} segundos por omissão, no máximo {max} segundos.", + "Leases are not renewable": "As concessões não são renováveis", + "Lease policy saved.": "Política de concessão guardada.", + "Leave a field empty to use the instance value.": "Deixe um campo vazio para usar o valor da instância.", + "Instance value: {value}": "Valor da instância: {value}", + "Renewal": "Renovação", + "Use the instance value ({value})": "Usar o valor da instância ({value})", + "Allowed": "Permitido", + "Not allowed": "Não permitido", + "Save lease policy": "Guardar política de concessão", + "Only an administrator can change this policy.": "Só um administrador pode alterar esta política.", + "Could not save the lease policy.": "Não foi possível guardar a política de concessão.", + "{member} got access from {confirmer}.": "{member} recebeu acesso de {confirmer}.", + "Automatically confirm new team folder members": "Confirmar automaticamente novos membros das pastas de equipa", + "Gave %n new member access to a team folder.": "%n novo membro recebeu acesso a uma pasta de equipa.", + "Gave %n new members access to a team folder.": "%n novos membros receberam acesso a uma pasta de equipa.", + "Give new team folder members access without waiting for the folder owner.": "Dê acesso aos novos membros sem esperar pelo proprietário da pasta.", + "New team folder members": "Novos membros das pastas de equipa", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "O proprietário ou um membro com permissão de escrita confirma-os a partir do cofre aberto. O Keepiq nunca desencripta no servidor.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "A aguardar que um membro com permissão de escrita abra o Keepiq. Também pode partilhar agora.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Parte da resposta ao comprometimento falhou ({failed} passo(s)). Verifique o registo do servidor e revogue novamente a suite para a concluir.", + "This also revoked suite {suite} and ended key migration {migration}.": "Isto também revogou a suite {suite} e terminou a migração de chaves {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revogar a segunda suite eliminou %n contacto de acesso de emergência.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revogar a segunda suite eliminou %n contactos de acesso de emergência.", + "A suite revoked as compromised cannot be reinstated.": "Uma suite revogada como comprometida não pode ser restabelecida.", + "Archives to keep": "Arquivos a manter", + "Back up every vault automatically": "Fazer cópia de cada cofre automaticamente", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faça cópia de cada cofre de acordo com um horário. Os arquivos só contêm texto cifrado e são restaurados com occ.", + "Back up now": "Fazer cópia agora", + "Backup public key (PEM, optional)": "Chave pública de cópia (PEM, opcional)", + "Backup requested for the next cron run": "Cópia pedida para a próxima execução do cron", + "Encrypted": "Cifrado", + "Every (hours)": "A cada (horas)", + "Last backup {when} failed: {error}": "Última cópia {when} falhou: {error}", + "Last backup {when} succeeded.": "Última cópia {when} concluída.", + "No archives yet.": "Ainda não há arquivos.", + "Size": "Tamanho", + "Vault backups": "Cópias do cofre", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Com uma chave, cada arquivo é cifrado para ela. Guarde a chave privada fora deste servidor: precisa dela para verificar ou restaurar.", + "Written": "Escrito", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilizador abrangido ainda não tem autenticação de dois fatores e não pode abrir o cofre enquanto isto estiver ativo.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilizadores abrangidos ainda não têm autenticação de dois fatores e não podem abrir o cofre enquanto isto estiver ativo.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Os códigos de recuperação não contam. Se os seus utilizadores entram através de um fornecedor de identidade com o seu próprio segundo fator, exclua os seus grupos.", + "Block personal vault export": "Bloquear a exportação do cofre pessoal", + "Keep work logins in team folders": "Manter os acessos de trabalho em pastas de equipa", + "Move to a team folder": "Mover para uma pasta de equipa", + "Not in a team folder": "Não está numa pasta de equipa", + "Only for these groups (empty is everyone)": "Apenas para estes grupos (vazio é todos)", + "Require two-factor login before the vault opens": "Exigir autenticação de dois fatores antes de abrir o cofre", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regras para cada cofre. Cada uma aplica-se a todos, ou apenas aos grupos que escolher.", + "Secret types that belong in a team folder": "Tipos de segredo que pertencem a uma pasta de equipa", + "Set up two-factor login": "Configurar a autenticação de dois fatores", + "Team folder you can write to": "Pasta de equipa onde pode escrever", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Os utilizadores não podem descarregar uma cópia de segurança, um CSV ou um ficheiro de transferência. O seu pacote de dados pessoais continua disponível.", + "Users cannot save these secret types in a personal folder.": "Os utilizadores não podem guardar estes tipos de segredo numa pasta pessoal.", + "Vault policies": "Políticas do cofre", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "A sua organização não permite exportar o seu cofre pessoal. O seu pacote de dados pessoais nas definições continua disponível.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "A sua organização guarda estes segredos numa pasta de equipa. Mova cada um para uma pasta de equipa.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "A sua organização guarda este tipo de segredo numa pasta de equipa. Escolha uma das suas pastas de equipa, ou uma onde pode escrever.", + "Your organisation requires two-factor login before you can open your vault.": "A sua organização exige autenticação de dois fatores antes de poder abrir o seu cofre.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Os utilizadores escolhem quanto tempo a extensão fica desbloqueada sem atividade. Você define o máximo que podem escolher.", + "Longest idle time before the extension locks": "Tempo máximo de inatividade antes de a extensão bloquear", + "1 minute": "1 minuto", + "5 minutes": "5 minutos", + "15 minutes": "15 minutos", + "1 hour": "1 hora", + "4 hours": "4 horas", + "Connector": "Conector", + "Directory (tenant) ID": "ID do diretório (inquilino)", + "Application (client) ID": "ID da aplicação (cliente)", + "Data collection rule immutable ID": "ID imutável da regra de recolha de dados", + "Stream name": "Nome do fluxo", + "Splunk index (optional)": "Índice Splunk (opcional)", + "Sourcetype (optional)": "Sourcetype (opcional)", + "Leave blank to keep the current one": "Deixe em branco para manter o atual", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF por syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Ponto final de recolha de dados (URL https)", + "HTTP Event Collector URL (https)": "URL do HTTP Event Collector (https)", + "Client secret (write-only)": "Segredo do cliente (só escrita)", + "HEC token (write-only)": "Token HEC (só escrita)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Reencaminhe os eventos de auditoria permitidos para o Splunk, Microsoft Sentinel, um recetor syslog ou um webhook. As mensagens só levam metadados limpos: nenhum valor secreto, nome, login ou texto cifrado sai alguma vez do servidor.", + "%n change waiting to sync": "%n alteração a aguardar sincronização", + "%n changes waiting to sync": "%n alterações a aguardar sincronização", + "Changes that could not sync": "Alterações que não foi possível sincronizar", + "Choose a version": "Escolher uma versão", + "Copy value": "Copiar valor", + "Deleted": "Eliminado", + "Discard": "Descartar", + "Keep my offline change": "Manter a minha alteração offline", + "Keep the server version": "Manter a versão do servidor", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "O Keepiq é só de leitura offline. O administrador não ativou a edição offline.", + "Let users edit secrets offline": "Permitir que os utilizadores editem segredos offline", + "Not synced yet": "Ainda não sincronizado", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "As alterações offline ficam no dispositivo, cifradas para o utilizador, e sincronizam no próximo desbloqueio online. Partilha, pastas e anexos continuam a precisar de ligação.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Edições, movimentos e eliminações ficam neste dispositivo e sincronizam quando voltar a estar online. Partilha e anexos precisam de ligação.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. As suas alterações ficam neste dispositivo e sincronizam quando voltar a estar online. Última sincronização {when}.", + "Open my changes": "Abrir as minhas alterações", + "Sharing needs a connection": "A partilha precisa de ligação", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Alguém alterou este segredo no servidor depois de a sua cópia offline ter sido feita. Escolha que versão manter.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronize ou descarte as alterações offline antes de renovar as chaves.", + "That password did not open your changes.": "Essa palavra-passe não abriu as suas alterações.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "O instantâneo offline guarda segredos cifrados (só podem ser abertos com a chave derivada da palavra-passe mestra do utilizador, tal como no servidor) e cifra em repouso nomes, URL e nomes de pastas. O acesso offline é só de leitura, a menos que permita abaixo a edição offline. Desative isto em dispositivos que nunca devem guardar credenciais; ao desativar, as caches existentes são apagadas no próximo carregamento.", + "The previous vault copy is gone, so these changes cannot be opened.": "A cópia anterior do cofre desapareceu, por isso estas alterações não podem ser abertas.", + "The server version": "A versão do servidor", + "This secret changed while you were offline": "Este segredo mudou enquanto estava offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Eliminou este segredo offline, mas entretanto foi alterado no servidor. Escolha que versão manter.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "As suas chaves foram alteradas noutro dispositivo. Introduza a palavra-passe mestra anterior para sincronizar as alterações offline, ou descarte-as.", + "Your offline change": "A sua alteração offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contacto de emergência tinha um pedido de acesso pendente quando a rotação de chave o removeu. Verifique quem o pediu antes de voltar a adicionar alguém.", + "%n contactos de emergência tinham um pedido de acesso pendente quando a rotação de chave os removeu. Verifique quem o pediu antes de voltar a adicionar alguém." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n item não pode ser representado em CXF e será ignorado.", + "%n itens não podem ser representados em CXF e serão ignorados." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n versão mais antiga foi descartada, porque só o histórico recente pode ser transferido.", + "%n versões mais antigas foram descartadas, porque só o histórico recente pode ser transferido." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Ainda é necessário cifrar e partilhar %n cópia do segredo.", + "Ainda é necessário cifrar e partilhar %n cópias do segredo." + ], + "_%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 segredo não pôde ser desencriptado e não está nesta exportação.", + "%n segredos não puderam ser desencriptados e não estão nesta exportação." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n segredo não pôde ser decifrado com a sua chave antiga, por isso não foi migrado.", + "%n segredos não puderam ser decifrados com a sua chave antiga, por isso não foram migrados." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n segredo não foi migrado.", + "%n segredos não foram migrados." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n segredo continua cifrado com a sua chave anterior.", + "%n segredos continuam cifrados com a sua chave anterior." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n segredo foi ignorado porque o sucessor ainda não possui uma cópia: adicione o sucessor à pasta e execute novamente.", + "%n segredos foram ignorados porque o sucessor ainda não possui uma cópia: adicione o sucessor à pasta e execute novamente." + ], + "_%n secret_::_%n secrets_": [ + "%n segredo", + "%n segredos" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n utilizador abrangido ainda não tem autenticação de dois fatores e não pode abrir o cofre enquanto isto estiver ativo.", + "%n utilizadores abrangidos ainda não têm autenticação de dois fatores e não podem abrir o cofre enquanto isto estiver ativo." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Terminar mesmo assim, perdendo o acesso a %n segredo", + "Terminar mesmo assim, perdendo o acesso a %n segredos" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n novo membro recebeu acesso a uma pasta de equipa.", + "%n novos membros receberam acesso a uma pasta de equipa." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotação da chave concluída. %n segredo foi novamente cifrado com a sua nova chave.", + "Rotação da chave concluída. %n segredos foram novamente cifrados com a sua nova chave." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revogar a segunda suite eliminou %n contacto de acesso de emergência.", + "Revogar a segunda suite eliminou %n contactos de acesso de emergência." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revogar esta suite eliminou %n contacto de acesso de emergência.", + "Revogar esta suite eliminou %n contactos de acesso de emergência." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "visto %n vez em fugas", + "visto %n vezes em fugas" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "partilhado com %n segredo", + "partilhado com %n segredos" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Esta pasta contém %n segredo diretamente.", + "Esta pasta contém %n segredos diretamente." + ], + "_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._": [ + "A rotação da chave removeu %n contacto de emergência. Verifique Acesso de emergência e adicione-o novamente se ainda o quiser.", + "A rotação da chave removeu %n contactos de emergência. Verifique Acesso de emergência e adicione-os novamente se ainda os quiser." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n alteração a aguardar sincronização", + "%n alterações a aguardar sincronização" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "O utilizador ainda está no grupo {groups}, que é membro de uma pasta de equipa. Remova-o do grupo ou desative a conta.", + "O utilizador ainda está nos grupos {groups}, que são membros de pastas de equipa. Remova-o dos grupos ou desative a conta." + ], + "Allow approval from another device": "Permitir aprovação a partir de outro dispositivo", + "App": "Aplicação", + "Approve a new device": "Aprovar um novo dispositivo", + "Approve from another device": "Aprovar a partir de outro dispositivo", + "Asked at": "Pedido às", + "Check that the new device shows these words:": "Confirme que o novo dispositivo mostra estas palavras:", + "Denied. If you did not ask, end your other sessions:": "Recusado. Se não fez este pedido, termine as suas outras sessões:", + "Device": "Dispositivo", + "IP address": "Endereço IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permitir que os utilizadores desbloqueiem um novo navegador aprovando-o a partir de um dispositivo onde o Keepiq já está desbloqueado.", + "New device approval": "Aprovação de novos dispositivos", + "Nextcloud security settings": "Definições de segurança do Nextcloud", + "Only approve a device you are using right now.": "Aprove apenas um dispositivo que esteja a usar neste momento.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Abra o Keepiq num dispositivo onde esteja desbloqueado e aprove este. Confirme que mostra as mesmas palavras:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "O dispositivo que aprova sela a chave de desbloqueio para o novo dispositivo. O servidor apenas a transmite e não a consegue abrir.", + "The master password is not right, or the request has ended.": "A palavra-passe mestra não está correta ou o pedido terminou.", + "The request expired. Ask again or use your master password.": "O pedido expirou. Peça novamente ou use a sua palavra-passe mestra.", + "The request was denied.": "O pedido foi recusado.", + "Too many requests. Try again in an hour or use your master password.": "Demasiados pedidos. Tente novamente dentro de uma hora ou use a sua palavra-passe mestra.", + "Unknown device": "Dispositivo desconhecido", + "Web app": "Aplicação web", + "A device": "Um dispositivo", + "A new device asks to open your vault": "Um novo dispositivo pede para abrir o seu cofre", + "%s asks to be approved. Only approve a device you are using right now.": "%s pede para ser aprovado. Aprove apenas um dispositivo que esteja a usar neste momento.", + "Access ends on (optional)": "O acesso termina em (opcional)", + "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.": "As aplicações do Keepiq não mostram nem copiam a palavra-passe. Alguém com conhecimentos técnicos pode ainda lê-la a partir do próprio dispositivo. Altere-a quando o acesso terminar.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Este segredo é apenas para utilização. Inicie sessão através da extensão de navegador do Keepiq.", + "Until {date}": "Até {date}", + "Use only": "Apenas utilização", + "Use only (can sign in, cannot view or copy)": "Apenas utilização (pode iniciar sessão, não pode ver nem copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Pode iniciar sessão com estas credenciais através da extensão de navegador do Keepiq. O proprietário optou por não lhe permitir vê-las ou copiá-las.", + "Your access ends on {date}": "O seu acesso termina em {date}", + "Your access to this secret has ended": "O seu acesso a este segredo terminou", + "Your access to \"%s\" ends tomorrow": "O seu acesso a \"%s\" termina amanhã", + "Your access to \"%s\" has ended": "O seu acesso a \"%s\" terminou", + "%1$s no longer has access to \"%2$s\"": "%1$s já não tem acesso a \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s podia ver esta palavra-passe. Altere-a se %1$s já não a dever conhecer.", + "%s could not view this password in Keepiq.": "%s não conseguiu ver esta palavra-passe no Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} de {threshold} aprovações", + "a recovery officer": "um responsável de recuperação", + "Account recovery": "Recuperação de conta", + "Approvals needed": "Aprovações necessárias", + "Ask {user} which words they see, by phone or in person. They must be:": "Pergunte a {user} que palavras vê, por telefone ou pessoalmente. Devem ser:", + "Check again": "Verificar novamente", + "Create the recovery key": "Criar a chave de recuperação", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Crie a chave de recuperação. O seu navegador gera-a e dá a cada responsável uma cópia que só ele pode abrir.", + "Decline": "Recusar", + "Enrol in account recovery": "Inscrever-se na recuperação de conta", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Inscreva-se para que a sua organização o possa ajudar a recuperar o cofre se esquecer a palavra-passe mestra.", + "Every user is enrolled": "Todos os utilizadores estão inscritos", + "Finish the recovery in the browser you asked from.": "Conclua a recuperação no navegador a partir do qual a pediu.", + "Forgot your master password?": "Esqueceu a palavra-passe mestra?", + "Hand the key over": "Entregar a chave", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permita que utilizadores que esqueceram a palavra-passe mestra recuperem o cofre, com aprovação dos responsáveis de recuperação que designar.", + "New master password": "Nova palavra-passe mestra", + "No one is asking to recover their account.": "Ninguém está a pedir para recuperar a conta.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ainda não há chave de recuperação. Um dos responsáveis cria-a nas suas definições do Keepiq.", + "Off": "Desligado", + "Officer {user} has no encryption set up yet.": "O responsável {user} ainda não configurou a encriptação.", + "Officers (user IDs, separated by commas)": "Responsáveis (IDs de utilizador, separados por vírgulas)", + "Policy": "Política", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publique esta impressão digital internamente, para que os utilizadores a possam verificar antes de se inscreverem.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperado com a ajuda de {officer}. Mude agora a chave do cofre em Definições, Segurança: \"A minha palavra-passe mestra foi comprometida\".", + "Recovery key fingerprint: {fingerprint}": "Impressão digital da chave de recuperação: {fingerprint}", + "Recovery officer": "Responsável de recuperação", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Os responsáveis removidos perdem agora a sua cópia, mas podem tê-la aberto antes. Peça a um responsável que crie uma nova chave de recuperação.", + "Repeat the new master password": "Repita a nova palavra-passe mestra", + "Retire this recovery key": "Retirar esta chave de recuperação", + "Set the new master password": "Definir a nova palavra-passe mestra", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "O certificado de recuperação não foi emitido por este Keepiq. Não se inscreva e avise o administrador.", + "The words match, approve": "As palavras coincidem, aprovar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Este utilizador está inscrito na recuperação de conta. Recuperar mantém os seus segredos; revogar elimina a sua inscrição.", + "Users may enrol": "Os utilizadores podem inscrever-se", + "Withdraw from account recovery": "Sair da recuperação de conta", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Está inscrito na recuperação de conta. Impressão digital da chave de recuperação: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Está inscrito. Se esquecer a palavra-passe mestra, a sua organização pode ajudá-lo a recuperar o cofre.", + "Your key is back. Choose a new master password.": "A sua chave está de volta. Escolha uma nova palavra-passe mestra.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Os seus responsáveis de recuperação foram avisados. Leia-lhes estas palavras quando lhe telefonarem ou se encontrarem consigo:", + "You are now an account recovery officer": "Agora é responsável de recuperação de contas", + "%s asks to recover their account. Compare the words with them before you approve.": "%s pede para recuperar a conta. Compare as palavras com essa pessoa antes de aprovar.", + "A user": "Um utilizador", + "Your account recovery request was declined": "O seu pedido de recuperação de conta foi recusado", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "A recuperação da sua conta está pronta. Abra o Keepiq no navegador a partir do qual a pediu.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} pede para desbloquear um novo dispositivo uma única vez. A palavra-passe mestra mantém-se.", + "Ask your organisation instead": "Em vez disso, peça à sua organização", + "The request ended. Ask again or use your master password.": "O pedido terminou. Peça novamente ou use a sua palavra-passe mestra.", + "Added by {user}": "Adicionado por {user}", + "Editor": "Editor", + "Manager": "Gestor", + "Role of {member}": "Função de {member}", + "Team folders you manage": "Pastas de equipa que gere", + "Viewer": "Leitor", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Não tem uma cópia destes segredos, por isso os novos membros ainda não os receberam. O proprietário pode partilhá-los: {names}", + "Admin areas": "Áreas de administração", + "Give a group only the parts of Keepiq administration it needs.": "Dê a um grupo apenas as partes da administração do Keepiq de que precisa.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegue uma ou mais áreas a um grupo na página de privilégios de administração. Os administradores da instância têm todas as áreas.", + "Open administration privileges": "Abrir privilégios de administração", + "Policies": "Políticas", + "Applications and machine access": "Aplicações e acesso de máquinas", + "People and offboarding": "Pessoas e saídas", + "Audit and compliance": "Auditoria e conformidade", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versão, autoridade de certificação, anexos, cache offline, verificação de fugas, tipos de segredos e cópias de segurança", + "master password, organisation password, vault policies, rotation, version history and trash": "palavra-passe mestra, palavra-passe da organização, políticas do cofre, rotação, histórico de versões e lixo", + "application queue, application requests and machine leases": "fila de aplicações, pedidos de aplicações e concessões de máquinas", + "team offboarding, encryption suites and admin handover": "saídas da equipa, suites de cifra e transferência pelo administrador", + "audit log, compliance reports, SIEM export and honey alerts": "registo de auditoria, relatórios de conformidade, exportação SIEM e alertas isco", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantas versões de um segredo são guardadas, durante quanto tempo, e quanto tempo os segredos eliminados ficam no lixo.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limites para anexos cifrados, aplicados no servidor em bytes cifrados armazenados.", + "Type the suite ID again to confirm": "Escreva novamente o ID do conjunto para confirmar", + "This does not match the suite ID.": "Isto não corresponde ao ID do conjunto.", + "Confirm with your master password": "Confirme com a sua palavra-passe mestre", + "Confirm": "Confirmar", + "That master password is not right.": "Essa palavra-passe mestre não está correta.", + "You are sharing with someone new. Enter your master password to confirm.": "Está a partilhar com alguém novo. Introduza a sua palavra-passe mestre para confirmar.", + "Enter your master password to confirm this share.": "Introduza a sua palavra-passe mestre para confirmar esta partilha.", + "Enter your master password to confirm this delegation.": "Introduza a sua palavra-passe mestre para confirmar esta delegação.", + "Approve {member}": "Aprovar {member}", + "Recipient": "Destinatário", + "No vault yet": "Ainda sem cofre", + "No matching users": "Nenhum utilizador correspondente", + "Partner organisations": "Organizações parceiras", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Troque segredos com outro Keepiq. Ambos os administradores adicionam-se mutuamente e comparam as impressões digitais raiz por telefone ou pessoalmente antes de guardar.", + "Federation needs Nextcloud 33 or later.": "A federação requer Nextcloud 33 ou posterior.", + "Your root fingerprint": "A sua impressão digital raiz", + "No partners yet.": "Ainda sem parceiros.", + "Users here may share to this partner": "Os utilizadores daqui podem partilhar com este parceiro", + "This partner may share to users here": "Este parceiro pode partilhar com os utilizadores daqui", + "Partner address": "Endereço do parceiro", + "Check partner": "Verificar parceiro", + "Partner root fingerprint": "Impressão digital raiz do parceiro", + "I compared this fingerprint with the partner's administrator": "Comparei esta impressão digital com o administrador do parceiro", + "Add partner": "Adicionar parceiro", + "A secret from another organisation": "Um segredo de outra organização", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s partilhou \"%2$s\" consigo. Aceite-o em Recebidos de outras organizações.", + "Incoming from other organisations": "Recebidos de outras organizações", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "As pessoas de organizações parceiras podem partilhar um segredo consigo. Aceite-o para guardar uma cópia só de leitura no seu cofre.", + "Nothing shared with you yet": "Ainda nada foi partilhado consigo", + "Secrets that people in partner organisations share with you appear here.": "Os segredos que as pessoas de organizações parceiras partilham consigo aparecem aqui.", + "From {sender}": "De {sender}", + "Accept": "Aceitar", + "Open in vault": "Abrir no cofre", + "The other organisation did not hand over the secret. Try again later.": "A outra organização não entregou o segredo. Tente novamente mais tarde.", + "Set up your vault before you accept a shared secret.": "Configure o seu cofre antes de aceitar um segredo partilhado.", + "Something went wrong. Try again.": "Algo correu mal. Tente novamente.", + "Waiting for your answer": "A aguardar a sua resposta", + "In your vault, read-only": "No seu cofre, só de leitura", + "Withdrawn by the sender": "Retirado pelo remetente", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} partilhou isto a partir de outra organização. Pode lê-lo, mas não alterá-lo nem partilhá-lo.", + "Someone": "Alguém", + "Share with someone at another organisation": "Partilhar com alguém de outra organização", + "Their account at the other organisation": "A conta da pessoa na outra organização", + "Check account": "Verificar conta", + "Certificate fingerprint of {account}": "Impressão digital do certificado de {account}", + "Compare it with them by phone if you want to be sure.": "Compare-a com a pessoa por telefone se quiser ter a certeza.", + "Shared. {account} can accept it in their own vault.": "Partilhado. {account} pode aceitá-lo no seu próprio cofre.", + "The certificate could not be verified. Nothing was shared.": "Não foi possível verificar o certificado. Nada foi partilhado.", + "That organisation is not one of your partners.": "Essa organização não é uma das suas parceiras.", + "No one with that account can receive secrets from you.": "Ninguém com essa conta pode receber segredos seus.", + "The other organisation did not answer. Try again later.": "A outra organização não respondeu. Tente novamente mais tarde.", + "This secret is already shared with that account.": "Este segredo já está partilhado com essa conta.", + "Other organisations": "Outras organizações", + "Receive secrets from other organisations": "Receber segredos de outras organizações", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "As pessoas de organizações parceiras podem então encontrar a sua conta e partilhar segredos consigo. Aceita cada um pessoalmente.", + "Shared": "Partilhado", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Em pausa: o certificado do destinatário ou a parceria mudou. Revogue a partilha ou partilhe novamente.", + "Their organisation did not get the last change. Revoke it or share again.": "A organização do destinatário não recebeu a última alteração. Revogue a partilha ou partilhe novamente.", + "Being withdrawn": "A ser retirado", + "Shared with another organisation": "Partilhado com outra organização", + "Change sent to another organisation": "Alteração enviada para outra organização", + "Share with another organisation revoked": "Partilha com outra organização revogada", + "Share with another organisation paused": "Partilha com outra organização em pausa", + "Another organisation did not get a change": "Outra organização não recebeu uma alteração", + "Secret received from another organisation": "Segredo recebido de outra organização", + "Secret from another organisation accepted": "Segredo de outra organização aceite", + "Secret from another organisation declined": "Segredo de outra organização recusado", + "Copy from another organisation updated": "Cópia de outra organização atualizada", + "Copy from another organisation removed": "Cópia de outra organização eliminada", + "Declined: they removed their copy. Share again if they need it.": "Recusado: o destinatário eliminou a sua cópia. Partilhe novamente se precisar dela.", + "Recipient at another organisation removed their copy": "Um destinatário de outra organização eliminou a sua cópia", + "Removed the user from %n team folder.": "Utilizador removido de %n pasta de equipa.", + "Removed the user from %n team folders.": "Utilizador removido de %n pastas de equipa.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Utilizador removido de %n pasta de equipa.", + "Utilizador removido de %n pastas de equipa." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Uma cópia restaurada veio de uma partilha que terminou. Continua só de leitura.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Não foi possível contactar a organização que partilhou uma cópia restaurada. A cópia continua só de leitura e não acompanha as suas alterações.", + "Recipient at another organisation restored their copy": "Um destinatário de outra organização restaurou a sua cópia" }, "plurals": null } diff --git a/l10n/rm.js b/l10n/rm.js index 2bfdbd8a1..186ede8a1 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1182,7 +1182,467 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Partì cun gruppas", + "Not shared with any group yet.": "Anc betg partì cun ina gruppa.", + "Revoke the share with {group}": "Revocar la partiziun cun {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partì cun {group}: {received} commembers han retschavì quai, {skipped} betg, perquai ch'els n'han anc betg configurà il criptadi.", + "Search groups": "Tschertgar gruppas", + "Failed to share": "La partiziun n'è betg reussida", + "Columns": "Colonnas", + "Column {number}": "Colonna {number}", + "Map one column to Name. Every secret needs a name.": "Attribuescha ina colonna al num. Mintga secret dovra in num.", + "Notes": "Notizias", + "Do not import": "Betg importar", + "Hide this value": "Zuppentar questa valur", + "Show this value": "Mussar questa valur", + "Defaults": "Valurs predefinidas", + "New secrets start as this type, and your secret list opens in this view.": "Novs secrets cumenzan cun quest tip, e tia glista da secrets s'avra en questa vista.", + "Default item type": "Tip d'element predefinì", + "Cards": "Cartas", + "Table": "Tabella", + "Could not save your default": "Betg pussaivel da memorisar tia valur predefinida", + "Recently used": "Duvrà dacurt", + "Opened": "Avert", + "You have not opened any secrets yet": "Ti n'has anc avert nagin secret", + "Could not delete the item type.": "Betg pussaivel da stizzar il tip d'element.", + "Could not load the item types.": "Betg pussaivel da chargiar ils tips d'element.", + "Could not save the item type.": "Betg pussaivel da memorisar il tip d'element.", + "Delete item type": "Stizzar il tip d'element", + "Edit item type": "Modifitgar il tip d'element", + "Fields": "Champs", + "Fields: {count}": "Champs: {count}", + "Hidden": "Zuppà", + "Item types": "Tips d'element", + "Move up": "Spustar ensi", + "New item type": "Nov tip d'element", + "No item types defined yet.": "Anc nagins tips d'element definids.", + "Required": "Obligatoric", + "Text": "Text", + "This field is required": "Quest champ è obligatoric", + "Web address": "Adressa web", + "{label} (required)": "{label} (obligatoric)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Stizzar «{name}»? Ils secrets da quest tip restan legibels e daventan elements Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Ils tips d'element che ti defineschas qua cumparan per tuts en il dialog Nov secret, cun ils champs che ti tschernas.", + "Secret moved to the trash": "Secret spustà en il chanaster da rument", + "Secret restored from the trash": "Secret restaurà ord il chanaster da rument", + "Secret deleted for good": "Secret stizzà definitivamain", + "Secret archived": "Secret archivà", + "Secret unarchived": "Secret prendì ord l'archiv", + "Unarchive": "Prender ord l'archiv", + "Could not archive the secret": "Betg reussì dad archivar il secret", + "Could not unarchive the secret": "Betg reussì da prender il secret ord l'archiv", + "Archive {count} secrets": "Archivar secrets: {count}", + "Unarchive {count} secrets": "Prender ord l'archiv secrets: {count}", + "Restore {count} secrets": "Restaurar secrets: {count}", + "Delete {count} secrets for good": "Stizzar definitivamain secrets: {count}", + "Done for {ok} of {total} secrets": "Fatg per {ok} da {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Secrets archivads svaneschan da la glista dal tresor, da la tschertga, da l'emplenida automatica e dal rapport da sanadad. Els mantegnan lur parts. Ti als chattas sut Archiv.", + "These secrets come back to the vault list, search and autofill.": "Quests secrets returnan en la glista dal tresor, en la tschertga ed en l'emplenida automatica.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Quests secrets returnan en la glista dal tresor. Lur parts veglias na returnan betg, parta pia danovamain nua che quai è necessari.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Quai stizza ils secrets cun lur agiuntas e lur istorgia da versiuns. Quai na po betg vegnir revocà.", + "Delete for good": "Stizzar definitivamain", + "Trash": "Chanaster da rument", + "The trash is empty": "Il chanaster da rument è vid", + "No archived secrets": "Nagins secrets archivads", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Secrets stizzads spetgan qua fin che la perioda da conservaziun è passada, lura vegnan els stizzads definitivamain.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivescha in secret en ses panel da detagls per al tegnair ordaifer la glista dal tresor, la tschertga e l'emplenida automatica.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limitas per agiuntas criptadas (applitgadas sin il server als bytes criptads memorisads), conservaziun da l'istorgia da versiuns e quant ditg che secrets stizzads restan en il chanaster da rument.", + "Days a deleted secret stays in the trash (1 to 365)": "Dis che in secret stizzà resta en il chanaster da rument (1 fin 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Quai spustescha il secret en il chanaster da rument e terminescha ussa sias parts. Ti al pos restaurar ord il chanaster da rument fin che la perioda da conservaziun è passada: 30 dis, sch'tes administratur n'ha betg midà quai.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Quai spustescha secrets en il chanaster da rument ({count}) e terminescha ussa lur parts. Ti als pos restaurar ord il chanaster da rument fin che la perioda da conservaziun è passada.", + "Remove {name} from favourites": "Allontanar {name} dals favurits", + "Add {name} to favourites": "Agiuntar {name} als favurits", + "Could not change the favourite": "Impussibel da midar il favurit", + "Remove from favourites": "Allontanar dals favurits", + "Add to favourites": "Agiuntar als favurits", + "Tags": "Etichettas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Las etichettas n'èn betg criptadas. Ils administraturs dal server las pon leger, sco ils nums da ordinaturs.", + "Favourites": "Favurits", + "Filter by tag": "Filtrar tenor etichetta", + "All tags": "Tut las etichettas", + "Last used": "Utilisà l'ultima giada", + "Tags for {count} secrets": "Etichettas per {count} secrets", + "Tag": "Etichetta", + "Remove tag": "Allontanar l'etichetta", + "Add tag": "Agiuntar ina etichetta", + "Could not change the tags. Try again.": "Impussibel da midar las etichettas. Empruvai anc ina giada.", + "Could not approve the application. It is still in the queue.": "Betg reussì d'approvar la dumonda. Ella è anc adina en la colonna.", + "Could not reject the application. It is still in the queue.": "Betg reussì da refusar la dumonda. Ella è anc adina en la colonna.", + "Removed the user from {count} team folders.": "Allontanà l'utilisader da {count} ordinaturs da team.", + "Approve a share": "Approvar ina partiziun", + "This approval link is incomplete. Open it again from the notification.": "Questa colliaziun d'approvaziun è incumpletta. Avra ella danovamain da la notificaziun.", + "Deny": "Refusar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} è entrà en ina gruppa cun la quala ti partas in secret. Partir il secret er cun el?", + "{requester} asks you to share a secret with {user}.": "{requester} ta dumonda da partir in secret cun {user}.", + "Shared. The recipient can now open the secret.": "Partì. Il destinatur po ussa avrir il secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Il destinatur n'ha anc betg configurà Keepiq, perquai n'è nagut vegnì partì. Emprova danovamain cura ch'el l'ha fatg.", + "Could not share the secret. Only its owner can approve this.": "Betg reussì da partir il secret. Mo ses possessur po approvar quai.", + "Could not share the secret. Try again.": "Betg reussì da partir il secret. Emprova danovamain.", + "Denied. Nothing was shared.": "Refusà. Nagut n'è vegnì partì.", + "Could not deny the request. Try again.": "Betg reussì da refusar la dumonda. Emprova danovamain.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ta dumonda da partir il secret \"%2$s\" cun %3$s.", + "Expires on (optional)": "Scada ils (facultativ)", + "Hand over to": "Surdar a", + "Choose a recipient": "Tscherna in destinatur", + "Hand over temporarily": "Surdar temporarmain", + "Expiry rules": "Reglas da scadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Fixescha quant ditg ch'ils pleds-clav d'in tip d'element u d'in ordinatur dastgan valair e cura che ti vegns regurdà. Sche pliras datas valan, quinta la pli baud.", + "Delete rule": "Stizzar la regla", + "Set by your administrator": "Fixà da tes administratur", + "No expiry rules yet.": "Anc naginas reglas da scadenza.", + "Applies to": "Vala per", + "Item type": "Tip d'element", + "Maximum age in days (empty for reminders only)": "Vegliadetgna maximala en dis (vid mo per regurdientschas)", + "Remind me this many days before, comma separated": "Ma regorda uschè blers dis ordavant, separà cun comma", + "Save rule": "Memorisar la regla", + "An item type": "In tip d'element", + "A folder": "In ordinatur", + "Folder {name}": "Ordinatur {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Scada suenter {days} dis", + "Reminders {days} days before": "Regurdientschas {days} dis ordavant", + "Could not save the expiry rule.": "Betg reussì da memorisar la regla da scadenza.", + "Could not delete the expiry rule.": "Betg reussì da stizzar la regla da scadenza.", + "All statuses": "Tut ils status", + "Compromised": "Cumpromess", + "Could not load the members.": "I n'è betg reussì da chargiar ils commembers.", + "Emergency contact": "Contact d'urgenza", + "Leaving user": "Utilisader che banduna", + "No": "Na", + "No users match this filter.": "Nagin utilisader na correspunda a quest filter.", + "Not set up": "Betg configurà", + "Revoke suite": "Revocar la suite", + "Revoked": "Revocà", + "Search users": "Tschertgar utilisaders", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Guardai tge utilisaders han configurà ina chascha forta. Cumenzai l'offboarding u revocai ina suite d'ina lingia.", + "Successor": "Successur", + "Team folders": "Ordinaturs da team", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utilisader è anc en la gruppa {groups}, che è commembra d'in ordinatur da team. Allontanai el da la gruppa u deactivai il conto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utilisader è anc en las gruppas {groups}, che èn commembras d'ordinaturs da team. Allontanai el da las gruppas u deactivai il conto.", + "Vault status": "Status da la chascha forta", + "Yes": "Gea", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "In export CXF N'È BETG CRIPTÀ. Mintga pled-clav e mintga login vegn a pudair vegnir legì sco text cler en la datoteca telechargiada. Conservai la a moda segira e stizzai la immediatamain suenter l'utilisaziun.", + "Root certificate expiring soon": "Il certificat da ragisch scada prest", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Il certificat da ragisch da la cassaforta scada en %1$d di(s). Renovai el avant. La renovaziun suttascriva danovamain mintga suite da criptaziun.", + "Compromise recovery aborted": "Recuperaziun suenter cumpromissiun interrutta", + "Key rotation ended by a compromise revoke": "Rotaziun da la clav terminada tras ina revocaziun pervia da cumpromissiun", + "Encryption suite revoke refused": "Revocaziun da la suita da criptografia refusada", + "Master password proof refused": "Cumprova dal pled-clav principal refusada", + "Your current master password": "Tes pled-clav principal actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact d'urgenza aveva ina dumonda d'access pendenta cura che la rotaziun da la clav l'ha allontanà. Controllescha tgi che ha dumandà avant che agiuntar puspè insatgi.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacts d'urgenza avevan ina dumonda d'access pendenta cura che la rotaziun da la clav als ha allontanà. Controllescha tgi che ha dumandà avant che agiuntar puspè insatgi.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Quests contacts d'urgenza n'èn betg vegnids transferids sin tia nova clav. Lur access d'urgenza è vegnì allontanà. Agiunta els puspè sut Access d'urgenza, sche ti vuls anc els.", + "Renew root certificate": "Renovar il certificat radical", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Quai crea in nov certificat radical ed intermediar. Mintga suite da criptaziun activa vegn suttascritta danovamain. Quai na po betg vegnir revocà.", + "Renew root": "Renovar la ragisch", + "Root renewed. {n} encryption suites signed again.": "Ragisch renovada. Suites da criptaziun suttascrittas danovamain: {n}.", + "Could not renew the root certificate.": "Impussibel da renovar il certificat radical.", + "Lease policy for this application": "Directiva da lease per questa applicaziun", + "In force now: {default} seconds by default, {max} seconds at most.": "Ussa en vigur: {default} secundas sco standard, maximalmain {max} secundas.", + "Leases are not renewable": "Leases na pon betg vegnir prolungads", + "Lease policy saved.": "Directiva da lease memorisada.", + "Leave a field empty to use the instance value.": "Lascha in champ vid per utilisar la valur da l'instanza.", + "Instance value: {value}": "Valur da l'instanza: {value}", + "Renewal": "Prolungaziun", + "Use the instance value ({value})": "Utilisar la valur da l'instanza ({value})", + "Allowed": "Permess", + "Not allowed": "Betg permess", + "Save lease policy": "Memorisar la directiva da lease", + "Only an administrator can change this policy.": "Mo in administratur po midar questa directiva.", + "Could not save the lease policy.": "Impussibel da memorisar la directiva da lease.", + "{member} got access from {confirmer}.": "{member} ha survegnì access da {confirmer}.", + "Automatically confirm new team folder members": "Confermar automaticamain novs commembers d'ordinaturs da team", + "Gave %n new member access to a team folder.": "%n nov commember ha survegnì access ad in ordinatur da team.", + "Gave %n new members access to a team folder.": "%n novs commembers han survegnì access ad in ordinatur da team.", + "Give new team folder members access without waiting for the folder owner.": "Dai access als novs commembers senza spetgar sin il possessur da l'ordinatur.", + "New team folder members": "Novs commembers d'ordinaturs da team", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Il possessur u in commember cun dretg da scriver als conferma da sia chascha forta averta. Keepiq na decifrescha mai sin il server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Spetgar fin ch'in commember cun dretg da scriver avra Keepiq. Vus pudais era parter ussa.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Ina part da la reacziun a la cumpromissiun n’è betg reussida ({failed} pass). Controllai il protocol dal server e revocai lura danovamain la suite per la terminar.", + "This also revoked suite {suite} and ended key migration {migration}.": "Quai ha er revocà la suite {suite} e terminà la migraziun da clavs {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La revocaziun da la segunda suite ha stizzà %n contact d’access d’urgenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "La revocaziun da la segunda suite ha stizzà %n contacts d’access d’urgenza.", + "A suite revoked as compromised cannot be reinstated.": "Ina suite revocada sco cumpromessa na po betg vegnir restituida.", + "Archives to keep": "Archivs da tegnair", + "Back up every vault automatically": "Far copias da segirezza da mintga chascha forta automaticamain", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faschai copias da segirezza da mintga chascha forta tenor plan. Ils archivs cuntegnan mo text criptà e vegnan restaurads cun occ.", + "Back up now": "Far ussa ina copia", + "Backup public key (PEM, optional)": "Clav publica da la copia (PEM, facultativ)", + "Backup requested for the next cron run": "Copia dumandada per la proxima execuziun da cron", + "Encrypted": "Criptà", + "Every (hours)": "Mintga (uras)", + "Last backup {when} failed: {error}": "L'ultima copia {when} n'è betg reussida: {error}", + "Last backup {when} succeeded.": "L'ultima copia {when} è reussida.", + "No archives yet.": "Anc nagins archivs.", + "Size": "Grondezza", + "Vault backups": "Copias da segirezza da la chascha forta", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Cun ina clav vegn mintga archiv criptà per ella. Tegnai la clav privata ordaifer quest server: Vus duvrais ella per verifitgar u restaurar.", + "Written": "Scrit", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilisader en il champ n'ha anc nagina annunzia en dus pass e na po betg avrir la chascha forta uschè ditg che quai è activà.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilisaders en il champ n'han anc nagina annunzia en dus pass e na pon betg avrir la chascha forta uschè ditg che quai è activà.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Codes da reserva na dumbran betg. Sche Voss utilisaders s'annunzian via in purschider d'identitad cun agen segund factur, laschai ora lur gruppas.", + "Block personal vault export": "Bloccar l'export da la chascha forta persunala", + "Keep work logins in team folders": "Tegnair las annunzias da lavur en ordinaturs da team", + "Move to a team folder": "Spustar en in ordinatur da team", + "Not in a team folder": "Betg en in ordinatur da team", + "Only for these groups (empty is everyone)": "Mo per questas gruppas (vid munta tuts)", + "Require two-factor login before the vault opens": "Pretender l'annunzia en dus pass avant che la chascha forta s'avra", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglas per mintga chascha forta. Mintgina vala per tuts u mo per las gruppas che Vus tschernis.", + "Secret types that belong in a team folder": "Tips da secrets che tutgan en in ordinatur da team", + "Set up two-factor login": "Configurar l'annunzia en dus pass", + "Team folder you can write to": "Ordinatur da team en il qual Vus pudais scriver", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Utilisaders na pon betg telechargiar ina copia da segirezza, in CSV u ina datoteca da transfer. Lur pachet da datas persunalas resta disponibel.", + "Users cannot save these secret types in a personal folder.": "Utilisaders na pon betg memorisar quests tips da secrets en in ordinatur persunal.", + "Vault policies": "Directivas da la chascha forta", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vossa organisaziun na permetta betg d'exportar Vossa chascha forta persunala. Voss pachet da datas persunalas en las configuraziuns resta disponibel.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vossa organisaziun tegna quests secrets en in ordinatur da team. Spustai mintgin en in ordinatur da team.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vossa organisaziun tegna quest tip da secret en in ordinatur da team. Tschernai in da Voss ordinaturs da team u in en il qual Vus pudais scriver.", + "Your organisation requires two-factor login before you can open your vault.": "Vossa organisaziun pretenda l'annunzia en dus pass avant che Vus pudais avrir Vossa chascha forta.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Ils utilisaders tschernan quant ditg che l'extensiun resta deblocada en cas d'inactivitad. Vus fixais il temp il pli lung ch'els pon tscherner.", + "Longest idle time before the extension locks": "Il temp d'inactivitad il pli lung avant che l'extensiun vegn bloccada", + "1 minute": "1 minuta", + "5 minutes": "5 minutas", + "15 minutes": "15 minutas", + "1 hour": "1 ura", + "4 hours": "4 uras", + "Connector": "Connectur", + "Directory (tenant) ID": "ID dal register (locatari)", + "Application (client) ID": "ID da l'applicaziun (client)", + "Data collection rule immutable ID": "ID immutabla da la regla da rimnada da datas", + "Stream name": "Num dal flux", + "Splunk index (optional)": "Index Splunk (facultativ)", + "Sourcetype (optional)": "Sourcetype (facultativ)", + "Leave blank to keep the current one": "Laschar vid per mantegnair la valur actuala", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punct final da rimnada da datas (URL https)", + "HTTP Event Collector URL (https)": "URL dal HTTP Event Collector (https)", + "Client secret (write-only)": "Secret dal client (mo scriver)", + "HEC token (write-only)": "Token HEC (mo scriver)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Trametter ils eveniments d'audit permess a Splunk, Microsoft Sentinel, in retschavider syslog u in webhook. Las messadis cuntegnan mo metadatas nettegiadas: nagina valur secreta, num, login u text criptà na banduna mai il server.", + "%n change waiting to sync": "%n midada spetga sin la sincronisaziun", + "%n changes waiting to sync": "%n midadas spetgan sin la sincronisaziun", + "Changes that could not sync": "Midadas che n'han betg pudì vegnir sincronisadas", + "Choose a version": "Tscherner ina versiun", + "Copy value": "Copiar la valur", + "Deleted": "Stizzà", + "Discard": "Bittar davent", + "Keep my offline change": "Mantegnair mia midada offline", + "Keep the server version": "Mantegnair la versiun dal server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq è offline mo per leger. L'administratur n'ha betg activà la modificaziun offline.", + "Let users edit secrets offline": "Permetter als utilisaders da modifitgar secrets offline", + "Not synced yet": "Anc betg sincronisà", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Las midadas offline restan sin l'apparat, criptadas per l'utilisader, e vegnan sincronisadas la proxima giada ch'il tresor vegn avert online. Parter, ordinaturs ed agiuntas dovran anc adina ina colliaziun.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Modificaziuns, spustaments e stizzadas restan sin quest apparat e vegnan sincronisads, cura che Vus essas puspè online. Parter ed agiuntas dovran ina colliaziun.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vossas midadas restan sin quest apparat e vegnan sincronisadas, cura che Vus essas puspè online. Ultima sincronisaziun {when}.", + "Open my changes": "Avrir mias midadas", + "Sharing needs a connection": "Parter dovra ina colliaziun", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Insatgi ha midà quest secret sin il server suenter che Vossa copia offline è vegnida fatga. Tscherni tge versiun mantegnair.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronisai u bittai davent Vossas midadas offline avant che remplazzar Vossas clavs.", + "That password did not open your changes.": "Quest pled-clav n'ha betg avert Vossas midadas.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La fotografia offline memorisescha secrets criptads (ins als po avrir mo cun la clav derivada dal pled-clav principal da l'utilisader, exact sco sin il server) e criptescha nums, URLs e nums d'ordinaturs memorisads. L'access offline è mo per leger, nun che Vus permettais sutvart la modificaziun offline. Deactivai quai per apparats che na dastgan mai memorisar datas d'annunzia; la deactivaziun stizza las memorias intermediaras existentas la proxima giada che la pagina vegn chargiada.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia precedenta dal tresor n'exista betg pli, perquai na pon questas midadas betg vegnir avertas.", + "The server version": "La versiun dal server", + "This secret changed while you were offline": "Quest secret è vegnì midà durant che Vus eras offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vus avais stizzà quest secret offline, ma el è vegnì midà sin il server dapi lura. Tscherni tge versiun mantegnair.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vossas clavs èn vegnidas midadas sin in auter apparat. Endatai Voss pled-clav principal precedent per sincronisar las midadas offline, u bittai davent ellas.", + "Your offline change": "Vossa midada offline", + "Allow approval from another device": "Permetter l'approvaziun d'in auter apparat", + "App": "App", + "Approve a new device": "Approvar in nov apparat", + "Approve from another device": "Approvar d'in auter apparat", + "Asked at": "Dumandà a las", + "Check that the new device shows these words:": "Controllai che il nov apparat mussa quests pleds:", + "Denied. If you did not ask, end your other sessions:": "Refusà. Sche Vus n'avais betg dumandà quai, terminai Vossas autras sesiuns:", + "Device": "Apparat", + "IP address": "Adressa IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lubir als utilisaders da debloccar in nov navigatur cun l'approvar d'in apparat nua che Keepiq è gia debloccà.", + "New device approval": "Approvaziun da novs apparats", + "Nextcloud security settings": "Parameters da segirezza da Nextcloud", + "Only approve a device you are using right now.": "Approvai mo in apparat che Vus utilisais gist ussa.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Avri Keepiq sin in apparat nua ch'el è debloccà ed approvai quest apparat. Controllai ch'el mussa ils medems pleds:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L'apparat che approva siglia la clav da debloccar per il nov apparat. Il server la transmetta mo e na po betg l'avrir.", + "The master password is not right, or the request has ended.": "Il pled-clav principal n'è betg correct, u la dumonda è terminada.", + "The request expired. Ask again or use your master password.": "La dumonda è scadida. Dumandai danovamain u utilisai Voss pled-clav principal.", + "The request was denied.": "La dumonda è vegnida refusada.", + "Too many requests. Try again in an hour or use your master password.": "Memia bleras dumondas. Empruvai danovamain en in'ura u utilisai Voss pled-clav principal.", + "Unknown device": "Apparat nunenconuschent", + "Web app": "App web", + "A device": "In apparat", + "A new device asks to open your vault": "In nov apparat dumonda dad avrir Voss tresor", + "%s asks to be approved. Only approve a device you are using right now.": "%s dumonda d'esser approvà. Approvai mo in apparat che Vus utilisais gist ussa.", + "Access ends on (optional)": "L'access finescha ils (opziunal)", + "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.": "Las apps da Keepiq na mussan ni copieschan il pled-clav. Insatgi cun enconuschientschas tecnicas al po tuttina leger sin ses agen apparat. Mida el cura che l'access finescha.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Quest secret è mo per l'utilisaziun. T'annunzia via l'extensiun dal navigatur da Keepiq.", + "Until {date}": "Fin ils {date}", + "Use only": "Mo utilisaziun", + "Use only (can sign in, cannot view or copy)": "Mo utilisaziun (po s'annunziar, na po betg vesair u copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ti pos t'annunziar cun quest login via l'extensiun dal navigatur da Keepiq. Il possessur ha decidì da betg ta laschar vesair u copiar el.", + "Your access ends on {date}": "Tes access finescha ils {date}", + "Your access to this secret has ended": "Tes access a quest secret è finì", + "Your access to \"%s\" ends tomorrow": "Tes access a «%s» finescha damaun", + "Your access to \"%s\" has ended": "Tes access a «%s» è finì", + "%1$s no longer has access to \"%2$s\"": "%1$s n'ha betg pli access a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s ha pudì vesair quest pled-clav. Mida el, sche %1$s na duess betg pli al enconuscher.", + "%s could not view this password in Keepiq.": "%s n'ha betg pudì vesair quest pled-clav en Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} da {threshold} approvaziuns", + "a recovery officer": "ina persuna responsabla per il recupero", + "Account recovery": "Recupero dal conto", + "Approvals needed": "Approvaziuns necessarias", + "Ask {user} which words they see, by phone or in person. They must be:": "Dumandai {user} per telefon u persunalmain tge pleds ch'el vesa. Els ston esser:", + "Check again": "Controllar anc ina giada", + "Create the recovery key": "Crear la clav da recupero", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creai la clav da recupero. Voss navigatur la generescha e dat a mintga responsabel ina copia che mo el po avrir.", + "Decline": "Refusar", + "Enrol in account recovery": "S'annunziar per il recupero dal conto", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "S'annunziai, uschia che Vossa organisaziun po As gidar a retschaiver enavos Voss tresor, sche Vus emblidais Voss pled-clav principal.", + "Every user is enrolled": "Tut ils utilisaders èn annunziads", + "Finish the recovery in the browser you asked from.": "Terminai il recupero en il navigatur nua che Vus avais dumandà.", + "Forgot your master password?": "Emblidà il pled-clav principal?", + "Hand the key over": "Surdar la clav", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lubir als utilisaders che han emblidà lur pled-clav principal da retschaiver enavos lur tresor, approvà da responsabels per il recupero che Vus nominais.", + "New master password": "Nov pled-clav principal", + "No one is asking to recover their account.": "Nagin na dumonda da recuperar ses conto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Anc nagina clav da recupero. In dals responsabels la crea en ses parameters da Keepiq.", + "Off": "Deactivà", + "Officer {user} has no encryption set up yet.": "Il responsabel {user} n'ha anc betg configurà la criptadi.", + "Officers (user IDs, separated by commas)": "Responsabels (IDs d'utilisader, separads cun commas)", + "Policy": "Directiva", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publitgai quest impronta internamain, uschia che utilisaders la pon controllar avant ch'els s'annunzian.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperà cun agid da {officer}. Midai ussa la clav da Voss tresor en Parameters, Segirezza: \"Mes pled-clav principal è vegnì cumpromess\".", + "Recovery key fingerprint: {fingerprint}": "Impronta da la clav da recupero: {fingerprint}", + "Recovery officer": "Responsabel per il recupero", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Responsabels allontanads perdan ussa lur copia, ma han forsa avert ella pli baud. Laschai crear in responsabel ina nova clav da recupero.", + "Repeat the new master password": "Repeter il nov pled-clav principal", + "Retire this recovery key": "Retrair questa clav da recupero", + "Set the new master password": "Fixar il nov pled-clav principal", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Il certificat da recupero n'è betg vegnì emess da quest Keepiq. Na s'annunziai betg ed infurmai Voss administratur.", + "The words match, approve": "Ils pleds correspundan, approvar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Quest utilisader è annunzià per il recupero dal conto. Il recupero mantegna ses secrets; la revocaziun stizza sia annunzia.", + "Users may enrol": "Utilisaders pon s'annunziar", + "Withdraw from account recovery": "Sa retrair dal recupero dal conto", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Vus essas annunzià per il recupero dal conto. Impronta da la clav da recupero: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Vus essas annunzià. Sche Vus emblidais Voss pled-clav principal, po Vossa organisaziun As gidar a retschaiver enavos Voss tresor.", + "Your key is back. Choose a new master password.": "Vossa clav è enavos. Tscherni in nov pled-clav principal.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Voss responsabels per il recupero èn vegnids infurmads. Legiai ad els quests pleds cura ch'els As telefoneschan u As scuntran:", + "You are now an account recovery officer": "Vus essas ussa responsabel per il recupero da contos", + "%s asks to recover their account. Compare the words with them before you approve.": "%s dumonda da recuperar ses conto. Cumparegliai ils pleds cun quella persuna avant ch'approvar.", + "A user": "In utilisader", + "Your account recovery request was declined": "Vossa dumonda da recupero dal conto è vegnida refusada", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Il recupero da Voss conto è pront. Avri Keepiq en il navigatur nua che Vus avais dumandà.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} dumonda da debloccar ina giada in nov apparat. Il pled-clav principal resta il medem.", + "Ask your organisation instead": "Dumonda empè tia organisaziun", + "The request ended. Ask again or use your master password.": "La dumonda è terminada. Dumonda anc ina giada u utilisescha tes pled-clav principal.", + "Added by {user}": "Agiuntà da {user}", + "Editor": "Editur", + "Manager": "Administratur", + "Role of {member}": "Rolla da {member}", + "Team folders you manage": "Ordinaturs da team che Vus administrais", + "Viewer": "Lectur", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Vus n'avais nagina copia da quests secrets, perquai n'han ils novs commembers anc betg survegnì els. Il proprietari als po parter: {names}", + "Admin areas": "Secturs d'administraziun", + "Give a group only the parts of Keepiq administration it needs.": "Dai ad ina gruppa mo las parts da l'administraziun da Keepiq ch'ella dovra.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegai in u plirs secturs ad ina gruppa sin la pagina dals privilegis d'administraziun. Ils administraturs da l'instanza han mintga sectur.", + "Open administration privileges": "Avrir ils privilegis d'administraziun", + "Policies": "Directivas", + "Applications and machine access": "Applicaziuns ed access da maschinas", + "People and offboarding": "Persunas e partenzas", + "Audit and compliance": "Revisiun e conformitad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versiun, autoritad da certificaziun, agiuntas, cache offline, controlla da perditas, tips da secrets e copias da segirezza", + "master password, organisation password, vault policies, rotation, version history and trash": "pled-clav principal, pled-clav da l'organisaziun, directivas da la chascha, rotaziun, istorgia da versiuns e chanaster", + "application queue, application requests and machine leases": "rait d'applicaziuns, dumondas d'applicaziuns e fittanzas da maschinas", + "team offboarding, encryption suites and admin handover": "partenzas dal team, suites da criptaziun e surpigliada da l'administratur", + "audit log, compliance reports, SIEM export and honey alerts": "protocol da revisiun, rapports da conformitad, export SIEM ed avertiments d'eschca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantas versiuns d'in secret vegnan tegnidas, quant ditg, e quant ditg ch'ils secrets stizzads restan en il chanaster.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limitas per agiuntas criptadas, applitgadas sin il server en bytes criptads memorisads.", + "Type the suite ID again to confirm": "Endatai anc ina giada l'ID da la suite per confermar", + "This does not match the suite ID.": "Quai na correspunda betg a l'ID da la suite.", + "Confirm with your master password": "Confermai cun Voss pled-clav principal", + "Confirm": "Confermar", + "That master password is not right.": "Quest pled-clav principal n'è betg correct.", + "You are sharing with someone new. Enter your master password to confirm.": "Vus partis cun ina persuna nova. Endatai Voss pled-clav principal per confermar.", + "Enter your master password to confirm this share.": "Endatai Voss pled-clav principal per confermar questa partiziun.", + "Enter your master password to confirm this delegation.": "Endatai Voss pled-clav principal per confermar questa delegaziun.", + "Approve {member}": "Approvar {member}", + "Recipient": "Destinatari", + "No vault yet": "Anc nagina cassaforta", + "No matching users": "Nagins utilisaders correspundents", + "Partner organisations": "Organisaziuns partenarias", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Barattai secrets cun in auter Keepiq. Omadus administraturs s’agiuntan vicendaivlamain e cumparan las improntas da ragisch per telefon u persunalmain avant che memorisar.", + "Federation needs Nextcloud 33 or later.": "La federaziun dovra Nextcloud 33 u pli nov.", + "Your root fingerprint": "Vossa impronta da ragisch", + "No partners yet.": "Anc nagins partenaris.", + "Users here may share to this partner": "Utilisaders qua dastgan parter cun quest partenari", + "This partner may share to users here": "Quest partenari dastga parter cun utilisaders qua", + "Partner address": "Adressa dal partenari", + "Check partner": "Verifitgar il partenari", + "Partner root fingerprint": "Impronta da ragisch dal partenari", + "I compared this fingerprint with the partner's administrator": "Jau hai cumparà questa impronta cun l’administratur dal partenari", + "Add partner": "Agiuntar il partenari", + "A secret from another organisation": "In secret d’ina autra organisaziun", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha cundividì \"%2$s\" cun Vus. Acceptai el sut Retschavì dad autras organisaziuns.", + "Incoming from other organisations": "Retschavì dad autras organisaziuns", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Persunas en organisaziuns partenarias pon cundivider in secret cun Vus. Acceptai el per tegnair ina copia mo per leger en Vossa cassaforta.", + "Nothing shared with you yet": "Anc nagut cundividì cun Vus", + "Secrets that people in partner organisations share with you appear here.": "Secrets che persunas en organisaziuns partenarias cundividan cun Vus cumparan qua.", + "From {sender}": "Da {sender}", + "Accept": "Acceptar", + "Open in vault": "Avrir en la cassaforta", + "The other organisation did not hand over the secret. Try again later.": "L’autra organisaziun n’ha betg surdà il secret. Empruvai pli tard anc ina giada.", + "Set up your vault before you accept a shared secret.": "Configurai Vossa cassaforta avant che acceptar in secret cundividì.", + "Something went wrong. Try again.": "Insatge è ì mal. Empruvai anc ina giada.", + "Waiting for your answer": "Spetga Vossa resposta", + "In your vault, read-only": "En Vossa cassaforta, mo per leger", + "Withdrawn by the sender": "Retratg dal speditur", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ha cundividì quai dad ina autra organisaziun. Vus pudais leger el, ma betg midar u cundivider.", + "Someone": "Insatgi", + "Share with someone at another organisation": "Cundivider cun insatgi dad ina autra organisaziun", + "Their account at the other organisation": "Il conto da la persuna en l’autra organisaziun", + "Check account": "Controllar il conto", + "Certificate fingerprint of {account}": "Impronta dal certificat da {account}", + "Compare it with them by phone if you want to be sure.": "Cumparai ella cun la persuna per telefon, sche Vus vulais esser segirs.", + "Shared. {account} can accept it in their own vault.": "Cundividì. {account} po acceptar el en sia atgna cassaforta.", + "The certificate could not be verified. Nothing was shared.": "Il certificat n’ha betg pudì vegnir verifitgà. Nagut n’è vegnì cundividì.", + "That organisation is not one of your partners.": "Questa organisaziun n’è betg in da Voss partenaris.", + "No one with that account can receive secrets from you.": "Nagin cun quest conto na po retschaiver secrets da Vus.", + "The other organisation did not answer. Try again later.": "L’autra organisaziun n’ha betg respundì. Empruvai pli tard anc ina giada.", + "This secret is already shared with that account.": "Quest secret è gia cundividì cun quest conto.", + "Other organisations": "Autras organisaziuns", + "Receive secrets from other organisations": "Retschaiver secrets dad autras organisaziuns", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Persunas en organisaziuns partenarias pon lura chattar Voss conto e cundivider secrets cun Vus. Vus acceptais mintgin sez.", + "Shared": "Cundividì", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: lur certificat u la partenadad è sa midà. Revocai la cundivisiun u cundividai danovamain.", + "Their organisation did not get the last change. Revoke it or share again.": "Lur organisaziun n’ha betg retschavì l’ultima midada. Revocai la cundivisiun u cundividai danovamain.", + "Being withdrawn": "Vegn retratg", + "Shared with another organisation": "Cundividì cun ina autra organisaziun", + "Change sent to another organisation": "Midada tramessa ad ina autra organisaziun", + "Share with another organisation revoked": "Cundivisiun cun ina autra organisaziun revocada", + "Share with another organisation paused": "Cundivisiun cun ina autra organisaziun en pausa", + "Another organisation did not get a change": "Ina autra organisaziun n’ha betg retschavì ina midada", + "Secret received from another organisation": "Secret retschavì dad ina autra organisaziun", + "Secret from another organisation accepted": "Secret d’ina autra organisaziun acceptà", + "Secret from another organisation declined": "Secret d’ina autra organisaziun refusà", + "Copy from another organisation updated": "Copia d’ina autra organisaziun actualisada", + "Copy from another organisation removed": "Copia d’ina autra organisaziun stizzada", + "Declined: they removed their copy. Share again if they need it.": "Refusà: il retschavider ha stizzà sia copia. Cundividai danovamain sch’el ha basegn.", + "Recipient at another organisation removed their copy": "In retschavider d’ina autra organisaziun ha stizzà sia copia", + "Removed the user from %n team folder.": "Allontanà l'utilisader da %n ordinatur da team.", + "Removed the user from %n team folders.": "Allontanà l'utilisader da %n ordinaturs da team.", + "A restored copy came from a share that has ended. It stays read-only.": "Ina copia restaurada deriva d’ina cundivisiun che è terminada. Ella resta mo per leger.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L’organisaziun che ha cundivì ina copia restaurada n’è betg cuntanschibla. La copia resta mo per leger e na suonda betg lur midadas.", + "Recipient at another organisation restored their copy": "In retschavider d’ina autra organisaziun ha restaurà sia copia" }, - "nplurals=2; plural=(n != 1);" + "nplurals=1; plural=0;" ) diff --git a/l10n/rm.json b/l10n/rm.json index 1117edb2c..0e739cb85 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1181,7 +1181,467 @@ "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." + "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.", + "Shared with groups": "Partì cun gruppas", + "Not shared with any group yet.": "Anc betg partì cun ina gruppa.", + "Revoke the share with {group}": "Revocar la partiziun cun {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partì cun {group}: {received} commembers han retschavì quai, {skipped} betg, perquai ch'els n'han anc betg configurà il criptadi.", + "Search groups": "Tschertgar gruppas", + "Failed to share": "La partiziun n'è betg reussida", + "Columns": "Colonnas", + "Column {number}": "Colonna {number}", + "Map one column to Name. Every secret needs a name.": "Attribuescha ina colonna al num. Mintga secret dovra in num.", + "Notes": "Notizias", + "Do not import": "Betg importar", + "Hide this value": "Zuppentar questa valur", + "Show this value": "Mussar questa valur", + "Defaults": "Valurs predefinidas", + "New secrets start as this type, and your secret list opens in this view.": "Novs secrets cumenzan cun quest tip, e tia glista da secrets s'avra en questa vista.", + "Default item type": "Tip d'element predefinì", + "Cards": "Cartas", + "Table": "Tabella", + "Could not save your default": "Betg pussaivel da memorisar tia valur predefinida", + "Recently used": "Duvrà dacurt", + "Opened": "Avert", + "You have not opened any secrets yet": "Ti n'has anc avert nagin secret", + "Could not delete the item type.": "Betg pussaivel da stizzar il tip d'element.", + "Could not load the item types.": "Betg pussaivel da chargiar ils tips d'element.", + "Could not save the item type.": "Betg pussaivel da memorisar il tip d'element.", + "Delete item type": "Stizzar il tip d'element", + "Edit item type": "Modifitgar il tip d'element", + "Fields": "Champs", + "Fields: {count}": "Champs: {count}", + "Hidden": "Zuppà", + "Item types": "Tips d'element", + "Move up": "Spustar ensi", + "New item type": "Nov tip d'element", + "No item types defined yet.": "Anc nagins tips d'element definids.", + "Required": "Obligatoric", + "Text": "Text", + "This field is required": "Quest champ è obligatoric", + "Web address": "Adressa web", + "{label} (required)": "{label} (obligatoric)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Stizzar «{name}»? Ils secrets da quest tip restan legibels e daventan elements Login.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Ils tips d'element che ti defineschas qua cumparan per tuts en il dialog Nov secret, cun ils champs che ti tschernas.", + "Secret moved to the trash": "Secret spustà en il chanaster da rument", + "Secret restored from the trash": "Secret restaurà ord il chanaster da rument", + "Secret deleted for good": "Secret stizzà definitivamain", + "Secret archived": "Secret archivà", + "Secret unarchived": "Secret prendì ord l'archiv", + "Unarchive": "Prender ord l'archiv", + "Could not archive the secret": "Betg reussì dad archivar il secret", + "Could not unarchive the secret": "Betg reussì da prender il secret ord l'archiv", + "Archive {count} secrets": "Archivar secrets: {count}", + "Unarchive {count} secrets": "Prender ord l'archiv secrets: {count}", + "Restore {count} secrets": "Restaurar secrets: {count}", + "Delete {count} secrets for good": "Stizzar definitivamain secrets: {count}", + "Done for {ok} of {total} secrets": "Fatg per {ok} da {total} secrets", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Secrets archivads svaneschan da la glista dal tresor, da la tschertga, da l'emplenida automatica e dal rapport da sanadad. Els mantegnan lur parts. Ti als chattas sut Archiv.", + "These secrets come back to the vault list, search and autofill.": "Quests secrets returnan en la glista dal tresor, en la tschertga ed en l'emplenida automatica.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Quests secrets returnan en la glista dal tresor. Lur parts veglias na returnan betg, parta pia danovamain nua che quai è necessari.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Quai stizza ils secrets cun lur agiuntas e lur istorgia da versiuns. Quai na po betg vegnir revocà.", + "Delete for good": "Stizzar definitivamain", + "Trash": "Chanaster da rument", + "The trash is empty": "Il chanaster da rument è vid", + "No archived secrets": "Nagins secrets archivads", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Secrets stizzads spetgan qua fin che la perioda da conservaziun è passada, lura vegnan els stizzads definitivamain.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivescha in secret en ses panel da detagls per al tegnair ordaifer la glista dal tresor, la tschertga e l'emplenida automatica.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limitas per agiuntas criptadas (applitgadas sin il server als bytes criptads memorisads), conservaziun da l'istorgia da versiuns e quant ditg che secrets stizzads restan en il chanaster da rument.", + "Days a deleted secret stays in the trash (1 to 365)": "Dis che in secret stizzà resta en il chanaster da rument (1 fin 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Quai spustescha il secret en il chanaster da rument e terminescha ussa sias parts. Ti al pos restaurar ord il chanaster da rument fin che la perioda da conservaziun è passada: 30 dis, sch'tes administratur n'ha betg midà quai.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Quai spustescha secrets en il chanaster da rument ({count}) e terminescha ussa lur parts. Ti als pos restaurar ord il chanaster da rument fin che la perioda da conservaziun è passada.", + "Remove {name} from favourites": "Allontanar {name} dals favurits", + "Add {name} to favourites": "Agiuntar {name} als favurits", + "Could not change the favourite": "Impussibel da midar il favurit", + "Remove from favourites": "Allontanar dals favurits", + "Add to favourites": "Agiuntar als favurits", + "Tags": "Etichettas", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Las etichettas n'èn betg criptadas. Ils administraturs dal server las pon leger, sco ils nums da ordinaturs.", + "Favourites": "Favurits", + "Filter by tag": "Filtrar tenor etichetta", + "All tags": "Tut las etichettas", + "Last used": "Utilisà l'ultima giada", + "Tags for {count} secrets": "Etichettas per {count} secrets", + "Tag": "Etichetta", + "Remove tag": "Allontanar l'etichetta", + "Add tag": "Agiuntar ina etichetta", + "Could not change the tags. Try again.": "Impussibel da midar las etichettas. Empruvai anc ina giada.", + "Could not approve the application. It is still in the queue.": "Betg reussì d'approvar la dumonda. Ella è anc adina en la colonna.", + "Could not reject the application. It is still in the queue.": "Betg reussì da refusar la dumonda. Ella è anc adina en la colonna.", + "Removed the user from {count} team folders.": "Allontanà l'utilisader da {count} ordinaturs da team.", + "Approve a share": "Approvar ina partiziun", + "This approval link is incomplete. Open it again from the notification.": "Questa colliaziun d'approvaziun è incumpletta. Avra ella danovamain da la notificaziun.", + "Deny": "Refusar", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} è entrà en ina gruppa cun la quala ti partas in secret. Partir il secret er cun el?", + "{requester} asks you to share a secret with {user}.": "{requester} ta dumonda da partir in secret cun {user}.", + "Shared. The recipient can now open the secret.": "Partì. Il destinatur po ussa avrir il secret.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Il destinatur n'ha anc betg configurà Keepiq, perquai n'è nagut vegnì partì. Emprova danovamain cura ch'el l'ha fatg.", + "Could not share the secret. Only its owner can approve this.": "Betg reussì da partir il secret. Mo ses possessur po approvar quai.", + "Could not share the secret. Try again.": "Betg reussì da partir il secret. Emprova danovamain.", + "Denied. Nothing was shared.": "Refusà. Nagut n'è vegnì partì.", + "Could not deny the request. Try again.": "Betg reussì da refusar la dumonda. Emprova danovamain.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ta dumonda da partir il secret \"%2$s\" cun %3$s.", + "Expires on (optional)": "Scada ils (facultativ)", + "Hand over to": "Surdar a", + "Choose a recipient": "Tscherna in destinatur", + "Hand over temporarily": "Surdar temporarmain", + "Expiry rules": "Reglas da scadenza", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Fixescha quant ditg ch'ils pleds-clav d'in tip d'element u d'in ordinatur dastgan valair e cura che ti vegns regurdà. Sche pliras datas valan, quinta la pli baud.", + "Delete rule": "Stizzar la regla", + "Set by your administrator": "Fixà da tes administratur", + "No expiry rules yet.": "Anc naginas reglas da scadenza.", + "Applies to": "Vala per", + "Item type": "Tip d'element", + "Maximum age in days (empty for reminders only)": "Vegliadetgna maximala en dis (vid mo per regurdientschas)", + "Remind me this many days before, comma separated": "Ma regorda uschè blers dis ordavant, separà cun comma", + "Save rule": "Memorisar la regla", + "An item type": "In tip d'element", + "A folder": "In ordinatur", + "Folder {name}": "Ordinatur {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Scada suenter {days} dis", + "Reminders {days} days before": "Regurdientschas {days} dis ordavant", + "Could not save the expiry rule.": "Betg reussì da memorisar la regla da scadenza.", + "Could not delete the expiry rule.": "Betg reussì da stizzar la regla da scadenza.", + "All statuses": "Tut ils status", + "Compromised": "Cumpromess", + "Could not load the members.": "I n'è betg reussì da chargiar ils commembers.", + "Emergency contact": "Contact d'urgenza", + "Leaving user": "Utilisader che banduna", + "No": "Na", + "No users match this filter.": "Nagin utilisader na correspunda a quest filter.", + "Not set up": "Betg configurà", + "Revoke suite": "Revocar la suite", + "Revoked": "Revocà", + "Search users": "Tschertgar utilisaders", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Guardai tge utilisaders han configurà ina chascha forta. Cumenzai l'offboarding u revocai ina suite d'ina lingia.", + "Successor": "Successur", + "Team folders": "Ordinaturs da team", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "L'utilisader è anc en la gruppa {groups}, che è commembra d'in ordinatur da team. Allontanai el da la gruppa u deactivai il conto.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "L'utilisader è anc en las gruppas {groups}, che èn commembras d'ordinaturs da team. Allontanai el da las gruppas u deactivai il conto.", + "Vault status": "Status da la chascha forta", + "Yes": "Gea", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "In export CXF N'È BETG CRIPTÀ. Mintga pled-clav e mintga login vegn a pudair vegnir legì sco text cler en la datoteca telechargiada. Conservai la a moda segira e stizzai la immediatamain suenter l'utilisaziun.", + "Root certificate expiring soon": "Il certificat da ragisch scada prest", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Il certificat da ragisch da la cassaforta scada en %1$d di(s). Renovai el avant. La renovaziun suttascriva danovamain mintga suite da criptaziun.", + "Compromise recovery aborted": "Recuperaziun suenter cumpromissiun interrutta", + "Key rotation ended by a compromise revoke": "Rotaziun da la clav terminada tras ina revocaziun pervia da cumpromissiun", + "Encryption suite revoke refused": "Revocaziun da la suita da criptografia refusada", + "Master password proof refused": "Cumprova dal pled-clav principal refusada", + "Your current master password": "Tes pled-clav principal actual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact d'urgenza aveva ina dumonda d'access pendenta cura che la rotaziun da la clav l'ha allontanà. Controllescha tgi che ha dumandà avant che agiuntar puspè insatgi.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacts d'urgenza avevan ina dumonda d'access pendenta cura che la rotaziun da la clav als ha allontanà. Controllescha tgi che ha dumandà avant che agiuntar puspè insatgi.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Quests contacts d'urgenza n'èn betg vegnids transferids sin tia nova clav. Lur access d'urgenza è vegnì allontanà. Agiunta els puspè sut Access d'urgenza, sche ti vuls anc els.", + "Renew root certificate": "Renovar il certificat radical", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Quai crea in nov certificat radical ed intermediar. Mintga suite da criptaziun activa vegn suttascritta danovamain. Quai na po betg vegnir revocà.", + "Renew root": "Renovar la ragisch", + "Root renewed. {n} encryption suites signed again.": "Ragisch renovada. Suites da criptaziun suttascrittas danovamain: {n}.", + "Could not renew the root certificate.": "Impussibel da renovar il certificat radical.", + "Lease policy for this application": "Directiva da lease per questa applicaziun", + "In force now: {default} seconds by default, {max} seconds at most.": "Ussa en vigur: {default} secundas sco standard, maximalmain {max} secundas.", + "Leases are not renewable": "Leases na pon betg vegnir prolungads", + "Lease policy saved.": "Directiva da lease memorisada.", + "Leave a field empty to use the instance value.": "Lascha in champ vid per utilisar la valur da l'instanza.", + "Instance value: {value}": "Valur da l'instanza: {value}", + "Renewal": "Prolungaziun", + "Use the instance value ({value})": "Utilisar la valur da l'instanza ({value})", + "Allowed": "Permess", + "Not allowed": "Betg permess", + "Save lease policy": "Memorisar la directiva da lease", + "Only an administrator can change this policy.": "Mo in administratur po midar questa directiva.", + "Could not save the lease policy.": "Impussibel da memorisar la directiva da lease.", + "{member} got access from {confirmer}.": "{member} ha survegnì access da {confirmer}.", + "Automatically confirm new team folder members": "Confermar automaticamain novs commembers d'ordinaturs da team", + "Gave %n new member access to a team folder.": "%n nov commember ha survegnì access ad in ordinatur da team.", + "Gave %n new members access to a team folder.": "%n novs commembers han survegnì access ad in ordinatur da team.", + "Give new team folder members access without waiting for the folder owner.": "Dai access als novs commembers senza spetgar sin il possessur da l'ordinatur.", + "New team folder members": "Novs commembers d'ordinaturs da team", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Il possessur u in commember cun dretg da scriver als conferma da sia chascha forta averta. Keepiq na decifrescha mai sin il server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Spetgar fin ch'in commember cun dretg da scriver avra Keepiq. Vus pudais era parter ussa.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Ina part da la reacziun a la cumpromissiun n’è betg reussida ({failed} pass). Controllai il protocol dal server e revocai lura danovamain la suite per la terminar.", + "This also revoked suite {suite} and ended key migration {migration}.": "Quai ha er revocà la suite {suite} e terminà la migraziun da clavs {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "La revocaziun da la segunda suite ha stizzà %n contact d’access d’urgenza.", + "Revoking the second suite deleted %n emergency-access contacts.": "La revocaziun da la segunda suite ha stizzà %n contacts d’access d’urgenza.", + "A suite revoked as compromised cannot be reinstated.": "Ina suite revocada sco cumpromessa na po betg vegnir restituida.", + "Archives to keep": "Archivs da tegnair", + "Back up every vault automatically": "Far copias da segirezza da mintga chascha forta automaticamain", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faschai copias da segirezza da mintga chascha forta tenor plan. Ils archivs cuntegnan mo text criptà e vegnan restaurads cun occ.", + "Back up now": "Far ussa ina copia", + "Backup public key (PEM, optional)": "Clav publica da la copia (PEM, facultativ)", + "Backup requested for the next cron run": "Copia dumandada per la proxima execuziun da cron", + "Encrypted": "Criptà", + "Every (hours)": "Mintga (uras)", + "Last backup {when} failed: {error}": "L'ultima copia {when} n'è betg reussida: {error}", + "Last backup {when} succeeded.": "L'ultima copia {when} è reussida.", + "No archives yet.": "Anc nagins archivs.", + "Size": "Grondezza", + "Vault backups": "Copias da segirezza da la chascha forta", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Cun ina clav vegn mintga archiv criptà per ella. Tegnai la clav privata ordaifer quest server: Vus duvrais ella per verifitgar u restaurar.", + "Written": "Scrit", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilisader en il champ n'ha anc nagina annunzia en dus pass e na po betg avrir la chascha forta uschè ditg che quai è activà.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilisaders en il champ n'han anc nagina annunzia en dus pass e na pon betg avrir la chascha forta uschè ditg che quai è activà.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Codes da reserva na dumbran betg. Sche Voss utilisaders s'annunzian via in purschider d'identitad cun agen segund factur, laschai ora lur gruppas.", + "Block personal vault export": "Bloccar l'export da la chascha forta persunala", + "Keep work logins in team folders": "Tegnair las annunzias da lavur en ordinaturs da team", + "Move to a team folder": "Spustar en in ordinatur da team", + "Not in a team folder": "Betg en in ordinatur da team", + "Only for these groups (empty is everyone)": "Mo per questas gruppas (vid munta tuts)", + "Require two-factor login before the vault opens": "Pretender l'annunzia en dus pass avant che la chascha forta s'avra", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reglas per mintga chascha forta. Mintgina vala per tuts u mo per las gruppas che Vus tschernis.", + "Secret types that belong in a team folder": "Tips da secrets che tutgan en in ordinatur da team", + "Set up two-factor login": "Configurar l'annunzia en dus pass", + "Team folder you can write to": "Ordinatur da team en il qual Vus pudais scriver", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Utilisaders na pon betg telechargiar ina copia da segirezza, in CSV u ina datoteca da transfer. Lur pachet da datas persunalas resta disponibel.", + "Users cannot save these secret types in a personal folder.": "Utilisaders na pon betg memorisar quests tips da secrets en in ordinatur persunal.", + "Vault policies": "Directivas da la chascha forta", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vossa organisaziun na permetta betg d'exportar Vossa chascha forta persunala. Voss pachet da datas persunalas en las configuraziuns resta disponibel.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vossa organisaziun tegna quests secrets en in ordinatur da team. Spustai mintgin en in ordinatur da team.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vossa organisaziun tegna quest tip da secret en in ordinatur da team. Tschernai in da Voss ordinaturs da team u in en il qual Vus pudais scriver.", + "Your organisation requires two-factor login before you can open your vault.": "Vossa organisaziun pretenda l'annunzia en dus pass avant che Vus pudais avrir Vossa chascha forta.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Ils utilisaders tschernan quant ditg che l'extensiun resta deblocada en cas d'inactivitad. Vus fixais il temp il pli lung ch'els pon tscherner.", + "Longest idle time before the extension locks": "Il temp d'inactivitad il pli lung avant che l'extensiun vegn bloccada", + "1 minute": "1 minuta", + "5 minutes": "5 minutas", + "15 minutes": "15 minutas", + "1 hour": "1 ura", + "4 hours": "4 uras", + "Connector": "Connectur", + "Directory (tenant) ID": "ID dal register (locatari)", + "Application (client) ID": "ID da l'applicaziun (client)", + "Data collection rule immutable ID": "ID immutabla da la regla da rimnada da datas", + "Stream name": "Num dal flux", + "Splunk index (optional)": "Index Splunk (facultativ)", + "Sourcetype (optional)": "Sourcetype (facultativ)", + "Leave blank to keep the current one": "Laschar vid per mantegnair la valur actuala", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punct final da rimnada da datas (URL https)", + "HTTP Event Collector URL (https)": "URL dal HTTP Event Collector (https)", + "Client secret (write-only)": "Secret dal client (mo scriver)", + "HEC token (write-only)": "Token HEC (mo scriver)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Trametter ils eveniments d'audit permess a Splunk, Microsoft Sentinel, in retschavider syslog u in webhook. Las messadis cuntegnan mo metadatas nettegiadas: nagina valur secreta, num, login u text criptà na banduna mai il server.", + "%n change waiting to sync": "%n midada spetga sin la sincronisaziun", + "%n changes waiting to sync": "%n midadas spetgan sin la sincronisaziun", + "Changes that could not sync": "Midadas che n'han betg pudì vegnir sincronisadas", + "Choose a version": "Tscherner ina versiun", + "Copy value": "Copiar la valur", + "Deleted": "Stizzà", + "Discard": "Bittar davent", + "Keep my offline change": "Mantegnair mia midada offline", + "Keep the server version": "Mantegnair la versiun dal server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq è offline mo per leger. L'administratur n'ha betg activà la modificaziun offline.", + "Let users edit secrets offline": "Permetter als utilisaders da modifitgar secrets offline", + "Not synced yet": "Anc betg sincronisà", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Las midadas offline restan sin l'apparat, criptadas per l'utilisader, e vegnan sincronisadas la proxima giada ch'il tresor vegn avert online. Parter, ordinaturs ed agiuntas dovran anc adina ina colliaziun.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Modificaziuns, spustaments e stizzadas restan sin quest apparat e vegnan sincronisads, cura che Vus essas puspè online. Parter ed agiuntas dovran ina colliaziun.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vossas midadas restan sin quest apparat e vegnan sincronisadas, cura che Vus essas puspè online. Ultima sincronisaziun {when}.", + "Open my changes": "Avrir mias midadas", + "Sharing needs a connection": "Parter dovra ina colliaziun", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Insatgi ha midà quest secret sin il server suenter che Vossa copia offline è vegnida fatga. Tscherni tge versiun mantegnair.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronisai u bittai davent Vossas midadas offline avant che remplazzar Vossas clavs.", + "That password did not open your changes.": "Quest pled-clav n'ha betg avert Vossas midadas.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "La fotografia offline memorisescha secrets criptads (ins als po avrir mo cun la clav derivada dal pled-clav principal da l'utilisader, exact sco sin il server) e criptescha nums, URLs e nums d'ordinaturs memorisads. L'access offline è mo per leger, nun che Vus permettais sutvart la modificaziun offline. Deactivai quai per apparats che na dastgan mai memorisar datas d'annunzia; la deactivaziun stizza las memorias intermediaras existentas la proxima giada che la pagina vegn chargiada.", + "The previous vault copy is gone, so these changes cannot be opened.": "La copia precedenta dal tresor n'exista betg pli, perquai na pon questas midadas betg vegnir avertas.", + "The server version": "La versiun dal server", + "This secret changed while you were offline": "Quest secret è vegnì midà durant che Vus eras offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Vus avais stizzà quest secret offline, ma el è vegnì midà sin il server dapi lura. Tscherni tge versiun mantegnair.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vossas clavs èn vegnidas midadas sin in auter apparat. Endatai Voss pled-clav principal precedent per sincronisar las midadas offline, u bittai davent ellas.", + "Your offline change": "Vossa midada offline", + "Allow approval from another device": "Permetter l'approvaziun d'in auter apparat", + "App": "App", + "Approve a new device": "Approvar in nov apparat", + "Approve from another device": "Approvar d'in auter apparat", + "Asked at": "Dumandà a las", + "Check that the new device shows these words:": "Controllai che il nov apparat mussa quests pleds:", + "Denied. If you did not ask, end your other sessions:": "Refusà. Sche Vus n'avais betg dumandà quai, terminai Vossas autras sesiuns:", + "Device": "Apparat", + "IP address": "Adressa IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lubir als utilisaders da debloccar in nov navigatur cun l'approvar d'in apparat nua che Keepiq è gia debloccà.", + "New device approval": "Approvaziun da novs apparats", + "Nextcloud security settings": "Parameters da segirezza da Nextcloud", + "Only approve a device you are using right now.": "Approvai mo in apparat che Vus utilisais gist ussa.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Avri Keepiq sin in apparat nua ch'el è debloccà ed approvai quest apparat. Controllai ch'el mussa ils medems pleds:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "L'apparat che approva siglia la clav da debloccar per il nov apparat. Il server la transmetta mo e na po betg l'avrir.", + "The master password is not right, or the request has ended.": "Il pled-clav principal n'è betg correct, u la dumonda è terminada.", + "The request expired. Ask again or use your master password.": "La dumonda è scadida. Dumandai danovamain u utilisai Voss pled-clav principal.", + "The request was denied.": "La dumonda è vegnida refusada.", + "Too many requests. Try again in an hour or use your master password.": "Memia bleras dumondas. Empruvai danovamain en in'ura u utilisai Voss pled-clav principal.", + "Unknown device": "Apparat nunenconuschent", + "Web app": "App web", + "A device": "In apparat", + "A new device asks to open your vault": "In nov apparat dumonda dad avrir Voss tresor", + "%s asks to be approved. Only approve a device you are using right now.": "%s dumonda d'esser approvà. Approvai mo in apparat che Vus utilisais gist ussa.", + "Access ends on (optional)": "L'access finescha ils (opziunal)", + "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.": "Las apps da Keepiq na mussan ni copieschan il pled-clav. Insatgi cun enconuschientschas tecnicas al po tuttina leger sin ses agen apparat. Mida el cura che l'access finescha.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Quest secret è mo per l'utilisaziun. T'annunzia via l'extensiun dal navigatur da Keepiq.", + "Until {date}": "Fin ils {date}", + "Use only": "Mo utilisaziun", + "Use only (can sign in, cannot view or copy)": "Mo utilisaziun (po s'annunziar, na po betg vesair u copiar)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ti pos t'annunziar cun quest login via l'extensiun dal navigatur da Keepiq. Il possessur ha decidì da betg ta laschar vesair u copiar el.", + "Your access ends on {date}": "Tes access finescha ils {date}", + "Your access to this secret has ended": "Tes access a quest secret è finì", + "Your access to \"%s\" ends tomorrow": "Tes access a «%s» finescha damaun", + "Your access to \"%s\" has ended": "Tes access a «%s» è finì", + "%1$s no longer has access to \"%2$s\"": "%1$s n'ha betg pli access a «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s ha pudì vesair quest pled-clav. Mida el, sche %1$s na duess betg pli al enconuscher.", + "%s could not view this password in Keepiq.": "%s n'ha betg pudì vesair quest pled-clav en Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} da {threshold} approvaziuns", + "a recovery officer": "ina persuna responsabla per il recupero", + "Account recovery": "Recupero dal conto", + "Approvals needed": "Approvaziuns necessarias", + "Ask {user} which words they see, by phone or in person. They must be:": "Dumandai {user} per telefon u persunalmain tge pleds ch'el vesa. Els ston esser:", + "Check again": "Controllar anc ina giada", + "Create the recovery key": "Crear la clav da recupero", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creai la clav da recupero. Voss navigatur la generescha e dat a mintga responsabel ina copia che mo el po avrir.", + "Decline": "Refusar", + "Enrol in account recovery": "S'annunziar per il recupero dal conto", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "S'annunziai, uschia che Vossa organisaziun po As gidar a retschaiver enavos Voss tresor, sche Vus emblidais Voss pled-clav principal.", + "Every user is enrolled": "Tut ils utilisaders èn annunziads", + "Finish the recovery in the browser you asked from.": "Terminai il recupero en il navigatur nua che Vus avais dumandà.", + "Forgot your master password?": "Emblidà il pled-clav principal?", + "Hand the key over": "Surdar la clav", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lubir als utilisaders che han emblidà lur pled-clav principal da retschaiver enavos lur tresor, approvà da responsabels per il recupero che Vus nominais.", + "New master password": "Nov pled-clav principal", + "No one is asking to recover their account.": "Nagin na dumonda da recuperar ses conto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Anc nagina clav da recupero. In dals responsabels la crea en ses parameters da Keepiq.", + "Off": "Deactivà", + "Officer {user} has no encryption set up yet.": "Il responsabel {user} n'ha anc betg configurà la criptadi.", + "Officers (user IDs, separated by commas)": "Responsabels (IDs d'utilisader, separads cun commas)", + "Policy": "Directiva", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publitgai quest impronta internamain, uschia che utilisaders la pon controllar avant ch'els s'annunzian.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperà cun agid da {officer}. Midai ussa la clav da Voss tresor en Parameters, Segirezza: \"Mes pled-clav principal è vegnì cumpromess\".", + "Recovery key fingerprint: {fingerprint}": "Impronta da la clav da recupero: {fingerprint}", + "Recovery officer": "Responsabel per il recupero", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Responsabels allontanads perdan ussa lur copia, ma han forsa avert ella pli baud. Laschai crear in responsabel ina nova clav da recupero.", + "Repeat the new master password": "Repeter il nov pled-clav principal", + "Retire this recovery key": "Retrair questa clav da recupero", + "Set the new master password": "Fixar il nov pled-clav principal", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Il certificat da recupero n'è betg vegnì emess da quest Keepiq. Na s'annunziai betg ed infurmai Voss administratur.", + "The words match, approve": "Ils pleds correspundan, approvar", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Quest utilisader è annunzià per il recupero dal conto. Il recupero mantegna ses secrets; la revocaziun stizza sia annunzia.", + "Users may enrol": "Utilisaders pon s'annunziar", + "Withdraw from account recovery": "Sa retrair dal recupero dal conto", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Vus essas annunzià per il recupero dal conto. Impronta da la clav da recupero: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Vus essas annunzià. Sche Vus emblidais Voss pled-clav principal, po Vossa organisaziun As gidar a retschaiver enavos Voss tresor.", + "Your key is back. Choose a new master password.": "Vossa clav è enavos. Tscherni in nov pled-clav principal.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Voss responsabels per il recupero èn vegnids infurmads. Legiai ad els quests pleds cura ch'els As telefoneschan u As scuntran:", + "You are now an account recovery officer": "Vus essas ussa responsabel per il recupero da contos", + "%s asks to recover their account. Compare the words with them before you approve.": "%s dumonda da recuperar ses conto. Cumparegliai ils pleds cun quella persuna avant ch'approvar.", + "A user": "In utilisader", + "Your account recovery request was declined": "Vossa dumonda da recupero dal conto è vegnida refusada", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Il recupero da Voss conto è pront. Avri Keepiq en il navigatur nua che Vus avais dumandà.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} dumonda da debloccar ina giada in nov apparat. Il pled-clav principal resta il medem.", + "Ask your organisation instead": "Dumonda empè tia organisaziun", + "The request ended. Ask again or use your master password.": "La dumonda è terminada. Dumonda anc ina giada u utilisescha tes pled-clav principal.", + "Added by {user}": "Agiuntà da {user}", + "Editor": "Editur", + "Manager": "Administratur", + "Role of {member}": "Rolla da {member}", + "Team folders you manage": "Ordinaturs da team che Vus administrais", + "Viewer": "Lectur", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Vus n'avais nagina copia da quests secrets, perquai n'han ils novs commembers anc betg survegnì els. Il proprietari als po parter: {names}", + "Admin areas": "Secturs d'administraziun", + "Give a group only the parts of Keepiq administration it needs.": "Dai ad ina gruppa mo las parts da l'administraziun da Keepiq ch'ella dovra.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegai in u plirs secturs ad ina gruppa sin la pagina dals privilegis d'administraziun. Ils administraturs da l'instanza han mintga sectur.", + "Open administration privileges": "Avrir ils privilegis d'administraziun", + "Policies": "Directivas", + "Applications and machine access": "Applicaziuns ed access da maschinas", + "People and offboarding": "Persunas e partenzas", + "Audit and compliance": "Revisiun e conformitad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versiun, autoritad da certificaziun, agiuntas, cache offline, controlla da perditas, tips da secrets e copias da segirezza", + "master password, organisation password, vault policies, rotation, version history and trash": "pled-clav principal, pled-clav da l'organisaziun, directivas da la chascha, rotaziun, istorgia da versiuns e chanaster", + "application queue, application requests and machine leases": "rait d'applicaziuns, dumondas d'applicaziuns e fittanzas da maschinas", + "team offboarding, encryption suites and admin handover": "partenzas dal team, suites da criptaziun e surpigliada da l'administratur", + "audit log, compliance reports, SIEM export and honey alerts": "protocol da revisiun, rapports da conformitad, export SIEM ed avertiments d'eschca", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Quantas versiuns d'in secret vegnan tegnidas, quant ditg, e quant ditg ch'ils secrets stizzads restan en il chanaster.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limitas per agiuntas criptadas, applitgadas sin il server en bytes criptads memorisads.", + "Type the suite ID again to confirm": "Endatai anc ina giada l'ID da la suite per confermar", + "This does not match the suite ID.": "Quai na correspunda betg a l'ID da la suite.", + "Confirm with your master password": "Confermai cun Voss pled-clav principal", + "Confirm": "Confermar", + "That master password is not right.": "Quest pled-clav principal n'è betg correct.", + "You are sharing with someone new. Enter your master password to confirm.": "Vus partis cun ina persuna nova. Endatai Voss pled-clav principal per confermar.", + "Enter your master password to confirm this share.": "Endatai Voss pled-clav principal per confermar questa partiziun.", + "Enter your master password to confirm this delegation.": "Endatai Voss pled-clav principal per confermar questa delegaziun.", + "Approve {member}": "Approvar {member}", + "Recipient": "Destinatari", + "No vault yet": "Anc nagina cassaforta", + "No matching users": "Nagins utilisaders correspundents", + "Partner organisations": "Organisaziuns partenarias", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Barattai secrets cun in auter Keepiq. Omadus administraturs s’agiuntan vicendaivlamain e cumparan las improntas da ragisch per telefon u persunalmain avant che memorisar.", + "Federation needs Nextcloud 33 or later.": "La federaziun dovra Nextcloud 33 u pli nov.", + "Your root fingerprint": "Vossa impronta da ragisch", + "No partners yet.": "Anc nagins partenaris.", + "Users here may share to this partner": "Utilisaders qua dastgan parter cun quest partenari", + "This partner may share to users here": "Quest partenari dastga parter cun utilisaders qua", + "Partner address": "Adressa dal partenari", + "Check partner": "Verifitgar il partenari", + "Partner root fingerprint": "Impronta da ragisch dal partenari", + "I compared this fingerprint with the partner's administrator": "Jau hai cumparà questa impronta cun l’administratur dal partenari", + "Add partner": "Agiuntar il partenari", + "A secret from another organisation": "In secret d’ina autra organisaziun", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ha cundividì \"%2$s\" cun Vus. Acceptai el sut Retschavì dad autras organisaziuns.", + "Incoming from other organisations": "Retschavì dad autras organisaziuns", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Persunas en organisaziuns partenarias pon cundivider in secret cun Vus. Acceptai el per tegnair ina copia mo per leger en Vossa cassaforta.", + "Nothing shared with you yet": "Anc nagut cundividì cun Vus", + "Secrets that people in partner organisations share with you appear here.": "Secrets che persunas en organisaziuns partenarias cundividan cun Vus cumparan qua.", + "From {sender}": "Da {sender}", + "Accept": "Acceptar", + "Open in vault": "Avrir en la cassaforta", + "The other organisation did not hand over the secret. Try again later.": "L’autra organisaziun n’ha betg surdà il secret. Empruvai pli tard anc ina giada.", + "Set up your vault before you accept a shared secret.": "Configurai Vossa cassaforta avant che acceptar in secret cundividì.", + "Something went wrong. Try again.": "Insatge è ì mal. Empruvai anc ina giada.", + "Waiting for your answer": "Spetga Vossa resposta", + "In your vault, read-only": "En Vossa cassaforta, mo per leger", + "Withdrawn by the sender": "Retratg dal speditur", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} ha cundividì quai dad ina autra organisaziun. Vus pudais leger el, ma betg midar u cundivider.", + "Someone": "Insatgi", + "Share with someone at another organisation": "Cundivider cun insatgi dad ina autra organisaziun", + "Their account at the other organisation": "Il conto da la persuna en l’autra organisaziun", + "Check account": "Controllar il conto", + "Certificate fingerprint of {account}": "Impronta dal certificat da {account}", + "Compare it with them by phone if you want to be sure.": "Cumparai ella cun la persuna per telefon, sche Vus vulais esser segirs.", + "Shared. {account} can accept it in their own vault.": "Cundividì. {account} po acceptar el en sia atgna cassaforta.", + "The certificate could not be verified. Nothing was shared.": "Il certificat n’ha betg pudì vegnir verifitgà. Nagut n’è vegnì cundividì.", + "That organisation is not one of your partners.": "Questa organisaziun n’è betg in da Voss partenaris.", + "No one with that account can receive secrets from you.": "Nagin cun quest conto na po retschaiver secrets da Vus.", + "The other organisation did not answer. Try again later.": "L’autra organisaziun n’ha betg respundì. Empruvai pli tard anc ina giada.", + "This secret is already shared with that account.": "Quest secret è gia cundividì cun quest conto.", + "Other organisations": "Autras organisaziuns", + "Receive secrets from other organisations": "Retschaiver secrets dad autras organisaziuns", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Persunas en organisaziuns partenarias pon lura chattar Voss conto e cundivider secrets cun Vus. Vus acceptais mintgin sez.", + "Shared": "Cundividì", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "En pausa: lur certificat u la partenadad è sa midà. Revocai la cundivisiun u cundividai danovamain.", + "Their organisation did not get the last change. Revoke it or share again.": "Lur organisaziun n’ha betg retschavì l’ultima midada. Revocai la cundivisiun u cundividai danovamain.", + "Being withdrawn": "Vegn retratg", + "Shared with another organisation": "Cundividì cun ina autra organisaziun", + "Change sent to another organisation": "Midada tramessa ad ina autra organisaziun", + "Share with another organisation revoked": "Cundivisiun cun ina autra organisaziun revocada", + "Share with another organisation paused": "Cundivisiun cun ina autra organisaziun en pausa", + "Another organisation did not get a change": "Ina autra organisaziun n’ha betg retschavì ina midada", + "Secret received from another organisation": "Secret retschavì dad ina autra organisaziun", + "Secret from another organisation accepted": "Secret d’ina autra organisaziun acceptà", + "Secret from another organisation declined": "Secret d’ina autra organisaziun refusà", + "Copy from another organisation updated": "Copia d’ina autra organisaziun actualisada", + "Copy from another organisation removed": "Copia d’ina autra organisaziun stizzada", + "Declined: they removed their copy. Share again if they need it.": "Refusà: il retschavider ha stizzà sia copia. Cundividai danovamain sch’el ha basegn.", + "Recipient at another organisation removed their copy": "In retschavider d’ina autra organisaziun ha stizzà sia copia", + "Removed the user from %n team folder.": "Allontanà l'utilisader da %n ordinatur da team.", + "Removed the user from %n team folders.": "Allontanà l'utilisader da %n ordinaturs da team.", + "A restored copy came from a share that has ended. It stays read-only.": "Ina copia restaurada deriva d’ina cundivisiun che è terminada. Ella resta mo per leger.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "L’organisaziun che ha cundivì ina copia restaurada n’è betg cuntanschibla. La copia resta mo per leger e na suonda betg lur midadas.", + "Recipient at another organisation restored their copy": "In retschavider d’ina autra organisaziun ha restaurà sia copia" }, "plurals": null } diff --git a/l10n/ro.js b/l10n/ro.js index 0a535d6b6..602faf77b 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Partajat cu grupuri", + "Not shared with any group yet.": "Încă nu este partajat cu niciun grup.", + "Revoke the share with {group}": "Revocă partajarea cu {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partajat cu {group}: {received} membri l-au primit, {skipped} nu, deoarece nu au configurat încă criptarea.", + "Search groups": "Caută grupuri", + "Failed to share": "Partajarea a eșuat", + "Columns": "Coloane", + "Column {number}": "Coloana {number}", + "Map one column to Name. Every secret needs a name.": "Asociați o coloană cu numele. Fiecare secret are nevoie de un nume.", + "Notes": "Note", + "Do not import": "Nu importa", + "Hide this value": "Ascunde această valoare", + "Show this value": "Afișează această valoare", + "Defaults": "Valori implicite", + "New secrets start as this type, and your secret list opens in this view.": "Secretele noi încep cu acest tip, iar lista ta de secrete se deschide în această vizualizare.", + "Default item type": "Tip implicit de element", + "Cards": "Carduri", + "Table": "Tabel", + "Could not save your default": "Valoarea implicită nu a putut fi salvată", + "Recently used": "Folosite recent", + "Opened": "Deschis", + "You have not opened any secrets yet": "Nu ai deschis încă niciun secret", + "Could not delete the item type.": "Tipul de element nu a putut fi șters.", + "Could not load the item types.": "Tipurile de element nu au putut fi încărcate.", + "Could not save the item type.": "Tipul de element nu a putut fi salvat.", + "Delete item type": "Șterge tipul de element", + "Edit item type": "Editează tipul de element", + "Fields": "Câmpuri", + "Fields: {count}": "Câmpuri: {count}", + "Hidden": "Ascuns", + "Item types": "Tipuri de element", + "Move up": "Mută în sus", + "New item type": "Tip de element nou", + "No item types defined yet.": "Nu există încă tipuri de element definite.", + "Required": "Obligatoriu", + "Text": "Text", + "This field is required": "Acest câmp este obligatoriu", + "Web address": "Adresă web", + "{label} (required)": "{label} (obligatoriu)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ștergi „{name}”? Secretele de acest tip rămân lizibile și devin elemente Autentificare.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tipurile de element pe care le definești aici apar pentru toți în fereastra Secret nou, cu câmpurile pe care le alegi.", + "Secret moved to the trash": "Secret mutat în coșul de gunoi", + "Secret restored from the trash": "Secret restaurat din coșul de gunoi", + "Secret deleted for good": "Secret șters definitiv", + "Secret archived": "Secret arhivat", + "Secret unarchived": "Secret scos din arhivă", + "Unarchive": "Scoate din arhivă", + "Could not archive the secret": "Secretul nu a putut fi arhivat", + "Could not unarchive the secret": "Secretul nu a putut fi scos din arhivă", + "Archive {count} secrets": "Arhivează secrete: {count}", + "Unarchive {count} secrets": "Scoate din arhivă secrete: {count}", + "Restore {count} secrets": "Restaurează secrete: {count}", + "Delete {count} secrets for good": "Șterge definitiv secrete: {count}", + "Done for {ok} of {total} secrets": "Gata pentru {ok} din {total} secrete", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Secretele arhivate dispar din lista seifului, din căutare, din completarea automată și din raportul de sănătate. Își păstrează partajările. Le găsești la Arhivă.", + "These secrets come back to the vault list, search and autofill.": "Aceste secrete revin în lista seifului, în căutare și în completarea automată.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Aceste secrete revin în lista seifului. Partajările vechi nu revin, așa că partajează-le din nou unde e nevoie.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Aceasta șterge secretele împreună cu atașamentele și istoricul versiunilor. Acțiunea nu poate fi anulată.", + "Delete for good": "Șterge definitiv", + "Trash": "Coș de gunoi", + "The trash is empty": "Coșul de gunoi este gol", + "No archived secrets": "Niciun secret arhivat", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Secretele șterse așteaptă aici până la sfârșitul perioadei de păstrare, apoi sunt șterse definitiv.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivează un secret din panoul său de detalii ca să îl ții în afara listei seifului, a căutării și a completării automate.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limite pentru atașamentele criptate (aplicate pe server pe octeții criptați stocați), păstrarea istoricului versiunilor și cât timp rămân secretele șterse în coșul de gunoi.", + "Days a deleted secret stays in the trash (1 to 365)": "Zile în care un secret șters rămâne în coșul de gunoi (1 până la 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Aceasta mută secretul în coșul de gunoi și îi încheie acum partajările. Îl poți restaura din coșul de gunoi până la sfârșitul perioadei de păstrare: 30 de zile, dacă administratorul nu a schimbat-o.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Aceasta mută secrete în coșul de gunoi ({count}) și le încheie acum partajările. Le poți restaura din coșul de gunoi până la sfârșitul perioadei de păstrare.", + "Remove {name} from favourites": "Elimină {name} din favorite", + "Add {name} to favourites": "Adaugă {name} la favorite", + "Could not change the favourite": "Favoritul nu a putut fi schimbat", + "Remove from favourites": "Elimină din favorite", + "Add to favourites": "Adaugă la favorite", + "Tags": "Etichete", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etichetele nu sunt criptate. Administratorii serverului le pot citi, la fel ca numele dosarelor.", + "Favourites": "Favorite", + "Filter by tag": "Filtrează după etichetă", + "All tags": "Toate etichetele", + "Last used": "Ultima utilizare", + "Tags for {count} secrets": "Etichete pentru {count} secrete", + "Tag": "Etichetă", + "Remove tag": "Elimină eticheta", + "Add tag": "Adaugă etichetă", + "Could not change the tags. Try again.": "Etichetele nu au putut fi schimbate. Încercați din nou.", + "Could not approve the application. It is still in the queue.": "Cererea nu a putut fi aprobată. Este încă în coadă.", + "Could not reject the application. It is still in the queue.": "Cererea nu a putut fi respinsă. Este încă în coadă.", + "Removed the user from {count} team folders.": "Utilizatorul a fost eliminat din {count} dosare de echipă.", + "Approve a share": "Aprobă o partajare", + "This approval link is incomplete. Open it again from the notification.": "Acest link de aprobare este incomplet. Deschide-l din nou din notificare.", + "Deny": "Refuză", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} s-a alăturat unui grup cu care partajezi un secret. Partajezi secretul și cu el?", + "{requester} asks you to share a secret with {user}.": "{requester} îți cere să partajezi un secret cu {user}.", + "Shared. The recipient can now open the secret.": "Partajat. Destinatarul poate deschide acum secretul.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Destinatarul nu a configurat încă Keepiq, așa că nu s-a partajat nimic. Încearcă din nou după ce o face.", + "Could not share the secret. Only its owner can approve this.": "Secretul nu a putut fi partajat. Doar proprietarul îl poate aproba.", + "Could not share the secret. Try again.": "Secretul nu a putut fi partajat. Încearcă din nou.", + "Denied. Nothing was shared.": "Refuzat. Nu s-a partajat nimic.", + "Could not deny the request. Try again.": "Cererea nu a putut fi refuzată. Încearcă din nou.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s îți cere să partajezi secretul \"%2$s\" cu %3$s.", + "Expires on (optional)": "Expiră la (opțional)", + "Hand over to": "Predă către", + "Choose a recipient": "Alege un destinatar", + "Hand over temporarily": "Predă temporar", + "Expiry rules": "Reguli de expirare", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stabilește cât timp pot exista parolele unui tip de element sau dintr-un dosar și când să primești memento-uri. Când se aplică mai multe date, contează cea mai devreme.", + "Delete rule": "Șterge regula", + "Set by your administrator": "Setat de administratorul tău", + "No expiry rules yet.": "Nu există încă reguli de expirare.", + "Applies to": "Se aplică pentru", + "Item type": "Tip de element", + "Maximum age in days (empty for reminders only)": "Vârsta maximă în zile (gol doar pentru memento-uri)", + "Remind me this many days before, comma separated": "Amintește-mi cu atâtea zile înainte, separate prin virgulă", + "Save rule": "Salvează regula", + "An item type": "Un tip de element", + "A folder": "Un dosar", + "Folder {name}": "Dosar {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Expiră după {days} zile", + "Reminders {days} days before": "Memento-uri cu {days} zile înainte", + "Could not save the expiry rule.": "Regula de expirare nu a putut fi salvată.", + "Could not delete the expiry rule.": "Regula de expirare nu a putut fi ștearsă.", + "All statuses": "Toate stările", + "Compromised": "Compromisă", + "Could not load the members.": "Membrii nu au putut fi încărcați.", + "Emergency contact": "Contact de urgență", + "Leaving user": "Utilizator care pleacă", + "No": "Nu", + "No users match this filter.": "Niciun utilizator nu corespunde acestui filtru.", + "Not set up": "Neconfigurat", + "Revoke suite": "Revocă suita", + "Revoked": "Revocată", + "Search users": "Caută utilizatori", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vedeți ce utilizatori și-au configurat un seif. Începeți offboardingul sau revocați o suită dintr-un rând.", + "Successor": "Succesor", + "Team folders": "Dosare de echipă", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Utilizatorul este încă în grupul {groups}, care este membru al unui dosar de echipă. Scoateți-l din grup sau dezactivați contul.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul.", + "Vault status": "Starea seifului", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un export CXF NU ESTE CRIPTAT. Fiecare parolă și nume de utilizator vor fi lizibile ca text simplu în fișierul descărcat. Păstrați-l în siguranță și ștergeți-l imediat după utilizare.", + "Root certificate expiring soon": "Certificatul rădăcină expiră în curând", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certificatul rădăcină al seifului expiră în %1$d zi(le). Reînnoiți-l înainte. Reînnoirea semnează din nou fiecare suită de criptare.", + "Compromise recovery aborted": "Recuperarea după compromitere a fost anulată", + "Key rotation ended by a compromise revoke": "Rotația cheii a fost încheiată de o revocare pentru compromitere", + "Encryption suite revoke refused": "Revocarea suitei de criptare a fost refuzată", + "Master password proof refused": "Dovada parolei principale a fost refuzată", + "Your current master password": "Parola principală actuală", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact de urgență avea o cerere de acces în așteptare când rotația cheii l-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Aceste contacte de urgență nu au fost transferate pe noua cheie. Accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.", + "Renew root certificate": "Reînnoiește certificatul rădăcină", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Se creează un nou certificat rădăcină și unul intermediar. Fiecare suită de criptare activă este semnată din nou. Acțiunea nu poate fi anulată.", + "Renew root": "Reînnoiește rădăcina", + "Root renewed. {n} encryption suites signed again.": "Rădăcină reînnoită. Suite de criptare semnate din nou: {n}.", + "Could not renew the root certificate.": "Certificatul rădăcină nu a putut fi reînnoit.", + "Lease policy for this application": "Politica de închiriere pentru această aplicație", + "In force now: {default} seconds by default, {max} seconds at most.": "În vigoare acum: implicit {default} secunde, cel mult {max} secunde.", + "Leases are not renewable": "Închirierile nu pot fi reînnoite", + "Lease policy saved.": "Politica de închiriere a fost salvată.", + "Leave a field empty to use the instance value.": "Lasă un câmp gol pentru a folosi valoarea instanței.", + "Instance value: {value}": "Valoarea instanței: {value}", + "Renewal": "Reînnoire", + "Use the instance value ({value})": "Folosește valoarea instanței ({value})", + "Allowed": "Permis", + "Not allowed": "Nepermis", + "Save lease policy": "Salvează politica de închiriere", + "Only an administrator can change this policy.": "Doar un administrator poate modifica această politică.", + "Could not save the lease policy.": "Politica de închiriere nu a putut fi salvată.", + "{member} got access from {confirmer}.": "{member} a primit acces de la {confirmer}.", + "Automatically confirm new team folder members": "Confirmă automat membrii noi ai dosarelor de echipă", + "Gave %n new member access to a team folder.": "%n membru nou a primit acces la un dosar de echipă.", + "Gave %n new members access to a team folder.": "%n membri noi au primit acces la un dosar de echipă.", + "Give new team folder members access without waiting for the folder owner.": "Oferiți acces membrilor noi fără a aștepta proprietarul dosarului.", + "New team folder members": "Membri noi ai dosarelor de echipă", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Proprietarul sau un membru cu drept de scriere îi confirmă din seiful deschis. Keepiq nu decriptează niciodată pe server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Se așteaptă ca un membru cu drept de scriere să deschidă Keepiq. Puteți partaja și acum.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "O parte din răspunsul la compromitere a eșuat ({failed} pas(i)). Verificați jurnalul serverului, apoi revocați din nou suita pentru a-l finaliza.", + "This also revoked suite {suite} and ended key migration {migration}.": "Aceasta a revocat și suita {suite} și a încheiat migrarea cheilor {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocarea celei de-a doua suite a șters %n contact de acces de urgență.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocarea celei de-a doua suite a șters %n contacte de acces de urgență.", + "A suite revoked as compromised cannot be reinstated.": "O suită revocată ca fiind compromisă nu poate fi restabilită.", + "Archives to keep": "Arhive de păstrat", + "Back up every vault automatically": "Fă automat copie fiecărui seif", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faceți copie fiecărui seif după un program. Arhivele conțin doar text cifrat și se restaurează cu occ.", + "Back up now": "Fă copie acum", + "Backup public key (PEM, optional)": "Cheie publică de copie (PEM, opțională)", + "Backup requested for the next cron run": "Copie cerută pentru următoarea rulare cron", + "Encrypted": "Criptată", + "Every (hours)": "La fiecare (ore)", + "Last backup {when} failed: {error}": "Ultima copie {when} a eșuat: {error}", + "Last backup {when} succeeded.": "Ultima copie {when} a reușit.", + "No archives yet.": "Încă nu există arhive.", + "Size": "Mărime", + "Vault backups": "Copiile seifului", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Cu o cheie, fiecare arhivă este criptată pentru ea. Păstrați cheia privată în afara acestui server: aveți nevoie de ea pentru verificare sau restaurare.", + "Written": "Scrisă", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilizator din domeniu nu are încă autentificare în doi pași și nu poate deschide seiful cât timp aceasta este activă.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Codurile de rezervă nu contează. Dacă utilizatorii se conectează printr-un furnizor de identitate cu propriul al doilea factor, excludeți grupurile lor.", + "Block personal vault export": "Blochează exportul seifului personal", + "Keep work logins in team folders": "Păstrează datele de autentificare de serviciu în dosare de echipă", + "Move to a team folder": "Mută într-un dosar de echipă", + "Not in a team folder": "Nu este într-un dosar de echipă", + "Only for these groups (empty is everyone)": "Doar pentru aceste grupuri (gol înseamnă toți)", + "Require two-factor login before the vault opens": "Cere autentificare în doi pași înainte de deschiderea seifului", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reguli pentru fiecare seif. Fiecare se aplică tuturor sau doar grupurilor alese.", + "Secret types that belong in a team folder": "Tipuri de secrete care aparțin unui dosar de echipă", + "Set up two-factor login": "Configurează autentificarea în doi pași", + "Team folder you can write to": "Dosar de echipă în care puteți scrie", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Utilizatorii nu pot descărca o copie de rezervă, un CSV sau un fișier de transfer. Pachetul lor de date personale rămâne disponibil.", + "Users cannot save these secret types in a personal folder.": "Utilizatorii nu pot salva aceste tipuri de secrete într-un dosar personal.", + "Vault policies": "Politicile seifului", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organizația dvs. nu permite exportul seifului personal. Pachetul de date personale din setări rămâne disponibil.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organizația dvs. păstrează aceste secrete într-un dosar de echipă. Mutați fiecare într-un dosar de echipă.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organizația dvs. păstrează acest tip de secret într-un dosar de echipă. Alegeți unul dintre dosarele dvs. de echipă sau unul în care puteți scrie.", + "Your organisation requires two-factor login before you can open your vault.": "Organizația dvs. cere autentificare în doi pași înainte să vă puteți deschide seiful.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Utilizatorii aleg cât timp extensia rămâne deblocată în lipsa activității. Dumneavoastră stabiliți durata maximă pe care o pot alege.", + "Longest idle time before the extension locks": "Durata maximă de inactivitate înainte ca extensia să se blocheze", + "1 minute": "1 minut", + "5 minutes": "5 minute", + "15 minutes": "15 minute", + "1 hour": "1 oră", + "4 hours": "4 ore", + "Connector": "Conector", + "Directory (tenant) ID": "ID director (chiriaș)", + "Application (client) ID": "ID aplicație (client)", + "Data collection rule immutable ID": "ID imuabil al regulii de colectare a datelor", + "Stream name": "Numele fluxului", + "Splunk index (optional)": "Index Splunk (opțional)", + "Sourcetype (optional)": "Sourcetype (opțional)", + "Leave blank to keep the current one": "Lăsați gol pentru a păstra valoarea actuală", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF prin syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punct final de colectare a datelor (URL https)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Secret client (doar scriere)", + "HEC token (write-only)": "Token HEC (doar scriere)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Redirecționați evenimentele de audit permise către Splunk, Microsoft Sentinel, un receptor syslog sau un webhook. Mesajele conțin doar metadate curățate: nicio valoare secretă, nume, autentificare sau text criptat nu părăsește vreodată serverul.", + "%n change waiting to sync": "%n modificare așteaptă sincronizarea", + "%n changes waiting to sync": "%n modificări așteaptă sincronizarea", + "Changes that could not sync": "Modificări care nu au putut fi sincronizate", + "Choose a version": "Alegeți o versiune", + "Copy value": "Copiați valoarea", + "Deleted": "Șters", + "Discard": "Renunțați", + "Keep my offline change": "Păstrați modificarea mea offline", + "Keep the server version": "Păstrați versiunea de pe server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq este doar pentru citire offline. Administratorul nu a activat editarea offline.", + "Let users edit secrets offline": "Permiteți utilizatorilor să editeze secrete offline", + "Not synced yet": "Încă nesincronizat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Modificările offline rămân pe dispozitiv, criptate pentru utilizator, și se sincronizează la următoarea deblocare online. Partajarea, dosarele și atașamentele au în continuare nevoie de conexiune.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Editările, mutările și ștergerile rămân pe acest dispozitiv și se sincronizează când reveniți online. Partajarea și atașamentele au nevoie de conexiune.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Modificările dvs. rămân pe acest dispozitiv și se sincronizează când reveniți online. Ultima sincronizare {when}.", + "Open my changes": "Deschideți modificările mele", + "Sharing needs a connection": "Partajarea are nevoie de conexiune", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Cineva a modificat acest secret pe server după ce a fost făcută copia dvs. offline. Alegeți ce versiune păstrați.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronizați sau renunțați la modificările offline înainte de a vă schimba cheile.", + "That password did not open your changes.": "Parola nu v-a deschis modificările.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Instantaneul offline stochează secrete criptate (se pot deschide doar cu cheia derivată din parola principală a utilizatorului, exact ca pe server) și criptează la stocare numele, URL-urile și numele dosarelor. Accesul offline este doar pentru citire, dacă nu permiteți mai jos editarea offline. Dezactivați acest lucru pentru dispozitivele care nu trebuie să păstreze niciodată date de autentificare; dezactivarea golește cache-urile existente la următoarea încărcare.", + "The previous vault copy is gone, so these changes cannot be opened.": "Copia anterioară a seifului nu mai există, așa că aceste modificări nu pot fi deschise.", + "The server version": "Versiunea de pe server", + "This secret changed while you were offline": "Acest secret s-a schimbat cât timp ați fost offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ați șters acest secret offline, dar între timp a fost modificat pe server. Alegeți ce versiune păstrați.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Cheile dvs. au fost schimbate pe alt dispozitiv. Introduceți parola principală anterioară pentru a sincroniza modificările offline sau renunțați la ele.", + "Your offline change": "Modificarea dvs. offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n contact de urgență avea o cerere de acces în așteptare când rotația cheii l-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.","%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.","%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n element nu poate fi reprezentat în CXF și va fi omis.","%n elemente nu pot fi reprezentate în CXF și vor fi omise.","%n elemente nu pot fi reprezentate în CXF și vor fi omise."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n versiune mai veche a fost eliminată, deoarece se poate transfera doar istoricul recent.","%n versiuni mai vechi au fost eliminate, deoarece se poate transfera doar istoricul recent.","%n versiuni mai vechi au fost eliminate, deoarece se poate transfera doar istoricul recent."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n copie a unui secret trebuie încă criptată și partajată.","%n copii de secrete trebuie încă criptate și partajate.","%n copii de secrete trebuie încă criptate și partajate."], + "_%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 secret nu a putut fi decriptat și nu este în acest export.","%n secrete nu au putut fi decriptate și nu sunt în acest export.","%n secrete nu au putut fi decriptate și nu sunt în acest export."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n secret nu a putut fi decriptat cu cheia dumneavoastră veche, așadar nu a fost migrat.","%n secrete nu au putut fi decriptate cu cheia dumneavoastră veche, așadar nu au fost migrate.","%n secrete nu au putut fi decriptate cu cheia dumneavoastră veche, așadar nu au fost migrate."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n secret nu a fost migrat.","%n secrete nu au fost migrate.","%n secrete nu au fost migrate."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n secret este încă criptat cu cheia dumneavoastră anterioară.","%n secrete sunt încă criptate cu cheia dumneavoastră anterioară.","%n secrete sunt încă criptate cu cheia dumneavoastră anterioară."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n secret a fost omis deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou.","%n secrete au fost omise deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou.","%n secrete au fost omise deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou."], + "_%n secret_::_%n secrets_": ["%n secret","%n secrete","%n secrete"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n utilizator din domeniu nu are încă autentificare în doi pași și nu poate deschide seiful cât timp aceasta este activă.","%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă.","%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Finalizează oricum, pierzând accesul la %n secret","Finalizează oricum, pierzând accesul la %n secrete","Finalizează oricum, pierzând accesul la %n secrete"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n membru nou a primit acces la un dosar de echipă.","%n membri noi au primit acces la un dosar de echipă.","%n membri noi au primit acces la un dosar de echipă."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotația cheii a fost finalizată. %n secret a fost recriptat cu noua dumneavoastră cheie.","Rotația cheii a fost finalizată. %n secrete au fost recriptate cu noua dumneavoastră cheie.","Rotația cheii a fost finalizată. %n secrete au fost recriptate cu noua dumneavoastră cheie."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revocarea celei de-a doua suite a șters %n contact de acces de urgență.","Revocarea celei de-a doua suite a șters %n contacte de acces de urgență.","Revocarea celei de-a doua suite a șters %n contacte de acces de urgență."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revocarea acestei suite a șters %n contact de acces de urgență.","Revocarea acestei suite a șters %n contacte de acces de urgență.","Revocarea acestei suite a șters %n contacte de acces de urgență."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["apărut %n dată în scurgeri","apărut %n ori în scurgeri","apărut %n ori în scurgeri"], + "_shared with %n secret_::_shared with %n secrets_": ["partajat cu %n secret","partajat cu %n secrete","partajat cu %n secrete"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Acest dosar conține direct %n secret.","Acest dosar conține direct %n secrete.","Acest dosar conține direct %n secrete."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n modificare așteaptă sincronizarea","%n modificări așteaptă sincronizarea","%n modificări așteaptă sincronizarea"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Utilizatorul este încă în grupul {groups}, care este membru al unui dosar de echipă. Scoateți-l din grup sau dezactivați contul.","Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul.","Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul."], + "Allow approval from another device": "Permite aprobarea de pe alt dispozitiv", + "App": "Aplicație", + "Approve a new device": "Aprobă un dispozitiv nou", + "Approve from another device": "Aprobă de pe alt dispozitiv", + "Asked at": "Solicitat la", + "Check that the new device shows these words:": "Verificați că dispozitivul nou afișează aceste cuvinte:", + "Denied. If you did not ask, end your other sessions:": "Refuzat. Dacă nu ați solicitat acest lucru, închideți celelalte sesiuni:", + "Device": "Dispozitiv", + "IP address": "Adresă IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permite utilizatorilor să deblocheze un browser nou aprobându-l de pe un dispozitiv pe care Keepiq este deja deblocat.", + "New device approval": "Aprobarea dispozitivelor noi", + "Nextcloud security settings": "Setări de securitate Nextcloud", + "Only approve a device you are using right now.": "Aprobați doar un dispozitiv pe care îl folosiți chiar acum.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Deschideți Keepiq pe un dispozitiv pe care este deblocat și aprobați-l pe acesta. Verificați că afișează aceleași cuvinte:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Dispozitivul care aprobă sigilează cheia de deblocare pentru dispozitivul nou. Serverul doar o transmite și nu o poate deschide.", + "The master password is not right, or the request has ended.": "Parola principală nu este corectă sau solicitarea s-a încheiat.", + "The request expired. Ask again or use your master password.": "Solicitarea a expirat. Solicitați din nou sau folosiți parola principală.", + "The request was denied.": "Solicitarea a fost refuzată.", + "Too many requests. Try again in an hour or use your master password.": "Prea multe solicitări. Încercați din nou peste o oră sau folosiți parola principală.", + "Unknown device": "Dispozitiv necunoscut", + "Web app": "Aplicație web", + "A device": "Un dispozitiv", + "A new device asks to open your vault": "Un dispozitiv nou cere să vă deschidă seiful", + "%s asks to be approved. Only approve a device you are using right now.": "%s cere să fie aprobat. Aprobați doar un dispozitiv pe care îl folosiți chiar acum.", + "Access ends on (optional)": "Accesul se încheie la (opțional)", + "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.": "Aplicațiile Keepiq nu vor afișa și nu vor copia parola. Cineva cu cunoștințe tehnice o poate citi totuși de pe propriul dispozitiv. Schimbați-o când accesul se încheie.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Acest secret este doar pentru utilizare. Conectați-vă prin extensia de browser Keepiq.", + "Until {date}": "Până la {date}", + "Use only": "Doar utilizare", + "Use only (can sign in, cannot view or copy)": "Doar utilizare (se poate conecta, nu poate vizualiza sau copia)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Vă puteți conecta cu aceste date prin extensia de browser Keepiq. Proprietarul a ales să nu vă permită să le vizualizați sau să le copiați.", + "Your access ends on {date}": "Accesul dvs. se încheie la {date}", + "Your access to this secret has ended": "Accesul dvs. la acest secret s-a încheiat", + "Your access to \"%s\" ends tomorrow": "Accesul dvs. la „%s” se încheie mâine", + "Your access to \"%s\" has ended": "Accesul dvs. la „%s” s-a încheiat", + "%1$s no longer has access to \"%2$s\"": "%1$s nu mai are acces la „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s putea vedea această parolă. Schimbați-o dacă %1$s nu ar mai trebui să o cunoască.", + "%s could not view this password in Keepiq.": "%s nu a putut vizualiza această parolă în Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} din {threshold} aprobări", + "a recovery officer": "un responsabil de recuperare", + "Account recovery": "Recuperarea contului", + "Approvals needed": "Aprobări necesare", + "Ask {user} which words they see, by phone or in person. They must be:": "Întrebați-l pe {user} ce cuvinte vede, la telefon sau în persoană. Trebuie să fie:", + "Check again": "Verifică din nou", + "Create the recovery key": "Creează cheia de recuperare", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creați cheia de recuperare. Browserul o generează și dă fiecărui responsabil o copie pe care doar el o poate deschide.", + "Decline": "Refuză", + "Enrol in account recovery": "Înscrieți-vă la recuperarea contului", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Înscrieți-vă ca organizația să vă poată ajuta să vă recuperați seiful dacă uitați parola principală.", + "Every user is enrolled": "Toți utilizatorii sunt înscriși", + "Finish the recovery in the browser you asked from.": "Finalizați recuperarea în browserul din care ați cerut-o.", + "Forgot your master password?": "Ați uitat parola principală?", + "Hand the key over": "Predă cheia", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permiteți utilizatorilor care și-au uitat parola principală să își recupereze seiful, cu aprobarea responsabililor de recuperare pe care îi desemnați.", + "New master password": "Parolă principală nouă", + "No one is asking to recover their account.": "Nimeni nu cere recuperarea contului.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Încă nu există o cheie de recuperare. Unul dintre responsabili o creează în setările sale Keepiq.", + "Off": "Dezactivat", + "Officer {user} has no encryption set up yet.": "Responsabilul {user} nu și-a configurat încă criptarea.", + "Officers (user IDs, separated by commas)": "Responsabili (ID-uri de utilizator, separate prin virgulă)", + "Policy": "Politică", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicați această amprentă intern, ca utilizatorii să o poată verifica înainte de înscriere.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperat cu ajutorul lui {officer}. Schimbați acum cheia seifului în Setări, Securitate: \"Parola mea principală a fost compromisă\".", + "Recovery key fingerprint: {fingerprint}": "Amprenta cheii de recuperare: {fingerprint}", + "Recovery officer": "Responsabil de recuperare", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Responsabilii eliminați își pierd acum copia, dar e posibil să o fi deschis înainte. Cereți unui responsabil să creeze o cheie de recuperare nouă.", + "Repeat the new master password": "Repetați noua parolă principală", + "Retire this recovery key": "Retrage această cheie de recuperare", + "Set the new master password": "Setează noua parolă principală", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certificatul de recuperare nu a fost emis de acest Keepiq. Nu vă înscrieți și anunțați administratorul.", + "The words match, approve": "Cuvintele se potrivesc, aprobă", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Acest utilizator este înscris la recuperarea contului. Recuperarea îi păstrează secretele; revocarea îi șterge înscrierea.", + "Users may enrol": "Utilizatorii se pot înscrie", + "Withdraw from account recovery": "Retrage-te de la recuperarea contului", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sunteți înscris la recuperarea contului. Amprenta cheii de recuperare: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sunteți înscris. Dacă uitați parola principală, organizația vă poate ajuta să vă recuperați seiful.", + "Your key is back. Choose a new master password.": "Cheia v-a fost returnată. Alegeți o parolă principală nouă.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Responsabilii de recuperare au fost anunțați. Citiți-le aceste cuvinte când vă sună sau vă întâlniți:", + "You are now an account recovery officer": "Acum sunteți responsabil de recuperarea conturilor", + "%s asks to recover their account. Compare the words with them before you approve.": "%s cere recuperarea contului. Comparați cuvintele cu această persoană înainte de a aproba.", + "A user": "Un utilizator", + "Your account recovery request was declined": "Cererea dvs. de recuperare a contului a fost refuzată", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Recuperarea contului este gata. Deschideți Keepiq în browserul din care ați cerut-o.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} cere deblocarea unui dispozitiv nou o singură dată. Parola principală rămâne aceeași.", + "Ask your organisation instead": "Întrebați mai bine organizația", + "The request ended. Ask again or use your master password.": "Cererea s-a încheiat. Cereți din nou sau folosiți parola principală.", + "Added by {user}": "Adăugat de {user}", + "Editor": "Editor", + "Manager": "Manager", + "Role of {member}": "Rolul lui {member}", + "Team folders you manage": "Dosare de echipă pe care le gestionați", + "Viewer": "Cititor", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nu dețineți o copie a acestor secrete, deci noii membri nu le-au primit încă. Proprietarul le poate partaja: {names}", + "Admin areas": "Zone de administrare", + "Give a group only the parts of Keepiq administration it needs.": "Dați unui grup doar părțile din administrarea Keepiq de care are nevoie.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegați una sau mai multe zone unui grup pe pagina de privilegii de administrare. Administratorii instanței au toate zonele.", + "Open administration privileges": "Deschide privilegiile de administrare", + "Policies": "Politici", + "Applications and machine access": "Aplicații și acces mașini", + "People and offboarding": "Persoane și plecări", + "Audit and compliance": "Audit și conformitate", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versiune, autoritate de certificare, atașamente, cache offline, verificarea scurgerilor, tipuri de secrete și copii de rezervă", + "master password, organisation password, vault policies, rotation, version history and trash": "parola principală, parola organizației, politicile seifului, rotație, istoricul versiunilor și coșul de gunoi", + "application queue, application requests and machine leases": "coada aplicațiilor, cererile aplicațiilor și închirierile mașinilor", + "team offboarding, encryption suites and admin handover": "plecări din echipă, suite de criptare și preluare de către administrator", + "audit log, compliance reports, SIEM export and honey alerts": "jurnal de audit, rapoarte de conformitate, export SIEM și alerte momeală", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Câte versiuni ale unui secret se păstrează, cât timp și cât timp rămân secretele șterse în coșul de gunoi.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limite pentru atașamentele criptate, aplicate pe server în octeți criptați stocați.", + "Type the suite ID again to confirm": "Introduceți din nou ID-ul suitei pentru confirmare", + "This does not match the suite ID.": "Nu corespunde cu ID-ul suitei.", + "Confirm with your master password": "Confirmați cu parola principală", + "Confirm": "Confirmare", + "That master password is not right.": "Această parolă principală nu este corectă.", + "You are sharing with someone new. Enter your master password to confirm.": "Partajați cu o persoană nouă. Introduceți parola principală pentru confirmare.", + "Enter your master password to confirm this share.": "Introduceți parola principală pentru a confirma această partajare.", + "Enter your master password to confirm this delegation.": "Introduceți parola principală pentru a confirma această delegare.", + "Approve {member}": "Aprobați {member}", + "Recipient": "Destinatar", + "No vault yet": "Încă nu are seif", + "No matching users": "Niciun utilizator potrivit", + "Partner organisations": "Organizații partenere", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Schimbați secrete cu un alt Keepiq. Ambii administratori se adaugă reciproc și compară amprentele rădăcină la telefon sau în persoană înainte de salvare.", + "Federation needs Nextcloud 33 or later.": "Federarea necesită Nextcloud 33 sau mai nou.", + "Your root fingerprint": "Amprenta dvs. rădăcină", + "No partners yet.": "Încă nu există parteneri.", + "Users here may share to this partner": "Utilizatorii de aici pot partaja cu acest partener", + "This partner may share to users here": "Acest partener poate partaja cu utilizatorii de aici", + "Partner address": "Adresa partenerului", + "Check partner": "Verificați partenerul", + "Partner root fingerprint": "Amprenta rădăcină a partenerului", + "I compared this fingerprint with the partner's administrator": "Am comparat această amprentă cu administratorul partenerului", + "Add partner": "Adăugați partenerul", + "A secret from another organisation": "Un secret de la o altă organizație", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s a partajat \"%2$s\" cu dvs. Acceptați-l în Primite de la alte organizații.", + "Incoming from other organisations": "Primite de la alte organizații", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Persoanele din organizațiile partenere pot partaja un secret cu dvs. Acceptați-l pentru a păstra o copie doar în citire în seiful dvs.", + "Nothing shared with you yet": "Nimic partajat cu dvs. încă", + "Secrets that people in partner organisations share with you appear here.": "Secretele pe care persoanele din organizațiile partenere le partajează cu dvs. apar aici.", + "From {sender}": "De la {sender}", + "Accept": "Acceptați", + "Open in vault": "Deschideți în seif", + "The other organisation did not hand over the secret. Try again later.": "Cealaltă organizație nu a transmis secretul. Încercați din nou mai târziu.", + "Set up your vault before you accept a shared secret.": "Configurați-vă seiful înainte de a accepta un secret partajat.", + "Something went wrong. Try again.": "Ceva nu a funcționat. Încercați din nou.", + "Waiting for your answer": "Se așteaptă răspunsul dvs.", + "In your vault, read-only": "În seiful dvs., doar în citire", + "Withdrawn by the sender": "Retras de expeditor", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} a partajat acest lucru dintr-o altă organizație. Îl puteți citi, dar nu îl puteți modifica sau partaja.", + "Someone": "Cineva", + "Share with someone at another organisation": "Partajați cu cineva dintr-o altă organizație", + "Their account at the other organisation": "Contul persoanei în cealaltă organizație", + "Check account": "Verificați contul", + "Certificate fingerprint of {account}": "Amprenta certificatului pentru {account}", + "Compare it with them by phone if you want to be sure.": "Comparați-o cu persoana respectivă la telefon dacă vreți să fiți sigur.", + "Shared. {account} can accept it in their own vault.": "Partajat. {account} îl poate accepta în propriul seif.", + "The certificate could not be verified. Nothing was shared.": "Certificatul nu a putut fi verificat. Nu s-a partajat nimic.", + "That organisation is not one of your partners.": "Organizația respectivă nu este unul dintre partenerii dvs.", + "No one with that account can receive secrets from you.": "Nimeni cu acel cont nu poate primi secrete de la dvs.", + "The other organisation did not answer. Try again later.": "Cealaltă organizație nu a răspuns. Încercați din nou mai târziu.", + "This secret is already shared with that account.": "Acest secret este deja partajat cu acel cont.", + "Other organisations": "Alte organizații", + "Receive secrets from other organisations": "Primiți secrete de la alte organizații", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Persoanele din organizațiile partenere vă pot găsi apoi contul și pot partaja secrete cu dvs. Acceptați fiecare secret personal.", + "Shared": "Partajat", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Întrerupt: certificatul destinatarului sau parteneriatul s-a schimbat. Revocați partajarea sau partajați din nou.", + "Their organisation did not get the last change. Revoke it or share again.": "Organizația destinatarului nu a primit ultima modificare. Revocați partajarea sau partajați din nou.", + "Being withdrawn": "Se retrage", + "Shared with another organisation": "Partajat cu o altă organizație", + "Change sent to another organisation": "Modificare trimisă unei alte organizații", + "Share with another organisation revoked": "Partajare cu o altă organizație revocată", + "Share with another organisation paused": "Partajare cu o altă organizație întreruptă", + "Another organisation did not get a change": "O altă organizație nu a primit o modificare", + "Secret received from another organisation": "Secret primit de la o altă organizație", + "Secret from another organisation accepted": "Secret de la o altă organizație acceptat", + "Secret from another organisation declined": "Secret de la o altă organizație refuzat", + "Copy from another organisation updated": "Copie de la o altă organizație actualizată", + "Copy from another organisation removed": "Copie de la o altă organizație eliminată", + "Declined: they removed their copy. Share again if they need it.": "Refuzat: destinatarul și-a eliminat copia. Partajați din nou dacă are nevoie de ea.", + "Recipient at another organisation removed their copy": "Un destinatar dintr-o altă organizație și-a eliminat copia", + "Removed the user from %n team folder.": "Utilizatorul a fost eliminat din %n dosar de echipă.", + "Removed the user from %n team folders.": "Utilizatorul a fost eliminat din %n dosare de echipă.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Utilizatorul a fost eliminat din %n dosar de echipă.","Utilizatorul a fost eliminat din %n dosare de echipă.","Utilizatorul a fost eliminat din %n dosare de echipă."], + "A restored copy came from a share that has ended. It stays read-only.": "O copie restaurată provine dintr-o partajare care s-a încheiat. Rămâne doar în citire.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizația care a partajat o copie restaurată nu a putut fi contactată. Copia rămâne doar în citire și nu urmează modificările lor.", + "Recipient at another organisation restored their copy": "Un destinatar dintr-o altă organizație și-a restaurat copia" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);" ) diff --git a/l10n/ro.json b/l10n/ro.json index d874ffa9a..4127c886f 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Partajat cu grupuri", + "Not shared with any group yet.": "Încă nu este partajat cu niciun grup.", + "Revoke the share with {group}": "Revocă partajarea cu {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Partajat cu {group}: {received} membri l-au primit, {skipped} nu, deoarece nu au configurat încă criptarea.", + "Search groups": "Caută grupuri", + "Failed to share": "Partajarea a eșuat", + "Columns": "Coloane", + "Column {number}": "Coloana {number}", + "Map one column to Name. Every secret needs a name.": "Asociați o coloană cu numele. Fiecare secret are nevoie de un nume.", + "Notes": "Note", + "Do not import": "Nu importa", + "Hide this value": "Ascunde această valoare", + "Show this value": "Afișează această valoare", + "Defaults": "Valori implicite", + "New secrets start as this type, and your secret list opens in this view.": "Secretele noi încep cu acest tip, iar lista ta de secrete se deschide în această vizualizare.", + "Default item type": "Tip implicit de element", + "Cards": "Carduri", + "Table": "Tabel", + "Could not save your default": "Valoarea implicită nu a putut fi salvată", + "Recently used": "Folosite recent", + "Opened": "Deschis", + "You have not opened any secrets yet": "Nu ai deschis încă niciun secret", + "Could not delete the item type.": "Tipul de element nu a putut fi șters.", + "Could not load the item types.": "Tipurile de element nu au putut fi încărcate.", + "Could not save the item type.": "Tipul de element nu a putut fi salvat.", + "Delete item type": "Șterge tipul de element", + "Edit item type": "Editează tipul de element", + "Fields": "Câmpuri", + "Fields: {count}": "Câmpuri: {count}", + "Hidden": "Ascuns", + "Item types": "Tipuri de element", + "Move up": "Mută în sus", + "New item type": "Tip de element nou", + "No item types defined yet.": "Nu există încă tipuri de element definite.", + "Required": "Obligatoriu", + "Text": "Text", + "This field is required": "Acest câmp este obligatoriu", + "Web address": "Adresă web", + "{label} (required)": "{label} (obligatoriu)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ștergi „{name}”? Secretele de acest tip rămân lizibile și devin elemente Autentificare.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Tipurile de element pe care le definești aici apar pentru toți în fereastra Secret nou, cu câmpurile pe care le alegi.", + "Secret moved to the trash": "Secret mutat în coșul de gunoi", + "Secret restored from the trash": "Secret restaurat din coșul de gunoi", + "Secret deleted for good": "Secret șters definitiv", + "Secret archived": "Secret arhivat", + "Secret unarchived": "Secret scos din arhivă", + "Unarchive": "Scoate din arhivă", + "Could not archive the secret": "Secretul nu a putut fi arhivat", + "Could not unarchive the secret": "Secretul nu a putut fi scos din arhivă", + "Archive {count} secrets": "Arhivează secrete: {count}", + "Unarchive {count} secrets": "Scoate din arhivă secrete: {count}", + "Restore {count} secrets": "Restaurează secrete: {count}", + "Delete {count} secrets for good": "Șterge definitiv secrete: {count}", + "Done for {ok} of {total} secrets": "Gata pentru {ok} din {total} secrete", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Secretele arhivate dispar din lista seifului, din căutare, din completarea automată și din raportul de sănătate. Își păstrează partajările. Le găsești la Arhivă.", + "These secrets come back to the vault list, search and autofill.": "Aceste secrete revin în lista seifului, în căutare și în completarea automată.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Aceste secrete revin în lista seifului. Partajările vechi nu revin, așa că partajează-le din nou unde e nevoie.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Aceasta șterge secretele împreună cu atașamentele și istoricul versiunilor. Acțiunea nu poate fi anulată.", + "Delete for good": "Șterge definitiv", + "Trash": "Coș de gunoi", + "The trash is empty": "Coșul de gunoi este gol", + "No archived secrets": "Niciun secret arhivat", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Secretele șterse așteaptă aici până la sfârșitul perioadei de păstrare, apoi sunt șterse definitiv.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivează un secret din panoul său de detalii ca să îl ții în afara listei seifului, a căutării și a completării automate.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limite pentru atașamentele criptate (aplicate pe server pe octeții criptați stocați), păstrarea istoricului versiunilor și cât timp rămân secretele șterse în coșul de gunoi.", + "Days a deleted secret stays in the trash (1 to 365)": "Zile în care un secret șters rămâne în coșul de gunoi (1 până la 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Aceasta mută secretul în coșul de gunoi și îi încheie acum partajările. Îl poți restaura din coșul de gunoi până la sfârșitul perioadei de păstrare: 30 de zile, dacă administratorul nu a schimbat-o.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Aceasta mută secrete în coșul de gunoi ({count}) și le încheie acum partajările. Le poți restaura din coșul de gunoi până la sfârșitul perioadei de păstrare.", + "Remove {name} from favourites": "Elimină {name} din favorite", + "Add {name} to favourites": "Adaugă {name} la favorite", + "Could not change the favourite": "Favoritul nu a putut fi schimbat", + "Remove from favourites": "Elimină din favorite", + "Add to favourites": "Adaugă la favorite", + "Tags": "Etichete", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etichetele nu sunt criptate. Administratorii serverului le pot citi, la fel ca numele dosarelor.", + "Favourites": "Favorite", + "Filter by tag": "Filtrează după etichetă", + "All tags": "Toate etichetele", + "Last used": "Ultima utilizare", + "Tags for {count} secrets": "Etichete pentru {count} secrete", + "Tag": "Etichetă", + "Remove tag": "Elimină eticheta", + "Add tag": "Adaugă etichetă", + "Could not change the tags. Try again.": "Etichetele nu au putut fi schimbate. Încercați din nou.", + "Could not approve the application. It is still in the queue.": "Cererea nu a putut fi aprobată. Este încă în coadă.", + "Could not reject the application. It is still in the queue.": "Cererea nu a putut fi respinsă. Este încă în coadă.", + "Removed the user from {count} team folders.": "Utilizatorul a fost eliminat din {count} dosare de echipă.", + "Approve a share": "Aprobă o partajare", + "This approval link is incomplete. Open it again from the notification.": "Acest link de aprobare este incomplet. Deschide-l din nou din notificare.", + "Deny": "Refuză", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} s-a alăturat unui grup cu care partajezi un secret. Partajezi secretul și cu el?", + "{requester} asks you to share a secret with {user}.": "{requester} îți cere să partajezi un secret cu {user}.", + "Shared. The recipient can now open the secret.": "Partajat. Destinatarul poate deschide acum secretul.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Destinatarul nu a configurat încă Keepiq, așa că nu s-a partajat nimic. Încearcă din nou după ce o face.", + "Could not share the secret. Only its owner can approve this.": "Secretul nu a putut fi partajat. Doar proprietarul îl poate aproba.", + "Could not share the secret. Try again.": "Secretul nu a putut fi partajat. Încearcă din nou.", + "Denied. Nothing was shared.": "Refuzat. Nu s-a partajat nimic.", + "Could not deny the request. Try again.": "Cererea nu a putut fi refuzată. Încearcă din nou.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s îți cere să partajezi secretul \"%2$s\" cu %3$s.", + "Expires on (optional)": "Expiră la (opțional)", + "Hand over to": "Predă către", + "Choose a recipient": "Alege un destinatar", + "Hand over temporarily": "Predă temporar", + "Expiry rules": "Reguli de expirare", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Stabilește cât timp pot exista parolele unui tip de element sau dintr-un dosar și când să primești memento-uri. Când se aplică mai multe date, contează cea mai devreme.", + "Delete rule": "Șterge regula", + "Set by your administrator": "Setat de administratorul tău", + "No expiry rules yet.": "Nu există încă reguli de expirare.", + "Applies to": "Se aplică pentru", + "Item type": "Tip de element", + "Maximum age in days (empty for reminders only)": "Vârsta maximă în zile (gol doar pentru memento-uri)", + "Remind me this many days before, comma separated": "Amintește-mi cu atâtea zile înainte, separate prin virgulă", + "Save rule": "Salvează regula", + "An item type": "Un tip de element", + "A folder": "Un dosar", + "Folder {name}": "Dosar {name}", + "Type {name}": "Tip {name}", + "Expires after {days} days": "Expiră după {days} zile", + "Reminders {days} days before": "Memento-uri cu {days} zile înainte", + "Could not save the expiry rule.": "Regula de expirare nu a putut fi salvată.", + "Could not delete the expiry rule.": "Regula de expirare nu a putut fi ștearsă.", + "All statuses": "Toate stările", + "Compromised": "Compromisă", + "Could not load the members.": "Membrii nu au putut fi încărcați.", + "Emergency contact": "Contact de urgență", + "Leaving user": "Utilizator care pleacă", + "No": "Nu", + "No users match this filter.": "Niciun utilizator nu corespunde acestui filtru.", + "Not set up": "Neconfigurat", + "Revoke suite": "Revocă suita", + "Revoked": "Revocată", + "Search users": "Caută utilizatori", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Vedeți ce utilizatori și-au configurat un seif. Începeți offboardingul sau revocați o suită dintr-un rând.", + "Successor": "Succesor", + "Team folders": "Dosare de echipă", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Utilizatorul este încă în grupul {groups}, care este membru al unui dosar de echipă. Scoateți-l din grup sau dezactivați contul.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul.", + "Vault status": "Starea seifului", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Un export CXF NU ESTE CRIPTAT. Fiecare parolă și nume de utilizator vor fi lizibile ca text simplu în fișierul descărcat. Păstrați-l în siguranță și ștergeți-l imediat după utilizare.", + "Root certificate expiring soon": "Certificatul rădăcină expiră în curând", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certificatul rădăcină al seifului expiră în %1$d zi(le). Reînnoiți-l înainte. Reînnoirea semnează din nou fiecare suită de criptare.", + "Compromise recovery aborted": "Recuperarea după compromitere a fost anulată", + "Key rotation ended by a compromise revoke": "Rotația cheii a fost încheiată de o revocare pentru compromitere", + "Encryption suite revoke refused": "Revocarea suitei de criptare a fost refuzată", + "Master password proof refused": "Dovada parolei principale a fost refuzată", + "Your current master password": "Parola principală actuală", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n contact de urgență avea o cerere de acces în așteptare când rotația cheii l-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Aceste contacte de urgență nu au fost transferate pe noua cheie. Accesul lor de urgență a fost eliminat. Adăugați-le din nou din Acces de urgență dacă le mai doriți.", + "Renew root certificate": "Reînnoiește certificatul rădăcină", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Se creează un nou certificat rădăcină și unul intermediar. Fiecare suită de criptare activă este semnată din nou. Acțiunea nu poate fi anulată.", + "Renew root": "Reînnoiește rădăcina", + "Root renewed. {n} encryption suites signed again.": "Rădăcină reînnoită. Suite de criptare semnate din nou: {n}.", + "Could not renew the root certificate.": "Certificatul rădăcină nu a putut fi reînnoit.", + "Lease policy for this application": "Politica de închiriere pentru această aplicație", + "In force now: {default} seconds by default, {max} seconds at most.": "În vigoare acum: implicit {default} secunde, cel mult {max} secunde.", + "Leases are not renewable": "Închirierile nu pot fi reînnoite", + "Lease policy saved.": "Politica de închiriere a fost salvată.", + "Leave a field empty to use the instance value.": "Lasă un câmp gol pentru a folosi valoarea instanței.", + "Instance value: {value}": "Valoarea instanței: {value}", + "Renewal": "Reînnoire", + "Use the instance value ({value})": "Folosește valoarea instanței ({value})", + "Allowed": "Permis", + "Not allowed": "Nepermis", + "Save lease policy": "Salvează politica de închiriere", + "Only an administrator can change this policy.": "Doar un administrator poate modifica această politică.", + "Could not save the lease policy.": "Politica de închiriere nu a putut fi salvată.", + "{member} got access from {confirmer}.": "{member} a primit acces de la {confirmer}.", + "Automatically confirm new team folder members": "Confirmă automat membrii noi ai dosarelor de echipă", + "Gave %n new member access to a team folder.": "%n membru nou a primit acces la un dosar de echipă.", + "Gave %n new members access to a team folder.": "%n membri noi au primit acces la un dosar de echipă.", + "Give new team folder members access without waiting for the folder owner.": "Oferiți acces membrilor noi fără a aștepta proprietarul dosarului.", + "New team folder members": "Membri noi ai dosarelor de echipă", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Proprietarul sau un membru cu drept de scriere îi confirmă din seiful deschis. Keepiq nu decriptează niciodată pe server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Se așteaptă ca un membru cu drept de scriere să deschidă Keepiq. Puteți partaja și acum.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "O parte din răspunsul la compromitere a eșuat ({failed} pas(i)). Verificați jurnalul serverului, apoi revocați din nou suita pentru a-l finaliza.", + "This also revoked suite {suite} and ended key migration {migration}.": "Aceasta a revocat și suita {suite} și a încheiat migrarea cheilor {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revocarea celei de-a doua suite a șters %n contact de acces de urgență.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revocarea celei de-a doua suite a șters %n contacte de acces de urgență.", + "A suite revoked as compromised cannot be reinstated.": "O suită revocată ca fiind compromisă nu poate fi restabilită.", + "Archives to keep": "Arhive de păstrat", + "Back up every vault automatically": "Fă automat copie fiecărui seif", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Faceți copie fiecărui seif după un program. Arhivele conțin doar text cifrat și se restaurează cu occ.", + "Back up now": "Fă copie acum", + "Backup public key (PEM, optional)": "Cheie publică de copie (PEM, opțională)", + "Backup requested for the next cron run": "Copie cerută pentru următoarea rulare cron", + "Encrypted": "Criptată", + "Every (hours)": "La fiecare (ore)", + "Last backup {when} failed: {error}": "Ultima copie {when} a eșuat: {error}", + "Last backup {when} succeeded.": "Ultima copie {when} a reușit.", + "No archives yet.": "Încă nu există arhive.", + "Size": "Mărime", + "Vault backups": "Copiile seifului", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Cu o cheie, fiecare arhivă este criptată pentru ea. Păstrați cheia privată în afara acestui server: aveți nevoie de ea pentru verificare sau restaurare.", + "Written": "Scrisă", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n utilizator din domeniu nu are încă autentificare în doi pași și nu poate deschide seiful cât timp aceasta este activă.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Codurile de rezervă nu contează. Dacă utilizatorii se conectează printr-un furnizor de identitate cu propriul al doilea factor, excludeți grupurile lor.", + "Block personal vault export": "Blochează exportul seifului personal", + "Keep work logins in team folders": "Păstrează datele de autentificare de serviciu în dosare de echipă", + "Move to a team folder": "Mută într-un dosar de echipă", + "Not in a team folder": "Nu este într-un dosar de echipă", + "Only for these groups (empty is everyone)": "Doar pentru aceste grupuri (gol înseamnă toți)", + "Require two-factor login before the vault opens": "Cere autentificare în doi pași înainte de deschiderea seifului", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Reguli pentru fiecare seif. Fiecare se aplică tuturor sau doar grupurilor alese.", + "Secret types that belong in a team folder": "Tipuri de secrete care aparțin unui dosar de echipă", + "Set up two-factor login": "Configurează autentificarea în doi pași", + "Team folder you can write to": "Dosar de echipă în care puteți scrie", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Utilizatorii nu pot descărca o copie de rezervă, un CSV sau un fișier de transfer. Pachetul lor de date personale rămâne disponibil.", + "Users cannot save these secret types in a personal folder.": "Utilizatorii nu pot salva aceste tipuri de secrete într-un dosar personal.", + "Vault policies": "Politicile seifului", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organizația dvs. nu permite exportul seifului personal. Pachetul de date personale din setări rămâne disponibil.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organizația dvs. păstrează aceste secrete într-un dosar de echipă. Mutați fiecare într-un dosar de echipă.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organizația dvs. păstrează acest tip de secret într-un dosar de echipă. Alegeți unul dintre dosarele dvs. de echipă sau unul în care puteți scrie.", + "Your organisation requires two-factor login before you can open your vault.": "Organizația dvs. cere autentificare în doi pași înainte să vă puteți deschide seiful.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Utilizatorii aleg cât timp extensia rămâne deblocată în lipsa activității. Dumneavoastră stabiliți durata maximă pe care o pot alege.", + "Longest idle time before the extension locks": "Durata maximă de inactivitate înainte ca extensia să se blocheze", + "1 minute": "1 minut", + "5 minutes": "5 minute", + "15 minutes": "15 minute", + "1 hour": "1 oră", + "4 hours": "4 ore", + "Connector": "Conector", + "Directory (tenant) ID": "ID director (chiriaș)", + "Application (client) ID": "ID aplicație (client)", + "Data collection rule immutable ID": "ID imuabil al regulii de colectare a datelor", + "Stream name": "Numele fluxului", + "Splunk index (optional)": "Index Splunk (opțional)", + "Sourcetype (optional)": "Sourcetype (opțional)", + "Leave blank to keep the current one": "Lăsați gol pentru a păstra valoarea actuală", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF prin syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Punct final de colectare a datelor (URL https)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Secret client (doar scriere)", + "HEC token (write-only)": "Token HEC (doar scriere)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Redirecționați evenimentele de audit permise către Splunk, Microsoft Sentinel, un receptor syslog sau un webhook. Mesajele conțin doar metadate curățate: nicio valoare secretă, nume, autentificare sau text criptat nu părăsește vreodată serverul.", + "%n change waiting to sync": "%n modificare așteaptă sincronizarea", + "%n changes waiting to sync": "%n modificări așteaptă sincronizarea", + "Changes that could not sync": "Modificări care nu au putut fi sincronizate", + "Choose a version": "Alegeți o versiune", + "Copy value": "Copiați valoarea", + "Deleted": "Șters", + "Discard": "Renunțați", + "Keep my offline change": "Păstrați modificarea mea offline", + "Keep the server version": "Păstrați versiunea de pe server", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq este doar pentru citire offline. Administratorul nu a activat editarea offline.", + "Let users edit secrets offline": "Permiteți utilizatorilor să editeze secrete offline", + "Not synced yet": "Încă nesincronizat", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Modificările offline rămân pe dispozitiv, criptate pentru utilizator, și se sincronizează la următoarea deblocare online. Partajarea, dosarele și atașamentele au în continuare nevoie de conexiune.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Editările, mutările și ștergerile rămân pe acest dispozitiv și se sincronizează când reveniți online. Partajarea și atașamentele au nevoie de conexiune.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Modificările dvs. rămân pe acest dispozitiv și se sincronizează când reveniți online. Ultima sincronizare {when}.", + "Open my changes": "Deschideți modificările mele", + "Sharing needs a connection": "Partajarea are nevoie de conexiune", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Cineva a modificat acest secret pe server după ce a fost făcută copia dvs. offline. Alegeți ce versiune păstrați.", + "Sync or discard your offline changes before you rotate your keys.": "Sincronizați sau renunțați la modificările offline înainte de a vă schimba cheile.", + "That password did not open your changes.": "Parola nu v-a deschis modificările.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Instantaneul offline stochează secrete criptate (se pot deschide doar cu cheia derivată din parola principală a utilizatorului, exact ca pe server) și criptează la stocare numele, URL-urile și numele dosarelor. Accesul offline este doar pentru citire, dacă nu permiteți mai jos editarea offline. Dezactivați acest lucru pentru dispozitivele care nu trebuie să păstreze niciodată date de autentificare; dezactivarea golește cache-urile existente la următoarea încărcare.", + "The previous vault copy is gone, so these changes cannot be opened.": "Copia anterioară a seifului nu mai există, așa că aceste modificări nu pot fi deschise.", + "The server version": "Versiunea de pe server", + "This secret changed while you were offline": "Acest secret s-a schimbat cât timp ați fost offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ați șters acest secret offline, dar între timp a fost modificat pe server. Alegeți ce versiune păstrați.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Cheile dvs. au fost schimbate pe alt dispozitiv. Introduceți parola principală anterioară pentru a sincroniza modificările offline sau renunțați la ele.", + "Your offline change": "Modificarea dvs. offline", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n contact de urgență avea o cerere de acces în așteptare când rotația cheii l-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou.", + "%n contacte de urgență aveau o cerere de acces în așteptare când rotația cheii le-a eliminat. Verificați cine a cerut înainte să adăugați pe cineva din nou." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n element nu poate fi reprezentat în CXF și va fi omis.", + "%n elemente nu pot fi reprezentate în CXF și vor fi omise.", + "%n elemente nu pot fi reprezentate în CXF și vor fi omise." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n versiune mai veche a fost eliminată, deoarece se poate transfera doar istoricul recent.", + "%n versiuni mai vechi au fost eliminate, deoarece se poate transfera doar istoricul recent.", + "%n versiuni mai vechi au fost eliminate, deoarece se poate transfera doar istoricul recent." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n copie a unui secret trebuie încă criptată și partajată.", + "%n copii de secrete trebuie încă criptate și partajate.", + "%n copii de secrete trebuie încă criptate și partajate." + ], + "_%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 secret nu a putut fi decriptat și nu este în acest export.", + "%n secrete nu au putut fi decriptate și nu sunt în acest export.", + "%n secrete nu au putut fi decriptate și nu sunt în acest export." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n secret nu a putut fi decriptat cu cheia dumneavoastră veche, așadar nu a fost migrat.", + "%n secrete nu au putut fi decriptate cu cheia dumneavoastră veche, așadar nu au fost migrate.", + "%n secrete nu au putut fi decriptate cu cheia dumneavoastră veche, așadar nu au fost migrate." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n secret nu a fost migrat.", + "%n secrete nu au fost migrate.", + "%n secrete nu au fost migrate." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n secret este încă criptat cu cheia dumneavoastră anterioară.", + "%n secrete sunt încă criptate cu cheia dumneavoastră anterioară.", + "%n secrete sunt încă criptate cu cheia dumneavoastră anterioară." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n secret a fost omis deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou.", + "%n secrete au fost omise deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou.", + "%n secrete au fost omise deoarece succesorul nu deține încă o copie — adăugați succesorul în dosar și rulați din nou." + ], + "_%n secret_::_%n secrets_": [ + "%n secret", + "%n secrete", + "%n secrete" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n utilizator din domeniu nu are încă autentificare în doi pași și nu poate deschide seiful cât timp aceasta este activă.", + "%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă.", + "%n utilizatori din domeniu nu au încă autentificare în doi pași și nu pot deschide seiful cât timp aceasta este activă." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Finalizează oricum, pierzând accesul la %n secret", + "Finalizează oricum, pierzând accesul la %n secrete", + "Finalizează oricum, pierzând accesul la %n secrete" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n membru nou a primit acces la un dosar de echipă.", + "%n membri noi au primit acces la un dosar de echipă.", + "%n membri noi au primit acces la un dosar de echipă." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotația cheii a fost finalizată. %n secret a fost recriptat cu noua dumneavoastră cheie.", + "Rotația cheii a fost finalizată. %n secrete au fost recriptate cu noua dumneavoastră cheie.", + "Rotația cheii a fost finalizată. %n secrete au fost recriptate cu noua dumneavoastră cheie." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revocarea celei de-a doua suite a șters %n contact de acces de urgență.", + "Revocarea celei de-a doua suite a șters %n contacte de acces de urgență.", + "Revocarea celei de-a doua suite a șters %n contacte de acces de urgență." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revocarea acestei suite a șters %n contact de acces de urgență.", + "Revocarea acestei suite a șters %n contacte de acces de urgență.", + "Revocarea acestei suite a șters %n contacte de acces de urgență." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "apărut %n dată în scurgeri", + "apărut %n ori în scurgeri", + "apărut %n ori în scurgeri" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "partajat cu %n secret", + "partajat cu %n secrete", + "partajat cu %n secrete" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Acest dosar conține direct %n secret.", + "Acest dosar conține direct %n secrete.", + "Acest dosar conține direct %n secrete." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n modificare așteaptă sincronizarea", + "%n modificări așteaptă sincronizarea", + "%n modificări așteaptă sincronizarea" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Utilizatorul este încă în grupul {groups}, care este membru al unui dosar de echipă. Scoateți-l din grup sau dezactivați contul.", + "Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul.", + "Utilizatorul este încă în grupurile {groups}, care sunt membre ale unor dosare de echipă. Scoateți-l din grupuri sau dezactivați contul." + ], + "Allow approval from another device": "Permite aprobarea de pe alt dispozitiv", + "App": "Aplicație", + "Approve a new device": "Aprobă un dispozitiv nou", + "Approve from another device": "Aprobă de pe alt dispozitiv", + "Asked at": "Solicitat la", + "Check that the new device shows these words:": "Verificați că dispozitivul nou afișează aceste cuvinte:", + "Denied. If you did not ask, end your other sessions:": "Refuzat. Dacă nu ați solicitat acest lucru, închideți celelalte sesiuni:", + "Device": "Dispozitiv", + "IP address": "Adresă IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Permite utilizatorilor să deblocheze un browser nou aprobându-l de pe un dispozitiv pe care Keepiq este deja deblocat.", + "New device approval": "Aprobarea dispozitivelor noi", + "Nextcloud security settings": "Setări de securitate Nextcloud", + "Only approve a device you are using right now.": "Aprobați doar un dispozitiv pe care îl folosiți chiar acum.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Deschideți Keepiq pe un dispozitiv pe care este deblocat și aprobați-l pe acesta. Verificați că afișează aceleași cuvinte:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Dispozitivul care aprobă sigilează cheia de deblocare pentru dispozitivul nou. Serverul doar o transmite și nu o poate deschide.", + "The master password is not right, or the request has ended.": "Parola principală nu este corectă sau solicitarea s-a încheiat.", + "The request expired. Ask again or use your master password.": "Solicitarea a expirat. Solicitați din nou sau folosiți parola principală.", + "The request was denied.": "Solicitarea a fost refuzată.", + "Too many requests. Try again in an hour or use your master password.": "Prea multe solicitări. Încercați din nou peste o oră sau folosiți parola principală.", + "Unknown device": "Dispozitiv necunoscut", + "Web app": "Aplicație web", + "A device": "Un dispozitiv", + "A new device asks to open your vault": "Un dispozitiv nou cere să vă deschidă seiful", + "%s asks to be approved. Only approve a device you are using right now.": "%s cere să fie aprobat. Aprobați doar un dispozitiv pe care îl folosiți chiar acum.", + "Access ends on (optional)": "Accesul se încheie la (opțional)", + "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.": "Aplicațiile Keepiq nu vor afișa și nu vor copia parola. Cineva cu cunoștințe tehnice o poate citi totuși de pe propriul dispozitiv. Schimbați-o când accesul se încheie.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Acest secret este doar pentru utilizare. Conectați-vă prin extensia de browser Keepiq.", + "Until {date}": "Până la {date}", + "Use only": "Doar utilizare", + "Use only (can sign in, cannot view or copy)": "Doar utilizare (se poate conecta, nu poate vizualiza sau copia)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Vă puteți conecta cu aceste date prin extensia de browser Keepiq. Proprietarul a ales să nu vă permită să le vizualizați sau să le copiați.", + "Your access ends on {date}": "Accesul dvs. se încheie la {date}", + "Your access to this secret has ended": "Accesul dvs. la acest secret s-a încheiat", + "Your access to \"%s\" ends tomorrow": "Accesul dvs. la „%s” se încheie mâine", + "Your access to \"%s\" has ended": "Accesul dvs. la „%s” s-a încheiat", + "%1$s no longer has access to \"%2$s\"": "%1$s nu mai are acces la „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s putea vedea această parolă. Schimbați-o dacă %1$s nu ar mai trebui să o cunoască.", + "%s could not view this password in Keepiq.": "%s nu a putut vizualiza această parolă în Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} din {threshold} aprobări", + "a recovery officer": "un responsabil de recuperare", + "Account recovery": "Recuperarea contului", + "Approvals needed": "Aprobări necesare", + "Ask {user} which words they see, by phone or in person. They must be:": "Întrebați-l pe {user} ce cuvinte vede, la telefon sau în persoană. Trebuie să fie:", + "Check again": "Verifică din nou", + "Create the recovery key": "Creează cheia de recuperare", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Creați cheia de recuperare. Browserul o generează și dă fiecărui responsabil o copie pe care doar el o poate deschide.", + "Decline": "Refuză", + "Enrol in account recovery": "Înscrieți-vă la recuperarea contului", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Înscrieți-vă ca organizația să vă poată ajuta să vă recuperați seiful dacă uitați parola principală.", + "Every user is enrolled": "Toți utilizatorii sunt înscriși", + "Finish the recovery in the browser you asked from.": "Finalizați recuperarea în browserul din care ați cerut-o.", + "Forgot your master password?": "Ați uitat parola principală?", + "Hand the key over": "Predă cheia", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Permiteți utilizatorilor care și-au uitat parola principală să își recupereze seiful, cu aprobarea responsabililor de recuperare pe care îi desemnați.", + "New master password": "Parolă principală nouă", + "No one is asking to recover their account.": "Nimeni nu cere recuperarea contului.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Încă nu există o cheie de recuperare. Unul dintre responsabili o creează în setările sale Keepiq.", + "Off": "Dezactivat", + "Officer {user} has no encryption set up yet.": "Responsabilul {user} nu și-a configurat încă criptarea.", + "Officers (user IDs, separated by commas)": "Responsabili (ID-uri de utilizator, separate prin virgulă)", + "Policy": "Politică", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicați această amprentă intern, ca utilizatorii să o poată verifica înainte de înscriere.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Recuperat cu ajutorul lui {officer}. Schimbați acum cheia seifului în Setări, Securitate: \"Parola mea principală a fost compromisă\".", + "Recovery key fingerprint: {fingerprint}": "Amprenta cheii de recuperare: {fingerprint}", + "Recovery officer": "Responsabil de recuperare", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Responsabilii eliminați își pierd acum copia, dar e posibil să o fi deschis înainte. Cereți unui responsabil să creeze o cheie de recuperare nouă.", + "Repeat the new master password": "Repetați noua parolă principală", + "Retire this recovery key": "Retrage această cheie de recuperare", + "Set the new master password": "Setează noua parolă principală", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certificatul de recuperare nu a fost emis de acest Keepiq. Nu vă înscrieți și anunțați administratorul.", + "The words match, approve": "Cuvintele se potrivesc, aprobă", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Acest utilizator este înscris la recuperarea contului. Recuperarea îi păstrează secretele; revocarea îi șterge înscrierea.", + "Users may enrol": "Utilizatorii se pot înscrie", + "Withdraw from account recovery": "Retrage-te de la recuperarea contului", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Sunteți înscris la recuperarea contului. Amprenta cheii de recuperare: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Sunteți înscris. Dacă uitați parola principală, organizația vă poate ajuta să vă recuperați seiful.", + "Your key is back. Choose a new master password.": "Cheia v-a fost returnată. Alegeți o parolă principală nouă.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Responsabilii de recuperare au fost anunțați. Citiți-le aceste cuvinte când vă sună sau vă întâlniți:", + "You are now an account recovery officer": "Acum sunteți responsabil de recuperarea conturilor", + "%s asks to recover their account. Compare the words with them before you approve.": "%s cere recuperarea contului. Comparați cuvintele cu această persoană înainte de a aproba.", + "A user": "Un utilizator", + "Your account recovery request was declined": "Cererea dvs. de recuperare a contului a fost refuzată", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Recuperarea contului este gata. Deschideți Keepiq în browserul din care ați cerut-o.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} cere deblocarea unui dispozitiv nou o singură dată. Parola principală rămâne aceeași.", + "Ask your organisation instead": "Întrebați mai bine organizația", + "The request ended. Ask again or use your master password.": "Cererea s-a încheiat. Cereți din nou sau folosiți parola principală.", + "Added by {user}": "Adăugat de {user}", + "Editor": "Editor", + "Manager": "Manager", + "Role of {member}": "Rolul lui {member}", + "Team folders you manage": "Dosare de echipă pe care le gestionați", + "Viewer": "Cititor", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nu dețineți o copie a acestor secrete, deci noii membri nu le-au primit încă. Proprietarul le poate partaja: {names}", + "Admin areas": "Zone de administrare", + "Give a group only the parts of Keepiq administration it needs.": "Dați unui grup doar părțile din administrarea Keepiq de care are nevoie.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegați una sau mai multe zone unui grup pe pagina de privilegii de administrare. Administratorii instanței au toate zonele.", + "Open administration privileges": "Deschide privilegiile de administrare", + "Policies": "Politici", + "Applications and machine access": "Aplicații și acces mașini", + "People and offboarding": "Persoane și plecări", + "Audit and compliance": "Audit și conformitate", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versiune, autoritate de certificare, atașamente, cache offline, verificarea scurgerilor, tipuri de secrete și copii de rezervă", + "master password, organisation password, vault policies, rotation, version history and trash": "parola principală, parola organizației, politicile seifului, rotație, istoricul versiunilor și coșul de gunoi", + "application queue, application requests and machine leases": "coada aplicațiilor, cererile aplicațiilor și închirierile mașinilor", + "team offboarding, encryption suites and admin handover": "plecări din echipă, suite de criptare și preluare de către administrator", + "audit log, compliance reports, SIEM export and honey alerts": "jurnal de audit, rapoarte de conformitate, export SIEM și alerte momeală", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Câte versiuni ale unui secret se păstrează, cât timp și cât timp rămân secretele șterse în coșul de gunoi.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limite pentru atașamentele criptate, aplicate pe server în octeți criptați stocați.", + "Type the suite ID again to confirm": "Introduceți din nou ID-ul suitei pentru confirmare", + "This does not match the suite ID.": "Nu corespunde cu ID-ul suitei.", + "Confirm with your master password": "Confirmați cu parola principală", + "Confirm": "Confirmare", + "That master password is not right.": "Această parolă principală nu este corectă.", + "You are sharing with someone new. Enter your master password to confirm.": "Partajați cu o persoană nouă. Introduceți parola principală pentru confirmare.", + "Enter your master password to confirm this share.": "Introduceți parola principală pentru a confirma această partajare.", + "Enter your master password to confirm this delegation.": "Introduceți parola principală pentru a confirma această delegare.", + "Approve {member}": "Aprobați {member}", + "Recipient": "Destinatar", + "No vault yet": "Încă nu are seif", + "No matching users": "Niciun utilizator potrivit", + "Partner organisations": "Organizații partenere", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Schimbați secrete cu un alt Keepiq. Ambii administratori se adaugă reciproc și compară amprentele rădăcină la telefon sau în persoană înainte de salvare.", + "Federation needs Nextcloud 33 or later.": "Federarea necesită Nextcloud 33 sau mai nou.", + "Your root fingerprint": "Amprenta dvs. rădăcină", + "No partners yet.": "Încă nu există parteneri.", + "Users here may share to this partner": "Utilizatorii de aici pot partaja cu acest partener", + "This partner may share to users here": "Acest partener poate partaja cu utilizatorii de aici", + "Partner address": "Adresa partenerului", + "Check partner": "Verificați partenerul", + "Partner root fingerprint": "Amprenta rădăcină a partenerului", + "I compared this fingerprint with the partner's administrator": "Am comparat această amprentă cu administratorul partenerului", + "Add partner": "Adăugați partenerul", + "A secret from another organisation": "Un secret de la o altă organizație", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s a partajat \"%2$s\" cu dvs. Acceptați-l în Primite de la alte organizații.", + "Incoming from other organisations": "Primite de la alte organizații", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Persoanele din organizațiile partenere pot partaja un secret cu dvs. Acceptați-l pentru a păstra o copie doar în citire în seiful dvs.", + "Nothing shared with you yet": "Nimic partajat cu dvs. încă", + "Secrets that people in partner organisations share with you appear here.": "Secretele pe care persoanele din organizațiile partenere le partajează cu dvs. apar aici.", + "From {sender}": "De la {sender}", + "Accept": "Acceptați", + "Open in vault": "Deschideți în seif", + "The other organisation did not hand over the secret. Try again later.": "Cealaltă organizație nu a transmis secretul. Încercați din nou mai târziu.", + "Set up your vault before you accept a shared secret.": "Configurați-vă seiful înainte de a accepta un secret partajat.", + "Something went wrong. Try again.": "Ceva nu a funcționat. Încercați din nou.", + "Waiting for your answer": "Se așteaptă răspunsul dvs.", + "In your vault, read-only": "În seiful dvs., doar în citire", + "Withdrawn by the sender": "Retras de expeditor", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} a partajat acest lucru dintr-o altă organizație. Îl puteți citi, dar nu îl puteți modifica sau partaja.", + "Someone": "Cineva", + "Share with someone at another organisation": "Partajați cu cineva dintr-o altă organizație", + "Their account at the other organisation": "Contul persoanei în cealaltă organizație", + "Check account": "Verificați contul", + "Certificate fingerprint of {account}": "Amprenta certificatului pentru {account}", + "Compare it with them by phone if you want to be sure.": "Comparați-o cu persoana respectivă la telefon dacă vreți să fiți sigur.", + "Shared. {account} can accept it in their own vault.": "Partajat. {account} îl poate accepta în propriul seif.", + "The certificate could not be verified. Nothing was shared.": "Certificatul nu a putut fi verificat. Nu s-a partajat nimic.", + "That organisation is not one of your partners.": "Organizația respectivă nu este unul dintre partenerii dvs.", + "No one with that account can receive secrets from you.": "Nimeni cu acel cont nu poate primi secrete de la dvs.", + "The other organisation did not answer. Try again later.": "Cealaltă organizație nu a răspuns. Încercați din nou mai târziu.", + "This secret is already shared with that account.": "Acest secret este deja partajat cu acel cont.", + "Other organisations": "Alte organizații", + "Receive secrets from other organisations": "Primiți secrete de la alte organizații", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Persoanele din organizațiile partenere vă pot găsi apoi contul și pot partaja secrete cu dvs. Acceptați fiecare secret personal.", + "Shared": "Partajat", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Întrerupt: certificatul destinatarului sau parteneriatul s-a schimbat. Revocați partajarea sau partajați din nou.", + "Their organisation did not get the last change. Revoke it or share again.": "Organizația destinatarului nu a primit ultima modificare. Revocați partajarea sau partajați din nou.", + "Being withdrawn": "Se retrage", + "Shared with another organisation": "Partajat cu o altă organizație", + "Change sent to another organisation": "Modificare trimisă unei alte organizații", + "Share with another organisation revoked": "Partajare cu o altă organizație revocată", + "Share with another organisation paused": "Partajare cu o altă organizație întreruptă", + "Another organisation did not get a change": "O altă organizație nu a primit o modificare", + "Secret received from another organisation": "Secret primit de la o altă organizație", + "Secret from another organisation accepted": "Secret de la o altă organizație acceptat", + "Secret from another organisation declined": "Secret de la o altă organizație refuzat", + "Copy from another organisation updated": "Copie de la o altă organizație actualizată", + "Copy from another organisation removed": "Copie de la o altă organizație eliminată", + "Declined: they removed their copy. Share again if they need it.": "Refuzat: destinatarul și-a eliminat copia. Partajați din nou dacă are nevoie de ea.", + "Recipient at another organisation removed their copy": "Un destinatar dintr-o altă organizație și-a eliminat copia", + "Removed the user from %n team folder.": "Utilizatorul a fost eliminat din %n dosar de echipă.", + "Removed the user from %n team folders.": "Utilizatorul a fost eliminat din %n dosare de echipă.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Utilizatorul a fost eliminat din %n dosar de echipă.", + "Utilizatorul a fost eliminat din %n dosare de echipă.", + "Utilizatorul a fost eliminat din %n dosare de echipă." + ], + "A restored copy came from a share that has ended. It stays read-only.": "O copie restaurată provine dintr-o partajare care s-a încheiat. Rămâne doar în citire.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizația care a partajat o copie restaurată nu a putut fi contactată. Copia rămâne doar în citire și nu urmează modificările lor.", + "Recipient at another organisation restored their copy": "Un destinatar dintr-o altă organizație și-a restaurat copia" }, "plurals": null } diff --git a/l10n/ru.js b/l10n/ru.js index 51346f251..7464a5b39 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен.", + "Shared with groups": "Предоставлен доступ группам", + "Not shared with any group yet.": "Пока не предоставлен ни одной группе.", + "Revoke the share with {group}": "Отозвать доступ для {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Предоставлено группе {group}: {received} участников получили, {skipped} нет, так как у них ещё не настроено шифрование.", + "Search groups": "Искать группы", + "Failed to share": "Не удалось предоставить доступ", + "Columns": "Столбцы", + "Column {number}": "Столбец {number}", + "Map one column to Name. Every secret needs a name.": "Сопоставьте один столбец с названием. У каждого секрета должно быть название.", + "Notes": "Заметки", + "Do not import": "Не импортировать", + "Hide this value": "Скрыть это значение", + "Show this value": "Показать это значение", + "Defaults": "По умолчанию", + "New secrets start as this type, and your secret list opens in this view.": "Новые секреты создаются с этим типом, а список секретов открывается в этом виде.", + "Default item type": "Тип элемента по умолчанию", + "Cards": "Карточки", + "Table": "Таблица", + "Could not save your default": "Не удалось сохранить значение по умолчанию", + "Recently used": "Недавно использованные", + "Opened": "Открыто", + "You have not opened any secrets yet": "Вы ещё не открывали секреты", + "Could not delete the item type.": "Не удалось удалить тип элемента.", + "Could not load the item types.": "Не удалось загрузить типы элементов.", + "Could not save the item type.": "Не удалось сохранить тип элемента.", + "Delete item type": "Удалить тип элемента", + "Edit item type": "Изменить тип элемента", + "Fields": "Поля", + "Fields: {count}": "Поля: {count}", + "Hidden": "Скрытое", + "Item types": "Типы элементов", + "Move up": "Вверх", + "New item type": "Новый тип элемента", + "No item types defined yet.": "Типы элементов ещё не определены.", + "Required": "Обязательное", + "Text": "Текст", + "This field is required": "Это поле обязательно", + "Web address": "Веб-адрес", + "{label} (required)": "{label} (обязательно)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Удалить «{name}»? Секреты этого типа останутся читаемыми и станут элементами Логин.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типы элементов, которые вы определите здесь, появятся у всех в окне Новый секрет с выбранными вами полями.", + "Secret moved to the trash": "Секрет перемещён в корзину", + "Secret restored from the trash": "Секрет восстановлен из корзины", + "Secret deleted for good": "Секрет удалён навсегда", + "Secret archived": "Секрет архивирован", + "Secret unarchived": "Секрет извлечён из архива", + "Unarchive": "Извлечь из архива", + "Could not archive the secret": "Не удалось архивировать секрет", + "Could not unarchive the secret": "Не удалось извлечь секрет из архива", + "Archive {count} secrets": "Архивировать секреты: {count}", + "Unarchive {count} secrets": "Извлечь из архива секреты: {count}", + "Restore {count} secrets": "Восстановить секреты: {count}", + "Delete {count} secrets for good": "Удалить навсегда секреты: {count}", + "Done for {ok} of {total} secrets": "Готово для {ok} из {total} секретов", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивированные секреты исчезают из списка хранилища, поиска, автозаполнения и отчёта о состоянии. Общий доступ к ним сохраняется. Их можно найти в Архиве.", + "These secrets come back to the vault list, search and autofill.": "Эти секреты возвращаются в список хранилища, поиск и автозаполнение.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Эти секреты возвращаются в список хранилища. Прежний общий доступ не возвращается, поэтому при необходимости поделитесь ими снова.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Это удалит секреты вместе с вложениями и историей версий. Отменить это нельзя.", + "Delete for good": "Удалить навсегда", + "Trash": "Корзина", + "The trash is empty": "Корзина пуста", + "No archived secrets": "Нет архивированных секретов", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Удалённые секреты ждут здесь до конца срока хранения, после чего удаляются навсегда.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивируйте секрет на его панели сведений, чтобы убрать его из списка хранилища, поиска и автозаполнения.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничения для зашифрованных вложений (применяются на сервере к сохранённым зашифрованным байтам), хранение истории версий и срок, в течение которого удалённые секреты остаются в корзине.", + "Days a deleted secret stays in the trash (1 to 365)": "Сколько дней удалённый секрет остаётся в корзине (от 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Секрет будет перемещён в корзину, а общий доступ к нему сразу прекратится. Его можно восстановить из корзины до конца срока хранения: 30 дней, если администратор не изменил этот срок.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Секреты будут перемещены в корзину ({count}), а общий доступ к ним сразу прекратится. Их можно восстановить из корзины до конца срока хранения.", + "Remove {name} from favourites": "Убрать {name} из избранного", + "Add {name} to favourites": "Добавить {name} в избранное", + "Could not change the favourite": "Не удалось изменить избранное", + "Remove from favourites": "Убрать из избранного", + "Add to favourites": "Добавить в избранное", + "Tags": "Метки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Метки не шифруются. Администраторы сервера могут их прочитать, как и названия папок.", + "Favourites": "Избранное", + "Filter by tag": "Фильтр по метке", + "All tags": "Все метки", + "Last used": "Последнее использование", + "Tags for {count} secrets": "Метки для {count} секретов", + "Tag": "Метка", + "Remove tag": "Убрать метку", + "Add tag": "Добавить метку", + "Could not change the tags. Try again.": "Не удалось изменить метки. Попробуйте ещё раз.", + "Could not approve the application. It is still in the queue.": "Не удалось одобрить заявку. Она всё ещё в очереди.", + "Could not reject the application. It is still in the queue.": "Не удалось отклонить заявку. Она всё ещё в очереди.", + "Removed the user from {count} team folders.": "Пользователь удалён из командных папок: {count}.", + "Approve a share": "Одобрить общий доступ", + "This approval link is incomplete. Open it again from the notification.": "Ссылка для одобрения неполная. Откройте её снова из уведомления.", + "Deny": "Отклонить", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} вступил в группу, с которой вы делитесь секретом. Поделиться секретом и с ним?", + "{requester} asks you to share a secret with {user}.": "{requester} просит вас поделиться секретом с {user}.", + "Shared. The recipient can now open the secret.": "Доступ предоставлен. Теперь получатель может открыть секрет.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Получатель ещё не настроил Keepiq, поэтому доступ не предоставлен. Повторите попытку, когда он это сделает.", + "Could not share the secret. Only its owner can approve this.": "Не удалось поделиться секретом. Одобрить это может только его владелец.", + "Could not share the secret. Try again.": "Не удалось поделиться секретом. Повторите попытку.", + "Denied. Nothing was shared.": "Отклонено. Доступ не предоставлен.", + "Could not deny the request. Try again.": "Не удалось отклонить запрос. Повторите попытку.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просит вас поделиться секретом \"%2$s\" с %3$s.", + "Expires on (optional)": "Истекает (необязательно)", + "Hand over to": "Передать", + "Choose a recipient": "Выберите получателя", + "Hand over temporarily": "Передать временно", + "Expiry rules": "Правила срока действия", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Укажите, сколько могут действовать пароли одного типа элементов или одной папки и когда напоминать. Если применяется несколько дат, учитывается самая ранняя.", + "Delete rule": "Удалить правило", + "Set by your administrator": "Задано администратором", + "No expiry rules yet.": "Правил срока действия пока нет.", + "Applies to": "Применяется к", + "Item type": "Тип элемента", + "Maximum age in days (empty for reminders only)": "Максимальный возраст в днях (пусто, если нужны только напоминания)", + "Remind me this many days before, comma separated": "За сколько дней напомнить, через запятую", + "Save rule": "Сохранить правило", + "An item type": "Тип элемента", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Истекает через {days} дн.", + "Reminders {days} days before": "Напоминания за {days} дн.", + "Could not save the expiry rule.": "Не удалось сохранить правило срока действия.", + "Could not delete the expiry rule.": "Не удалось удалить правило срока действия.", + "All statuses": "Все статусы", + "Compromised": "Скомпрометирован", + "Could not load the members.": "Не удалось загрузить участников.", + "Emergency contact": "Экстренный контакт", + "Leaving user": "Уходящий пользователь", + "No": "Нет", + "No users match this filter.": "Нет пользователей по этому фильтру.", + "Not set up": "Не настроено", + "Revoke suite": "Отозвать набор", + "Revoked": "Отозван", + "Search users": "Искать пользователей", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Посмотрите, кто из пользователей настроил хранилище. Начните увольнение или отзовите набор из строки.", + "Successor": "Преемник", + "Team folders": "Командные папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Пользователь всё ещё в группе {groups}, которая входит в командную папку. Удалите его из группы или отключите учётную запись.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись.", + "Vault status": "Статус хранилища", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Экспорт в CXF НЕ ЗАШИФРОВАН. Каждый пароль и логин будут читаемы как обычный текст в скачанном файле. Храните файл в надёжном месте и удалите сразу после использования.", + "Root certificate expiring soon": "Срок действия корневого сертификата скоро истечёт", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Срок действия корневого сертификата хранилища истекает через %1$d дн. Обновите его до этого. При обновлении каждый набор шифрования подписывается заново.", + "Compromise recovery aborted": "Восстановление после компрометации прервано", + "Key rotation ended by a compromise revoke": "Смена ключа завершена отзывом из-за компрометации", + "Encryption suite revoke refused": "Отзыв набора шифрования отклонён", + "Master password proof refused": "Подтверждение мастер-пароля отклонено", + "Your current master password": "Ваш текущий мастер-пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "У %n экстренного контакта был ожидающий запрос доступа, когда смена ключа удалила его. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Эти экстренные контакты не были перенесены на ваш новый ключ. Их экстренный доступ удалён. Добавьте их снова в разделе Экстренный доступ, если они вам ещё нужны.", + "Renew root certificate": "Обновить корневой сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Будут созданы новые корневой и промежуточный сертификаты. Каждый активный набор шифрования будет подписан заново. Это действие нельзя отменить.", + "Renew root": "Обновить корень", + "Root renewed. {n} encryption suites signed again.": "Корень обновлён. Заново подписано наборов шифрования: {n}.", + "Could not renew the root certificate.": "Не удалось обновить корневой сертификат.", + "Lease policy for this application": "Политика аренды для этого приложения", + "In force now: {default} seconds by default, {max} seconds at most.": "Сейчас действует: по умолчанию {default} секунд, не более {max} секунд.", + "Leases are not renewable": "Аренду нельзя продлевать", + "Lease policy saved.": "Политика аренды сохранена.", + "Leave a field empty to use the instance value.": "Оставьте поле пустым, чтобы использовать значение экземпляра.", + "Instance value: {value}": "Значение экземпляра: {value}", + "Renewal": "Продление", + "Use the instance value ({value})": "Использовать значение экземпляра ({value})", + "Allowed": "Разрешено", + "Not allowed": "Не разрешено", + "Save lease policy": "Сохранить политику аренды", + "Only an administrator can change this policy.": "Изменить эту политику может только администратор.", + "Could not save the lease policy.": "Не удалось сохранить политику аренды.", + "{member} got access from {confirmer}.": "{member} получил доступ от {confirmer}.", + "Automatically confirm new team folder members": "Автоматически подтверждать новых участников командных папок", + "Gave %n new member access to a team folder.": "%n новый участник получил доступ к командной папке.", + "Gave %n new members access to a team folder.": "Новые участники (%n) получили доступ к командной папке.", + "Give new team folder members access without waiting for the folder owner.": "Давайте доступ новым участникам, не дожидаясь владельца папки.", + "New team folder members": "Новые участники командных папок", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Владелец или участник с правом записи подтверждает их из открытого хранилища. Keepiq никогда не расшифровывает на сервере.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ожидание, пока участник с правом записи откроет Keepiq. Можно поделиться и сейчас.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Часть реакции на компрометацию не выполнена ({failed} шаг(ов)). Проверьте журнал сервера, затем снова отзовите набор, чтобы завершить её.", + "This also revoked suite {suite} and ended key migration {migration}.": "Это также отозвало набор {suite} и завершило миграцию ключей {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отзыв второго набора удалил %n контакт аварийного доступа.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отзыв второго набора удалил %n контактов аварийного доступа.", + "A suite revoked as compromised cannot be reinstated.": "Набор, отозванный как скомпрометированный, нельзя восстановить.", + "Archives to keep": "Сколько архивов хранить", + "Back up every vault automatically": "Автоматически создавать копию каждого хранилища", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Создавайте копию каждого хранилища по расписанию. Архивы содержат только шифротекст и восстанавливаются через occ.", + "Back up now": "Создать копию сейчас", + "Backup public key (PEM, optional)": "Открытый ключ резервной копии (PEM, необязательно)", + "Backup requested for the next cron run": "Копия запрошена на следующий запуск cron", + "Encrypted": "Зашифрован", + "Every (hours)": "Каждые (часов)", + "Last backup {when} failed: {error}": "Последняя копия {when} не удалась: {error}", + "Last backup {when} succeeded.": "Последняя копия {when} создана.", + "No archives yet.": "Архивов пока нет.", + "Size": "Размер", + "Vault backups": "Резервные копии хранилища", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "С ключом каждый архив шифруется для него. Храните закрытый ключ вне этого сервера: он нужен для проверки или восстановления.", + "Written": "Записан", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n пользователь в области действия ещё не настроил двухфакторный вход и не может открыть хранилище, пока это включено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервные коды не учитываются. Если ваши пользователи входят через поставщика удостоверений со своим вторым фактором, исключите их группы.", + "Block personal vault export": "Запретить экспорт личного хранилища", + "Keep work logins in team folders": "Хранить рабочие учётные данные в командных папках", + "Move to a team folder": "Переместить в командную папку", + "Not in a team folder": "Не в командной папке", + "Only for these groups (empty is everyone)": "Только для этих групп (пусто означает всех)", + "Require two-factor login before the vault opens": "Требовать двухфакторный вход перед открытием хранилища", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила для каждого хранилища. Каждое действует для всех или только для выбранных групп.", + "Secret types that belong in a team folder": "Типы секретов, которые хранятся в командной папке", + "Set up two-factor login": "Настроить двухфакторный вход", + "Team folder you can write to": "Командная папка, в которую вы можете записывать", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Пользователи не могут скачать резервную копию, CSV или файл переноса. Их пакет личных данных остаётся доступным.", + "Users cannot save these secret types in a personal folder.": "Пользователи не могут сохранять эти типы секретов в личной папке.", + "Vault policies": "Правила хранилища", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша организация не разрешает экспорт личного хранилища. Ваш пакет личных данных в настройках остаётся доступным.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша организация хранит эти секреты в командной папке. Переместите каждый в командную папку.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша организация хранит этот тип секрета в командной папке. Выберите одну из своих командных папок или ту, в которую вы можете записывать.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша организация требует двухфакторный вход, прежде чем вы сможете открыть хранилище.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Пользователи выбирают, как долго расширение остаётся разблокированным при бездействии. Вы задаёте наибольшее время, которое можно выбрать.", + "Longest idle time before the extension locks": "Наибольшее время бездействия до блокировки расширения", + "1 minute": "1 минута", + "5 minutes": "5 минут", + "15 minutes": "15 минут", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Коннектор", + "Directory (tenant) ID": "ИД каталога (клиента)", + "Application (client) ID": "ИД приложения (клиента)", + "Data collection rule immutable ID": "Неизменяемый ИД правила сбора данных", + "Stream name": "Имя потока", + "Splunk index (optional)": "Индекс Splunk (необязательно)", + "Sourcetype (optional)": "Sourcetype (необязательно)", + "Leave blank to keep the current one": "Оставьте пустым, чтобы сохранить текущее значение", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF через syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Конечная точка сбора данных (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Секрет клиента (только запись)", + "HEC token (write-only)": "Токен HEC (только запись)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Пересылайте разрешённые события аудита в Splunk, Microsoft Sentinel, приёмник syslog или веб-хук. Сообщения содержат только очищенные метаданные: никакое секретное значение, имя, логин или шифротекст никогда не покидает сервер.", + "%n change waiting to sync": "%n изменение ожидает синхронизации", + "%n changes waiting to sync": "%n изменений ожидают синхронизации", + "Changes that could not sync": "Изменения, которые не удалось синхронизировать", + "Choose a version": "Выбрать версию", + "Copy value": "Скопировать значение", + "Deleted": "Удалено", + "Discard": "Отбросить", + "Keep my offline change": "Оставить моё автономное изменение", + "Keep the server version": "Оставить версию с сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без сети Keepiq доступен только для чтения. Администратор не включил редактирование без сети.", + "Let users edit secrets offline": "Разрешить пользователям редактировать секреты без сети", + "Not synced yet": "Ещё не синхронизировано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Изменения без сети остаются на устройстве, зашифрованные для пользователя, и синхронизируются при следующей разблокировке в сети. Общий доступ, папки и вложения по-прежнему требуют подключения.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без сети. Правки, перемещения и удаления остаются на этом устройстве и синхронизируются, когда вы снова будете в сети. Общий доступ и вложения требуют подключения.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без сети. Ваши изменения остаются на этом устройстве и синхронизируются, когда вы снова будете в сети. Последняя синхронизация {when}.", + "Open my changes": "Открыть мои изменения", + "Sharing needs a connection": "Общий доступ требует подключения", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Кто-то изменил этот секрет на сервере после создания вашей автономной копии. Выберите, какую версию оставить.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизируйте или отбросьте изменения без сети, прежде чем менять ключи.", + "That password did not open your changes.": "Этот пароль не открыл ваши изменения.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Автономный снимок хранит зашифрованные секреты (открываются только ключом, полученным из мастер-пароля пользователя, точно как на сервере) и шифрует названия, URL и названия папок при хранении. Автономный доступ только для чтения, если ниже вы не разрешите редактирование без сети. Отключите это для устройств, которые никогда не должны кэшировать учётные данные; отключение очищает существующие кэши при следующей загрузке.", + "The previous vault copy is gone, so these changes cannot be opened.": "Предыдущей копии хранилища больше нет, поэтому эти изменения нельзя открыть.", + "The server version": "Версия с сервера", + "This secret changed while you were offline": "Этот секрет изменился, пока вы были без сети", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Вы удалили этот секрет без сети, но с тех пор его изменили на сервере. Выберите, какую версию оставить.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваши ключи были изменены на другом устройстве. Введите предыдущий мастер-пароль, чтобы синхронизировать изменения без сети, или отбросьте их.", + "Your offline change": "Ваше автономное изменение", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["У %n экстренного контакта был ожидающий запрос доступа, когда смена ключа удалила его. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.","У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.","У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n элемент нельзя представить в CXF, он будет пропущен.","%n элементов нельзя представить в CXF, они будут пропущены.","%n элементов нельзя представить в CXF, они будут пропущены."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n более старая версия была отброшена, так как перенести можно только недавнюю историю.","%n более старых версий были отброшены, так как перенести можно только недавнюю историю.","%n более старых версий были отброшены, так как перенести можно только недавнюю историю."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Ещё требуется зашифровать и предоставить %n копию секрета.","Ещё требуется зашифровать и предоставить %n копий секретов.","Ещё требуется зашифровать и предоставить %n копий секретов."], + "_%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 секрет не удалось расшифровать, и его нет в этом экспорте.","%n секретов не удалось расшифровать, и их нет в этом экспорте.","%n секретов не удалось расшифровать, и их нет в этом экспорте."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n секрет не удалось расшифровать вашим старым ключом, поэтому он не был перенесён.","%n секретов не удалось расшифровать вашим старым ключом, поэтому они не были перенесены.","%n секретов не удалось расшифровать вашим старым ключом, поэтому они не были перенесены."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n секрет не был перенесён.","%n секретов не были перенесены.","%n секретов не были перенесены."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n секрет всё ещё зашифрован вашим предыдущим ключом.","%n секретов всё ещё зашифрованы вашим предыдущим ключом.","%n секретов всё ещё зашифрованы вашим предыдущим ключом."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n секрет пропущен, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова.","%n секретов пропущено, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова.","%n секретов пропущено, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова."], + "_%n secret_::_%n secrets_": ["%n секрет","%n секретов","%n секретов"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n пользователь в области действия ещё не настроил двухфакторный вход и не может открыть хранилище, пока это включено.","Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено.","Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Всё равно завершить, потеряв доступ к %n секрету","Всё равно завершить, потеряв доступ к %n секретам","Всё равно завершить, потеряв доступ к %n секретам"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n новый участник получил доступ к командной папке.","Новые участники (%n) получили доступ к командной папке.","Новые участники (%n) получили доступ к командной папке."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ротация ключа завершена. %n секрет был перешифрован вашим новым ключом.","Ротация ключа завершена. %n секретов было перешифровано вашим новым ключом.","Ротация ключа завершена. %n секретов было перешифровано вашим новым ключом."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Отзыв второго набора удалил %n контакт аварийного доступа.","Отзыв второго набора удалил %n контактов аварийного доступа.","Отзыв второго набора удалил %n контактов аварийного доступа."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Отзыв этого набора удалил %n контакт аварийного доступа.","Отзыв этого набора удалил %n контактов аварийного доступа.","Отзыв этого набора удалил %n контактов аварийного доступа."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["встречается в утечках %n раз","встречается в утечках %n раз","встречается в утечках %n раз"], + "_shared with %n secret_::_shared with %n secrets_": ["доступно %n секрету","доступно %n секретам","доступно %n секретам"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Эта папка содержит %n секрет напрямую.","Эта папка содержит %n секретов напрямую.","Эта папка содержит %n секретов напрямую."], + "_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._": ["Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.","Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.","Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n изменение ожидает синхронизации","%n изменений ожидают синхронизации","%n изменений ожидают синхронизации"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Пользователь всё ещё в группе {groups}, которая входит в командную папку. Удалите его из группы или отключите учётную запись.","Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись.","Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись."], + "Allow approval from another device": "Разрешить подтверждение с другого устройства", + "App": "Приложение", + "Approve a new device": "Подтвердить новое устройство", + "Approve from another device": "Подтвердить с другого устройства", + "Asked at": "Запрошено в", + "Check that the new device shows these words:": "Убедитесь, что новое устройство показывает эти слова:", + "Denied. If you did not ask, end your other sessions:": "Отклонено. Если вы этого не запрашивали, завершите другие сеансы:", + "Device": "Устройство", + "IP address": "IP-адрес", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Позволяет пользователям разблокировать новый браузер, подтвердив его с устройства, на котором Keepiq уже разблокирован.", + "New device approval": "Подтверждение новых устройств", + "Nextcloud security settings": "Настройки безопасности Nextcloud", + "Only approve a device you are using right now.": "Подтверждайте только устройство, которым пользуетесь прямо сейчас.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Откройте Keepiq на устройстве, где он разблокирован, и подтвердите это устройство. Убедитесь, что там показаны те же слова:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Подтверждающее устройство запечатывает ключ разблокировки для нового устройства. Сервер только передаёт его и не может его открыть.", + "The master password is not right, or the request has ended.": "Мастер-пароль неверен, или запрос завершён.", + "The request expired. Ask again or use your master password.": "Срок запроса истёк. Запросите снова или используйте мастер-пароль.", + "The request was denied.": "Запрос отклонён.", + "Too many requests. Try again in an hour or use your master password.": "Слишком много запросов. Повторите через час или используйте мастер-пароль.", + "Unknown device": "Неизвестное устройство", + "Web app": "Веб-приложение", + "A device": "Устройство", + "A new device asks to open your vault": "Новое устройство просит открыть ваше хранилище", + "%s asks to be approved. Only approve a device you are using right now.": "%s просит подтверждения. Подтверждайте только устройство, которым пользуетесь прямо сейчас.", + "Access ends on (optional)": "Доступ заканчивается (необязательно)", + "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.": "Приложения Keepiq не покажут и не скопируют пароль. Человек с техническими навыками всё же может прочитать его на своём устройстве. Смените его, когда доступ закончится.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Этот секрет только для использования. Войдите через расширение браузера Keepiq.", + "Until {date}": "До {date}", + "Use only": "Только использование", + "Use only (can sign in, cannot view or copy)": "Только использование (может войти, не может просмотреть или скопировать)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Вы можете войти с этими учётными данными через расширение браузера Keepiq. Владелец решил не разрешать вам просматривать или копировать их.", + "Your access ends on {date}": "Ваш доступ заканчивается {date}", + "Your access to this secret has ended": "Ваш доступ к этому секрету закончился", + "Your access to \"%s\" ends tomorrow": "Ваш доступ к «%s» заканчивается завтра", + "Your access to \"%s\" has ended": "Ваш доступ к «%s» закончился", + "%1$s no longer has access to \"%2$s\"": "У %1$s больше нет доступа к «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s мог(ла) видеть этот пароль. Смените его, если %1$s больше не должен(на) его знать.", + "%s could not view this password in Keepiq.": "%s не смог(ла) просмотреть этот пароль в Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} из {threshold} подтверждений", + "a recovery officer": "сотрудник по восстановлению", + "Account recovery": "Восстановление учётной записи", + "Approvals needed": "Нужно подтверждений", + "Ask {user} which words they see, by phone or in person. They must be:": "Спросите у {user}, какие слова он видит, по телефону или лично. Они должны быть такими:", + "Check again": "Проверить снова", + "Create the recovery key": "Создать ключ восстановления", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Создайте ключ восстановления. Ваш браузер создаёт его и даёт каждому сотруднику копию, которую может открыть только он.", + "Decline": "Отклонить", + "Enrol in account recovery": "Подключиться к восстановлению учётной записи", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Подключитесь, чтобы организация могла помочь вам вернуть хранилище, если вы забудете мастер-пароль.", + "Every user is enrolled": "Все пользователи подключены", + "Finish the recovery in the browser you asked from.": "Завершите восстановление в браузере, из которого вы его запросили.", + "Forgot your master password?": "Забыли мастер-пароль?", + "Hand the key over": "Передать ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Позвольте пользователям, забывшим мастер-пароль, вернуть хранилище с подтверждением назначенных вами сотрудников по восстановлению.", + "New master password": "Новый мастер-пароль", + "No one is asking to recover their account.": "Никто не просит восстановить учётную запись.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа восстановления пока нет. Один из сотрудников создаёт его в своих настройках Keepiq.", + "Off": "Выключено", + "Officer {user} has no encryption set up yet.": "Сотрудник {user} ещё не настроил шифрование.", + "Officers (user IDs, separated by commas)": "Сотрудники (ID пользователей через запятую)", + "Policy": "Политика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Опубликуйте этот отпечаток внутри организации, чтобы пользователи могли проверить его перед подключением.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Восстановлено с помощью {officer}. Смените ключ хранилища сейчас в Настройки, Безопасность: \"Мой мастер-пароль скомпрометирован\".", + "Recovery key fingerprint: {fingerprint}": "Отпечаток ключа восстановления: {fingerprint}", + "Recovery officer": "Сотрудник по восстановлению", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Удалённые сотрудники теряют свою копию сейчас, но могли открыть её раньше. Попросите сотрудника создать новый ключ восстановления.", + "Repeat the new master password": "Повторите новый мастер-пароль", + "Retire this recovery key": "Вывести этот ключ восстановления из работы", + "Set the new master password": "Задать новый мастер-пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификат восстановления выдан не этим Keepiq. Не подключайтесь и сообщите администратору.", + "The words match, approve": "Слова совпадают, подтвердить", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Этот пользователь подключён к восстановлению учётной записи. Восстановление сохраняет его секреты; отзыв удаляет его подключение.", + "Users may enrol": "Пользователи могут подключаться", + "Withdraw from account recovery": "Отключиться от восстановления учётной записи", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Вы подключены к восстановлению учётной записи. Отпечаток ключа восстановления: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Вы подключены. Если вы забудете мастер-пароль, организация может помочь вам вернуть хранилище.", + "Your key is back. Choose a new master password.": "Ваш ключ возвращён. Выберите новый мастер-пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваши сотрудники по восстановлению уведомлены. Прочитайте им эти слова, когда они позвонят или встретятся с вами:", + "You are now an account recovery officer": "Теперь вы сотрудник по восстановлению учётных записей", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просит восстановить учётную запись. Сверьте с ним слова перед подтверждением.", + "A user": "Пользователь", + "Your account recovery request was declined": "Ваш запрос на восстановление учётной записи отклонён", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Восстановление учётной записи готово. Откройте Keepiq в браузере, из которого вы его запросили.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просит один раз разблокировать новое устройство. Мастер-пароль остаётся прежним.", + "Ask your organisation instead": "Лучше обратиться к своей организации", + "The request ended. Ask again or use your master password.": "Запрос завершён. Отправьте его снова или используйте мастер-пароль.", + "Added by {user}": "Добавлено пользователем {user}", + "Editor": "Редактор", + "Manager": "Менеджер", + "Role of {member}": "Роль {member}", + "Team folders you manage": "Командные папки, которыми вы управляете", + "Viewer": "Читатель", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас нет копии этих секретов, поэтому новые участники их ещё не получили. Владелец может поделиться ими: {names}", + "Admin areas": "Области администрирования", + "Give a group only the parts of Keepiq administration it needs.": "Дайте группе только те части администрирования Keepiq, которые ей нужны.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегируйте одну или несколько областей группе на странице прав администрирования. Администраторы экземпляра имеют все области.", + "Open administration privileges": "Открыть права администрирования", + "Policies": "Политики", + "Applications and machine access": "Приложения и доступ машин", + "People and offboarding": "Люди и увольнение", + "Audit and compliance": "Аудит и соответствие", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версия, центр сертификации, вложения, автономный кэш, проверка утечек, типы секретов и резервные копии", + "master password, organisation password, vault policies, rotation, version history and trash": "мастер-пароль, пароль организации, политики хранилища, ротация, история версий и корзина", + "application queue, application requests and machine leases": "очередь приложений, запросы приложений и аренды машин", + "team offboarding, encryption suites and admin handover": "увольнение из команды, наборы шифрования и передача администратору", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аудита, отчёты о соответствии, экспорт в SIEM и оповещения о приманках", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Сколько версий секрета хранится, как долго и как долго удалённые секреты остаются в корзине.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничения для зашифрованных вложений, применяемые на сервере к хранимым зашифрованным байтам.", + "Type the suite ID again to confirm": "Введите ID набора ещё раз для подтверждения", + "This does not match the suite ID.": "Не совпадает с ID набора.", + "Confirm with your master password": "Подтвердите мастер-паролем", + "Confirm": "Подтвердить", + "That master password is not right.": "Этот мастер-пароль неверен.", + "You are sharing with someone new. Enter your master password to confirm.": "Вы делитесь с новым человеком. Введите мастер-пароль для подтверждения.", + "Enter your master password to confirm this share.": "Введите мастер-пароль, чтобы подтвердить этот общий доступ.", + "Enter your master password to confirm this delegation.": "Введите мастер-пароль, чтобы подтвердить это делегирование.", + "Approve {member}": "Одобрить {member}", + "Recipient": "Получатель", + "No vault yet": "Хранилища пока нет", + "No matching users": "Нет подходящих пользователей", + "Partner organisations": "Партнёрские организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обменивайтесь секретами с другим Keepiq. Оба администратора добавляют друг друга и перед сохранением сверяют корневые отпечатки по телефону или лично.", + "Federation needs Nextcloud 33 or later.": "Для федерации нужен Nextcloud 33 или новее.", + "Your root fingerprint": "Ваш корневой отпечаток", + "No partners yet.": "Партнёров пока нет.", + "Users here may share to this partner": "Пользователи здесь могут делиться с этим партнёром", + "This partner may share to users here": "Этот партнёр может делиться с пользователями здесь", + "Partner address": "Адрес партнёра", + "Check partner": "Проверить партнёра", + "Partner root fingerprint": "Корневой отпечаток партнёра", + "I compared this fingerprint with the partner's administrator": "Я сверил этот отпечаток с администратором партнёра", + "Add partner": "Добавить партнёра", + "A secret from another organisation": "Секрет из другой организации", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Пользователь %1$s поделился с вами \"%2$s\". Примите его в разделе Входящие из других организаций.", + "Incoming from other organisations": "Входящие из других организаций", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Сотрудники партнёрских организаций могут поделиться с вами секретом. Примите его, чтобы хранить копию только для чтения в своём хранилище.", + "Nothing shared with you yet": "С вами пока ничем не поделились", + "Secrets that people in partner organisations share with you appear here.": "Здесь появляются секреты, которыми с вами делятся сотрудники партнёрских организаций.", + "From {sender}": "От {sender}", + "Accept": "Принять", + "Open in vault": "Открыть в хранилище", + "The other organisation did not hand over the secret. Try again later.": "Другая организация не передала секрет. Повторите попытку позже.", + "Set up your vault before you accept a shared secret.": "Настройте хранилище, прежде чем принимать общий секрет.", + "Something went wrong. Try again.": "Что-то пошло не так. Повторите попытку.", + "Waiting for your answer": "Ожидает вашего ответа", + "In your vault, read-only": "В вашем хранилище, только для чтения", + "Withdrawn by the sender": "Отозвано отправителем", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Пользователь {sender} поделился этим из другой организации. Вы можете читать его, но не можете изменять или делиться им.", + "Someone": "Кто-то", + "Share with someone at another organisation": "Поделиться с человеком из другой организации", + "Their account at the other organisation": "Учётная запись этого человека в другой организации", + "Check account": "Проверить учётную запись", + "Certificate fingerprint of {account}": "Отпечаток сертификата учётной записи {account}", + "Compare it with them by phone if you want to be sure.": "Сверьте его с этим человеком по телефону, если хотите быть уверены.", + "Shared. {account} can accept it in their own vault.": "Доступ предоставлен. {account} может принять секрет в своём хранилище.", + "The certificate could not be verified. Nothing was shared.": "Не удалось проверить сертификат. Ничего не передано.", + "That organisation is not one of your partners.": "Эта организация не входит в число ваших партнёров.", + "No one with that account can receive secrets from you.": "Никто с этой учётной записью не может получать от вас секреты.", + "The other organisation did not answer. Try again later.": "Другая организация не ответила. Повторите попытку позже.", + "This secret is already shared with that account.": "Этот секрет уже передан этой учётной записи.", + "Other organisations": "Другие организации", + "Receive secrets from other organisations": "Получать секреты из других организаций", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тогда сотрудники партнёрских организаций смогут найти вашу учётную запись и делиться с вами секретами. Каждый из них вы принимаете сами.", + "Shared": "Доступ предоставлен", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Приостановлено: изменился их сертификат или партнёрство. Доступ можно отозвать или предоставить заново.", + "Their organisation did not get the last change. Revoke it or share again.": "Их организация не получила последнее изменение. Доступ можно отозвать или предоставить заново.", + "Being withdrawn": "Отзывается", + "Shared with another organisation": "Общий доступ предоставлен другой организации", + "Change sent to another organisation": "Изменение отправлено другой организации", + "Share with another organisation revoked": "Общий доступ для другой организации отозван", + "Share with another organisation paused": "Общий доступ для другой организации приостановлен", + "Another organisation did not get a change": "Другая организация не получила изменение", + "Secret received from another organisation": "Получен секрет из другой организации", + "Secret from another organisation accepted": "Секрет из другой организации принят", + "Secret from another organisation declined": "Секрет из другой организации отклонён", + "Copy from another organisation updated": "Копия из другой организации обновлена", + "Copy from another organisation removed": "Копия из другой организации удалена", + "Declined: they removed their copy. Share again if they need it.": "Отклонено: получатель удалил свою копию. Предоставьте доступ снова, если он нужен.", + "Recipient at another organisation removed their copy": "Получатель из другой организации удалил свою копию", + "Removed the user from %n team folder.": "Пользователь удалён из %n командной папки.", + "Removed the user from %n team folders.": "Пользователь удалён из командных папок: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Пользователь удалён из %n командной папки.","Пользователь удалён из командных папок: %n.","Пользователь удалён из командных папок: %n."], + "A restored copy came from a share that has ended. It stays read-only.": "Восстановленная копия получена из общего доступа, который завершён. Она остаётся только для чтения.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не удалось связаться с организацией, которая поделилась восстановленной копией. Копия остаётся только для чтения и не получает их изменения.", + "Recipient at another organisation restored their copy": "Получатель из другой организации восстановил свою копию" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/ru.json b/l10n/ru.json index b62a19aa0..b7e2463d2 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1181,7 +1181,592 @@ "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.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротация ключа удалила экстренный доступ этого контакта. Назначьте его снова, если он вам ещё нужен.", + "Shared with groups": "Предоставлен доступ группам", + "Not shared with any group yet.": "Пока не предоставлен ни одной группе.", + "Revoke the share with {group}": "Отозвать доступ для {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Предоставлено группе {group}: {received} участников получили, {skipped} нет, так как у них ещё не настроено шифрование.", + "Search groups": "Искать группы", + "Failed to share": "Не удалось предоставить доступ", + "Columns": "Столбцы", + "Column {number}": "Столбец {number}", + "Map one column to Name. Every secret needs a name.": "Сопоставьте один столбец с названием. У каждого секрета должно быть название.", + "Notes": "Заметки", + "Do not import": "Не импортировать", + "Hide this value": "Скрыть это значение", + "Show this value": "Показать это значение", + "Defaults": "По умолчанию", + "New secrets start as this type, and your secret list opens in this view.": "Новые секреты создаются с этим типом, а список секретов открывается в этом виде.", + "Default item type": "Тип элемента по умолчанию", + "Cards": "Карточки", + "Table": "Таблица", + "Could not save your default": "Не удалось сохранить значение по умолчанию", + "Recently used": "Недавно использованные", + "Opened": "Открыто", + "You have not opened any secrets yet": "Вы ещё не открывали секреты", + "Could not delete the item type.": "Не удалось удалить тип элемента.", + "Could not load the item types.": "Не удалось загрузить типы элементов.", + "Could not save the item type.": "Не удалось сохранить тип элемента.", + "Delete item type": "Удалить тип элемента", + "Edit item type": "Изменить тип элемента", + "Fields": "Поля", + "Fields: {count}": "Поля: {count}", + "Hidden": "Скрытое", + "Item types": "Типы элементов", + "Move up": "Вверх", + "New item type": "Новый тип элемента", + "No item types defined yet.": "Типы элементов ещё не определены.", + "Required": "Обязательное", + "Text": "Текст", + "This field is required": "Это поле обязательно", + "Web address": "Веб-адрес", + "{label} (required)": "{label} (обязательно)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Удалить «{name}»? Секреты этого типа останутся читаемыми и станут элементами Логин.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типы элементов, которые вы определите здесь, появятся у всех в окне Новый секрет с выбранными вами полями.", + "Secret moved to the trash": "Секрет перемещён в корзину", + "Secret restored from the trash": "Секрет восстановлен из корзины", + "Secret deleted for good": "Секрет удалён навсегда", + "Secret archived": "Секрет архивирован", + "Secret unarchived": "Секрет извлечён из архива", + "Unarchive": "Извлечь из архива", + "Could not archive the secret": "Не удалось архивировать секрет", + "Could not unarchive the secret": "Не удалось извлечь секрет из архива", + "Archive {count} secrets": "Архивировать секреты: {count}", + "Unarchive {count} secrets": "Извлечь из архива секреты: {count}", + "Restore {count} secrets": "Восстановить секреты: {count}", + "Delete {count} secrets for good": "Удалить навсегда секреты: {count}", + "Done for {ok} of {total} secrets": "Готово для {ok} из {total} секретов", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивированные секреты исчезают из списка хранилища, поиска, автозаполнения и отчёта о состоянии. Общий доступ к ним сохраняется. Их можно найти в Архиве.", + "These secrets come back to the vault list, search and autofill.": "Эти секреты возвращаются в список хранилища, поиск и автозаполнение.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Эти секреты возвращаются в список хранилища. Прежний общий доступ не возвращается, поэтому при необходимости поделитесь ими снова.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Это удалит секреты вместе с вложениями и историей версий. Отменить это нельзя.", + "Delete for good": "Удалить навсегда", + "Trash": "Корзина", + "The trash is empty": "Корзина пуста", + "No archived secrets": "Нет архивированных секретов", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Удалённые секреты ждут здесь до конца срока хранения, после чего удаляются навсегда.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивируйте секрет на его панели сведений, чтобы убрать его из списка хранилища, поиска и автозаполнения.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничения для зашифрованных вложений (применяются на сервере к сохранённым зашифрованным байтам), хранение истории версий и срок, в течение которого удалённые секреты остаются в корзине.", + "Days a deleted secret stays in the trash (1 to 365)": "Сколько дней удалённый секрет остаётся в корзине (от 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Секрет будет перемещён в корзину, а общий доступ к нему сразу прекратится. Его можно восстановить из корзины до конца срока хранения: 30 дней, если администратор не изменил этот срок.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Секреты будут перемещены в корзину ({count}), а общий доступ к ним сразу прекратится. Их можно восстановить из корзины до конца срока хранения.", + "Remove {name} from favourites": "Убрать {name} из избранного", + "Add {name} to favourites": "Добавить {name} в избранное", + "Could not change the favourite": "Не удалось изменить избранное", + "Remove from favourites": "Убрать из избранного", + "Add to favourites": "Добавить в избранное", + "Tags": "Метки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Метки не шифруются. Администраторы сервера могут их прочитать, как и названия папок.", + "Favourites": "Избранное", + "Filter by tag": "Фильтр по метке", + "All tags": "Все метки", + "Last used": "Последнее использование", + "Tags for {count} secrets": "Метки для {count} секретов", + "Tag": "Метка", + "Remove tag": "Убрать метку", + "Add tag": "Добавить метку", + "Could not change the tags. Try again.": "Не удалось изменить метки. Попробуйте ещё раз.", + "Could not approve the application. It is still in the queue.": "Не удалось одобрить заявку. Она всё ещё в очереди.", + "Could not reject the application. It is still in the queue.": "Не удалось отклонить заявку. Она всё ещё в очереди.", + "Removed the user from {count} team folders.": "Пользователь удалён из командных папок: {count}.", + "Approve a share": "Одобрить общий доступ", + "This approval link is incomplete. Open it again from the notification.": "Ссылка для одобрения неполная. Откройте её снова из уведомления.", + "Deny": "Отклонить", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} вступил в группу, с которой вы делитесь секретом. Поделиться секретом и с ним?", + "{requester} asks you to share a secret with {user}.": "{requester} просит вас поделиться секретом с {user}.", + "Shared. The recipient can now open the secret.": "Доступ предоставлен. Теперь получатель может открыть секрет.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Получатель ещё не настроил Keepiq, поэтому доступ не предоставлен. Повторите попытку, когда он это сделает.", + "Could not share the secret. Only its owner can approve this.": "Не удалось поделиться секретом. Одобрить это может только его владелец.", + "Could not share the secret. Try again.": "Не удалось поделиться секретом. Повторите попытку.", + "Denied. Nothing was shared.": "Отклонено. Доступ не предоставлен.", + "Could not deny the request. Try again.": "Не удалось отклонить запрос. Повторите попытку.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просит вас поделиться секретом \"%2$s\" с %3$s.", + "Expires on (optional)": "Истекает (необязательно)", + "Hand over to": "Передать", + "Choose a recipient": "Выберите получателя", + "Hand over temporarily": "Передать временно", + "Expiry rules": "Правила срока действия", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Укажите, сколько могут действовать пароли одного типа элементов или одной папки и когда напоминать. Если применяется несколько дат, учитывается самая ранняя.", + "Delete rule": "Удалить правило", + "Set by your administrator": "Задано администратором", + "No expiry rules yet.": "Правил срока действия пока нет.", + "Applies to": "Применяется к", + "Item type": "Тип элемента", + "Maximum age in days (empty for reminders only)": "Максимальный возраст в днях (пусто, если нужны только напоминания)", + "Remind me this many days before, comma separated": "За сколько дней напомнить, через запятую", + "Save rule": "Сохранить правило", + "An item type": "Тип элемента", + "A folder": "Папка", + "Folder {name}": "Папка {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Истекает через {days} дн.", + "Reminders {days} days before": "Напоминания за {days} дн.", + "Could not save the expiry rule.": "Не удалось сохранить правило срока действия.", + "Could not delete the expiry rule.": "Не удалось удалить правило срока действия.", + "All statuses": "Все статусы", + "Compromised": "Скомпрометирован", + "Could not load the members.": "Не удалось загрузить участников.", + "Emergency contact": "Экстренный контакт", + "Leaving user": "Уходящий пользователь", + "No": "Нет", + "No users match this filter.": "Нет пользователей по этому фильтру.", + "Not set up": "Не настроено", + "Revoke suite": "Отозвать набор", + "Revoked": "Отозван", + "Search users": "Искать пользователей", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Посмотрите, кто из пользователей настроил хранилище. Начните увольнение или отзовите набор из строки.", + "Successor": "Преемник", + "Team folders": "Командные папки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Пользователь всё ещё в группе {groups}, которая входит в командную папку. Удалите его из группы или отключите учётную запись.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись.", + "Vault status": "Статус хранилища", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Экспорт в CXF НЕ ЗАШИФРОВАН. Каждый пароль и логин будут читаемы как обычный текст в скачанном файле. Храните файл в надёжном месте и удалите сразу после использования.", + "Root certificate expiring soon": "Срок действия корневого сертификата скоро истечёт", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Срок действия корневого сертификата хранилища истекает через %1$d дн. Обновите его до этого. При обновлении каждый набор шифрования подписывается заново.", + "Compromise recovery aborted": "Восстановление после компрометации прервано", + "Key rotation ended by a compromise revoke": "Смена ключа завершена отзывом из-за компрометации", + "Encryption suite revoke refused": "Отзыв набора шифрования отклонён", + "Master password proof refused": "Подтверждение мастер-пароля отклонено", + "Your current master password": "Ваш текущий мастер-пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "У %n экстренного контакта был ожидающий запрос доступа, когда смена ключа удалила его. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Эти экстренные контакты не были перенесены на ваш новый ключ. Их экстренный доступ удалён. Добавьте их снова в разделе Экстренный доступ, если они вам ещё нужны.", + "Renew root certificate": "Обновить корневой сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Будут созданы новые корневой и промежуточный сертификаты. Каждый активный набор шифрования будет подписан заново. Это действие нельзя отменить.", + "Renew root": "Обновить корень", + "Root renewed. {n} encryption suites signed again.": "Корень обновлён. Заново подписано наборов шифрования: {n}.", + "Could not renew the root certificate.": "Не удалось обновить корневой сертификат.", + "Lease policy for this application": "Политика аренды для этого приложения", + "In force now: {default} seconds by default, {max} seconds at most.": "Сейчас действует: по умолчанию {default} секунд, не более {max} секунд.", + "Leases are not renewable": "Аренду нельзя продлевать", + "Lease policy saved.": "Политика аренды сохранена.", + "Leave a field empty to use the instance value.": "Оставьте поле пустым, чтобы использовать значение экземпляра.", + "Instance value: {value}": "Значение экземпляра: {value}", + "Renewal": "Продление", + "Use the instance value ({value})": "Использовать значение экземпляра ({value})", + "Allowed": "Разрешено", + "Not allowed": "Не разрешено", + "Save lease policy": "Сохранить политику аренды", + "Only an administrator can change this policy.": "Изменить эту политику может только администратор.", + "Could not save the lease policy.": "Не удалось сохранить политику аренды.", + "{member} got access from {confirmer}.": "{member} получил доступ от {confirmer}.", + "Automatically confirm new team folder members": "Автоматически подтверждать новых участников командных папок", + "Gave %n new member access to a team folder.": "%n новый участник получил доступ к командной папке.", + "Gave %n new members access to a team folder.": "Новые участники (%n) получили доступ к командной папке.", + "Give new team folder members access without waiting for the folder owner.": "Давайте доступ новым участникам, не дожидаясь владельца папки.", + "New team folder members": "Новые участники командных папок", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Владелец или участник с правом записи подтверждает их из открытого хранилища. Keepiq никогда не расшифровывает на сервере.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Ожидание, пока участник с правом записи откроет Keepiq. Можно поделиться и сейчас.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Часть реакции на компрометацию не выполнена ({failed} шаг(ов)). Проверьте журнал сервера, затем снова отзовите набор, чтобы завершить её.", + "This also revoked suite {suite} and ended key migration {migration}.": "Это также отозвало набор {suite} и завершило миграцию ключей {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Отзыв второго набора удалил %n контакт аварийного доступа.", + "Revoking the second suite deleted %n emergency-access contacts.": "Отзыв второго набора удалил %n контактов аварийного доступа.", + "A suite revoked as compromised cannot be reinstated.": "Набор, отозванный как скомпрометированный, нельзя восстановить.", + "Archives to keep": "Сколько архивов хранить", + "Back up every vault automatically": "Автоматически создавать копию каждого хранилища", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Создавайте копию каждого хранилища по расписанию. Архивы содержат только шифротекст и восстанавливаются через occ.", + "Back up now": "Создать копию сейчас", + "Backup public key (PEM, optional)": "Открытый ключ резервной копии (PEM, необязательно)", + "Backup requested for the next cron run": "Копия запрошена на следующий запуск cron", + "Encrypted": "Зашифрован", + "Every (hours)": "Каждые (часов)", + "Last backup {when} failed: {error}": "Последняя копия {when} не удалась: {error}", + "Last backup {when} succeeded.": "Последняя копия {when} создана.", + "No archives yet.": "Архивов пока нет.", + "Size": "Размер", + "Vault backups": "Резервные копии хранилища", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "С ключом каждый архив шифруется для него. Храните закрытый ключ вне этого сервера: он нужен для проверки или восстановления.", + "Written": "Записан", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n пользователь в области действия ещё не настроил двухфакторный вход и не может открыть хранилище, пока это включено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервные коды не учитываются. Если ваши пользователи входят через поставщика удостоверений со своим вторым фактором, исключите их группы.", + "Block personal vault export": "Запретить экспорт личного хранилища", + "Keep work logins in team folders": "Хранить рабочие учётные данные в командных папках", + "Move to a team folder": "Переместить в командную папку", + "Not in a team folder": "Не в командной папке", + "Only for these groups (empty is everyone)": "Только для этих групп (пусто означает всех)", + "Require two-factor login before the vault opens": "Требовать двухфакторный вход перед открытием хранилища", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила для каждого хранилища. Каждое действует для всех или только для выбранных групп.", + "Secret types that belong in a team folder": "Типы секретов, которые хранятся в командной папке", + "Set up two-factor login": "Настроить двухфакторный вход", + "Team folder you can write to": "Командная папка, в которую вы можете записывать", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Пользователи не могут скачать резервную копию, CSV или файл переноса. Их пакет личных данных остаётся доступным.", + "Users cannot save these secret types in a personal folder.": "Пользователи не могут сохранять эти типы секретов в личной папке.", + "Vault policies": "Правила хранилища", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша организация не разрешает экспорт личного хранилища. Ваш пакет личных данных в настройках остаётся доступным.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша организация хранит эти секреты в командной папке. Переместите каждый в командную папку.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша организация хранит этот тип секрета в командной папке. Выберите одну из своих командных папок или ту, в которую вы можете записывать.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша организация требует двухфакторный вход, прежде чем вы сможете открыть хранилище.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Пользователи выбирают, как долго расширение остаётся разблокированным при бездействии. Вы задаёте наибольшее время, которое можно выбрать.", + "Longest idle time before the extension locks": "Наибольшее время бездействия до блокировки расширения", + "1 minute": "1 минута", + "5 minutes": "5 минут", + "15 minutes": "15 минут", + "1 hour": "1 час", + "4 hours": "4 часа", + "Connector": "Коннектор", + "Directory (tenant) ID": "ИД каталога (клиента)", + "Application (client) ID": "ИД приложения (клиента)", + "Data collection rule immutable ID": "Неизменяемый ИД правила сбора данных", + "Stream name": "Имя потока", + "Splunk index (optional)": "Индекс Splunk (необязательно)", + "Sourcetype (optional)": "Sourcetype (необязательно)", + "Leave blank to keep the current one": "Оставьте пустым, чтобы сохранить текущее значение", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF через syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Конечная точка сбора данных (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Секрет клиента (только запись)", + "HEC token (write-only)": "Токен HEC (только запись)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Пересылайте разрешённые события аудита в Splunk, Microsoft Sentinel, приёмник syslog или веб-хук. Сообщения содержат только очищенные метаданные: никакое секретное значение, имя, логин или шифротекст никогда не покидает сервер.", + "%n change waiting to sync": "%n изменение ожидает синхронизации", + "%n changes waiting to sync": "%n изменений ожидают синхронизации", + "Changes that could not sync": "Изменения, которые не удалось синхронизировать", + "Choose a version": "Выбрать версию", + "Copy value": "Скопировать значение", + "Deleted": "Удалено", + "Discard": "Отбросить", + "Keep my offline change": "Оставить моё автономное изменение", + "Keep the server version": "Оставить версию с сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без сети Keepiq доступен только для чтения. Администратор не включил редактирование без сети.", + "Let users edit secrets offline": "Разрешить пользователям редактировать секреты без сети", + "Not synced yet": "Ещё не синхронизировано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Изменения без сети остаются на устройстве, зашифрованные для пользователя, и синхронизируются при следующей разблокировке в сети. Общий доступ, папки и вложения по-прежнему требуют подключения.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без сети. Правки, перемещения и удаления остаются на этом устройстве и синхронизируются, когда вы снова будете в сети. Общий доступ и вложения требуют подключения.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без сети. Ваши изменения остаются на этом устройстве и синхронизируются, когда вы снова будете в сети. Последняя синхронизация {when}.", + "Open my changes": "Открыть мои изменения", + "Sharing needs a connection": "Общий доступ требует подключения", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Кто-то изменил этот секрет на сервере после создания вашей автономной копии. Выберите, какую версию оставить.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизируйте или отбросьте изменения без сети, прежде чем менять ключи.", + "That password did not open your changes.": "Этот пароль не открыл ваши изменения.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Автономный снимок хранит зашифрованные секреты (открываются только ключом, полученным из мастер-пароля пользователя, точно как на сервере) и шифрует названия, URL и названия папок при хранении. Автономный доступ только для чтения, если ниже вы не разрешите редактирование без сети. Отключите это для устройств, которые никогда не должны кэшировать учётные данные; отключение очищает существующие кэши при следующей загрузке.", + "The previous vault copy is gone, so these changes cannot be opened.": "Предыдущей копии хранилища больше нет, поэтому эти изменения нельзя открыть.", + "The server version": "Версия с сервера", + "This secret changed while you were offline": "Этот секрет изменился, пока вы были без сети", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Вы удалили этот секрет без сети, но с тех пор его изменили на сервере. Выберите, какую версию оставить.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваши ключи были изменены на другом устройстве. Введите предыдущий мастер-пароль, чтобы синхронизировать изменения без сети, или отбросьте их.", + "Your offline change": "Ваше автономное изменение", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "У %n экстренного контакта был ожидающий запрос доступа, когда смена ключа удалила его. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять.", + "У экстренных контактов (%n) был ожидающий запрос доступа, когда смена ключа удалила их. Проверьте, кто запрашивал, прежде чем снова кого-либо добавлять." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n элемент нельзя представить в CXF, он будет пропущен.", + "%n элементов нельзя представить в CXF, они будут пропущены.", + "%n элементов нельзя представить в CXF, они будут пропущены." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n более старая версия была отброшена, так как перенести можно только недавнюю историю.", + "%n более старых версий были отброшены, так как перенести можно только недавнюю историю.", + "%n более старых версий были отброшены, так как перенести можно только недавнюю историю." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Ещё требуется зашифровать и предоставить %n копию секрета.", + "Ещё требуется зашифровать и предоставить %n копий секретов.", + "Ещё требуется зашифровать и предоставить %n копий секретов." + ], + "_%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 секрет не удалось расшифровать, и его нет в этом экспорте.", + "%n секретов не удалось расшифровать, и их нет в этом экспорте.", + "%n секретов не удалось расшифровать, и их нет в этом экспорте." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n секрет не удалось расшифровать вашим старым ключом, поэтому он не был перенесён.", + "%n секретов не удалось расшифровать вашим старым ключом, поэтому они не были перенесены.", + "%n секретов не удалось расшифровать вашим старым ключом, поэтому они не были перенесены." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n секрет не был перенесён.", + "%n секретов не были перенесены.", + "%n секретов не были перенесены." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n секрет всё ещё зашифрован вашим предыдущим ключом.", + "%n секретов всё ещё зашифрованы вашим предыдущим ключом.", + "%n секретов всё ещё зашифрованы вашим предыдущим ключом." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n секрет пропущен, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова.", + "%n секретов пропущено, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова.", + "%n секретов пропущено, так как у преемника ещё нет копии — добавьте преемника в папку и запустите снова." + ], + "_%n secret_::_%n secrets_": [ + "%n секрет", + "%n секретов", + "%n секретов" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n пользователь в области действия ещё не настроил двухфакторный вход и не может открыть хранилище, пока это включено.", + "Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено.", + "Пользователи в области действия (%n) ещё не настроили двухфакторный вход и не могут открыть хранилище, пока это включено." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Всё равно завершить, потеряв доступ к %n секрету", + "Всё равно завершить, потеряв доступ к %n секретам", + "Всё равно завершить, потеряв доступ к %n секретам" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n новый участник получил доступ к командной папке.", + "Новые участники (%n) получили доступ к командной папке.", + "Новые участники (%n) получили доступ к командной папке." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ротация ключа завершена. %n секрет был перешифрован вашим новым ключом.", + "Ротация ключа завершена. %n секретов было перешифровано вашим новым ключом.", + "Ротация ключа завершена. %n секретов было перешифровано вашим новым ключом." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Отзыв второго набора удалил %n контакт аварийного доступа.", + "Отзыв второго набора удалил %n контактов аварийного доступа.", + "Отзыв второго набора удалил %n контактов аварийного доступа." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Отзыв этого набора удалил %n контакт аварийного доступа.", + "Отзыв этого набора удалил %n контактов аварийного доступа.", + "Отзыв этого набора удалил %n контактов аварийного доступа." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "встречается в утечках %n раз", + "встречается в утечках %n раз", + "встречается в утечках %n раз" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "доступно %n секрету", + "доступно %n секретам", + "доступно %n секретам" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Эта папка содержит %n секрет напрямую.", + "Эта папка содержит %n секретов напрямую.", + "Эта папка содержит %n секретов напрямую." + ], + "_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._": [ + "Ротация ключа удалила %n экстренный контакт. Проверьте «Экстренный доступ» и добавьте его снова, если он вам ещё нужен.", + "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны.", + "Ротация ключа удалила %n экстренных контактов. Проверьте «Экстренный доступ» и добавьте их снова, если они вам ещё нужны." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n изменение ожидает синхронизации", + "%n изменений ожидают синхронизации", + "%n изменений ожидают синхронизации" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Пользователь всё ещё в группе {groups}, которая входит в командную папку. Удалите его из группы или отключите учётную запись.", + "Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись.", + "Пользователь всё ещё в группах {groups}, которые входят в командные папки. Удалите его из групп или отключите учётную запись." + ], + "Allow approval from another device": "Разрешить подтверждение с другого устройства", + "App": "Приложение", + "Approve a new device": "Подтвердить новое устройство", + "Approve from another device": "Подтвердить с другого устройства", + "Asked at": "Запрошено в", + "Check that the new device shows these words:": "Убедитесь, что новое устройство показывает эти слова:", + "Denied. If you did not ask, end your other sessions:": "Отклонено. Если вы этого не запрашивали, завершите другие сеансы:", + "Device": "Устройство", + "IP address": "IP-адрес", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Позволяет пользователям разблокировать новый браузер, подтвердив его с устройства, на котором Keepiq уже разблокирован.", + "New device approval": "Подтверждение новых устройств", + "Nextcloud security settings": "Настройки безопасности Nextcloud", + "Only approve a device you are using right now.": "Подтверждайте только устройство, которым пользуетесь прямо сейчас.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Откройте Keepiq на устройстве, где он разблокирован, и подтвердите это устройство. Убедитесь, что там показаны те же слова:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Подтверждающее устройство запечатывает ключ разблокировки для нового устройства. Сервер только передаёт его и не может его открыть.", + "The master password is not right, or the request has ended.": "Мастер-пароль неверен, или запрос завершён.", + "The request expired. Ask again or use your master password.": "Срок запроса истёк. Запросите снова или используйте мастер-пароль.", + "The request was denied.": "Запрос отклонён.", + "Too many requests. Try again in an hour or use your master password.": "Слишком много запросов. Повторите через час или используйте мастер-пароль.", + "Unknown device": "Неизвестное устройство", + "Web app": "Веб-приложение", + "A device": "Устройство", + "A new device asks to open your vault": "Новое устройство просит открыть ваше хранилище", + "%s asks to be approved. Only approve a device you are using right now.": "%s просит подтверждения. Подтверждайте только устройство, которым пользуетесь прямо сейчас.", + "Access ends on (optional)": "Доступ заканчивается (необязательно)", + "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.": "Приложения Keepiq не покажут и не скопируют пароль. Человек с техническими навыками всё же может прочитать его на своём устройстве. Смените его, когда доступ закончится.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Этот секрет только для использования. Войдите через расширение браузера Keepiq.", + "Until {date}": "До {date}", + "Use only": "Только использование", + "Use only (can sign in, cannot view or copy)": "Только использование (может войти, не может просмотреть или скопировать)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Вы можете войти с этими учётными данными через расширение браузера Keepiq. Владелец решил не разрешать вам просматривать или копировать их.", + "Your access ends on {date}": "Ваш доступ заканчивается {date}", + "Your access to this secret has ended": "Ваш доступ к этому секрету закончился", + "Your access to \"%s\" ends tomorrow": "Ваш доступ к «%s» заканчивается завтра", + "Your access to \"%s\" has ended": "Ваш доступ к «%s» закончился", + "%1$s no longer has access to \"%2$s\"": "У %1$s больше нет доступа к «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s мог(ла) видеть этот пароль. Смените его, если %1$s больше не должен(на) его знать.", + "%s could not view this password in Keepiq.": "%s не смог(ла) просмотреть этот пароль в Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} из {threshold} подтверждений", + "a recovery officer": "сотрудник по восстановлению", + "Account recovery": "Восстановление учётной записи", + "Approvals needed": "Нужно подтверждений", + "Ask {user} which words they see, by phone or in person. They must be:": "Спросите у {user}, какие слова он видит, по телефону или лично. Они должны быть такими:", + "Check again": "Проверить снова", + "Create the recovery key": "Создать ключ восстановления", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Создайте ключ восстановления. Ваш браузер создаёт его и даёт каждому сотруднику копию, которую может открыть только он.", + "Decline": "Отклонить", + "Enrol in account recovery": "Подключиться к восстановлению учётной записи", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Подключитесь, чтобы организация могла помочь вам вернуть хранилище, если вы забудете мастер-пароль.", + "Every user is enrolled": "Все пользователи подключены", + "Finish the recovery in the browser you asked from.": "Завершите восстановление в браузере, из которого вы его запросили.", + "Forgot your master password?": "Забыли мастер-пароль?", + "Hand the key over": "Передать ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Позвольте пользователям, забывшим мастер-пароль, вернуть хранилище с подтверждением назначенных вами сотрудников по восстановлению.", + "New master password": "Новый мастер-пароль", + "No one is asking to recover their account.": "Никто не просит восстановить учётную запись.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа восстановления пока нет. Один из сотрудников создаёт его в своих настройках Keepiq.", + "Off": "Выключено", + "Officer {user} has no encryption set up yet.": "Сотрудник {user} ещё не настроил шифрование.", + "Officers (user IDs, separated by commas)": "Сотрудники (ID пользователей через запятую)", + "Policy": "Политика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Опубликуйте этот отпечаток внутри организации, чтобы пользователи могли проверить его перед подключением.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Восстановлено с помощью {officer}. Смените ключ хранилища сейчас в Настройки, Безопасность: \"Мой мастер-пароль скомпрометирован\".", + "Recovery key fingerprint: {fingerprint}": "Отпечаток ключа восстановления: {fingerprint}", + "Recovery officer": "Сотрудник по восстановлению", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Удалённые сотрудники теряют свою копию сейчас, но могли открыть её раньше. Попросите сотрудника создать новый ключ восстановления.", + "Repeat the new master password": "Повторите новый мастер-пароль", + "Retire this recovery key": "Вывести этот ключ восстановления из работы", + "Set the new master password": "Задать новый мастер-пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификат восстановления выдан не этим Keepiq. Не подключайтесь и сообщите администратору.", + "The words match, approve": "Слова совпадают, подтвердить", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Этот пользователь подключён к восстановлению учётной записи. Восстановление сохраняет его секреты; отзыв удаляет его подключение.", + "Users may enrol": "Пользователи могут подключаться", + "Withdraw from account recovery": "Отключиться от восстановления учётной записи", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Вы подключены к восстановлению учётной записи. Отпечаток ключа восстановления: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Вы подключены. Если вы забудете мастер-пароль, организация может помочь вам вернуть хранилище.", + "Your key is back. Choose a new master password.": "Ваш ключ возвращён. Выберите новый мастер-пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваши сотрудники по восстановлению уведомлены. Прочитайте им эти слова, когда они позвонят или встретятся с вами:", + "You are now an account recovery officer": "Теперь вы сотрудник по восстановлению учётных записей", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просит восстановить учётную запись. Сверьте с ним слова перед подтверждением.", + "A user": "Пользователь", + "Your account recovery request was declined": "Ваш запрос на восстановление учётной записи отклонён", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Восстановление учётной записи готово. Откройте Keepiq в браузере, из которого вы его запросили.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просит один раз разблокировать новое устройство. Мастер-пароль остаётся прежним.", + "Ask your organisation instead": "Лучше обратиться к своей организации", + "The request ended. Ask again or use your master password.": "Запрос завершён. Отправьте его снова или используйте мастер-пароль.", + "Added by {user}": "Добавлено пользователем {user}", + "Editor": "Редактор", + "Manager": "Менеджер", + "Role of {member}": "Роль {member}", + "Team folders you manage": "Командные папки, которыми вы управляете", + "Viewer": "Читатель", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас нет копии этих секретов, поэтому новые участники их ещё не получили. Владелец может поделиться ими: {names}", + "Admin areas": "Области администрирования", + "Give a group only the parts of Keepiq administration it needs.": "Дайте группе только те части администрирования Keepiq, которые ей нужны.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегируйте одну или несколько областей группе на странице прав администрирования. Администраторы экземпляра имеют все области.", + "Open administration privileges": "Открыть права администрирования", + "Policies": "Политики", + "Applications and machine access": "Приложения и доступ машин", + "People and offboarding": "Люди и увольнение", + "Audit and compliance": "Аудит и соответствие", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версия, центр сертификации, вложения, автономный кэш, проверка утечек, типы секретов и резервные копии", + "master password, organisation password, vault policies, rotation, version history and trash": "мастер-пароль, пароль организации, политики хранилища, ротация, история версий и корзина", + "application queue, application requests and machine leases": "очередь приложений, запросы приложений и аренды машин", + "team offboarding, encryption suites and admin handover": "увольнение из команды, наборы шифрования и передача администратору", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аудита, отчёты о соответствии, экспорт в SIEM и оповещения о приманках", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Сколько версий секрета хранится, как долго и как долго удалённые секреты остаются в корзине.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничения для зашифрованных вложений, применяемые на сервере к хранимым зашифрованным байтам.", + "Type the suite ID again to confirm": "Введите ID набора ещё раз для подтверждения", + "This does not match the suite ID.": "Не совпадает с ID набора.", + "Confirm with your master password": "Подтвердите мастер-паролем", + "Confirm": "Подтвердить", + "That master password is not right.": "Этот мастер-пароль неверен.", + "You are sharing with someone new. Enter your master password to confirm.": "Вы делитесь с новым человеком. Введите мастер-пароль для подтверждения.", + "Enter your master password to confirm this share.": "Введите мастер-пароль, чтобы подтвердить этот общий доступ.", + "Enter your master password to confirm this delegation.": "Введите мастер-пароль, чтобы подтвердить это делегирование.", + "Approve {member}": "Одобрить {member}", + "Recipient": "Получатель", + "No vault yet": "Хранилища пока нет", + "No matching users": "Нет подходящих пользователей", + "Partner organisations": "Партнёрские организации", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обменивайтесь секретами с другим Keepiq. Оба администратора добавляют друг друга и перед сохранением сверяют корневые отпечатки по телефону или лично.", + "Federation needs Nextcloud 33 or later.": "Для федерации нужен Nextcloud 33 или новее.", + "Your root fingerprint": "Ваш корневой отпечаток", + "No partners yet.": "Партнёров пока нет.", + "Users here may share to this partner": "Пользователи здесь могут делиться с этим партнёром", + "This partner may share to users here": "Этот партнёр может делиться с пользователями здесь", + "Partner address": "Адрес партнёра", + "Check partner": "Проверить партнёра", + "Partner root fingerprint": "Корневой отпечаток партнёра", + "I compared this fingerprint with the partner's administrator": "Я сверил этот отпечаток с администратором партнёра", + "Add partner": "Добавить партнёра", + "A secret from another organisation": "Секрет из другой организации", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Пользователь %1$s поделился с вами \"%2$s\". Примите его в разделе Входящие из других организаций.", + "Incoming from other organisations": "Входящие из других организаций", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Сотрудники партнёрских организаций могут поделиться с вами секретом. Примите его, чтобы хранить копию только для чтения в своём хранилище.", + "Nothing shared with you yet": "С вами пока ничем не поделились", + "Secrets that people in partner organisations share with you appear here.": "Здесь появляются секреты, которыми с вами делятся сотрудники партнёрских организаций.", + "From {sender}": "От {sender}", + "Accept": "Принять", + "Open in vault": "Открыть в хранилище", + "The other organisation did not hand over the secret. Try again later.": "Другая организация не передала секрет. Повторите попытку позже.", + "Set up your vault before you accept a shared secret.": "Настройте хранилище, прежде чем принимать общий секрет.", + "Something went wrong. Try again.": "Что-то пошло не так. Повторите попытку.", + "Waiting for your answer": "Ожидает вашего ответа", + "In your vault, read-only": "В вашем хранилище, только для чтения", + "Withdrawn by the sender": "Отозвано отправителем", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Пользователь {sender} поделился этим из другой организации. Вы можете читать его, но не можете изменять или делиться им.", + "Someone": "Кто-то", + "Share with someone at another organisation": "Поделиться с человеком из другой организации", + "Their account at the other organisation": "Учётная запись этого человека в другой организации", + "Check account": "Проверить учётную запись", + "Certificate fingerprint of {account}": "Отпечаток сертификата учётной записи {account}", + "Compare it with them by phone if you want to be sure.": "Сверьте его с этим человеком по телефону, если хотите быть уверены.", + "Shared. {account} can accept it in their own vault.": "Доступ предоставлен. {account} может принять секрет в своём хранилище.", + "The certificate could not be verified. Nothing was shared.": "Не удалось проверить сертификат. Ничего не передано.", + "That organisation is not one of your partners.": "Эта организация не входит в число ваших партнёров.", + "No one with that account can receive secrets from you.": "Никто с этой учётной записью не может получать от вас секреты.", + "The other organisation did not answer. Try again later.": "Другая организация не ответила. Повторите попытку позже.", + "This secret is already shared with that account.": "Этот секрет уже передан этой учётной записи.", + "Other organisations": "Другие организации", + "Receive secrets from other organisations": "Получать секреты из других организаций", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тогда сотрудники партнёрских организаций смогут найти вашу учётную запись и делиться с вами секретами. Каждый из них вы принимаете сами.", + "Shared": "Доступ предоставлен", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Приостановлено: изменился их сертификат или партнёрство. Доступ можно отозвать или предоставить заново.", + "Their organisation did not get the last change. Revoke it or share again.": "Их организация не получила последнее изменение. Доступ можно отозвать или предоставить заново.", + "Being withdrawn": "Отзывается", + "Shared with another organisation": "Общий доступ предоставлен другой организации", + "Change sent to another organisation": "Изменение отправлено другой организации", + "Share with another organisation revoked": "Общий доступ для другой организации отозван", + "Share with another organisation paused": "Общий доступ для другой организации приостановлен", + "Another organisation did not get a change": "Другая организация не получила изменение", + "Secret received from another organisation": "Получен секрет из другой организации", + "Secret from another organisation accepted": "Секрет из другой организации принят", + "Secret from another organisation declined": "Секрет из другой организации отклонён", + "Copy from another organisation updated": "Копия из другой организации обновлена", + "Copy from another organisation removed": "Копия из другой организации удалена", + "Declined: they removed their copy. Share again if they need it.": "Отклонено: получатель удалил свою копию. Предоставьте доступ снова, если он нужен.", + "Recipient at another organisation removed their copy": "Получатель из другой организации удалил свою копию", + "Removed the user from %n team folder.": "Пользователь удалён из %n командной папки.", + "Removed the user from %n team folders.": "Пользователь удалён из командных папок: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Пользователь удалён из %n командной папки.", + "Пользователь удалён из командных папок: %n.", + "Пользователь удалён из командных папок: %n." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Восстановленная копия получена из общего доступа, который завершён. Она остаётся только для чтения.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не удалось связаться с организацией, которая поделилась восстановленной копией. Копия остаётся только для чтения и не получает их изменения.", + "Recipient at another organisation restored their copy": "Получатель из другой организации восстановил свою копию" }, "plurals": null } diff --git a/l10n/sk.js b/l10n/sk.js index ce063adec..b50b055aa 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Zdieľané so skupinami", + "Not shared with any group yet.": "Zatiaľ nezdieľané so žiadnou skupinou.", + "Revoke the share with {group}": "Zrušiť zdieľanie so skupinou {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Zdieľané so skupinou {group}: {received} členov to dostalo, {skipped} nie, pretože ešte nemajú nastavené šifrovanie.", + "Search groups": "Hľadať skupiny", + "Failed to share": "Zdieľanie zlyhalo", + "Columns": "Stĺpce", + "Column {number}": "Stĺpec {number}", + "Map one column to Name. Every secret needs a name.": "Priraďte jeden stĺpec k názvu. Každé tajomstvo potrebuje názov.", + "Notes": "Poznámky", + "Do not import": "Neimportovať", + "Hide this value": "Skryť túto hodnotu", + "Show this value": "Zobraziť túto hodnotu", + "Defaults": "Predvolené", + "New secrets start as this type, and your secret list opens in this view.": "Nové tajomstvá začínajú týmto typom a zoznam tajomstiev sa otvorí v tomto zobrazení.", + "Default item type": "Predvolený typ položky", + "Cards": "Karty", + "Table": "Tabuľka", + "Could not save your default": "Predvolenú hodnotu sa nepodarilo uložiť", + "Recently used": "Nedávno použité", + "Opened": "Otvorené", + "You have not opened any secrets yet": "Zatiaľ ste neotvorili žiadne tajomstvo", + "Could not delete the item type.": "Typ položky sa nepodarilo odstrániť.", + "Could not load the item types.": "Typy položiek sa nepodarilo načítať.", + "Could not save the item type.": "Typ položky sa nepodarilo uložiť.", + "Delete item type": "Odstrániť typ položky", + "Edit item type": "Upraviť typ položky", + "Fields": "Polia", + "Fields: {count}": "Polia: {count}", + "Hidden": "Skryté", + "Item types": "Typy položiek", + "Move up": "Posunúť nahor", + "New item type": "Nový typ položky", + "No item types defined yet.": "Zatiaľ nie sú definované žiadne typy položiek.", + "Required": "Povinné", + "Text": "Text", + "This field is required": "Toto pole je povinné", + "Web address": "Webová adresa", + "{label} (required)": "{label} (povinné)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Odstrániť „{name}“? Tajomstvá tohto typu zostanú čitateľné a stanú sa položkami Prihlásenie.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy položiek, ktoré tu definujete, sa všetkým zobrazia v dialógu Nové tajomstvo so zvolenými poľami.", + "Secret moved to the trash": "Tajomstvo presunuté do koša", + "Secret restored from the trash": "Tajomstvo obnovené z koša", + "Secret deleted for good": "Tajomstvo natrvalo odstránené", + "Secret archived": "Tajomstvo archivované", + "Secret unarchived": "Tajomstvo vrátené z archívu", + "Unarchive": "Vrátiť z archívu", + "Could not archive the secret": "Tajomstvo sa nepodarilo archivovať", + "Could not unarchive the secret": "Tajomstvo sa nepodarilo vrátiť z archívu", + "Archive {count} secrets": "Archivovať tajomstvá: {count}", + "Unarchive {count} secrets": "Vrátiť z archívu tajomstvá: {count}", + "Restore {count} secrets": "Obnoviť tajomstvá: {count}", + "Delete {count} secrets for good": "Natrvalo odstrániť tajomstvá: {count}", + "Done for {ok} of {total} secrets": "Hotovo pre {ok} z {total} tajomstiev", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivované tajomstvá zmiznú zo zoznamu trezoru, z vyhľadávania, automatického vypĺňania a správy o stave. Zostanú zdieľané. Nájdete ich v Archíve.", + "These secrets come back to the vault list, search and autofill.": "Tieto tajomstvá sa vrátia do zoznamu trezoru, do vyhľadávania a automatického vypĺňania.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tieto tajomstvá sa vrátia do zoznamu trezoru. Ich pôvodné zdieľania sa nevrátia, preto ich podľa potreby zdieľajte znova.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Týmto sa odstránia tajomstvá aj s prílohami a históriou verzií. Túto akciu nemožno vrátiť.", + "Delete for good": "Natrvalo odstrániť", + "Trash": "Kôš", + "The trash is empty": "Kôš je prázdny", + "No archived secrets": "Žiadne archivované tajomstvá", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Odstránené tajomstvá tu čakajú do konca doby uchovávania, potom sa natrvalo odstránia.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivujte tajomstvo v jeho paneli podrobností, aby nebolo v zozname trezoru, vo vyhľadávaní ani v automatickom vypĺňaní.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity pre šifrované prílohy (vynucované na serveri v uložených šifrovaných bajtoch), uchovávanie histórie verzií a ako dlho zostávajú odstránené tajomstvá v koši.", + "Days a deleted secret stays in the trash (1 to 365)": "Počet dní, počas ktorých odstránené tajomstvo zostáva v koši (1 až 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Týmto sa tajomstvo presunie do koša a jeho zdieľania hneď skončia. Z koša ho môžete obnoviť do konca doby uchovávania: 30 dní, ak to správca nezmenil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Týmto sa tajomstvá presunú do koša ({count}) a ich zdieľania hneď skončia. Z koša ich môžete obnoviť do konca doby uchovávania.", + "Remove {name} from favourites": "Odobrať {name} z obľúbených", + "Add {name} to favourites": "Pridať {name} do obľúbených", + "Could not change the favourite": "Obľúbenú položku nemožno zmeniť", + "Remove from favourites": "Odobrať z obľúbených", + "Add to favourites": "Pridať do obľúbených", + "Tags": "Štítky", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Štítky nie sú šifrované. Správcovia servera ich môžu čítať, rovnako ako názvy priečinkov.", + "Favourites": "Obľúbené", + "Filter by tag": "Filtrovať podľa štítka", + "All tags": "Všetky štítky", + "Last used": "Naposledy použité", + "Tags for {count} secrets": "Štítky pre {count} tajomstiev", + "Tag": "Štítok", + "Remove tag": "Odobrať štítok", + "Add tag": "Pridať štítok", + "Could not change the tags. Try again.": "Štítky nemožno zmeniť. Skúste to znova.", + "Could not approve the application. It is still in the queue.": "Žiadosť sa nepodarilo schváliť. Stále je vo fronte.", + "Could not reject the application. It is still in the queue.": "Žiadosť sa nepodarilo zamietnuť. Stále je vo fronte.", + "Removed the user from {count} team folders.": "Používateľ bol odstránený z {count} tímových priečinkov.", + "Approve a share": "Schváliť zdieľanie", + "This approval link is incomplete. Open it again from the notification.": "Tento odkaz na schválenie je neúplný. Otvorte ho znova z upozornenia.", + "Deny": "Zamietnuť", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} sa pripojil(a) k skupine, s ktorou zdieľate tajomstvo. Zdieľať tajomstvo aj s touto osobou?", + "{requester} asks you to share a secret with {user}.": "{requester} vás žiada o zdieľanie tajomstva s {user}.", + "Shared. The recipient can now open the secret.": "Zdieľané. Príjemca teraz môže tajomstvo otvoriť.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Príjemca si ešte nenastavil Keepiq, preto sa nič nezdieľalo. Skúste to znova, keď to urobí.", + "Could not share the secret. Only its owner can approve this.": "Tajomstvo sa nepodarilo zdieľať. Schváliť to môže iba jeho vlastník.", + "Could not share the secret. Try again.": "Tajomstvo sa nepodarilo zdieľať. Skúste to znova.", + "Denied. Nothing was shared.": "Zamietnuté. Nič sa nezdieľalo.", + "Could not deny the request. Try again.": "Požiadavku sa nepodarilo zamietnuť. Skúste to znova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vás žiada o zdieľanie tajomstva \"%2$s\" s %3$s.", + "Expires on (optional)": "Platnosť do (voliteľné)", + "Hand over to": "Odovzdať", + "Choose a recipient": "Vyberte príjemcu", + "Hand over temporarily": "Dočasne odovzdať", + "Expiry rules": "Pravidlá vypršania platnosti", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavte, ako dlho môžu platiť heslá jedného typu položky alebo v jednom priečinku a kedy dostanete pripomienku. Ak platí viac dátumov, rozhoduje najskorší.", + "Delete rule": "Odstrániť pravidlo", + "Set by your administrator": "Nastavené vaším správcom", + "No expiry rules yet.": "Zatiaľ žiadne pravidlá vypršania platnosti.", + "Applies to": "Platí pre", + "Item type": "Typ položky", + "Maximum age in days (empty for reminders only)": "Maximálny vek v dňoch (prázdne iba pre pripomienky)", + "Remind me this many days before, comma separated": "Pripomenúť toľko dní vopred, oddeľte čiarkami", + "Save rule": "Uložiť pravidlo", + "An item type": "Typ položky", + "A folder": "Priečinok", + "Folder {name}": "Priečinok {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Vyprší po {days} dňoch", + "Reminders {days} days before": "Pripomienky {days} dní vopred", + "Could not save the expiry rule.": "Pravidlo vypršania platnosti sa nepodarilo uložiť.", + "Could not delete the expiry rule.": "Pravidlo vypršania platnosti sa nepodarilo odstrániť.", + "All statuses": "Všetky stavy", + "Compromised": "Kompromitovaná", + "Could not load the members.": "Členov sa nepodarilo načítať.", + "Emergency contact": "Núdzový kontakt", + "Leaving user": "Odchádzajúci používateľ", + "No": "Nie", + "No users match this filter.": "Tomuto filtru nezodpovedá žiadny používateľ.", + "Not set up": "Nenastavené", + "Revoke suite": "Odvolať sadu", + "Revoked": "Odvolaná", + "Search users": "Hľadať používateľov", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pozrite sa, ktorí používatelia si nastavili trezor. Spustite offboarding alebo odvolajte sadu z riadka.", + "Successor": "Nástupca", + "Team folders": "Tímové priečinky", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Používateľ je stále v skupine {groups}, ktorá je členom tímového priečinka. Odstráňte ho zo skupiny alebo zakážte účet.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet.", + "Vault status": "Stav trezoru", + "Yes": "Áno", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Export do CXF NIE JE ZAŠIFROVANÝ. Každé heslo a prihlasovacie meno bude v stiahnutom súbore čitateľné ako otvorený text. Uložte ho bezpečne a hneď po použití odstráňte.", + "Root certificate expiring soon": "Koreňový certifikát čoskoro vyprší", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Koreňový certifikát trezoru vyprší o %1$d dní. Obnovte ho predtým. Obnovenie znova podpíše každú šifrovaciu sadu.", + "Compromise recovery aborted": "Obnova po kompromitácii prerušená", + "Key rotation ended by a compromise revoke": "Rotácia kľúča ukončená odvolaním pre kompromitáciu", + "Encryption suite revoke refused": "Odvolanie šifrovacej sady zamietnuté", + "Master password proof refused": "Dôkaz hlavného hesla zamietnutý", + "Your current master password": "Vaše súčasné hlavné heslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n núdzový kontakt mal nevybavenú žiadosť o prístup, keď ho rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tieto núdzové kontakty neboli prenesené na váš nový kľúč. Ich núdzový prístup bol odstránený. Ak ich stále chcete, pridajte ich znova v Núdzovom prístupe.", + "Renew root certificate": "Obnoviť koreňový certifikát", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Vytvorí sa nový koreňový a sprostredkujúci certifikát. Každá aktívna šifrovacia sada sa znova podpíše. Túto akciu nemožno vrátiť.", + "Renew root": "Obnoviť koreň", + "Root renewed. {n} encryption suites signed again.": "Koreň obnovený. Znova podpísaných šifrovacích sád: {n}.", + "Could not renew the root certificate.": "Koreňový certifikát sa nepodarilo obnoviť.", + "Lease policy for this application": "Zásady prenájmu pre túto aplikáciu", + "In force now: {default} seconds by default, {max} seconds at most.": "Teraz platí: predvolene {default} sekúnd, najviac {max} sekúnd.", + "Leases are not renewable": "Prenájmy nemožno obnovovať", + "Lease policy saved.": "Zásady prenájmu uložené.", + "Leave a field empty to use the instance value.": "Ponechajte pole prázdne, ak chcete použiť hodnotu inštancie.", + "Instance value: {value}": "Hodnota inštancie: {value}", + "Renewal": "Obnovenie", + "Use the instance value ({value})": "Použiť hodnotu inštancie ({value})", + "Allowed": "Povolené", + "Not allowed": "Nepovolené", + "Save lease policy": "Uložiť zásady prenájmu", + "Only an administrator can change this policy.": "Tieto zásady môže zmeniť iba správca.", + "Could not save the lease policy.": "Zásady prenájmu sa nepodarilo uložiť.", + "{member} got access from {confirmer}.": "{member} získal prístup od {confirmer}.", + "Automatically confirm new team folder members": "Automaticky potvrdzovať nových členov tímových priečinkov", + "Gave %n new member access to a team folder.": "%n nový člen získal prístup k tímovému priečinku.", + "Gave %n new members access to a team folder.": "Noví členovia (%n) získali prístup k tímovému priečinku.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novým členom prístup bez čakania na vlastníka priečinka.", + "New team folder members": "Noví členovia tímových priečinkov", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlastník alebo člen s právom zápisu ich potvrdí zo svojho otvoreného trezoru. Keepiq nikdy nedešifruje na serveri.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čaká sa, kým člen s právom zápisu otvorí Keepiq. Môžete aj zdieľať hneď.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Časť reakcie na kompromitáciu zlyhala ({failed} krok(ov)). Skontrolujte protokol servera a potom sadu znova odvolajte, aby ste ju dokončili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tým sa odvolala aj sada {suite} a ukončila migrácia kľúčov {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Odvolanie druhej sady odstránilo %n kontakt núdzového prístupu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu.", + "A suite revoked as compromised cannot be reinstated.": "Sadu odvolanú ako kompromitovanú nemožno obnoviť.", + "Archives to keep": "Archívy na uchovanie", + "Back up every vault automatically": "Automaticky zálohovať každý trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Zálohujte každý trezor podľa plánu. Archívy obsahujú len šifrovaný text a obnovujú sa cez occ.", + "Back up now": "Zálohovať teraz", + "Backup public key (PEM, optional)": "Verejný kľúč zálohy (PEM, voliteľný)", + "Backup requested for the next cron run": "Záloha vyžiadaná na ďalší beh cronu", + "Encrypted": "Šifrované", + "Every (hours)": "Každých (hodín)", + "Last backup {when} failed: {error}": "Posledná záloha {when} zlyhala: {error}", + "Last backup {when} succeeded.": "Posledná záloha {when} prebehla.", + "No archives yet.": "Zatiaľ žiadne archívy.", + "Size": "Veľkosť", + "Vault backups": "Zálohy trezoru", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S kľúčom sa každý archív šifruje preň. Súkromný kľúč uchovávajte mimo tohto servera: potrebujete ho na overenie alebo obnovenie.", + "Written": "Zapísané", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n používateľ v rozsahu zatiaľ nemá dvojfázové prihlásenie a nemôže otvoriť trezor, kým je toto zapnuté.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Záložné kódy sa nepočítajú. Ak sa vaši používatelia prihlasujú cez poskytovateľa identity s vlastným druhým faktorom, ich skupiny vynechajte.", + "Block personal vault export": "Blokovať export osobného trezoru", + "Keep work logins in team folders": "Uchovávať pracovné prihlasovacie údaje v tímových priečinkoch", + "Move to a team folder": "Presunúť do tímového priečinka", + "Not in a team folder": "Nie je v tímovom priečinku", + "Only for these groups (empty is everyone)": "Len pre tieto skupiny (prázdne znamená všetkých)", + "Require two-factor login before the vault opens": "Vyžadovať dvojfázové prihlásenie pred otvorením trezoru", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravidlá pre každý trezor. Každé platí pre všetkých alebo len pre skupiny, ktoré zvolíte.", + "Secret types that belong in a team folder": "Typy tajomstiev, ktoré patria do tímového priečinka", + "Set up two-factor login": "Nastaviť dvojfázové prihlásenie", + "Team folder you can write to": "Tímový priečinok, do ktorého môžete zapisovať", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Používatelia nemôžu stiahnuť zálohu, CSV ani prenosový súbor. Ich balík osobných údajov zostáva dostupný.", + "Users cannot save these secret types in a personal folder.": "Používatelia nemôžu ukladať tieto typy tajomstiev do osobného priečinka.", + "Vault policies": "Pravidlá trezoru", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizácia nepovoľuje export vášho osobného trezoru. Váš balík osobných údajov v nastaveniach zostáva dostupný.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizácia uchováva tieto tajomstvá v tímovom priečinku. Presuňte každé do tímového priečinka.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizácia uchováva tento typ tajomstva v tímovom priečinku. Vyberte jeden zo svojich tímových priečinkov alebo taký, do ktorého môžete zapisovať.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizácia vyžaduje dvojfázové prihlásenie, kým budete môcť otvoriť svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Používatelia volia, ako dlho zostane rozšírenie pri nečinnosti odomknuté. Vy nastavujete najdlhší čas, ktorý môžu zvoliť.", + "Longest idle time before the extension locks": "Najdlhší čas nečinnosti pred uzamknutím rozšírenia", + "1 minute": "1 minúta", + "5 minutes": "5 minút", + "15 minutes": "15 minút", + "1 hour": "1 hodina", + "4 hours": "4 hodiny", + "Connector": "Konektor", + "Directory (tenant) ID": "ID adresára (nájomníka)", + "Application (client) ID": "ID aplikácie (klienta)", + "Data collection rule immutable ID": "Nemenné ID pravidla zberu údajov", + "Stream name": "Názov streamu", + "Splunk index (optional)": "Index Splunk (voliteľné)", + "Sourcetype (optional)": "Sourcetype (voliteľné)", + "Leave blank to keep the current one": "Ponechajte prázdne, aby zostala súčasná hodnota", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF cez syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Koncový bod zberu údajov (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajný kľúč klienta (iba zápis)", + "HEC token (write-only)": "Token HEC (iba zápis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Preposielajte povolené auditné udalosti do Splunk, Microsoft Sentinel, prijímača syslog alebo webhooku. Správy obsahujú iba očistené metaúdaje: žiadna tajná hodnota, meno, prihlasovacie meno ani šifrovaný text nikdy neopustí server.", + "%n change waiting to sync": "%n zmena čaká na synchronizáciu", + "%n changes waiting to sync": "%n zmien čaká na synchronizáciu", + "Changes that could not sync": "Zmeny, ktoré sa nepodarilo synchronizovať", + "Choose a version": "Vybrať verziu", + "Copy value": "Kopírovať hodnotu", + "Deleted": "Odstránené", + "Discard": "Zahodiť", + "Keep my offline change": "Ponechať moju offline zmenu", + "Keep the server version": "Ponechať verziu zo servera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je offline len na čítanie. Správca nezapol úpravy offline.", + "Let users edit secrets offline": "Povoliť používateľom upravovať tajomstvá offline", + "Not synced yet": "Zatiaľ nesynchronizované", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline zmeny zostávajú v zariadení, šifrované pre používateľa, a synchronizujú sa pri ďalšom odomknutí online. Zdieľanie, priečinky a prílohy stále potrebujú pripojenie.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Úpravy, presuny a odstránenia zostávajú v tomto zariadení a synchronizujú sa, keď budete znova online. Zdieľanie a prílohy potrebujú pripojenie.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vaše zmeny zostávajú v tomto zariadení a synchronizujú sa, keď budete znova online. Naposledy synchronizované {when}.", + "Open my changes": "Otvoriť moje zmeny", + "Sharing needs a connection": "Zdieľanie potrebuje pripojenie", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Niekto zmenil toto tajomstvo na serveri po vytvorení vašej offline kópie. Vyberte, ktorú verziu ponechať.", + "Sync or discard your offline changes before you rotate your keys.": "Pred výmenou kľúčov synchronizujte alebo zahoďte offline zmeny.", + "That password did not open your changes.": "Toto heslo neotvorilo vaše zmeny.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline snímka ukladá šifrované tajomstvá (otvoriť ich možno len kľúčom odvodeným z hlavného hesla používateľa, presne ako na serveri) a šifruje názvy, URL a názvy priečinkov v úložisku. Offline prístup je len na čítanie, pokiaľ nižšie nepovolíte úpravy offline. Vypnite to pre zariadenia, ktoré nikdy nesmú ukladať prihlasovacie údaje; vypnutie vymaže existujúce vyrovnávacie pamäte pri ďalšom načítaní.", + "The previous vault copy is gone, so these changes cannot be opened.": "Predchádzajúca kópia trezoru zmizla, takže tieto zmeny nemožno otvoriť.", + "The server version": "Verzia zo servera", + "This secret changed while you were offline": "Toto tajomstvo sa zmenilo, keď ste boli offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Toto tajomstvo ste offline odstránili, ale medzitým bolo na serveri zmenené. Vyberte, ktorú verziu ponechať.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaše kľúče boli zmenené na inom zariadení. Zadajte predchádzajúce hlavné heslo na synchronizáciu offline zmien, alebo ich zahoďte.", + "Your offline change": "Vaša offline zmena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n núdzový kontakt mal nevybavenú žiadosť o prístup, keď ho rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.","Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.","Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n položku nie je možné vyjadriť vo formáte CXF a bude preskočená.","%n položiek nie je možné vyjadriť vo formáte CXF a budú preskočené.","%n položiek nie je možné vyjadriť vo formáte CXF a budú preskočené."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n staršia verzia bola zahodená, pretože preniesť možno len nedávnu históriu.","%n starších verzií bolo zahodených, pretože preniesť možno len nedávnu históriu.","%n starších verzií bolo zahodených, pretože preniesť možno len nedávnu históriu."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kópiu tajomstva je stále potrebné zašifrovať a nazdieľať.","%n kópií tajomstva je stále potrebné zašifrovať a nazdieľať.","%n kópií tajomstva je stále potrebné zašifrovať a nazdieľať."], + "_%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 tajomstvo sa nepodarilo dešifrovať a nie je v tomto exporte.","%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte.","%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n tajomstvo sa nepodarilo dešifrovať vaším starým kľúčom, takže nebolo migrované.","%n tajomstiev sa nepodarilo dešifrovať vaším starým kľúčom, takže neboli migrované.","%n tajomstiev sa nepodarilo dešifrovať vaším starým kľúčom, takže neboli migrované."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n tajomstvo nebolo migrované.","%n tajomstiev nebolo migrovaných.","%n tajomstiev nebolo migrovaných."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n tajomstvo je stále zašifrované vaším predchádzajúcim kľúčom.","%n tajomstiev je stále zašifrovaných vaším predchádzajúcim kľúčom.","%n tajomstiev je stále zašifrovaných vaším predchádzajúcim kľúčom."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n tajomstvo bolo preskočené, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova.","%n tajomstiev bolo preskočených, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova.","%n tajomstiev bolo preskočených, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova."], + "_%n secret_::_%n secrets_": ["%n tajomstvo","%n tajomstiev","%n tajomstiev"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n používateľ v rozsahu zatiaľ nemá dvojfázové prihlásenie a nemôže otvoriť trezor, kým je toto zapnuté.","Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté.","Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Aj tak dokončiť a stratiť prístup k %n tajomstvu","Aj tak dokončiť a stratiť prístup k %n tajomstvám","Aj tak dokončiť a stratiť prístup k %n tajomstvám"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nový člen získal prístup k tímovému priečinku.","Noví členovia (%n) získali prístup k tímovému priečinku.","Noví členovia (%n) získali prístup k tímovému priečinku."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotácia kľúča dokončená. %n tajomstvo bolo znovu zašifrované vaším novým kľúčom.","Rotácia kľúča dokončená. %n tajomstiev bolo znovu zašifrovaných vaším novým kľúčom.","Rotácia kľúča dokončená. %n tajomstiev bolo znovu zašifrovaných vaším novým kľúčom."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Odvolanie druhej sady odstránilo %n kontakt núdzového prístupu.","Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu.","Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Odvolanie tejto sady odstránilo %n kontakt núdzového prístupu.","Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu.","Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["nájdené v únikoch %n krát","nájdené v únikoch %n krát","nájdené v únikoch %n krát"], + "_shared with %n secret_::_shared with %n secrets_": ["zdieľané s %n tajomstvom","zdieľané s %n tajomstvami","zdieľané s %n tajomstvami"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Tento priečinok obsahuje priamo %n tajomstvo.","Tento priečinok obsahuje priamo %n tajomstiev.","Tento priečinok obsahuje priamo %n tajomstiev."], + "_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._": ["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.","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.","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."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n zmena čaká na synchronizáciu","%n zmien čaká na synchronizáciu","%n zmien čaká na synchronizáciu"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Používateľ je stále v skupine {groups}, ktorá je členom tímového priečinka. Odstráňte ho zo skupiny alebo zakážte účet.","Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet.","Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet."], + "Allow approval from another device": "Povoliť schválenie z iného zariadenia", + "App": "Aplikácia", + "Approve a new device": "Schváliť nové zariadenie", + "Approve from another device": "Schváliť z iného zariadenia", + "Asked at": "Vyžiadané o", + "Check that the new device shows these words:": "Skontrolujte, či nové zariadenie zobrazuje tieto slová:", + "Denied. If you did not ask, end your other sessions:": "Zamietnuté. Ak ste o to nežiadali, ukončite svoje ostatné relácie:", + "Device": "Zariadenie", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Umožniť používateľom odomknúť nový prehliadač jeho schválením zo zariadenia, kde je Keepiq už odomknutý.", + "New device approval": "Schvaľovanie nových zariadení", + "Nextcloud security settings": "Nastavenia zabezpečenia Nextcloud", + "Only approve a device you are using right now.": "Schvaľujte len zariadenie, ktoré práve používate.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorte Keepiq na zariadení, kde je odomknutý, a schváľte toto zariadenie. Skontrolujte, či zobrazuje rovnaké slová:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Schvaľujúce zariadenie zapečatí odomykací kľúč pre nové zariadenie. Server ho len odovzdá ďalej a nemôže ho otvoriť.", + "The master password is not right, or the request has ended.": "Hlavné heslo nie je správne alebo žiadosť skončila.", + "The request expired. Ask again or use your master password.": "Platnosť žiadosti vypršala. Požiadajte znova alebo použite hlavné heslo.", + "The request was denied.": "Žiadosť bola zamietnutá.", + "Too many requests. Try again in an hour or use your master password.": "Príliš veľa žiadostí. Skúste to znova o hodinu alebo použite hlavné heslo.", + "Unknown device": "Neznáme zariadenie", + "Web app": "Webová aplikácia", + "A device": "Zariadenie", + "A new device asks to open your vault": "Nové zariadenie žiada o otvorenie vášho trezoru", + "%s asks to be approved. Only approve a device you are using right now.": "%s žiada o schválenie. Schvaľujte len zariadenie, ktoré práve používate.", + "Access ends on (optional)": "Prístup končí (voliteľné)", + "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.": "Aplikácie Keepiq heslo nezobrazia ani neskopírujú. Niekto s technickými znalosťami ho však stále môže prečítať zo svojho zariadenia. Keď prístup skončí, heslo zmeňte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Toto tajomstvo je len na použitie. Prihláste sa cez rozšírenie prehliadača Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Len použitie", + "Use only (can sign in, cannot view or copy)": "Len použitie (môže sa prihlásiť, nemôže zobraziť ani skopírovať)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S týmto prihlásením sa môžete prihlásiť cez rozšírenie prehliadača Keepiq. Vlastník sa rozhodol, že ho nemôžete zobraziť ani skopírovať.", + "Your access ends on {date}": "Váš prístup končí {date}", + "Your access to this secret has ended": "Váš prístup k tomuto tajomstvu skončil", + "Your access to \"%s\" ends tomorrow": "Váš prístup k „%s“ končí zajtra", + "Your access to \"%s\" has ended": "Váš prístup k „%s“ skončil", + "%1$s no longer has access to \"%2$s\"": "%1$s už nemá prístup k „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mohol(a) vidieť toto heslo. Zmeňte ho, ak by ho %1$s už nemal(a) poznať.", + "%s could not view this password in Keepiq.": "%s nemohol(a) zobraziť toto heslo v Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} schválení", + "a recovery officer": "poverenec pre obnovu", + "Account recovery": "Obnova účtu", + "Approvals needed": "Potrebné schválenia", + "Ask {user} which words they see, by phone or in person. They must be:": "Opýtajte sa používateľa {user}, aké slová vidí, telefonicky alebo osobne. Musia to byť:", + "Check again": "Skontrolovať znova", + "Create the recovery key": "Vytvoriť kľúč na obnovu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Vytvorte kľúč na obnovu. Váš prehliadač ho vytvorí a každému poverencovi dá kópiu, ktorú otvorí len on.", + "Decline": "Odmietnuť", + "Enrol in account recovery": "Prihlásiť sa na obnovu účtu", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prihláste sa, aby vám organizácia mohla pomôcť získať trezor späť, ak zabudnete hlavné heslo.", + "Every user is enrolled": "Všetci používatelia sú prihlásení", + "Finish the recovery in the browser you asked from.": "Dokončite obnovu v prehliadači, z ktorého ste žiadali.", + "Forgot your master password?": "Zabudli ste hlavné heslo?", + "Hand the key over": "Odovzdať kľúč", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Umožnite používateľom, ktorí zabudli hlavné heslo, získať trezor späť so schválením poverencov pre obnovu, ktorých určíte.", + "New master password": "Nové hlavné heslo", + "No one is asking to recover their account.": "Nikto nežiada o obnovu účtu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Kľúč na obnovu zatiaľ neexistuje. Jeden z poverencov ho vytvorí vo svojich nastaveniach Keepiq.", + "Off": "Vypnuté", + "Officer {user} has no encryption set up yet.": "Poverenec {user} ešte nemá nastavené šifrovanie.", + "Officers (user IDs, separated by commas)": "Poverenci (ID používateľov oddelené čiarkami)", + "Policy": "Pravidlá", + "Publish this fingerprint internally, so users can check it before they enrol.": "Zverejnite tento odtlačok interne, aby si ho používatelia mohli overiť pred prihlásením.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnovené s pomocou {officer}. Teraz zmeňte kľúč trezoru v Nastaveniach, Zabezpečenie: \"Moje hlavné heslo bolo prezradené\".", + "Recovery key fingerprint: {fingerprint}": "Odtlačok kľúča na obnovu: {fingerprint}", + "Recovery officer": "Poverenec pre obnovu", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odobraní poverenci teraz prídu o svoju kópiu, ale mohli ju predtým otvoriť. Nechajte poverenca vytvoriť nový kľúč na obnovu.", + "Repeat the new master password": "Zopakujte nové hlavné heslo", + "Retire this recovery key": "Vyradiť tento kľúč na obnovu", + "Set the new master password": "Nastaviť nové hlavné heslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikát na obnovu nevydal tento Keepiq. Neprihlasujte sa a informujte správcu.", + "The words match, approve": "Slová sa zhodujú, schváliť", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tento používateľ je prihlásený na obnovu účtu. Obnova zachová jeho tajomstvá; odvolanie zmaže jeho prihlásenie.", + "Users may enrol": "Používatelia sa môžu prihlásiť", + "Withdraw from account recovery": "Odhlásiť sa z obnovy účtu", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Ste prihlásení na obnovu účtu. Odtlačok kľúča na obnovu: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Ste prihlásení. Ak zabudnete hlavné heslo, organizácia vám môže pomôcť získať trezor späť.", + "Your key is back. Choose a new master password.": "Kľúč je späť. Zvoľte nové hlavné heslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši poverenci pre obnovu boli informovaní. Prečítajte im tieto slová, keď vám zavolajú alebo sa s vami stretnú:", + "You are now an account recovery officer": "Teraz ste poverencom pre obnovu účtov", + "%s asks to recover their account. Compare the words with them before you approve.": "%s žiada o obnovu účtu. Pred schválením s ním porovnajte slová.", + "A user": "Používateľ", + "Your account recovery request was declined": "Vaša žiadosť o obnovu účtu bola zamietnutá", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnova účtu je pripravená. Otvorte Keepiq v prehliadači, z ktorého ste žiadali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} žiada o jednorazové odomknutie nového zariadenia. Hlavné heslo zostáva rovnaké.", + "Ask your organisation instead": "Radšej požiadať svoju organizáciu", + "The request ended. Ask again or use your master password.": "Žiadosť skončila. Požiadajte znova alebo použite hlavné heslo.", + "Added by {user}": "Pridal(a) {user}", + "Editor": "Editor", + "Manager": "Správca", + "Role of {member}": "Rola používateľa {member}", + "Team folders you manage": "Tímové priečinky, ktoré spravujete", + "Viewer": "Čitateľ", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemáte kópiu týchto tajomstiev, takže ich noví členovia zatiaľ nedostali. Vlastník ich môže zdieľať: {names}", + "Admin areas": "Oblasti správy", + "Give a group only the parts of Keepiq administration it needs.": "Dajte skupine len tie časti správy Keepiq, ktoré potrebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegujte jednu alebo viac oblastí na skupinu na stránke oprávnení na správu. Správcovia inštancie majú každú oblasť.", + "Open administration privileges": "Otvoriť oprávnenia na správu", + "Policies": "Zásady", + "Applications and machine access": "Aplikácie a prístup strojov", + "People and offboarding": "Ľudia a odchody", + "Audit and compliance": "Audit a súlad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzia, certifikačná autorita, prílohy, offline vyrovnávacia pamäť, kontrola únikov, typy tajomstiev a zálohy", + "master password, organisation password, vault policies, rotation, version history and trash": "hlavné heslo, heslo organizácie, zásady trezoru, rotácia, história verzií a kôš", + "application queue, application requests and machine leases": "front aplikácií, požiadavky aplikácií a prenájmy strojov", + "team offboarding, encryption suites and admin handover": "odchody z tímu, šifrovacie sady a prevzatie správcom", + "audit log, compliance reports, SIEM export and honey alerts": "auditný denník, správy o súlade, export SIEM a návnadové upozornenia", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koľko verzií tajomstva sa uchováva, ako dlho a ako dlho zostávajú vymazané tajomstvá v koši.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity šifrovaných príloh, vynucované na serveri v uložených šifrovaných bajtoch.", + "Type the suite ID again to confirm": "Na potvrdenie zadajte ID sady znova", + "This does not match the suite ID.": "Nezhoduje sa s ID sady.", + "Confirm with your master password": "Potvrďte hlavným heslom", + "Confirm": "Potvrdiť", + "That master password is not right.": "Toto hlavné heslo nie je správne.", + "You are sharing with someone new. Enter your master password to confirm.": "Zdieľate s novou osobou. Na potvrdenie zadajte hlavné heslo.", + "Enter your master password to confirm this share.": "Na potvrdenie tohto zdieľania zadajte hlavné heslo.", + "Enter your master password to confirm this delegation.": "Na potvrdenie tohto delegovania zadajte hlavné heslo.", + "Approve {member}": "Schváliť {member}", + "Recipient": "Príjemca", + "No vault yet": "Zatiaľ nemá trezor", + "No matching users": "Žiadni zodpovedajúci používatelia", + "Partner organisations": "Partnerské organizácie", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vymieňajte si tajomstvá s iným Keepiq. Obaja správcovia sa navzájom pridajú a pred uložením porovnajú koreňové odtlačky telefonicky alebo osobne.", + "Federation needs Nextcloud 33 or later.": "Federácia vyžaduje Nextcloud 33 alebo novší.", + "Your root fingerprint": "Váš koreňový odtlačok", + "No partners yet.": "Zatiaľ žiadni partneri.", + "Users here may share to this partner": "Používatelia tu môžu zdieľať s týmto partnerom", + "This partner may share to users here": "Tento partner môže zdieľať s používateľmi tu", + "Partner address": "Adresa partnera", + "Check partner": "Overiť partnera", + "Partner root fingerprint": "Koreňový odtlačok partnera", + "I compared this fingerprint with the partner's administrator": "Porovnal som tento odtlačok so správcom partnera", + "Add partner": "Pridať partnera", + "A secret from another organisation": "Tajomstvo z inej organizácie", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Používateľ %1$s s vami zdieľa \"%2$s\". Prijmite ho v sekcii Prichádzajúce z iných organizácií.", + "Incoming from other organisations": "Prichádzajúce z iných organizácií", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Ľudia v partnerských organizáciách s vami môžu zdieľať tajomstvo. Prijmite ho, aby ste si vo svojom trezore ponechali kópiu iba na čítanie.", + "Nothing shared with you yet": "Zatiaľ s vami nebolo nič zdieľané", + "Secrets that people in partner organisations share with you appear here.": "Tu sa zobrazia tajomstvá, ktoré s vami zdieľajú ľudia v partnerských organizáciách.", + "From {sender}": "Od {sender}", + "Accept": "Prijať", + "Open in vault": "Otvoriť v trezore", + "The other organisation did not hand over the secret. Try again later.": "Druhá organizácia tajomstvo neodovzdala. Skúste to neskôr znova.", + "Set up your vault before you accept a shared secret.": "Pred prijatím zdieľaného tajomstva si nastavte trezor.", + "Something went wrong. Try again.": "Niečo sa pokazilo. Skúste to znova.", + "Waiting for your answer": "Čaká na vašu odpoveď", + "In your vault, read-only": "Vo vašom trezore, iba na čítanie", + "Withdrawn by the sender": "Stiahnuté odosielateľom", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} to zdieľa z inej organizácie. Môžete to čítať, ale nie meniť ani zdieľať.", + "Someone": "Niekto", + "Share with someone at another organisation": "Zdieľať s niekým z inej organizácie", + "Their account at the other organisation": "Účet danej osoby v druhej organizácii", + "Check account": "Skontrolovať účet", + "Certificate fingerprint of {account}": "Odtlačok certifikátu účtu {account}", + "Compare it with them by phone if you want to be sure.": "Ak si chcete byť istí, porovnajte ho s danou osobou telefonicky.", + "Shared. {account} can accept it in their own vault.": "Zdieľané. {account} to môže prijať vo svojom vlastnom trezore.", + "The certificate could not be verified. Nothing was shared.": "Certifikát sa nepodarilo overiť. Nič nebolo zdieľané.", + "That organisation is not one of your partners.": "Táto organizácia nie je medzi vašimi partnermi.", + "No one with that account can receive secrets from you.": "Nikto s týmto účtom od vás nemôže prijímať tajomstvá.", + "The other organisation did not answer. Try again later.": "Druhá organizácia neodpovedala. Skúste to neskôr znova.", + "This secret is already shared with that account.": "Toto tajomstvo je s týmto účtom už zdieľané.", + "Other organisations": "Iné organizácie", + "Receive secrets from other organisations": "Prijímať tajomstvá z iných organizácií", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Ľudia v partnerských organizáciách potom môžu nájsť váš účet a zdieľať s vami tajomstvá. Každé z nich prijímate sami.", + "Shared": "Zdieľané", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pozastavené: zmenil sa ich certifikát alebo partnerstvo. Zdieľanie môžete odvolať alebo zdieľať znova.", + "Their organisation did not get the last change. Revoke it or share again.": "Ich organizácia nedostala poslednú zmenu. Zdieľanie môžete odvolať alebo zdieľať znova.", + "Being withdrawn": "Odvoláva sa", + "Shared with another organisation": "Zdieľané s inou organizáciou", + "Change sent to another organisation": "Zmena odoslaná inej organizácii", + "Share with another organisation revoked": "Zdieľanie s inou organizáciou odvolané", + "Share with another organisation paused": "Zdieľanie s inou organizáciou pozastavené", + "Another organisation did not get a change": "Iná organizácia nedostala zmenu", + "Secret received from another organisation": "Tajomstvo prijaté z inej organizácie", + "Secret from another organisation accepted": "Tajomstvo z inej organizácie prijaté", + "Secret from another organisation declined": "Tajomstvo z inej organizácie odmietnuté", + "Copy from another organisation updated": "Kópia z inej organizácie aktualizovaná", + "Copy from another organisation removed": "Kópia z inej organizácie odstránená", + "Declined: they removed their copy. Share again if they need it.": "Odmietnuté: príjemca odstránil svoju kópiu. Ak ju potrebuje, zdieľajte znova.", + "Recipient at another organisation removed their copy": "Príjemca z inej organizácie odstránil svoju kópiu", + "Removed the user from %n team folder.": "Používateľ bol odstránený z %n tímového priečinka.", + "Removed the user from %n team folders.": "Používateľ bol odstránený z %n tímových priečinkov.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Používateľ bol odstránený z %n tímového priečinka.","Používateľ bol odstránený z %n tímových priečinkov.","Používateľ bol odstránený z %n tímových priečinkov."], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovená kópia pochádza zo zdieľania, ktoré skončilo. Zostáva iba na čítanie.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizáciu, ktorá zdieľala obnovenú kópiu, sa nepodarilo zastihnúť. Kópia zostáva iba na čítanie a nesleduje ich zmeny.", + "Recipient at another organisation restored their copy": "Príjemca z inej organizácie obnovil svoju kópiu" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n==1 ? 0 : (n>=2 && n<=4) ? 1 : 2);" ) diff --git a/l10n/sk.json b/l10n/sk.json index 0bd362488..53dc0d739 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1181,7 +1181,592 @@ "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." + "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.", + "Shared with groups": "Zdieľané so skupinami", + "Not shared with any group yet.": "Zatiaľ nezdieľané so žiadnou skupinou.", + "Revoke the share with {group}": "Zrušiť zdieľanie so skupinou {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Zdieľané so skupinou {group}: {received} členov to dostalo, {skipped} nie, pretože ešte nemajú nastavené šifrovanie.", + "Search groups": "Hľadať skupiny", + "Failed to share": "Zdieľanie zlyhalo", + "Columns": "Stĺpce", + "Column {number}": "Stĺpec {number}", + "Map one column to Name. Every secret needs a name.": "Priraďte jeden stĺpec k názvu. Každé tajomstvo potrebuje názov.", + "Notes": "Poznámky", + "Do not import": "Neimportovať", + "Hide this value": "Skryť túto hodnotu", + "Show this value": "Zobraziť túto hodnotu", + "Defaults": "Predvolené", + "New secrets start as this type, and your secret list opens in this view.": "Nové tajomstvá začínajú týmto typom a zoznam tajomstiev sa otvorí v tomto zobrazení.", + "Default item type": "Predvolený typ položky", + "Cards": "Karty", + "Table": "Tabuľka", + "Could not save your default": "Predvolenú hodnotu sa nepodarilo uložiť", + "Recently used": "Nedávno použité", + "Opened": "Otvorené", + "You have not opened any secrets yet": "Zatiaľ ste neotvorili žiadne tajomstvo", + "Could not delete the item type.": "Typ položky sa nepodarilo odstrániť.", + "Could not load the item types.": "Typy položiek sa nepodarilo načítať.", + "Could not save the item type.": "Typ položky sa nepodarilo uložiť.", + "Delete item type": "Odstrániť typ položky", + "Edit item type": "Upraviť typ položky", + "Fields": "Polia", + "Fields: {count}": "Polia: {count}", + "Hidden": "Skryté", + "Item types": "Typy položiek", + "Move up": "Posunúť nahor", + "New item type": "Nový typ položky", + "No item types defined yet.": "Zatiaľ nie sú definované žiadne typy položiek.", + "Required": "Povinné", + "Text": "Text", + "This field is required": "Toto pole je povinné", + "Web address": "Webová adresa", + "{label} (required)": "{label} (povinné)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Odstrániť „{name}“? Tajomstvá tohto typu zostanú čitateľné a stanú sa položkami Prihlásenie.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Typy položiek, ktoré tu definujete, sa všetkým zobrazia v dialógu Nové tajomstvo so zvolenými poľami.", + "Secret moved to the trash": "Tajomstvo presunuté do koša", + "Secret restored from the trash": "Tajomstvo obnovené z koša", + "Secret deleted for good": "Tajomstvo natrvalo odstránené", + "Secret archived": "Tajomstvo archivované", + "Secret unarchived": "Tajomstvo vrátené z archívu", + "Unarchive": "Vrátiť z archívu", + "Could not archive the secret": "Tajomstvo sa nepodarilo archivovať", + "Could not unarchive the secret": "Tajomstvo sa nepodarilo vrátiť z archívu", + "Archive {count} secrets": "Archivovať tajomstvá: {count}", + "Unarchive {count} secrets": "Vrátiť z archívu tajomstvá: {count}", + "Restore {count} secrets": "Obnoviť tajomstvá: {count}", + "Delete {count} secrets for good": "Natrvalo odstrániť tajomstvá: {count}", + "Done for {ok} of {total} secrets": "Hotovo pre {ok} z {total} tajomstiev", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Archivované tajomstvá zmiznú zo zoznamu trezoru, z vyhľadávania, automatického vypĺňania a správy o stave. Zostanú zdieľané. Nájdete ich v Archíve.", + "These secrets come back to the vault list, search and autofill.": "Tieto tajomstvá sa vrátia do zoznamu trezoru, do vyhľadávania a automatického vypĺňania.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Tieto tajomstvá sa vrátia do zoznamu trezoru. Ich pôvodné zdieľania sa nevrátia, preto ich podľa potreby zdieľajte znova.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Týmto sa odstránia tajomstvá aj s prílohami a históriou verzií. Túto akciu nemožno vrátiť.", + "Delete for good": "Natrvalo odstrániť", + "Trash": "Kôš", + "The trash is empty": "Kôš je prázdny", + "No archived secrets": "Žiadne archivované tajomstvá", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Odstránené tajomstvá tu čakajú do konca doby uchovávania, potom sa natrvalo odstránia.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Archivujte tajomstvo v jeho paneli podrobností, aby nebolo v zozname trezoru, vo vyhľadávaní ani v automatickom vypĺňaní.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Limity pre šifrované prílohy (vynucované na serveri v uložených šifrovaných bajtoch), uchovávanie histórie verzií a ako dlho zostávajú odstránené tajomstvá v koši.", + "Days a deleted secret stays in the trash (1 to 365)": "Počet dní, počas ktorých odstránené tajomstvo zostáva v koši (1 až 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Týmto sa tajomstvo presunie do koša a jeho zdieľania hneď skončia. Z koša ho môžete obnoviť do konca doby uchovávania: 30 dní, ak to správca nezmenil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Týmto sa tajomstvá presunú do koša ({count}) a ich zdieľania hneď skončia. Z koša ich môžete obnoviť do konca doby uchovávania.", + "Remove {name} from favourites": "Odobrať {name} z obľúbených", + "Add {name} to favourites": "Pridať {name} do obľúbených", + "Could not change the favourite": "Obľúbenú položku nemožno zmeniť", + "Remove from favourites": "Odobrať z obľúbených", + "Add to favourites": "Pridať do obľúbených", + "Tags": "Štítky", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Štítky nie sú šifrované. Správcovia servera ich môžu čítať, rovnako ako názvy priečinkov.", + "Favourites": "Obľúbené", + "Filter by tag": "Filtrovať podľa štítka", + "All tags": "Všetky štítky", + "Last used": "Naposledy použité", + "Tags for {count} secrets": "Štítky pre {count} tajomstiev", + "Tag": "Štítok", + "Remove tag": "Odobrať štítok", + "Add tag": "Pridať štítok", + "Could not change the tags. Try again.": "Štítky nemožno zmeniť. Skúste to znova.", + "Could not approve the application. It is still in the queue.": "Žiadosť sa nepodarilo schváliť. Stále je vo fronte.", + "Could not reject the application. It is still in the queue.": "Žiadosť sa nepodarilo zamietnuť. Stále je vo fronte.", + "Removed the user from {count} team folders.": "Používateľ bol odstránený z {count} tímových priečinkov.", + "Approve a share": "Schváliť zdieľanie", + "This approval link is incomplete. Open it again from the notification.": "Tento odkaz na schválenie je neúplný. Otvorte ho znova z upozornenia.", + "Deny": "Zamietnuť", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} sa pripojil(a) k skupine, s ktorou zdieľate tajomstvo. Zdieľať tajomstvo aj s touto osobou?", + "{requester} asks you to share a secret with {user}.": "{requester} vás žiada o zdieľanie tajomstva s {user}.", + "Shared. The recipient can now open the secret.": "Zdieľané. Príjemca teraz môže tajomstvo otvoriť.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Príjemca si ešte nenastavil Keepiq, preto sa nič nezdieľalo. Skúste to znova, keď to urobí.", + "Could not share the secret. Only its owner can approve this.": "Tajomstvo sa nepodarilo zdieľať. Schváliť to môže iba jeho vlastník.", + "Could not share the secret. Try again.": "Tajomstvo sa nepodarilo zdieľať. Skúste to znova.", + "Denied. Nothing was shared.": "Zamietnuté. Nič sa nezdieľalo.", + "Could not deny the request. Try again.": "Požiadavku sa nepodarilo zamietnuť. Skúste to znova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vás žiada o zdieľanie tajomstva \"%2$s\" s %3$s.", + "Expires on (optional)": "Platnosť do (voliteľné)", + "Hand over to": "Odovzdať", + "Choose a recipient": "Vyberte príjemcu", + "Hand over temporarily": "Dočasne odovzdať", + "Expiry rules": "Pravidlá vypršania platnosti", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavte, ako dlho môžu platiť heslá jedného typu položky alebo v jednom priečinku a kedy dostanete pripomienku. Ak platí viac dátumov, rozhoduje najskorší.", + "Delete rule": "Odstrániť pravidlo", + "Set by your administrator": "Nastavené vaším správcom", + "No expiry rules yet.": "Zatiaľ žiadne pravidlá vypršania platnosti.", + "Applies to": "Platí pre", + "Item type": "Typ položky", + "Maximum age in days (empty for reminders only)": "Maximálny vek v dňoch (prázdne iba pre pripomienky)", + "Remind me this many days before, comma separated": "Pripomenúť toľko dní vopred, oddeľte čiarkami", + "Save rule": "Uložiť pravidlo", + "An item type": "Typ položky", + "A folder": "Priečinok", + "Folder {name}": "Priečinok {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Vyprší po {days} dňoch", + "Reminders {days} days before": "Pripomienky {days} dní vopred", + "Could not save the expiry rule.": "Pravidlo vypršania platnosti sa nepodarilo uložiť.", + "Could not delete the expiry rule.": "Pravidlo vypršania platnosti sa nepodarilo odstrániť.", + "All statuses": "Všetky stavy", + "Compromised": "Kompromitovaná", + "Could not load the members.": "Členov sa nepodarilo načítať.", + "Emergency contact": "Núdzový kontakt", + "Leaving user": "Odchádzajúci používateľ", + "No": "Nie", + "No users match this filter.": "Tomuto filtru nezodpovedá žiadny používateľ.", + "Not set up": "Nenastavené", + "Revoke suite": "Odvolať sadu", + "Revoked": "Odvolaná", + "Search users": "Hľadať používateľov", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Pozrite sa, ktorí používatelia si nastavili trezor. Spustite offboarding alebo odvolajte sadu z riadka.", + "Successor": "Nástupca", + "Team folders": "Tímové priečinky", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Používateľ je stále v skupine {groups}, ktorá je členom tímového priečinka. Odstráňte ho zo skupiny alebo zakážte účet.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet.", + "Vault status": "Stav trezoru", + "Yes": "Áno", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Export do CXF NIE JE ZAŠIFROVANÝ. Každé heslo a prihlasovacie meno bude v stiahnutom súbore čitateľné ako otvorený text. Uložte ho bezpečne a hneď po použití odstráňte.", + "Root certificate expiring soon": "Koreňový certifikát čoskoro vyprší", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Koreňový certifikát trezoru vyprší o %1$d dní. Obnovte ho predtým. Obnovenie znova podpíše každú šifrovaciu sadu.", + "Compromise recovery aborted": "Obnova po kompromitácii prerušená", + "Key rotation ended by a compromise revoke": "Rotácia kľúča ukončená odvolaním pre kompromitáciu", + "Encryption suite revoke refused": "Odvolanie šifrovacej sady zamietnuté", + "Master password proof refused": "Dôkaz hlavného hesla zamietnutý", + "Your current master password": "Vaše súčasné hlavné heslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n núdzový kontakt mal nevybavenú žiadosť o prístup, keď ho rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Tieto núdzové kontakty neboli prenesené na váš nový kľúč. Ich núdzový prístup bol odstránený. Ak ich stále chcete, pridajte ich znova v Núdzovom prístupe.", + "Renew root certificate": "Obnoviť koreňový certifikát", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Vytvorí sa nový koreňový a sprostredkujúci certifikát. Každá aktívna šifrovacia sada sa znova podpíše. Túto akciu nemožno vrátiť.", + "Renew root": "Obnoviť koreň", + "Root renewed. {n} encryption suites signed again.": "Koreň obnovený. Znova podpísaných šifrovacích sád: {n}.", + "Could not renew the root certificate.": "Koreňový certifikát sa nepodarilo obnoviť.", + "Lease policy for this application": "Zásady prenájmu pre túto aplikáciu", + "In force now: {default} seconds by default, {max} seconds at most.": "Teraz platí: predvolene {default} sekúnd, najviac {max} sekúnd.", + "Leases are not renewable": "Prenájmy nemožno obnovovať", + "Lease policy saved.": "Zásady prenájmu uložené.", + "Leave a field empty to use the instance value.": "Ponechajte pole prázdne, ak chcete použiť hodnotu inštancie.", + "Instance value: {value}": "Hodnota inštancie: {value}", + "Renewal": "Obnovenie", + "Use the instance value ({value})": "Použiť hodnotu inštancie ({value})", + "Allowed": "Povolené", + "Not allowed": "Nepovolené", + "Save lease policy": "Uložiť zásady prenájmu", + "Only an administrator can change this policy.": "Tieto zásady môže zmeniť iba správca.", + "Could not save the lease policy.": "Zásady prenájmu sa nepodarilo uložiť.", + "{member} got access from {confirmer}.": "{member} získal prístup od {confirmer}.", + "Automatically confirm new team folder members": "Automaticky potvrdzovať nových členov tímových priečinkov", + "Gave %n new member access to a team folder.": "%n nový člen získal prístup k tímovému priečinku.", + "Gave %n new members access to a team folder.": "Noví členovia (%n) získali prístup k tímovému priečinku.", + "Give new team folder members access without waiting for the folder owner.": "Dajte novým členom prístup bez čakania na vlastníka priečinka.", + "New team folder members": "Noví členovia tímových priečinkov", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Vlastník alebo člen s právom zápisu ich potvrdí zo svojho otvoreného trezoru. Keepiq nikdy nedešifruje na serveri.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čaká sa, kým člen s právom zápisu otvorí Keepiq. Môžete aj zdieľať hneď.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Časť reakcie na kompromitáciu zlyhala ({failed} krok(ov)). Skontrolujte protokol servera a potom sadu znova odvolajte, aby ste ju dokončili.", + "This also revoked suite {suite} and ended key migration {migration}.": "Tým sa odvolala aj sada {suite} a ukončila migrácia kľúčov {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Odvolanie druhej sady odstránilo %n kontakt núdzového prístupu.", + "Revoking the second suite deleted %n emergency-access contacts.": "Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu.", + "A suite revoked as compromised cannot be reinstated.": "Sadu odvolanú ako kompromitovanú nemožno obnoviť.", + "Archives to keep": "Archívy na uchovanie", + "Back up every vault automatically": "Automaticky zálohovať každý trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Zálohujte každý trezor podľa plánu. Archívy obsahujú len šifrovaný text a obnovujú sa cez occ.", + "Back up now": "Zálohovať teraz", + "Backup public key (PEM, optional)": "Verejný kľúč zálohy (PEM, voliteľný)", + "Backup requested for the next cron run": "Záloha vyžiadaná na ďalší beh cronu", + "Encrypted": "Šifrované", + "Every (hours)": "Každých (hodín)", + "Last backup {when} failed: {error}": "Posledná záloha {when} zlyhala: {error}", + "Last backup {when} succeeded.": "Posledná záloha {when} prebehla.", + "No archives yet.": "Zatiaľ žiadne archívy.", + "Size": "Veľkosť", + "Vault backups": "Zálohy trezoru", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S kľúčom sa každý archív šifruje preň. Súkromný kľúč uchovávajte mimo tohto servera: potrebujete ho na overenie alebo obnovenie.", + "Written": "Zapísané", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n používateľ v rozsahu zatiaľ nemá dvojfázové prihlásenie a nemôže otvoriť trezor, kým je toto zapnuté.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Záložné kódy sa nepočítajú. Ak sa vaši používatelia prihlasujú cez poskytovateľa identity s vlastným druhým faktorom, ich skupiny vynechajte.", + "Block personal vault export": "Blokovať export osobného trezoru", + "Keep work logins in team folders": "Uchovávať pracovné prihlasovacie údaje v tímových priečinkoch", + "Move to a team folder": "Presunúť do tímového priečinka", + "Not in a team folder": "Nie je v tímovom priečinku", + "Only for these groups (empty is everyone)": "Len pre tieto skupiny (prázdne znamená všetkých)", + "Require two-factor login before the vault opens": "Vyžadovať dvojfázové prihlásenie pred otvorením trezoru", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravidlá pre každý trezor. Každé platí pre všetkých alebo len pre skupiny, ktoré zvolíte.", + "Secret types that belong in a team folder": "Typy tajomstiev, ktoré patria do tímového priečinka", + "Set up two-factor login": "Nastaviť dvojfázové prihlásenie", + "Team folder you can write to": "Tímový priečinok, do ktorého môžete zapisovať", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Používatelia nemôžu stiahnuť zálohu, CSV ani prenosový súbor. Ich balík osobných údajov zostáva dostupný.", + "Users cannot save these secret types in a personal folder.": "Používatelia nemôžu ukladať tieto typy tajomstiev do osobného priečinka.", + "Vault policies": "Pravidlá trezoru", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizácia nepovoľuje export vášho osobného trezoru. Váš balík osobných údajov v nastaveniach zostáva dostupný.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizácia uchováva tieto tajomstvá v tímovom priečinku. Presuňte každé do tímového priečinka.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizácia uchováva tento typ tajomstva v tímovom priečinku. Vyberte jeden zo svojich tímových priečinkov alebo taký, do ktorého môžete zapisovať.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizácia vyžaduje dvojfázové prihlásenie, kým budete môcť otvoriť svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Používatelia volia, ako dlho zostane rozšírenie pri nečinnosti odomknuté. Vy nastavujete najdlhší čas, ktorý môžu zvoliť.", + "Longest idle time before the extension locks": "Najdlhší čas nečinnosti pred uzamknutím rozšírenia", + "1 minute": "1 minúta", + "5 minutes": "5 minút", + "15 minutes": "15 minút", + "1 hour": "1 hodina", + "4 hours": "4 hodiny", + "Connector": "Konektor", + "Directory (tenant) ID": "ID adresára (nájomníka)", + "Application (client) ID": "ID aplikácie (klienta)", + "Data collection rule immutable ID": "Nemenné ID pravidla zberu údajov", + "Stream name": "Názov streamu", + "Splunk index (optional)": "Index Splunk (voliteľné)", + "Sourcetype (optional)": "Sourcetype (voliteľné)", + "Leave blank to keep the current one": "Ponechajte prázdne, aby zostala súčasná hodnota", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF cez syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Koncový bod zberu údajov (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectora (https)", + "Client secret (write-only)": "Tajný kľúč klienta (iba zápis)", + "HEC token (write-only)": "Token HEC (iba zápis)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Preposielajte povolené auditné udalosti do Splunk, Microsoft Sentinel, prijímača syslog alebo webhooku. Správy obsahujú iba očistené metaúdaje: žiadna tajná hodnota, meno, prihlasovacie meno ani šifrovaný text nikdy neopustí server.", + "%n change waiting to sync": "%n zmena čaká na synchronizáciu", + "%n changes waiting to sync": "%n zmien čaká na synchronizáciu", + "Changes that could not sync": "Zmeny, ktoré sa nepodarilo synchronizovať", + "Choose a version": "Vybrať verziu", + "Copy value": "Kopírovať hodnotu", + "Deleted": "Odstránené", + "Discard": "Zahodiť", + "Keep my offline change": "Ponechať moju offline zmenu", + "Keep the server version": "Ponechať verziu zo servera", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je offline len na čítanie. Správca nezapol úpravy offline.", + "Let users edit secrets offline": "Povoliť používateľom upravovať tajomstvá offline", + "Not synced yet": "Zatiaľ nesynchronizované", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offline zmeny zostávajú v zariadení, šifrované pre používateľa, a synchronizujú sa pri ďalšom odomknutí online. Zdieľanie, priečinky a prílohy stále potrebujú pripojenie.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Úpravy, presuny a odstránenia zostávajú v tomto zariadení a synchronizujú sa, keď budete znova online. Zdieľanie a prílohy potrebujú pripojenie.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Vaše zmeny zostávajú v tomto zariadení a synchronizujú sa, keď budete znova online. Naposledy synchronizované {when}.", + "Open my changes": "Otvoriť moje zmeny", + "Sharing needs a connection": "Zdieľanie potrebuje pripojenie", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Niekto zmenil toto tajomstvo na serveri po vytvorení vašej offline kópie. Vyberte, ktorú verziu ponechať.", + "Sync or discard your offline changes before you rotate your keys.": "Pred výmenou kľúčov synchronizujte alebo zahoďte offline zmeny.", + "That password did not open your changes.": "Toto heslo neotvorilo vaše zmeny.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offline snímka ukladá šifrované tajomstvá (otvoriť ich možno len kľúčom odvodeným z hlavného hesla používateľa, presne ako na serveri) a šifruje názvy, URL a názvy priečinkov v úložisku. Offline prístup je len na čítanie, pokiaľ nižšie nepovolíte úpravy offline. Vypnite to pre zariadenia, ktoré nikdy nesmú ukladať prihlasovacie údaje; vypnutie vymaže existujúce vyrovnávacie pamäte pri ďalšom načítaní.", + "The previous vault copy is gone, so these changes cannot be opened.": "Predchádzajúca kópia trezoru zmizla, takže tieto zmeny nemožno otvoriť.", + "The server version": "Verzia zo servera", + "This secret changed while you were offline": "Toto tajomstvo sa zmenilo, keď ste boli offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Toto tajomstvo ste offline odstránili, ale medzitým bolo na serveri zmenené. Vyberte, ktorú verziu ponechať.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaše kľúče boli zmenené na inom zariadení. Zadajte predchádzajúce hlavné heslo na synchronizáciu offline zmien, alebo ich zahoďte.", + "Your offline change": "Vaša offline zmena", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n núdzový kontakt mal nevybavenú žiadosť o prístup, keď ho rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal.", + "Núdzové kontakty (%n) mali nevybavenú žiadosť o prístup, keď ich rotácia kľúča odstránila. Skôr ako niekoho znova pridáte, overte, kto žiadal." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n položku nie je možné vyjadriť vo formáte CXF a bude preskočená.", + "%n položiek nie je možné vyjadriť vo formáte CXF a budú preskočené.", + "%n položiek nie je možné vyjadriť vo formáte CXF a budú preskočené." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n staršia verzia bola zahodená, pretože preniesť možno len nedávnu históriu.", + "%n starších verzií bolo zahodených, pretože preniesť možno len nedávnu históriu.", + "%n starších verzií bolo zahodených, pretože preniesť možno len nedávnu históriu." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kópiu tajomstva je stále potrebné zašifrovať a nazdieľať.", + "%n kópií tajomstva je stále potrebné zašifrovať a nazdieľať.", + "%n kópií tajomstva je stále potrebné zašifrovať a nazdieľať." + ], + "_%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 tajomstvo sa nepodarilo dešifrovať a nie je v tomto exporte.", + "%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte.", + "%n tajomstiev sa nepodarilo dešifrovať a nie sú v tomto exporte." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n tajomstvo sa nepodarilo dešifrovať vaším starým kľúčom, takže nebolo migrované.", + "%n tajomstiev sa nepodarilo dešifrovať vaším starým kľúčom, takže neboli migrované.", + "%n tajomstiev sa nepodarilo dešifrovať vaším starým kľúčom, takže neboli migrované." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n tajomstvo nebolo migrované.", + "%n tajomstiev nebolo migrovaných.", + "%n tajomstiev nebolo migrovaných." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n tajomstvo je stále zašifrované vaším predchádzajúcim kľúčom.", + "%n tajomstiev je stále zašifrovaných vaším predchádzajúcim kľúčom.", + "%n tajomstiev je stále zašifrovaných vaším predchádzajúcim kľúčom." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n tajomstvo bolo preskočené, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova.", + "%n tajomstiev bolo preskočených, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova.", + "%n tajomstiev bolo preskočených, pretože nástupca ešte nemá kópiu — pridajte nástupcu do zložky a spustite akciu znova." + ], + "_%n secret_::_%n secrets_": [ + "%n tajomstvo", + "%n tajomstiev", + "%n tajomstiev" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n používateľ v rozsahu zatiaľ nemá dvojfázové prihlásenie a nemôže otvoriť trezor, kým je toto zapnuté.", + "Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté.", + "Používatelia v rozsahu (%n) zatiaľ nemajú dvojfázové prihlásenie a nemôžu otvoriť trezor, kým je toto zapnuté." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Aj tak dokončiť a stratiť prístup k %n tajomstvu", + "Aj tak dokončiť a stratiť prístup k %n tajomstvám", + "Aj tak dokončiť a stratiť prístup k %n tajomstvám" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nový člen získal prístup k tímovému priečinku.", + "Noví členovia (%n) získali prístup k tímovému priečinku.", + "Noví členovia (%n) získali prístup k tímovému priečinku." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotácia kľúča dokončená. %n tajomstvo bolo znovu zašifrované vaším novým kľúčom.", + "Rotácia kľúča dokončená. %n tajomstiev bolo znovu zašifrovaných vaším novým kľúčom.", + "Rotácia kľúča dokončená. %n tajomstiev bolo znovu zašifrovaných vaším novým kľúčom." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Odvolanie druhej sady odstránilo %n kontakt núdzového prístupu.", + "Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu.", + "Odvolanie druhej sady odstránilo %n kontaktov núdzového prístupu." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Odvolanie tejto sady odstránilo %n kontakt núdzového prístupu.", + "Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu.", + "Odvolanie tejto sady odstránilo %n kontaktov núdzového prístupu." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "nájdené v únikoch %n krát", + "nájdené v únikoch %n krát", + "nájdené v únikoch %n krát" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "zdieľané s %n tajomstvom", + "zdieľané s %n tajomstvami", + "zdieľané s %n tajomstvami" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Tento priečinok obsahuje priamo %n tajomstvo.", + "Tento priečinok obsahuje priamo %n tajomstiev.", + "Tento priečinok obsahuje priamo %n tajomstiev." + ], + "_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._": [ + "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.", + "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.", + "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." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n zmena čaká na synchronizáciu", + "%n zmien čaká na synchronizáciu", + "%n zmien čaká na synchronizáciu" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Používateľ je stále v skupine {groups}, ktorá je členom tímového priečinka. Odstráňte ho zo skupiny alebo zakážte účet.", + "Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet.", + "Používateľ je stále v skupinách {groups}, ktoré sú členmi tímových priečinkov. Odstráňte ho zo skupín alebo zakážte účet." + ], + "Allow approval from another device": "Povoliť schválenie z iného zariadenia", + "App": "Aplikácia", + "Approve a new device": "Schváliť nové zariadenie", + "Approve from another device": "Schváliť z iného zariadenia", + "Asked at": "Vyžiadané o", + "Check that the new device shows these words:": "Skontrolujte, či nové zariadenie zobrazuje tieto slová:", + "Denied. If you did not ask, end your other sessions:": "Zamietnuté. Ak ste o to nežiadali, ukončite svoje ostatné relácie:", + "Device": "Zariadenie", + "IP address": "IP adresa", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Umožniť používateľom odomknúť nový prehliadač jeho schválením zo zariadenia, kde je Keepiq už odomknutý.", + "New device approval": "Schvaľovanie nových zariadení", + "Nextcloud security settings": "Nastavenia zabezpečenia Nextcloud", + "Only approve a device you are using right now.": "Schvaľujte len zariadenie, ktoré práve používate.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Otvorte Keepiq na zariadení, kde je odomknutý, a schváľte toto zariadenie. Skontrolujte, či zobrazuje rovnaké slová:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Schvaľujúce zariadenie zapečatí odomykací kľúč pre nové zariadenie. Server ho len odovzdá ďalej a nemôže ho otvoriť.", + "The master password is not right, or the request has ended.": "Hlavné heslo nie je správne alebo žiadosť skončila.", + "The request expired. Ask again or use your master password.": "Platnosť žiadosti vypršala. Požiadajte znova alebo použite hlavné heslo.", + "The request was denied.": "Žiadosť bola zamietnutá.", + "Too many requests. Try again in an hour or use your master password.": "Príliš veľa žiadostí. Skúste to znova o hodinu alebo použite hlavné heslo.", + "Unknown device": "Neznáme zariadenie", + "Web app": "Webová aplikácia", + "A device": "Zariadenie", + "A new device asks to open your vault": "Nové zariadenie žiada o otvorenie vášho trezoru", + "%s asks to be approved. Only approve a device you are using right now.": "%s žiada o schválenie. Schvaľujte len zariadenie, ktoré práve používate.", + "Access ends on (optional)": "Prístup končí (voliteľné)", + "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.": "Aplikácie Keepiq heslo nezobrazia ani neskopírujú. Niekto s technickými znalosťami ho však stále môže prečítať zo svojho zariadenia. Keď prístup skončí, heslo zmeňte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Toto tajomstvo je len na použitie. Prihláste sa cez rozšírenie prehliadača Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Len použitie", + "Use only (can sign in, cannot view or copy)": "Len použitie (môže sa prihlásiť, nemôže zobraziť ani skopírovať)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S týmto prihlásením sa môžete prihlásiť cez rozšírenie prehliadača Keepiq. Vlastník sa rozhodol, že ho nemôžete zobraziť ani skopírovať.", + "Your access ends on {date}": "Váš prístup končí {date}", + "Your access to this secret has ended": "Váš prístup k tomuto tajomstvu skončil", + "Your access to \"%s\" ends tomorrow": "Váš prístup k „%s“ končí zajtra", + "Your access to \"%s\" has ended": "Váš prístup k „%s“ skončil", + "%1$s no longer has access to \"%2$s\"": "%1$s už nemá prístup k „%2$s“", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mohol(a) vidieť toto heslo. Zmeňte ho, ak by ho %1$s už nemal(a) poznať.", + "%s could not view this password in Keepiq.": "%s nemohol(a) zobraziť toto heslo v Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} z {threshold} schválení", + "a recovery officer": "poverenec pre obnovu", + "Account recovery": "Obnova účtu", + "Approvals needed": "Potrebné schválenia", + "Ask {user} which words they see, by phone or in person. They must be:": "Opýtajte sa používateľa {user}, aké slová vidí, telefonicky alebo osobne. Musia to byť:", + "Check again": "Skontrolovať znova", + "Create the recovery key": "Vytvoriť kľúč na obnovu", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Vytvorte kľúč na obnovu. Váš prehliadač ho vytvorí a každému poverencovi dá kópiu, ktorú otvorí len on.", + "Decline": "Odmietnuť", + "Enrol in account recovery": "Prihlásiť sa na obnovu účtu", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prihláste sa, aby vám organizácia mohla pomôcť získať trezor späť, ak zabudnete hlavné heslo.", + "Every user is enrolled": "Všetci používatelia sú prihlásení", + "Finish the recovery in the browser you asked from.": "Dokončite obnovu v prehliadači, z ktorého ste žiadali.", + "Forgot your master password?": "Zabudli ste hlavné heslo?", + "Hand the key over": "Odovzdať kľúč", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Umožnite používateľom, ktorí zabudli hlavné heslo, získať trezor späť so schválením poverencov pre obnovu, ktorých určíte.", + "New master password": "Nové hlavné heslo", + "No one is asking to recover their account.": "Nikto nežiada o obnovu účtu.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Kľúč na obnovu zatiaľ neexistuje. Jeden z poverencov ho vytvorí vo svojich nastaveniach Keepiq.", + "Off": "Vypnuté", + "Officer {user} has no encryption set up yet.": "Poverenec {user} ešte nemá nastavené šifrovanie.", + "Officers (user IDs, separated by commas)": "Poverenci (ID používateľov oddelené čiarkami)", + "Policy": "Pravidlá", + "Publish this fingerprint internally, so users can check it before they enrol.": "Zverejnite tento odtlačok interne, aby si ho používatelia mohli overiť pred prihlásením.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnovené s pomocou {officer}. Teraz zmeňte kľúč trezoru v Nastaveniach, Zabezpečenie: \"Moje hlavné heslo bolo prezradené\".", + "Recovery key fingerprint: {fingerprint}": "Odtlačok kľúča na obnovu: {fingerprint}", + "Recovery officer": "Poverenec pre obnovu", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odobraní poverenci teraz prídu o svoju kópiu, ale mohli ju predtým otvoriť. Nechajte poverenca vytvoriť nový kľúč na obnovu.", + "Repeat the new master password": "Zopakujte nové hlavné heslo", + "Retire this recovery key": "Vyradiť tento kľúč na obnovu", + "Set the new master password": "Nastaviť nové hlavné heslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikát na obnovu nevydal tento Keepiq. Neprihlasujte sa a informujte správcu.", + "The words match, approve": "Slová sa zhodujú, schváliť", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Tento používateľ je prihlásený na obnovu účtu. Obnova zachová jeho tajomstvá; odvolanie zmaže jeho prihlásenie.", + "Users may enrol": "Používatelia sa môžu prihlásiť", + "Withdraw from account recovery": "Odhlásiť sa z obnovy účtu", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Ste prihlásení na obnovu účtu. Odtlačok kľúča na obnovu: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Ste prihlásení. Ak zabudnete hlavné heslo, organizácia vám môže pomôcť získať trezor späť.", + "Your key is back. Choose a new master password.": "Kľúč je späť. Zvoľte nové hlavné heslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši poverenci pre obnovu boli informovaní. Prečítajte im tieto slová, keď vám zavolajú alebo sa s vami stretnú:", + "You are now an account recovery officer": "Teraz ste poverencom pre obnovu účtov", + "%s asks to recover their account. Compare the words with them before you approve.": "%s žiada o obnovu účtu. Pred schválením s ním porovnajte slová.", + "A user": "Používateľ", + "Your account recovery request was declined": "Vaša žiadosť o obnovu účtu bola zamietnutá", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnova účtu je pripravená. Otvorte Keepiq v prehliadači, z ktorého ste žiadali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} žiada o jednorazové odomknutie nového zariadenia. Hlavné heslo zostáva rovnaké.", + "Ask your organisation instead": "Radšej požiadať svoju organizáciu", + "The request ended. Ask again or use your master password.": "Žiadosť skončila. Požiadajte znova alebo použite hlavné heslo.", + "Added by {user}": "Pridal(a) {user}", + "Editor": "Editor", + "Manager": "Správca", + "Role of {member}": "Rola používateľa {member}", + "Team folders you manage": "Tímové priečinky, ktoré spravujete", + "Viewer": "Čitateľ", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nemáte kópiu týchto tajomstiev, takže ich noví členovia zatiaľ nedostali. Vlastník ich môže zdieľať: {names}", + "Admin areas": "Oblasti správy", + "Give a group only the parts of Keepiq administration it needs.": "Dajte skupine len tie časti správy Keepiq, ktoré potrebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegujte jednu alebo viac oblastí na skupinu na stránke oprávnení na správu. Správcovia inštancie majú každú oblasť.", + "Open administration privileges": "Otvoriť oprávnenia na správu", + "Policies": "Zásady", + "Applications and machine access": "Aplikácie a prístup strojov", + "People and offboarding": "Ľudia a odchody", + "Audit and compliance": "Audit a súlad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "verzia, certifikačná autorita, prílohy, offline vyrovnávacia pamäť, kontrola únikov, typy tajomstiev a zálohy", + "master password, organisation password, vault policies, rotation, version history and trash": "hlavné heslo, heslo organizácie, zásady trezoru, rotácia, história verzií a kôš", + "application queue, application requests and machine leases": "front aplikácií, požiadavky aplikácií a prenájmy strojov", + "team offboarding, encryption suites and admin handover": "odchody z tímu, šifrovacie sady a prevzatie správcom", + "audit log, compliance reports, SIEM export and honey alerts": "auditný denník, správy o súlade, export SIEM a návnadové upozornenia", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koľko verzií tajomstva sa uchováva, ako dlho a ako dlho zostávajú vymazané tajomstvá v koši.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Limity šifrovaných príloh, vynucované na serveri v uložených šifrovaných bajtoch.", + "Type the suite ID again to confirm": "Na potvrdenie zadajte ID sady znova", + "This does not match the suite ID.": "Nezhoduje sa s ID sady.", + "Confirm with your master password": "Potvrďte hlavným heslom", + "Confirm": "Potvrdiť", + "That master password is not right.": "Toto hlavné heslo nie je správne.", + "You are sharing with someone new. Enter your master password to confirm.": "Zdieľate s novou osobou. Na potvrdenie zadajte hlavné heslo.", + "Enter your master password to confirm this share.": "Na potvrdenie tohto zdieľania zadajte hlavné heslo.", + "Enter your master password to confirm this delegation.": "Na potvrdenie tohto delegovania zadajte hlavné heslo.", + "Approve {member}": "Schváliť {member}", + "Recipient": "Príjemca", + "No vault yet": "Zatiaľ nemá trezor", + "No matching users": "Žiadni zodpovedajúci používatelia", + "Partner organisations": "Partnerské organizácie", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Vymieňajte si tajomstvá s iným Keepiq. Obaja správcovia sa navzájom pridajú a pred uložením porovnajú koreňové odtlačky telefonicky alebo osobne.", + "Federation needs Nextcloud 33 or later.": "Federácia vyžaduje Nextcloud 33 alebo novší.", + "Your root fingerprint": "Váš koreňový odtlačok", + "No partners yet.": "Zatiaľ žiadni partneri.", + "Users here may share to this partner": "Používatelia tu môžu zdieľať s týmto partnerom", + "This partner may share to users here": "Tento partner môže zdieľať s používateľmi tu", + "Partner address": "Adresa partnera", + "Check partner": "Overiť partnera", + "Partner root fingerprint": "Koreňový odtlačok partnera", + "I compared this fingerprint with the partner's administrator": "Porovnal som tento odtlačok so správcom partnera", + "Add partner": "Pridať partnera", + "A secret from another organisation": "Tajomstvo z inej organizácie", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Používateľ %1$s s vami zdieľa \"%2$s\". Prijmite ho v sekcii Prichádzajúce z iných organizácií.", + "Incoming from other organisations": "Prichádzajúce z iných organizácií", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Ľudia v partnerských organizáciách s vami môžu zdieľať tajomstvo. Prijmite ho, aby ste si vo svojom trezore ponechali kópiu iba na čítanie.", + "Nothing shared with you yet": "Zatiaľ s vami nebolo nič zdieľané", + "Secrets that people in partner organisations share with you appear here.": "Tu sa zobrazia tajomstvá, ktoré s vami zdieľajú ľudia v partnerských organizáciách.", + "From {sender}": "Od {sender}", + "Accept": "Prijať", + "Open in vault": "Otvoriť v trezore", + "The other organisation did not hand over the secret. Try again later.": "Druhá organizácia tajomstvo neodovzdala. Skúste to neskôr znova.", + "Set up your vault before you accept a shared secret.": "Pred prijatím zdieľaného tajomstva si nastavte trezor.", + "Something went wrong. Try again.": "Niečo sa pokazilo. Skúste to znova.", + "Waiting for your answer": "Čaká na vašu odpoveď", + "In your vault, read-only": "Vo vašom trezore, iba na čítanie", + "Withdrawn by the sender": "Stiahnuté odosielateľom", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} to zdieľa z inej organizácie. Môžete to čítať, ale nie meniť ani zdieľať.", + "Someone": "Niekto", + "Share with someone at another organisation": "Zdieľať s niekým z inej organizácie", + "Their account at the other organisation": "Účet danej osoby v druhej organizácii", + "Check account": "Skontrolovať účet", + "Certificate fingerprint of {account}": "Odtlačok certifikátu účtu {account}", + "Compare it with them by phone if you want to be sure.": "Ak si chcete byť istí, porovnajte ho s danou osobou telefonicky.", + "Shared. {account} can accept it in their own vault.": "Zdieľané. {account} to môže prijať vo svojom vlastnom trezore.", + "The certificate could not be verified. Nothing was shared.": "Certifikát sa nepodarilo overiť. Nič nebolo zdieľané.", + "That organisation is not one of your partners.": "Táto organizácia nie je medzi vašimi partnermi.", + "No one with that account can receive secrets from you.": "Nikto s týmto účtom od vás nemôže prijímať tajomstvá.", + "The other organisation did not answer. Try again later.": "Druhá organizácia neodpovedala. Skúste to neskôr znova.", + "This secret is already shared with that account.": "Toto tajomstvo je s týmto účtom už zdieľané.", + "Other organisations": "Iné organizácie", + "Receive secrets from other organisations": "Prijímať tajomstvá z iných organizácií", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Ľudia v partnerských organizáciách potom môžu nájsť váš účet a zdieľať s vami tajomstvá. Každé z nich prijímate sami.", + "Shared": "Zdieľané", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pozastavené: zmenil sa ich certifikát alebo partnerstvo. Zdieľanie môžete odvolať alebo zdieľať znova.", + "Their organisation did not get the last change. Revoke it or share again.": "Ich organizácia nedostala poslednú zmenu. Zdieľanie môžete odvolať alebo zdieľať znova.", + "Being withdrawn": "Odvoláva sa", + "Shared with another organisation": "Zdieľané s inou organizáciou", + "Change sent to another organisation": "Zmena odoslaná inej organizácii", + "Share with another organisation revoked": "Zdieľanie s inou organizáciou odvolané", + "Share with another organisation paused": "Zdieľanie s inou organizáciou pozastavené", + "Another organisation did not get a change": "Iná organizácia nedostala zmenu", + "Secret received from another organisation": "Tajomstvo prijaté z inej organizácie", + "Secret from another organisation accepted": "Tajomstvo z inej organizácie prijaté", + "Secret from another organisation declined": "Tajomstvo z inej organizácie odmietnuté", + "Copy from another organisation updated": "Kópia z inej organizácie aktualizovaná", + "Copy from another organisation removed": "Kópia z inej organizácie odstránená", + "Declined: they removed their copy. Share again if they need it.": "Odmietnuté: príjemca odstránil svoju kópiu. Ak ju potrebuje, zdieľajte znova.", + "Recipient at another organisation removed their copy": "Príjemca z inej organizácie odstránil svoju kópiu", + "Removed the user from %n team folder.": "Používateľ bol odstránený z %n tímového priečinka.", + "Removed the user from %n team folders.": "Používateľ bol odstránený z %n tímových priečinkov.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Používateľ bol odstránený z %n tímového priečinka.", + "Používateľ bol odstránený z %n tímových priečinkov.", + "Používateľ bol odstránený z %n tímových priečinkov." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovená kópia pochádza zo zdieľania, ktoré skončilo. Zostáva iba na čítanie.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizáciu, ktorá zdieľala obnovenú kópiu, sa nepodarilo zastihnúť. Kópia zostáva iba na čítanie a nesleduje ich zmeny.", + "Recipient at another organisation restored their copy": "Príjemca z inej organizácie obnovil svoju kópiu" }, "plurals": null } diff --git a/l10n/sl.js b/l10n/sl.js index bbabf22bf..a92d41f16 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Deljeno s skupinami", + "Not shared with any group yet.": "Še ni deljeno z nobeno skupino.", + "Revoke the share with {group}": "Prekliči deljenje s skupino {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deljeno s skupino {group}: {received} članov je to prejelo, {skipped} ne, ker še nimajo nastavljenega šifriranja.", + "Search groups": "Išči skupine", + "Failed to share": "Deljenje ni uspelo", + "Columns": "Stolpci", + "Column {number}": "Stolpec {number}", + "Map one column to Name. Every secret needs a name.": "En stolpec povežite z imenom. Vsaka skrivnost potrebuje ime.", + "Notes": "Opombe", + "Do not import": "Ne uvozi", + "Hide this value": "Skrij to vrednost", + "Show this value": "Pokaži to vrednost", + "Defaults": "Privzeto", + "New secrets start as this type, and your secret list opens in this view.": "Nove skrivnosti se začnejo s to vrsto, seznam skrivnosti pa se odpre v tem pogledu.", + "Default item type": "Privzeta vrsta elementa", + "Cards": "Kartice", + "Table": "Tabela", + "Could not save your default": "Privzete vrednosti ni bilo mogoče shraniti", + "Recently used": "Nedavno uporabljeno", + "Opened": "Odprto", + "You have not opened any secrets yet": "Nobene skrivnosti še niste odprli", + "Could not delete the item type.": "Vrste elementa ni bilo mogoče izbrisati.", + "Could not load the item types.": "Vrst elementov ni bilo mogoče naložiti.", + "Could not save the item type.": "Vrste elementa ni bilo mogoče shraniti.", + "Delete item type": "Izbriši vrsto elementa", + "Edit item type": "Uredi vrsto elementa", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skrito", + "Item types": "Vrste elementov", + "Move up": "Premakni gor", + "New item type": "Nova vrsta elementa", + "No item types defined yet.": "Vrste elementov še niso določene.", + "Required": "Obvezno", + "Text": "Besedilo", + "This field is required": "To polje je obvezno", + "Web address": "Spletni naslov", + "{label} (required)": "{label} (obvezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrišem »{name}«? Skrivnosti te vrste ostanejo berljive in postanejo elementi Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste elementov, ki jih določite tukaj, se vsem prikažejo v oknu Nova skrivnost z izbranimi polji.", + "Secret moved to the trash": "Skrivnost premaknjena v koš", + "Secret restored from the trash": "Skrivnost obnovljena iz koša", + "Secret deleted for good": "Skrivnost trajno izbrisana", + "Secret archived": "Skrivnost arhivirana", + "Secret unarchived": "Skrivnost vrnjena iz arhiva", + "Unarchive": "Vrni iz arhiva", + "Could not archive the secret": "Skrivnosti ni bilo mogoče arhivirati", + "Could not unarchive the secret": "Skrivnosti ni bilo mogoče vrniti iz arhiva", + "Archive {count} secrets": "Arhiviraj skrivnosti: {count}", + "Unarchive {count} secrets": "Vrni iz arhiva skrivnosti: {count}", + "Restore {count} secrets": "Obnovi skrivnosti: {count}", + "Delete {count} secrets for good": "Trajno izbriši skrivnosti: {count}", + "Done for {ok} of {total} secrets": "Končano za {ok} od {total} skrivnosti", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane skrivnosti izginejo s seznama trezorja, iz iskanja, samodejnega izpolnjevanja in poročila o stanju. Ohranijo deljenja. Najdete jih v Arhivu.", + "These secrets come back to the vault list, search and autofill.": "Te skrivnosti se vrnejo na seznam trezorja, v iskanje in samodejno izpolnjevanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Te skrivnosti se vrnejo na seznam trezorja. Stara deljenja se ne vrnejo, zato jih po potrebi delite znova.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "S tem se izbrišejo skrivnosti skupaj s prilogami in zgodovino različic. Tega ni mogoče razveljaviti.", + "Delete for good": "Trajno izbriši", + "Trash": "Koš", + "The trash is empty": "Koš je prazen", + "No archived secrets": "Ni arhiviranih skrivnosti", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane skrivnosti čakajo tukaj do konca obdobja hrambe, nato se trajno izbrišejo.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte skrivnost na njeni podrobni plošči, da ostane zunaj seznama trezorja, iskanja in samodejnega izpolnjevanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Omejitve za šifrirane priloge (uveljavljene na strežniku v shranjenih šifriranih bajtih), hramba zgodovine različic in kako dolgo izbrisane skrivnosti ostanejo v košu.", + "Days a deleted secret stays in the trash (1 to 365)": "Število dni, ko izbrisana skrivnost ostane v košu (od 1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "S tem se skrivnost premakne v koš in njena deljenja se takoj končajo. Iz koša jo lahko obnovite do konca obdobja hrambe: 30 dni, razen če je skrbnik to spremenil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "S tem se skrivnosti premaknejo v koš ({count}) in njihova deljenja se takoj končajo. Iz koša jih lahko obnovite do konca obdobja hrambe.", + "Remove {name} from favourites": "Odstrani {name} iz priljubljenih", + "Add {name} to favourites": "Dodaj {name} med priljubljene", + "Could not change the favourite": "Priljubljenega ni bilo mogoče spremeniti", + "Remove from favourites": "Odstrani iz priljubljenih", + "Add to favourites": "Dodaj med priljubljene", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake niso šifrirane. Skrbniki strežnika jih lahko berejo, tako kot imena map.", + "Favourites": "Priljubljeni", + "Filter by tag": "Filtriraj po oznaki", + "All tags": "Vse oznake", + "Last used": "Nazadnje uporabljeno", + "Tags for {count} secrets": "Oznake za {count} skrivnosti", + "Tag": "Oznaka", + "Remove tag": "Odstrani oznako", + "Add tag": "Dodaj oznako", + "Could not change the tags. Try again.": "Oznak ni bilo mogoče spremeniti. Poskusite znova.", + "Could not approve the application. It is still in the queue.": "Prijave ni bilo mogoče odobriti. Še vedno je v čakalni vrsti.", + "Could not reject the application. It is still in the queue.": "Prijave ni bilo mogoče zavrniti. Še vedno je v čakalni vrsti.", + "Removed the user from {count} team folders.": "Uporabnik je bil odstranjen iz {count} skupinskih map.", + "Approve a share": "Odobri souporabo", + "This approval link is incomplete. Open it again from the notification.": "Ta povezava za odobritev je nepopolna. Ponovno jo odprite iz obvestila.", + "Deny": "Zavrni", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se je pridružil skupini, s katero delite skrivnost. Želite skrivnost deliti tudi z njim?", + "{requester} asks you to share a secret with {user}.": "{requester} vas prosi, da skrivnost delite z uporabnikom {user}.", + "Shared. The recipient can now open the secret.": "V souporabi. Prejemnik lahko zdaj odpre skrivnost.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Prejemnik še ni nastavil Keepiq, zato ni bilo nič deljeno. Poskusite znova, ko to stori.", + "Could not share the secret. Only its owner can approve this.": "Skrivnosti ni bilo mogoče deliti. To lahko odobri le njen lastnik.", + "Could not share the secret. Try again.": "Skrivnosti ni bilo mogoče deliti. Poskusite znova.", + "Denied. Nothing was shared.": "Zavrnjeno. Nič ni bilo deljeno.", + "Could not deny the request. Try again.": "Zahteve ni bilo mogoče zavrniti. Poskusite znova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vas prosi, da skrivnost \"%2$s\" delite z uporabnikom %3$s.", + "Expires on (optional)": "Poteče (izbirno)", + "Hand over to": "Predaj uporabniku", + "Choose a recipient": "Izberite prejemnika", + "Hand over temporarily": "Začasno predaj", + "Expiry rules": "Pravila poteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavite, kako dolgo smejo veljati gesla ene vrste predmeta ali v eni mapi in kdaj želite opomnik. Če velja več datumov, šteje najzgodnejši.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Nastavil vaš skrbnik", + "No expiry rules yet.": "Pravil poteka še ni.", + "Applies to": "Velja za", + "Item type": "Vrsta predmeta", + "Maximum age in days (empty for reminders only)": "Največja starost v dneh (prazno samo za opomnike)", + "Remind me this many days before, comma separated": "Opomni me toliko dni prej, ločeno z vejicami", + "Save rule": "Shrani pravilo", + "An item type": "Vrsta predmeta", + "A folder": "Mapa", + "Folder {name}": "Mapa {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Poteče po {days} dneh", + "Reminders {days} days before": "Opomniki {days} dni prej", + "Could not save the expiry rule.": "Pravila poteka ni bilo mogoče shraniti.", + "Could not delete the expiry rule.": "Pravila poteka ni bilo mogoče izbrisati.", + "All statuses": "Vsa stanja", + "Compromised": "Ogrožena", + "Could not load the members.": "Članov ni bilo mogoče naložiti.", + "Emergency contact": "Kontakt za nujne primere", + "Leaving user": "Odhajajoči uporabnik", + "No": "Ne", + "No users match this filter.": "Temu filtru ne ustreza noben uporabnik.", + "Not set up": "Ni nastavljeno", + "Revoke suite": "Prekliči zbirko", + "Revoked": "Preklicana", + "Search users": "Iskanje uporabnikov", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Oglejte si, kateri uporabniki so nastavili trezor. Začnite odjavo ali prekličite zbirko iz vrstice.", + "Successor": "Naslednik", + "Team folders": "Ekipne mape", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Uporabnik je še vedno v skupini {groups}, ki je članica ekipne mape. Odstranite ga iz skupine ali onemogočite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.", + "Vault status": "Stanje trezorja", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz v CXF NI ŠIFRIRAN. Vsako geslo in prijava bosta v preneseni datoteki berljiva kot navadno besedilo. Datoteko varno shranite in jo takoj po uporabi izbrišite.", + "Root certificate expiring soon": "Korensko potrdilo kmalu poteče", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korensko potrdilo trezorja poteče čez %1$d dni. Obnovite ga pred tem. Obnova znova podpiše vsak šifrirni paket.", + "Compromise recovery aborted": "Obnovitev po ogrožanju prekinjena", + "Key rotation ended by a compromise revoke": "Menjavo ključa je končal preklic zaradi ogrožanja", + "Encryption suite revoke refused": "Preklic šifrirnega kompleta zavrnjen", + "Master password proof refused": "Dokaz glavnega gesla zavrnjen", + "Your current master password": "Vaše trenutno glavno geslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n stik za nujne primere je imel čakajočo zahtevo za dostop, ko ga je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ti stiki za nujne primere niso bili preneseni na vaš novi ključ. Njihov dostop v sili je bil odstranjen. Če jih še želite, jih znova dodajte v Dostopu v sili.", + "Renew root certificate": "Obnovi korensko potrdilo", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ustvari se novo korensko in vmesno potrdilo. Vsak aktiven šifrirni paket se ponovno podpiše. Tega ni mogoče razveljaviti.", + "Renew root": "Obnovi koren", + "Root renewed. {n} encryption suites signed again.": "Koren obnovljen. Ponovno podpisanih šifrirnih paketov: {n}.", + "Could not renew the root certificate.": "Korenskega potrdila ni bilo mogoče obnoviti.", + "Lease policy for this application": "Pravilnik zakupa za ta program", + "In force now: {default} seconds by default, {max} seconds at most.": "Zdaj velja: privzeto {default} sekund, največ {max} sekund.", + "Leases are not renewable": "Zakupov ni mogoče podaljšati", + "Lease policy saved.": "Pravilnik zakupa je shranjen.", + "Leave a field empty to use the instance value.": "Pustite polje prazno, da uporabite vrednost primerka.", + "Instance value: {value}": "Vrednost primerka: {value}", + "Renewal": "Podaljšanje", + "Use the instance value ({value})": "Uporabi vrednost primerka ({value})", + "Allowed": "Dovoljeno", + "Not allowed": "Ni dovoljeno", + "Save lease policy": "Shrani pravilnik zakupa", + "Only an administrator can change this policy.": "Ta pravilnik lahko spremeni samo skrbnik.", + "Could not save the lease policy.": "Pravilnika zakupa ni bilo mogoče shraniti.", + "{member} got access from {confirmer}.": "{member} je dobil dostop od {confirmer}.", + "Automatically confirm new team folder members": "Samodejno potrdi nove člane ekipnih map", + "Gave %n new member access to a team folder.": "%n nov član je dobil dostop do ekipne mape.", + "Gave %n new members access to a team folder.": "Novi člani (%n) so dobili dostop do ekipne mape.", + "Give new team folder members access without waiting for the folder owner.": "Novim članom omogočite dostop brez čakanja na lastnika mape.", + "New team folder members": "Novi člani ekipnih map", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Lastnik ali član s pravico pisanja jih potrdi iz odprtega trezorja. Keepiq nikoli ne dešifrira na strežniku.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čakanje, da član s pravico pisanja odpre Keepiq. Lahko pa delite že zdaj.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Del odziva na ogroženost ni uspel ({failed} korak(ov)). Preverite strežniški dnevnik in nato znova prekličite zbirko, da jo dokončate.", + "This also revoked suite {suite} and ended key migration {migration}.": "S tem je bila preklicana tudi zbirka {suite} in končana selitev ključev {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Preklic druge zbirke je izbrisal %n stik za nujni dostop.", + "Revoking the second suite deleted %n emergency-access contacts.": "Preklic druge zbirke je izbrisal %n stikov za nujni dostop.", + "A suite revoked as compromised cannot be reinstated.": "Zbirke, preklicane kot ogrožene, ni mogoče obnoviti.", + "Archives to keep": "Arhivi za hrambo", + "Back up every vault automatically": "Samodejno varnostno kopiraj vsak trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varnostno kopirajte vsak trezor po urniku. Arhivi vsebujejo le šifrirano besedilo in se obnovijo z occ.", + "Back up now": "Kopiraj zdaj", + "Backup public key (PEM, optional)": "Javni ključ varnostne kopije (PEM, neobvezno)", + "Backup requested for the next cron run": "Kopija zahtevana za naslednji zagon crona", + "Encrypted": "Šifrirano", + "Every (hours)": "Vsakih (ur)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} ni uspela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspela.", + "No archives yet.": "Še ni arhivov.", + "Size": "Velikost", + "Vault backups": "Varnostne kopije trezorja", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se vsak arhiv šifrira zanj. Zasebni ključ hranite zunaj tega strežnika: potrebujete ga za preverjanje ali obnovo.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n uporabnik v obsegu še nima dvostopenjske prijave in ne more odpreti trezorja, dokler je to vklopljeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervne kode ne štejejo. Če se vaši uporabniki prijavljajo prek ponudnika identitete z lastnim drugim faktorjem, izpustite njihove skupine.", + "Block personal vault export": "Blokiraj izvoz osebnega trezorja", + "Keep work logins in team folders": "Službene prijave hrani v ekipnih mapah", + "Move to a team folder": "Premakni v ekipno mapo", + "Not in a team folder": "Ni v ekipni mapi", + "Only for these groups (empty is everyone)": "Samo za te skupine (prazno pomeni vse)", + "Require two-factor login before the vault opens": "Zahtevaj dvostopenjsko prijavo pred odprtjem trezorja", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za vsak trezor. Vsako velja za vse ali le za skupine, ki jih izberete.", + "Secret types that belong in a team folder": "Vrste skrivnosti, ki sodijo v ekipno mapo", + "Set up two-factor login": "Nastavi dvostopenjsko prijavo", + "Team folder you can write to": "Ekipna mapa, v katero lahko pišete", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Uporabniki ne morejo prenesti varnostne kopije, CSV ali datoteke za prenos. Njihov paket osebnih podatkov ostane na voljo.", + "Users cannot save these secret types in a personal folder.": "Uporabniki teh vrst skrivnosti ne morejo shraniti v osebno mapo.", + "Vault policies": "Pravila trezorja", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dovoli izvoza vašega osebnega trezorja. Vaš paket osebnih podatkov v nastavitvah ostane na voljo.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija hrani te skrivnosti v ekipni mapi. Vsako premaknite v ekipno mapo.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija hrani to vrsto skrivnosti v ekipni mapi. Izberite eno od svojih ekipnih map ali tisto, v katero lahko pišete.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahteva dvostopenjsko prijavo, preden lahko odprete svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Uporabniki izberejo, kako dolgo razširitev ostane odklenjena med nedejavnostjo. Vi nastavite najdaljši čas, ki ga lahko izberejo.", + "Longest idle time before the extension locks": "Najdaljši čas nedejavnosti, preden se razširitev zaklene", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 ura", + "4 hours": "4 ure", + "Connector": "Povezovalnik", + "Directory (tenant) ID": "ID imenika (najemnika)", + "Application (client) ID": "ID aplikacije (odjemalca)", + "Data collection rule immutable ID": "Nespremenljivi ID pravila zbiranja podatkov", + "Stream name": "Ime toka", + "Splunk index (optional)": "Indeks Splunk (neobvezno)", + "Sourcetype (optional)": "Sourcetype (neobvezno)", + "Leave blank to keep the current one": "Pustite prazno, da ostane trenutna vrednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF prek sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Končna točka zbiranja podatkov (URL https)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectorja (https)", + "Client secret (write-only)": "Skrivnost odjemalca (samo pisanje)", + "HEC token (write-only)": "Žeton HEC (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Posredujte dovoljene revizijske dogodke v Splunk, Microsoft Sentinel, sprejemnik syslog ali spletni kavelj. Sporočila vsebujejo le očiščene metapodatke: nobena skrivna vrednost, ime, prijava ali šifrirano besedilo nikoli ne zapusti strežnika.", + "%n change waiting to sync": "%n sprememba čaka na sinhronizacijo", + "%n changes waiting to sync": "%n sprememb čaka na sinhronizacijo", + "Changes that could not sync": "Spremembe, ki jih ni bilo mogoče sinhronizirati", + "Choose a version": "Izberi različico", + "Copy value": "Kopiraj vrednost", + "Deleted": "Izbrisano", + "Discard": "Zavrzi", + "Keep my offline change": "Obdrži mojo spremembo brez povezave", + "Keep the server version": "Obdrži različico s strežnika", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je brez povezave samo za branje. Skrbnik ni vklopil urejanja brez povezave.", + "Let users edit secrets offline": "Dovoli uporabnikom urejanje skrivnosti brez povezave", + "Not synced yet": "Še ni sinhronizirano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Spremembe brez povezave ostanejo na napravi, šifrirane za uporabnika, in se sinhronizirajo ob naslednjem odklepanju s povezavo. Deljenje, mape in priloge še vedno potrebujejo povezavo.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Brez povezave. Urejanja, premiki in izbrisi ostanejo na tej napravi in se sinhronizirajo, ko boste spet povezani. Deljenje in priloge potrebujejo povezavo.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Brez povezave. Vaše spremembe ostanejo na tej napravi in se sinhronizirajo, ko boste spet povezani. Nazadnje sinhronizirano {when}.", + "Open my changes": "Odpri moje spremembe", + "Sharing needs a connection": "Deljenje potrebuje povezavo", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Nekdo je spremenil to skrivnost na strežniku, potem ko je bila narejena vaša kopija brez povezave. Izberite, katero različico obdržati.", + "Sync or discard your offline changes before you rotate your keys.": "Pred zamenjavo ključev sinhronizirajte ali zavrzite spremembe brez povezave.", + "That password did not open your changes.": "S tem geslom vaših sprememb ni bilo mogoče odpreti.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Posnetek brez povezave hrani šifrirane skrivnosti (odpreti jih je mogoče le s ključem, izpeljanim iz glavnega gesla uporabnika, natanko kot na strežniku) in šifrira imena, URL-je in imena map v hrambi. Dostop brez povezave je samo za branje, razen če spodaj dovolite urejanje brez povezave. Izklopite to za naprave, ki nikoli ne smejo predpomniti poverilnic; izklop izbriše obstoječe predpomnilnike ob naslednjem nalaganju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prejšnje kopije trezorja ni več, zato teh sprememb ni mogoče odpreti.", + "The server version": "Različica s strežnika", + "This secret changed while you were offline": "Ta skrivnost se je spremenila, ko ste bili brez povezave", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "To skrivnost ste izbrisali brez povezave, vendar je bila medtem spremenjena na strežniku. Izberite, katero različico obdržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključi so bili zamenjani na drugi napravi. Vnesite prejšnje glavno geslo, da sinhronizirate spremembe brez povezave, ali jih zavrzite.", + "Your offline change": "Vaša sprememba brez povezave", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n stik za nujne primere je imel čakajočo zahtevo za dostop, ko ga je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.","Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.","Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.","Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n predmeta ni mogoče predstaviti v obliki CXF in bo preskočen.","%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni.","%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni.","%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n starejša različica je bila zavržena, ker je mogoče prenesti le nedavno zgodovino.","%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino.","%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino.","%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopijo skrivnosti je treba še šifrirati in dati v souporabo.","%n kopij skrivnosti je treba še šifrirati in dati v souporabo.","%n kopij skrivnosti je treba še šifrirati in dati v souporabo.","%n kopij skrivnosti je treba še šifrirati in dati v souporabo."], + "_%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 skrivnosti ni bilo mogoče dešifrirati in je ni v tem izvozu.","%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.","%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.","%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato ni bila preseljena.","%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene.","%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene.","%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n skrivnost ni bila preseljena.","%n skrivnosti ni bilo preseljenih.","%n skrivnosti ni bilo preseljenih.","%n skrivnosti ni bilo preseljenih."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n skrivnost je še vedno šifrirana z vašim prejšnjim ključem.","%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem.","%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem.","%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n skrivnost je bila preskočena, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.","%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.","%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.","%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova."], + "_%n secret_::_%n secrets_": ["%n skrivnost","%n skrivnosti","%n skrivnosti","%n skrivnosti"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n uporabnik v obsegu še nima dvostopenjske prijave in ne more odpreti trezorja, dokler je to vklopljeno.","Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.","Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.","Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno."], + "_Emergency access for %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 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.","Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Vseeno zaključi in izgubi dostop do %n skrivnosti","Vseeno zaključi in izgubi dostop do %n skrivnosti","Vseeno zaključi in izgubi dostop do %n skrivnosti","Vseeno zaključi in izgubi dostop do %n skrivnosti"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n nov član je dobil dostop do ekipne mape.","Novi člani (%n) so dobili dostop do ekipne mape.","Novi člani (%n) so dobili dostop do ekipne mape.","Novi člani (%n) so dobili dostop do ekipne mape."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rotacija ključa je zaključena. %n skrivnost je bila znova šifrirana z vašim novim ključem.","Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem.","Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem.","Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Preklic druge zbirke je izbrisal %n stik za nujni dostop.","Preklic druge zbirke je izbrisal %n stikov za nujni dostop.","Preklic druge zbirke je izbrisal %n stikov za nujni dostop.","Preklic druge zbirke je izbrisal %n stikov za nujni dostop."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Preklic tega kompleta je izbrisal %n stik zasilnega dostopa.","Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.","Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.","Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["zabeleženo %n krat v razkritjih","zabeleženo %n krat v razkritjih","zabeleženo %n krat v razkritjih","zabeleženo %n krat v razkritjih"], + "_shared with %n secret_::_shared with %n secrets_": ["v souporabi z %n skrivnostjo","v souporabi z %n skrivnostmi","v souporabi z %n skrivnostmi","v souporabi z %n skrivnostmi"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ta mapa neposredno vsebuje %n skrivnost.","Ta mapa neposredno vsebuje %n skrivnosti.","Ta mapa neposredno vsebuje %n skrivnosti.","Ta mapa neposredno vsebuje %n skrivnosti."], + "_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._": ["Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.","Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.","Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.","Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n sprememba čaka na sinhronizacijo","%n sprememb čaka na sinhronizacijo","%n sprememb čaka na sinhronizacijo","%n sprememb čaka na sinhronizacijo"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Uporabnik je še vedno v skupini {groups}, ki je članica ekipne mape. Odstranite ga iz skupine ali onemogočite račun.","Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.","Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.","Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun."], + "Allow approval from another device": "Dovoli odobritev z druge naprave", + "App": "Aplikacija", + "Approve a new device": "Odobri novo napravo", + "Approve from another device": "Odobri z druge naprave", + "Asked at": "Zahtevano ob", + "Check that the new device shows these words:": "Preverite, ali nova naprava prikazuje te besede:", + "Denied. If you did not ask, end your other sessions:": "Zavrnjeno. Če tega niste zahtevali, končajte druge seje:", + "Device": "Naprava", + "IP address": "IP-naslov", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Uporabnikom omogoči odklepanje novega brskalnika z odobritvijo z naprave, na kateri je Keepiq že odklenjen.", + "New device approval": "Odobritev novih naprav", + "Nextcloud security settings": "Varnostne nastavitve Nextcloud", + "Only approve a device you are using right now.": "Odobrite samo napravo, ki jo uporabljate prav zdaj.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Odprite Keepiq na napravi, na kateri je odklenjen, in odobrite to napravo. Preverite, ali prikazuje enake besede:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Naprava, ki odobri, zapečati ključ za odklepanje za novo napravo. Strežnik ga le posreduje in ga ne more odpreti.", + "The master password is not right, or the request has ended.": "Glavno geslo ni pravilno ali pa se je zahteva končala.", + "The request expired. Ask again or use your master password.": "Zahteva je potekla. Zahtevajte znova ali uporabite glavno geslo.", + "The request was denied.": "Zahteva je bila zavrnjena.", + "Too many requests. Try again in an hour or use your master password.": "Preveč zahtev. Poskusite znova čez eno uro ali uporabite glavno geslo.", + "Unknown device": "Neznana naprava", + "Web app": "Spletna aplikacija", + "A device": "Naprava", + "A new device asks to open your vault": "Nova naprava želi odpreti vaš trezor", + "%s asks to be approved. Only approve a device you are using right now.": "%s prosi za odobritev. Odobrite samo napravo, ki jo uporabljate prav zdaj.", + "Access ends on (optional)": "Dostop poteče (neobvezno)", + "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.": "Aplikacije Keepiq gesla ne bodo prikazale ali kopirale. Nekdo s tehničnim znanjem ga lahko še vedno prebere na svoji napravi. Ko dostop poteče, geslo zamenjajte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ta skrivnost je samo za uporabo. Prijavite se prek razširitve brskalnika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo uporaba", + "Use only (can sign in, cannot view or copy)": "Samo uporaba (lahko se prijavi, ne more videti ali kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S to prijavo se lahko prijavite prek razširitve brskalnika Keepiq. Lastnik se je odločil, da je ne morete videti ali kopirati.", + "Your access ends on {date}": "Vaš dostop poteče {date}", + "Your access to this secret has ended": "Vaš dostop do te skrivnosti je potekel", + "Your access to \"%s\" ends tomorrow": "Vaš dostop do »%s« poteče jutri", + "Your access to \"%s\" has ended": "Vaš dostop do »%s« je potekel", + "%1$s no longer has access to \"%2$s\"": "%1$s nima več dostopa do »%2$s«", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je lahko videl(a) to geslo. Zamenjajte ga, če ga %1$s ne sme več poznati.", + "%s could not view this password in Keepiq.": "%s tega gesla v Keepiq ni mogel(a) videti.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobritev", + "a recovery officer": "pooblaščenec za obnovitev", + "Account recovery": "Obnovitev računa", + "Approvals needed": "Potrebne odobritve", + "Ask {user} which words they see, by phone or in person. They must be:": "Vprašajte uporabnika {user}, katere besede vidi, po telefonu ali osebno. Biti morajo:", + "Check again": "Preveri znova", + "Create the recovery key": "Ustvari obnovitveni ključ", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Ustvarite obnovitveni ključ. Vaš brskalnik ga izdela in vsakemu pooblaščencu da kopijo, ki jo lahko odpre samo on.", + "Decline": "Zavrni", + "Enrol in account recovery": "Prijavite se v obnovitev računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se, da vam organizacija lahko pomaga dobiti trezor nazaj, če pozabite glavno geslo.", + "Every user is enrolled": "Vsi uporabniki so prijavljeni", + "Finish the recovery in the browser you asked from.": "Dokončajte obnovitev v brskalniku, iz katerega ste jo zahtevali.", + "Forgot your master password?": "Ste pozabili glavno geslo?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Uporabnikom, ki so pozabili glavno geslo, omogočite, da dobijo trezor nazaj, z odobritvijo pooblaščencev za obnovitev, ki jih imenujete.", + "New master password": "Novo glavno geslo", + "No one is asking to recover their account.": "Nihče ne zahteva obnovitve računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Obnovitvenega ključa še ni. Eden od pooblaščencev ga ustvari v svojih nastavitvah Keepiq.", + "Off": "Izklopljeno", + "Officer {user} has no encryption set up yet.": "Pooblaščenec {user} še nima nastavljenega šifriranja.", + "Officers (user IDs, separated by commas)": "Pooblaščenci (ID-ji uporabnikov, ločeni z vejicami)", + "Policy": "Pravilnik", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ta prstni odtis interno, da ga uporabniki lahko preverijo pred prijavo.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnovljeno s pomočjo {officer}. Zdaj zamenjajte ključ trezorja v Nastavitve, Varnost: \"Moje glavno geslo je bilo ogroženo\".", + "Recovery key fingerprint: {fingerprint}": "Prstni odtis obnovitvenega ključa: {fingerprint}", + "Recovery officer": "Pooblaščenec za obnovitev", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odstranjeni pooblaščenci zdaj izgubijo svojo kopijo, vendar so jo morda že prej odprli. Naj pooblaščenec ustvari nov obnovitveni ključ.", + "Repeat the new master password": "Ponovite novo glavno geslo", + "Retire this recovery key": "Umakni ta obnovitveni ključ", + "Set the new master password": "Nastavi novo glavno geslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Obnovitvenega potrdila ni izdal ta Keepiq. Ne prijavite se in obvestite skrbnika.", + "The words match, approve": "Besede se ujemajo, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ta uporabnik je prijavljen v obnovitev računa. Obnovitev ohrani njegove skrivnosti; preklic izbriše njegovo prijavo.", + "Users may enrol": "Uporabniki se lahko prijavijo", + "Withdraw from account recovery": "Odjavi se iz obnovitve računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste v obnovitev računa. Prstni odtis obnovitvenega ključa: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Če pozabite glavno geslo, vam organizacija lahko pomaga dobiti trezor nazaj.", + "Your key is back. Choose a new master password.": "Ključ je spet vaš. Izberite novo glavno geslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši pooblaščenci za obnovitev so obveščeni. Preberite jim te besede, ko vas pokličejo ali se srečate:", + "You are now an account recovery officer": "Zdaj ste pooblaščenec za obnovitev računov", + "%s asks to recover their account. Compare the words with them before you approve.": "%s zahteva obnovitev računa. Pred odobritvijo z njim primerjajte besede.", + "A user": "Uporabnik", + "Your account recovery request was declined": "Vaša zahteva za obnovitev računa je bila zavrnjena", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnovitev računa je pripravljena. Odprite Keepiq v brskalniku, iz katerega ste jo zahtevali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prosi za enkratno odklepanje nove naprave. Glavno geslo ostane enako.", + "Ask your organisation instead": "Raje vprašajte svojo organizacijo", + "The request ended. Ask again or use your master password.": "Zahteva se je končala. Vprašajte znova ali uporabite glavno geslo.", + "Added by {user}": "Dodal(a) {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Vloga uporabnika {member}", + "Team folders you manage": "Skupinske mape, ki jih upravljate", + "Viewer": "Bralec", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nimate kopije teh skrivnosti, zato jih novi člani še niso prejeli. Lastnik jih lahko deli: {names}", + "Admin areas": "Področja upravljanja", + "Give a group only the parts of Keepiq administration it needs.": "Skupini dajte samo tiste dele upravljanja Keepiq, ki jih potrebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Dodelite eno ali več področij skupini na strani skrbniških pravic. Skrbniki primerka imajo vsa področja.", + "Open administration privileges": "Odpri skrbniške pravice", + "Policies": "Pravilniki", + "Applications and machine access": "Aplikacije in dostop strojev", + "People and offboarding": "Ljudje in odhodi", + "Audit and compliance": "Revizija in skladnost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "različica, overitelj potrdil, priloge, predpomnilnik brez povezave, preverjanje uhajanja, vrste skrivnosti in varnostne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavno geslo, geslo organizacije, pravilniki trezorja, rotacija, zgodovina različic in koš", + "application queue, application requests and machine leases": "vrsta aplikacij, zahteve aplikacij in najemi strojev", + "team offboarding, encryption suites and admin handover": "odhodi iz ekipe, šifrirni nabori in prevzem s strani skrbnika", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski dnevnik, poročila o skladnosti, izvoz SIEM in opozorila vab", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko različic skrivnosti se hrani, kako dolgo in kako dolgo izbrisane skrivnosti ostanejo v košu.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Omejitve za šifrirane priloge, ki jih strežnik uveljavlja v shranjenih šifriranih bajtih.", + "Type the suite ID again to confirm": "Za potrditev znova vnesite ID nabora", + "This does not match the suite ID.": "To se ne ujema z ID-jem nabora.", + "Confirm with your master password": "Potrdite z glavnim geslom", + "Confirm": "Potrdi", + "That master password is not right.": "To glavno geslo ni pravilno.", + "You are sharing with someone new. Enter your master password to confirm.": "Delite z novo osebo. Za potrditev vnesite glavno geslo.", + "Enter your master password to confirm this share.": "Za potrditev te souporabe vnesite glavno geslo.", + "Enter your master password to confirm this delegation.": "Za potrditev tega pooblastila vnesite glavno geslo.", + "Approve {member}": "Odobri {member}", + "Recipient": "Prejemnik", + "No vault yet": "Še nima trezorja", + "No matching users": "Ni ustreznih uporabnikov", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Izmenjujte skrivnosti z drugim Keepiq. Oba skrbnika se dodata drug drugemu in pred shranjevanjem primerjata korenska prstna odtisa po telefonu ali osebno.", + "Federation needs Nextcloud 33 or later.": "Federacija zahteva Nextcloud 33 ali novejši.", + "Your root fingerprint": "Vaš korenski prstni odtis", + "No partners yet.": "Partnerjev še ni.", + "Users here may share to this partner": "Uporabniki tukaj lahko delijo s tem partnerjem", + "This partner may share to users here": "Ta partner lahko deli z uporabniki tukaj", + "Partner address": "Naslov partnerja", + "Check partner": "Preveri partnerja", + "Partner root fingerprint": "Korenski prstni odtis partnerja", + "I compared this fingerprint with the partner's administrator": "Ta prstni odtis sem primerjal s skrbnikom partnerja", + "Add partner": "Dodaj partnerja", + "A secret from another organisation": "Skrivnost iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Uporabnik %1$s je z vami delil \"%2$s\". Sprejmite jo v razdelku Prejeto iz drugih organizacij.", + "Incoming from other organisations": "Prejeto iz drugih organizacij", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osebe v partnerskih organizacijah lahko z vami delijo skrivnost. Sprejmite jo, da v svojem trezorju obdržite kopijo samo za branje.", + "Nothing shared with you yet": "Z vami še ni nič deljeno", + "Secrets that people in partner organisations share with you appear here.": "Skrivnosti, ki jih z vami delijo osebe v partnerskih organizacijah, so prikazane tukaj.", + "From {sender}": "Od {sender}", + "Accept": "Sprejmi", + "Open in vault": "Odpri v trezorju", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija skrivnosti ni predala. Poskusite znova pozneje.", + "Set up your vault before you accept a shared secret.": "Preden sprejmete deljeno skrivnost, nastavite svoj trezor.", + "Something went wrong. Try again.": "Nekaj je šlo narobe. Poskusite znova.", + "Waiting for your answer": "Čaka na vaš odgovor", + "In your vault, read-only": "V vašem trezorju, samo za branje", + "Withdrawn by the sender": "Pošiljatelj je preklical", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} je to delil iz druge organizacije. Lahko to berete, ne morete pa tega spreminjati ali deliti.", + "Someone": "Nekdo", + "Share with someone at another organisation": "Deli z nekom iz druge organizacije", + "Their account at the other organisation": "Račun te osebe v drugi organizaciji", + "Check account": "Preveri račun", + "Certificate fingerprint of {account}": "Prstni odtis potrdila za {account}", + "Compare it with them by phone if you want to be sure.": "Če želite biti prepričani, ga primerjajte s to osebo po telefonu.", + "Shared. {account} can accept it in their own vault.": "Deljeno. {account} ga lahko sprejme v svoj trezor.", + "The certificate could not be verified. Nothing was shared.": "Potrdila ni bilo mogoče preveriti. Nič ni bilo deljeno.", + "That organisation is not one of your partners.": "Ta organizacija ni med vašimi partnerji.", + "No one with that account can receive secrets from you.": "Nihče s tem računom ne more prejemati skrivnosti od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija se ni odzvala. Poskusite znova pozneje.", + "This secret is already shared with that account.": "Ta skrivnost je s tem računom že deljena.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Prejemanje skrivnosti iz drugih organizacij", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osebe v partnerskih organizacijah lahko nato najdejo vaš račun in z vami delijo skrivnosti. Vsako sprejmete sami.", + "Shared": "Deljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Začasno ustavljeno: spremenilo se je njihovo potrdilo ali partnerstvo. Razveljavite ali delite znova.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija ni prejela zadnje spremembe. Razveljavite ali delite znova.", + "Being withdrawn": "Umika se", + "Shared with another organisation": "Deljeno z drugo organizacijo", + "Change sent to another organisation": "Sprememba poslana drugi organizaciji", + "Share with another organisation revoked": "Souporaba z drugo organizacijo preklicana", + "Share with another organisation paused": "Souporaba z drugo organizacijo začasno ustavljena", + "Another organisation did not get a change": "Druga organizacija ni prejela spremembe", + "Secret received from another organisation": "Skrivnost prejeta iz druge organizacije", + "Secret from another organisation accepted": "Skrivnost iz druge organizacije sprejeta", + "Secret from another organisation declined": "Skrivnost iz druge organizacije zavrnjena", + "Copy from another organisation updated": "Kopija iz druge organizacije posodobljena", + "Copy from another organisation removed": "Kopija iz druge organizacije odstranjena", + "Declined: they removed their copy. Share again if they need it.": "Zavrnjeno: prejemnik je odstranil svojo kopijo. Delite znova, če jo potrebuje.", + "Recipient at another organisation removed their copy": "Prejemnik iz druge organizacije je odstranil svojo kopijo", + "Removed the user from %n team folder.": "Uporabnik je bil odstranjen iz %n skupinske mape.", + "Removed the user from %n team folders.": "Uporabnik je bil odstranjen iz %n skupinskih map.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Uporabnik je bil odstranjen iz %n skupinske mape.","Uporabnik je bil odstranjen iz %n skupinskih map.","Uporabnik je bil odstranjen iz %n skupinskih map.","Uporabnik je bil odstranjen iz %n skupinskih map."], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovljena kopija izvira iz deljenja, ki se je končalo. Ostane samo za branje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacije, ki je delila obnovljeno kopijo, ni bilo mogoče doseči. Kopija ostane samo za branje in ne sledi njihovim spremembam.", + "Recipient at another organisation restored their copy": "Prejemnik iz druge organizacije je obnovil svojo kopijo" }, - "nplurals=2; plural=(n != 1);" + "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ) diff --git a/l10n/sl.json b/l10n/sl.json index 0db1d30a6..a0f02ce3c 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1181,7 +1181,617 @@ "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." + "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.", + "Shared with groups": "Deljeno s skupinami", + "Not shared with any group yet.": "Še ni deljeno z nobeno skupino.", + "Revoke the share with {group}": "Prekliči deljenje s skupino {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Deljeno s skupino {group}: {received} članov je to prejelo, {skipped} ne, ker še nimajo nastavljenega šifriranja.", + "Search groups": "Išči skupine", + "Failed to share": "Deljenje ni uspelo", + "Columns": "Stolpci", + "Column {number}": "Stolpec {number}", + "Map one column to Name. Every secret needs a name.": "En stolpec povežite z imenom. Vsaka skrivnost potrebuje ime.", + "Notes": "Opombe", + "Do not import": "Ne uvozi", + "Hide this value": "Skrij to vrednost", + "Show this value": "Pokaži to vrednost", + "Defaults": "Privzeto", + "New secrets start as this type, and your secret list opens in this view.": "Nove skrivnosti se začnejo s to vrsto, seznam skrivnosti pa se odpre v tem pogledu.", + "Default item type": "Privzeta vrsta elementa", + "Cards": "Kartice", + "Table": "Tabela", + "Could not save your default": "Privzete vrednosti ni bilo mogoče shraniti", + "Recently used": "Nedavno uporabljeno", + "Opened": "Odprto", + "You have not opened any secrets yet": "Nobene skrivnosti še niste odprli", + "Could not delete the item type.": "Vrste elementa ni bilo mogoče izbrisati.", + "Could not load the item types.": "Vrst elementov ni bilo mogoče naložiti.", + "Could not save the item type.": "Vrste elementa ni bilo mogoče shraniti.", + "Delete item type": "Izbriši vrsto elementa", + "Edit item type": "Uredi vrsto elementa", + "Fields": "Polja", + "Fields: {count}": "Polja: {count}", + "Hidden": "Skrito", + "Item types": "Vrste elementov", + "Move up": "Premakni gor", + "New item type": "Nova vrsta elementa", + "No item types defined yet.": "Vrste elementov še niso določene.", + "Required": "Obvezno", + "Text": "Besedilo", + "This field is required": "To polje je obvezno", + "Web address": "Spletni naslov", + "{label} (required)": "{label} (obvezno)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Izbrišem »{name}«? Skrivnosti te vrste ostanejo berljive in postanejo elementi Prijava.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Vrste elementov, ki jih določite tukaj, se vsem prikažejo v oknu Nova skrivnost z izbranimi polji.", + "Secret moved to the trash": "Skrivnost premaknjena v koš", + "Secret restored from the trash": "Skrivnost obnovljena iz koša", + "Secret deleted for good": "Skrivnost trajno izbrisana", + "Secret archived": "Skrivnost arhivirana", + "Secret unarchived": "Skrivnost vrnjena iz arhiva", + "Unarchive": "Vrni iz arhiva", + "Could not archive the secret": "Skrivnosti ni bilo mogoče arhivirati", + "Could not unarchive the secret": "Skrivnosti ni bilo mogoče vrniti iz arhiva", + "Archive {count} secrets": "Arhiviraj skrivnosti: {count}", + "Unarchive {count} secrets": "Vrni iz arhiva skrivnosti: {count}", + "Restore {count} secrets": "Obnovi skrivnosti: {count}", + "Delete {count} secrets for good": "Trajno izbriši skrivnosti: {count}", + "Done for {ok} of {total} secrets": "Končano za {ok} od {total} skrivnosti", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arhivirane skrivnosti izginejo s seznama trezorja, iz iskanja, samodejnega izpolnjevanja in poročila o stanju. Ohranijo deljenja. Najdete jih v Arhivu.", + "These secrets come back to the vault list, search and autofill.": "Te skrivnosti se vrnejo na seznam trezorja, v iskanje in samodejno izpolnjevanje.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Te skrivnosti se vrnejo na seznam trezorja. Stara deljenja se ne vrnejo, zato jih po potrebi delite znova.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "S tem se izbrišejo skrivnosti skupaj s prilogami in zgodovino različic. Tega ni mogoče razveljaviti.", + "Delete for good": "Trajno izbriši", + "Trash": "Koš", + "The trash is empty": "Koš je prazen", + "No archived secrets": "Ni arhiviranih skrivnosti", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Izbrisane skrivnosti čakajo tukaj do konca obdobja hrambe, nato se trajno izbrišejo.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arhivirajte skrivnost na njeni podrobni plošči, da ostane zunaj seznama trezorja, iskanja in samodejnega izpolnjevanja.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Omejitve za šifrirane priloge (uveljavljene na strežniku v shranjenih šifriranih bajtih), hramba zgodovine različic in kako dolgo izbrisane skrivnosti ostanejo v košu.", + "Days a deleted secret stays in the trash (1 to 365)": "Število dni, ko izbrisana skrivnost ostane v košu (od 1 do 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "S tem se skrivnost premakne v koš in njena deljenja se takoj končajo. Iz koša jo lahko obnovite do konca obdobja hrambe: 30 dni, razen če je skrbnik to spremenil.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "S tem se skrivnosti premaknejo v koš ({count}) in njihova deljenja se takoj končajo. Iz koša jih lahko obnovite do konca obdobja hrambe.", + "Remove {name} from favourites": "Odstrani {name} iz priljubljenih", + "Add {name} to favourites": "Dodaj {name} med priljubljene", + "Could not change the favourite": "Priljubljenega ni bilo mogoče spremeniti", + "Remove from favourites": "Odstrani iz priljubljenih", + "Add to favourites": "Dodaj med priljubljene", + "Tags": "Oznake", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Oznake niso šifrirane. Skrbniki strežnika jih lahko berejo, tako kot imena map.", + "Favourites": "Priljubljeni", + "Filter by tag": "Filtriraj po oznaki", + "All tags": "Vse oznake", + "Last used": "Nazadnje uporabljeno", + "Tags for {count} secrets": "Oznake za {count} skrivnosti", + "Tag": "Oznaka", + "Remove tag": "Odstrani oznako", + "Add tag": "Dodaj oznako", + "Could not change the tags. Try again.": "Oznak ni bilo mogoče spremeniti. Poskusite znova.", + "Could not approve the application. It is still in the queue.": "Prijave ni bilo mogoče odobriti. Še vedno je v čakalni vrsti.", + "Could not reject the application. It is still in the queue.": "Prijave ni bilo mogoče zavrniti. Še vedno je v čakalni vrsti.", + "Removed the user from {count} team folders.": "Uporabnik je bil odstranjen iz {count} skupinskih map.", + "Approve a share": "Odobri souporabo", + "This approval link is incomplete. Open it again from the notification.": "Ta povezava za odobritev je nepopolna. Ponovno jo odprite iz obvestila.", + "Deny": "Zavrni", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} se je pridružil skupini, s katero delite skrivnost. Želite skrivnost deliti tudi z njim?", + "{requester} asks you to share a secret with {user}.": "{requester} vas prosi, da skrivnost delite z uporabnikom {user}.", + "Shared. The recipient can now open the secret.": "V souporabi. Prejemnik lahko zdaj odpre skrivnost.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Prejemnik še ni nastavil Keepiq, zato ni bilo nič deljeno. Poskusite znova, ko to stori.", + "Could not share the secret. Only its owner can approve this.": "Skrivnosti ni bilo mogoče deliti. To lahko odobri le njen lastnik.", + "Could not share the secret. Try again.": "Skrivnosti ni bilo mogoče deliti. Poskusite znova.", + "Denied. Nothing was shared.": "Zavrnjeno. Nič ni bilo deljeno.", + "Could not deny the request. Try again.": "Zahteve ni bilo mogoče zavrniti. Poskusite znova.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s vas prosi, da skrivnost \"%2$s\" delite z uporabnikom %3$s.", + "Expires on (optional)": "Poteče (izbirno)", + "Hand over to": "Predaj uporabniku", + "Choose a recipient": "Izberite prejemnika", + "Hand over temporarily": "Začasno predaj", + "Expiry rules": "Pravila poteka", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Nastavite, kako dolgo smejo veljati gesla ene vrste predmeta ali v eni mapi in kdaj želite opomnik. Če velja več datumov, šteje najzgodnejši.", + "Delete rule": "Izbriši pravilo", + "Set by your administrator": "Nastavil vaš skrbnik", + "No expiry rules yet.": "Pravil poteka še ni.", + "Applies to": "Velja za", + "Item type": "Vrsta predmeta", + "Maximum age in days (empty for reminders only)": "Največja starost v dneh (prazno samo za opomnike)", + "Remind me this many days before, comma separated": "Opomni me toliko dni prej, ločeno z vejicami", + "Save rule": "Shrani pravilo", + "An item type": "Vrsta predmeta", + "A folder": "Mapa", + "Folder {name}": "Mapa {name}", + "Type {name}": "Vrsta {name}", + "Expires after {days} days": "Poteče po {days} dneh", + "Reminders {days} days before": "Opomniki {days} dni prej", + "Could not save the expiry rule.": "Pravila poteka ni bilo mogoče shraniti.", + "Could not delete the expiry rule.": "Pravila poteka ni bilo mogoče izbrisati.", + "All statuses": "Vsa stanja", + "Compromised": "Ogrožena", + "Could not load the members.": "Članov ni bilo mogoče naložiti.", + "Emergency contact": "Kontakt za nujne primere", + "Leaving user": "Odhajajoči uporabnik", + "No": "Ne", + "No users match this filter.": "Temu filtru ne ustreza noben uporabnik.", + "Not set up": "Ni nastavljeno", + "Revoke suite": "Prekliči zbirko", + "Revoked": "Preklicana", + "Search users": "Iskanje uporabnikov", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Oglejte si, kateri uporabniki so nastavili trezor. Začnite odjavo ali prekličite zbirko iz vrstice.", + "Successor": "Naslednik", + "Team folders": "Ekipne mape", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Uporabnik je še vedno v skupini {groups}, ki je članica ekipne mape. Odstranite ga iz skupine ali onemogočite račun.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.", + "Vault status": "Stanje trezorja", + "Yes": "Da", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Izvoz v CXF NI ŠIFRIRAN. Vsako geslo in prijava bosta v preneseni datoteki berljiva kot navadno besedilo. Datoteko varno shranite in jo takoj po uporabi izbrišite.", + "Root certificate expiring soon": "Korensko potrdilo kmalu poteče", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Korensko potrdilo trezorja poteče čez %1$d dni. Obnovite ga pred tem. Obnova znova podpiše vsak šifrirni paket.", + "Compromise recovery aborted": "Obnovitev po ogrožanju prekinjena", + "Key rotation ended by a compromise revoke": "Menjavo ključa je končal preklic zaradi ogrožanja", + "Encryption suite revoke refused": "Preklic šifrirnega kompleta zavrnjen", + "Master password proof refused": "Dokaz glavnega gesla zavrnjen", + "Your current master password": "Vaše trenutno glavno geslo", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n stik za nujne primere je imel čakajočo zahtevo za dostop, ko ga je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ti stiki za nujne primere niso bili preneseni na vaš novi ključ. Njihov dostop v sili je bil odstranjen. Če jih še želite, jih znova dodajte v Dostopu v sili.", + "Renew root certificate": "Obnovi korensko potrdilo", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ustvari se novo korensko in vmesno potrdilo. Vsak aktiven šifrirni paket se ponovno podpiše. Tega ni mogoče razveljaviti.", + "Renew root": "Obnovi koren", + "Root renewed. {n} encryption suites signed again.": "Koren obnovljen. Ponovno podpisanih šifrirnih paketov: {n}.", + "Could not renew the root certificate.": "Korenskega potrdila ni bilo mogoče obnoviti.", + "Lease policy for this application": "Pravilnik zakupa za ta program", + "In force now: {default} seconds by default, {max} seconds at most.": "Zdaj velja: privzeto {default} sekund, največ {max} sekund.", + "Leases are not renewable": "Zakupov ni mogoče podaljšati", + "Lease policy saved.": "Pravilnik zakupa je shranjen.", + "Leave a field empty to use the instance value.": "Pustite polje prazno, da uporabite vrednost primerka.", + "Instance value: {value}": "Vrednost primerka: {value}", + "Renewal": "Podaljšanje", + "Use the instance value ({value})": "Uporabi vrednost primerka ({value})", + "Allowed": "Dovoljeno", + "Not allowed": "Ni dovoljeno", + "Save lease policy": "Shrani pravilnik zakupa", + "Only an administrator can change this policy.": "Ta pravilnik lahko spremeni samo skrbnik.", + "Could not save the lease policy.": "Pravilnika zakupa ni bilo mogoče shraniti.", + "{member} got access from {confirmer}.": "{member} je dobil dostop od {confirmer}.", + "Automatically confirm new team folder members": "Samodejno potrdi nove člane ekipnih map", + "Gave %n new member access to a team folder.": "%n nov član je dobil dostop do ekipne mape.", + "Gave %n new members access to a team folder.": "Novi člani (%n) so dobili dostop do ekipne mape.", + "Give new team folder members access without waiting for the folder owner.": "Novim članom omogočite dostop brez čakanja na lastnika mape.", + "New team folder members": "Novi člani ekipnih map", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Lastnik ali član s pravico pisanja jih potrdi iz odprtega trezorja. Keepiq nikoli ne dešifrira na strežniku.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Čakanje, da član s pravico pisanja odpre Keepiq. Lahko pa delite že zdaj.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Del odziva na ogroženost ni uspel ({failed} korak(ov)). Preverite strežniški dnevnik in nato znova prekličite zbirko, da jo dokončate.", + "This also revoked suite {suite} and ended key migration {migration}.": "S tem je bila preklicana tudi zbirka {suite} in končana selitev ključev {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Preklic druge zbirke je izbrisal %n stik za nujni dostop.", + "Revoking the second suite deleted %n emergency-access contacts.": "Preklic druge zbirke je izbrisal %n stikov za nujni dostop.", + "A suite revoked as compromised cannot be reinstated.": "Zbirke, preklicane kot ogrožene, ni mogoče obnoviti.", + "Archives to keep": "Arhivi za hrambo", + "Back up every vault automatically": "Samodejno varnostno kopiraj vsak trezor", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Varnostno kopirajte vsak trezor po urniku. Arhivi vsebujejo le šifrirano besedilo in se obnovijo z occ.", + "Back up now": "Kopiraj zdaj", + "Backup public key (PEM, optional)": "Javni ključ varnostne kopije (PEM, neobvezno)", + "Backup requested for the next cron run": "Kopija zahtevana za naslednji zagon crona", + "Encrypted": "Šifrirano", + "Every (hours)": "Vsakih (ur)", + "Last backup {when} failed: {error}": "Zadnja kopija {when} ni uspela: {error}", + "Last backup {when} succeeded.": "Zadnja kopija {when} je uspela.", + "No archives yet.": "Še ni arhivov.", + "Size": "Velikost", + "Vault backups": "Varnostne kopije trezorja", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "S ključem se vsak arhiv šifrira zanj. Zasebni ključ hranite zunaj tega strežnika: potrebujete ga za preverjanje ali obnovo.", + "Written": "Zapisano", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n uporabnik v obsegu še nima dvostopenjske prijave in ne more odpreti trezorja, dokler je to vklopljeno.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Rezervne kode ne štejejo. Če se vaši uporabniki prijavljajo prek ponudnika identitete z lastnim drugim faktorjem, izpustite njihove skupine.", + "Block personal vault export": "Blokiraj izvoz osebnega trezorja", + "Keep work logins in team folders": "Službene prijave hrani v ekipnih mapah", + "Move to a team folder": "Premakni v ekipno mapo", + "Not in a team folder": "Ni v ekipni mapi", + "Only for these groups (empty is everyone)": "Samo za te skupine (prazno pomeni vse)", + "Require two-factor login before the vault opens": "Zahtevaj dvostopenjsko prijavo pred odprtjem trezorja", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Pravila za vsak trezor. Vsako velja za vse ali le za skupine, ki jih izberete.", + "Secret types that belong in a team folder": "Vrste skrivnosti, ki sodijo v ekipno mapo", + "Set up two-factor login": "Nastavi dvostopenjsko prijavo", + "Team folder you can write to": "Ekipna mapa, v katero lahko pišete", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Uporabniki ne morejo prenesti varnostne kopije, CSV ali datoteke za prenos. Njihov paket osebnih podatkov ostane na voljo.", + "Users cannot save these secret types in a personal folder.": "Uporabniki teh vrst skrivnosti ne morejo shraniti v osebno mapo.", + "Vault policies": "Pravila trezorja", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Vaša organizacija ne dovoli izvoza vašega osebnega trezorja. Vaš paket osebnih podatkov v nastavitvah ostane na voljo.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Vaša organizacija hrani te skrivnosti v ekipni mapi. Vsako premaknite v ekipno mapo.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Vaša organizacija hrani to vrsto skrivnosti v ekipni mapi. Izberite eno od svojih ekipnih map ali tisto, v katero lahko pišete.", + "Your organisation requires two-factor login before you can open your vault.": "Vaša organizacija zahteva dvostopenjsko prijavo, preden lahko odprete svoj trezor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Uporabniki izberejo, kako dolgo razširitev ostane odklenjena med nedejavnostjo. Vi nastavite najdaljši čas, ki ga lahko izberejo.", + "Longest idle time before the extension locks": "Najdaljši čas nedejavnosti, preden se razširitev zaklene", + "1 minute": "1 minuta", + "5 minutes": "5 minut", + "15 minutes": "15 minut", + "1 hour": "1 ura", + "4 hours": "4 ure", + "Connector": "Povezovalnik", + "Directory (tenant) ID": "ID imenika (najemnika)", + "Application (client) ID": "ID aplikacije (odjemalca)", + "Data collection rule immutable ID": "Nespremenljivi ID pravila zbiranja podatkov", + "Stream name": "Ime toka", + "Splunk index (optional)": "Indeks Splunk (neobvezno)", + "Sourcetype (optional)": "Sourcetype (neobvezno)", + "Leave blank to keep the current one": "Pustite prazno, da ostane trenutna vrednost", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF prek sysloga", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Končna točka zbiranja podatkov (URL https)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collectorja (https)", + "Client secret (write-only)": "Skrivnost odjemalca (samo pisanje)", + "HEC token (write-only)": "Žeton HEC (samo pisanje)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Posredujte dovoljene revizijske dogodke v Splunk, Microsoft Sentinel, sprejemnik syslog ali spletni kavelj. Sporočila vsebujejo le očiščene metapodatke: nobena skrivna vrednost, ime, prijava ali šifrirano besedilo nikoli ne zapusti strežnika.", + "%n change waiting to sync": "%n sprememba čaka na sinhronizacijo", + "%n changes waiting to sync": "%n sprememb čaka na sinhronizacijo", + "Changes that could not sync": "Spremembe, ki jih ni bilo mogoče sinhronizirati", + "Choose a version": "Izberi različico", + "Copy value": "Kopiraj vrednost", + "Deleted": "Izbrisano", + "Discard": "Zavrzi", + "Keep my offline change": "Obdrži mojo spremembo brez povezave", + "Keep the server version": "Obdrži različico s strežnika", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq je brez povezave samo za branje. Skrbnik ni vklopil urejanja brez povezave.", + "Let users edit secrets offline": "Dovoli uporabnikom urejanje skrivnosti brez povezave", + "Not synced yet": "Še ni sinhronizirano", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Spremembe brez povezave ostanejo na napravi, šifrirane za uporabnika, in se sinhronizirajo ob naslednjem odklepanju s povezavo. Deljenje, mape in priloge še vedno potrebujejo povezavo.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Brez povezave. Urejanja, premiki in izbrisi ostanejo na tej napravi in se sinhronizirajo, ko boste spet povezani. Deljenje in priloge potrebujejo povezavo.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Brez povezave. Vaše spremembe ostanejo na tej napravi in se sinhronizirajo, ko boste spet povezani. Nazadnje sinhronizirano {when}.", + "Open my changes": "Odpri moje spremembe", + "Sharing needs a connection": "Deljenje potrebuje povezavo", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Nekdo je spremenil to skrivnost na strežniku, potem ko je bila narejena vaša kopija brez povezave. Izberite, katero različico obdržati.", + "Sync or discard your offline changes before you rotate your keys.": "Pred zamenjavo ključev sinhronizirajte ali zavrzite spremembe brez povezave.", + "That password did not open your changes.": "S tem geslom vaših sprememb ni bilo mogoče odpreti.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Posnetek brez povezave hrani šifrirane skrivnosti (odpreti jih je mogoče le s ključem, izpeljanim iz glavnega gesla uporabnika, natanko kot na strežniku) in šifrira imena, URL-je in imena map v hrambi. Dostop brez povezave je samo za branje, razen če spodaj dovolite urejanje brez povezave. Izklopite to za naprave, ki nikoli ne smejo predpomniti poverilnic; izklop izbriše obstoječe predpomnilnike ob naslednjem nalaganju.", + "The previous vault copy is gone, so these changes cannot be opened.": "Prejšnje kopije trezorja ni več, zato teh sprememb ni mogoče odpreti.", + "The server version": "Različica s strežnika", + "This secret changed while you were offline": "Ta skrivnost se je spremenila, ko ste bili brez povezave", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "To skrivnost ste izbrisali brez povezave, vendar je bila medtem spremenjena na strežniku. Izberite, katero različico obdržati.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Vaši ključi so bili zamenjani na drugi napravi. Vnesite prejšnje glavno geslo, da sinhronizirate spremembe brez povezave, ali jih zavrzite.", + "Your offline change": "Vaša sprememba brez povezave", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n stik za nujne primere je imel čakajočo zahtevo za dostop, ko ga je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate.", + "Stiki za nujne primere (%n) so imeli čakajočo zahtevo za dostop, ko jih je menjava ključa odstranila. Preverite, kdo je zahteval, preden koga znova dodate." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n predmeta ni mogoče predstaviti v obliki CXF in bo preskočen.", + "%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni.", + "%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni.", + "%n predmetov ni mogoče predstaviti v obliki CXF in bodo preskočeni." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n starejša različica je bila zavržena, ker je mogoče prenesti le nedavno zgodovino.", + "%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino.", + "%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino.", + "%n starejših različic je bilo zavrženih, ker je mogoče prenesti le nedavno zgodovino." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopijo skrivnosti je treba še šifrirati in dati v souporabo.", + "%n kopij skrivnosti je treba še šifrirati in dati v souporabo.", + "%n kopij skrivnosti je treba še šifrirati in dati v souporabo.", + "%n kopij skrivnosti je treba še šifrirati in dati v souporabo." + ], + "_%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 skrivnosti ni bilo mogoče dešifrirati in je ni v tem izvozu.", + "%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.", + "%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu.", + "%n skrivnosti ni bilo mogoče dešifrirati in jih ni v tem izvozu." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato ni bila preseljena.", + "%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene.", + "%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene.", + "%n skrivnosti ni bilo mogoče odšifrirati z vašim starim ključem, zato niso bile preseljene." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n skrivnost ni bila preseljena.", + "%n skrivnosti ni bilo preseljenih.", + "%n skrivnosti ni bilo preseljenih.", + "%n skrivnosti ni bilo preseljenih." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n skrivnost je še vedno šifrirana z vašim prejšnjim ključem.", + "%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem.", + "%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem.", + "%n skrivnosti je še vedno šifriranih z vašim prejšnjim ključem." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n skrivnost je bila preskočena, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.", + "%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.", + "%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova.", + "%n skrivnosti je bilo preskočenih, ker naslednik še nima kopije — dodajte naslednika v mapo in zaženite znova." + ], + "_%n secret_::_%n secrets_": [ + "%n skrivnost", + "%n skrivnosti", + "%n skrivnosti", + "%n skrivnosti" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n uporabnik v obsegu še nima dvostopenjske prijave in ne more odpreti trezorja, dokler je to vklopljeno.", + "Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.", + "Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno.", + "Uporabniki v obsegu (%n) še nimajo dvostopenjske prijave in ne morejo odpreti trezorja, dokler je to vklopljeno." + ], + "_Emergency access for %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 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.", + "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Vseeno zaključi in izgubi dostop do %n skrivnosti", + "Vseeno zaključi in izgubi dostop do %n skrivnosti", + "Vseeno zaključi in izgubi dostop do %n skrivnosti", + "Vseeno zaključi in izgubi dostop do %n skrivnosti" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n nov član je dobil dostop do ekipne mape.", + "Novi člani (%n) so dobili dostop do ekipne mape.", + "Novi člani (%n) so dobili dostop do ekipne mape.", + "Novi člani (%n) so dobili dostop do ekipne mape." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rotacija ključa je zaključena. %n skrivnost je bila znova šifrirana z vašim novim ključem.", + "Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem.", + "Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem.", + "Rotacija ključa je zaključena. %n skrivnosti je bilo znova šifriranih z vašim novim ključem." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Preklic druge zbirke je izbrisal %n stik za nujni dostop.", + "Preklic druge zbirke je izbrisal %n stikov za nujni dostop.", + "Preklic druge zbirke je izbrisal %n stikov za nujni dostop.", + "Preklic druge zbirke je izbrisal %n stikov za nujni dostop." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Preklic tega kompleta je izbrisal %n stik zasilnega dostopa.", + "Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.", + "Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa.", + "Preklic tega kompleta je izbrisal %n stikov zasilnega dostopa." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "zabeleženo %n krat v razkritjih", + "zabeleženo %n krat v razkritjih", + "zabeleženo %n krat v razkritjih", + "zabeleženo %n krat v razkritjih" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "v souporabi z %n skrivnostjo", + "v souporabi z %n skrivnostmi", + "v souporabi z %n skrivnostmi", + "v souporabi z %n skrivnostmi" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ta mapa neposredno vsebuje %n skrivnost.", + "Ta mapa neposredno vsebuje %n skrivnosti.", + "Ta mapa neposredno vsebuje %n skrivnosti.", + "Ta mapa neposredno vsebuje %n skrivnosti." + ], + "_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._": [ + "Rotacija ključa je odstranila %n stik za nujne primere. Preverite Dostop v nujnih primerih in ga znova dodajte, če ga še želite.", + "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.", + "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite.", + "Rotacija ključa je odstranila %n stikov za nujne primere. Preverite Dostop v nujnih primerih in jih znova dodajte, če jih še želite." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n sprememba čaka na sinhronizacijo", + "%n sprememb čaka na sinhronizacijo", + "%n sprememb čaka na sinhronizacijo", + "%n sprememb čaka na sinhronizacijo" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Uporabnik je še vedno v skupini {groups}, ki je članica ekipne mape. Odstranite ga iz skupine ali onemogočite račun.", + "Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.", + "Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun.", + "Uporabnik je še vedno v skupinah {groups}, ki so članice ekipnih map. Odstranite ga iz skupin ali onemogočite račun." + ], + "Allow approval from another device": "Dovoli odobritev z druge naprave", + "App": "Aplikacija", + "Approve a new device": "Odobri novo napravo", + "Approve from another device": "Odobri z druge naprave", + "Asked at": "Zahtevano ob", + "Check that the new device shows these words:": "Preverite, ali nova naprava prikazuje te besede:", + "Denied. If you did not ask, end your other sessions:": "Zavrnjeno. Če tega niste zahtevali, končajte druge seje:", + "Device": "Naprava", + "IP address": "IP-naslov", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Uporabnikom omogoči odklepanje novega brskalnika z odobritvijo z naprave, na kateri je Keepiq že odklenjen.", + "New device approval": "Odobritev novih naprav", + "Nextcloud security settings": "Varnostne nastavitve Nextcloud", + "Only approve a device you are using right now.": "Odobrite samo napravo, ki jo uporabljate prav zdaj.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Odprite Keepiq na napravi, na kateri je odklenjen, in odobrite to napravo. Preverite, ali prikazuje enake besede:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Naprava, ki odobri, zapečati ključ za odklepanje za novo napravo. Strežnik ga le posreduje in ga ne more odpreti.", + "The master password is not right, or the request has ended.": "Glavno geslo ni pravilno ali pa se je zahteva končala.", + "The request expired. Ask again or use your master password.": "Zahteva je potekla. Zahtevajte znova ali uporabite glavno geslo.", + "The request was denied.": "Zahteva je bila zavrnjena.", + "Too many requests. Try again in an hour or use your master password.": "Preveč zahtev. Poskusite znova čez eno uro ali uporabite glavno geslo.", + "Unknown device": "Neznana naprava", + "Web app": "Spletna aplikacija", + "A device": "Naprava", + "A new device asks to open your vault": "Nova naprava želi odpreti vaš trezor", + "%s asks to be approved. Only approve a device you are using right now.": "%s prosi za odobritev. Odobrite samo napravo, ki jo uporabljate prav zdaj.", + "Access ends on (optional)": "Dostop poteče (neobvezno)", + "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.": "Aplikacije Keepiq gesla ne bodo prikazale ali kopirale. Nekdo s tehničnim znanjem ga lahko še vedno prebere na svoji napravi. Ko dostop poteče, geslo zamenjajte.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ta skrivnost je samo za uporabo. Prijavite se prek razširitve brskalnika Keepiq.", + "Until {date}": "Do {date}", + "Use only": "Samo uporaba", + "Use only (can sign in, cannot view or copy)": "Samo uporaba (lahko se prijavi, ne more videti ali kopirati)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "S to prijavo se lahko prijavite prek razširitve brskalnika Keepiq. Lastnik se je odločil, da je ne morete videti ali kopirati.", + "Your access ends on {date}": "Vaš dostop poteče {date}", + "Your access to this secret has ended": "Vaš dostop do te skrivnosti je potekel", + "Your access to \"%s\" ends tomorrow": "Vaš dostop do »%s« poteče jutri", + "Your access to \"%s\" has ended": "Vaš dostop do »%s« je potekel", + "%1$s no longer has access to \"%2$s\"": "%1$s nima več dostopa do »%2$s«", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s je lahko videl(a) to geslo. Zamenjajte ga, če ga %1$s ne sme več poznati.", + "%s could not view this password in Keepiq.": "%s tega gesla v Keepiq ni mogel(a) videti.", + "{approvals} of {threshold} approvals": "{approvals} od {threshold} odobritev", + "a recovery officer": "pooblaščenec za obnovitev", + "Account recovery": "Obnovitev računa", + "Approvals needed": "Potrebne odobritve", + "Ask {user} which words they see, by phone or in person. They must be:": "Vprašajte uporabnika {user}, katere besede vidi, po telefonu ali osebno. Biti morajo:", + "Check again": "Preveri znova", + "Create the recovery key": "Ustvari obnovitveni ključ", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Ustvarite obnovitveni ključ. Vaš brskalnik ga izdela in vsakemu pooblaščencu da kopijo, ki jo lahko odpre samo on.", + "Decline": "Zavrni", + "Enrol in account recovery": "Prijavite se v obnovitev računa", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Prijavite se, da vam organizacija lahko pomaga dobiti trezor nazaj, če pozabite glavno geslo.", + "Every user is enrolled": "Vsi uporabniki so prijavljeni", + "Finish the recovery in the browser you asked from.": "Dokončajte obnovitev v brskalniku, iz katerega ste jo zahtevali.", + "Forgot your master password?": "Ste pozabili glavno geslo?", + "Hand the key over": "Predaj ključ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Uporabnikom, ki so pozabili glavno geslo, omogočite, da dobijo trezor nazaj, z odobritvijo pooblaščencev za obnovitev, ki jih imenujete.", + "New master password": "Novo glavno geslo", + "No one is asking to recover their account.": "Nihče ne zahteva obnovitve računa.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Obnovitvenega ključa še ni. Eden od pooblaščencev ga ustvari v svojih nastavitvah Keepiq.", + "Off": "Izklopljeno", + "Officer {user} has no encryption set up yet.": "Pooblaščenec {user} še nima nastavljenega šifriranja.", + "Officers (user IDs, separated by commas)": "Pooblaščenci (ID-ji uporabnikov, ločeni z vejicami)", + "Policy": "Pravilnik", + "Publish this fingerprint internally, so users can check it before they enrol.": "Objavite ta prstni odtis interno, da ga uporabniki lahko preverijo pred prijavo.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Obnovljeno s pomočjo {officer}. Zdaj zamenjajte ključ trezorja v Nastavitve, Varnost: \"Moje glavno geslo je bilo ogroženo\".", + "Recovery key fingerprint: {fingerprint}": "Prstni odtis obnovitvenega ključa: {fingerprint}", + "Recovery officer": "Pooblaščenec za obnovitev", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Odstranjeni pooblaščenci zdaj izgubijo svojo kopijo, vendar so jo morda že prej odprli. Naj pooblaščenec ustvari nov obnovitveni ključ.", + "Repeat the new master password": "Ponovite novo glavno geslo", + "Retire this recovery key": "Umakni ta obnovitveni ključ", + "Set the new master password": "Nastavi novo glavno geslo", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Obnovitvenega potrdila ni izdal ta Keepiq. Ne prijavite se in obvestite skrbnika.", + "The words match, approve": "Besede se ujemajo, odobri", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ta uporabnik je prijavljen v obnovitev računa. Obnovitev ohrani njegove skrivnosti; preklic izbriše njegovo prijavo.", + "Users may enrol": "Uporabniki se lahko prijavijo", + "Withdraw from account recovery": "Odjavi se iz obnovitve računa", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Prijavljeni ste v obnovitev računa. Prstni odtis obnovitvenega ključa: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Prijavljeni ste. Če pozabite glavno geslo, vam organizacija lahko pomaga dobiti trezor nazaj.", + "Your key is back. Choose a new master password.": "Ključ je spet vaš. Izberite novo glavno geslo.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Vaši pooblaščenci za obnovitev so obveščeni. Preberite jim te besede, ko vas pokličejo ali se srečate:", + "You are now an account recovery officer": "Zdaj ste pooblaščenec za obnovitev računov", + "%s asks to recover their account. Compare the words with them before you approve.": "%s zahteva obnovitev računa. Pred odobritvijo z njim primerjajte besede.", + "A user": "Uporabnik", + "Your account recovery request was declined": "Vaša zahteva za obnovitev računa je bila zavrnjena", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Obnovitev računa je pripravljena. Odprite Keepiq v brskalniku, iz katerega ste jo zahtevali.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} prosi za enkratno odklepanje nove naprave. Glavno geslo ostane enako.", + "Ask your organisation instead": "Raje vprašajte svojo organizacijo", + "The request ended. Ask again or use your master password.": "Zahteva se je končala. Vprašajte znova ali uporabite glavno geslo.", + "Added by {user}": "Dodal(a) {user}", + "Editor": "Urednik", + "Manager": "Upravitelj", + "Role of {member}": "Vloga uporabnika {member}", + "Team folders you manage": "Skupinske mape, ki jih upravljate", + "Viewer": "Bralec", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nimate kopije teh skrivnosti, zato jih novi člani še niso prejeli. Lastnik jih lahko deli: {names}", + "Admin areas": "Področja upravljanja", + "Give a group only the parts of Keepiq administration it needs.": "Skupini dajte samo tiste dele upravljanja Keepiq, ki jih potrebuje.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Dodelite eno ali več področij skupini na strani skrbniških pravic. Skrbniki primerka imajo vsa področja.", + "Open administration privileges": "Odpri skrbniške pravice", + "Policies": "Pravilniki", + "Applications and machine access": "Aplikacije in dostop strojev", + "People and offboarding": "Ljudje in odhodi", + "Audit and compliance": "Revizija in skladnost", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "različica, overitelj potrdil, priloge, predpomnilnik brez povezave, preverjanje uhajanja, vrste skrivnosti in varnostne kopije", + "master password, organisation password, vault policies, rotation, version history and trash": "glavno geslo, geslo organizacije, pravilniki trezorja, rotacija, zgodovina različic in koš", + "application queue, application requests and machine leases": "vrsta aplikacij, zahteve aplikacij in najemi strojev", + "team offboarding, encryption suites and admin handover": "odhodi iz ekipe, šifrirni nabori in prevzem s strani skrbnika", + "audit log, compliance reports, SIEM export and honey alerts": "revizijski dnevnik, poročila o skladnosti, izvoz SIEM in opozorila vab", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Koliko različic skrivnosti se hrani, kako dolgo in kako dolgo izbrisane skrivnosti ostanejo v košu.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Omejitve za šifrirane priloge, ki jih strežnik uveljavlja v shranjenih šifriranih bajtih.", + "Type the suite ID again to confirm": "Za potrditev znova vnesite ID nabora", + "This does not match the suite ID.": "To se ne ujema z ID-jem nabora.", + "Confirm with your master password": "Potrdite z glavnim geslom", + "Confirm": "Potrdi", + "That master password is not right.": "To glavno geslo ni pravilno.", + "You are sharing with someone new. Enter your master password to confirm.": "Delite z novo osebo. Za potrditev vnesite glavno geslo.", + "Enter your master password to confirm this share.": "Za potrditev te souporabe vnesite glavno geslo.", + "Enter your master password to confirm this delegation.": "Za potrditev tega pooblastila vnesite glavno geslo.", + "Approve {member}": "Odobri {member}", + "Recipient": "Prejemnik", + "No vault yet": "Še nima trezorja", + "No matching users": "Ni ustreznih uporabnikov", + "Partner organisations": "Partnerske organizacije", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Izmenjujte skrivnosti z drugim Keepiq. Oba skrbnika se dodata drug drugemu in pred shranjevanjem primerjata korenska prstna odtisa po telefonu ali osebno.", + "Federation needs Nextcloud 33 or later.": "Federacija zahteva Nextcloud 33 ali novejši.", + "Your root fingerprint": "Vaš korenski prstni odtis", + "No partners yet.": "Partnerjev še ni.", + "Users here may share to this partner": "Uporabniki tukaj lahko delijo s tem partnerjem", + "This partner may share to users here": "Ta partner lahko deli z uporabniki tukaj", + "Partner address": "Naslov partnerja", + "Check partner": "Preveri partnerja", + "Partner root fingerprint": "Korenski prstni odtis partnerja", + "I compared this fingerprint with the partner's administrator": "Ta prstni odtis sem primerjal s skrbnikom partnerja", + "Add partner": "Dodaj partnerja", + "A secret from another organisation": "Skrivnost iz druge organizacije", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Uporabnik %1$s je z vami delil \"%2$s\". Sprejmite jo v razdelku Prejeto iz drugih organizacij.", + "Incoming from other organisations": "Prejeto iz drugih organizacij", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Osebe v partnerskih organizacijah lahko z vami delijo skrivnost. Sprejmite jo, da v svojem trezorju obdržite kopijo samo za branje.", + "Nothing shared with you yet": "Z vami še ni nič deljeno", + "Secrets that people in partner organisations share with you appear here.": "Skrivnosti, ki jih z vami delijo osebe v partnerskih organizacijah, so prikazane tukaj.", + "From {sender}": "Od {sender}", + "Accept": "Sprejmi", + "Open in vault": "Odpri v trezorju", + "The other organisation did not hand over the secret. Try again later.": "Druga organizacija skrivnosti ni predala. Poskusite znova pozneje.", + "Set up your vault before you accept a shared secret.": "Preden sprejmete deljeno skrivnost, nastavite svoj trezor.", + "Something went wrong. Try again.": "Nekaj je šlo narobe. Poskusite znova.", + "Waiting for your answer": "Čaka na vaš odgovor", + "In your vault, read-only": "V vašem trezorju, samo za branje", + "Withdrawn by the sender": "Pošiljatelj je preklical", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} je to delil iz druge organizacije. Lahko to berete, ne morete pa tega spreminjati ali deliti.", + "Someone": "Nekdo", + "Share with someone at another organisation": "Deli z nekom iz druge organizacije", + "Their account at the other organisation": "Račun te osebe v drugi organizaciji", + "Check account": "Preveri račun", + "Certificate fingerprint of {account}": "Prstni odtis potrdila za {account}", + "Compare it with them by phone if you want to be sure.": "Če želite biti prepričani, ga primerjajte s to osebo po telefonu.", + "Shared. {account} can accept it in their own vault.": "Deljeno. {account} ga lahko sprejme v svoj trezor.", + "The certificate could not be verified. Nothing was shared.": "Potrdila ni bilo mogoče preveriti. Nič ni bilo deljeno.", + "That organisation is not one of your partners.": "Ta organizacija ni med vašimi partnerji.", + "No one with that account can receive secrets from you.": "Nihče s tem računom ne more prejemati skrivnosti od vas.", + "The other organisation did not answer. Try again later.": "Druga organizacija se ni odzvala. Poskusite znova pozneje.", + "This secret is already shared with that account.": "Ta skrivnost je s tem računom že deljena.", + "Other organisations": "Druge organizacije", + "Receive secrets from other organisations": "Prejemanje skrivnosti iz drugih organizacij", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Osebe v partnerskih organizacijah lahko nato najdejo vaš račun in z vami delijo skrivnosti. Vsako sprejmete sami.", + "Shared": "Deljeno", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Začasno ustavljeno: spremenilo se je njihovo potrdilo ali partnerstvo. Razveljavite ali delite znova.", + "Their organisation did not get the last change. Revoke it or share again.": "Njihova organizacija ni prejela zadnje spremembe. Razveljavite ali delite znova.", + "Being withdrawn": "Umika se", + "Shared with another organisation": "Deljeno z drugo organizacijo", + "Change sent to another organisation": "Sprememba poslana drugi organizaciji", + "Share with another organisation revoked": "Souporaba z drugo organizacijo preklicana", + "Share with another organisation paused": "Souporaba z drugo organizacijo začasno ustavljena", + "Another organisation did not get a change": "Druga organizacija ni prejela spremembe", + "Secret received from another organisation": "Skrivnost prejeta iz druge organizacije", + "Secret from another organisation accepted": "Skrivnost iz druge organizacije sprejeta", + "Secret from another organisation declined": "Skrivnost iz druge organizacije zavrnjena", + "Copy from another organisation updated": "Kopija iz druge organizacije posodobljena", + "Copy from another organisation removed": "Kopija iz druge organizacije odstranjena", + "Declined: they removed their copy. Share again if they need it.": "Zavrnjeno: prejemnik je odstranil svojo kopijo. Delite znova, če jo potrebuje.", + "Recipient at another organisation removed their copy": "Prejemnik iz druge organizacije je odstranil svojo kopijo", + "Removed the user from %n team folder.": "Uporabnik je bil odstranjen iz %n skupinske mape.", + "Removed the user from %n team folders.": "Uporabnik je bil odstranjen iz %n skupinskih map.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Uporabnik je bil odstranjen iz %n skupinske mape.", + "Uporabnik je bil odstranjen iz %n skupinskih map.", + "Uporabnik je bil odstranjen iz %n skupinskih map.", + "Uporabnik je bil odstranjen iz %n skupinskih map." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Obnovljena kopija izvira iz deljenja, ki se je končalo. Ostane samo za branje.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizacije, ki je delila obnovljeno kopijo, ni bilo mogoče doseči. Kopija ostane samo za branje in ne sledi njihovim spremembam.", + "Recipient at another organisation restored their copy": "Prejemnik iz druge organizacije je obnovil svojo kopijo" }, "plurals": null } diff --git a/l10n/sq.js b/l10n/sq.js index d6942d8c4..4693fceb7 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Ndarë me grupe", + "Not shared with any group yet.": "Ende nuk është ndarë me asnjë grup.", + "Revoke the share with {group}": "Revoko ndarjen me {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Ndarë me {group}: {received} anëtarë e morën, {skipped} jo, sepse ende nuk kanë konfiguruar enkriptimin.", + "Search groups": "Kërko grupe", + "Failed to share": "Ndarja dështoi", + "Columns": "Kolonat", + "Column {number}": "Kolona {number}", + "Map one column to Name. Every secret needs a name.": "Lidhni një kolonë me emrin. Çdo sekret ka nevojë për një emër.", + "Notes": "Shënime", + "Do not import": "Mos importo", + "Hide this value": "Fshihe këtë vlerë", + "Show this value": "Shfaqe këtë vlerë", + "Defaults": "Parazgjedhjet", + "New secrets start as this type, and your secret list opens in this view.": "Sekretet e reja fillojnë me këtë lloj dhe lista jote e sekreteve hapet në këtë pamje.", + "Default item type": "Lloji i parazgjedhur i elementit", + "Cards": "Kartela", + "Table": "Tabelë", + "Could not save your default": "Parazgjedhja nuk u ruajt", + "Recently used": "Përdorur së fundi", + "Opened": "Hapur", + "You have not opened any secrets yet": "Nuk ke hapur ende asnjë sekret", + "Could not delete the item type.": "Lloji i elementit nuk u fshi dot.", + "Could not load the item types.": "Llojet e elementeve nuk u ngarkuan dot.", + "Could not save the item type.": "Lloji i elementit nuk u ruajt dot.", + "Delete item type": "Fshi llojin e elementit", + "Edit item type": "Ndrysho llojin e elementit", + "Fields": "Fushat", + "Fields: {count}": "Fushat: {count}", + "Hidden": "E fshehur", + "Item types": "Llojet e elementeve", + "Move up": "Lëviz lart", + "New item type": "Lloj i ri elementi", + "No item types defined yet.": "Ende nuk ka lloje elementesh të përcaktuara.", + "Required": "E detyrueshme", + "Text": "Tekst", + "This field is required": "Kjo fushë është e detyrueshme", + "Web address": "Adresë uebi", + "{label} (required)": "{label} (e detyrueshme)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Të fshihet “{name}”? Sekretet e këtij lloji mbeten të lexueshme dhe bëhen elemente Hyrja.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Llojet e elementeve që përcakton këtu u shfaqen të gjithëve te dritarja Sekret i ri, me fushat që zgjedh.", + "Secret moved to the trash": "Sekreti u zhvendos në kosh", + "Secret restored from the trash": "Sekreti u rikthye nga koshi", + "Secret deleted for good": "Sekreti u fshi përgjithmonë", + "Secret archived": "Sekreti u arkivua", + "Secret unarchived": "Sekreti u nxor nga arkivi", + "Unarchive": "Nxirre nga arkivi", + "Could not archive the secret": "Sekreti nuk u arkivua dot", + "Could not unarchive the secret": "Sekreti nuk u nxor dot nga arkivi", + "Archive {count} secrets": "Arkivo sekrete: {count}", + "Unarchive {count} secrets": "Nxirr nga arkivi sekrete: {count}", + "Restore {count} secrets": "Rikthe sekrete: {count}", + "Delete {count} secrets for good": "Fshi përgjithmonë sekrete: {count}", + "Done for {ok} of {total} secrets": "U krye për {ok} nga {total} sekrete", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Sekretet e arkivuara largohen nga lista e kasafortës, kërkimi, plotësimi automatik dhe raporti i shëndetit. Ruajnë ndarjet e tyre. I gjen te Arkivi.", + "These secrets come back to the vault list, search and autofill.": "Këto sekrete kthehen në listën e kasafortës, në kërkim dhe në plotësimin automatik.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Këto sekrete kthehen në listën e kasafortës. Ndarjet e vjetra nuk kthehen, ndaj ndaji sërish ku duhet.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Kjo fshin sekretet bashkë me bashkëngjitjet dhe historikun e versioneve. Nuk mund të zhbëhet.", + "Delete for good": "Fshi përgjithmonë", + "Trash": "Kosh", + "The trash is empty": "Koshi është bosh", + "No archived secrets": "Nuk ka sekrete të arkivuara", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Sekretet e fshira presin këtu deri në fund të periudhës së ruajtjes, pastaj fshihen përgjithmonë.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivo një sekret nga paneli i tij i hollësive për ta mbajtur jashtë listës së kasafortës, kërkimit dhe plotësimit automatik.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Kufij për bashkëngjitjet e enkriptuara (zbatohen në server mbi bajtet e enkriptuara të ruajtura), ruajtja e historikut të versioneve dhe sa kohë qëndrojnë në kosh sekretet e fshira.", + "Days a deleted secret stays in the trash (1 to 365)": "Ditë që një sekret i fshirë qëndron në kosh (1 deri në 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Kjo e zhvendos sekretin në kosh dhe i mbyll tani ndarjet. Mund ta rikthesh nga koshi deri në fund të periudhës së ruajtjes: 30 ditë, përveç nëse administratori e ka ndryshuar.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Kjo i zhvendos sekretet në kosh ({count}) dhe u mbyll tani ndarjet. Mund t'i rikthesh nga koshi deri në fund të periudhës së ruajtjes.", + "Remove {name} from favourites": "Hiq {name} nga të preferuarat", + "Add {name} to favourites": "Shto {name} te të preferuarat", + "Could not change the favourite": "Të preferuarën nuk u ndryshua dot", + "Remove from favourites": "Hiq nga të preferuarat", + "Add to favourites": "Shto te të preferuarat", + "Tags": "Etiketa", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketat nuk janë të fshehtëzuara. Administratorët e shërbyesit mund t'i lexojnë, ashtu si emrat e dosjeve.", + "Favourites": "Të preferuarat", + "Filter by tag": "Filtro sipas etiketës", + "All tags": "Të gjitha etiketat", + "Last used": "Përdorur së fundi", + "Tags for {count} secrets": "Etiketa për {count} sekrete", + "Tag": "Etiketë", + "Remove tag": "Hiq etiketën", + "Add tag": "Shto etiketë", + "Could not change the tags. Try again.": "Etiketat nuk u ndryshuan dot. Provoni sërish.", + "Could not approve the application. It is still in the queue.": "Aplikimi nuk u miratua dot. Është ende në radhë.", + "Could not reject the application. It is still in the queue.": "Aplikimi nuk u refuzua dot. Është ende në radhë.", + "Removed the user from {count} team folders.": "Përdoruesi u hoq nga {count} dosje ekipi.", + "Approve a share": "Mirato një ndarje", + "This approval link is incomplete. Open it again from the notification.": "Kjo lidhje miratimi është e paplotë. Hapeni sërish nga njoftimi.", + "Deny": "Refuzo", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} u bashkua me një grup me të cilin ndani një sekret. Ta ndani sekretin edhe me të?", + "{requester} asks you to share a secret with {user}.": "{requester} ju kërkon të ndani një sekret me {user}.", + "Shared. The recipient can now open the secret.": "U nda. Marrësi tani mund ta hapë sekretin.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Marrësi nuk e ka konfiguruar ende Keepiq, ndaj nuk u nda asgjë. Riprovoni pasi ta ketë bërë.", + "Could not share the secret. Only its owner can approve this.": "Sekreti nuk u nda dot. Vetëm pronari i tij mund ta miratojë këtë.", + "Could not share the secret. Try again.": "Sekreti nuk u nda dot. Riprovoni.", + "Denied. Nothing was shared.": "U refuzua. Nuk u nda asgjë.", + "Could not deny the request. Try again.": "Kërkesa nuk u refuzua dot. Riprovoni.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ju kërkon të ndani sekretin \"%2$s\" me %3$s.", + "Expires on (optional)": "Skadon më (opsionale)", + "Hand over to": "Dorëzoja", + "Choose a recipient": "Zgjidhni një marrës", + "Hand over temporarily": "Dorëzo përkohësisht", + "Expiry rules": "Rregulla skadimi", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Caktoni sa gjatë mund të jetojnë fjalëkalimet e një lloji objekti ose në një dosje, dhe kur të kujtoheni. Kur vlejnë disa data, llogaritet më e hershmja.", + "Delete rule": "Fshi rregullin", + "Set by your administrator": "Caktuar nga administratori juaj", + "No expiry rules yet.": "Ende nuk ka rregulla skadimi.", + "Applies to": "Vlen për", + "Item type": "Lloj objekti", + "Maximum age in days (empty for reminders only)": "Mosha maksimale në ditë (bosh vetëm për kujtesa)", + "Remind me this many days before, comma separated": "Më kujto kaq ditë përpara, të ndara me presje", + "Save rule": "Ruaje rregullin", + "An item type": "Një lloj objekti", + "A folder": "Një dosje", + "Folder {name}": "Dosja {name}", + "Type {name}": "Lloji {name}", + "Expires after {days} days": "Skadon pas {days} ditësh", + "Reminders {days} days before": "Kujtesa {days} ditë përpara", + "Could not save the expiry rule.": "Rregulli i skadimit nuk u ruajt dot.", + "Could not delete the expiry rule.": "Rregulli i skadimit nuk u fshi dot.", + "All statuses": "Të gjitha gjendjet", + "Compromised": "E komprometuar", + "Could not load the members.": "Anëtarët nuk u ngarkuan dot.", + "Emergency contact": "Kontakt urgjence", + "Leaving user": "Përdorues që largohet", + "No": "Jo", + "No users match this filter.": "Asnjë përdorues nuk përputhet me këtë filtër.", + "Not set up": "Pa u konfiguruar", + "Revoke suite": "Revoko paketën", + "Revoked": "E revokuar", + "Search users": "Kërko përdorues", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Shihni cilët përdorues kanë konfiguruar një kasafortë. Nisni largimin ose revokoni një paketë nga një rresht.", + "Successor": "Pasardhës", + "Team folders": "Dosje ekipi", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Përdoruesi është ende në grupin {groups}, që është anëtar i një dosjeje ekipi. Hiqeni nga grupi ose çaktivizoni llogarinë.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Përdoruesi është ende në grupet {groups}, që janë anëtare të dosjeve të ekipit. Hiqeni nga grupet ose çaktivizoni llogarinë.", + "Vault status": "Gjendja e kasafortës", + "Yes": "Po", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Një eksport CXF është I PAKRIPTUAR. Çdo fjalëkalim dhe kredencial hyrjeje do të lexohet si tekst i thjeshtë në skedarin e shkarkuar. Ruajeni në mënyrë të sigurt dhe fshijeni menjëherë pas përdorimit.", + "Root certificate expiring soon": "Certifikata rrënjë skadon së shpejti", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certifikata rrënjë e kasafortës skadon pas %1$d dite(ve). Rinovojeni para kësaj. Rinovimi nënshkruan sërish çdo paketë enkriptimi.", + "Compromise recovery aborted": "Rikuperimi pas komprometimit u ndërpre", + "Key rotation ended by a compromise revoke": "Rrotullimi i çelësit përfundoi nga një revokim për shkak të komprometimit", + "Encryption suite revoke refused": "Revokimi i paketës së enkriptimit u refuzua", + "Master password proof refused": "Prova e fjalëkalimit kryesor u refuzua", + "Your current master password": "Fjalëkalimi yt kryesor aktual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt urgjence kishte një kërkesë aksesi në pritje kur rrotullimi i çelësit e hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n kontakte urgjence kishin një kërkesë aksesi në pritje kur rrotullimi i çelësit i hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Këto kontakte urgjence nuk u transferuan te çelësi yt i ri. Aksesi i tyre i urgjencës u hoq. Shtoji përsëri te Aksesi i urgjencës nëse i do ende.", + "Renew root certificate": "Rinovo certifikatën rrënjë", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Kjo krijon një certifikatë të re rrënjë dhe të ndërmjetme. Çdo paketë aktive enkriptimi nënshkruhet përsëri. Kjo nuk mund të zhbëhet.", + "Renew root": "Rinovo rrënjën", + "Root renewed. {n} encryption suites signed again.": "Rrënja u rinovua. Paketa enkriptimi të nënshkruara përsëri: {n}.", + "Could not renew the root certificate.": "Certifikata rrënjë nuk mund të rinovohej.", + "Lease policy for this application": "Politika e qirasë për këtë aplikacion", + "In force now: {default} seconds by default, {max} seconds at most.": "Në fuqi tani: {default} sekonda si parazgjedhje, më së shumti {max} sekonda.", + "Leases are not renewable": "Qiratë nuk mund të rinovohen", + "Lease policy saved.": "Politika e qirasë u ruajt.", + "Leave a field empty to use the instance value.": "Lëreni një fushë bosh për të përdorur vlerën e instancës.", + "Instance value: {value}": "Vlera e instancës: {value}", + "Renewal": "Rinovimi", + "Use the instance value ({value})": "Përdor vlerën e instancës ({value})", + "Allowed": "Lejohet", + "Not allowed": "Nuk lejohet", + "Save lease policy": "Ruaj politikën e qirasë", + "Only an administrator can change this policy.": "Vetëm një administrator mund ta ndryshojë këtë politikë.", + "Could not save the lease policy.": "Politika e qirasë nuk mund të ruhej.", + "{member} got access from {confirmer}.": "{member} mori qasje nga {confirmer}.", + "Automatically confirm new team folder members": "Konfirmo automatikisht anëtarët e rinj të dosjeve të ekipit", + "Gave %n new member access to a team folder.": "%n anëtar i ri mori qasje në një dosje ekipi.", + "Gave %n new members access to a team folder.": "%n anëtarë të rinj morën qasje në një dosje ekipi.", + "Give new team folder members access without waiting for the folder owner.": "Jepuni qasje anëtarëve të rinj pa pritur pronarin e dosjes.", + "New team folder members": "Anëtarë të rinj të dosjeve të ekipit", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Pronari ose një anëtar me të drejtë shkrimi i konfirmon nga kasaforta e hapur. Keepiq nuk deshifron kurrë në server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Në pritje që një anëtar me të drejtë shkrimi të hapë Keepiq. Mund të ndani edhe tani.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Një pjesë e reagimit ndaj komprometimit dështoi ({failed} hap(a)). Kontrolloni regjistrin e serverit, pastaj revokoni sërish paketën për ta përfunduar.", + "This also revoked suite {suite} and ended key migration {migration}.": "Kjo revokoi edhe paketën {suite} dhe përfundoi migrimin e çelësave {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revokimi i paketës së dytë fshiu %n kontakt aksesi emergjence.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revokimi i paketës së dytë fshiu %n kontakte aksesi emergjence.", + "A suite revoked as compromised cannot be reinstated.": "Një paketë e revokuar si e komprometuar nuk mund të rikthehet.", + "Archives to keep": "Arkiva për t'u mbajtur", + "Back up every vault automatically": "Bëj kopje rezervë të çdo kasaforte automatikisht", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Bëni kopje rezervë të çdo kasaforte sipas orarit. Arkivat përmbajnë vetëm tekst të shifruar dhe rikthehen me occ.", + "Back up now": "Bëj kopje tani", + "Backup public key (PEM, optional)": "Çelësi publik i kopjes (PEM, opsional)", + "Backup requested for the next cron run": "Kopja u kërkua për ekzekutimin e ardhshëm të cron", + "Encrypted": "I shifruar", + "Every (hours)": "Çdo (orë)", + "Last backup {when} failed: {error}": "Kopja e fundit {when} dështoi: {error}", + "Last backup {when} succeeded.": "Kopja e fundit {when} u krye.", + "No archives yet.": "Ende pa arkiva.", + "Size": "Madhësia", + "Vault backups": "Kopjet e kasafortës", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Me një çelës, çdo arkiv shifrohet për të. Mbajeni çelësin privat jashtë këtij serveri: ju duhet për verifikim ose rikthim.", + "Written": "Shkruar", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n përdorues në fushëveprim ende nuk ka hyrje me dy faktorë dhe nuk mund ta hapë kasafortën sa kohë që kjo është aktive.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n përdorues në fushëveprim ende nuk kanë hyrje me dy faktorë dhe nuk mund ta hapin kasafortën sa kohë që kjo është aktive.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Kodet rezervë nuk llogariten. Nëse përdoruesit hyjnë përmes një ofruesi identiteti me faktorin e vet të dytë, lërini jashtë grupet e tyre.", + "Block personal vault export": "Blloko eksportin e kasafortës personale", + "Keep work logins in team folders": "Mbaj kredencialet e punës në dosje ekipi", + "Move to a team folder": "Zhvendose në një dosje ekipi", + "Not in a team folder": "Jo në një dosje ekipi", + "Only for these groups (empty is everyone)": "Vetëm për këto grupe (bosh do të thotë të gjithë)", + "Require two-factor login before the vault opens": "Kërko hyrje me dy faktorë para se të hapet kasaforta", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rregulla për çdo kasafortë. Secili vlen për të gjithë, ose vetëm për grupet që zgjidhni.", + "Secret types that belong in a team folder": "Lloje sekretesh që i përkasin një dosjeje ekipi", + "Set up two-factor login": "Konfiguro hyrjen me dy faktorë", + "Team folder you can write to": "Dosje ekipi ku mund të shkruani", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Përdoruesit nuk mund të shkarkojnë kopje rezervë, CSV ose skedar transferimi. Paketa e tyre e të dhënave personale mbetet e disponueshme.", + "Users cannot save these secret types in a personal folder.": "Përdoruesit nuk mund t'i ruajnë këto lloje sekretesh në një dosje personale.", + "Vault policies": "Politikat e kasafortës", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organizata juaj nuk lejon eksportin e kasafortës suaj personale. Paketa juaj e të dhënave personale në cilësime mbetet e disponueshme.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organizata juaj i mban këto sekrete në një dosje ekipi. Zhvendoseni secilin në një dosje ekipi.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organizata juaj e mban këtë lloj sekreti në një dosje ekipi. Zgjidhni një nga dosjet tuaja të ekipit, ose një ku mund të shkruani.", + "Your organisation requires two-factor login before you can open your vault.": "Organizata juaj kërkon hyrje me dy faktorë para se të mund ta hapni kasafortën tuaj.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Përdoruesit zgjedhin sa kohë zgjatja mbetet e shkyçur kur nuk përdoret. Ju caktoni kohën më të gjatë që mund të zgjedhin.", + "Longest idle time before the extension locks": "Koha më e gjatë pa aktivitet para se zgjatja të kyçet", + "1 minute": "1 minutë", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 orë", + "4 hours": "4 orë", + "Connector": "Lidhës", + "Directory (tenant) ID": "ID e drejtorisë (qiramarrësit)", + "Application (client) ID": "ID e aplikacionit (klientit)", + "Data collection rule immutable ID": "ID e pandryshueshme e rregullit të mbledhjes së të dhënave", + "Stream name": "Emri i rrjedhës", + "Splunk index (optional)": "Indeksi Splunk (opsional)", + "Sourcetype (optional)": "Sourcetype (opsional)", + "Leave blank to keep the current one": "Lëreni bosh për të mbajtur vlerën aktuale", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF përmes syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Pika fundore e mbledhjes së të dhënave (URL https)", + "HTTP Event Collector URL (https)": "URL e HTTP Event Collector (https)", + "Client secret (write-only)": "Sekreti i klientit (vetëm shkrim)", + "HEC token (write-only)": "Token HEC (vetëm shkrim)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Përcillni ngjarjet e lejuara të auditimit te Splunk, Microsoft Sentinel, një marrës syslog ose një webhook. Mesazhet mbajnë vetëm meta të dhëna të pastruara: asnjë vlerë sekrete, emër, hyrje apo tekst i shifruar nuk largohet kurrë nga serveri.", + "%n change waiting to sync": "%n ndryshim pret sinkronizimin", + "%n changes waiting to sync": "%n ndryshime presin sinkronizimin", + "Changes that could not sync": "Ndryshime që nuk u sinkronizuan dot", + "Choose a version": "Zgjidhni një version", + "Copy value": "Kopjo vlerën", + "Deleted": "Fshirë", + "Discard": "Hidhe", + "Keep my offline change": "Mbaj ndryshimin tim jashtë linje", + "Keep the server version": "Mbaj versionin e serverit", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq është vetëm për lexim jashtë linje. Administratori nuk e ka aktivizuar redaktimin jashtë linje.", + "Let users edit secrets offline": "Lejo përdoruesit të redaktojnë sekrete jashtë linje", + "Not synced yet": "Ende i pasinkronizuar", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Ndryshimet jashtë linje mbeten në pajisje, të enkriptuara për përdoruesin, dhe sinkronizohen në shkyçjen e radhës në linjë. Ndarja, dosjet dhe bashkëngjitjet kanë ende nevojë për lidhje.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Jashtë linje. Redaktimet, zhvendosjet dhe fshirjet mbeten në këtë pajisje dhe sinkronizohen kur të jeni sërish në linjë. Ndarja dhe bashkëngjitjet kanë nevojë për lidhje.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Jashtë linje. Ndryshimet tuaja mbeten në këtë pajisje dhe sinkronizohen kur të jeni sërish në linjë. Sinkronizimi i fundit {when}.", + "Open my changes": "Hap ndryshimet e mia", + "Sharing needs a connection": "Ndarja ka nevojë për lidhje", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Dikush e ndryshoi këtë sekret në server pasi u bë kopja juaj jashtë linje. Zgjidhni cilin version të mbani.", + "Sync or discard your offline changes before you rotate your keys.": "Sinkronizoni ose hidhni ndryshimet jashtë linje para se të ndërroni çelësat.", + "That password did not open your changes.": "Ky fjalëkalim nuk i hapi ndryshimet tuaja.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Fotografia jashtë linje ruan sekrete të enkriptuara (hapen vetëm me çelësin që rrjedh nga fjalëkalimi kryesor i përdoruesit, njësoj si në server) dhe enkripton emrat, URL-të dhe emrat e dosjeve në ruajtje. Qasja jashtë linje është vetëm për lexim, përveç nëse lejoni më poshtë redaktimin jashtë linje. Çaktivizojeni për pajisje që nuk duhet të ruajnë kurrë kredenciale; çaktivizimi pastron memorien ekzistuese në ngarkimin e radhës.", + "The previous vault copy is gone, so these changes cannot be opened.": "Kopja e mëparshme e kasafortës nuk është më, ndaj këto ndryshime nuk mund të hapen.", + "The server version": "Versioni i serverit", + "This secret changed while you were offline": "Ky sekret ndryshoi ndërsa ishit jashtë linje", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "E fshitë këtë sekret jashtë linje, por që atëherë është ndryshuar në server. Zgjidhni cilin version të mbani.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Çelësat tuaj u ndërruan në një pajisje tjetër. Jepni fjalëkalimin kryesor të mëparshëm për të sinkronizuar ndryshimet jashtë linje, ose hidhini.", + "Your offline change": "Ndryshimi juaj jashtë linje", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n kontakt urgjence kishte një kërkesë aksesi në pritje kur rrotullimi i çelësit e hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.","%n kontakte urgjence kishin një kërkesë aksesi në pritje kur rrotullimi i çelësit i hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n artikull nuk mund të përfaqësohet në CXF dhe do të anashkalohet.","%n artikuj nuk mund të përfaqësohen në CXF dhe do të anashkalohen."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n version më i vjetër u hoq, sepse mund të kalohet vetëm historiku i kohëve të fundit.","%n versione më të vjetër u hoqën, sepse mund të kalohet vetëm historiku i kohëve të fundit."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopje sekreti duhet ende kriptuar dhe ndarë.","%n kopje sekreti duhen ende kriptuar dhe ndarë."], + "_%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 sekret nuk mund të deshifrohej dhe nuk është në këtë eksport.","%n sekrete nuk mund të deshifroheshin dhe nuk janë në këtë eksport."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n sekret nuk mundi të deshifrohej me kyçin tuaj të vjetër, kështu që nuk u migrua.","%n sekrete nuk mundën të deshifroheshin me kyçin tuaj të vjetër, kështu që nuk u migruan."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n sekret nuk u migrua.","%n sekrete nuk u migruan."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n sekret është ende i kriptuar me kyçin tuaj të mëparshëm.","%n sekrete janë ende të kriptuara me kyçin tuaj të mëparshëm."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n sekret u anashkalua sepse pasardhësi ende nuk ka asnjë kopje — shtojeni pasardhësin në dosje dhe rinisni veprimin.","%n sekrete u anashkaluan sepse pasardhësi ende nuk ka asnjë kopje — shtojeni pasardhësin në dosje dhe rinisni veprimin."], + "_%n secret_::_%n secrets_": ["%n sekret","%n sekrete"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n përdorues në fushëveprim ende nuk ka hyrje me dy faktorë dhe nuk mund ta hapë kasafortën sa kohë që kjo është aktive.","%n përdorues në fushëveprim ende nuk kanë hyrje me dy faktorë dhe nuk mund ta hapin kasafortën sa kohë që kjo është aktive."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Përfundo gjithsesi, duke humbur aksesin në %n sekret","Përfundo gjithsesi, duke humbur aksesin në %n sekrete"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n anëtar i ri mori qasje në një dosje ekipi.","%n anëtarë të rinj morën qasje në një dosje ekipi."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Rrotullimi i kyçit përfundoi. %n sekret u rikriptua me kyçin tuaj të re.","Rrotullimi i kyçit përfundoi. %n sekrete u rikriptuan me kyçin tuaj të re."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Revokimi i paketës së dytë fshiu %n kontakt aksesi emergjence.","Revokimi i paketës së dytë fshiu %n kontakte aksesi emergjence."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Revokimi i kësaj suite fshiu %n kontakt të qasjes së emergjencës.","Revokimi i kësaj suite fshiu %n kontakte të qasjes së emergjencës."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["parë %n herë në rrjedhje","parë %n herë në rrjedhje"], + "_shared with %n secret_::_shared with %n secrets_": ["e ndarë me %n sekret","e ndarë me %n sekrete"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Kjo dosje përmban drejtpërdrejt %n sekret.","Kjo dosje përmban drejtpërdrejt %n sekrete."], + "_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._": ["Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.","Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n ndryshim pret sinkronizimin","%n ndryshime presin sinkronizimin"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Përdoruesi është ende në grupin {groups}, që është anëtar i një dosjeje ekipi. Hiqeni nga grupi ose çaktivizoni llogarinë.","Përdoruesi është ende në grupet {groups}, që janë anëtare të dosjeve të ekipit. Hiqeni nga grupet ose çaktivizoni llogarinë."], + "Allow approval from another device": "Lejo miratimin nga një pajisje tjetër", + "App": "Aplikacion", + "Approve a new device": "Mirato një pajisje të re", + "Approve from another device": "Mirato nga një pajisje tjetër", + "Asked at": "Kërkuar më", + "Check that the new device shows these words:": "Kontrolloni që pajisja e re shfaq këto fjalë:", + "Denied. If you did not ask, end your other sessions:": "U refuzua. Nëse nuk e kërkuat ju, mbyllni seancat e tjera:", + "Device": "Pajisje", + "IP address": "Adresë IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lejoni përdoruesit të shkyçin një shfletues të ri duke e miratuar nga një pajisje ku Keepiq është tashmë i shkyçur.", + "New device approval": "Miratimi i pajisjeve të reja", + "Nextcloud security settings": "Cilësimet e sigurisë së Nextcloud", + "Only approve a device you are using right now.": "Miratoni vetëm një pajisje që po përdorni tani.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Hapni Keepiq në një pajisje ku është i shkyçur dhe miratoni këtë pajisje. Kontrolloni që shfaq të njëjtat fjalë:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Pajisja që miraton vulos çelësin e shkyçjes për pajisjen e re. Serveri vetëm e përcjell dhe nuk mund ta hapë.", + "The master password is not right, or the request has ended.": "Fjalëkalimi kryesor nuk është i saktë ose kërkesa ka përfunduar.", + "The request expired. Ask again or use your master password.": "Kërkesa skadoi. Kërkoni përsëri ose përdorni fjalëkalimin kryesor.", + "The request was denied.": "Kërkesa u refuzua.", + "Too many requests. Try again in an hour or use your master password.": "Shumë kërkesa. Provoni përsëri pas një ore ose përdorni fjalëkalimin kryesor.", + "Unknown device": "Pajisje e panjohur", + "Web app": "Aplikacion web", + "A device": "Një pajisje", + "A new device asks to open your vault": "Një pajisje e re kërkon të hapë kasafortën tuaj", + "%s asks to be approved. Only approve a device you are using right now.": "%s kërkon miratim. Miratoni vetëm një pajisje që po përdorni tani.", + "Access ends on (optional)": "Qasja përfundon më (opsionale)", + "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.": "Aplikacionet e Keepiq nuk do ta shfaqin as ta kopjojnë fjalëkalimin. Dikush me aftësi teknike mund ta lexojë ende nga pajisja e vet. Ndryshojeni kur t'i përfundojë qasja.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ky sekret është vetëm për përdorim. Hyni përmes shtesës së shfletuesit Keepiq.", + "Until {date}": "Deri më {date}", + "Use only": "Vetëm përdorim", + "Use only (can sign in, cannot view or copy)": "Vetëm përdorim (mund të hyjë, nuk mund ta shohë as ta kopjojë)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Mund të hyni me këto kredenciale përmes shtesës së shfletuesit Keepiq. Pronari zgjodhi të mos ju lejojë t'i shihni apo t'i kopjoni.", + "Your access ends on {date}": "Qasja juaj përfundon më {date}", + "Your access to this secret has ended": "Qasja juaj në këtë sekret ka përfunduar", + "Your access to \"%s\" ends tomorrow": "Qasja juaj në \"%s\" përfundon nesër", + "Your access to \"%s\" has ended": "Qasja juaj në \"%s\" ka përfunduar", + "%1$s no longer has access to \"%2$s\"": "%1$s nuk ka më qasje në \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mund ta shihte këtë fjalëkalim. Ndryshojeni nëse %1$s nuk duhet ta dijë më.", + "%s could not view this password in Keepiq.": "%s nuk mundi ta shihte këtë fjalëkalim në Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} nga {threshold} miratime", + "a recovery officer": "një zyrtar rikuperimi", + "Account recovery": "Rikuperimi i llogarisë", + "Approvals needed": "Miratime të nevojshme", + "Ask {user} which words they see, by phone or in person. They must be:": "Pyeteni {user} cilat fjalë sheh, në telefon ose personalisht. Duhet të jenë:", + "Check again": "Kontrollo përsëri", + "Create the recovery key": "Krijo çelësin e rikuperimit", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Krijoni çelësin e rikuperimit. Shfletuesi juaj e krijon dhe i jep secilit zyrtar një kopje që vetëm ai mund ta hapë.", + "Decline": "Refuzo", + "Enrol in account recovery": "Regjistrohu në rikuperimin e llogarisë", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Regjistrohuni që organizata juaj t'ju ndihmojë ta rimerrni kasafortën nëse harroni fjalëkalimin kryesor.", + "Every user is enrolled": "Të gjithë përdoruesit janë regjistruar", + "Finish the recovery in the browser you asked from.": "Përfundojeni rikuperimin në shfletuesin nga i cili e kërkuat.", + "Forgot your master password?": "Harruat fjalëkalimin kryesor?", + "Hand the key over": "Dorëzo çelësin", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lejoni përdoruesit që harruan fjalëkalimin kryesor ta rimarrin kasafortën, me miratimin e zyrtarëve të rikuperimit që caktoni.", + "New master password": "Fjalëkalim kryesor i ri", + "No one is asking to recover their account.": "Askush nuk po kërkon të rikuperojë llogarinë.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ende nuk ka çelës rikuperimi. Një nga zyrtarët e krijon te cilësimet e veta të Keepiq.", + "Off": "Joaktiv", + "Officer {user} has no encryption set up yet.": "Zyrtari {user} nuk ka ende të konfiguruar enkriptimin.", + "Officers (user IDs, separated by commas)": "Zyrtarët (ID përdoruesish, të ndara me presje)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publikojeni këtë shenjë gishti brenda organizatës, që përdoruesit ta kontrollojnë para se të regjistrohen.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "U rikuperua me ndihmën e {officer}. Ndërroni tani çelësin e kasafortës te Cilësimet, Siguria: \"Fjalëkalimi im kryesor është komprometuar\".", + "Recovery key fingerprint: {fingerprint}": "Shenja e gishtit e çelësit të rikuperimit: {fingerprint}", + "Recovery officer": "Zyrtar rikuperimi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Zyrtarët e hequr e humbin kopjen tani, por mund ta kenë hapur më parë. Kërkojini një zyrtari të krijojë një çelës të ri rikuperimi.", + "Repeat the new master password": "Përsëritni fjalëkalimin kryesor të ri", + "Retire this recovery key": "Tërhiq këtë çelës rikuperimi", + "Set the new master password": "Vendos fjalëkalimin kryesor të ri", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikata e rikuperimit nuk është lëshuar nga ky Keepiq. Mos u regjistroni dhe njoftoni administratorin.", + "The words match, approve": "Fjalët përputhen, mirato", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ky përdorues është regjistruar në rikuperimin e llogarisë. Rikuperimi ruan sekretet e tij; revokimi fshin regjistrimin e tij.", + "Users may enrol": "Përdoruesit mund të regjistrohen", + "Withdraw from account recovery": "Tërhiqu nga rikuperimi i llogarisë", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jeni regjistruar në rikuperimin e llogarisë. Shenja e gishtit e çelësit të rikuperimit: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jeni regjistruar. Nëse harroni fjalëkalimin kryesor, organizata juaj mund t'ju ndihmojë ta rimerrni kasafortën.", + "Your key is back. Choose a new master password.": "Çelësi juaj u kthye. Zgjidhni një fjalëkalim kryesor të ri.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Zyrtarët tuaj të rikuperimit u njoftuan. Lexojuni këto fjalë kur t'ju telefonojnë ose t'ju takojnë:", + "You are now an account recovery officer": "Tani jeni zyrtar i rikuperimit të llogarive", + "%s asks to recover their account. Compare the words with them before you approve.": "%s kërkon të rikuperojë llogarinë. Krahasoni fjalët me të para se të miratoni.", + "A user": "Një përdorues", + "Your account recovery request was declined": "Kërkesa juaj për rikuperimin e llogarisë u refuzua", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Rikuperimi i llogarisë suaj është gati. Hapni Keepiq në shfletuesin nga i cili e kërkuat.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} kërkon që një pajisje e re të shkyçet një herë. Fjalëkalimi kryesor mbetet i njëjtë.", + "Ask your organisation instead": "Pyet më mirë organizatën tënde", + "The request ended. Ask again or use your master password.": "Kërkesa përfundoi. Kërko përsëri ose përdor fjalëkalimin kryesor.", + "Added by {user}": "Shtuar nga {user}", + "Editor": "Redaktor", + "Manager": "Menaxher", + "Role of {member}": "Roli i {member}", + "Team folders you manage": "Dosjet e ekipit që menaxhoni", + "Viewer": "Shikues", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nuk keni kopje të këtyre sekreteve, prandaj anëtarët e rinj nuk i kanë marrë ende. Pronari mund t'i ndajë: {names}", + "Admin areas": "Fushat e administrimit", + "Give a group only the parts of Keepiq administration it needs.": "Jepini një grupi vetëm pjesët e administrimit të Keepiq që i nevojiten.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegoni një ose më shumë fusha te një grup në faqen e privilegjeve të administrimit. Administratorët e instancës kanë çdo fushë.", + "Open administration privileges": "Hap privilegjet e administrimit", + "Policies": "Politikat", + "Applications and machine access": "Aplikacionet dhe qasja e makinave", + "People and offboarding": "Njerëzit dhe largimet", + "Audit and compliance": "Auditimi dhe përputhshmëria", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versioni, autoriteti i certifikimit, bashkëngjitjet, memoria offline, kontrolli i rrjedhjeve, llojet e sekreteve dhe kopjet rezervë", + "master password, organisation password, vault policies, rotation, version history and trash": "fjalëkalimi kryesor, fjalëkalimi i organizatës, politikat e kasafortës, rrotullimi, historiku i versioneve dhe koshi", + "application queue, application requests and machine leases": "radha e aplikacioneve, kërkesat e aplikacioneve dhe qiratë e makinave", + "team offboarding, encryption suites and admin handover": "largimet nga ekipi, paketat e enkriptimit dhe marrja përsipër nga administratori", + "audit log, compliance reports, SIEM export and honey alerts": "regjistri i auditimit, raportet e përputhshmërisë, eksporti SIEM dhe sinjalizimet e karremave", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Sa versione të një sekreti ruhen, për sa kohë, dhe sa kohë qëndrojnë në kosh sekretet e fshira.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Kufijtë për bashkëngjitjet e enkriptuara, të zbatuara në server në bajtë të enkriptuar të ruajtur.", + "Type the suite ID again to confirm": "Shkruani përsëri ID-në e grupit për ta konfirmuar", + "This does not match the suite ID.": "Kjo nuk përputhet me ID-në e grupit.", + "Confirm with your master password": "Konfirmoni me fjalëkalimin kryesor", + "Confirm": "Konfirmo", + "That master password is not right.": "Ky fjalëkalim kryesor nuk është i saktë.", + "You are sharing with someone new. Enter your master password to confirm.": "Po ndani me dikë të ri. Shkruani fjalëkalimin kryesor për ta konfirmuar.", + "Enter your master password to confirm this share.": "Shkruani fjalëkalimin kryesor për të konfirmuar këtë ndarje.", + "Enter your master password to confirm this delegation.": "Shkruani fjalëkalimin kryesor për të konfirmuar këtë delegim.", + "Approve {member}": "Mirato {member}", + "Recipient": "Marrësi", + "No vault yet": "Ende pa kasafortë", + "No matching users": "Asnjë përdorues që përputhet", + "Partner organisations": "Organizata partnere", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Shkëmbeni sekrete me një Keepiq tjetër. Të dy administratorët shtojnë njëri-tjetrin dhe krahasojnë gjurmët rrënjë në telefon ose personalisht para ruajtjes.", + "Federation needs Nextcloud 33 or later.": "Federimi kërkon Nextcloud 33 ose më të ri.", + "Your root fingerprint": "Gjurma juaj rrënjë", + "No partners yet.": "Ende pa partnerë.", + "Users here may share to this partner": "Përdoruesit këtu mund të ndajnë me këtë partner", + "This partner may share to users here": "Ky partner mund të ndajë me përdoruesit këtu", + "Partner address": "Adresa e partnerit", + "Check partner": "Kontrollo partnerin", + "Partner root fingerprint": "Gjurma rrënjë e partnerit", + "I compared this fingerprint with the partner's administrator": "E krahasova këtë gjurmë me administratorin e partnerit", + "Add partner": "Shto partnerin", + "A secret from another organisation": "Një sekret nga një organizatë tjetër", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ndau \"%2$s\" me ju. Pranojeni te Të ardhura nga organizata të tjera.", + "Incoming from other organisations": "Të ardhura nga organizata të tjera", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personat në organizatat partnere mund të ndajnë një sekret me ju. Pranojeni për të mbajtur një kopje vetëm për lexim në kasafortën tuaj.", + "Nothing shared with you yet": "Asgjë nuk është ndarë ende me ju", + "Secrets that people in partner organisations share with you appear here.": "Sekretet që personat në organizatat partnere ndajnë me ju shfaqen këtu.", + "From {sender}": "Nga {sender}", + "Accept": "Prano", + "Open in vault": "Hape në kasafortë", + "The other organisation did not hand over the secret. Try again later.": "Organizata tjetër nuk e dorëzoi sekretin. Provoni përsëri më vonë.", + "Set up your vault before you accept a shared secret.": "Konfiguroni kasafortën tuaj përpara se të pranoni një sekret të ndarë.", + "Something went wrong. Try again.": "Diçka shkoi keq. Provoni përsëri.", + "Waiting for your answer": "Në pritje të përgjigjes suaj", + "In your vault, read-only": "Në kasafortën tuaj, vetëm për lexim", + "Withdrawn by the sender": "Tërhequr nga dërguesi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} e ndau këtë nga një organizatë tjetër. Mund ta lexoni, por jo ta ndryshoni ose ta ndani.", + "Someone": "Dikush", + "Share with someone at another organisation": "Ndaj me dikë nga një organizatë tjetër", + "Their account at the other organisation": "Llogaria e personit në organizatën tjetër", + "Check account": "Kontrollo llogarinë", + "Certificate fingerprint of {account}": "Gjurma e certifikatës së {account}", + "Compare it with them by phone if you want to be sure.": "Krahasojeni me personin në telefon nëse doni të jeni të sigurt.", + "Shared. {account} can accept it in their own vault.": "U nda. {account} mund ta pranojë në kasafortën e vet.", + "The certificate could not be verified. Nothing was shared.": "Certifikata nuk mund të verifikohej. Asgjë nuk u nda.", + "That organisation is not one of your partners.": "Ajo organizatë nuk është një nga partnerët tuaj.", + "No one with that account can receive secrets from you.": "Askush me atë llogari nuk mund të marrë sekrete nga ju.", + "The other organisation did not answer. Try again later.": "Organizata tjetër nuk u përgjigj. Provoni përsëri më vonë.", + "This secret is already shared with that account.": "Ky sekret është ndarë tashmë me atë llogari.", + "Other organisations": "Organizata të tjera", + "Receive secrets from other organisations": "Merr sekrete nga organizata të tjera", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personat në organizatat partnere mund ta gjejnë atëherë llogarinë tuaj dhe të ndajnë sekrete me ju. Secilin prej tyre e pranoni vetë.", + "Shared": "E ndarë", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Në pauzë: certifikata e tyre ose partneriteti ndryshoi. Revokojeni ose ndajeni përsëri.", + "Their organisation did not get the last change. Revoke it or share again.": "Organizata e tyre nuk e mori ndryshimin e fundit. Revokojeni ose ndajeni përsëri.", + "Being withdrawn": "Po tërhiqet", + "Shared with another organisation": "U nda me një organizatë tjetër", + "Change sent to another organisation": "Ndryshimi u dërgua te një organizatë tjetër", + "Share with another organisation revoked": "Ndarja me një organizatë tjetër u revokua", + "Share with another organisation paused": "Ndarja me një organizatë tjetër u vu në pauzë", + "Another organisation did not get a change": "Një organizatë tjetër nuk e mori një ndryshim", + "Secret received from another organisation": "U mor një sekret nga një organizatë tjetër", + "Secret from another organisation accepted": "Sekreti nga një organizatë tjetër u pranua", + "Secret from another organisation declined": "Sekreti nga një organizatë tjetër u refuzua", + "Copy from another organisation updated": "Kopja nga një organizatë tjetër u përditësua", + "Copy from another organisation removed": "Kopja nga një organizatë tjetër u hoq", + "Declined: they removed their copy. Share again if they need it.": "Refuzuar: marrësi hoqi kopjen e vet. Ndajeni përsëri nëse i nevojitet.", + "Recipient at another organisation removed their copy": "Një marrës nga një organizatë tjetër hoqi kopjen e vet", + "Removed the user from %n team folder.": "Përdoruesi u hoq nga %n dosje ekipi.", + "Removed the user from %n team folders.": "Përdoruesi u hoq nga %n dosje ekipi.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Përdoruesi u hoq nga %n dosje ekipi.","Përdoruesi u hoq nga %n dosje ekipi."], + "A restored copy came from a share that has ended. It stays read-only.": "Një kopje e rikthyer vjen nga një ndarje që ka përfunduar. Mbetet vetëm për lexim.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizata që ndau një kopje të rikthyer nuk u arrit. Kopja mbetet vetëm për lexim dhe nuk ndjek ndryshimet e tyre.", + "Recipient at another organisation restored their copy": "Një marrës nga një organizatë tjetër rikthye kopjen e vet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sq.json b/l10n/sq.json index bc1383d98..9fde3a5b7 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Ndarë me grupe", + "Not shared with any group yet.": "Ende nuk është ndarë me asnjë grup.", + "Revoke the share with {group}": "Revoko ndarjen me {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Ndarë me {group}: {received} anëtarë e morën, {skipped} jo, sepse ende nuk kanë konfiguruar enkriptimin.", + "Search groups": "Kërko grupe", + "Failed to share": "Ndarja dështoi", + "Columns": "Kolonat", + "Column {number}": "Kolona {number}", + "Map one column to Name. Every secret needs a name.": "Lidhni një kolonë me emrin. Çdo sekret ka nevojë për një emër.", + "Notes": "Shënime", + "Do not import": "Mos importo", + "Hide this value": "Fshihe këtë vlerë", + "Show this value": "Shfaqe këtë vlerë", + "Defaults": "Parazgjedhjet", + "New secrets start as this type, and your secret list opens in this view.": "Sekretet e reja fillojnë me këtë lloj dhe lista jote e sekreteve hapet në këtë pamje.", + "Default item type": "Lloji i parazgjedhur i elementit", + "Cards": "Kartela", + "Table": "Tabelë", + "Could not save your default": "Parazgjedhja nuk u ruajt", + "Recently used": "Përdorur së fundi", + "Opened": "Hapur", + "You have not opened any secrets yet": "Nuk ke hapur ende asnjë sekret", + "Could not delete the item type.": "Lloji i elementit nuk u fshi dot.", + "Could not load the item types.": "Llojet e elementeve nuk u ngarkuan dot.", + "Could not save the item type.": "Lloji i elementit nuk u ruajt dot.", + "Delete item type": "Fshi llojin e elementit", + "Edit item type": "Ndrysho llojin e elementit", + "Fields": "Fushat", + "Fields: {count}": "Fushat: {count}", + "Hidden": "E fshehur", + "Item types": "Llojet e elementeve", + "Move up": "Lëviz lart", + "New item type": "Lloj i ri elementi", + "No item types defined yet.": "Ende nuk ka lloje elementesh të përcaktuara.", + "Required": "E detyrueshme", + "Text": "Tekst", + "This field is required": "Kjo fushë është e detyrueshme", + "Web address": "Adresë uebi", + "{label} (required)": "{label} (e detyrueshme)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Të fshihet “{name}”? Sekretet e këtij lloji mbeten të lexueshme dhe bëhen elemente Hyrja.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Llojet e elementeve që përcakton këtu u shfaqen të gjithëve te dritarja Sekret i ri, me fushat që zgjedh.", + "Secret moved to the trash": "Sekreti u zhvendos në kosh", + "Secret restored from the trash": "Sekreti u rikthye nga koshi", + "Secret deleted for good": "Sekreti u fshi përgjithmonë", + "Secret archived": "Sekreti u arkivua", + "Secret unarchived": "Sekreti u nxor nga arkivi", + "Unarchive": "Nxirre nga arkivi", + "Could not archive the secret": "Sekreti nuk u arkivua dot", + "Could not unarchive the secret": "Sekreti nuk u nxor dot nga arkivi", + "Archive {count} secrets": "Arkivo sekrete: {count}", + "Unarchive {count} secrets": "Nxirr nga arkivi sekrete: {count}", + "Restore {count} secrets": "Rikthe sekrete: {count}", + "Delete {count} secrets for good": "Fshi përgjithmonë sekrete: {count}", + "Done for {ok} of {total} secrets": "U krye për {ok} nga {total} sekrete", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Sekretet e arkivuara largohen nga lista e kasafortës, kërkimi, plotësimi automatik dhe raporti i shëndetit. Ruajnë ndarjet e tyre. I gjen te Arkivi.", + "These secrets come back to the vault list, search and autofill.": "Këto sekrete kthehen në listën e kasafortës, në kërkim dhe në plotësimin automatik.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Këto sekrete kthehen në listën e kasafortës. Ndarjet e vjetra nuk kthehen, ndaj ndaji sërish ku duhet.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Kjo fshin sekretet bashkë me bashkëngjitjet dhe historikun e versioneve. Nuk mund të zhbëhet.", + "Delete for good": "Fshi përgjithmonë", + "Trash": "Kosh", + "The trash is empty": "Koshi është bosh", + "No archived secrets": "Nuk ka sekrete të arkivuara", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Sekretet e fshira presin këtu deri në fund të periudhës së ruajtjes, pastaj fshihen përgjithmonë.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivo një sekret nga paneli i tij i hollësive për ta mbajtur jashtë listës së kasafortës, kërkimit dhe plotësimit automatik.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Kufij për bashkëngjitjet e enkriptuara (zbatohen në server mbi bajtet e enkriptuara të ruajtura), ruajtja e historikut të versioneve dhe sa kohë qëndrojnë në kosh sekretet e fshira.", + "Days a deleted secret stays in the trash (1 to 365)": "Ditë që një sekret i fshirë qëndron në kosh (1 deri në 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Kjo e zhvendos sekretin në kosh dhe i mbyll tani ndarjet. Mund ta rikthesh nga koshi deri në fund të periudhës së ruajtjes: 30 ditë, përveç nëse administratori e ka ndryshuar.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Kjo i zhvendos sekretet në kosh ({count}) dhe u mbyll tani ndarjet. Mund t'i rikthesh nga koshi deri në fund të periudhës së ruajtjes.", + "Remove {name} from favourites": "Hiq {name} nga të preferuarat", + "Add {name} to favourites": "Shto {name} te të preferuarat", + "Could not change the favourite": "Të preferuarën nuk u ndryshua dot", + "Remove from favourites": "Hiq nga të preferuarat", + "Add to favourites": "Shto te të preferuarat", + "Tags": "Etiketa", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketat nuk janë të fshehtëzuara. Administratorët e shërbyesit mund t'i lexojnë, ashtu si emrat e dosjeve.", + "Favourites": "Të preferuarat", + "Filter by tag": "Filtro sipas etiketës", + "All tags": "Të gjitha etiketat", + "Last used": "Përdorur së fundi", + "Tags for {count} secrets": "Etiketa për {count} sekrete", + "Tag": "Etiketë", + "Remove tag": "Hiq etiketën", + "Add tag": "Shto etiketë", + "Could not change the tags. Try again.": "Etiketat nuk u ndryshuan dot. Provoni sërish.", + "Could not approve the application. It is still in the queue.": "Aplikimi nuk u miratua dot. Është ende në radhë.", + "Could not reject the application. It is still in the queue.": "Aplikimi nuk u refuzua dot. Është ende në radhë.", + "Removed the user from {count} team folders.": "Përdoruesi u hoq nga {count} dosje ekipi.", + "Approve a share": "Mirato një ndarje", + "This approval link is incomplete. Open it again from the notification.": "Kjo lidhje miratimi është e paplotë. Hapeni sërish nga njoftimi.", + "Deny": "Refuzo", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} u bashkua me një grup me të cilin ndani një sekret. Ta ndani sekretin edhe me të?", + "{requester} asks you to share a secret with {user}.": "{requester} ju kërkon të ndani një sekret me {user}.", + "Shared. The recipient can now open the secret.": "U nda. Marrësi tani mund ta hapë sekretin.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Marrësi nuk e ka konfiguruar ende Keepiq, ndaj nuk u nda asgjë. Riprovoni pasi ta ketë bërë.", + "Could not share the secret. Only its owner can approve this.": "Sekreti nuk u nda dot. Vetëm pronari i tij mund ta miratojë këtë.", + "Could not share the secret. Try again.": "Sekreti nuk u nda dot. Riprovoni.", + "Denied. Nothing was shared.": "U refuzua. Nuk u nda asgjë.", + "Could not deny the request. Try again.": "Kërkesa nuk u refuzua dot. Riprovoni.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ju kërkon të ndani sekretin \"%2$s\" me %3$s.", + "Expires on (optional)": "Skadon më (opsionale)", + "Hand over to": "Dorëzoja", + "Choose a recipient": "Zgjidhni një marrës", + "Hand over temporarily": "Dorëzo përkohësisht", + "Expiry rules": "Rregulla skadimi", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Caktoni sa gjatë mund të jetojnë fjalëkalimet e një lloji objekti ose në një dosje, dhe kur të kujtoheni. Kur vlejnë disa data, llogaritet më e hershmja.", + "Delete rule": "Fshi rregullin", + "Set by your administrator": "Caktuar nga administratori juaj", + "No expiry rules yet.": "Ende nuk ka rregulla skadimi.", + "Applies to": "Vlen për", + "Item type": "Lloj objekti", + "Maximum age in days (empty for reminders only)": "Mosha maksimale në ditë (bosh vetëm për kujtesa)", + "Remind me this many days before, comma separated": "Më kujto kaq ditë përpara, të ndara me presje", + "Save rule": "Ruaje rregullin", + "An item type": "Një lloj objekti", + "A folder": "Një dosje", + "Folder {name}": "Dosja {name}", + "Type {name}": "Lloji {name}", + "Expires after {days} days": "Skadon pas {days} ditësh", + "Reminders {days} days before": "Kujtesa {days} ditë përpara", + "Could not save the expiry rule.": "Rregulli i skadimit nuk u ruajt dot.", + "Could not delete the expiry rule.": "Rregulli i skadimit nuk u fshi dot.", + "All statuses": "Të gjitha gjendjet", + "Compromised": "E komprometuar", + "Could not load the members.": "Anëtarët nuk u ngarkuan dot.", + "Emergency contact": "Kontakt urgjence", + "Leaving user": "Përdorues që largohet", + "No": "Jo", + "No users match this filter.": "Asnjë përdorues nuk përputhet me këtë filtër.", + "Not set up": "Pa u konfiguruar", + "Revoke suite": "Revoko paketën", + "Revoked": "E revokuar", + "Search users": "Kërko përdorues", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Shihni cilët përdorues kanë konfiguruar një kasafortë. Nisni largimin ose revokoni një paketë nga një rresht.", + "Successor": "Pasardhës", + "Team folders": "Dosje ekipi", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Përdoruesi është ende në grupin {groups}, që është anëtar i një dosjeje ekipi. Hiqeni nga grupi ose çaktivizoni llogarinë.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Përdoruesi është ende në grupet {groups}, që janë anëtare të dosjeve të ekipit. Hiqeni nga grupet ose çaktivizoni llogarinë.", + "Vault status": "Gjendja e kasafortës", + "Yes": "Po", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Një eksport CXF është I PAKRIPTUAR. Çdo fjalëkalim dhe kredencial hyrjeje do të lexohet si tekst i thjeshtë në skedarin e shkarkuar. Ruajeni në mënyrë të sigurt dhe fshijeni menjëherë pas përdorimit.", + "Root certificate expiring soon": "Certifikata rrënjë skadon së shpejti", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Certifikata rrënjë e kasafortës skadon pas %1$d dite(ve). Rinovojeni para kësaj. Rinovimi nënshkruan sërish çdo paketë enkriptimi.", + "Compromise recovery aborted": "Rikuperimi pas komprometimit u ndërpre", + "Key rotation ended by a compromise revoke": "Rrotullimi i çelësit përfundoi nga një revokim për shkak të komprometimit", + "Encryption suite revoke refused": "Revokimi i paketës së enkriptimit u refuzua", + "Master password proof refused": "Prova e fjalëkalimit kryesor u refuzua", + "Your current master password": "Fjalëkalimi yt kryesor aktual", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n kontakt urgjence kishte një kërkesë aksesi në pritje kur rrotullimi i çelësit e hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n kontakte urgjence kishin një kërkesë aksesi në pritje kur rrotullimi i çelësit i hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Këto kontakte urgjence nuk u transferuan te çelësi yt i ri. Aksesi i tyre i urgjencës u hoq. Shtoji përsëri te Aksesi i urgjencës nëse i do ende.", + "Renew root certificate": "Rinovo certifikatën rrënjë", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Kjo krijon një certifikatë të re rrënjë dhe të ndërmjetme. Çdo paketë aktive enkriptimi nënshkruhet përsëri. Kjo nuk mund të zhbëhet.", + "Renew root": "Rinovo rrënjën", + "Root renewed. {n} encryption suites signed again.": "Rrënja u rinovua. Paketa enkriptimi të nënshkruara përsëri: {n}.", + "Could not renew the root certificate.": "Certifikata rrënjë nuk mund të rinovohej.", + "Lease policy for this application": "Politika e qirasë për këtë aplikacion", + "In force now: {default} seconds by default, {max} seconds at most.": "Në fuqi tani: {default} sekonda si parazgjedhje, më së shumti {max} sekonda.", + "Leases are not renewable": "Qiratë nuk mund të rinovohen", + "Lease policy saved.": "Politika e qirasë u ruajt.", + "Leave a field empty to use the instance value.": "Lëreni një fushë bosh për të përdorur vlerën e instancës.", + "Instance value: {value}": "Vlera e instancës: {value}", + "Renewal": "Rinovimi", + "Use the instance value ({value})": "Përdor vlerën e instancës ({value})", + "Allowed": "Lejohet", + "Not allowed": "Nuk lejohet", + "Save lease policy": "Ruaj politikën e qirasë", + "Only an administrator can change this policy.": "Vetëm një administrator mund ta ndryshojë këtë politikë.", + "Could not save the lease policy.": "Politika e qirasë nuk mund të ruhej.", + "{member} got access from {confirmer}.": "{member} mori qasje nga {confirmer}.", + "Automatically confirm new team folder members": "Konfirmo automatikisht anëtarët e rinj të dosjeve të ekipit", + "Gave %n new member access to a team folder.": "%n anëtar i ri mori qasje në një dosje ekipi.", + "Gave %n new members access to a team folder.": "%n anëtarë të rinj morën qasje në një dosje ekipi.", + "Give new team folder members access without waiting for the folder owner.": "Jepuni qasje anëtarëve të rinj pa pritur pronarin e dosjes.", + "New team folder members": "Anëtarë të rinj të dosjeve të ekipit", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Pronari ose një anëtar me të drejtë shkrimi i konfirmon nga kasaforta e hapur. Keepiq nuk deshifron kurrë në server.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Në pritje që një anëtar me të drejtë shkrimi të hapë Keepiq. Mund të ndani edhe tani.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Një pjesë e reagimit ndaj komprometimit dështoi ({failed} hap(a)). Kontrolloni regjistrin e serverit, pastaj revokoni sërish paketën për ta përfunduar.", + "This also revoked suite {suite} and ended key migration {migration}.": "Kjo revokoi edhe paketën {suite} dhe përfundoi migrimin e çelësave {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Revokimi i paketës së dytë fshiu %n kontakt aksesi emergjence.", + "Revoking the second suite deleted %n emergency-access contacts.": "Revokimi i paketës së dytë fshiu %n kontakte aksesi emergjence.", + "A suite revoked as compromised cannot be reinstated.": "Një paketë e revokuar si e komprometuar nuk mund të rikthehet.", + "Archives to keep": "Arkiva për t'u mbajtur", + "Back up every vault automatically": "Bëj kopje rezervë të çdo kasaforte automatikisht", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Bëni kopje rezervë të çdo kasaforte sipas orarit. Arkivat përmbajnë vetëm tekst të shifruar dhe rikthehen me occ.", + "Back up now": "Bëj kopje tani", + "Backup public key (PEM, optional)": "Çelësi publik i kopjes (PEM, opsional)", + "Backup requested for the next cron run": "Kopja u kërkua për ekzekutimin e ardhshëm të cron", + "Encrypted": "I shifruar", + "Every (hours)": "Çdo (orë)", + "Last backup {when} failed: {error}": "Kopja e fundit {when} dështoi: {error}", + "Last backup {when} succeeded.": "Kopja e fundit {when} u krye.", + "No archives yet.": "Ende pa arkiva.", + "Size": "Madhësia", + "Vault backups": "Kopjet e kasafortës", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Me një çelës, çdo arkiv shifrohet për të. Mbajeni çelësin privat jashtë këtij serveri: ju duhet për verifikim ose rikthim.", + "Written": "Shkruar", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n përdorues në fushëveprim ende nuk ka hyrje me dy faktorë dhe nuk mund ta hapë kasafortën sa kohë që kjo është aktive.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n përdorues në fushëveprim ende nuk kanë hyrje me dy faktorë dhe nuk mund ta hapin kasafortën sa kohë që kjo është aktive.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Kodet rezervë nuk llogariten. Nëse përdoruesit hyjnë përmes një ofruesi identiteti me faktorin e vet të dytë, lërini jashtë grupet e tyre.", + "Block personal vault export": "Blloko eksportin e kasafortës personale", + "Keep work logins in team folders": "Mbaj kredencialet e punës në dosje ekipi", + "Move to a team folder": "Zhvendose në një dosje ekipi", + "Not in a team folder": "Jo në një dosje ekipi", + "Only for these groups (empty is everyone)": "Vetëm për këto grupe (bosh do të thotë të gjithë)", + "Require two-factor login before the vault opens": "Kërko hyrje me dy faktorë para se të hapet kasaforta", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Rregulla për çdo kasafortë. Secili vlen për të gjithë, ose vetëm për grupet që zgjidhni.", + "Secret types that belong in a team folder": "Lloje sekretesh që i përkasin një dosjeje ekipi", + "Set up two-factor login": "Konfiguro hyrjen me dy faktorë", + "Team folder you can write to": "Dosje ekipi ku mund të shkruani", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Përdoruesit nuk mund të shkarkojnë kopje rezervë, CSV ose skedar transferimi. Paketa e tyre e të dhënave personale mbetet e disponueshme.", + "Users cannot save these secret types in a personal folder.": "Përdoruesit nuk mund t'i ruajnë këto lloje sekretesh në një dosje personale.", + "Vault policies": "Politikat e kasafortës", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Organizata juaj nuk lejon eksportin e kasafortës suaj personale. Paketa juaj e të dhënave personale në cilësime mbetet e disponueshme.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Organizata juaj i mban këto sekrete në një dosje ekipi. Zhvendoseni secilin në një dosje ekipi.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Organizata juaj e mban këtë lloj sekreti në një dosje ekipi. Zgjidhni një nga dosjet tuaja të ekipit, ose një ku mund të shkruani.", + "Your organisation requires two-factor login before you can open your vault.": "Organizata juaj kërkon hyrje me dy faktorë para se të mund ta hapni kasafortën tuaj.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Përdoruesit zgjedhin sa kohë zgjatja mbetet e shkyçur kur nuk përdoret. Ju caktoni kohën më të gjatë që mund të zgjedhin.", + "Longest idle time before the extension locks": "Koha më e gjatë pa aktivitet para se zgjatja të kyçet", + "1 minute": "1 minutë", + "5 minutes": "5 minuta", + "15 minutes": "15 minuta", + "1 hour": "1 orë", + "4 hours": "4 orë", + "Connector": "Lidhës", + "Directory (tenant) ID": "ID e drejtorisë (qiramarrësit)", + "Application (client) ID": "ID e aplikacionit (klientit)", + "Data collection rule immutable ID": "ID e pandryshueshme e rregullit të mbledhjes së të dhënave", + "Stream name": "Emri i rrjedhës", + "Splunk index (optional)": "Indeksi Splunk (opsional)", + "Sourcetype (optional)": "Sourcetype (opsional)", + "Leave blank to keep the current one": "Lëreni bosh për të mbajtur vlerën aktuale", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF përmes syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Pika fundore e mbledhjes së të dhënave (URL https)", + "HTTP Event Collector URL (https)": "URL e HTTP Event Collector (https)", + "Client secret (write-only)": "Sekreti i klientit (vetëm shkrim)", + "HEC token (write-only)": "Token HEC (vetëm shkrim)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Përcillni ngjarjet e lejuara të auditimit te Splunk, Microsoft Sentinel, një marrës syslog ose një webhook. Mesazhet mbajnë vetëm meta të dhëna të pastruara: asnjë vlerë sekrete, emër, hyrje apo tekst i shifruar nuk largohet kurrë nga serveri.", + "%n change waiting to sync": "%n ndryshim pret sinkronizimin", + "%n changes waiting to sync": "%n ndryshime presin sinkronizimin", + "Changes that could not sync": "Ndryshime që nuk u sinkronizuan dot", + "Choose a version": "Zgjidhni një version", + "Copy value": "Kopjo vlerën", + "Deleted": "Fshirë", + "Discard": "Hidhe", + "Keep my offline change": "Mbaj ndryshimin tim jashtë linje", + "Keep the server version": "Mbaj versionin e serverit", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq është vetëm për lexim jashtë linje. Administratori nuk e ka aktivizuar redaktimin jashtë linje.", + "Let users edit secrets offline": "Lejo përdoruesit të redaktojnë sekrete jashtë linje", + "Not synced yet": "Ende i pasinkronizuar", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Ndryshimet jashtë linje mbeten në pajisje, të enkriptuara për përdoruesin, dhe sinkronizohen në shkyçjen e radhës në linjë. Ndarja, dosjet dhe bashkëngjitjet kanë ende nevojë për lidhje.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Jashtë linje. Redaktimet, zhvendosjet dhe fshirjet mbeten në këtë pajisje dhe sinkronizohen kur të jeni sërish në linjë. Ndarja dhe bashkëngjitjet kanë nevojë për lidhje.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Jashtë linje. Ndryshimet tuaja mbeten në këtë pajisje dhe sinkronizohen kur të jeni sërish në linjë. Sinkronizimi i fundit {when}.", + "Open my changes": "Hap ndryshimet e mia", + "Sharing needs a connection": "Ndarja ka nevojë për lidhje", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Dikush e ndryshoi këtë sekret në server pasi u bë kopja juaj jashtë linje. Zgjidhni cilin version të mbani.", + "Sync or discard your offline changes before you rotate your keys.": "Sinkronizoni ose hidhni ndryshimet jashtë linje para se të ndërroni çelësat.", + "That password did not open your changes.": "Ky fjalëkalim nuk i hapi ndryshimet tuaja.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Fotografia jashtë linje ruan sekrete të enkriptuara (hapen vetëm me çelësin që rrjedh nga fjalëkalimi kryesor i përdoruesit, njësoj si në server) dhe enkripton emrat, URL-të dhe emrat e dosjeve në ruajtje. Qasja jashtë linje është vetëm për lexim, përveç nëse lejoni më poshtë redaktimin jashtë linje. Çaktivizojeni për pajisje që nuk duhet të ruajnë kurrë kredenciale; çaktivizimi pastron memorien ekzistuese në ngarkimin e radhës.", + "The previous vault copy is gone, so these changes cannot be opened.": "Kopja e mëparshme e kasafortës nuk është më, ndaj këto ndryshime nuk mund të hapen.", + "The server version": "Versioni i serverit", + "This secret changed while you were offline": "Ky sekret ndryshoi ndërsa ishit jashtë linje", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "E fshitë këtë sekret jashtë linje, por që atëherë është ndryshuar në server. Zgjidhni cilin version të mbani.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Çelësat tuaj u ndërruan në një pajisje tjetër. Jepni fjalëkalimin kryesor të mëparshëm për të sinkronizuar ndryshimet jashtë linje, ose hidhini.", + "Your offline change": "Ndryshimi juaj jashtë linje", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n kontakt urgjence kishte një kërkesë aksesi në pritje kur rrotullimi i çelësit e hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri.", + "%n kontakte urgjence kishin një kërkesë aksesi në pritje kur rrotullimi i çelësit i hoqi. Kontrollo kush e kërkoi para se të shtosh dikë përsëri." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n artikull nuk mund të përfaqësohet në CXF dhe do të anashkalohet.", + "%n artikuj nuk mund të përfaqësohen në CXF dhe do të anashkalohen." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n version më i vjetër u hoq, sepse mund të kalohet vetëm historiku i kohëve të fundit.", + "%n versione më të vjetër u hoqën, sepse mund të kalohet vetëm historiku i kohëve të fundit." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopje sekreti duhet ende kriptuar dhe ndarë.", + "%n kopje sekreti duhen ende kriptuar dhe ndarë." + ], + "_%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 sekret nuk mund të deshifrohej dhe nuk është në këtë eksport.", + "%n sekrete nuk mund të deshifroheshin dhe nuk janë në këtë eksport." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n sekret nuk mundi të deshifrohej me kyçin tuaj të vjetër, kështu që nuk u migrua.", + "%n sekrete nuk mundën të deshifroheshin me kyçin tuaj të vjetër, kështu që nuk u migruan." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n sekret nuk u migrua.", + "%n sekrete nuk u migruan." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n sekret është ende i kriptuar me kyçin tuaj të mëparshëm.", + "%n sekrete janë ende të kriptuara me kyçin tuaj të mëparshëm." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n sekret u anashkalua sepse pasardhësi ende nuk ka asnjë kopje — shtojeni pasardhësin në dosje dhe rinisni veprimin.", + "%n sekrete u anashkaluan sepse pasardhësi ende nuk ka asnjë kopje — shtojeni pasardhësin në dosje dhe rinisni veprimin." + ], + "_%n secret_::_%n secrets_": [ + "%n sekret", + "%n sekrete" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n përdorues në fushëveprim ende nuk ka hyrje me dy faktorë dhe nuk mund ta hapë kasafortën sa kohë që kjo është aktive.", + "%n përdorues në fushëveprim ende nuk kanë hyrje me dy faktorë dhe nuk mund ta hapin kasafortën sa kohë që kjo është aktive." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Përfundo gjithsesi, duke humbur aksesin në %n sekret", + "Përfundo gjithsesi, duke humbur aksesin në %n sekrete" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n anëtar i ri mori qasje në një dosje ekipi.", + "%n anëtarë të rinj morën qasje në një dosje ekipi." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Rrotullimi i kyçit përfundoi. %n sekret u rikriptua me kyçin tuaj të re.", + "Rrotullimi i kyçit përfundoi. %n sekrete u rikriptuan me kyçin tuaj të re." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Revokimi i paketës së dytë fshiu %n kontakt aksesi emergjence.", + "Revokimi i paketës së dytë fshiu %n kontakte aksesi emergjence." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Revokimi i kësaj suite fshiu %n kontakt të qasjes së emergjencës.", + "Revokimi i kësaj suite fshiu %n kontakte të qasjes së emergjencës." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "parë %n herë në rrjedhje", + "parë %n herë në rrjedhje" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "e ndarë me %n sekret", + "e ndarë me %n sekrete" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Kjo dosje përmban drejtpërdrejt %n sekret.", + "Kjo dosje përmban drejtpërdrejt %n sekrete." + ], + "_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._": [ + "Rrotullimi i kyçit hoqi %n kontakt emergjence. Kontrolloni Aksesin e emergjencës dhe shtojeni përsëri nëse e doni ende.", + "Rrotullimi i kyçit hoqi %n kontakte emergjence. Kontrolloni Aksesin e emergjencës dhe shtojini përsëri nëse i doni ende." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n ndryshim pret sinkronizimin", + "%n ndryshime presin sinkronizimin" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Përdoruesi është ende në grupin {groups}, që është anëtar i një dosjeje ekipi. Hiqeni nga grupi ose çaktivizoni llogarinë.", + "Përdoruesi është ende në grupet {groups}, që janë anëtare të dosjeve të ekipit. Hiqeni nga grupet ose çaktivizoni llogarinë." + ], + "Allow approval from another device": "Lejo miratimin nga një pajisje tjetër", + "App": "Aplikacion", + "Approve a new device": "Mirato një pajisje të re", + "Approve from another device": "Mirato nga një pajisje tjetër", + "Asked at": "Kërkuar më", + "Check that the new device shows these words:": "Kontrolloni që pajisja e re shfaq këto fjalë:", + "Denied. If you did not ask, end your other sessions:": "U refuzua. Nëse nuk e kërkuat ju, mbyllni seancat e tjera:", + "Device": "Pajisje", + "IP address": "Adresë IP", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Lejoni përdoruesit të shkyçin një shfletues të ri duke e miratuar nga një pajisje ku Keepiq është tashmë i shkyçur.", + "New device approval": "Miratimi i pajisjeve të reja", + "Nextcloud security settings": "Cilësimet e sigurisë së Nextcloud", + "Only approve a device you are using right now.": "Miratoni vetëm një pajisje që po përdorni tani.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Hapni Keepiq në një pajisje ku është i shkyçur dhe miratoni këtë pajisje. Kontrolloni që shfaq të njëjtat fjalë:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Pajisja që miraton vulos çelësin e shkyçjes për pajisjen e re. Serveri vetëm e përcjell dhe nuk mund ta hapë.", + "The master password is not right, or the request has ended.": "Fjalëkalimi kryesor nuk është i saktë ose kërkesa ka përfunduar.", + "The request expired. Ask again or use your master password.": "Kërkesa skadoi. Kërkoni përsëri ose përdorni fjalëkalimin kryesor.", + "The request was denied.": "Kërkesa u refuzua.", + "Too many requests. Try again in an hour or use your master password.": "Shumë kërkesa. Provoni përsëri pas një ore ose përdorni fjalëkalimin kryesor.", + "Unknown device": "Pajisje e panjohur", + "Web app": "Aplikacion web", + "A device": "Një pajisje", + "A new device asks to open your vault": "Një pajisje e re kërkon të hapë kasafortën tuaj", + "%s asks to be approved. Only approve a device you are using right now.": "%s kërkon miratim. Miratoni vetëm një pajisje që po përdorni tani.", + "Access ends on (optional)": "Qasja përfundon më (opsionale)", + "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.": "Aplikacionet e Keepiq nuk do ta shfaqin as ta kopjojnë fjalëkalimin. Dikush me aftësi teknike mund ta lexojë ende nga pajisja e vet. Ndryshojeni kur t'i përfundojë qasja.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ky sekret është vetëm për përdorim. Hyni përmes shtesës së shfletuesit Keepiq.", + "Until {date}": "Deri më {date}", + "Use only": "Vetëm përdorim", + "Use only (can sign in, cannot view or copy)": "Vetëm përdorim (mund të hyjë, nuk mund ta shohë as ta kopjojë)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Mund të hyni me këto kredenciale përmes shtesës së shfletuesit Keepiq. Pronari zgjodhi të mos ju lejojë t'i shihni apo t'i kopjoni.", + "Your access ends on {date}": "Qasja juaj përfundon më {date}", + "Your access to this secret has ended": "Qasja juaj në këtë sekret ka përfunduar", + "Your access to \"%s\" ends tomorrow": "Qasja juaj në \"%s\" përfundon nesër", + "Your access to \"%s\" has ended": "Qasja juaj në \"%s\" ka përfunduar", + "%1$s no longer has access to \"%2$s\"": "%1$s nuk ka më qasje në \"%2$s\"", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s mund ta shihte këtë fjalëkalim. Ndryshojeni nëse %1$s nuk duhet ta dijë më.", + "%s could not view this password in Keepiq.": "%s nuk mundi ta shihte këtë fjalëkalim në Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} nga {threshold} miratime", + "a recovery officer": "një zyrtar rikuperimi", + "Account recovery": "Rikuperimi i llogarisë", + "Approvals needed": "Miratime të nevojshme", + "Ask {user} which words they see, by phone or in person. They must be:": "Pyeteni {user} cilat fjalë sheh, në telefon ose personalisht. Duhet të jenë:", + "Check again": "Kontrollo përsëri", + "Create the recovery key": "Krijo çelësin e rikuperimit", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Krijoni çelësin e rikuperimit. Shfletuesi juaj e krijon dhe i jep secilit zyrtar një kopje që vetëm ai mund ta hapë.", + "Decline": "Refuzo", + "Enrol in account recovery": "Regjistrohu në rikuperimin e llogarisë", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Regjistrohuni që organizata juaj t'ju ndihmojë ta rimerrni kasafortën nëse harroni fjalëkalimin kryesor.", + "Every user is enrolled": "Të gjithë përdoruesit janë regjistruar", + "Finish the recovery in the browser you asked from.": "Përfundojeni rikuperimin në shfletuesin nga i cili e kërkuat.", + "Forgot your master password?": "Harruat fjalëkalimin kryesor?", + "Hand the key over": "Dorëzo çelësin", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Lejoni përdoruesit që harruan fjalëkalimin kryesor ta rimarrin kasafortën, me miratimin e zyrtarëve të rikuperimit që caktoni.", + "New master password": "Fjalëkalim kryesor i ri", + "No one is asking to recover their account.": "Askush nuk po kërkon të rikuperojë llogarinë.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ende nuk ka çelës rikuperimi. Një nga zyrtarët e krijon te cilësimet e veta të Keepiq.", + "Off": "Joaktiv", + "Officer {user} has no encryption set up yet.": "Zyrtari {user} nuk ka ende të konfiguruar enkriptimin.", + "Officers (user IDs, separated by commas)": "Zyrtarët (ID përdoruesish, të ndara me presje)", + "Policy": "Politika", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publikojeni këtë shenjë gishti brenda organizatës, që përdoruesit ta kontrollojnë para se të regjistrohen.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "U rikuperua me ndihmën e {officer}. Ndërroni tani çelësin e kasafortës te Cilësimet, Siguria: \"Fjalëkalimi im kryesor është komprometuar\".", + "Recovery key fingerprint: {fingerprint}": "Shenja e gishtit e çelësit të rikuperimit: {fingerprint}", + "Recovery officer": "Zyrtar rikuperimi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Zyrtarët e hequr e humbin kopjen tani, por mund ta kenë hapur më parë. Kërkojini një zyrtari të krijojë një çelës të ri rikuperimi.", + "Repeat the new master password": "Përsëritni fjalëkalimin kryesor të ri", + "Retire this recovery key": "Tërhiq këtë çelës rikuperimi", + "Set the new master password": "Vendos fjalëkalimin kryesor të ri", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Certifikata e rikuperimit nuk është lëshuar nga ky Keepiq. Mos u regjistroni dhe njoftoni administratorin.", + "The words match, approve": "Fjalët përputhen, mirato", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Ky përdorues është regjistruar në rikuperimin e llogarisë. Rikuperimi ruan sekretet e tij; revokimi fshin regjistrimin e tij.", + "Users may enrol": "Përdoruesit mund të regjistrohen", + "Withdraw from account recovery": "Tërhiqu nga rikuperimi i llogarisë", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Jeni regjistruar në rikuperimin e llogarisë. Shenja e gishtit e çelësit të rikuperimit: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Jeni regjistruar. Nëse harroni fjalëkalimin kryesor, organizata juaj mund t'ju ndihmojë ta rimerrni kasafortën.", + "Your key is back. Choose a new master password.": "Çelësi juaj u kthye. Zgjidhni një fjalëkalim kryesor të ri.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Zyrtarët tuaj të rikuperimit u njoftuan. Lexojuni këto fjalë kur t'ju telefonojnë ose t'ju takojnë:", + "You are now an account recovery officer": "Tani jeni zyrtar i rikuperimit të llogarive", + "%s asks to recover their account. Compare the words with them before you approve.": "%s kërkon të rikuperojë llogarinë. Krahasoni fjalët me të para se të miratoni.", + "A user": "Një përdorues", + "Your account recovery request was declined": "Kërkesa juaj për rikuperimin e llogarisë u refuzua", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Rikuperimi i llogarisë suaj është gati. Hapni Keepiq në shfletuesin nga i cili e kërkuat.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} kërkon që një pajisje e re të shkyçet një herë. Fjalëkalimi kryesor mbetet i njëjtë.", + "Ask your organisation instead": "Pyet më mirë organizatën tënde", + "The request ended. Ask again or use your master password.": "Kërkesa përfundoi. Kërko përsëri ose përdor fjalëkalimin kryesor.", + "Added by {user}": "Shtuar nga {user}", + "Editor": "Redaktor", + "Manager": "Menaxher", + "Role of {member}": "Roli i {member}", + "Team folders you manage": "Dosjet e ekipit që menaxhoni", + "Viewer": "Shikues", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Nuk keni kopje të këtyre sekreteve, prandaj anëtarët e rinj nuk i kanë marrë ende. Pronari mund t'i ndajë: {names}", + "Admin areas": "Fushat e administrimit", + "Give a group only the parts of Keepiq administration it needs.": "Jepini një grupi vetëm pjesët e administrimit të Keepiq që i nevojiten.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegoni një ose më shumë fusha te një grup në faqen e privilegjeve të administrimit. Administratorët e instancës kanë çdo fushë.", + "Open administration privileges": "Hap privilegjet e administrimit", + "Policies": "Politikat", + "Applications and machine access": "Aplikacionet dhe qasja e makinave", + "People and offboarding": "Njerëzit dhe largimet", + "Audit and compliance": "Auditimi dhe përputhshmëria", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "versioni, autoriteti i certifikimit, bashkëngjitjet, memoria offline, kontrolli i rrjedhjeve, llojet e sekreteve dhe kopjet rezervë", + "master password, organisation password, vault policies, rotation, version history and trash": "fjalëkalimi kryesor, fjalëkalimi i organizatës, politikat e kasafortës, rrotullimi, historiku i versioneve dhe koshi", + "application queue, application requests and machine leases": "radha e aplikacioneve, kërkesat e aplikacioneve dhe qiratë e makinave", + "team offboarding, encryption suites and admin handover": "largimet nga ekipi, paketat e enkriptimit dhe marrja përsipër nga administratori", + "audit log, compliance reports, SIEM export and honey alerts": "regjistri i auditimit, raportet e përputhshmërisë, eksporti SIEM dhe sinjalizimet e karremave", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Sa versione të një sekreti ruhen, për sa kohë, dhe sa kohë qëndrojnë në kosh sekretet e fshira.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Kufijtë për bashkëngjitjet e enkriptuara, të zbatuara në server në bajtë të enkriptuar të ruajtur.", + "Type the suite ID again to confirm": "Shkruani përsëri ID-në e grupit për ta konfirmuar", + "This does not match the suite ID.": "Kjo nuk përputhet me ID-në e grupit.", + "Confirm with your master password": "Konfirmoni me fjalëkalimin kryesor", + "Confirm": "Konfirmo", + "That master password is not right.": "Ky fjalëkalim kryesor nuk është i saktë.", + "You are sharing with someone new. Enter your master password to confirm.": "Po ndani me dikë të ri. Shkruani fjalëkalimin kryesor për ta konfirmuar.", + "Enter your master password to confirm this share.": "Shkruani fjalëkalimin kryesor për të konfirmuar këtë ndarje.", + "Enter your master password to confirm this delegation.": "Shkruani fjalëkalimin kryesor për të konfirmuar këtë delegim.", + "Approve {member}": "Mirato {member}", + "Recipient": "Marrësi", + "No vault yet": "Ende pa kasafortë", + "No matching users": "Asnjë përdorues që përputhet", + "Partner organisations": "Organizata partnere", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Shkëmbeni sekrete me një Keepiq tjetër. Të dy administratorët shtojnë njëri-tjetrin dhe krahasojnë gjurmët rrënjë në telefon ose personalisht para ruajtjes.", + "Federation needs Nextcloud 33 or later.": "Federimi kërkon Nextcloud 33 ose më të ri.", + "Your root fingerprint": "Gjurma juaj rrënjë", + "No partners yet.": "Ende pa partnerë.", + "Users here may share to this partner": "Përdoruesit këtu mund të ndajnë me këtë partner", + "This partner may share to users here": "Ky partner mund të ndajë me përdoruesit këtu", + "Partner address": "Adresa e partnerit", + "Check partner": "Kontrollo partnerin", + "Partner root fingerprint": "Gjurma rrënjë e partnerit", + "I compared this fingerprint with the partner's administrator": "E krahasova këtë gjurmë me administratorin e partnerit", + "Add partner": "Shto partnerin", + "A secret from another organisation": "Një sekret nga një organizatë tjetër", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s ndau \"%2$s\" me ju. Pranojeni te Të ardhura nga organizata të tjera.", + "Incoming from other organisations": "Të ardhura nga organizata të tjera", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personat në organizatat partnere mund të ndajnë një sekret me ju. Pranojeni për të mbajtur një kopje vetëm për lexim në kasafortën tuaj.", + "Nothing shared with you yet": "Asgjë nuk është ndarë ende me ju", + "Secrets that people in partner organisations share with you appear here.": "Sekretet që personat në organizatat partnere ndajnë me ju shfaqen këtu.", + "From {sender}": "Nga {sender}", + "Accept": "Prano", + "Open in vault": "Hape në kasafortë", + "The other organisation did not hand over the secret. Try again later.": "Organizata tjetër nuk e dorëzoi sekretin. Provoni përsëri më vonë.", + "Set up your vault before you accept a shared secret.": "Konfiguroni kasafortën tuaj përpara se të pranoni një sekret të ndarë.", + "Something went wrong. Try again.": "Diçka shkoi keq. Provoni përsëri.", + "Waiting for your answer": "Në pritje të përgjigjes suaj", + "In your vault, read-only": "Në kasafortën tuaj, vetëm për lexim", + "Withdrawn by the sender": "Tërhequr nga dërguesi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} e ndau këtë nga një organizatë tjetër. Mund ta lexoni, por jo ta ndryshoni ose ta ndani.", + "Someone": "Dikush", + "Share with someone at another organisation": "Ndaj me dikë nga një organizatë tjetër", + "Their account at the other organisation": "Llogaria e personit në organizatën tjetër", + "Check account": "Kontrollo llogarinë", + "Certificate fingerprint of {account}": "Gjurma e certifikatës së {account}", + "Compare it with them by phone if you want to be sure.": "Krahasojeni me personin në telefon nëse doni të jeni të sigurt.", + "Shared. {account} can accept it in their own vault.": "U nda. {account} mund ta pranojë në kasafortën e vet.", + "The certificate could not be verified. Nothing was shared.": "Certifikata nuk mund të verifikohej. Asgjë nuk u nda.", + "That organisation is not one of your partners.": "Ajo organizatë nuk është një nga partnerët tuaj.", + "No one with that account can receive secrets from you.": "Askush me atë llogari nuk mund të marrë sekrete nga ju.", + "The other organisation did not answer. Try again later.": "Organizata tjetër nuk u përgjigj. Provoni përsëri më vonë.", + "This secret is already shared with that account.": "Ky sekret është ndarë tashmë me atë llogari.", + "Other organisations": "Organizata të tjera", + "Receive secrets from other organisations": "Merr sekrete nga organizata të tjera", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personat në organizatat partnere mund ta gjejnë atëherë llogarinë tuaj dhe të ndajnë sekrete me ju. Secilin prej tyre e pranoni vetë.", + "Shared": "E ndarë", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Në pauzë: certifikata e tyre ose partneriteti ndryshoi. Revokojeni ose ndajeni përsëri.", + "Their organisation did not get the last change. Revoke it or share again.": "Organizata e tyre nuk e mori ndryshimin e fundit. Revokojeni ose ndajeni përsëri.", + "Being withdrawn": "Po tërhiqet", + "Shared with another organisation": "U nda me një organizatë tjetër", + "Change sent to another organisation": "Ndryshimi u dërgua te një organizatë tjetër", + "Share with another organisation revoked": "Ndarja me një organizatë tjetër u revokua", + "Share with another organisation paused": "Ndarja me një organizatë tjetër u vu në pauzë", + "Another organisation did not get a change": "Një organizatë tjetër nuk e mori një ndryshim", + "Secret received from another organisation": "U mor një sekret nga një organizatë tjetër", + "Secret from another organisation accepted": "Sekreti nga një organizatë tjetër u pranua", + "Secret from another organisation declined": "Sekreti nga një organizatë tjetër u refuzua", + "Copy from another organisation updated": "Kopja nga një organizatë tjetër u përditësua", + "Copy from another organisation removed": "Kopja nga një organizatë tjetër u hoq", + "Declined: they removed their copy. Share again if they need it.": "Refuzuar: marrësi hoqi kopjen e vet. Ndajeni përsëri nëse i nevojitet.", + "Recipient at another organisation removed their copy": "Një marrës nga një organizatë tjetër hoqi kopjen e vet", + "Removed the user from %n team folder.": "Përdoruesi u hoq nga %n dosje ekipi.", + "Removed the user from %n team folders.": "Përdoruesi u hoq nga %n dosje ekipi.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Përdoruesi u hoq nga %n dosje ekipi.", + "Përdoruesi u hoq nga %n dosje ekipi." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Një kopje e rikthyer vjen nga një ndarje që ka përfunduar. Mbetet vetëm për lexim.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organizata që ndau një kopje të rikthyer nuk u arrit. Kopja mbetet vetëm për lexim dhe nuk ndjek ndryshimet e tyre.", + "Recipient at another organisation restored their copy": "Një marrës nga një organizatë tjetër rikthye kopjen e vet" }, "plurals": null } diff --git a/l10n/sr.js b/l10n/sr.js index c836fd5bf..f3e47cd4f 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите.", + "Shared with groups": "Дељено са групама", + "Not shared with any group yet.": "Још није дељено ни са једном групом.", + "Revoke the share with {group}": "Опозови дељење са групом {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Дељено са групом {group}: {received} чланова је то примило, {skipped} није јер још нису подесили шифровање.", + "Search groups": "Претражи групе", + "Failed to share": "Дељење није успело", + "Columns": "Колоне", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Повежите једну колону са називом. Свака тајна мора имати назив.", + "Notes": "Белешке", + "Do not import": "Не увози", + "Hide this value": "Сакриј ову вредност", + "Show this value": "Прикажи ову вредност", + "Defaults": "Подразумевано", + "New secrets start as this type, and your secret list opens in this view.": "Нове тајне почињу као ова врста, а твоја листа тајни се отвара у овом приказу.", + "Default item type": "Подразумевана врста ставке", + "Cards": "Картице", + "Table": "Табела", + "Could not save your default": "Подразумевану вредност није могуће сачувати", + "Recently used": "Недавно коришћено", + "Opened": "Отворено", + "You have not opened any secrets yet": "Још ниси отворио ниједну тајну", + "Could not delete the item type.": "Врсту ставке није могуће обрисати.", + "Could not load the item types.": "Врсте ставки није могуће учитати.", + "Could not save the item type.": "Врсту ставке није могуће сачувати.", + "Delete item type": "Обриши врсту ставке", + "Edit item type": "Уреди врсту ставке", + "Fields": "Поља", + "Fields: {count}": "Поља: {count}", + "Hidden": "Скривено", + "Item types": "Врсте ставки", + "Move up": "Помери горе", + "New item type": "Нова врста ставке", + "No item types defined yet.": "Још нема дефинисаних врста ставки.", + "Required": "Обавезно", + "Text": "Текст", + "This field is required": "Ово поље је обавезно", + "Web address": "Веб адреса", + "{label} (required)": "{label} (обавезно)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Обрисати „{name}”? Тајне ове врсте остају читљиве и постају ставке Пријава.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Врсте ставки које овде дефинишеш свима се приказују у дијалогу Нова тајна, са пољима која изабереш.", + "Secret moved to the trash": "Тајна премештена у отпад", + "Secret restored from the trash": "Тајна враћена из отпада", + "Secret deleted for good": "Тајна трајно обрисана", + "Secret archived": "Тајна архивирана", + "Secret unarchived": "Тајна враћена из архиве", + "Unarchive": "Врати из архиве", + "Could not archive the secret": "Тајну није могуће архивирати", + "Could not unarchive the secret": "Тајну није могуће вратити из архиве", + "Archive {count} secrets": "Архивирај тајне: {count}", + "Unarchive {count} secrets": "Врати из архиве тајне: {count}", + "Restore {count} secrets": "Врати тајне: {count}", + "Delete {count} secrets for good": "Трајно обриши тајне: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} од {total} тајни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивиране тајне нестају са листе трезора, из претраге, аутоматског попуњавања и извештаја о стању. Задржавају дељења. Наћи ћете их у Архиви.", + "These secrets come back to the vault list, search and autofill.": "Ове тајне се враћају на листу трезора, у претрагу и аутоматско попуњавање.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ове тајне се враћају на листу трезора. Стара дељења се не враћају, па их поново поделите где је потребно.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Овим се бришу тајне заједно са прилозима и историјом верзија. Ово се не може поништити.", + "Delete for good": "Трајно обриши", + "Trash": "Отпад", + "The trash is empty": "Отпад је празан", + "No archived secrets": "Нема архивираних тајни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Обрисане тајне чекају овде до краја периода чувања, а затим се трајно бришу.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирајте тајну на њеном панелу са детаљима да би остала ван листе трезора, претраге и аутоматског попуњавања.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничења за шифроване прилоге (спроводе се на серверу у сачуваним шифрованим бајтовима), чување историје верзија и колико дуго обрисане тајне остају у отпаду.", + "Days a deleted secret stays in the trash (1 to 365)": "Број дана колико обрисана тајна остаје у отпаду (1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Овим се тајна премешта у отпад и њена дељења одмах престају. Можете је вратити из отпада до краја периода чувања: 30 дана, осим ако је администратор то променио.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Овим се тајне премештају у отпад ({count}) и њихова дељења одмах престају. Можете их вратити из отпада до краја периода чувања.", + "Remove {name} from favourites": "Уклони {name} из омиљених", + "Add {name} to favourites": "Додај {name} у омиљене", + "Could not change the favourite": "Није могуће променити омиљено", + "Remove from favourites": "Уклони из омиљених", + "Add to favourites": "Додај у омиљене", + "Tags": "Ознаке", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Ознаке нису шифроване. Администратори сервера могу да их читају, као и називе фасцикли.", + "Favourites": "Омиљено", + "Filter by tag": "Филтрирај по ознаци", + "All tags": "Све ознаке", + "Last used": "Последње коришћено", + "Tags for {count} secrets": "Ознаке за {count} тајни", + "Tag": "Ознака", + "Remove tag": "Уклони ознаку", + "Add tag": "Додај ознаку", + "Could not change the tags. Try again.": "Није могуће променити ознаке. Покушајте поново.", + "Could not approve the application. It is still in the queue.": "Није могуће одобрити захтев. Још је у реду чекања.", + "Could not reject the application. It is still in the queue.": "Није могуће одбити захтев. Још је у реду чекања.", + "Removed the user from {count} team folders.": "Корисник је уклоњен из {count} тимских фасцикли.", + "Approve a share": "Одобри дељење", + "This approval link is incomplete. Open it again from the notification.": "Овај линк за одобравање је непотпун. Отворите га поново из обавештења.", + "Deny": "Одбиј", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се придружио групи са којом делите тајну. Да ли да поделите тајну и са њим?", + "{requester} asks you to share a secret with {user}.": "{requester} тражи да поделите тајну са корисником {user}.", + "Shared. The recipient can now open the secret.": "Подељено. Прималац сада може да отвори тајну.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Прималац још није подесио Keepiq, па ништа није подељено. Покушајте поново када то уради.", + "Could not share the secret. Only its owner can approve this.": "Није могуће поделити тајну. Ово може да одобри само њен власник.", + "Could not share the secret. Try again.": "Није могуће поделити тајну. Покушајте поново.", + "Denied. Nothing was shared.": "Одбијено. Ништа није подељено.", + "Could not deny the request. Try again.": "Није могуће одбити захтев. Покушајте поново.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s тражи да поделите тајну \"%2$s\" са корисником %3$s.", + "Expires on (optional)": "Истиче (опционо)", + "Hand over to": "Предај кориснику", + "Choose a recipient": "Изаберите примаоца", + "Hand over temporarily": "Привремено предај", + "Expiry rules": "Правила истека", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Подесите колико дуго смеју да важе лозинке једне врсте ставке или у једној фасцикли и када желите подсетник. Када важи више датума, рачуна се најранији.", + "Delete rule": "Обриши правило", + "Set by your administrator": "Поставио ваш администратор", + "No expiry rules yet.": "Још нема правила истека.", + "Applies to": "Примењује се на", + "Item type": "Врста ставке", + "Maximum age in days (empty for reminders only)": "Највећа старост у данима (празно само за подсетнике)", + "Remind me this many days before, comma separated": "Подсети ме толико дана пре, раздвојено зарезима", + "Save rule": "Сачувај правило", + "An item type": "Врста ставке", + "A folder": "Фасцикла", + "Folder {name}": "Фасцикла {name}", + "Type {name}": "Врста {name}", + "Expires after {days} days": "Истиче након {days} дана", + "Reminders {days} days before": "Подсетници {days} дана пре", + "Could not save the expiry rule.": "Није могуће сачувати правило истека.", + "Could not delete the expiry rule.": "Није могуће обрисати правило истека.", + "All statuses": "Сви статуси", + "Compromised": "Компромитован", + "Could not load the members.": "Није могуће учитати чланове.", + "Emergency contact": "Контакт за хитне случајеве", + "Leaving user": "Корисник који одлази", + "No": "Не", + "No users match this filter.": "Ниједан корисник не одговара овом филтеру.", + "Not set up": "Није подешено", + "Revoke suite": "Опозови пакет", + "Revoked": "Опозван", + "Search users": "Претражи кориснике", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Погледајте који су корисници подесили трезор. Покрените одјаву или опозовите пакет из реда.", + "Successor": "Наследник", + "Team folders": "Тимске фасцикле", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Корисник је још у групи {groups}, која је чланица тимске фасцикле. Уклоните га из групе или онемогућите налог.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог.", + "Vault status": "Статус трезора", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Извоз у CXF НИЈЕ ШИФРОВАН. Свака лозинка и пријава биће читљива као отворени текст у преузетој датотеци. Чувајте је на безбедном месту и избришите је одмах након употребе.", + "Root certificate expiring soon": "Коренски сертификат ускоро истиче", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Коренски сертификат трезора истиче за %1$d дан(а). Обновите га пре тога. Обнова поново потписује сваки пакет шифровања.", + "Compromise recovery aborted": "Опоравак након компромитовања прекинут", + "Key rotation ended by a compromise revoke": "Ротација кључа завршена опозивом због компромитовања", + "Encryption suite revoke refused": "Опозив пакета за шифровање одбијен", + "Master password proof refused": "Доказ главне лозинке одбијен", + "Your current master password": "Ваша тренутна главна лозинка", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за хитне случајеве имао је захтев за приступ на чекању када га је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ови контакти за хитне случајеве нису пренети на ваш нови кључ. Њихов хитни приступ је уклоњен. Поново их додајте у Хитном приступу ако их још желите.", + "Renew root certificate": "Обнови коренски сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ово ствара нови коренски и посреднички сертификат. Сваки активни пакет шифровања поново се потписује. Ово се не може опозвати.", + "Renew root": "Обнови корен", + "Root renewed. {n} encryption suites signed again.": "Корен обновљен. Поново потписаних пакета шифровања: {n}.", + "Could not renew the root certificate.": "Коренски сертификат није могуће обновити.", + "Lease policy for this application": "Политика закупа за ову апликацију", + "In force now: {default} seconds by default, {max} seconds at most.": "Сада важи: подразумевано {default} секунди, највише {max} секунди.", + "Leases are not renewable": "Закупи се не могу обнављати", + "Lease policy saved.": "Политика закупа је сачувана.", + "Leave a field empty to use the instance value.": "Оставите поље празно да бисте користили вредност инстанце.", + "Instance value: {value}": "Вредност инстанце: {value}", + "Renewal": "Обнављање", + "Use the instance value ({value})": "Користи вредност инстанце ({value})", + "Allowed": "Дозвољено", + "Not allowed": "Није дозвољено", + "Save lease policy": "Сачувај политику закупа", + "Only an administrator can change this policy.": "Само администратор може да промени ову политику.", + "Could not save the lease policy.": "Политику закупа није могуће сачувати.", + "{member} got access from {confirmer}.": "{member} је добио приступ од {confirmer}.", + "Automatically confirm new team folder members": "Аутоматски потврди нове чланове тимских фасцикли", + "Gave %n new member access to a team folder.": "%n нови члан добио је приступ тимској фасцикли.", + "Gave %n new members access to a team folder.": "Нови чланови (%n) добили су приступ тимској фасцикли.", + "Give new team folder members access without waiting for the folder owner.": "Дајте новим члановима приступ без чекања власника фасцикле.", + "New team folder members": "Нови чланови тимских фасцикли", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Власник или члан са правом писања потврђује их из отвореног трезора. Keepiq никад не дешифрује на серверу.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Чека се да члан са правом писања отвори Keepiq. Можете и одмах да делите.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Део одговора на компромитацију није успео ({failed} корак(а)). Проверите дневник сервера, а затим поново опозовите пакет да бисте га завршили.", + "This also revoked suite {suite} and ended key migration {migration}.": "Тиме је опозван и пакет {suite} и завршена миграција кључева {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Опозив другог пакета обрисао је %n контакт за хитан приступ.", + "Revoking the second suite deleted %n emergency-access contacts.": "Опозив другог пакета обрисао је %n контаката за хитан приступ.", + "A suite revoked as compromised cannot be reinstated.": "Пакет опозван као компромитован не може се вратити.", + "Archives to keep": "Архиве за чување", + "Back up every vault automatically": "Аутоматски направи резервну копију сваког трезора", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Правите резервну копију сваког трезора по распореду. Архиве садрже само шифровани текст и враћају се преко occ.", + "Back up now": "Направи копију сада", + "Backup public key (PEM, optional)": "Јавни кључ резервне копије (PEM, опционо)", + "Backup requested for the next cron run": "Копија затражена за следеће покретање crona", + "Encrypted": "Шифровано", + "Every (hours)": "Сваких (сати)", + "Last backup {when} failed: {error}": "Последња копија {when} није успела: {error}", + "Last backup {when} succeeded.": "Последња копија {when} је успела.", + "No archives yet.": "Још нема архива.", + "Size": "Величина", + "Vault backups": "Резервне копије трезора", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Са кључем се свака архива шифрује за њега. Приватни кључ чувајте ван овог сервера: потребан вам је за проверу или враћање.", + "Written": "Записано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n корисник у опсегу још нема пријаву у два корака и не може да отвори трезор док је ово укључено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервни кодови се не рачунају. Ако се ваши корисници пријављују преко добављача идентитета са сопственим другим фактором, изоставите њихове групе.", + "Block personal vault export": "Блокирај извоз личног трезора", + "Keep work logins in team folders": "Чувај пословне пријаве у тимским фасциклама", + "Move to a team folder": "Премести у тимску фасциклу", + "Not in a team folder": "Није у тимској фасцикли", + "Only for these groups (empty is everyone)": "Само за ове групе (празно значи сви)", + "Require two-factor login before the vault opens": "Захтевај пријаву у два корака пре отварања трезора", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за сваки трезор. Свако важи за све или само за групе које изаберете.", + "Secret types that belong in a team folder": "Врсте тајни које припадају у тимску фасциклу", + "Set up two-factor login": "Подеси пријаву у два корака", + "Team folder you can write to": "Тимска фасцикла у коју можете да пишете", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Корисници не могу да преузму резервну копију, CSV нити датотеку за пренос. Њихов пакет личних података остаје доступан.", + "Users cannot save these secret types in a personal folder.": "Корисници не могу да сачувају ове врсте тајни у личну фасциклу.", + "Vault policies": "Правила трезора", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша организација не дозвољава извоз вашег личног трезора. Ваш пакет личних података у подешавањима остаје доступан.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша организација чува ове тајне у тимској фасцикли. Преместите сваку у тимску фасциклу.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша организација чува ову врсту тајне у тимској фасцикли. Изаберите једну од својих тимских фасцикли или ону у коју можете да пишете.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша организација захтева пријаву у два корака пре него што можете да отворите свој трезор.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Корисници бирају колико дуго проширење остаје откључано током неактивности. Ви постављате најдуже време које смеју да изаберу.", + "Longest idle time before the extension locks": "Најдуже време неактивности пре закључавања проширења", + "1 minute": "1 минут", + "5 minutes": "5 минута", + "15 minutes": "15 минута", + "1 hour": "1 сат", + "4 hours": "4 сата", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД директоријума (закупца)", + "Application (client) ID": "ИД апликације (клијента)", + "Data collection rule immutable ID": "Непроменљиви ИД правила прикупљања података", + "Stream name": "Назив тока", + "Splunk index (optional)": "Splunk индекс (опционо)", + "Sourcetype (optional)": "Sourcetype (опционо)", + "Leave blank to keep the current one": "Оставите празно да задржите тренутну вредност", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF преко syslog-а", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крајња тачка прикупљања података (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector-а (https)", + "Client secret (write-only)": "Тајна клијента (само уписивање)", + "HEC token (write-only)": "HEC токен (само уписивање)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Прослеђујте дозвољене ревизијске догађаје у Splunk, Microsoft Sentinel, syslog пријемник или webhook. Поруке садрже само очишћене метаподатке: ниједна тајна вредност, име, пријава или шифровани текст никада не напушта сервер.", + "%n change waiting to sync": "%n измена чека синхронизацију", + "%n changes waiting to sync": "%n измена чека синхронизацију", + "Changes that could not sync": "Измене које нису могле да се синхронизују", + "Choose a version": "Изаберите верзију", + "Copy value": "Копирај вредност", + "Deleted": "Обрисано", + "Discard": "Одбаци", + "Keep my offline change": "Задржи моју ванмрежну измену", + "Keep the server version": "Задржи верзију са сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq је ван мреже само за читање. Администратор није укључио уређивање ван мреже.", + "Let users edit secrets offline": "Дозволи корисницима да уређују тајне ван мреже", + "Not synced yet": "Још није синхронизовано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Ванмрежне измене остају на уређају, шифроване за корисника, и синхронизују се при следећем откључавању на мрежи. Дељење, фасцикле и прилози и даље траже везу.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Ван мреже. Уређивања, премештања и брисања остају на овом уређају и синхронизују се када поново будете на мрежи. Дељење и прилози траже везу.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Ван мреже. Ваше измене остају на овом уређају и синхронизују се када поново будете на мрежи. Последња синхронизација {when}.", + "Open my changes": "Отвори моје измене", + "Sharing needs a connection": "Дељење тражи везу", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Неко је изменио ову тајну на серверу након што је направљена ваша ванмрежна копија. Изаберите коју верзију да задржите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизујте или одбаците ванмрежне измене пре замене кључева.", + "That password did not open your changes.": "Та лозинка није отворила ваше измене.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Ванмрежни снимак чува шифроване тајне (отварају се само кључем изведеним из главне лозинке корисника, тачно као на серверу) и шифрује називе, URL-ове и називе фасцикли у складишту. Ванмрежни приступ је само за читање, осим ако испод дозволите уређивање ван мреже. Искључите ово за уређаје који никад не смеју да кеширају акредитиве; искључивање брише постојеће кешеве при следећем учитавању.", + "The previous vault copy is gone, so these changes cannot be opened.": "Претходне копије трезора више нема, па се ове измене не могу отворити.", + "The server version": "Верзија са сервера", + "This secret changed while you were offline": "Ова тајна се променила док сте били ван мреже", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ову тајну сте обрисали ван мреже, али је у међувремену измењена на серверу. Изаберите коју верзију да задржите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваши кључеви су промењени на другом уређају. Унесите претходну главну лозинку да синхронизујете ванмрежне измене или их одбаците.", + "Your offline change": "Ваша ванмрежна измена", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n контакт за хитне случајеве имао је захтев за приступ на чекању када га је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.","Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.","Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n ставку није могуће приказати у CXF-у и биће прескочена.","%n ставки није могуће приказати у CXF-у и биће прескочене.","%n ставки није могуће приказати у CXF-у и биће прескочене."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n старија верзија је одбачена јер се може пренети само недавна историја.","%n старијих верзија је одбачено јер се може пренети само недавна историја.","%n старијих верзија је одбачено јер се може пренети само недавна историја."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n копију тајне још треба шифровати и поделити.","%n копија тајни још треба шифровати и поделити.","%n копија тајни још треба шифровати и поделити."], + "_%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 тајна није могла да се дешифрује и није у овом извозу.","%n тајни није могло да се дешифрује и нису у овом извозу.","%n тајни није могло да се дешифрује и нису у овом извозу."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n тајна није могла да се дешифрује вашим старим кључем, па није пренета.","%n тајни није могло да се дешифрује вашим старим кључем, па нису пренете.","%n тајни није могло да се дешифрује вашим старим кључем, па нису пренете."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n тајна није пренета.","%n тајни није пренето.","%n тајни није пренето."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n тајна је још увек шифрована вашим претходним кључем.","%n тајни је још увек шифровано вашим претходним кључем.","%n тајни је још увек шифровано вашим претходним кључем."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n тајна је прескочена јер наследник још нема копију — додајте наследника у фасциклу и покрените поново.","%n тајни је прескочено јер наследник још нема копију — додајте наследника у фасциклу и покрените поново.","%n тајни је прескочено јер наследник још нема копију — додајте наследника у фасциклу и покрените поново."], + "_%n secret_::_%n secrets_": ["%n тајна","%n тајни","%n тајни"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n корисник у опсегу још нема пријаву у два корака и не може да отвори трезор док је ово укључено.","Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено.","Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Ипак заврши, уз губитак приступа %n тајни","Ипак заврши, уз губитак приступа %n тајни","Ипак заврши, уз губитак приступа %n тајни"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n нови члан добио је приступ тимској фасцикли.","Нови чланови (%n) добили су приступ тимској фасцикли.","Нови чланови (%n) добили су приступ тимској фасцикли."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ротација кључа је завршена. %n тајна је поново шифрована вашим новим кључем.","Ротација кључа је завршена. %n тајни је поново шифровано вашим новим кључем.","Ротација кључа је завршена. %n тајни је поново шифровано вашим новим кључем."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Опозив другог пакета обрисао је %n контакт за хитан приступ.","Опозив другог пакета обрисао је %n контаката за хитан приступ.","Опозив другог пакета обрисао је %n контаката за хитан приступ."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Опозивање овог комплета избрисало је %n контакт хитног приступа.","Опозивање овог комплета избрисало је %n контаката хитног приступа.","Опозивање овог комплета избрисало је %n контаката хитног приступа."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["забележено %n пут у процурелим подацима","забележено %n пута у процурелим подацима","забележено %n пута у процурелим подацима"], + "_shared with %n secret_::_shared with %n secrets_": ["подељено са %n тајном","подељено са %n тајни","подељено са %n тајни"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ова фасцикла директно садржи %n тајну.","Ова фасцикла директно садржи %n тајни.","Ова фасцикла директно садржи %n тајни."], + "_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._": ["Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.","Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.","Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n измена чека синхронизацију","%n измена чека синхронизацију","%n измена чека синхронизацију"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Корисник је још у групи {groups}, која је чланица тимске фасцикле. Уклоните га из групе или онемогућите налог.","Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог.","Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог."], + "Allow approval from another device": "Дозволи одобрење са другог уређаја", + "App": "Апликација", + "Approve a new device": "Одобри нови уређај", + "Approve from another device": "Одобри са другог уређаја", + "Asked at": "Затражено у", + "Check that the new device shows these words:": "Проверите да ли нови уређај приказује ове речи:", + "Denied. If you did not ask, end your other sessions:": "Одбијено. Ако то нисте затражили, завршите остале сесије:", + "Device": "Уређај", + "IP address": "IP адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Омогући корисницима да откључају нови прегледач одобравањем са уређаја на коме је Keepiq већ откључан.", + "New device approval": "Одобравање нових уређаја", + "Nextcloud security settings": "Nextcloud безбедносна подешавања", + "Only approve a device you are using right now.": "Одобрите само уређај који тренутно користите.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворите Keepiq на уређају на коме је откључан и одобрите овај уређај. Проверите да ли приказује исте речи:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Уређај који одобрава запечати кључ за откључавање за нови уређај. Сервер га само прослеђује и не може да га отвори.", + "The master password is not right, or the request has ended.": "Главна лозинка није тачна или је захтев завршен.", + "The request expired. Ask again or use your master password.": "Захтев је истекао. Затражите поново или користите главну лозинку.", + "The request was denied.": "Захтев је одбијен.", + "Too many requests. Try again in an hour or use your master password.": "Превише захтева. Покушајте поново за сат времена или користите главну лозинку.", + "Unknown device": "Непознат уређај", + "Web app": "Веб апликација", + "A device": "Уређај", + "A new device asks to open your vault": "Нови уређај тражи да отвори ваш сеф", + "%s asks to be approved. Only approve a device you are using right now.": "%s тражи одобрење. Одобрите само уређај који тренутно користите.", + "Access ends on (optional)": "Приступ истиче (опционо)", + "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.": "Апликације Keepiq неће приказати нити копирати лозинку. Неко са техничким знањем и даље може да је прочита на свом уређају. Промените је када приступ истекне.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ова тајна је само за коришћење. Пријавите се преко Keepiq проширења за прегледач.", + "Until {date}": "До {date}", + "Use only": "Само коришћење", + "Use only (can sign in, cannot view or copy)": "Само коришћење (може да се пријави, не може да види нити копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Овом пријавом можете да се пријавите преко Keepiq проширења за прегледач. Власник је одлучио да не можете да је видите нити копирате.", + "Your access ends on {date}": "Ваш приступ истиче {date}", + "Your access to this secret has ended": "Ваш приступ овој тајни је истекао", + "Your access to \"%s\" ends tomorrow": "Ваш приступ ставци „%s” истиче сутра", + "Your access to \"%s\" has ended": "Ваш приступ ставци „%s” је истекао", + "%1$s no longer has access to \"%2$s\"": "%1$s више нема приступ ставци „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s је могао/ла да види ову лозинку. Промените је ако %1$s више не би требало да је зна.", + "%s could not view this password in Keepiq.": "%s није могао/ла да види ову лозинку у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} од {threshold} одобрења", + "a recovery officer": "службеник за опоравак", + "Account recovery": "Опоравак налога", + "Approvals needed": "Потребна одобрења", + "Ask {user} which words they see, by phone or in person. They must be:": "Питајте корисника {user} које речи види, телефоном или лично. Морају бити:", + "Check again": "Провери поново", + "Create the recovery key": "Направи кључ за опоравак", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Направите кључ за опоравак. Ваш прегледач га прави и сваком службенику даје копију коју само он може да отвори.", + "Decline": "Одбиј", + "Enrol in account recovery": "Пријавите се за опоравак налога", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Пријавите се како би вам организација могла да помогне да вратите трезор ако заборавите главну лозинку.", + "Every user is enrolled": "Сви корисници су пријављени", + "Finish the recovery in the browser you asked from.": "Завршите опоравак у прегледачу из ког сте га затражили.", + "Forgot your master password?": "Заборавили сте главну лозинку?", + "Hand the key over": "Предај кључ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Омогућите корисницима који су заборавили главну лозинку да врате трезор, уз одобрење службеника за опоравак које именујете.", + "New master password": "Нова главна лозинка", + "No one is asking to recover their account.": "Нико не тражи опоравак налога.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Још нема кључа за опоравак. Један од службеника га прави у својим Keepiq подешавањима.", + "Off": "Искључено", + "Officer {user} has no encryption set up yet.": "Службеник {user} још нема подешено шифровање.", + "Officers (user IDs, separated by commas)": "Службеници (ID-јеви корисника, раздвојени зарезима)", + "Policy": "Правила", + "Publish this fingerprint internally, so users can check it before they enrol.": "Објавите овај отисак интерно, како би корисници могли да га провере пре пријаве.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Опорављено уз помоћ корисника {officer}. Сада промените кључ трезора у Подешавањима, Безбедност: \"Моја главна лозинка је компромитована\".", + "Recovery key fingerprint: {fingerprint}": "Отисак кључа за опоравак: {fingerprint}", + "Recovery officer": "Службеник за опоравак", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Уклоњени службеници сада губе своју копију, али су је можда раније отворили. Нека службеник направи нови кључ за опоравак.", + "Repeat the new master password": "Поновите нову главну лозинку", + "Retire this recovery key": "Повуци овај кључ за опоравак", + "Set the new master password": "Постави нову главну лозинку", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификат за опоравак није издао овај Keepiq. Немојте се пријавити и обавестите администратора.", + "The words match, approve": "Речи се поклапају, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Овај корисник је пријављен за опоравак налога. Опоравак чува његове тајне; опозив брише његову пријаву.", + "Users may enrol": "Корисници могу да се пријаве", + "Withdraw from account recovery": "Одјави се са опоравка налога", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Пријављени сте за опоравак налога. Отисак кључа за опоравак: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Пријављени сте. Ако заборавите главну лозинку, организација вам може помоћи да вратите трезор.", + "Your key is back. Choose a new master password.": "Кључ је враћен. Изаберите нову главну лозинку.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваши службеници за опоравак су обавештени. Прочитајте им ове речи када вас позову или се нађете:", + "You are now an account recovery officer": "Сада сте службеник за опоравак налога", + "%s asks to recover their account. Compare the words with them before you approve.": "%s тражи опоравак налога. Упоредите речи с њим пре него што одобрите.", + "A user": "Корисник", + "Your account recovery request was declined": "Ваш захтев за опоравак налога је одбијен", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Опоравак налога је спреман. Отворите Keepiq у прегледачу из ког сте га затражили.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} тражи једнократно откључавање новог уређаја. Главна лозинка остаје иста.", + "Ask your organisation instead": "Уместо тога, питајте своју организацију", + "The request ended. Ask again or use your master password.": "Захтев је завршен. Затражите поново или употребите главну лозинку.", + "Added by {user}": "Додао/ла {user}", + "Editor": "Уредник", + "Manager": "Управник", + "Role of {member}": "Улога корисника {member}", + "Team folders you manage": "Тимске фасцикле којима управљате", + "Viewer": "Прегледач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Немате копију ових тајни, па их нови чланови још нису добили. Власник може да их подели: {names}", + "Admin areas": "Области администрације", + "Give a group only the parts of Keepiq administration it needs.": "Дајте групи само оне делове администрације Keepiq-а који су јој потребни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирајте једну или више области групи на страници администраторских овлашћења. Администратори инстанце имају сваку област.", + "Open administration privileges": "Отвори администраторска овлашћења", + "Policies": "Смернице", + "Applications and machine access": "Апликације и приступ машина", + "People and offboarding": "Људи и одласци", + "Audit and compliance": "Ревизија и усклађеност", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "верзија, сертификационо тело, прилози, офлајн кеш, провера цурења, типови тајни и резервне копије", + "master password, organisation password, vault policies, rotation, version history and trash": "главна лозинка, лозинка организације, смернице трезора, ротација, историја верзија и смеће", + "application queue, application requests and machine leases": "ред апликација, захтеви апликација и закупи машина", + "team offboarding, encryption suites and admin handover": "одласци из тима, пакети шифровања и преузимање од стране администратора", + "audit log, compliance reports, SIEM export and honey alerts": "ревизорски дневник, извештаји о усклађености, SIEM извоз и упозорења мамаца", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колико верзија тајне се чува, колико дуго, и колико дуго обрисане тајне остају у смећу.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничења за шифроване прилоге, која сервер примењује на сачуване шифроване бајтове.", + "Type the suite ID again to confirm": "Поново унесите ID комплета ради потврде", + "This does not match the suite ID.": "Ово се не поклапа са ID-ом комплета.", + "Confirm with your master password": "Потврдите главном лозинком", + "Confirm": "Потврди", + "That master password is not right.": "Та главна лозинка није исправна.", + "You are sharing with someone new. Enter your master password to confirm.": "Делите са новом особом. Унесите главну лозинку ради потврде.", + "Enter your master password to confirm this share.": "Унесите главну лозинку да потврдите ово дељење.", + "Enter your master password to confirm this delegation.": "Унесите главну лозинку да потврдите ово делегирање.", + "Approve {member}": "Одобри {member}", + "Recipient": "Прималац", + "No vault yet": "Још нема трезор", + "No matching users": "Нема одговарајућих корисника", + "Partner organisations": "Партнерске организације", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Размењујте тајне са другим Keepiq-ом. Оба администратора додају један другог и пре чувања упореде коренске отиске телефоном или лично.", + "Federation needs Nextcloud 33 or later.": "Федерација захтева Nextcloud 33 или новији.", + "Your root fingerprint": "Ваш коренски отисак", + "No partners yet.": "Још нема партнера.", + "Users here may share to this partner": "Корисници овде смеју да деле са овим партнером", + "This partner may share to users here": "Овај партнер сме да дели са корисницима овде", + "Partner address": "Адреса партнера", + "Check partner": "Провери партнера", + "Partner root fingerprint": "Коренски отисак партнера", + "I compared this fingerprint with the partner's administrator": "Упоредио сам овај отисак са администратором партнера", + "Add partner": "Додај партнера", + "A secret from another organisation": "Тајна из друге организације", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s дели са вама \"%2$s\". Прихватите је у одељку Долазно из других организација.", + "Incoming from other organisations": "Долазно из других организација", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Особе у партнерским организацијама могу са вама да деле тајну. Прихватите је да бисте задржали копију само за читање у свом трезору.", + "Nothing shared with you yet": "Још ништа није подељено са вама", + "Secrets that people in partner organisations share with you appear here.": "Овде се приказују тајне које са вама деле особе у партнерским организацијама.", + "From {sender}": "Од {sender}", + "Accept": "Прихвати", + "Open in vault": "Отвори у трезору", + "The other organisation did not hand over the secret. Try again later.": "Друга организација није предала тајну. Покушајте поново касније.", + "Set up your vault before you accept a shared secret.": "Подесите свој трезор пре него што прихватите подељену тајну.", + "Something went wrong. Try again.": "Нешто није у реду. Покушајте поново.", + "Waiting for your answer": "Чека ваш одговор", + "In your vault, read-only": "У вашем трезору, само за читање", + "Withdrawn by the sender": "Пошиљалац је повукао", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} дели ово из друге организације. Можете то да читате, али не и да мењате или делите.", + "Someone": "Неко", + "Share with someone at another organisation": "Подели са неким из друге организације", + "Their account at the other organisation": "Рачун те особе у другој организацији", + "Check account": "Провери рачун", + "Certificate fingerprint of {account}": "Отисак сертификата за {account}", + "Compare it with them by phone if you want to be sure.": "Упоредите га са том особом телефоном ако желите да будете сигурни.", + "Shared. {account} can accept it in their own vault.": "Подељено. {account} то може да прихвати у свом трезору.", + "The certificate could not be verified. Nothing was shared.": "Сертификат није могуће проверити. Ништа није подељено.", + "That organisation is not one of your partners.": "Та организација није један од ваших партнера.", + "No one with that account can receive secrets from you.": "Нико са тим рачуном не може да прима тајне од вас.", + "The other organisation did not answer. Try again later.": "Друга организација није одговорила. Покушајте поново касније.", + "This secret is already shared with that account.": "Ова тајна је већ подељена са тим рачуном.", + "Other organisations": "Друге организације", + "Receive secrets from other organisations": "Примање тајни из других организација", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Особе у партнерским организацијама тада могу да пронађу ваш рачун и деле тајне са вама. Сваку од њих прихватате сами.", + "Shared": "Подељено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Паузирано: променио се њихов сертификат или партнерство. Опозовите или поделите поново.", + "Their organisation did not get the last change. Revoke it or share again.": "Њихова организација није добила последњу промену. Опозовите или поделите поново.", + "Being withdrawn": "Повлачи се", + "Shared with another organisation": "Подељено са другом организацијом", + "Change sent to another organisation": "Промена послата другој организацији", + "Share with another organisation revoked": "Дељење са другом организацијом укинуто", + "Share with another organisation paused": "Дељење са другом организацијом паузирано", + "Another organisation did not get a change": "Друга организација није добила промену", + "Secret received from another organisation": "Тајна примљена из друге организације", + "Secret from another organisation accepted": "Тајна из друге организације прихваћена", + "Secret from another organisation declined": "Тајна из друге организације одбијена", + "Copy from another organisation updated": "Копија из друге организације ажурирана", + "Copy from another organisation removed": "Копија из друге организације уклоњена", + "Declined: they removed their copy. Share again if they need it.": "Одбијено: прималац је уклонио своју копију. Поделите поново ако му треба.", + "Recipient at another organisation removed their copy": "Прималац из друге организације уклонио је своју копију", + "Removed the user from %n team folder.": "Корисник је уклоњен из %n тимске фасцикле.", + "Removed the user from %n team folders.": "Корисник је уклоњен из %n тимских фасцикли.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Корисник је уклоњен из %n тимске фасцикле.","Корисник је уклоњен из %n тимских фасцикли.","Корисник је уклоњен из %n тимских фасцикли."], + "A restored copy came from a share that has ended. It stays read-only.": "Враћена копија потиче из дељења које је завршено. Остаје само за читање.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организација која је поделила враћену копију није доступна. Копија остаје само за читање и не прати њихове измене.", + "Recipient at another organisation restored their copy": "Прималац из друге организације вратио је своју копију" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/sr.json b/l10n/sr.json index ec9c0ac40..ca7c7bc79 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1181,7 +1181,592 @@ "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.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротација кључа је уклонила приступ у хитним случајевима овог контакта. Одредите га поново ако га још желите.", + "Shared with groups": "Дељено са групама", + "Not shared with any group yet.": "Још није дељено ни са једном групом.", + "Revoke the share with {group}": "Опозови дељење са групом {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Дељено са групом {group}: {received} чланова је то примило, {skipped} није јер још нису подесили шифровање.", + "Search groups": "Претражи групе", + "Failed to share": "Дељење није успело", + "Columns": "Колоне", + "Column {number}": "Колона {number}", + "Map one column to Name. Every secret needs a name.": "Повежите једну колону са називом. Свака тајна мора имати назив.", + "Notes": "Белешке", + "Do not import": "Не увози", + "Hide this value": "Сакриј ову вредност", + "Show this value": "Прикажи ову вредност", + "Defaults": "Подразумевано", + "New secrets start as this type, and your secret list opens in this view.": "Нове тајне почињу као ова врста, а твоја листа тајни се отвара у овом приказу.", + "Default item type": "Подразумевана врста ставке", + "Cards": "Картице", + "Table": "Табела", + "Could not save your default": "Подразумевану вредност није могуће сачувати", + "Recently used": "Недавно коришћено", + "Opened": "Отворено", + "You have not opened any secrets yet": "Још ниси отворио ниједну тајну", + "Could not delete the item type.": "Врсту ставке није могуће обрисати.", + "Could not load the item types.": "Врсте ставки није могуће учитати.", + "Could not save the item type.": "Врсту ставке није могуће сачувати.", + "Delete item type": "Обриши врсту ставке", + "Edit item type": "Уреди врсту ставке", + "Fields": "Поља", + "Fields: {count}": "Поља: {count}", + "Hidden": "Скривено", + "Item types": "Врсте ставки", + "Move up": "Помери горе", + "New item type": "Нова врста ставке", + "No item types defined yet.": "Још нема дефинисаних врста ставки.", + "Required": "Обавезно", + "Text": "Текст", + "This field is required": "Ово поље је обавезно", + "Web address": "Веб адреса", + "{label} (required)": "{label} (обавезно)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Обрисати „{name}”? Тајне ове врсте остају читљиве и постају ставке Пријава.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Врсте ставки које овде дефинишеш свима се приказују у дијалогу Нова тајна, са пољима која изабереш.", + "Secret moved to the trash": "Тајна премештена у отпад", + "Secret restored from the trash": "Тајна враћена из отпада", + "Secret deleted for good": "Тајна трајно обрисана", + "Secret archived": "Тајна архивирана", + "Secret unarchived": "Тајна враћена из архиве", + "Unarchive": "Врати из архиве", + "Could not archive the secret": "Тајну није могуће архивирати", + "Could not unarchive the secret": "Тајну није могуће вратити из архиве", + "Archive {count} secrets": "Архивирај тајне: {count}", + "Unarchive {count} secrets": "Врати из архиве тајне: {count}", + "Restore {count} secrets": "Врати тајне: {count}", + "Delete {count} secrets for good": "Трајно обриши тајне: {count}", + "Done for {ok} of {total} secrets": "Готово за {ok} од {total} тајни", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архивиране тајне нестају са листе трезора, из претраге, аутоматског попуњавања и извештаја о стању. Задржавају дељења. Наћи ћете их у Архиви.", + "These secrets come back to the vault list, search and autofill.": "Ове тајне се враћају на листу трезора, у претрагу и аутоматско попуњавање.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ове тајне се враћају на листу трезора. Стара дељења се не враћају, па их поново поделите где је потребно.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Овим се бришу тајне заједно са прилозима и историјом верзија. Ово се не може поништити.", + "Delete for good": "Трајно обриши", + "Trash": "Отпад", + "The trash is empty": "Отпад је празан", + "No archived secrets": "Нема архивираних тајни", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Обрисане тајне чекају овде до краја периода чувања, а затим се трајно бришу.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архивирајте тајну на њеном панелу са детаљима да би остала ван листе трезора, претраге и аутоматског попуњавања.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Ограничења за шифроване прилоге (спроводе се на серверу у сачуваним шифрованим бајтовима), чување историје верзија и колико дуго обрисане тајне остају у отпаду.", + "Days a deleted secret stays in the trash (1 to 365)": "Број дана колико обрисана тајна остаје у отпаду (1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Овим се тајна премешта у отпад и њена дељења одмах престају. Можете је вратити из отпада до краја периода чувања: 30 дана, осим ако је администратор то променио.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Овим се тајне премештају у отпад ({count}) и њихова дељења одмах престају. Можете их вратити из отпада до краја периода чувања.", + "Remove {name} from favourites": "Уклони {name} из омиљених", + "Add {name} to favourites": "Додај {name} у омиљене", + "Could not change the favourite": "Није могуће променити омиљено", + "Remove from favourites": "Уклони из омиљених", + "Add to favourites": "Додај у омиљене", + "Tags": "Ознаке", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Ознаке нису шифроване. Администратори сервера могу да их читају, као и називе фасцикли.", + "Favourites": "Омиљено", + "Filter by tag": "Филтрирај по ознаци", + "All tags": "Све ознаке", + "Last used": "Последње коришћено", + "Tags for {count} secrets": "Ознаке за {count} тајни", + "Tag": "Ознака", + "Remove tag": "Уклони ознаку", + "Add tag": "Додај ознаку", + "Could not change the tags. Try again.": "Није могуће променити ознаке. Покушајте поново.", + "Could not approve the application. It is still in the queue.": "Није могуће одобрити захтев. Још је у реду чекања.", + "Could not reject the application. It is still in the queue.": "Није могуће одбити захтев. Још је у реду чекања.", + "Removed the user from {count} team folders.": "Корисник је уклоњен из {count} тимских фасцикли.", + "Approve a share": "Одобри дељење", + "This approval link is incomplete. Open it again from the notification.": "Овај линк за одобравање је непотпун. Отворите га поново из обавештења.", + "Deny": "Одбиј", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} се придружио групи са којом делите тајну. Да ли да поделите тајну и са њим?", + "{requester} asks you to share a secret with {user}.": "{requester} тражи да поделите тајну са корисником {user}.", + "Shared. The recipient can now open the secret.": "Подељено. Прималац сада може да отвори тајну.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Прималац још није подесио Keepiq, па ништа није подељено. Покушајте поново када то уради.", + "Could not share the secret. Only its owner can approve this.": "Није могуће поделити тајну. Ово може да одобри само њен власник.", + "Could not share the secret. Try again.": "Није могуће поделити тајну. Покушајте поново.", + "Denied. Nothing was shared.": "Одбијено. Ништа није подељено.", + "Could not deny the request. Try again.": "Није могуће одбити захтев. Покушајте поново.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s тражи да поделите тајну \"%2$s\" са корисником %3$s.", + "Expires on (optional)": "Истиче (опционо)", + "Hand over to": "Предај кориснику", + "Choose a recipient": "Изаберите примаоца", + "Hand over temporarily": "Привремено предај", + "Expiry rules": "Правила истека", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Подесите колико дуго смеју да важе лозинке једне врсте ставке или у једној фасцикли и када желите подсетник. Када важи више датума, рачуна се најранији.", + "Delete rule": "Обриши правило", + "Set by your administrator": "Поставио ваш администратор", + "No expiry rules yet.": "Још нема правила истека.", + "Applies to": "Примењује се на", + "Item type": "Врста ставке", + "Maximum age in days (empty for reminders only)": "Највећа старост у данима (празно само за подсетнике)", + "Remind me this many days before, comma separated": "Подсети ме толико дана пре, раздвојено зарезима", + "Save rule": "Сачувај правило", + "An item type": "Врста ставке", + "A folder": "Фасцикла", + "Folder {name}": "Фасцикла {name}", + "Type {name}": "Врста {name}", + "Expires after {days} days": "Истиче након {days} дана", + "Reminders {days} days before": "Подсетници {days} дана пре", + "Could not save the expiry rule.": "Није могуће сачувати правило истека.", + "Could not delete the expiry rule.": "Није могуће обрисати правило истека.", + "All statuses": "Сви статуси", + "Compromised": "Компромитован", + "Could not load the members.": "Није могуће учитати чланове.", + "Emergency contact": "Контакт за хитне случајеве", + "Leaving user": "Корисник који одлази", + "No": "Не", + "No users match this filter.": "Ниједан корисник не одговара овом филтеру.", + "Not set up": "Није подешено", + "Revoke suite": "Опозови пакет", + "Revoked": "Опозван", + "Search users": "Претражи кориснике", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Погледајте који су корисници подесили трезор. Покрените одјаву или опозовите пакет из реда.", + "Successor": "Наследник", + "Team folders": "Тимске фасцикле", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Корисник је још у групи {groups}, која је чланица тимске фасцикле. Уклоните га из групе или онемогућите налог.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог.", + "Vault status": "Статус трезора", + "Yes": "Да", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Извоз у CXF НИЈЕ ШИФРОВАН. Свака лозинка и пријава биће читљива као отворени текст у преузетој датотеци. Чувајте је на безбедном месту и избришите је одмах након употребе.", + "Root certificate expiring soon": "Коренски сертификат ускоро истиче", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Коренски сертификат трезора истиче за %1$d дан(а). Обновите га пре тога. Обнова поново потписује сваки пакет шифровања.", + "Compromise recovery aborted": "Опоравак након компромитовања прекинут", + "Key rotation ended by a compromise revoke": "Ротација кључа завршена опозивом због компромитовања", + "Encryption suite revoke refused": "Опозив пакета за шифровање одбијен", + "Master password proof refused": "Доказ главне лозинке одбијен", + "Your current master password": "Ваша тренутна главна лозинка", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n контакт за хитне случајеве имао је захтев за приступ на чекању када га је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ови контакти за хитне случајеве нису пренети на ваш нови кључ. Њихов хитни приступ је уклоњен. Поново их додајте у Хитном приступу ако их још желите.", + "Renew root certificate": "Обнови коренски сертификат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Ово ствара нови коренски и посреднички сертификат. Сваки активни пакет шифровања поново се потписује. Ово се не може опозвати.", + "Renew root": "Обнови корен", + "Root renewed. {n} encryption suites signed again.": "Корен обновљен. Поново потписаних пакета шифровања: {n}.", + "Could not renew the root certificate.": "Коренски сертификат није могуће обновити.", + "Lease policy for this application": "Политика закупа за ову апликацију", + "In force now: {default} seconds by default, {max} seconds at most.": "Сада важи: подразумевано {default} секунди, највише {max} секунди.", + "Leases are not renewable": "Закупи се не могу обнављати", + "Lease policy saved.": "Политика закупа је сачувана.", + "Leave a field empty to use the instance value.": "Оставите поље празно да бисте користили вредност инстанце.", + "Instance value: {value}": "Вредност инстанце: {value}", + "Renewal": "Обнављање", + "Use the instance value ({value})": "Користи вредност инстанце ({value})", + "Allowed": "Дозвољено", + "Not allowed": "Није дозвољено", + "Save lease policy": "Сачувај политику закупа", + "Only an administrator can change this policy.": "Само администратор може да промени ову политику.", + "Could not save the lease policy.": "Политику закупа није могуће сачувати.", + "{member} got access from {confirmer}.": "{member} је добио приступ од {confirmer}.", + "Automatically confirm new team folder members": "Аутоматски потврди нове чланове тимских фасцикли", + "Gave %n new member access to a team folder.": "%n нови члан добио је приступ тимској фасцикли.", + "Gave %n new members access to a team folder.": "Нови чланови (%n) добили су приступ тимској фасцикли.", + "Give new team folder members access without waiting for the folder owner.": "Дајте новим члановима приступ без чекања власника фасцикле.", + "New team folder members": "Нови чланови тимских фасцикли", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Власник или члан са правом писања потврђује их из отвореног трезора. Keepiq никад не дешифрује на серверу.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Чека се да члан са правом писања отвори Keepiq. Можете и одмах да делите.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Део одговора на компромитацију није успео ({failed} корак(а)). Проверите дневник сервера, а затим поново опозовите пакет да бисте га завршили.", + "This also revoked suite {suite} and ended key migration {migration}.": "Тиме је опозван и пакет {suite} и завршена миграција кључева {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Опозив другог пакета обрисао је %n контакт за хитан приступ.", + "Revoking the second suite deleted %n emergency-access contacts.": "Опозив другог пакета обрисао је %n контаката за хитан приступ.", + "A suite revoked as compromised cannot be reinstated.": "Пакет опозван као компромитован не може се вратити.", + "Archives to keep": "Архиве за чување", + "Back up every vault automatically": "Аутоматски направи резервну копију сваког трезора", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Правите резервну копију сваког трезора по распореду. Архиве садрже само шифровани текст и враћају се преко occ.", + "Back up now": "Направи копију сада", + "Backup public key (PEM, optional)": "Јавни кључ резервне копије (PEM, опционо)", + "Backup requested for the next cron run": "Копија затражена за следеће покретање crona", + "Encrypted": "Шифровано", + "Every (hours)": "Сваких (сати)", + "Last backup {when} failed: {error}": "Последња копија {when} није успела: {error}", + "Last backup {when} succeeded.": "Последња копија {when} је успела.", + "No archives yet.": "Још нема архива.", + "Size": "Величина", + "Vault backups": "Резервне копије трезора", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Са кључем се свака архива шифрује за њега. Приватни кључ чувајте ван овог сервера: потребан вам је за проверу или враћање.", + "Written": "Записано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n корисник у опсегу још нема пријаву у два корака и не може да отвори трезор док је ово укључено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервни кодови се не рачунају. Ако се ваши корисници пријављују преко добављача идентитета са сопственим другим фактором, изоставите њихове групе.", + "Block personal vault export": "Блокирај извоз личног трезора", + "Keep work logins in team folders": "Чувај пословне пријаве у тимским фасциклама", + "Move to a team folder": "Премести у тимску фасциклу", + "Not in a team folder": "Није у тимској фасцикли", + "Only for these groups (empty is everyone)": "Само за ове групе (празно значи сви)", + "Require two-factor login before the vault opens": "Захтевај пријаву у два корака пре отварања трезора", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила за сваки трезор. Свако важи за све или само за групе које изаберете.", + "Secret types that belong in a team folder": "Врсте тајни које припадају у тимску фасциклу", + "Set up two-factor login": "Подеси пријаву у два корака", + "Team folder you can write to": "Тимска фасцикла у коју можете да пишете", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Корисници не могу да преузму резервну копију, CSV нити датотеку за пренос. Њихов пакет личних података остаје доступан.", + "Users cannot save these secret types in a personal folder.": "Корисници не могу да сачувају ове врсте тајни у личну фасциклу.", + "Vault policies": "Правила трезора", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша организација не дозвољава извоз вашег личног трезора. Ваш пакет личних података у подешавањима остаје доступан.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша организација чува ове тајне у тимској фасцикли. Преместите сваку у тимску фасциклу.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша организација чува ову врсту тајне у тимској фасцикли. Изаберите једну од својих тимских фасцикли или ону у коју можете да пишете.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша организација захтева пријаву у два корака пре него што можете да отворите свој трезор.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Корисници бирају колико дуго проширење остаје откључано током неактивности. Ви постављате најдуже време које смеју да изаберу.", + "Longest idle time before the extension locks": "Најдуже време неактивности пре закључавања проширења", + "1 minute": "1 минут", + "5 minutes": "5 минута", + "15 minutes": "15 минута", + "1 hour": "1 сат", + "4 hours": "4 сата", + "Connector": "Конектор", + "Directory (tenant) ID": "ИД директоријума (закупца)", + "Application (client) ID": "ИД апликације (клијента)", + "Data collection rule immutable ID": "Непроменљиви ИД правила прикупљања података", + "Stream name": "Назив тока", + "Splunk index (optional)": "Splunk индекс (опционо)", + "Sourcetype (optional)": "Sourcetype (опционо)", + "Leave blank to keep the current one": "Оставите празно да задржите тренутну вредност", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF преко syslog-а", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Крајња тачка прикупљања података (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector-а (https)", + "Client secret (write-only)": "Тајна клијента (само уписивање)", + "HEC token (write-only)": "HEC токен (само уписивање)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Прослеђујте дозвољене ревизијске догађаје у Splunk, Microsoft Sentinel, syslog пријемник или webhook. Поруке садрже само очишћене метаподатке: ниједна тајна вредност, име, пријава или шифровани текст никада не напушта сервер.", + "%n change waiting to sync": "%n измена чека синхронизацију", + "%n changes waiting to sync": "%n измена чека синхронизацију", + "Changes that could not sync": "Измене које нису могле да се синхронизују", + "Choose a version": "Изаберите верзију", + "Copy value": "Копирај вредност", + "Deleted": "Обрисано", + "Discard": "Одбаци", + "Keep my offline change": "Задржи моју ванмрежну измену", + "Keep the server version": "Задржи верзију са сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq је ван мреже само за читање. Администратор није укључио уређивање ван мреже.", + "Let users edit secrets offline": "Дозволи корисницима да уређују тајне ван мреже", + "Not synced yet": "Још није синхронизовано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Ванмрежне измене остају на уређају, шифроване за корисника, и синхронизују се при следећем откључавању на мрежи. Дељење, фасцикле и прилози и даље траже везу.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Ван мреже. Уређивања, премештања и брисања остају на овом уређају и синхронизују се када поново будете на мрежи. Дељење и прилози траже везу.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Ван мреже. Ваше измене остају на овом уређају и синхронизују се када поново будете на мрежи. Последња синхронизација {when}.", + "Open my changes": "Отвори моје измене", + "Sharing needs a connection": "Дељење тражи везу", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Неко је изменио ову тајну на серверу након што је направљена ваша ванмрежна копија. Изаберите коју верзију да задржите.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронизујте или одбаците ванмрежне измене пре замене кључева.", + "That password did not open your changes.": "Та лозинка није отворила ваше измене.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Ванмрежни снимак чува шифроване тајне (отварају се само кључем изведеним из главне лозинке корисника, тачно као на серверу) и шифрује називе, URL-ове и називе фасцикли у складишту. Ванмрежни приступ је само за читање, осим ако испод дозволите уређивање ван мреже. Искључите ово за уређаје који никад не смеју да кеширају акредитиве; искључивање брише постојеће кешеве при следећем учитавању.", + "The previous vault copy is gone, so these changes cannot be opened.": "Претходне копије трезора више нема, па се ове измене не могу отворити.", + "The server version": "Верзија са сервера", + "This secret changed while you were offline": "Ова тајна се променила док сте били ван мреже", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ову тајну сте обрисали ван мреже, али је у међувремену измењена на серверу. Изаберите коју верзију да задржите.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваши кључеви су промењени на другом уређају. Унесите претходну главну лозинку да синхронизујете ванмрежне измене или их одбаците.", + "Your offline change": "Ваша ванмрежна измена", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n контакт за хитне случајеве имао је захтев за приступ на чекању када га је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога.", + "Контакти за хитне случајеве (%n) имали су захтев за приступ на чекању када их је ротација кључа уклонила. Проверите ко је тражио пре него што поново додате било кога." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n ставку није могуће приказати у CXF-у и биће прескочена.", + "%n ставки није могуће приказати у CXF-у и биће прескочене.", + "%n ставки није могуће приказати у CXF-у и биће прескочене." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n старија верзија је одбачена јер се може пренети само недавна историја.", + "%n старијих верзија је одбачено јер се може пренети само недавна историја.", + "%n старијих верзија је одбачено јер се може пренети само недавна историја." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n копију тајне још треба шифровати и поделити.", + "%n копија тајни још треба шифровати и поделити.", + "%n копија тајни још треба шифровати и поделити." + ], + "_%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 тајна није могла да се дешифрује и није у овом извозу.", + "%n тајни није могло да се дешифрује и нису у овом извозу.", + "%n тајни није могло да се дешифрује и нису у овом извозу." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n тајна није могла да се дешифрује вашим старим кључем, па није пренета.", + "%n тајни није могло да се дешифрује вашим старим кључем, па нису пренете.", + "%n тајни није могло да се дешифрује вашим старим кључем, па нису пренете." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n тајна није пренета.", + "%n тајни није пренето.", + "%n тајни није пренето." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n тајна је још увек шифрована вашим претходним кључем.", + "%n тајни је још увек шифровано вашим претходним кључем.", + "%n тајни је још увек шифровано вашим претходним кључем." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n тајна је прескочена јер наследник још нема копију — додајте наследника у фасциклу и покрените поново.", + "%n тајни је прескочено јер наследник још нема копију — додајте наследника у фасциклу и покрените поново.", + "%n тајни је прескочено јер наследник још нема копију — додајте наследника у фасциклу и покрените поново." + ], + "_%n secret_::_%n secrets_": [ + "%n тајна", + "%n тајни", + "%n тајни" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n корисник у опсегу још нема пријаву у два корака и не може да отвори трезор док је ово укључено.", + "Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено.", + "Корисници у опсегу (%n) још немају пријаву у два корака и не могу да отворе трезор док је ово укључено." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Ипак заврши, уз губитак приступа %n тајни", + "Ипак заврши, уз губитак приступа %n тајни", + "Ипак заврши, уз губитак приступа %n тајни" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n нови члан добио је приступ тимској фасцикли.", + "Нови чланови (%n) добили су приступ тимској фасцикли.", + "Нови чланови (%n) добили су приступ тимској фасцикли." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ротација кључа је завршена. %n тајна је поново шифрована вашим новим кључем.", + "Ротација кључа је завршена. %n тајни је поново шифровано вашим новим кључем.", + "Ротација кључа је завршена. %n тајни је поново шифровано вашим новим кључем." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Опозив другог пакета обрисао је %n контакт за хитан приступ.", + "Опозив другог пакета обрисао је %n контаката за хитан приступ.", + "Опозив другог пакета обрисао је %n контаката за хитан приступ." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Опозивање овог комплета избрисало је %n контакт хитног приступа.", + "Опозивање овог комплета избрисало је %n контаката хитног приступа.", + "Опозивање овог комплета избрисало је %n контаката хитног приступа." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "забележено %n пут у процурелим подацима", + "забележено %n пута у процурелим подацима", + "забележено %n пута у процурелим подацима" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "подељено са %n тајном", + "подељено са %n тајни", + "подељено са %n тајни" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ова фасцикла директно садржи %n тајну.", + "Ова фасцикла директно садржи %n тајни.", + "Ова фасцикла директно садржи %n тајни." + ], + "_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._": [ + "Ротација кључа је уклонила %n контакт за хитне случајеве. Проверите Приступ у хитним случајевима и додајте га поново ако га још желите.", + "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите.", + "Ротација кључа је уклонила %n контакта за хитне случајеве. Проверите Приступ у хитним случајевима и додајте их поново ако их још желите." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n измена чека синхронизацију", + "%n измена чека синхронизацију", + "%n измена чека синхронизацију" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Корисник је још у групи {groups}, која је чланица тимске фасцикле. Уклоните га из групе или онемогућите налог.", + "Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог.", + "Корисник је још у групама {groups}, које су чланице тимских фасцикли. Уклоните га из група или онемогућите налог." + ], + "Allow approval from another device": "Дозволи одобрење са другог уређаја", + "App": "Апликација", + "Approve a new device": "Одобри нови уређај", + "Approve from another device": "Одобри са другог уређаја", + "Asked at": "Затражено у", + "Check that the new device shows these words:": "Проверите да ли нови уређај приказује ове речи:", + "Denied. If you did not ask, end your other sessions:": "Одбијено. Ако то нисте затражили, завршите остале сесије:", + "Device": "Уређај", + "IP address": "IP адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Омогући корисницима да откључају нови прегледач одобравањем са уређаја на коме је Keepiq већ откључан.", + "New device approval": "Одобравање нових уређаја", + "Nextcloud security settings": "Nextcloud безбедносна подешавања", + "Only approve a device you are using right now.": "Одобрите само уређај који тренутно користите.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Отворите Keepiq на уређају на коме је откључан и одобрите овај уређај. Проверите да ли приказује исте речи:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Уређај који одобрава запечати кључ за откључавање за нови уређај. Сервер га само прослеђује и не може да га отвори.", + "The master password is not right, or the request has ended.": "Главна лозинка није тачна или је захтев завршен.", + "The request expired. Ask again or use your master password.": "Захтев је истекао. Затражите поново или користите главну лозинку.", + "The request was denied.": "Захтев је одбијен.", + "Too many requests. Try again in an hour or use your master password.": "Превише захтева. Покушајте поново за сат времена или користите главну лозинку.", + "Unknown device": "Непознат уређај", + "Web app": "Веб апликација", + "A device": "Уређај", + "A new device asks to open your vault": "Нови уређај тражи да отвори ваш сеф", + "%s asks to be approved. Only approve a device you are using right now.": "%s тражи одобрење. Одобрите само уређај који тренутно користите.", + "Access ends on (optional)": "Приступ истиче (опционо)", + "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.": "Апликације Keepiq неће приказати нити копирати лозинку. Неко са техничким знањем и даље може да је прочита на свом уређају. Промените је када приступ истекне.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Ова тајна је само за коришћење. Пријавите се преко Keepiq проширења за прегледач.", + "Until {date}": "До {date}", + "Use only": "Само коришћење", + "Use only (can sign in, cannot view or copy)": "Само коришћење (може да се пријави, не може да види нити копира)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Овом пријавом можете да се пријавите преко Keepiq проширења за прегледач. Власник је одлучио да не можете да је видите нити копирате.", + "Your access ends on {date}": "Ваш приступ истиче {date}", + "Your access to this secret has ended": "Ваш приступ овој тајни је истекао", + "Your access to \"%s\" ends tomorrow": "Ваш приступ ставци „%s” истиче сутра", + "Your access to \"%s\" has ended": "Ваш приступ ставци „%s” је истекао", + "%1$s no longer has access to \"%2$s\"": "%1$s више нема приступ ставци „%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s је могао/ла да види ову лозинку. Промените је ако %1$s више не би требало да је зна.", + "%s could not view this password in Keepiq.": "%s није могао/ла да види ову лозинку у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} од {threshold} одобрења", + "a recovery officer": "службеник за опоравак", + "Account recovery": "Опоравак налога", + "Approvals needed": "Потребна одобрења", + "Ask {user} which words they see, by phone or in person. They must be:": "Питајте корисника {user} које речи види, телефоном или лично. Морају бити:", + "Check again": "Провери поново", + "Create the recovery key": "Направи кључ за опоравак", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Направите кључ за опоравак. Ваш прегледач га прави и сваком службенику даје копију коју само он може да отвори.", + "Decline": "Одбиј", + "Enrol in account recovery": "Пријавите се за опоравак налога", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Пријавите се како би вам организација могла да помогне да вратите трезор ако заборавите главну лозинку.", + "Every user is enrolled": "Сви корисници су пријављени", + "Finish the recovery in the browser you asked from.": "Завршите опоравак у прегледачу из ког сте га затражили.", + "Forgot your master password?": "Заборавили сте главну лозинку?", + "Hand the key over": "Предај кључ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Омогућите корисницима који су заборавили главну лозинку да врате трезор, уз одобрење службеника за опоравак које именујете.", + "New master password": "Нова главна лозинка", + "No one is asking to recover their account.": "Нико не тражи опоравак налога.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Још нема кључа за опоравак. Један од службеника га прави у својим Keepiq подешавањима.", + "Off": "Искључено", + "Officer {user} has no encryption set up yet.": "Службеник {user} још нема подешено шифровање.", + "Officers (user IDs, separated by commas)": "Службеници (ID-јеви корисника, раздвојени зарезима)", + "Policy": "Правила", + "Publish this fingerprint internally, so users can check it before they enrol.": "Објавите овај отисак интерно, како би корисници могли да га провере пре пријаве.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Опорављено уз помоћ корисника {officer}. Сада промените кључ трезора у Подешавањима, Безбедност: \"Моја главна лозинка је компромитована\".", + "Recovery key fingerprint: {fingerprint}": "Отисак кључа за опоравак: {fingerprint}", + "Recovery officer": "Службеник за опоравак", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Уклоњени службеници сада губе своју копију, али су је можда раније отворили. Нека службеник направи нови кључ за опоравак.", + "Repeat the new master password": "Поновите нову главну лозинку", + "Retire this recovery key": "Повуци овај кључ за опоравак", + "Set the new master password": "Постави нову главну лозинку", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертификат за опоравак није издао овај Keepiq. Немојте се пријавити и обавестите администратора.", + "The words match, approve": "Речи се поклапају, одобри", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Овај корисник је пријављен за опоравак налога. Опоравак чува његове тајне; опозив брише његову пријаву.", + "Users may enrol": "Корисници могу да се пријаве", + "Withdraw from account recovery": "Одјави се са опоравка налога", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Пријављени сте за опоравак налога. Отисак кључа за опоравак: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Пријављени сте. Ако заборавите главну лозинку, организација вам може помоћи да вратите трезор.", + "Your key is back. Choose a new master password.": "Кључ је враћен. Изаберите нову главну лозинку.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваши службеници за опоравак су обавештени. Прочитајте им ове речи када вас позову или се нађете:", + "You are now an account recovery officer": "Сада сте службеник за опоравак налога", + "%s asks to recover their account. Compare the words with them before you approve.": "%s тражи опоравак налога. Упоредите речи с њим пре него што одобрите.", + "A user": "Корисник", + "Your account recovery request was declined": "Ваш захтев за опоравак налога је одбијен", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Опоравак налога је спреман. Отворите Keepiq у прегледачу из ког сте га затражили.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} тражи једнократно откључавање новог уређаја. Главна лозинка остаје иста.", + "Ask your organisation instead": "Уместо тога, питајте своју организацију", + "The request ended. Ask again or use your master password.": "Захтев је завршен. Затражите поново или употребите главну лозинку.", + "Added by {user}": "Додао/ла {user}", + "Editor": "Уредник", + "Manager": "Управник", + "Role of {member}": "Улога корисника {member}", + "Team folders you manage": "Тимске фасцикле којима управљате", + "Viewer": "Прегледач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Немате копију ових тајни, па их нови чланови још нису добили. Власник може да их подели: {names}", + "Admin areas": "Области администрације", + "Give a group only the parts of Keepiq administration it needs.": "Дајте групи само оне делове администрације Keepiq-а који су јој потребни.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегирајте једну или више области групи на страници администраторских овлашћења. Администратори инстанце имају сваку област.", + "Open administration privileges": "Отвори администраторска овлашћења", + "Policies": "Смернице", + "Applications and machine access": "Апликације и приступ машина", + "People and offboarding": "Људи и одласци", + "Audit and compliance": "Ревизија и усклађеност", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "верзија, сертификационо тело, прилози, офлајн кеш, провера цурења, типови тајни и резервне копије", + "master password, organisation password, vault policies, rotation, version history and trash": "главна лозинка, лозинка организације, смернице трезора, ротација, историја верзија и смеће", + "application queue, application requests and machine leases": "ред апликација, захтеви апликација и закупи машина", + "team offboarding, encryption suites and admin handover": "одласци из тима, пакети шифровања и преузимање од стране администратора", + "audit log, compliance reports, SIEM export and honey alerts": "ревизорски дневник, извештаји о усклађености, SIEM извоз и упозорења мамаца", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Колико верзија тајне се чува, колико дуго, и колико дуго обрисане тајне остају у смећу.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Ограничења за шифроване прилоге, која сервер примењује на сачуване шифроване бајтове.", + "Type the suite ID again to confirm": "Поново унесите ID комплета ради потврде", + "This does not match the suite ID.": "Ово се не поклапа са ID-ом комплета.", + "Confirm with your master password": "Потврдите главном лозинком", + "Confirm": "Потврди", + "That master password is not right.": "Та главна лозинка није исправна.", + "You are sharing with someone new. Enter your master password to confirm.": "Делите са новом особом. Унесите главну лозинку ради потврде.", + "Enter your master password to confirm this share.": "Унесите главну лозинку да потврдите ово дељење.", + "Enter your master password to confirm this delegation.": "Унесите главну лозинку да потврдите ово делегирање.", + "Approve {member}": "Одобри {member}", + "Recipient": "Прималац", + "No vault yet": "Још нема трезор", + "No matching users": "Нема одговарајућих корисника", + "Partner organisations": "Партнерске организације", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Размењујте тајне са другим Keepiq-ом. Оба администратора додају један другог и пре чувања упореде коренске отиске телефоном или лично.", + "Federation needs Nextcloud 33 or later.": "Федерација захтева Nextcloud 33 или новији.", + "Your root fingerprint": "Ваш коренски отисак", + "No partners yet.": "Још нема партнера.", + "Users here may share to this partner": "Корисници овде смеју да деле са овим партнером", + "This partner may share to users here": "Овај партнер сме да дели са корисницима овде", + "Partner address": "Адреса партнера", + "Check partner": "Провери партнера", + "Partner root fingerprint": "Коренски отисак партнера", + "I compared this fingerprint with the partner's administrator": "Упоредио сам овај отисак са администратором партнера", + "Add partner": "Додај партнера", + "A secret from another organisation": "Тајна из друге организације", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s дели са вама \"%2$s\". Прихватите је у одељку Долазно из других организација.", + "Incoming from other organisations": "Долазно из других организација", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Особе у партнерским организацијама могу са вама да деле тајну. Прихватите је да бисте задржали копију само за читање у свом трезору.", + "Nothing shared with you yet": "Још ништа није подељено са вама", + "Secrets that people in partner organisations share with you appear here.": "Овде се приказују тајне које са вама деле особе у партнерским организацијама.", + "From {sender}": "Од {sender}", + "Accept": "Прихвати", + "Open in vault": "Отвори у трезору", + "The other organisation did not hand over the secret. Try again later.": "Друга организација није предала тајну. Покушајте поново касније.", + "Set up your vault before you accept a shared secret.": "Подесите свој трезор пре него што прихватите подељену тајну.", + "Something went wrong. Try again.": "Нешто није у реду. Покушајте поново.", + "Waiting for your answer": "Чека ваш одговор", + "In your vault, read-only": "У вашем трезору, само за читање", + "Withdrawn by the sender": "Пошиљалац је повукао", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} дели ово из друге организације. Можете то да читате, али не и да мењате или делите.", + "Someone": "Неко", + "Share with someone at another organisation": "Подели са неким из друге организације", + "Their account at the other organisation": "Рачун те особе у другој организацији", + "Check account": "Провери рачун", + "Certificate fingerprint of {account}": "Отисак сертификата за {account}", + "Compare it with them by phone if you want to be sure.": "Упоредите га са том особом телефоном ако желите да будете сигурни.", + "Shared. {account} can accept it in their own vault.": "Подељено. {account} то може да прихвати у свом трезору.", + "The certificate could not be verified. Nothing was shared.": "Сертификат није могуће проверити. Ништа није подељено.", + "That organisation is not one of your partners.": "Та организација није један од ваших партнера.", + "No one with that account can receive secrets from you.": "Нико са тим рачуном не може да прима тајне од вас.", + "The other organisation did not answer. Try again later.": "Друга организација није одговорила. Покушајте поново касније.", + "This secret is already shared with that account.": "Ова тајна је већ подељена са тим рачуном.", + "Other organisations": "Друге организације", + "Receive secrets from other organisations": "Примање тајни из других организација", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Особе у партнерским организацијама тада могу да пронађу ваш рачун и деле тајне са вама. Сваку од њих прихватате сами.", + "Shared": "Подељено", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Паузирано: променио се њихов сертификат или партнерство. Опозовите или поделите поново.", + "Their organisation did not get the last change. Revoke it or share again.": "Њихова организација није добила последњу промену. Опозовите или поделите поново.", + "Being withdrawn": "Повлачи се", + "Shared with another organisation": "Подељено са другом организацијом", + "Change sent to another organisation": "Промена послата другој организацији", + "Share with another organisation revoked": "Дељење са другом организацијом укинуто", + "Share with another organisation paused": "Дељење са другом организацијом паузирано", + "Another organisation did not get a change": "Друга организација није добила промену", + "Secret received from another organisation": "Тајна примљена из друге организације", + "Secret from another organisation accepted": "Тајна из друге организације прихваћена", + "Secret from another organisation declined": "Тајна из друге организације одбијена", + "Copy from another organisation updated": "Копија из друге организације ажурирана", + "Copy from another organisation removed": "Копија из друге организације уклоњена", + "Declined: they removed their copy. Share again if they need it.": "Одбијено: прималац је уклонио своју копију. Поделите поново ако му треба.", + "Recipient at another organisation removed their copy": "Прималац из друге организације уклонио је своју копију", + "Removed the user from %n team folder.": "Корисник је уклоњен из %n тимске фасцикле.", + "Removed the user from %n team folders.": "Корисник је уклоњен из %n тимских фасцикли.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Корисник је уклоњен из %n тимске фасцикле.", + "Корисник је уклоњен из %n тимских фасцикли.", + "Корисник је уклоњен из %n тимских фасцикли." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Враћена копија потиче из дељења које је завршено. Остаје само за читање.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Организација која је поделила враћену копију није доступна. Копија остаје само за читање и не прати њихове измене.", + "Recipient at another organisation restored their copy": "Прималац из друге организације вратио је своју копију" }, "plurals": null } diff --git a/l10n/sv.js b/l10n/sv.js index e9c667a44..9f7e01e15 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Delad med grupper", + "Not shared with any group yet.": "Inte delad med någon grupp än.", + "Revoke the share with {group}": "Återkalla delningen med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delad med {group}: {received} medlemmar tog emot den, {skipped} gjorde det inte eftersom de inte har konfigurerat kryptering än.", + "Search groups": "Sök grupper", + "Failed to share": "Delningen misslyckades", + "Columns": "Kolumner", + "Column {number}": "Kolumn {number}", + "Map one column to Name. Every secret needs a name.": "Koppla en kolumn till Namn. Varje hemlighet behöver ett namn.", + "Notes": "Anteckningar", + "Do not import": "Importera inte", + "Hide this value": "Dölj detta värde", + "Show this value": "Visa detta värde", + "Defaults": "Standardval", + "New secrets start as this type, and your secret list opens in this view.": "Nya hemligheter börjar som denna typ, och din hemlighetslista öppnas i denna vy.", + "Default item type": "Standardobjekttyp", + "Cards": "Kort", + "Table": "Tabell", + "Could not save your default": "Det gick inte att spara ditt standardval", + "Recently used": "Nyligen använda", + "Opened": "Öppnad", + "You have not opened any secrets yet": "Du har inte öppnat några hemligheter än", + "Could not delete the item type.": "Det gick inte att ta bort objekttypen.", + "Could not load the item types.": "Det gick inte att läsa in objekttyperna.", + "Could not save the item type.": "Det gick inte att spara objekttypen.", + "Delete item type": "Ta bort objekttyp", + "Edit item type": "Redigera objekttyp", + "Fields": "Fält", + "Fields: {count}": "Fält: {count}", + "Hidden": "Dold", + "Item types": "Objekttyper", + "Move up": "Flytta upp", + "New item type": "Ny objekttyp", + "No item types defined yet.": "Inga objekttyper har definierats än.", + "Required": "Obligatoriskt", + "Text": "Text", + "This field is required": "Det här fältet är obligatoriskt", + "Web address": "Webbadress", + "{label} (required)": "{label} (obligatoriskt)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ta bort ”{name}”? Hemligheter av den här typen går fortfarande att läsa och blir Inloggning-objekt.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Objekttyper som du definierar här visas för alla i dialogen Ny hemlighet, med de fält du väljer.", + "Secret moved to the trash": "Hemlighet flyttad till papperskorgen", + "Secret restored from the trash": "Hemlighet återställd från papperskorgen", + "Secret deleted for good": "Hemlighet borttagen för gott", + "Secret archived": "Hemlighet arkiverad", + "Secret unarchived": "Hemlighet hämtad ur arkivet", + "Unarchive": "Hämta ur arkivet", + "Could not archive the secret": "Det gick inte att arkivera hemligheten", + "Could not unarchive the secret": "Det gick inte att hämta hemligheten ur arkivet", + "Archive {count} secrets": "Arkivera {count} hemligheter", + "Unarchive {count} secrets": "Hämta {count} hemligheter ur arkivet", + "Restore {count} secrets": "Återställ {count} hemligheter", + "Delete {count} secrets for good": "Ta bort {count} hemligheter för gott", + "Done for {ok} of {total} secrets": "Klart för {ok} av {total} hemligheter", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverade hemligheter försvinner från valvets lista, sökningen, autofyllningen och hälsorapporten. De behåller sina delningar. Du hittar dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "De här hemligheterna kommer tillbaka till valvets lista, sökningen och autofyllningen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "De här hemligheterna kommer tillbaka till valvets lista. Deras gamla delningar kommer inte tillbaka, så dela dem igen där det behövs.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Detta tar bort hemligheterna med deras bilagor och versionshistorik. Det går inte att ångra.", + "Delete for good": "Ta bort för gott", + "Trash": "Papperskorg", + "The trash is empty": "Papperskorgen är tom", + "No archived secrets": "Inga arkiverade hemligheter", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Borttagna hemligheter väntar här tills lagringstiden tar slut, sedan tas de bort för gott.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivera en hemlighet från dess detaljpanel för att hålla den borta från valvets lista, sökningen och autofyllningen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Gränser för krypterade bilagor (tillämpas på servern på lagrade krypterade byte), lagring av versionshistorik och hur länge borttagna hemligheter ligger kvar i papperskorgen.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagar som en borttagen hemlighet ligger kvar i papperskorgen (1 till 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Detta flyttar hemligheten till papperskorgen och avslutar dess delningar nu. Du kan återställa den från papperskorgen tills lagringstiden tar slut: 30 dagar om inte din administratör har ändrat det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Detta flyttar {count} hemligheter till papperskorgen och avslutar deras delningar nu. Du kan återställa dem från papperskorgen tills lagringstiden tar slut.", + "Remove {name} from favourites": "Ta bort {name} från favoriter", + "Add {name} to favourites": "Lägg till {name} i favoriter", + "Could not change the favourite": "Det gick inte att ändra favoriten", + "Remove from favourites": "Ta bort från favoriter", + "Add to favourites": "Lägg till i favoriter", + "Tags": "Taggar", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Taggar är inte krypterade. Serveradministratörer kan läsa dem, precis som mappnamn.", + "Favourites": "Favoriter", + "Filter by tag": "Filtrera efter tagg", + "All tags": "Alla taggar", + "Last used": "Senast använd", + "Tags for {count} secrets": "Taggar för {count} hemligheter", + "Tag": "Tagg", + "Remove tag": "Ta bort tagg", + "Add tag": "Lägg till tagg", + "Could not change the tags. Try again.": "Det gick inte att ändra taggarna. Försök igen.", + "Could not approve the application. It is still in the queue.": "Ansökan kunde inte godkännas. Den ligger fortfarande i kön.", + "Could not reject the application. It is still in the queue.": "Ansökan kunde inte avslås. Den ligger fortfarande i kön.", + "Removed the user from {count} team folders.": "Användaren togs bort från {count} teammappar.", + "Approve a share": "Godkänn en delning", + "This approval link is incomplete. Open it again from the notification.": "Den här godkännandelänken är ofullständig. Öppna den igen från aviseringen.", + "Deny": "Neka", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} har gått med i en grupp som du delar en hemlighet med. Vill du dela hemligheten med dem också?", + "{requester} asks you to share a secret with {user}.": "{requester} ber dig dela en hemlighet med {user}.", + "Shared. The recipient can now open the secret.": "Delad. Mottagaren kan nu öppna hemligheten.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Mottagaren har inte konfigurerat Keepiq ännu, så inget delades. Försök igen när det är gjort.", + "Could not share the secret. Only its owner can approve this.": "Hemligheten kunde inte delas. Endast ägaren kan godkänna detta.", + "Could not share the secret. Try again.": "Hemligheten kunde inte delas. Försök igen.", + "Denied. Nothing was shared.": "Nekad. Inget delades.", + "Could not deny the request. Try again.": "Begäran kunde inte nekas. Försök igen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ber dig dela hemligheten \"%2$s\" med %3$s.", + "Expires on (optional)": "Upphör den (valfritt)", + "Hand over to": "Överlämna till", + "Choose a recipient": "Välj en mottagare", + "Hand over temporarily": "Överlämna tillfälligt", + "Expiry rules": "Utgångsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ange hur länge lösenord av en objekttyp eller i en mapp får leva, och när du ska påminnas. När flera datum gäller räknas det tidigaste.", + "Delete rule": "Ta bort regel", + "Set by your administrator": "Inställt av din administratör", + "No expiry rules yet.": "Inga utgångsregler än.", + "Applies to": "Gäller för", + "Item type": "Objekttyp", + "Maximum age in days (empty for reminders only)": "Maximal ålder i dagar (tomt för endast påminnelser)", + "Remind me this many days before, comma separated": "Påminn mig så här många dagar innan, kommaseparerat", + "Save rule": "Spara regel", + "An item type": "En objekttyp", + "A folder": "En mapp", + "Folder {name}": "Mapp {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Upphör efter {days} dagar", + "Reminders {days} days before": "Påminnelser {days} dagar innan", + "Could not save the expiry rule.": "Utgångsregeln kunde inte sparas.", + "Could not delete the expiry rule.": "Utgångsregeln kunde inte tas bort.", + "All statuses": "Alla statusar", + "Compromised": "Komprometterad", + "Could not load the members.": "Det gick inte att läsa in medlemmarna.", + "Emergency contact": "Nödkontakt", + "Leaving user": "Avgående användare", + "No": "Nej", + "No users match this filter.": "Inga användare matchar det här filtret.", + "Not set up": "Inte konfigurerat", + "Revoke suite": "Återkalla svit", + "Revoked": "Återkallad", + "Search users": "Sök användare", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se vilka användare som har konfigurerat ett valv. Starta offboarding eller återkalla en svit från en rad.", + "Successor": "Efterträdare", + "Team folders": "Teammappar", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Användaren finns fortfarande i gruppen {groups}, som är medlem i en teammapp. Ta bort användaren från gruppen eller inaktivera kontot.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Användaren finns fortfarande i grupperna {groups}, som är medlemmar i teammappar. Ta bort användaren från grupperna eller inaktivera kontot.", + "Vault status": "Valvstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-export är OKRYPTERAD. Varje lösenord och inloggning blir läsbar som klartext i den hämtade filen. Förvara den säkert och ta bort den omedelbart efter användning.", + "Root certificate expiring soon": "Rotcertifikatet går snart ut", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Valvets rotcertifikat går ut om %1$d dag(ar). Förnya det innan dess. Förnyelsen signerar om varje krypteringssvit.", + "Compromise recovery aborted": "Återställning efter intrång avbruten", + "Key rotation ended by a compromise revoke": "Nyckelrotation avslutad av en återkallelse på grund av intrång", + "Encryption suite revoke refused": "Återkallelse av krypteringssvit nekad", + "Master password proof refused": "Bevis för huvudlösenord nekat", + "Your current master password": "Ditt nuvarande huvudlösenord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nödkontakt hade en väntande åtkomstbegäran när din nyckelrotation tog bort den. Kontrollera vem som frågade innan du lägger till någon igen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nödkontakter hade en väntande åtkomstbegäran när din nyckelrotation tog bort dem. Kontrollera vem som frågade innan du lägger till någon igen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "De här nödkontakterna fördes inte över till din nya nyckel. Deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.", + "Renew root certificate": "Förnya rotcertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Detta skapar ett nytt rot- och mellanliggande certifikat. Varje aktiv krypteringssvit signeras igen. Det går inte att ångra.", + "Renew root": "Förnya rot", + "Root renewed. {n} encryption suites signed again.": "Rot förnyad. {n} krypteringssviter signerade igen.", + "Could not renew the root certificate.": "Rotcertifikatet kunde inte förnyas.", + "Lease policy for this application": "Leasepolicy för det här programmet", + "In force now: {default} seconds by default, {max} seconds at most.": "Gäller nu: {default} sekunder som standard, högst {max} sekunder.", + "Leases are not renewable": "Leasingavtal kan inte förnyas", + "Lease policy saved.": "Leasepolicy sparad.", + "Leave a field empty to use the instance value.": "Lämna ett fält tomt för att använda instansens värde.", + "Instance value: {value}": "Instansens värde: {value}", + "Renewal": "Förnyelse", + "Use the instance value ({value})": "Använd instansens värde ({value})", + "Allowed": "Tillåtet", + "Not allowed": "Inte tillåtet", + "Save lease policy": "Spara leasepolicy", + "Only an administrator can change this policy.": "Bara en administratör kan ändra den här policyn.", + "Could not save the lease policy.": "Leasepolicyn kunde inte sparas.", + "{member} got access from {confirmer}.": "{member} fick åtkomst av {confirmer}.", + "Automatically confirm new team folder members": "Bekräfta nya teammappsmedlemmar automatiskt", + "Gave %n new member access to a team folder.": "%n ny medlem fick åtkomst till en teammapp.", + "Gave %n new members access to a team folder.": "%n nya medlemmar fick åtkomst till en teammapp.", + "Give new team folder members access without waiting for the folder owner.": "Ge nya teammappsmedlemmar åtkomst utan att vänta på mappens ägare.", + "New team folder members": "Nya teammappsmedlemmar", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ägaren eller en medlem med skrivrätt bekräftar dem från sitt öppna valv. Keepiq dekrypterar aldrig på servern.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Väntar på att en medlem med skrivrätt öppnar Keepiq. Du kan också dela nu.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del av kompromissvaret misslyckades ({failed} steg). Kontrollera serverloggen och återkalla sedan sviten igen för att slutföra.", + "This also revoked suite {suite} and ended key migration {migration}.": "Detta återkallade även svit {suite} och avslutade nyckelmigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakter.", + "A suite revoked as compromised cannot be reinstated.": "En svit som återkallats som komprometterad kan inte återställas.", + "Archives to keep": "Arkiv att behålla", + "Back up every vault automatically": "Säkerhetskopiera varje valv automatiskt", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Säkerhetskopiera varje valv enligt schema. Arkiven innehåller bara krypterad text och återställs med occ.", + "Back up now": "Säkerhetskopiera nu", + "Backup public key (PEM, optional)": "Offentlig säkerhetskopieringsnyckel (PEM, valfri)", + "Backup requested for the next cron run": "Säkerhetskopia begärd till nästa cron-körning", + "Encrypted": "Krypterad", + "Every (hours)": "Var (timme)", + "Last backup {when} failed: {error}": "Senaste säkerhetskopian {when} misslyckades: {error}", + "Last backup {when} succeeded.": "Senaste säkerhetskopian {when} lyckades.", + "No archives yet.": "Inga arkiv ännu.", + "Size": "Storlek", + "Vault backups": "Valvsäkerhetskopior", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nyckel krypteras varje arkiv till den. Förvara den privata nyckeln utanför den här servern: du behöver den för att verifiera eller återställa.", + "Written": "Skriven", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Reservkoder räknas inte. Om dina användare loggar in via en identitetsleverantör med egen andra faktor, lämna bort deras grupper.", + "Block personal vault export": "Blockera export av personligt valv", + "Keep work logins in team folders": "Behåll arbetsinloggningar i teammappar", + "Move to a team folder": "Flytta till en teammapp", + "Not in a team folder": "Inte i en teammapp", + "Only for these groups (empty is everyone)": "Bara för dessa grupper (tomt betyder alla)", + "Require two-factor login before the vault opens": "Kräv tvåstegsinloggning innan valvet öppnas", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler för varje valv. Varje regel gäller alla, eller bara grupperna du väljer.", + "Secret types that belong in a team folder": "Hemlighetstyper som hör hemma i en teammapp", + "Set up two-factor login": "Konfigurera tvåstegsinloggning", + "Team folder you can write to": "Teammapp du kan skriva i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Användare kan inte ladda ner en säkerhetskopia, CSV eller överföringsfil. Deras personliga datapaket finns kvar.", + "Users cannot save these secret types in a personal folder.": "Användare kan inte spara dessa hemlighetstyper i en personlig mapp.", + "Vault policies": "Valvregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Din organisation tillåter inte export av ditt personliga valv. Ditt personliga datapaket i inställningarna finns kvar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Din organisation förvarar dessa hemligheter i en teammapp. Flytta var och en till en teammapp.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Din organisation förvarar den här typen av hemlighet i en teammapp. Välj en av dina teammappar, eller en du kan skriva i.", + "Your organisation requires two-factor login before you can open your vault.": "Din organisation kräver tvåstegsinloggning innan du kan öppna ditt valv.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Användarna väljer hur länge tillägget förblir upplåst vid inaktivitet. Du anger den längsta tid de får välja.", + "Longest idle time before the extension locks": "Längsta inaktiva tid innan tillägget låses", + "1 minute": "1 minut", + "5 minutes": "5 minuter", + "15 minutes": "15 minuter", + "1 hour": "1 timme", + "4 hours": "4 timmar", + "Connector": "Anslutning", + "Directory (tenant) ID": "Katalog-ID (klientorganisation)", + "Application (client) ID": "Program-ID (klient)", + "Data collection rule immutable ID": "Oföränderligt ID för datainsamlingsregeln", + "Stream name": "Strömnamn", + "Splunk index (optional)": "Splunk-index (valfritt)", + "Sourcetype (optional)": "Sourcetype (valfritt)", + "Leave blank to keep the current one": "Lämna tomt för att behålla den nuvarande", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Slutpunkt för datainsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemlighet (endast skrivning)", + "HEC token (write-only)": "HEC-token (endast skrivning)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Vidarebefordra tillåtna granskningshändelser till Splunk, Microsoft Sentinel, en syslog-mottagare eller en webhook. Meddelanden innehåller bara rensade metadata: inget hemligt värde, namn, inloggning eller krypterad text lämnar någonsin servern.", + "%n change waiting to sync": "%n ändring väntar på synkronisering", + "%n changes waiting to sync": "%n ändringar väntar på synkronisering", + "Changes that could not sync": "Ändringar som inte kunde synkroniseras", + "Choose a version": "Välj en version", + "Copy value": "Kopiera värde", + "Deleted": "Borttagen", + "Discard": "Kasta", + "Keep my offline change": "Behåll min offlineändring", + "Keep the server version": "Behåll serverversionen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq är skrivskyddat offline. Din administratör har inte slagit på offlineredigering.", + "Let users edit secrets offline": "Låt användare redigera hemligheter offline", + "Not synced yet": "Inte synkroniserad än", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offlineändringar sparas på enheten, krypterade för användaren, och synkroniseras vid nästa upplåsning online. Delning, mappar och bilagor kräver fortfarande anslutning.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Redigeringar, flyttar och borttagningar stannar på den här enheten och synkroniseras när du är online igen. Delning och bilagor kräver anslutning.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Dina ändringar stannar på den här enheten och synkroniseras när du är online igen. Senast synkroniserad {when}.", + "Open my changes": "Öppna mina ändringar", + "Sharing needs a connection": "Delning kräver anslutning", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Någon ändrade den här hemligheten på servern efter att din offlinekopia gjordes. Välj vilken version du vill behålla.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronisera eller kasta dina offlineändringar innan du byter nycklar.", + "That password did not open your changes.": "Det lösenordet öppnade inte dina ändringar.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offlineögonblicksbilden lagrar krypterade hemligheter (kan bara öppnas med nyckeln som härleds från användarens huvudlösenord, precis som på servern) och krypterar namn, URL:er och mappnamn i vila. Offlineåtkomst är skrivskyddad om du inte tillåter offlineredigering nedan. Stäng av detta för enheter som aldrig får cacha inloggningsuppgifter; när det stängs av rensas befintliga cacher vid nästa inläsning.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den tidigare valvkopian är borta, så de här ändringarna kan inte öppnas.", + "The server version": "Serverversionen", + "This secret changed while you were offline": "Den här hemligheten ändrades medan du var offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du tog bort den här hemligheten offline, men den har ändrats på servern sedan dess. Välj vilken version du vill behålla.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Dina nycklar ändrades på en annan enhet. Ange ditt tidigare huvudlösenord för att synkronisera offlineändringarna, eller kasta dem.", + "Your offline change": "Din offlineändring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n nödkontakt hade en väntande åtkomstbegäran när din nyckelrotation tog bort den. Kontrollera vem som frågade innan du lägger till någon igen.","%n nödkontakter hade en väntande åtkomstbegäran när din nyckelrotation tog bort dem. Kontrollera vem som frågade innan du lägger till någon igen."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n objekt kan inte återges i CXF och hoppas över.","%n objekt kan inte återges i CXF och hoppas över."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n äldre version togs bort, eftersom endast den senaste historiken kan föras med.","%n äldre versioner togs bort, eftersom endast den senaste historiken kan föras med."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n kopia av en hemlighet behöver fortfarande krypteras och delas.","%n kopior av hemligheter behöver fortfarande krypteras och delas."], + "_%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 hemlighet kunde inte dekrypteras och finns inte i den här exporten.","%n hemligheter kunde inte dekrypteras och finns inte i den här exporten."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n hemlighet kunde inte dekrypteras med din gamla nyckel och migrerades därför inte.","%n hemligheter kunde inte dekrypteras med din gamla nyckel och migrerades därför inte."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n hemlighet migrerades inte.","%n hemligheter migrerades inte."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n hemlighet är fortfarande krypterad med din tidigare nyckel.","%n hemligheter är fortfarande krypterade med din tidigare nyckel."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n hemlighet hoppades över eftersom efterträdaren ännu inte har någon kopia — lägg till efterträdaren i mappen och kör igen.","%n hemligheter hoppades över eftersom efterträdaren ännu inte har någon kopia — lägg till efterträdaren i mappen och kör igen."], + "_%n secret_::_%n secrets_": ["%n hemlighet","%n hemligheter"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.","%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Slutför ändå och förlora åtkomsten till %n hemlighet","Slutför ändå och förlora åtkomsten till %n hemligheter"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n ny medlem fick åtkomst till en teammapp.","%n nya medlemmar fick åtkomst till en teammapp."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Nyckelrotationen är klar. %n hemlighet omkrypterades med din nya nyckel.","Nyckelrotationen är klar. %n hemligheter omkrypterades med din nya nyckel."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Återkallandet av den andra sviten tog bort %n nödåtkomstkontakt.","Återkallandet av den andra sviten tog bort %n nödåtkomstkontakter."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Återkallelsen av denna svit tog bort %n nödåtkomstkontakt.","Återkallelsen av denna svit tog bort %n nödåtkomstkontakter."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["sedd %n gång i läckor","sedd %n gånger i läckor"], + "_shared with %n secret_::_shared with %n secrets_": ["delad med %n hemlighet","delad med %n hemligheter"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Den här mappen innehåller %n hemlighet direkt.","Den här mappen innehåller %n hemligheter direkt."], + "_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._": ["Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.","Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n ändring väntar på synkronisering","%n ändringar väntar på synkronisering"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Användaren finns fortfarande i gruppen {groups}, som är medlem i en teammapp. Ta bort användaren från gruppen eller inaktivera kontot.","Användaren finns fortfarande i grupperna {groups}, som är medlemmar i teammappar. Ta bort användaren från grupperna eller inaktivera kontot."], + "Allow approval from another device": "Tillåt godkännande från en annan enhet", + "App": "App", + "Approve a new device": "Godkänn en ny enhet", + "Approve from another device": "Godkänn från en annan enhet", + "Asked at": "Begärd kl.", + "Check that the new device shows these words:": "Kontrollera att den nya enheten visar dessa ord:", + "Denied. If you did not ask, end your other sessions:": "Nekad. Om du inte har begärt detta, avsluta dina andra sessioner:", + "Device": "Enhet", + "IP address": "IP-adress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Låt användare låsa upp en ny webbläsare genom att godkänna den från en enhet där Keepiq redan är upplåst.", + "New device approval": "Godkännande av nya enheter", + "Nextcloud security settings": "Säkerhetsinställningar för Nextcloud", + "Only approve a device you are using right now.": "Godkänn bara en enhet som du använder just nu.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Öppna Keepiq på en enhet där det är upplåst och godkänn den här. Kontrollera att den visar samma ord:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den godkännande enheten förseglar upplåsningsnyckeln till den nya enheten. Servern skickar bara vidare den och kan inte öppna den.", + "The master password is not right, or the request has ended.": "Huvudlösenordet är fel, eller så har begäran avslutats.", + "The request expired. Ask again or use your master password.": "Begäran har gått ut. Begär igen eller använd ditt huvudlösenord.", + "The request was denied.": "Begäran nekades.", + "Too many requests. Try again in an hour or use your master password.": "För många begäranden. Försök igen om en timme eller använd ditt huvudlösenord.", + "Unknown device": "Okänd enhet", + "Web app": "Webbapp", + "A device": "En enhet", + "A new device asks to open your vault": "En ny enhet ber om att få öppna ditt valv", + "%s asks to be approved. Only approve a device you are using right now.": "%s ber om att bli godkänd. Godkänn bara en enhet som du använder just nu.", + "Access ends on (optional)": "Åtkomsten upphör den (valfritt)", + "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.": "Keepiqs appar visar eller kopierar inte lösenordet. Någon med teknisk kunskap kan ändå läsa det från sin egen enhet. Byt det när åtkomsten upphör.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Den här hemligheten får endast användas. Logga in via webbläsartillägget för Keepiq.", + "Until {date}": "Till {date}", + "Use only": "Endast användning", + "Use only (can sign in, cannot view or copy)": "Endast användning (kan logga in, kan inte visa eller kopiera)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logga in med den här inloggningen via webbläsartillägget för Keepiq. Ägaren har valt att inte låta dig visa eller kopiera den.", + "Your access ends on {date}": "Din åtkomst upphör den {date}", + "Your access to this secret has ended": "Din åtkomst till den här hemligheten har upphört", + "Your access to \"%s\" ends tomorrow": "Din åtkomst till ”%s” upphör i morgon", + "Your access to \"%s\" has ended": "Din åtkomst till ”%s” har upphört", + "%1$s no longer has access to \"%2$s\"": "%1$s har inte längre åtkomst till ”%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunde se det här lösenordet. Byt det om %1$s inte längre ska känna till det.", + "%s could not view this password in Keepiq.": "%s kunde inte visa det här lösenordet i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} av {threshold} godkännanden", + "a recovery officer": "en återställningsansvarig", + "Account recovery": "Kontoåterställning", + "Approvals needed": "Godkännanden som krävs", + "Ask {user} which words they see, by phone or in person. They must be:": "Fråga {user} vilka ord de ser, per telefon eller personligen. De måste vara:", + "Check again": "Kontrollera igen", + "Create the recovery key": "Skapa återställningsnyckeln", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Skapa återställningsnyckeln. Din webbläsare skapar den och ger varje ansvarig en kopia som bara de kan öppna.", + "Decline": "Avböj", + "Enrol in account recovery": "Anmäl dig till kontoåterställning", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Anmäl dig så att din organisation kan hjälpa dig att få tillbaka ditt valv om du glömmer ditt huvudlösenord.", + "Every user is enrolled": "Alla användare är anmälda", + "Finish the recovery in the browser you asked from.": "Slutför återställningen i webbläsaren du frågade från.", + "Forgot your master password?": "Glömt ditt huvudlösenord?", + "Hand the key over": "Lämna över nyckeln", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Låt användare som glömt sitt huvudlösenord få tillbaka sitt valv, godkänt av återställningsansvariga som du utser.", + "New master password": "Nytt huvudlösenord", + "No one is asking to recover their account.": "Ingen ber om att återställa sitt konto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen återställningsnyckel ännu. En av de ansvariga skapar den i sina Keepiq-inställningar.", + "Off": "Av", + "Officer {user} has no encryption set up yet.": "Den ansvarige {user} har inte konfigurerat kryptering ännu.", + "Officers (user IDs, separated by commas)": "Ansvariga (användar-ID:n, separerade med kommatecken)", + "Policy": "Policy", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicera detta fingeravtryck internt, så att användare kan kontrollera det innan de anmäler sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Återställt med hjälp av {officer}. Byt din valvnyckel nu under Inställningar, Säkerhet: \"Mitt huvudlösenord har röjts\".", + "Recovery key fingerprint: {fingerprint}": "Återställningsnyckelns fingeravtryck: {fingerprint}", + "Recovery officer": "Återställningsansvarig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Borttagna ansvariga förlorar sin kopia nu, men kan ha öppnat den tidigare. Låt en ansvarig skapa en ny återställningsnyckel.", + "Repeat the new master password": "Upprepa det nya huvudlösenordet", + "Retire this recovery key": "Pensionera denna återställningsnyckel", + "Set the new master password": "Ange det nya huvudlösenordet", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Återställningscertifikatet är inte utfärdat av denna Keepiq. Anmäl dig inte och meddela din administratör.", + "The words match, approve": "Orden stämmer, godkänn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denna användare är anmäld till kontoåterställning. Återställning behåller deras hemligheter; återkallande raderar deras anmälan.", + "Users may enrol": "Användare får anmäla sig", + "Withdraw from account recovery": "Avanmäl dig från kontoåterställning", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du är anmäld till kontoåterställning. Återställningsnyckelns fingeravtryck: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du är anmäld. Om du glömmer ditt huvudlösenord kan din organisation hjälpa dig att få tillbaka ditt valv.", + "Your key is back. Choose a new master password.": "Din nyckel är tillbaka. Välj ett nytt huvudlösenord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Dina återställningsansvariga har fått besked. Läs upp dessa ord för dem när de ringer eller träffar dig:", + "You are now an account recovery officer": "Du är nu ansvarig för kontoåterställning", + "%s asks to recover their account. Compare the words with them before you approve.": "%s ber om att återställa sitt konto. Jämför orden med personen innan du godkänner.", + "A user": "En användare", + "Your account recovery request was declined": "Din begäran om kontoåterställning avböjdes", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Din kontoåterställning är klar. Öppna Keepiq i webbläsaren du frågade från.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ber om att låsa upp en ny enhet en gång. Huvudlösenordet förblir detsamma.", + "Ask your organisation instead": "Fråga din organisation i stället", + "The request ended. Ask again or use your master password.": "Begäran har avslutats. Fråga igen eller använd ditt huvudlösenord.", + "Added by {user}": "Tillagd av {user}", + "Editor": "Redigerare", + "Manager": "Ansvarig", + "Role of {member}": "Roll för {member}", + "Team folders you manage": "Teammappar som du hanterar", + "Viewer": "Läsare", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopia av dessa hemligheter, så de nya medlemmarna har inte fått dem än. Ägaren kan dela dem: {names}", + "Admin areas": "Administrationsområden", + "Give a group only the parts of Keepiq administration it needs.": "Ge en grupp bara de delar av Keepiq-administrationen som den behöver.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegera ett eller flera områden till en grupp på sidan för administrationsbehörigheter. Instansadministratörer har alla områden.", + "Open administration privileges": "Öppna administrationsbehörigheter", + "Policies": "Policyer", + "Applications and machine access": "Applikationer och maskinåtkomst", + "People and offboarding": "Personer och avslut", + "Audit and compliance": "Granskning och efterlevnad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certifikatutfärdare, bilagor, offlinecache, läckkontroll, hemlighetstyper och säkerhetskopior", + "master password, organisation password, vault policies, rotation, version history and trash": "huvudlösenord, organisationslösenord, valvpolicyer, rotation, versionshistorik och papperskorg", + "application queue, application requests and machine leases": "applikationskö, applikationsförfrågningar och maskinleasar", + "team offboarding, encryption suites and admin handover": "teamavslut, krypteringssviter och övertagande av administratör", + "audit log, compliance reports, SIEM export and honey alerts": "granskningslogg, efterlevnadsrapporter, SIEM-export och lockbeteslarm", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hur många versioner av en hemlighet som sparas, hur länge, och hur länge raderade hemligheter ligger kvar i papperskorgen.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Gränser för krypterade bilagor, som servern tillämpar i lagrade krypterade byte.", + "Type the suite ID again to confirm": "Skriv svit-ID:t igen för att bekräfta", + "This does not match the suite ID.": "Detta matchar inte svit-ID:t.", + "Confirm with your master password": "Bekräfta med ditt huvudlösenord", + "Confirm": "Bekräfta", + "That master password is not right.": "Det huvudlösenordet stämmer inte.", + "You are sharing with someone new. Enter your master password to confirm.": "Du delar med en ny person. Ange ditt huvudlösenord för att bekräfta.", + "Enter your master password to confirm this share.": "Ange ditt huvudlösenord för att bekräfta delningen.", + "Enter your master password to confirm this delegation.": "Ange ditt huvudlösenord för att bekräfta delegeringen.", + "Approve {member}": "Godkänn {member}", + "Recipient": "Mottagare", + "No vault yet": "Har inget valv än", + "No matching users": "Inga matchande användare", + "Partner organisations": "Partnerorganisationer", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Utbyt hemligheter med en annan Keepiq. Båda administratörerna lägger till varandra och jämför rotfingeravtrycken per telefon eller personligen innan de sparar.", + "Federation needs Nextcloud 33 or later.": "Federation kräver Nextcloud 33 eller senare.", + "Your root fingerprint": "Ditt rotfingeravtryck", + "No partners yet.": "Inga partner än.", + "Users here may share to this partner": "Användare här får dela med den här partnern", + "This partner may share to users here": "Den här partnern får dela med användare här", + "Partner address": "Partnerns adress", + "Check partner": "Kontrollera partner", + "Partner root fingerprint": "Partnerns rotfingeravtryck", + "I compared this fingerprint with the partner's administrator": "Jag har jämfört det här fingeravtrycket med partnerns administratör", + "Add partner": "Lägg till partner", + "A secret from another organisation": "En hemlighet från en annan organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delat \"%2$s\" med dig. Godkänn den under Inkommande från andra organisationer.", + "Incoming from other organisations": "Inkommande från andra organisationer", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisationer kan dela en hemlighet med dig. Godkänn den för att behålla en skrivskyddad kopia i ditt valv.", + "Nothing shared with you yet": "Inget delat med dig ännu", + "Secrets that people in partner organisations share with you appear here.": "Hemligheter som personer i partnerorganisationer delar med dig visas här.", + "From {sender}": "Från {sender}", + "Accept": "Godkänn", + "Open in vault": "Öppna i valvet", + "The other organisation did not hand over the secret. Try again later.": "Den andra organisationen lämnade inte över hemligheten. Försök igen senare.", + "Set up your vault before you accept a shared secret.": "Konfigurera ditt valv innan du godkänner en delad hemlighet.", + "Something went wrong. Try again.": "Något gick fel. Försök igen.", + "Waiting for your answer": "Väntar på ditt svar", + "In your vault, read-only": "I ditt valv, skrivskyddad", + "Withdrawn by the sender": "Återkallad av avsändaren", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delade detta från en annan organisation. Du kan läsa det, men inte ändra eller dela det.", + "Someone": "Någon", + "Share with someone at another organisation": "Dela med någon i en annan organisation", + "Their account at the other organisation": "Personens konto i den andra organisationen", + "Check account": "Kontrollera konto", + "Certificate fingerprint of {account}": "Certifikatets fingeravtryck för {account}", + "Compare it with them by phone if you want to be sure.": "Jämför det med personen per telefon om du vill vara säker.", + "Shared. {account} can accept it in their own vault.": "Delad. {account} kan godkänna den i sitt eget valv.", + "The certificate could not be verified. Nothing was shared.": "Certifikatet kunde inte verifieras. Inget delades.", + "That organisation is not one of your partners.": "Den organisationen är inte en av dina partner.", + "No one with that account can receive secrets from you.": "Ingen med det kontot kan ta emot hemligheter från dig.", + "The other organisation did not answer. Try again later.": "Den andra organisationen svarade inte. Försök igen senare.", + "This secret is already shared with that account.": "Den här hemligheten är redan delad med det kontot.", + "Other organisations": "Andra organisationer", + "Receive secrets from other organisations": "Ta emot hemligheter från andra organisationer", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisationer kan då hitta ditt konto och dela hemligheter med dig. Du godkänner var och en själv.", + "Shared": "Delad", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pausad: mottagarens certifikat eller partnerskapet har ändrats. Återkalla den eller dela igen.", + "Their organisation did not get the last change. Revoke it or share again.": "Mottagarens organisation fick inte den senaste ändringen. Återkalla den eller dela igen.", + "Being withdrawn": "Återkallas", + "Shared with another organisation": "Delad med en annan organisation", + "Change sent to another organisation": "Ändring skickad till en annan organisation", + "Share with another organisation revoked": "Delning med en annan organisation återkallad", + "Share with another organisation paused": "Delning med en annan organisation pausad", + "Another organisation did not get a change": "En annan organisation fick inte en ändring", + "Secret received from another organisation": "Hemlighet mottagen från en annan organisation", + "Secret from another organisation accepted": "Hemlighet från en annan organisation godkänd", + "Secret from another organisation declined": "Hemlighet från en annan organisation avböjd", + "Copy from another organisation updated": "Kopia från en annan organisation uppdaterad", + "Copy from another organisation removed": "Kopia från en annan organisation borttagen", + "Declined: they removed their copy. Share again if they need it.": "Avböjd: mottagaren tog bort sin kopia. Dela igen om de behöver den.", + "Recipient at another organisation removed their copy": "Mottagare i en annan organisation tog bort sin kopia", + "Removed the user from %n team folder.": "Användaren togs bort från %n teammapp.", + "Removed the user from %n team folders.": "Användaren togs bort från %n teammappar.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Användaren togs bort från %n teammapp.","Användaren togs bort från %n teammappar."], + "A restored copy came from a share that has ended. It stays read-only.": "En återställd kopia kom från en delning som har upphört. Den förblir skrivskyddad.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisationen som delade en återställd kopia gick inte att nå. Kopian förblir skrivskyddad och följer inte deras ändringar.", + "Recipient at another organisation restored their copy": "Mottagare i en annan organisation återställde sin kopia" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/sv.json b/l10n/sv.json index 7ebfed1e7..44780e511 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1181,7 +1181,567 @@ "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." + "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.", + "Shared with groups": "Delad med grupper", + "Not shared with any group yet.": "Inte delad med någon grupp än.", + "Revoke the share with {group}": "Återkalla delningen med {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Delad med {group}: {received} medlemmar tog emot den, {skipped} gjorde det inte eftersom de inte har konfigurerat kryptering än.", + "Search groups": "Sök grupper", + "Failed to share": "Delningen misslyckades", + "Columns": "Kolumner", + "Column {number}": "Kolumn {number}", + "Map one column to Name. Every secret needs a name.": "Koppla en kolumn till Namn. Varje hemlighet behöver ett namn.", + "Notes": "Anteckningar", + "Do not import": "Importera inte", + "Hide this value": "Dölj detta värde", + "Show this value": "Visa detta värde", + "Defaults": "Standardval", + "New secrets start as this type, and your secret list opens in this view.": "Nya hemligheter börjar som denna typ, och din hemlighetslista öppnas i denna vy.", + "Default item type": "Standardobjekttyp", + "Cards": "Kort", + "Table": "Tabell", + "Could not save your default": "Det gick inte att spara ditt standardval", + "Recently used": "Nyligen använda", + "Opened": "Öppnad", + "You have not opened any secrets yet": "Du har inte öppnat några hemligheter än", + "Could not delete the item type.": "Det gick inte att ta bort objekttypen.", + "Could not load the item types.": "Det gick inte att läsa in objekttyperna.", + "Could not save the item type.": "Det gick inte att spara objekttypen.", + "Delete item type": "Ta bort objekttyp", + "Edit item type": "Redigera objekttyp", + "Fields": "Fält", + "Fields: {count}": "Fält: {count}", + "Hidden": "Dold", + "Item types": "Objekttyper", + "Move up": "Flytta upp", + "New item type": "Ny objekttyp", + "No item types defined yet.": "Inga objekttyper har definierats än.", + "Required": "Obligatoriskt", + "Text": "Text", + "This field is required": "Det här fältet är obligatoriskt", + "Web address": "Webbadress", + "{label} (required)": "{label} (obligatoriskt)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Ta bort ”{name}”? Hemligheter av den här typen går fortfarande att läsa och blir Inloggning-objekt.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Objekttyper som du definierar här visas för alla i dialogen Ny hemlighet, med de fält du väljer.", + "Secret moved to the trash": "Hemlighet flyttad till papperskorgen", + "Secret restored from the trash": "Hemlighet återställd från papperskorgen", + "Secret deleted for good": "Hemlighet borttagen för gott", + "Secret archived": "Hemlighet arkiverad", + "Secret unarchived": "Hemlighet hämtad ur arkivet", + "Unarchive": "Hämta ur arkivet", + "Could not archive the secret": "Det gick inte att arkivera hemligheten", + "Could not unarchive the secret": "Det gick inte att hämta hemligheten ur arkivet", + "Archive {count} secrets": "Arkivera {count} hemligheter", + "Unarchive {count} secrets": "Hämta {count} hemligheter ur arkivet", + "Restore {count} secrets": "Återställ {count} hemligheter", + "Delete {count} secrets for good": "Ta bort {count} hemligheter för gott", + "Done for {ok} of {total} secrets": "Klart för {ok} av {total} hemligheter", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arkiverade hemligheter försvinner från valvets lista, sökningen, autofyllningen och hälsorapporten. De behåller sina delningar. Du hittar dem under Arkiv.", + "These secrets come back to the vault list, search and autofill.": "De här hemligheterna kommer tillbaka till valvets lista, sökningen och autofyllningen.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "De här hemligheterna kommer tillbaka till valvets lista. Deras gamla delningar kommer inte tillbaka, så dela dem igen där det behövs.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Detta tar bort hemligheterna med deras bilagor och versionshistorik. Det går inte att ångra.", + "Delete for good": "Ta bort för gott", + "Trash": "Papperskorg", + "The trash is empty": "Papperskorgen är tom", + "No archived secrets": "Inga arkiverade hemligheter", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Borttagna hemligheter väntar här tills lagringstiden tar slut, sedan tas de bort för gott.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Arkivera en hemlighet från dess detaljpanel för att hålla den borta från valvets lista, sökningen och autofyllningen.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Gränser för krypterade bilagor (tillämpas på servern på lagrade krypterade byte), lagring av versionshistorik och hur länge borttagna hemligheter ligger kvar i papperskorgen.", + "Days a deleted secret stays in the trash (1 to 365)": "Dagar som en borttagen hemlighet ligger kvar i papperskorgen (1 till 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Detta flyttar hemligheten till papperskorgen och avslutar dess delningar nu. Du kan återställa den från papperskorgen tills lagringstiden tar slut: 30 dagar om inte din administratör har ändrat det.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Detta flyttar {count} hemligheter till papperskorgen och avslutar deras delningar nu. Du kan återställa dem från papperskorgen tills lagringstiden tar slut.", + "Remove {name} from favourites": "Ta bort {name} från favoriter", + "Add {name} to favourites": "Lägg till {name} i favoriter", + "Could not change the favourite": "Det gick inte att ändra favoriten", + "Remove from favourites": "Ta bort från favoriter", + "Add to favourites": "Lägg till i favoriter", + "Tags": "Taggar", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Taggar är inte krypterade. Serveradministratörer kan läsa dem, precis som mappnamn.", + "Favourites": "Favoriter", + "Filter by tag": "Filtrera efter tagg", + "All tags": "Alla taggar", + "Last used": "Senast använd", + "Tags for {count} secrets": "Taggar för {count} hemligheter", + "Tag": "Tagg", + "Remove tag": "Ta bort tagg", + "Add tag": "Lägg till tagg", + "Could not change the tags. Try again.": "Det gick inte att ändra taggarna. Försök igen.", + "Could not approve the application. It is still in the queue.": "Ansökan kunde inte godkännas. Den ligger fortfarande i kön.", + "Could not reject the application. It is still in the queue.": "Ansökan kunde inte avslås. Den ligger fortfarande i kön.", + "Removed the user from {count} team folders.": "Användaren togs bort från {count} teammappar.", + "Approve a share": "Godkänn en delning", + "This approval link is incomplete. Open it again from the notification.": "Den här godkännandelänken är ofullständig. Öppna den igen från aviseringen.", + "Deny": "Neka", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} har gått med i en grupp som du delar en hemlighet med. Vill du dela hemligheten med dem också?", + "{requester} asks you to share a secret with {user}.": "{requester} ber dig dela en hemlighet med {user}.", + "Shared. The recipient can now open the secret.": "Delad. Mottagaren kan nu öppna hemligheten.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Mottagaren har inte konfigurerat Keepiq ännu, så inget delades. Försök igen när det är gjort.", + "Could not share the secret. Only its owner can approve this.": "Hemligheten kunde inte delas. Endast ägaren kan godkänna detta.", + "Could not share the secret. Try again.": "Hemligheten kunde inte delas. Försök igen.", + "Denied. Nothing was shared.": "Nekad. Inget delades.", + "Could not deny the request. Try again.": "Begäran kunde inte nekas. Försök igen.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s ber dig dela hemligheten \"%2$s\" med %3$s.", + "Expires on (optional)": "Upphör den (valfritt)", + "Hand over to": "Överlämna till", + "Choose a recipient": "Välj en mottagare", + "Hand over temporarily": "Överlämna tillfälligt", + "Expiry rules": "Utgångsregler", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Ange hur länge lösenord av en objekttyp eller i en mapp får leva, och när du ska påminnas. När flera datum gäller räknas det tidigaste.", + "Delete rule": "Ta bort regel", + "Set by your administrator": "Inställt av din administratör", + "No expiry rules yet.": "Inga utgångsregler än.", + "Applies to": "Gäller för", + "Item type": "Objekttyp", + "Maximum age in days (empty for reminders only)": "Maximal ålder i dagar (tomt för endast påminnelser)", + "Remind me this many days before, comma separated": "Påminn mig så här många dagar innan, kommaseparerat", + "Save rule": "Spara regel", + "An item type": "En objekttyp", + "A folder": "En mapp", + "Folder {name}": "Mapp {name}", + "Type {name}": "Typ {name}", + "Expires after {days} days": "Upphör efter {days} dagar", + "Reminders {days} days before": "Påminnelser {days} dagar innan", + "Could not save the expiry rule.": "Utgångsregeln kunde inte sparas.", + "Could not delete the expiry rule.": "Utgångsregeln kunde inte tas bort.", + "All statuses": "Alla statusar", + "Compromised": "Komprometterad", + "Could not load the members.": "Det gick inte att läsa in medlemmarna.", + "Emergency contact": "Nödkontakt", + "Leaving user": "Avgående användare", + "No": "Nej", + "No users match this filter.": "Inga användare matchar det här filtret.", + "Not set up": "Inte konfigurerat", + "Revoke suite": "Återkalla svit", + "Revoked": "Återkallad", + "Search users": "Sök användare", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Se vilka användare som har konfigurerat ett valv. Starta offboarding eller återkalla en svit från en rad.", + "Successor": "Efterträdare", + "Team folders": "Teammappar", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Användaren finns fortfarande i gruppen {groups}, som är medlem i en teammapp. Ta bort användaren från gruppen eller inaktivera kontot.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Användaren finns fortfarande i grupperna {groups}, som är medlemmar i teammappar. Ta bort användaren från grupperna eller inaktivera kontot.", + "Vault status": "Valvstatus", + "Yes": "Ja", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "En CXF-export är OKRYPTERAD. Varje lösenord och inloggning blir läsbar som klartext i den hämtade filen. Förvara den säkert och ta bort den omedelbart efter användning.", + "Root certificate expiring soon": "Rotcertifikatet går snart ut", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Valvets rotcertifikat går ut om %1$d dag(ar). Förnya det innan dess. Förnyelsen signerar om varje krypteringssvit.", + "Compromise recovery aborted": "Återställning efter intrång avbruten", + "Key rotation ended by a compromise revoke": "Nyckelrotation avslutad av en återkallelse på grund av intrång", + "Encryption suite revoke refused": "Återkallelse av krypteringssvit nekad", + "Master password proof refused": "Bevis för huvudlösenord nekat", + "Your current master password": "Ditt nuvarande huvudlösenord", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n nödkontakt hade en väntande åtkomstbegäran när din nyckelrotation tog bort den. Kontrollera vem som frågade innan du lägger till någon igen.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n nödkontakter hade en väntande åtkomstbegäran när din nyckelrotation tog bort dem. Kontrollera vem som frågade innan du lägger till någon igen.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "De här nödkontakterna fördes inte över till din nya nyckel. Deras nödåtkomst togs bort. Lägg till dem igen under Nödåtkomst om du fortfarande vill ha dem.", + "Renew root certificate": "Förnya rotcertifikat", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Detta skapar ett nytt rot- och mellanliggande certifikat. Varje aktiv krypteringssvit signeras igen. Det går inte att ångra.", + "Renew root": "Förnya rot", + "Root renewed. {n} encryption suites signed again.": "Rot förnyad. {n} krypteringssviter signerade igen.", + "Could not renew the root certificate.": "Rotcertifikatet kunde inte förnyas.", + "Lease policy for this application": "Leasepolicy för det här programmet", + "In force now: {default} seconds by default, {max} seconds at most.": "Gäller nu: {default} sekunder som standard, högst {max} sekunder.", + "Leases are not renewable": "Leasingavtal kan inte förnyas", + "Lease policy saved.": "Leasepolicy sparad.", + "Leave a field empty to use the instance value.": "Lämna ett fält tomt för att använda instansens värde.", + "Instance value: {value}": "Instansens värde: {value}", + "Renewal": "Förnyelse", + "Use the instance value ({value})": "Använd instansens värde ({value})", + "Allowed": "Tillåtet", + "Not allowed": "Inte tillåtet", + "Save lease policy": "Spara leasepolicy", + "Only an administrator can change this policy.": "Bara en administratör kan ändra den här policyn.", + "Could not save the lease policy.": "Leasepolicyn kunde inte sparas.", + "{member} got access from {confirmer}.": "{member} fick åtkomst av {confirmer}.", + "Automatically confirm new team folder members": "Bekräfta nya teammappsmedlemmar automatiskt", + "Gave %n new member access to a team folder.": "%n ny medlem fick åtkomst till en teammapp.", + "Gave %n new members access to a team folder.": "%n nya medlemmar fick åtkomst till en teammapp.", + "Give new team folder members access without waiting for the folder owner.": "Ge nya teammappsmedlemmar åtkomst utan att vänta på mappens ägare.", + "New team folder members": "Nya teammappsmedlemmar", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Ägaren eller en medlem med skrivrätt bekräftar dem från sitt öppna valv. Keepiq dekrypterar aldrig på servern.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Väntar på att en medlem med skrivrätt öppnar Keepiq. Du kan också dela nu.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "En del av kompromissvaret misslyckades ({failed} steg). Kontrollera serverloggen och återkalla sedan sviten igen för att slutföra.", + "This also revoked suite {suite} and ended key migration {migration}.": "Detta återkallade även svit {suite} och avslutade nyckelmigrering {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakt.", + "Revoking the second suite deleted %n emergency-access contacts.": "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakter.", + "A suite revoked as compromised cannot be reinstated.": "En svit som återkallats som komprometterad kan inte återställas.", + "Archives to keep": "Arkiv att behålla", + "Back up every vault automatically": "Säkerhetskopiera varje valv automatiskt", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Säkerhetskopiera varje valv enligt schema. Arkiven innehåller bara krypterad text och återställs med occ.", + "Back up now": "Säkerhetskopiera nu", + "Backup public key (PEM, optional)": "Offentlig säkerhetskopieringsnyckel (PEM, valfri)", + "Backup requested for the next cron run": "Säkerhetskopia begärd till nästa cron-körning", + "Encrypted": "Krypterad", + "Every (hours)": "Var (timme)", + "Last backup {when} failed: {error}": "Senaste säkerhetskopian {when} misslyckades: {error}", + "Last backup {when} succeeded.": "Senaste säkerhetskopian {when} lyckades.", + "No archives yet.": "Inga arkiv ännu.", + "Size": "Storlek", + "Vault backups": "Valvsäkerhetskopior", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Med en nyckel krypteras varje arkiv till den. Förvara den privata nyckeln utanför den här servern: du behöver den för att verifiera eller återställa.", + "Written": "Skriven", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Reservkoder räknas inte. Om dina användare loggar in via en identitetsleverantör med egen andra faktor, lämna bort deras grupper.", + "Block personal vault export": "Blockera export av personligt valv", + "Keep work logins in team folders": "Behåll arbetsinloggningar i teammappar", + "Move to a team folder": "Flytta till en teammapp", + "Not in a team folder": "Inte i en teammapp", + "Only for these groups (empty is everyone)": "Bara för dessa grupper (tomt betyder alla)", + "Require two-factor login before the vault opens": "Kräv tvåstegsinloggning innan valvet öppnas", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Regler för varje valv. Varje regel gäller alla, eller bara grupperna du väljer.", + "Secret types that belong in a team folder": "Hemlighetstyper som hör hemma i en teammapp", + "Set up two-factor login": "Konfigurera tvåstegsinloggning", + "Team folder you can write to": "Teammapp du kan skriva i", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Användare kan inte ladda ner en säkerhetskopia, CSV eller överföringsfil. Deras personliga datapaket finns kvar.", + "Users cannot save these secret types in a personal folder.": "Användare kan inte spara dessa hemlighetstyper i en personlig mapp.", + "Vault policies": "Valvregler", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Din organisation tillåter inte export av ditt personliga valv. Ditt personliga datapaket i inställningarna finns kvar.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Din organisation förvarar dessa hemligheter i en teammapp. Flytta var och en till en teammapp.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Din organisation förvarar den här typen av hemlighet i en teammapp. Välj en av dina teammappar, eller en du kan skriva i.", + "Your organisation requires two-factor login before you can open your vault.": "Din organisation kräver tvåstegsinloggning innan du kan öppna ditt valv.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Användarna väljer hur länge tillägget förblir upplåst vid inaktivitet. Du anger den längsta tid de får välja.", + "Longest idle time before the extension locks": "Längsta inaktiva tid innan tillägget låses", + "1 minute": "1 minut", + "5 minutes": "5 minuter", + "15 minutes": "15 minuter", + "1 hour": "1 timme", + "4 hours": "4 timmar", + "Connector": "Anslutning", + "Directory (tenant) ID": "Katalog-ID (klientorganisation)", + "Application (client) ID": "Program-ID (klient)", + "Data collection rule immutable ID": "Oföränderligt ID för datainsamlingsregeln", + "Stream name": "Strömnamn", + "Splunk index (optional)": "Splunk-index (valfritt)", + "Sourcetype (optional)": "Sourcetype (valfritt)", + "Leave blank to keep the current one": "Lämna tomt för att behålla den nuvarande", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF via syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Slutpunkt för datainsamling (https-URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector-URL (https)", + "Client secret (write-only)": "Klienthemlighet (endast skrivning)", + "HEC token (write-only)": "HEC-token (endast skrivning)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Vidarebefordra tillåtna granskningshändelser till Splunk, Microsoft Sentinel, en syslog-mottagare eller en webhook. Meddelanden innehåller bara rensade metadata: inget hemligt värde, namn, inloggning eller krypterad text lämnar någonsin servern.", + "%n change waiting to sync": "%n ändring väntar på synkronisering", + "%n changes waiting to sync": "%n ändringar väntar på synkronisering", + "Changes that could not sync": "Ändringar som inte kunde synkroniseras", + "Choose a version": "Välj en version", + "Copy value": "Kopiera värde", + "Deleted": "Borttagen", + "Discard": "Kasta", + "Keep my offline change": "Behåll min offlineändring", + "Keep the server version": "Behåll serverversionen", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq är skrivskyddat offline. Din administratör har inte slagit på offlineredigering.", + "Let users edit secrets offline": "Låt användare redigera hemligheter offline", + "Not synced yet": "Inte synkroniserad än", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Offlineändringar sparas på enheten, krypterade för användaren, och synkroniseras vid nästa upplåsning online. Delning, mappar och bilagor kräver fortfarande anslutning.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Offline. Redigeringar, flyttar och borttagningar stannar på den här enheten och synkroniseras när du är online igen. Delning och bilagor kräver anslutning.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Offline. Dina ändringar stannar på den här enheten och synkroniseras när du är online igen. Senast synkroniserad {when}.", + "Open my changes": "Öppna mina ändringar", + "Sharing needs a connection": "Delning kräver anslutning", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Någon ändrade den här hemligheten på servern efter att din offlinekopia gjordes. Välj vilken version du vill behålla.", + "Sync or discard your offline changes before you rotate your keys.": "Synkronisera eller kasta dina offlineändringar innan du byter nycklar.", + "That password did not open your changes.": "Det lösenordet öppnade inte dina ändringar.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Offlineögonblicksbilden lagrar krypterade hemligheter (kan bara öppnas med nyckeln som härleds från användarens huvudlösenord, precis som på servern) och krypterar namn, URL:er och mappnamn i vila. Offlineåtkomst är skrivskyddad om du inte tillåter offlineredigering nedan. Stäng av detta för enheter som aldrig får cacha inloggningsuppgifter; när det stängs av rensas befintliga cacher vid nästa inläsning.", + "The previous vault copy is gone, so these changes cannot be opened.": "Den tidigare valvkopian är borta, så de här ändringarna kan inte öppnas.", + "The server version": "Serverversionen", + "This secret changed while you were offline": "Den här hemligheten ändrades medan du var offline", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Du tog bort den här hemligheten offline, men den har ändrats på servern sedan dess. Välj vilken version du vill behålla.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Dina nycklar ändrades på en annan enhet. Ange ditt tidigare huvudlösenord för att synkronisera offlineändringarna, eller kasta dem.", + "Your offline change": "Din offlineändring", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n nödkontakt hade en väntande åtkomstbegäran när din nyckelrotation tog bort den. Kontrollera vem som frågade innan du lägger till någon igen.", + "%n nödkontakter hade en väntande åtkomstbegäran när din nyckelrotation tog bort dem. Kontrollera vem som frågade innan du lägger till någon igen." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n objekt kan inte återges i CXF och hoppas över.", + "%n objekt kan inte återges i CXF och hoppas över." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n äldre version togs bort, eftersom endast den senaste historiken kan föras med.", + "%n äldre versioner togs bort, eftersom endast den senaste historiken kan föras med." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n kopia av en hemlighet behöver fortfarande krypteras och delas.", + "%n kopior av hemligheter behöver fortfarande krypteras och delas." + ], + "_%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 hemlighet kunde inte dekrypteras och finns inte i den här exporten.", + "%n hemligheter kunde inte dekrypteras och finns inte i den här exporten." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n hemlighet kunde inte dekrypteras med din gamla nyckel och migrerades därför inte.", + "%n hemligheter kunde inte dekrypteras med din gamla nyckel och migrerades därför inte." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n hemlighet migrerades inte.", + "%n hemligheter migrerades inte." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n hemlighet är fortfarande krypterad med din tidigare nyckel.", + "%n hemligheter är fortfarande krypterade med din tidigare nyckel." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n hemlighet hoppades över eftersom efterträdaren ännu inte har någon kopia — lägg till efterträdaren i mappen och kör igen.", + "%n hemligheter hoppades över eftersom efterträdaren ännu inte har någon kopia — lägg till efterträdaren i mappen och kör igen." + ], + "_%n secret_::_%n secrets_": [ + "%n hemlighet", + "%n hemligheter" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på.", + "%n användare i omfånget har ännu inte tvåstegsinloggning och kan inte öppna valvet medan detta är på." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Slutför ändå och förlora åtkomsten till %n hemlighet", + "Slutför ändå och förlora åtkomsten till %n hemligheter" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n ny medlem fick åtkomst till en teammapp.", + "%n nya medlemmar fick åtkomst till en teammapp." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Nyckelrotationen är klar. %n hemlighet omkrypterades med din nya nyckel.", + "Nyckelrotationen är klar. %n hemligheter omkrypterades med din nya nyckel." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakt.", + "Återkallandet av den andra sviten tog bort %n nödåtkomstkontakter." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Återkallelsen av denna svit tog bort %n nödåtkomstkontakt.", + "Återkallelsen av denna svit tog bort %n nödåtkomstkontakter." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "sedd %n gång i läckor", + "sedd %n gånger i läckor" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "delad med %n hemlighet", + "delad med %n hemligheter" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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 folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Den här mappen innehåller %n hemlighet direkt.", + "Den här mappen innehåller %n hemligheter direkt." + ], + "_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._": [ + "Din nyckelrotation tog bort %n nödkontakt. Kontrollera Nödåtkomst och lägg till den igen om du fortfarande vill ha den.", + "Din nyckelrotation tog bort %n nödkontakter. Kontrollera Nödåtkomst och lägg till dem igen om du fortfarande vill ha dem." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n ändring väntar på synkronisering", + "%n ändringar väntar på synkronisering" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Användaren finns fortfarande i gruppen {groups}, som är medlem i en teammapp. Ta bort användaren från gruppen eller inaktivera kontot.", + "Användaren finns fortfarande i grupperna {groups}, som är medlemmar i teammappar. Ta bort användaren från grupperna eller inaktivera kontot." + ], + "Allow approval from another device": "Tillåt godkännande från en annan enhet", + "App": "App", + "Approve a new device": "Godkänn en ny enhet", + "Approve from another device": "Godkänn från en annan enhet", + "Asked at": "Begärd kl.", + "Check that the new device shows these words:": "Kontrollera att den nya enheten visar dessa ord:", + "Denied. If you did not ask, end your other sessions:": "Nekad. Om du inte har begärt detta, avsluta dina andra sessioner:", + "Device": "Enhet", + "IP address": "IP-adress", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Låt användare låsa upp en ny webbläsare genom att godkänna den från en enhet där Keepiq redan är upplåst.", + "New device approval": "Godkännande av nya enheter", + "Nextcloud security settings": "Säkerhetsinställningar för Nextcloud", + "Only approve a device you are using right now.": "Godkänn bara en enhet som du använder just nu.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Öppna Keepiq på en enhet där det är upplåst och godkänn den här. Kontrollera att den visar samma ord:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Den godkännande enheten förseglar upplåsningsnyckeln till den nya enheten. Servern skickar bara vidare den och kan inte öppna den.", + "The master password is not right, or the request has ended.": "Huvudlösenordet är fel, eller så har begäran avslutats.", + "The request expired. Ask again or use your master password.": "Begäran har gått ut. Begär igen eller använd ditt huvudlösenord.", + "The request was denied.": "Begäran nekades.", + "Too many requests. Try again in an hour or use your master password.": "För många begäranden. Försök igen om en timme eller använd ditt huvudlösenord.", + "Unknown device": "Okänd enhet", + "Web app": "Webbapp", + "A device": "En enhet", + "A new device asks to open your vault": "En ny enhet ber om att få öppna ditt valv", + "%s asks to be approved. Only approve a device you are using right now.": "%s ber om att bli godkänd. Godkänn bara en enhet som du använder just nu.", + "Access ends on (optional)": "Åtkomsten upphör den (valfritt)", + "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.": "Keepiqs appar visar eller kopierar inte lösenordet. Någon med teknisk kunskap kan ändå läsa det från sin egen enhet. Byt det när åtkomsten upphör.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Den här hemligheten får endast användas. Logga in via webbläsartillägget för Keepiq.", + "Until {date}": "Till {date}", + "Use only": "Endast användning", + "Use only (can sign in, cannot view or copy)": "Endast användning (kan logga in, kan inte visa eller kopiera)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Du kan logga in med den här inloggningen via webbläsartillägget för Keepiq. Ägaren har valt att inte låta dig visa eller kopiera den.", + "Your access ends on {date}": "Din åtkomst upphör den {date}", + "Your access to this secret has ended": "Din åtkomst till den här hemligheten har upphört", + "Your access to \"%s\" ends tomorrow": "Din åtkomst till ”%s” upphör i morgon", + "Your access to \"%s\" has ended": "Din åtkomst till ”%s” har upphört", + "%1$s no longer has access to \"%2$s\"": "%1$s har inte längre åtkomst till ”%2$s”", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s kunde se det här lösenordet. Byt det om %1$s inte längre ska känna till det.", + "%s could not view this password in Keepiq.": "%s kunde inte visa det här lösenordet i Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} av {threshold} godkännanden", + "a recovery officer": "en återställningsansvarig", + "Account recovery": "Kontoåterställning", + "Approvals needed": "Godkännanden som krävs", + "Ask {user} which words they see, by phone or in person. They must be:": "Fråga {user} vilka ord de ser, per telefon eller personligen. De måste vara:", + "Check again": "Kontrollera igen", + "Create the recovery key": "Skapa återställningsnyckeln", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Skapa återställningsnyckeln. Din webbläsare skapar den och ger varje ansvarig en kopia som bara de kan öppna.", + "Decline": "Avböj", + "Enrol in account recovery": "Anmäl dig till kontoåterställning", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Anmäl dig så att din organisation kan hjälpa dig att få tillbaka ditt valv om du glömmer ditt huvudlösenord.", + "Every user is enrolled": "Alla användare är anmälda", + "Finish the recovery in the browser you asked from.": "Slutför återställningen i webbläsaren du frågade från.", + "Forgot your master password?": "Glömt ditt huvudlösenord?", + "Hand the key over": "Lämna över nyckeln", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Låt användare som glömt sitt huvudlösenord få tillbaka sitt valv, godkänt av återställningsansvariga som du utser.", + "New master password": "Nytt huvudlösenord", + "No one is asking to recover their account.": "Ingen ber om att återställa sitt konto.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ingen återställningsnyckel ännu. En av de ansvariga skapar den i sina Keepiq-inställningar.", + "Off": "Av", + "Officer {user} has no encryption set up yet.": "Den ansvarige {user} har inte konfigurerat kryptering ännu.", + "Officers (user IDs, separated by commas)": "Ansvariga (användar-ID:n, separerade med kommatecken)", + "Policy": "Policy", + "Publish this fingerprint internally, so users can check it before they enrol.": "Publicera detta fingeravtryck internt, så att användare kan kontrollera det innan de anmäler sig.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Återställt med hjälp av {officer}. Byt din valvnyckel nu under Inställningar, Säkerhet: \"Mitt huvudlösenord har röjts\".", + "Recovery key fingerprint: {fingerprint}": "Återställningsnyckelns fingeravtryck: {fingerprint}", + "Recovery officer": "Återställningsansvarig", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Borttagna ansvariga förlorar sin kopia nu, men kan ha öppnat den tidigare. Låt en ansvarig skapa en ny återställningsnyckel.", + "Repeat the new master password": "Upprepa det nya huvudlösenordet", + "Retire this recovery key": "Pensionera denna återställningsnyckel", + "Set the new master password": "Ange det nya huvudlösenordet", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Återställningscertifikatet är inte utfärdat av denna Keepiq. Anmäl dig inte och meddela din administratör.", + "The words match, approve": "Orden stämmer, godkänn", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Denna användare är anmäld till kontoåterställning. Återställning behåller deras hemligheter; återkallande raderar deras anmälan.", + "Users may enrol": "Användare får anmäla sig", + "Withdraw from account recovery": "Avanmäl dig från kontoåterställning", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Du är anmäld till kontoåterställning. Återställningsnyckelns fingeravtryck: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Du är anmäld. Om du glömmer ditt huvudlösenord kan din organisation hjälpa dig att få tillbaka ditt valv.", + "Your key is back. Choose a new master password.": "Din nyckel är tillbaka. Välj ett nytt huvudlösenord.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Dina återställningsansvariga har fått besked. Läs upp dessa ord för dem när de ringer eller träffar dig:", + "You are now an account recovery officer": "Du är nu ansvarig för kontoåterställning", + "%s asks to recover their account. Compare the words with them before you approve.": "%s ber om att återställa sitt konto. Jämför orden med personen innan du godkänner.", + "A user": "En användare", + "Your account recovery request was declined": "Din begäran om kontoåterställning avböjdes", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Din kontoåterställning är klar. Öppna Keepiq i webbläsaren du frågade från.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} ber om att låsa upp en ny enhet en gång. Huvudlösenordet förblir detsamma.", + "Ask your organisation instead": "Fråga din organisation i stället", + "The request ended. Ask again or use your master password.": "Begäran har avslutats. Fråga igen eller använd ditt huvudlösenord.", + "Added by {user}": "Tillagd av {user}", + "Editor": "Redigerare", + "Manager": "Ansvarig", + "Role of {member}": "Roll för {member}", + "Team folders you manage": "Teammappar som du hanterar", + "Viewer": "Läsare", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Du har ingen kopia av dessa hemligheter, så de nya medlemmarna har inte fått dem än. Ägaren kan dela dem: {names}", + "Admin areas": "Administrationsområden", + "Give a group only the parts of Keepiq administration it needs.": "Ge en grupp bara de delar av Keepiq-administrationen som den behöver.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Delegera ett eller flera områden till en grupp på sidan för administrationsbehörigheter. Instansadministratörer har alla områden.", + "Open administration privileges": "Öppna administrationsbehörigheter", + "Policies": "Policyer", + "Applications and machine access": "Applikationer och maskinåtkomst", + "People and offboarding": "Personer och avslut", + "Audit and compliance": "Granskning och efterlevnad", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "version, certifikatutfärdare, bilagor, offlinecache, läckkontroll, hemlighetstyper och säkerhetskopior", + "master password, organisation password, vault policies, rotation, version history and trash": "huvudlösenord, organisationslösenord, valvpolicyer, rotation, versionshistorik och papperskorg", + "application queue, application requests and machine leases": "applikationskö, applikationsförfrågningar och maskinleasar", + "team offboarding, encryption suites and admin handover": "teamavslut, krypteringssviter och övertagande av administratör", + "audit log, compliance reports, SIEM export and honey alerts": "granskningslogg, efterlevnadsrapporter, SIEM-export och lockbeteslarm", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Hur många versioner av en hemlighet som sparas, hur länge, och hur länge raderade hemligheter ligger kvar i papperskorgen.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Gränser för krypterade bilagor, som servern tillämpar i lagrade krypterade byte.", + "Type the suite ID again to confirm": "Skriv svit-ID:t igen för att bekräfta", + "This does not match the suite ID.": "Detta matchar inte svit-ID:t.", + "Confirm with your master password": "Bekräfta med ditt huvudlösenord", + "Confirm": "Bekräfta", + "That master password is not right.": "Det huvudlösenordet stämmer inte.", + "You are sharing with someone new. Enter your master password to confirm.": "Du delar med en ny person. Ange ditt huvudlösenord för att bekräfta.", + "Enter your master password to confirm this share.": "Ange ditt huvudlösenord för att bekräfta delningen.", + "Enter your master password to confirm this delegation.": "Ange ditt huvudlösenord för att bekräfta delegeringen.", + "Approve {member}": "Godkänn {member}", + "Recipient": "Mottagare", + "No vault yet": "Har inget valv än", + "No matching users": "Inga matchande användare", + "Partner organisations": "Partnerorganisationer", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Utbyt hemligheter med en annan Keepiq. Båda administratörerna lägger till varandra och jämför rotfingeravtrycken per telefon eller personligen innan de sparar.", + "Federation needs Nextcloud 33 or later.": "Federation kräver Nextcloud 33 eller senare.", + "Your root fingerprint": "Ditt rotfingeravtryck", + "No partners yet.": "Inga partner än.", + "Users here may share to this partner": "Användare här får dela med den här partnern", + "This partner may share to users here": "Den här partnern får dela med användare här", + "Partner address": "Partnerns adress", + "Check partner": "Kontrollera partner", + "Partner root fingerprint": "Partnerns rotfingeravtryck", + "I compared this fingerprint with the partner's administrator": "Jag har jämfört det här fingeravtrycket med partnerns administratör", + "Add partner": "Lägg till partner", + "A secret from another organisation": "En hemlighet från en annan organisation", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s har delat \"%2$s\" med dig. Godkänn den under Inkommande från andra organisationer.", + "Incoming from other organisations": "Inkommande från andra organisationer", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Personer i partnerorganisationer kan dela en hemlighet med dig. Godkänn den för att behålla en skrivskyddad kopia i ditt valv.", + "Nothing shared with you yet": "Inget delat med dig ännu", + "Secrets that people in partner organisations share with you appear here.": "Hemligheter som personer i partnerorganisationer delar med dig visas här.", + "From {sender}": "Från {sender}", + "Accept": "Godkänn", + "Open in vault": "Öppna i valvet", + "The other organisation did not hand over the secret. Try again later.": "Den andra organisationen lämnade inte över hemligheten. Försök igen senare.", + "Set up your vault before you accept a shared secret.": "Konfigurera ditt valv innan du godkänner en delad hemlighet.", + "Something went wrong. Try again.": "Något gick fel. Försök igen.", + "Waiting for your answer": "Väntar på ditt svar", + "In your vault, read-only": "I ditt valv, skrivskyddad", + "Withdrawn by the sender": "Återkallad av avsändaren", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} delade detta från en annan organisation. Du kan läsa det, men inte ändra eller dela det.", + "Someone": "Någon", + "Share with someone at another organisation": "Dela med någon i en annan organisation", + "Their account at the other organisation": "Personens konto i den andra organisationen", + "Check account": "Kontrollera konto", + "Certificate fingerprint of {account}": "Certifikatets fingeravtryck för {account}", + "Compare it with them by phone if you want to be sure.": "Jämför det med personen per telefon om du vill vara säker.", + "Shared. {account} can accept it in their own vault.": "Delad. {account} kan godkänna den i sitt eget valv.", + "The certificate could not be verified. Nothing was shared.": "Certifikatet kunde inte verifieras. Inget delades.", + "That organisation is not one of your partners.": "Den organisationen är inte en av dina partner.", + "No one with that account can receive secrets from you.": "Ingen med det kontot kan ta emot hemligheter från dig.", + "The other organisation did not answer. Try again later.": "Den andra organisationen svarade inte. Försök igen senare.", + "This secret is already shared with that account.": "Den här hemligheten är redan delad med det kontot.", + "Other organisations": "Andra organisationer", + "Receive secrets from other organisations": "Ta emot hemligheter från andra organisationer", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Personer i partnerorganisationer kan då hitta ditt konto och dela hemligheter med dig. Du godkänner var och en själv.", + "Shared": "Delad", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Pausad: mottagarens certifikat eller partnerskapet har ändrats. Återkalla den eller dela igen.", + "Their organisation did not get the last change. Revoke it or share again.": "Mottagarens organisation fick inte den senaste ändringen. Återkalla den eller dela igen.", + "Being withdrawn": "Återkallas", + "Shared with another organisation": "Delad med en annan organisation", + "Change sent to another organisation": "Ändring skickad till en annan organisation", + "Share with another organisation revoked": "Delning med en annan organisation återkallad", + "Share with another organisation paused": "Delning med en annan organisation pausad", + "Another organisation did not get a change": "En annan organisation fick inte en ändring", + "Secret received from another organisation": "Hemlighet mottagen från en annan organisation", + "Secret from another organisation accepted": "Hemlighet från en annan organisation godkänd", + "Secret from another organisation declined": "Hemlighet från en annan organisation avböjd", + "Copy from another organisation updated": "Kopia från en annan organisation uppdaterad", + "Copy from another organisation removed": "Kopia från en annan organisation borttagen", + "Declined: they removed their copy. Share again if they need it.": "Avböjd: mottagaren tog bort sin kopia. Dela igen om de behöver den.", + "Recipient at another organisation removed their copy": "Mottagare i en annan organisation tog bort sin kopia", + "Removed the user from %n team folder.": "Användaren togs bort från %n teammapp.", + "Removed the user from %n team folders.": "Användaren togs bort från %n teammappar.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Användaren togs bort från %n teammapp.", + "Användaren togs bort från %n teammappar." + ], + "A restored copy came from a share that has ended. It stays read-only.": "En återställd kopia kom från en delning som har upphört. Den förblir skrivskyddad.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Organisationen som delade en återställd kopia gick inte att nå. Kopian förblir skrivskyddad och följer inte deras ändringar.", + "Recipient at another organisation restored their copy": "Mottagare i en annan organisation återställde sin kopia" }, "plurals": null } diff --git a/l10n/tr.js b/l10n/tr.js index 8ba3802d3..2029b52d0 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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." + "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.", + "Shared with groups": "Gruplarla paylaşıldı", + "Not shared with any group yet.": "Henüz hiçbir grupla paylaşılmadı.", + "Revoke the share with {group}": "{group} ile paylaşımı geri al", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "{group} ile paylaşıldı: {received} üye aldı, {skipped} üye henüz şifreleme ayarlamadığı için almadı.", + "Search groups": "Grup ara", + "Failed to share": "Paylaşılamadı", + "Columns": "Sütunlar", + "Column {number}": "Sütun {number}", + "Map one column to Name. Every secret needs a name.": "Bir sütunu Ad alanına eşleyin. Her gizli bilginin bir adı olmalı.", + "Notes": "Notlar", + "Do not import": "İçe aktarma", + "Hide this value": "Bu değeri gizle", + "Show this value": "Bu değeri göster", + "Defaults": "Varsayılanlar", + "New secrets start as this type, and your secret list opens in this view.": "Yeni sırlar bu türle başlar ve sır listeniz bu görünümde açılır.", + "Default item type": "Varsayılan öğe türü", + "Cards": "Kartlar", + "Table": "Tablo", + "Could not save your default": "Varsayılanınız kaydedilemedi", + "Recently used": "Son kullanılanlar", + "Opened": "Açıldı", + "You have not opened any secrets yet": "Henüz hiçbir sır açmadınız", + "Could not delete the item type.": "Öğe türü silinemedi.", + "Could not load the item types.": "Öğe türleri yüklenemedi.", + "Could not save the item type.": "Öğe türü kaydedilemedi.", + "Delete item type": "Öğe türünü sil", + "Edit item type": "Öğe türünü düzenle", + "Fields": "Alanlar", + "Fields: {count}": "Alanlar: {count}", + "Hidden": "Gizli", + "Item types": "Öğe türleri", + "Move up": "Yukarı taşı", + "New item type": "Yeni öğe türü", + "No item types defined yet.": "Henüz tanımlı öğe türü yok.", + "Required": "Zorunlu", + "Text": "Metin", + "This field is required": "Bu alan zorunludur", + "Web address": "Web adresi", + "{label} (required)": "{label} (zorunlu)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "“{name}” silinsin mi? Bu türdeki sırlar okunabilir kalır ve Giriş öğesi olur.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Burada tanımladığınız öğe türleri, seçtiğiniz alanlarla herkesin Yeni gizli penceresinde görünür.", + "Secret moved to the trash": "Gizli bilgi çöp kutusuna taşındı", + "Secret restored from the trash": "Gizli bilgi çöp kutusundan geri yüklendi", + "Secret deleted for good": "Gizli bilgi kalıcı olarak silindi", + "Secret archived": "Gizli bilgi arşivlendi", + "Secret unarchived": "Gizli bilgi arşivden çıkarıldı", + "Unarchive": "Arşivden çıkar", + "Could not archive the secret": "Gizli bilgi arşivlenemedi", + "Could not unarchive the secret": "Gizli bilgi arşivden çıkarılamadı", + "Archive {count} secrets": "Gizli bilgileri arşivle: {count}", + "Unarchive {count} secrets": "Gizli bilgileri arşivden çıkar: {count}", + "Restore {count} secrets": "Gizli bilgileri geri yükle: {count}", + "Delete {count} secrets for good": "Gizli bilgileri kalıcı olarak sil: {count}", + "Done for {ok} of {total} secrets": "{total} gizli bilginin {ok} tanesi tamamlandı", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arşivlenen gizli bilgiler kasa listesinden, aramadan, otomatik doldurmadan ve sağlık raporundan çıkar. Paylaşımları korunur. Onları Arşiv altında bulursunuz.", + "These secrets come back to the vault list, search and autofill.": "Bu gizli bilgiler kasa listesine, aramaya ve otomatik doldurmaya geri döner.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Bu gizli bilgiler kasa listesine geri döner. Eski paylaşımları geri gelmez, bu yüzden gereken yerde yeniden paylaşın.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Bu işlem gizli bilgileri ekleri ve sürüm geçmişiyle birlikte siler. Geri alınamaz.", + "Delete for good": "Kalıcı olarak sil", + "Trash": "Çöp kutusu", + "The trash is empty": "Çöp kutusu boş", + "No archived secrets": "Arşivlenmiş gizli bilgi yok", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Silinen gizli bilgiler saklama süresi bitene kadar burada bekler, sonra kalıcı olarak silinir.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Bir gizli bilgiyi kasa listesi, arama ve otomatik doldurma dışında tutmak için ayrıntı panelinden arşivleyin.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Şifreli ekler için sınırlar (sunucuda saklanan şifreli baytlar üzerinde uygulanır), sürüm geçmişinin saklanması ve silinen gizli bilgilerin çöp kutusunda ne kadar kalacağı.", + "Days a deleted secret stays in the trash (1 to 365)": "Silinen bir gizli bilginin çöp kutusunda kaldığı gün sayısı (1 ile 365 arası)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Bu işlem gizli bilgiyi çöp kutusuna taşır ve paylaşımlarını hemen sonlandırır. Saklama süresi bitene kadar çöp kutusundan geri yükleyebilirsiniz: yöneticiniz değiştirmediyse 30 gün.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Bu işlem gizli bilgileri çöp kutusuna taşır ({count}) ve paylaşımlarını hemen sonlandırır. Saklama süresi bitene kadar onları çöp kutusundan geri yükleyebilirsiniz.", + "Remove {name} from favourites": "{name} öğesini sık kullanılanlardan kaldır", + "Add {name} to favourites": "{name} öğesini sık kullanılanlara ekle", + "Could not change the favourite": "Sık kullanılan değiştirilemedi", + "Remove from favourites": "Sık kullanılanlardan kaldır", + "Add to favourites": "Sık kullanılanlara ekle", + "Tags": "Etiketler", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketler şifrelenmez. Sunucu yöneticileri, klasör adları gibi bunları da okuyabilir.", + "Favourites": "Sık kullanılanlar", + "Filter by tag": "Etikete göre süz", + "All tags": "Tüm etiketler", + "Last used": "Son kullanım", + "Tags for {count} secrets": "{count} gizli bilgi için etiketler", + "Tag": "Etiket", + "Remove tag": "Etiketi kaldır", + "Add tag": "Etiket ekle", + "Could not change the tags. Try again.": "Etiketler değiştirilemedi. Yeniden deneyin.", + "Could not approve the application. It is still in the queue.": "Başvuru onaylanamadı. Hâlâ kuyrukta.", + "Could not reject the application. It is still in the queue.": "Başvuru reddedilemedi. Hâlâ kuyrukta.", + "Removed the user from {count} team folders.": "Kullanıcı {count} takım klasöründen çıkarıldı.", + "Approve a share": "Bir paylaşımı onayla", + "This approval link is incomplete. Open it again from the notification.": "Bu onay bağlantısı eksik. Bildirimden yeniden açın.", + "Deny": "Reddet", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user}, bir sırrı paylaştığınız bir gruba katıldı. Sır onunla da paylaşılsın mı?", + "{requester} asks you to share a secret with {user}.": "{requester}, bir sırrı {user} ile paylaşmanızı istiyor.", + "Shared. The recipient can now open the secret.": "Paylaşıldı. Alıcı artık sırrı açabilir.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Alıcı henüz Keepiq kurulumunu yapmadı, bu nedenle hiçbir şey paylaşılmadı. Kurulumu yaptığında yeniden deneyin.", + "Could not share the secret. Only its owner can approve this.": "Sır paylaşılamadı. Bunu yalnızca sahibi onaylayabilir.", + "Could not share the secret. Try again.": "Sır paylaşılamadı. Yeniden deneyin.", + "Denied. Nothing was shared.": "Reddedildi. Hiçbir şey paylaşılmadı.", + "Could not deny the request. Try again.": "İstek reddedilemedi. Yeniden deneyin.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s, \"%2$s\" sırrını %3$s ile paylaşmanızı istiyor.", + "Expires on (optional)": "Sona erme tarihi (isteğe bağlı)", + "Hand over to": "Şu kişiye devret", + "Choose a recipient": "Bir alıcı seçin", + "Hand over temporarily": "Geçici olarak devret", + "Expiry rules": "Sona erme kuralları", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Bir öge türündeki ya da bir klasördeki parolaların ne kadar süre geçerli olacağını ve ne zaman hatırlatılacağını belirleyin. Birden fazla tarih geçerli olduğunda en erken olanı sayılır.", + "Delete rule": "Kuralı sil", + "Set by your administrator": "Yöneticiniz tarafından ayarlandı", + "No expiry rules yet.": "Henüz sona erme kuralı yok.", + "Applies to": "Uygulandığı yer", + "Item type": "Öge türü", + "Maximum age in days (empty for reminders only)": "Gün olarak en uzun süre (yalnızca hatırlatmalar için boş bırakın)", + "Remind me this many days before, comma separated": "Kaç gün önce hatırlatılsın, virgülle ayırın", + "Save rule": "Kuralı kaydet", + "An item type": "Bir öge türü", + "A folder": "Bir klasör", + "Folder {name}": "{name} klasörü", + "Type {name}": "{name} türü", + "Expires after {days} days": "{days} gün sonra sona erer", + "Reminders {days} days before": "{days} gün önce hatırlatma", + "Could not save the expiry rule.": "Sona erme kuralı kaydedilemedi.", + "Could not delete the expiry rule.": "Sona erme kuralı silinemedi.", + "All statuses": "Tüm durumlar", + "Compromised": "Ele geçirilmiş", + "Could not load the members.": "Üyeler yüklenemedi.", + "Emergency contact": "Acil durum kişisi", + "Leaving user": "Ayrılan kullanıcı", + "No": "Hayır", + "No users match this filter.": "Bu filtreyle eşleşen kullanıcı yok.", + "Not set up": "Kurulmadı", + "Revoke suite": "Paketi iptal et", + "Revoked": "İptal edildi", + "Search users": "Kullanıcı ara", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Hangi kullanıcıların kasa kurduğunu görün. Bir satırdan ayrılış işlemini başlatın veya bir paketi iptal edin.", + "Successor": "Halef", + "Team folders": "Ekip klasörleri", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Kullanıcı hâlâ bir ekip klasörünün üyesi olan {groups} grubunda. Kullanıcıyı gruptan çıkarın veya hesabı devre dışı bırakın.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Kullanıcı hâlâ ekip klasörlerinin üyesi olan {groups} gruplarında. Kullanıcıyı gruplardan çıkarın veya hesabı devre dışı bırakın.", + "Vault status": "Kasa durumu", + "Yes": "Evet", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF dışa aktarımı ŞİFRELENMEMİŞTİR. İndirilen dosyada her parola ve kullanıcı adı düz metin olarak okunabilir olacak. Dosyayı güvenli biçimde saklayın ve kullandıktan hemen sonra silin.", + "Root certificate expiring soon": "Kök sertifikanın süresi yakında doluyor", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Kasanın kök sertifikasının süresi %1$d gün içinde doluyor. Bundan önce yenileyin. Yenileme her şifreleme paketini yeniden imzalar.", + "Compromise recovery aborted": "Ele geçirilme sonrası kurtarma iptal edildi", + "Key rotation ended by a compromise revoke": "Anahtar değişimi, ele geçirilme nedeniyle yapılan bir iptalle sonlandırıldı", + "Encryption suite revoke refused": "Şifreleme paketi iptali reddedildi", + "Master password proof refused": "Ana parola kanıtı reddedildi", + "Your current master password": "Geçerli ana parolanız", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n acil durum kişisinin, anahtar değişimi onu kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n acil durum kişisinin, anahtar değişimi onları kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Bu acil durum kişileri yeni anahtarınıza aktarılmadı. Acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız Acil durum erişimi bölümünden yeniden ekleyin.", + "Renew root certificate": "Kök sertifikayı yenile", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Bu, yeni bir kök ve ara sertifika oluşturur. Her etkin şifreleme paketi yeniden imzalanır. Bu işlem geri alınamaz.", + "Renew root": "Kökü yenile", + "Root renewed. {n} encryption suites signed again.": "Kök yenilendi. Yeniden imzalanan şifreleme paketi: {n}.", + "Could not renew the root certificate.": "Kök sertifika yenilenemedi.", + "Lease policy for this application": "Bu uygulama için kiralama ilkesi", + "In force now: {default} seconds by default, {max} seconds at most.": "Şu an geçerli: varsayılan {default} saniye, en fazla {max} saniye.", + "Leases are not renewable": "Kiralamalar yenilenemez", + "Lease policy saved.": "Kiralama ilkesi kaydedildi.", + "Leave a field empty to use the instance value.": "Örnek değerini kullanmak için bir alanı boş bırakın.", + "Instance value: {value}": "Örnek değeri: {value}", + "Renewal": "Yenileme", + "Use the instance value ({value})": "Örnek değerini kullan ({value})", + "Allowed": "İzin verildi", + "Not allowed": "İzin verilmedi", + "Save lease policy": "Kiralama ilkesini kaydet", + "Only an administrator can change this policy.": "Bu ilkeyi yalnızca bir yönetici değiştirebilir.", + "Could not save the lease policy.": "Kiralama ilkesi kaydedilemedi.", + "{member} got access from {confirmer}.": "{member}, {confirmer} tarafından erişim aldı.", + "Automatically confirm new team folder members": "Yeni ekip klasörü üyelerini otomatik onayla", + "Gave %n new member access to a team folder.": "%n yeni üye bir ekip klasörüne erişim aldı.", + "Gave %n new members access to a team folder.": "%n yeni üye bir ekip klasörüne erişim aldı.", + "Give new team folder members access without waiting for the folder owner.": "Yeni ekip klasörü üyelerine klasör sahibini beklemeden erişim verin.", + "New team folder members": "Yeni ekip klasörü üyeleri", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Sahip veya yazma izni olan bir üye onları açık kasasından onaylar. Keepiq sunucuda asla şifre çözmez.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Yazma izni olan bir üyenin Keepiq'i açması bekleniyor. Şimdi de paylaşabilirsiniz.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Güvenlik ihlali yanıtının bir kısmı başarısız oldu ({failed} adım). Sunucu günlüğünü kontrol edin, ardından tamamlamak için paketi yeniden iptal edin.", + "This also revoked suite {suite} and ended key migration {migration}.": "Bu işlem {suite} paketini de iptal etti ve {migration} anahtar taşımasını sonlandırdı.", + "Revoking the second suite deleted %n emergency-access contact.": "İkinci paketin iptali %n acil erişim kişisini sildi.", + "Revoking the second suite deleted %n emergency-access contacts.": "İkinci paketin iptali %n acil erişim kişisini sildi.", + "A suite revoked as compromised cannot be reinstated.": "İhlal edilmiş olarak iptal edilen bir paket geri yüklenemez.", + "Archives to keep": "Saklanacak arşivler", + "Back up every vault automatically": "Her kasayı otomatik yedekle", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Her kasayı bir zamanlamayla yedekleyin. Arşivler yalnızca şifreli metin içerir ve occ ile geri yüklenir.", + "Back up now": "Şimdi yedekle", + "Backup public key (PEM, optional)": "Yedek ortak anahtarı (PEM, isteğe bağlı)", + "Backup requested for the next cron run": "Yedek bir sonraki cron çalışması için istendi", + "Encrypted": "Şifreli", + "Every (hours)": "Her (saat)", + "Last backup {when} failed: {error}": "Son yedek {when} başarısız: {error}", + "Last backup {when} succeeded.": "Son yedek {when} başarılı.", + "No archives yet.": "Henüz arşiv yok.", + "Size": "Boyut", + "Vault backups": "Kasa yedekleri", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Bir anahtarla her arşiv ona göre şifrelenir. Özel anahtarı bu sunucunun dışında tutun: doğrulamak veya geri yüklemek için gerekir.", + "Written": "Yazıldı", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamaz.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamazlar.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Yedek kodlar sayılmaz. Kullanıcılarınız kendi ikinci faktörü olan bir kimlik sağlayıcıyla giriş yapıyorsa gruplarını dışarıda bırakın.", + "Block personal vault export": "Kişisel kasa dışa aktarımını engelle", + "Keep work logins in team folders": "İş girişlerini ekip klasörlerinde tut", + "Move to a team folder": "Bir ekip klasörüne taşı", + "Not in a team folder": "Bir ekip klasöründe değil", + "Only for these groups (empty is everyone)": "Yalnızca bu gruplar için (boş herkes demektir)", + "Require two-factor login before the vault opens": "Kasa açılmadan önce iki adımlı giriş iste", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Her kasa için kurallar. Her biri herkese ya da yalnızca seçtiğiniz gruplara uygulanır.", + "Secret types that belong in a team folder": "Bir ekip klasörüne ait gizli bilgi türleri", + "Set up two-factor login": "İki adımlı girişi ayarla", + "Team folder you can write to": "Yazabileceğiniz ekip klasörü", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Kullanıcılar yedek, CSV veya aktarım dosyası indiremez. Kişisel veri paketleri kullanılabilir kalır.", + "Users cannot save these secret types in a personal folder.": "Kullanıcılar bu gizli bilgi türlerini kişisel bir klasöre kaydedemez.", + "Vault policies": "Kasa kuralları", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Kuruluşunuz kişisel kasanızın dışa aktarılmasına izin vermiyor. Ayarlarınızdaki kişisel veri paketiniz kullanılabilir kalır.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Kuruluşunuz bu gizli bilgileri bir ekip klasöründe tutar. Her birini bir ekip klasörüne taşıyın.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Kuruluşunuz bu tür gizli bilgiyi bir ekip klasöründe tutar. Ekip klasörlerinizden birini ya da yazabileceğiniz birini seçin.", + "Your organisation requires two-factor login before you can open your vault.": "Kuruluşunuz kasanızı açabilmeniz için iki adımlı giriş istiyor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Kullanıcılar, uzantının boşta kaldığında ne kadar süre kilidi açık kalacağını seçer. Seçebilecekleri en uzun süreyi siz belirlersiniz.", + "Longest idle time before the extension locks": "Uzantı kilitlenmeden önceki en uzun boşta kalma süresi", + "1 minute": "1 dakika", + "5 minutes": "5 dakika", + "15 minutes": "15 dakika", + "1 hour": "1 saat", + "4 hours": "4 saat", + "Connector": "Bağlayıcı", + "Directory (tenant) ID": "Dizin (kiracı) kimliği", + "Application (client) ID": "Uygulama (istemci) kimliği", + "Data collection rule immutable ID": "Veri toplama kuralının değişmez kimliği", + "Stream name": "Akış adı", + "Splunk index (optional)": "Splunk dizini (isteğe bağlı)", + "Sourcetype (optional)": "Sourcetype (isteğe bağlı)", + "Leave blank to keep the current one": "Mevcut olanı korumak için boş bırakın", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "Syslog üzerinden CEF", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Veri toplama uç noktası (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL'si (https)", + "Client secret (write-only)": "İstemci gizli anahtarı (yalnızca yazma)", + "HEC token (write-only)": "HEC belirteci (yalnızca yazma)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "İzin verilen denetim olaylarını Splunk'a, Microsoft Sentinel'e, bir syslog alıcısına veya bir webhook'a iletin. İletiler yalnızca temizlenmiş meta veriler taşır: hiçbir gizli değer, ad, oturum açma bilgisi veya şifreli metin sunucudan asla çıkmaz.", + "%n change waiting to sync": "%n değişiklik eşitlenmeyi bekliyor", + "%n changes waiting to sync": "%n değişiklik eşitlenmeyi bekliyor", + "Changes that could not sync": "Eşitlenemeyen değişiklikler", + "Choose a version": "Bir sürüm seçin", + "Copy value": "Değeri kopyala", + "Deleted": "Silindi", + "Discard": "At", + "Keep my offline change": "Çevrim dışı değişikliğimi koru", + "Keep the server version": "Sunucu sürümünü koru", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq çevrim dışıyken salt okunurdur. Yöneticiniz çevrim dışı düzenlemeyi açmadı.", + "Let users edit secrets offline": "Kullanıcıların sırları çevrim dışı düzenlemesine izin ver", + "Not synced yet": "Henüz eşitlenmedi", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Çevrim dışı değişiklikler cihazda, kullanıcıya şifrelenmiş olarak kalır ve bir sonraki çevrim içi kilit açmada eşitlenir. Paylaşım, klasörler ve ekler için hâlâ bağlantı gerekir.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Çevrim dışı. Düzenlemeler, taşımalar ve silmeler bu cihazda kalır ve yeniden çevrim içi olduğunuzda eşitlenir. Paylaşım ve ekler için bağlantı gerekir.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Çevrim dışı. Değişiklikleriniz bu cihazda kalır ve yeniden çevrim içi olduğunuzda eşitlenir. Son eşitleme {when}.", + "Open my changes": "Değişikliklerimi aç", + "Sharing needs a connection": "Paylaşım için bağlantı gerekir", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Çevrim dışı kopyanız oluşturulduktan sonra biri bu sırrı sunucuda değiştirdi. Hangi sürümü koruyacağınızı seçin.", + "Sync or discard your offline changes before you rotate your keys.": "Anahtarlarınızı değiştirmeden önce çevrim dışı değişikliklerinizi eşitleyin ya da atın.", + "That password did not open your changes.": "Bu parola değişikliklerinizi açmadı.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Çevrim dışı anlık görüntü şifreli sırları saklar (yalnızca kullanıcının ana parolasından türetilen anahtarla açılabilir, tıpkı sunucudaki gibi) ve adları, URL'leri ve klasör adlarını depolamada şifreler. Aşağıda çevrim dışı düzenlemeye izin vermezseniz çevrim dışı erişim salt okunurdur. Kimlik bilgilerini asla önbelleğe almaması gereken cihazlar için bunu kapatın; kapatmak, var olan önbellekleri bir sonraki yüklemede temizler.", + "The previous vault copy is gone, so these changes cannot be opened.": "Kasanın önceki kopyası artık yok, bu yüzden bu değişiklikler açılamıyor.", + "The server version": "Sunucu sürümü", + "This secret changed while you were offline": "Siz çevrim dışıyken bu sır değişti", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Bu sırrı çevrim dışıyken sildiniz, ancak o zamandan beri sunucuda değiştirildi. Hangi sürümü koruyacağınızı seçin.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Anahtarlarınız başka bir cihazda değiştirildi. Çevrim dışı değişikliklerinizi eşitlemek için önceki ana parolanızı girin ya da onları atın.", + "Your offline change": "Çevrim dışı değişikliğiniz", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n acil durum kişisinin, anahtar değişimi onu kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n öğe CXF içinde gösterilemiyor ve atlanacak."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["Yalnızca son geçmiş taşınabildiği için %n eski sürüm atıldı."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["%n gizli kopyasının hâlâ şifrelenip paylaşılması gerekiyor."], + "_%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 gizli bilginin şifresi çözülemedi ve bu dışa aktarımda yer almıyor."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n sır eski anahtarınızla şifresi çözülemedi, bu yüzden taşınmadı."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n sır taşınmadı."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n sır hâlâ önceki anahtarınızla şifreli."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["Ardıl henüz bir kopyaya sahip olmadığı için %n gizli atlandı — ardılı klasöre ekleyin ve yeniden çalıştırın."], + "_%n secret_::_%n secrets_": ["%n gizli"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamaz."], + "_Emergency access for %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 contact could not be carried across and was removed. Re-establish it so they can still recover your vault."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Yine de bitir, %n sırra erişimi kaybederek"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n yeni üye bir ekip klasörüne erişim aldı."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Anahtar döndürme bitti. %n sır yeni anahtarınızla yeniden şifrelendi."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["İkinci paketin iptali %n acil erişim kişisini sildi."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Bu paketin iptali %n acil durum erişim kişisini sildi."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["sızıntılarda %n kez görüldü"], + "_shared with %n secret_::_shared with %n secrets_": ["%n gizliyle paylaşıldı"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Bu klasör doğrudan %n gizli içeriyor."], + "_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._": ["Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n değişiklik eşitlenmeyi bekliyor"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Kullanıcı hâlâ bir ekip klasörünün üyesi olan {groups} grubunda. Kullanıcıyı gruptan çıkarın veya hesabı devre dışı bırakın."], + "Allow approval from another device": "Başka bir cihazdan onaya izin ver", + "App": "Uygulama", + "Approve a new device": "Yeni bir cihazı onayla", + "Approve from another device": "Başka bir cihazdan onayla", + "Asked at": "İstenme zamanı", + "Check that the new device shows these words:": "Yeni cihazın şu kelimeleri gösterdiğini kontrol edin:", + "Denied. If you did not ask, end your other sessions:": "Reddedildi. Bunu siz istemediyseniz diğer oturumlarınızı sonlandırın:", + "Device": "Cihaz", + "IP address": "IP adresi", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Kullanıcıların yeni bir tarayıcının kilidini, Keepiq'in zaten açık olduğu bir cihazdan onaylayarak açmasına izin verin.", + "New device approval": "Yeni cihaz onayı", + "Nextcloud security settings": "Nextcloud güvenlik ayarları", + "Only approve a device you are using right now.": "Yalnızca şu anda kullandığınız bir cihazı onaylayın.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Keepiq'i kilidinin açık olduğu bir cihazda açın ve bu cihazı onaylayın. Aynı kelimeleri gösterdiğini kontrol edin:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Onaylayan cihaz, kilit açma anahtarını yeni cihaz için mühürler. Sunucu yalnızca anahtarı iletir ve açamaz.", + "The master password is not right, or the request has ended.": "Ana parola doğru değil veya istek sona erdi.", + "The request expired. Ask again or use your master password.": "İsteğin süresi doldu. Yeniden isteyin veya ana parolanızı kullanın.", + "The request was denied.": "İstek reddedildi.", + "Too many requests. Try again in an hour or use your master password.": "Çok fazla istek. Bir saat sonra yeniden deneyin veya ana parolanızı kullanın.", + "Unknown device": "Bilinmeyen cihaz", + "Web app": "Web uygulaması", + "A device": "Bir cihaz", + "A new device asks to open your vault": "Yeni bir cihaz kasanızı açmak istiyor", + "%s asks to be approved. Only approve a device you are using right now.": "%s onay istiyor. Yalnızca şu anda kullandığınız bir cihazı onaylayın.", + "Access ends on (optional)": "Erişimin bitiş tarihi (isteğe bağlı)", + "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.": "Keepiq uygulamaları parolayı göstermez veya kopyalamaz. Teknik bilgisi olan biri yine de parolayı kendi cihazından okuyabilir. Erişimi sona erdiğinde parolayı değiştirin.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Bu gizli bilgi yalnızca kullanım içindir. Keepiq tarayıcı eklentisi ile oturum açın.", + "Until {date}": "{date} tarihine kadar", + "Use only": "Yalnızca kullanım", + "Use only (can sign in, cannot view or copy)": "Yalnızca kullanım (oturum açabilir, göremez veya kopyalayamaz)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Bu oturum açma bilgisiyle Keepiq tarayıcı eklentisi üzerinden oturum açabilirsiniz. Sahibi, bilgiyi görmenize veya kopyalamanıza izin vermemeyi seçti.", + "Your access ends on {date}": "Erişiminiz {date} tarihinde sona eriyor", + "Your access to this secret has ended": "Bu gizli bilgiye erişiminiz sona erdi", + "Your access to \"%s\" ends tomorrow": "\"%s\" erişiminiz yarın sona eriyor", + "Your access to \"%s\" has ended": "\"%s\" erişiminiz sona erdi", + "%1$s no longer has access to \"%2$s\"": "%1$s artık \"%2$s\" erişimine sahip değil", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s bu parolayı görebiliyordu. %1$s artık bilmemesi gerekiyorsa parolayı değiştirin.", + "%s could not view this password in Keepiq.": "%s bu parolayı Keepiq içinde göremedi.", + "{approvals} of {threshold} approvals": "{approvals}/{threshold} onay", + "a recovery officer": "bir kurtarma yetkilisi", + "Account recovery": "Hesap kurtarma", + "Approvals needed": "Gereken onaylar", + "Ask {user} which words they see, by phone or in person. They must be:": "{user} kullanıcısına telefonla veya yüz yüze hangi kelimeleri gördüğünü sorun. Şunlar olmalı:", + "Check again": "Yeniden denetle", + "Create the recovery key": "Kurtarma anahtarı oluştur", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Kurtarma anahtarını oluşturun. Tarayıcınız onu üretir ve her yetkiliye yalnızca onun açabileceği bir kopya verir.", + "Decline": "Reddet", + "Enrol in account recovery": "Hesap kurtarmaya kaydol", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Ana parolanızı unutursanız kuruluşunuzun kasanızı geri almanıza yardım edebilmesi için kaydolun.", + "Every user is enrolled": "Tüm kullanıcılar kayıtlı", + "Finish the recovery in the browser you asked from.": "Kurtarmayı, istediğiniz tarayıcıda tamamlayın.", + "Forgot your master password?": "Ana parolanızı mı unuttunuz?", + "Hand the key over": "Anahtarı teslim et", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ana parolasını unutan kullanıcıların, atadığınız kurtarma yetkililerinin onayıyla kasalarını geri almasına izin verin.", + "New master password": "Yeni ana parola", + "No one is asking to recover their account.": "Hesabını kurtarmak isteyen kimse yok.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Henüz kurtarma anahtarı yok. Yetkililerden biri onu kendi Keepiq ayarlarında oluşturur.", + "Off": "Kapalı", + "Officer {user} has no encryption set up yet.": "{user} yetkilisi henüz şifrelemeyi ayarlamadı.", + "Officers (user IDs, separated by commas)": "Yetkililer (kullanıcı kimlikleri, virgülle ayrılmış)", + "Policy": "İlke", + "Publish this fingerprint internally, so users can check it before they enrol.": "Kullanıcıların kaydolmadan önce denetleyebilmesi için bu parmak izini kurum içinde yayınlayın.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "{officer} yardımıyla kurtarıldı. Kasa anahtarınızı şimdi Ayarlar, Güvenlik bölümünden değiştirin: \"Ana parolam ele geçirildi\".", + "Recovery key fingerprint: {fingerprint}": "Kurtarma anahtarı parmak izi: {fingerprint}", + "Recovery officer": "Kurtarma yetkilisi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Kaldırılan yetkililer kopyalarını şimdi kaybeder, ancak daha önce açmış olabilirler. Bir yetkiliden yeni bir kurtarma anahtarı oluşturmasını isteyin.", + "Repeat the new master password": "Yeni ana parolayı tekrarlayın", + "Retire this recovery key": "Bu kurtarma anahtarını kullanımdan kaldır", + "Set the new master password": "Yeni ana parolayı belirle", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Kurtarma sertifikası bu Keepiq tarafından verilmemiş. Kaydolmayın ve yöneticinize bildirin.", + "The words match, approve": "Kelimeler eşleşiyor, onayla", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Bu kullanıcı hesap kurtarmaya kayıtlı. Kurtarma sırlarını korur; iptal etmek kaydını siler.", + "Users may enrol": "Kullanıcılar kaydolabilir", + "Withdraw from account recovery": "Hesap kurtarmadan çekil", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Hesap kurtarmaya kayıtlısınız. Kurtarma anahtarı parmak izi: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Kayıtlısınız. Ana parolanızı unutursanız kuruluşunuz kasanızı geri almanıza yardım edebilir.", + "Your key is back. Choose a new master password.": "Anahtarınız geri geldi. Yeni bir ana parola seçin.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Kurtarma yetkililerinize haber verildi. Sizi aradıklarında veya buluştuğunuzda onlara şu kelimeleri okuyun:", + "You are now an account recovery officer": "Artık bir hesap kurtarma yetkilisisiniz", + "%s asks to recover their account. Compare the words with them before you approve.": "%s hesabını kurtarmak istiyor. Onaylamadan önce kelimeleri onunla karşılaştırın.", + "A user": "Bir kullanıcı", + "Your account recovery request was declined": "Hesap kurtarma isteğiniz reddedildi", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Hesap kurtarmanız hazır. Keepiq'i istediğiniz tarayıcıda açın.", + "{user} asks to unlock a new device once. They keep their master password.": "{user}, yeni bir cihazın kilidinin bir kez açılmasını istiyor. Ana parola aynı kalır.", + "Ask your organisation instead": "Bunun yerine kuruluşunuza sorun", + "The request ended. Ask again or use your master password.": "İstek sona erdi. Yeniden isteyin ya da ana parolanızı kullanın.", + "Added by {user}": "{user} tarafından eklendi", + "Editor": "Düzenleyici", + "Manager": "Yönetici", + "Role of {member}": "{member} rolü", + "Team folders you manage": "Yönettiğiniz ekip klasörleri", + "Viewer": "Görüntüleyici", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Bu gizli bilgilerin bir kopyası sizde yok, bu yüzden yeni üyeler henüz bunları almadı. Sahibi bunları paylaşabilir: {names}", + "Admin areas": "Yönetim alanları", + "Give a group only the parts of Keepiq administration it needs.": "Bir gruba Keepiq yönetiminin yalnızca ihtiyaç duyduğu bölümlerini verin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Yönetim yetkileri sayfasında bir gruba bir veya daha fazla alan devredin. Örnek yöneticileri her alana sahiptir.", + "Open administration privileges": "Yönetim yetkilerini aç", + "Policies": "İlkeler", + "Applications and machine access": "Uygulamalar ve makine erişimi", + "People and offboarding": "Kişiler ve ayrılanlar", + "Audit and compliance": "Denetim ve uyumluluk", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "sürüm, sertifika yetkilisi, ekler, çevrimdışı önbellek, sızıntı denetimi, gizli bilgi türleri ve yedekler", + "master password, organisation password, vault policies, rotation, version history and trash": "ana parola, kuruluş parolası, kasa ilkeleri, rotasyon, sürüm geçmişi ve çöp kutusu", + "application queue, application requests and machine leases": "uygulama kuyruğu, uygulama istekleri ve makine kiralamaları", + "team offboarding, encryption suites and admin handover": "ekipten ayrılanlar, şifreleme paketleri ve yönetici devralması", + "audit log, compliance reports, SIEM export and honey alerts": "denetim günlüğü, uyumluluk raporları, SIEM dışa aktarımı ve tuzak uyarıları", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Bir gizli bilginin kaç sürümünün ne kadar süre saklandığı ve silinen gizli bilgilerin çöp kutusunda ne kadar kaldığı.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Şifreli ekler için sınırlar; sunucu bunları depolanan şifreli baytlarda uygular.", + "Type the suite ID again to confirm": "Onaylamak için paket kimliğini yeniden yazın", + "This does not match the suite ID.": "Bu, paket kimliğiyle eşleşmiyor.", + "Confirm with your master password": "Ana parolanızla onaylayın", + "Confirm": "Onayla", + "That master password is not right.": "Bu ana parola doğru değil.", + "You are sharing with someone new. Enter your master password to confirm.": "Yeni biriyle paylaşıyorsunuz. Onaylamak için ana parolanızı girin.", + "Enter your master password to confirm this share.": "Bu paylaşımı onaylamak için ana parolanızı girin.", + "Enter your master password to confirm this delegation.": "Bu yetki devrini onaylamak için ana parolanızı girin.", + "Approve {member}": "{member} onayla", + "Recipient": "Alıcı", + "No vault yet": "Henüz kasası yok", + "No matching users": "Eşleşen kullanıcı yok", + "Partner organisations": "Ortak kuruluşlar", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Başka bir Keepiq ile sır paylaşın. İki yönetici de birbirini ekler ve kaydetmeden önce kök parmak izlerini telefonda veya yüz yüze karşılaştırır.", + "Federation needs Nextcloud 33 or later.": "Federasyon için Nextcloud 33 veya üstü gerekir.", + "Your root fingerprint": "Kök parmak iziniz", + "No partners yet.": "Henüz ortak yok.", + "Users here may share to this partner": "Buradaki kullanıcılar bu ortakla paylaşabilir", + "This partner may share to users here": "Bu ortak buradaki kullanıcılarla paylaşabilir", + "Partner address": "Ortağın adresi", + "Check partner": "Ortağı denetle", + "Partner root fingerprint": "Ortağın kök parmak izi", + "I compared this fingerprint with the partner's administrator": "Bu parmak izini ortağın yöneticisiyle karşılaştırdım", + "Add partner": "Ortak ekle", + "A secret from another organisation": "Başka bir kuruluştan bir gizli", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s, \"%2$s\" öğesini sizinle paylaştı. Diğer kuruluşlardan gelenler altında kabul edin.", + "Incoming from other organisations": "Diğer kuruluşlardan gelenler", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Ortak kuruluşlardaki kişiler sizinle bir gizli paylaşabilir. Kasanızda salt okunur bir kopya tutmak için kabul edin.", + "Nothing shared with you yet": "Henüz sizinle hiçbir şey paylaşılmadı", + "Secrets that people in partner organisations share with you appear here.": "Ortak kuruluşlardaki kişilerin sizinle paylaştığı gizliler burada görünür.", + "From {sender}": "Gönderen: {sender}", + "Accept": "Kabul et", + "Open in vault": "Kasada aç", + "The other organisation did not hand over the secret. Try again later.": "Diğer kuruluş gizliyi teslim etmedi. Daha sonra yeniden deneyin.", + "Set up your vault before you accept a shared secret.": "Paylaşılan bir gizliyi kabul etmeden önce kasanızı kurun.", + "Something went wrong. Try again.": "Bir şeyler ters gitti. Yeniden deneyin.", + "Waiting for your answer": "Yanıtınız bekleniyor", + "In your vault, read-only": "Kasanızda, salt okunur", + "Withdrawn by the sender": "Gönderen tarafından geri çekildi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} bunu başka bir kuruluştan paylaştı. Okuyabilirsiniz, ancak değiştiremez veya paylaşamazsınız.", + "Someone": "Biri", + "Share with someone at another organisation": "Başka bir kuruluştaki biriyle paylaş", + "Their account at the other organisation": "Kişinin diğer kuruluştaki hesabı", + "Check account": "Hesabı denetle", + "Certificate fingerprint of {account}": "{account} hesabının sertifika parmak izi", + "Compare it with them by phone if you want to be sure.": "Emin olmak istiyorsanız bunu kişiyle telefonda karşılaştırın.", + "Shared. {account} can accept it in their own vault.": "Paylaşıldı. {account} bunu kendi kasasında kabul edebilir.", + "The certificate could not be verified. Nothing was shared.": "Sertifika doğrulanamadı. Hiçbir şey paylaşılmadı.", + "That organisation is not one of your partners.": "Bu kuruluş ortaklarınızdan biri değil.", + "No one with that account can receive secrets from you.": "Bu hesaba sahip hiç kimse sizden gizli alamaz.", + "The other organisation did not answer. Try again later.": "Diğer kuruluş yanıt vermedi. Daha sonra yeniden deneyin.", + "This secret is already shared with that account.": "Bu gizli zaten bu hesapla paylaşılmış.", + "Other organisations": "Diğer kuruluşlar", + "Receive secrets from other organisations": "Diğer kuruluşlardan gizli al", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Ortak kuruluşlardaki kişiler böylece hesabınızı bulabilir ve sizinle gizli paylaşabilir. Her birini kendiniz kabul edersiniz.", + "Shared": "Paylaşıldı", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Duraklatıldı: alıcının sertifikası veya ortaklık değişti. İptal edin veya yeniden paylaşın.", + "Their organisation did not get the last change. Revoke it or share again.": "Alıcının kuruluşu son değişikliği almadı. İptal edin veya yeniden paylaşın.", + "Being withdrawn": "Geri çekiliyor", + "Shared with another organisation": "Başka bir kuruluşla paylaşıldı", + "Change sent to another organisation": "Değişiklik başka bir kuruluşa gönderildi", + "Share with another organisation revoked": "Başka bir kuruluşla paylaşım kaldırıldı", + "Share with another organisation paused": "Başka bir kuruluşla paylaşım duraklatıldı", + "Another organisation did not get a change": "Başka bir kuruluş bir değişikliği almadı", + "Secret received from another organisation": "Başka bir kuruluştan gizli alındı", + "Secret from another organisation accepted": "Başka bir kuruluştan gelen gizli kabul edildi", + "Secret from another organisation declined": "Başka bir kuruluştan gelen gizli reddedildi", + "Copy from another organisation updated": "Başka bir kuruluştan gelen kopya güncellendi", + "Copy from another organisation removed": "Başka bir kuruluştan gelen kopya kaldırıldı", + "Declined: they removed their copy. Share again if they need it.": "Reddedildi: alıcı kendi kopyasını kaldırdı. Gerekirse yeniden paylaşın.", + "Recipient at another organisation removed their copy": "Başka bir kuruluştaki alıcı kendi kopyasını kaldırdı", + "Removed the user from %n team folder.": "Kullanıcı %n takım klasöründen çıkarıldı.", + "Removed the user from %n team folders.": "Kullanıcı %n takım klasöründen çıkarıldı.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Kullanıcı %n takım klasöründen çıkarıldı."], + "A restored copy came from a share that has ended. It stays read-only.": "Geri yüklenen bir kopya sona ermiş bir paylaşımdan geliyor. Salt okunur kalır.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Geri yüklenen bir kopyayı paylaşan kuruluşa ulaşılamadı. Kopya salt okunur kalır ve onların değişikliklerini izlemez.", + "Recipient at another organisation restored their copy": "Başka bir kuruluştaki alıcı kendi kopyasını geri yükledi" }, - "nplurals=2; plural=(n != 1);" + "nplurals=1; plural=0;" ) diff --git a/l10n/tr.json b/l10n/tr.json index c9aa7aa0e..607b92de9 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1181,7 +1181,542 @@ "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." + "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.", + "Shared with groups": "Gruplarla paylaşıldı", + "Not shared with any group yet.": "Henüz hiçbir grupla paylaşılmadı.", + "Revoke the share with {group}": "{group} ile paylaşımı geri al", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "{group} ile paylaşıldı: {received} üye aldı, {skipped} üye henüz şifreleme ayarlamadığı için almadı.", + "Search groups": "Grup ara", + "Failed to share": "Paylaşılamadı", + "Columns": "Sütunlar", + "Column {number}": "Sütun {number}", + "Map one column to Name. Every secret needs a name.": "Bir sütunu Ad alanına eşleyin. Her gizli bilginin bir adı olmalı.", + "Notes": "Notlar", + "Do not import": "İçe aktarma", + "Hide this value": "Bu değeri gizle", + "Show this value": "Bu değeri göster", + "Defaults": "Varsayılanlar", + "New secrets start as this type, and your secret list opens in this view.": "Yeni sırlar bu türle başlar ve sır listeniz bu görünümde açılır.", + "Default item type": "Varsayılan öğe türü", + "Cards": "Kartlar", + "Table": "Tablo", + "Could not save your default": "Varsayılanınız kaydedilemedi", + "Recently used": "Son kullanılanlar", + "Opened": "Açıldı", + "You have not opened any secrets yet": "Henüz hiçbir sır açmadınız", + "Could not delete the item type.": "Öğe türü silinemedi.", + "Could not load the item types.": "Öğe türleri yüklenemedi.", + "Could not save the item type.": "Öğe türü kaydedilemedi.", + "Delete item type": "Öğe türünü sil", + "Edit item type": "Öğe türünü düzenle", + "Fields": "Alanlar", + "Fields: {count}": "Alanlar: {count}", + "Hidden": "Gizli", + "Item types": "Öğe türleri", + "Move up": "Yukarı taşı", + "New item type": "Yeni öğe türü", + "No item types defined yet.": "Henüz tanımlı öğe türü yok.", + "Required": "Zorunlu", + "Text": "Metin", + "This field is required": "Bu alan zorunludur", + "Web address": "Web adresi", + "{label} (required)": "{label} (zorunlu)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "“{name}” silinsin mi? Bu türdeki sırlar okunabilir kalır ve Giriş öğesi olur.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Burada tanımladığınız öğe türleri, seçtiğiniz alanlarla herkesin Yeni gizli penceresinde görünür.", + "Secret moved to the trash": "Gizli bilgi çöp kutusuna taşındı", + "Secret restored from the trash": "Gizli bilgi çöp kutusundan geri yüklendi", + "Secret deleted for good": "Gizli bilgi kalıcı olarak silindi", + "Secret archived": "Gizli bilgi arşivlendi", + "Secret unarchived": "Gizli bilgi arşivden çıkarıldı", + "Unarchive": "Arşivden çıkar", + "Could not archive the secret": "Gizli bilgi arşivlenemedi", + "Could not unarchive the secret": "Gizli bilgi arşivden çıkarılamadı", + "Archive {count} secrets": "Gizli bilgileri arşivle: {count}", + "Unarchive {count} secrets": "Gizli bilgileri arşivden çıkar: {count}", + "Restore {count} secrets": "Gizli bilgileri geri yükle: {count}", + "Delete {count} secrets for good": "Gizli bilgileri kalıcı olarak sil: {count}", + "Done for {ok} of {total} secrets": "{total} gizli bilginin {ok} tanesi tamamlandı", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Arşivlenen gizli bilgiler kasa listesinden, aramadan, otomatik doldurmadan ve sağlık raporundan çıkar. Paylaşımları korunur. Onları Arşiv altında bulursunuz.", + "These secrets come back to the vault list, search and autofill.": "Bu gizli bilgiler kasa listesine, aramaya ve otomatik doldurmaya geri döner.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Bu gizli bilgiler kasa listesine geri döner. Eski paylaşımları geri gelmez, bu yüzden gereken yerde yeniden paylaşın.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Bu işlem gizli bilgileri ekleri ve sürüm geçmişiyle birlikte siler. Geri alınamaz.", + "Delete for good": "Kalıcı olarak sil", + "Trash": "Çöp kutusu", + "The trash is empty": "Çöp kutusu boş", + "No archived secrets": "Arşivlenmiş gizli bilgi yok", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Silinen gizli bilgiler saklama süresi bitene kadar burada bekler, sonra kalıcı olarak silinir.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Bir gizli bilgiyi kasa listesi, arama ve otomatik doldurma dışında tutmak için ayrıntı panelinden arşivleyin.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Şifreli ekler için sınırlar (sunucuda saklanan şifreli baytlar üzerinde uygulanır), sürüm geçmişinin saklanması ve silinen gizli bilgilerin çöp kutusunda ne kadar kalacağı.", + "Days a deleted secret stays in the trash (1 to 365)": "Silinen bir gizli bilginin çöp kutusunda kaldığı gün sayısı (1 ile 365 arası)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Bu işlem gizli bilgiyi çöp kutusuna taşır ve paylaşımlarını hemen sonlandırır. Saklama süresi bitene kadar çöp kutusundan geri yükleyebilirsiniz: yöneticiniz değiştirmediyse 30 gün.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Bu işlem gizli bilgileri çöp kutusuna taşır ({count}) ve paylaşımlarını hemen sonlandırır. Saklama süresi bitene kadar onları çöp kutusundan geri yükleyebilirsiniz.", + "Remove {name} from favourites": "{name} öğesini sık kullanılanlardan kaldır", + "Add {name} to favourites": "{name} öğesini sık kullanılanlara ekle", + "Could not change the favourite": "Sık kullanılan değiştirilemedi", + "Remove from favourites": "Sık kullanılanlardan kaldır", + "Add to favourites": "Sık kullanılanlara ekle", + "Tags": "Etiketler", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Etiketler şifrelenmez. Sunucu yöneticileri, klasör adları gibi bunları da okuyabilir.", + "Favourites": "Sık kullanılanlar", + "Filter by tag": "Etikete göre süz", + "All tags": "Tüm etiketler", + "Last used": "Son kullanım", + "Tags for {count} secrets": "{count} gizli bilgi için etiketler", + "Tag": "Etiket", + "Remove tag": "Etiketi kaldır", + "Add tag": "Etiket ekle", + "Could not change the tags. Try again.": "Etiketler değiştirilemedi. Yeniden deneyin.", + "Could not approve the application. It is still in the queue.": "Başvuru onaylanamadı. Hâlâ kuyrukta.", + "Could not reject the application. It is still in the queue.": "Başvuru reddedilemedi. Hâlâ kuyrukta.", + "Removed the user from {count} team folders.": "Kullanıcı {count} takım klasöründen çıkarıldı.", + "Approve a share": "Bir paylaşımı onayla", + "This approval link is incomplete. Open it again from the notification.": "Bu onay bağlantısı eksik. Bildirimden yeniden açın.", + "Deny": "Reddet", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user}, bir sırrı paylaştığınız bir gruba katıldı. Sır onunla da paylaşılsın mı?", + "{requester} asks you to share a secret with {user}.": "{requester}, bir sırrı {user} ile paylaşmanızı istiyor.", + "Shared. The recipient can now open the secret.": "Paylaşıldı. Alıcı artık sırrı açabilir.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Alıcı henüz Keepiq kurulumunu yapmadı, bu nedenle hiçbir şey paylaşılmadı. Kurulumu yaptığında yeniden deneyin.", + "Could not share the secret. Only its owner can approve this.": "Sır paylaşılamadı. Bunu yalnızca sahibi onaylayabilir.", + "Could not share the secret. Try again.": "Sır paylaşılamadı. Yeniden deneyin.", + "Denied. Nothing was shared.": "Reddedildi. Hiçbir şey paylaşılmadı.", + "Could not deny the request. Try again.": "İstek reddedilemedi. Yeniden deneyin.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s, \"%2$s\" sırrını %3$s ile paylaşmanızı istiyor.", + "Expires on (optional)": "Sona erme tarihi (isteğe bağlı)", + "Hand over to": "Şu kişiye devret", + "Choose a recipient": "Bir alıcı seçin", + "Hand over temporarily": "Geçici olarak devret", + "Expiry rules": "Sona erme kuralları", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Bir öge türündeki ya da bir klasördeki parolaların ne kadar süre geçerli olacağını ve ne zaman hatırlatılacağını belirleyin. Birden fazla tarih geçerli olduğunda en erken olanı sayılır.", + "Delete rule": "Kuralı sil", + "Set by your administrator": "Yöneticiniz tarafından ayarlandı", + "No expiry rules yet.": "Henüz sona erme kuralı yok.", + "Applies to": "Uygulandığı yer", + "Item type": "Öge türü", + "Maximum age in days (empty for reminders only)": "Gün olarak en uzun süre (yalnızca hatırlatmalar için boş bırakın)", + "Remind me this many days before, comma separated": "Kaç gün önce hatırlatılsın, virgülle ayırın", + "Save rule": "Kuralı kaydet", + "An item type": "Bir öge türü", + "A folder": "Bir klasör", + "Folder {name}": "{name} klasörü", + "Type {name}": "{name} türü", + "Expires after {days} days": "{days} gün sonra sona erer", + "Reminders {days} days before": "{days} gün önce hatırlatma", + "Could not save the expiry rule.": "Sona erme kuralı kaydedilemedi.", + "Could not delete the expiry rule.": "Sona erme kuralı silinemedi.", + "All statuses": "Tüm durumlar", + "Compromised": "Ele geçirilmiş", + "Could not load the members.": "Üyeler yüklenemedi.", + "Emergency contact": "Acil durum kişisi", + "Leaving user": "Ayrılan kullanıcı", + "No": "Hayır", + "No users match this filter.": "Bu filtreyle eşleşen kullanıcı yok.", + "Not set up": "Kurulmadı", + "Revoke suite": "Paketi iptal et", + "Revoked": "İptal edildi", + "Search users": "Kullanıcı ara", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Hangi kullanıcıların kasa kurduğunu görün. Bir satırdan ayrılış işlemini başlatın veya bir paketi iptal edin.", + "Successor": "Halef", + "Team folders": "Ekip klasörleri", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Kullanıcı hâlâ bir ekip klasörünün üyesi olan {groups} grubunda. Kullanıcıyı gruptan çıkarın veya hesabı devre dışı bırakın.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Kullanıcı hâlâ ekip klasörlerinin üyesi olan {groups} gruplarında. Kullanıcıyı gruplardan çıkarın veya hesabı devre dışı bırakın.", + "Vault status": "Kasa durumu", + "Yes": "Evet", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "CXF dışa aktarımı ŞİFRELENMEMİŞTİR. İndirilen dosyada her parola ve kullanıcı adı düz metin olarak okunabilir olacak. Dosyayı güvenli biçimde saklayın ve kullandıktan hemen sonra silin.", + "Root certificate expiring soon": "Kök sertifikanın süresi yakında doluyor", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Kasanın kök sertifikasının süresi %1$d gün içinde doluyor. Bundan önce yenileyin. Yenileme her şifreleme paketini yeniden imzalar.", + "Compromise recovery aborted": "Ele geçirilme sonrası kurtarma iptal edildi", + "Key rotation ended by a compromise revoke": "Anahtar değişimi, ele geçirilme nedeniyle yapılan bir iptalle sonlandırıldı", + "Encryption suite revoke refused": "Şifreleme paketi iptali reddedildi", + "Master password proof refused": "Ana parola kanıtı reddedildi", + "Your current master password": "Geçerli ana parolanız", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n acil durum kişisinin, anahtar değişimi onu kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "%n acil durum kişisinin, anahtar değişimi onları kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Bu acil durum kişileri yeni anahtarınıza aktarılmadı. Acil durum erişimleri kaldırıldı. Hâlâ istiyorsanız Acil durum erişimi bölümünden yeniden ekleyin.", + "Renew root certificate": "Kök sertifikayı yenile", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Bu, yeni bir kök ve ara sertifika oluşturur. Her etkin şifreleme paketi yeniden imzalanır. Bu işlem geri alınamaz.", + "Renew root": "Kökü yenile", + "Root renewed. {n} encryption suites signed again.": "Kök yenilendi. Yeniden imzalanan şifreleme paketi: {n}.", + "Could not renew the root certificate.": "Kök sertifika yenilenemedi.", + "Lease policy for this application": "Bu uygulama için kiralama ilkesi", + "In force now: {default} seconds by default, {max} seconds at most.": "Şu an geçerli: varsayılan {default} saniye, en fazla {max} saniye.", + "Leases are not renewable": "Kiralamalar yenilenemez", + "Lease policy saved.": "Kiralama ilkesi kaydedildi.", + "Leave a field empty to use the instance value.": "Örnek değerini kullanmak için bir alanı boş bırakın.", + "Instance value: {value}": "Örnek değeri: {value}", + "Renewal": "Yenileme", + "Use the instance value ({value})": "Örnek değerini kullan ({value})", + "Allowed": "İzin verildi", + "Not allowed": "İzin verilmedi", + "Save lease policy": "Kiralama ilkesini kaydet", + "Only an administrator can change this policy.": "Bu ilkeyi yalnızca bir yönetici değiştirebilir.", + "Could not save the lease policy.": "Kiralama ilkesi kaydedilemedi.", + "{member} got access from {confirmer}.": "{member}, {confirmer} tarafından erişim aldı.", + "Automatically confirm new team folder members": "Yeni ekip klasörü üyelerini otomatik onayla", + "Gave %n new member access to a team folder.": "%n yeni üye bir ekip klasörüne erişim aldı.", + "Gave %n new members access to a team folder.": "%n yeni üye bir ekip klasörüne erişim aldı.", + "Give new team folder members access without waiting for the folder owner.": "Yeni ekip klasörü üyelerine klasör sahibini beklemeden erişim verin.", + "New team folder members": "Yeni ekip klasörü üyeleri", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Sahip veya yazma izni olan bir üye onları açık kasasından onaylar. Keepiq sunucuda asla şifre çözmez.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Yazma izni olan bir üyenin Keepiq'i açması bekleniyor. Şimdi de paylaşabilirsiniz.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Güvenlik ihlali yanıtının bir kısmı başarısız oldu ({failed} adım). Sunucu günlüğünü kontrol edin, ardından tamamlamak için paketi yeniden iptal edin.", + "This also revoked suite {suite} and ended key migration {migration}.": "Bu işlem {suite} paketini de iptal etti ve {migration} anahtar taşımasını sonlandırdı.", + "Revoking the second suite deleted %n emergency-access contact.": "İkinci paketin iptali %n acil erişim kişisini sildi.", + "Revoking the second suite deleted %n emergency-access contacts.": "İkinci paketin iptali %n acil erişim kişisini sildi.", + "A suite revoked as compromised cannot be reinstated.": "İhlal edilmiş olarak iptal edilen bir paket geri yüklenemez.", + "Archives to keep": "Saklanacak arşivler", + "Back up every vault automatically": "Her kasayı otomatik yedekle", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Her kasayı bir zamanlamayla yedekleyin. Arşivler yalnızca şifreli metin içerir ve occ ile geri yüklenir.", + "Back up now": "Şimdi yedekle", + "Backup public key (PEM, optional)": "Yedek ortak anahtarı (PEM, isteğe bağlı)", + "Backup requested for the next cron run": "Yedek bir sonraki cron çalışması için istendi", + "Encrypted": "Şifreli", + "Every (hours)": "Her (saat)", + "Last backup {when} failed: {error}": "Son yedek {when} başarısız: {error}", + "Last backup {when} succeeded.": "Son yedek {when} başarılı.", + "No archives yet.": "Henüz arşiv yok.", + "Size": "Boyut", + "Vault backups": "Kasa yedekleri", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "Bir anahtarla her arşiv ona göre şifrelenir. Özel anahtarı bu sunucunun dışında tutun: doğrulamak veya geri yüklemek için gerekir.", + "Written": "Yazıldı", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamaz.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamazlar.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Yedek kodlar sayılmaz. Kullanıcılarınız kendi ikinci faktörü olan bir kimlik sağlayıcıyla giriş yapıyorsa gruplarını dışarıda bırakın.", + "Block personal vault export": "Kişisel kasa dışa aktarımını engelle", + "Keep work logins in team folders": "İş girişlerini ekip klasörlerinde tut", + "Move to a team folder": "Bir ekip klasörüne taşı", + "Not in a team folder": "Bir ekip klasöründe değil", + "Only for these groups (empty is everyone)": "Yalnızca bu gruplar için (boş herkes demektir)", + "Require two-factor login before the vault opens": "Kasa açılmadan önce iki adımlı giriş iste", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Her kasa için kurallar. Her biri herkese ya da yalnızca seçtiğiniz gruplara uygulanır.", + "Secret types that belong in a team folder": "Bir ekip klasörüne ait gizli bilgi türleri", + "Set up two-factor login": "İki adımlı girişi ayarla", + "Team folder you can write to": "Yazabileceğiniz ekip klasörü", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Kullanıcılar yedek, CSV veya aktarım dosyası indiremez. Kişisel veri paketleri kullanılabilir kalır.", + "Users cannot save these secret types in a personal folder.": "Kullanıcılar bu gizli bilgi türlerini kişisel bir klasöre kaydedemez.", + "Vault policies": "Kasa kuralları", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Kuruluşunuz kişisel kasanızın dışa aktarılmasına izin vermiyor. Ayarlarınızdaki kişisel veri paketiniz kullanılabilir kalır.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Kuruluşunuz bu gizli bilgileri bir ekip klasöründe tutar. Her birini bir ekip klasörüne taşıyın.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Kuruluşunuz bu tür gizli bilgiyi bir ekip klasöründe tutar. Ekip klasörlerinizden birini ya da yazabileceğiniz birini seçin.", + "Your organisation requires two-factor login before you can open your vault.": "Kuruluşunuz kasanızı açabilmeniz için iki adımlı giriş istiyor.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Kullanıcılar, uzantının boşta kaldığında ne kadar süre kilidi açık kalacağını seçer. Seçebilecekleri en uzun süreyi siz belirlersiniz.", + "Longest idle time before the extension locks": "Uzantı kilitlenmeden önceki en uzun boşta kalma süresi", + "1 minute": "1 dakika", + "5 minutes": "5 dakika", + "15 minutes": "15 dakika", + "1 hour": "1 saat", + "4 hours": "4 saat", + "Connector": "Bağlayıcı", + "Directory (tenant) ID": "Dizin (kiracı) kimliği", + "Application (client) ID": "Uygulama (istemci) kimliği", + "Data collection rule immutable ID": "Veri toplama kuralının değişmez kimliği", + "Stream name": "Akış adı", + "Splunk index (optional)": "Splunk dizini (isteğe bağlı)", + "Sourcetype (optional)": "Sourcetype (isteğe bağlı)", + "Leave blank to keep the current one": "Mevcut olanı korumak için boş bırakın", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "Syslog üzerinden CEF", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Veri toplama uç noktası (https URL)", + "HTTP Event Collector URL (https)": "HTTP Event Collector URL'si (https)", + "Client secret (write-only)": "İstemci gizli anahtarı (yalnızca yazma)", + "HEC token (write-only)": "HEC belirteci (yalnızca yazma)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "İzin verilen denetim olaylarını Splunk'a, Microsoft Sentinel'e, bir syslog alıcısına veya bir webhook'a iletin. İletiler yalnızca temizlenmiş meta veriler taşır: hiçbir gizli değer, ad, oturum açma bilgisi veya şifreli metin sunucudan asla çıkmaz.", + "%n change waiting to sync": "%n değişiklik eşitlenmeyi bekliyor", + "%n changes waiting to sync": "%n değişiklik eşitlenmeyi bekliyor", + "Changes that could not sync": "Eşitlenemeyen değişiklikler", + "Choose a version": "Bir sürüm seçin", + "Copy value": "Değeri kopyala", + "Deleted": "Silindi", + "Discard": "At", + "Keep my offline change": "Çevrim dışı değişikliğimi koru", + "Keep the server version": "Sunucu sürümünü koru", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Keepiq çevrim dışıyken salt okunurdur. Yöneticiniz çevrim dışı düzenlemeyi açmadı.", + "Let users edit secrets offline": "Kullanıcıların sırları çevrim dışı düzenlemesine izin ver", + "Not synced yet": "Henüz eşitlenmedi", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Çevrim dışı değişiklikler cihazda, kullanıcıya şifrelenmiş olarak kalır ve bir sonraki çevrim içi kilit açmada eşitlenir. Paylaşım, klasörler ve ekler için hâlâ bağlantı gerekir.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Çevrim dışı. Düzenlemeler, taşımalar ve silmeler bu cihazda kalır ve yeniden çevrim içi olduğunuzda eşitlenir. Paylaşım ve ekler için bağlantı gerekir.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Çevrim dışı. Değişiklikleriniz bu cihazda kalır ve yeniden çevrim içi olduğunuzda eşitlenir. Son eşitleme {when}.", + "Open my changes": "Değişikliklerimi aç", + "Sharing needs a connection": "Paylaşım için bağlantı gerekir", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Çevrim dışı kopyanız oluşturulduktan sonra biri bu sırrı sunucuda değiştirdi. Hangi sürümü koruyacağınızı seçin.", + "Sync or discard your offline changes before you rotate your keys.": "Anahtarlarınızı değiştirmeden önce çevrim dışı değişikliklerinizi eşitleyin ya da atın.", + "That password did not open your changes.": "Bu parola değişikliklerinizi açmadı.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Çevrim dışı anlık görüntü şifreli sırları saklar (yalnızca kullanıcının ana parolasından türetilen anahtarla açılabilir, tıpkı sunucudaki gibi) ve adları, URL'leri ve klasör adlarını depolamada şifreler. Aşağıda çevrim dışı düzenlemeye izin vermezseniz çevrim dışı erişim salt okunurdur. Kimlik bilgilerini asla önbelleğe almaması gereken cihazlar için bunu kapatın; kapatmak, var olan önbellekleri bir sonraki yüklemede temizler.", + "The previous vault copy is gone, so these changes cannot be opened.": "Kasanın önceki kopyası artık yok, bu yüzden bu değişiklikler açılamıyor.", + "The server version": "Sunucu sürümü", + "This secret changed while you were offline": "Siz çevrim dışıyken bu sır değişti", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Bu sırrı çevrim dışıyken sildiniz, ancak o zamandan beri sunucuda değiştirildi. Hangi sürümü koruyacağınızı seçin.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Anahtarlarınız başka bir cihazda değiştirildi. Çevrim dışı değişikliklerinizi eşitlemek için önceki ana parolanızı girin ya da onları atın.", + "Your offline change": "Çevrim dışı değişikliğiniz", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n acil durum kişisinin, anahtar değişimi onu kaldırdığında bekleyen bir erişim isteği vardı. Birini yeniden eklemeden önce kimin istediğini kontrol edin." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n öğe CXF içinde gösterilemiyor ve atlanacak." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "Yalnızca son geçmiş taşınabildiği için %n eski sürüm atıldı." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "%n gizli kopyasının hâlâ şifrelenip paylaşılması gerekiyor." + ], + "_%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 gizli bilginin şifresi çözülemedi ve bu dışa aktarımda yer almıyor." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n sır eski anahtarınızla şifresi çözülemedi, bu yüzden taşınmadı." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n sır taşınmadı." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n sır hâlâ önceki anahtarınızla şifreli." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "Ardıl henüz bir kopyaya sahip olmadığı için %n gizli atlandı — ardılı klasöre ekleyin ve yeniden çalıştırın." + ], + "_%n secret_::_%n secrets_": [ + "%n gizli" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "Kapsamdaki %n kullanıcının henüz iki adımlı girişi yok ve bu açıkken kasayı açamaz." + ], + "_Emergency access for %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 contact could not be carried across and was removed. Re-establish it so they can still recover your vault." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Yine de bitir, %n sırra erişimi kaybederek" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n yeni üye bir ekip klasörüne erişim aldı." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Anahtar döndürme bitti. %n sır yeni anahtarınızla yeniden şifrelendi." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "İkinci paketin iptali %n acil erişim kişisini sildi." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Bu paketin iptali %n acil durum erişim kişisini sildi." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "sızıntılarda %n kez görüldü" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "%n gizliyle paylaşıldı" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Bu klasör doğrudan %n gizli içeriyor." + ], + "_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._": [ + "Anahtar döndürmeniz %n acil durum kişisini kaldırdı. Acil durum erişimini kontrol edin ve hâlâ istiyorsanız yeniden ekleyin." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n değişiklik eşitlenmeyi bekliyor" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Kullanıcı hâlâ bir ekip klasörünün üyesi olan {groups} grubunda. Kullanıcıyı gruptan çıkarın veya hesabı devre dışı bırakın." + ], + "Allow approval from another device": "Başka bir cihazdan onaya izin ver", + "App": "Uygulama", + "Approve a new device": "Yeni bir cihazı onayla", + "Approve from another device": "Başka bir cihazdan onayla", + "Asked at": "İstenme zamanı", + "Check that the new device shows these words:": "Yeni cihazın şu kelimeleri gösterdiğini kontrol edin:", + "Denied. If you did not ask, end your other sessions:": "Reddedildi. Bunu siz istemediyseniz diğer oturumlarınızı sonlandırın:", + "Device": "Cihaz", + "IP address": "IP adresi", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Kullanıcıların yeni bir tarayıcının kilidini, Keepiq'in zaten açık olduğu bir cihazdan onaylayarak açmasına izin verin.", + "New device approval": "Yeni cihaz onayı", + "Nextcloud security settings": "Nextcloud güvenlik ayarları", + "Only approve a device you are using right now.": "Yalnızca şu anda kullandığınız bir cihazı onaylayın.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Keepiq'i kilidinin açık olduğu bir cihazda açın ve bu cihazı onaylayın. Aynı kelimeleri gösterdiğini kontrol edin:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Onaylayan cihaz, kilit açma anahtarını yeni cihaz için mühürler. Sunucu yalnızca anahtarı iletir ve açamaz.", + "The master password is not right, or the request has ended.": "Ana parola doğru değil veya istek sona erdi.", + "The request expired. Ask again or use your master password.": "İsteğin süresi doldu. Yeniden isteyin veya ana parolanızı kullanın.", + "The request was denied.": "İstek reddedildi.", + "Too many requests. Try again in an hour or use your master password.": "Çok fazla istek. Bir saat sonra yeniden deneyin veya ana parolanızı kullanın.", + "Unknown device": "Bilinmeyen cihaz", + "Web app": "Web uygulaması", + "A device": "Bir cihaz", + "A new device asks to open your vault": "Yeni bir cihaz kasanızı açmak istiyor", + "%s asks to be approved. Only approve a device you are using right now.": "%s onay istiyor. Yalnızca şu anda kullandığınız bir cihazı onaylayın.", + "Access ends on (optional)": "Erişimin bitiş tarihi (isteğe bağlı)", + "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.": "Keepiq uygulamaları parolayı göstermez veya kopyalamaz. Teknik bilgisi olan biri yine de parolayı kendi cihazından okuyabilir. Erişimi sona erdiğinde parolayı değiştirin.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Bu gizli bilgi yalnızca kullanım içindir. Keepiq tarayıcı eklentisi ile oturum açın.", + "Until {date}": "{date} tarihine kadar", + "Use only": "Yalnızca kullanım", + "Use only (can sign in, cannot view or copy)": "Yalnızca kullanım (oturum açabilir, göremez veya kopyalayamaz)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Bu oturum açma bilgisiyle Keepiq tarayıcı eklentisi üzerinden oturum açabilirsiniz. Sahibi, bilgiyi görmenize veya kopyalamanıza izin vermemeyi seçti.", + "Your access ends on {date}": "Erişiminiz {date} tarihinde sona eriyor", + "Your access to this secret has ended": "Bu gizli bilgiye erişiminiz sona erdi", + "Your access to \"%s\" ends tomorrow": "\"%s\" erişiminiz yarın sona eriyor", + "Your access to \"%s\" has ended": "\"%s\" erişiminiz sona erdi", + "%1$s no longer has access to \"%2$s\"": "%1$s artık \"%2$s\" erişimine sahip değil", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s bu parolayı görebiliyordu. %1$s artık bilmemesi gerekiyorsa parolayı değiştirin.", + "%s could not view this password in Keepiq.": "%s bu parolayı Keepiq içinde göremedi.", + "{approvals} of {threshold} approvals": "{approvals}/{threshold} onay", + "a recovery officer": "bir kurtarma yetkilisi", + "Account recovery": "Hesap kurtarma", + "Approvals needed": "Gereken onaylar", + "Ask {user} which words they see, by phone or in person. They must be:": "{user} kullanıcısına telefonla veya yüz yüze hangi kelimeleri gördüğünü sorun. Şunlar olmalı:", + "Check again": "Yeniden denetle", + "Create the recovery key": "Kurtarma anahtarı oluştur", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Kurtarma anahtarını oluşturun. Tarayıcınız onu üretir ve her yetkiliye yalnızca onun açabileceği bir kopya verir.", + "Decline": "Reddet", + "Enrol in account recovery": "Hesap kurtarmaya kaydol", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Ana parolanızı unutursanız kuruluşunuzun kasanızı geri almanıza yardım edebilmesi için kaydolun.", + "Every user is enrolled": "Tüm kullanıcılar kayıtlı", + "Finish the recovery in the browser you asked from.": "Kurtarmayı, istediğiniz tarayıcıda tamamlayın.", + "Forgot your master password?": "Ana parolanızı mı unuttunuz?", + "Hand the key over": "Anahtarı teslim et", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Ana parolasını unutan kullanıcıların, atadığınız kurtarma yetkililerinin onayıyla kasalarını geri almasına izin verin.", + "New master password": "Yeni ana parola", + "No one is asking to recover their account.": "Hesabını kurtarmak isteyen kimse yok.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Henüz kurtarma anahtarı yok. Yetkililerden biri onu kendi Keepiq ayarlarında oluşturur.", + "Off": "Kapalı", + "Officer {user} has no encryption set up yet.": "{user} yetkilisi henüz şifrelemeyi ayarlamadı.", + "Officers (user IDs, separated by commas)": "Yetkililer (kullanıcı kimlikleri, virgülle ayrılmış)", + "Policy": "İlke", + "Publish this fingerprint internally, so users can check it before they enrol.": "Kullanıcıların kaydolmadan önce denetleyebilmesi için bu parmak izini kurum içinde yayınlayın.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "{officer} yardımıyla kurtarıldı. Kasa anahtarınızı şimdi Ayarlar, Güvenlik bölümünden değiştirin: \"Ana parolam ele geçirildi\".", + "Recovery key fingerprint: {fingerprint}": "Kurtarma anahtarı parmak izi: {fingerprint}", + "Recovery officer": "Kurtarma yetkilisi", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Kaldırılan yetkililer kopyalarını şimdi kaybeder, ancak daha önce açmış olabilirler. Bir yetkiliden yeni bir kurtarma anahtarı oluşturmasını isteyin.", + "Repeat the new master password": "Yeni ana parolayı tekrarlayın", + "Retire this recovery key": "Bu kurtarma anahtarını kullanımdan kaldır", + "Set the new master password": "Yeni ana parolayı belirle", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Kurtarma sertifikası bu Keepiq tarafından verilmemiş. Kaydolmayın ve yöneticinize bildirin.", + "The words match, approve": "Kelimeler eşleşiyor, onayla", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Bu kullanıcı hesap kurtarmaya kayıtlı. Kurtarma sırlarını korur; iptal etmek kaydını siler.", + "Users may enrol": "Kullanıcılar kaydolabilir", + "Withdraw from account recovery": "Hesap kurtarmadan çekil", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Hesap kurtarmaya kayıtlısınız. Kurtarma anahtarı parmak izi: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Kayıtlısınız. Ana parolanızı unutursanız kuruluşunuz kasanızı geri almanıza yardım edebilir.", + "Your key is back. Choose a new master password.": "Anahtarınız geri geldi. Yeni bir ana parola seçin.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Kurtarma yetkililerinize haber verildi. Sizi aradıklarında veya buluştuğunuzda onlara şu kelimeleri okuyun:", + "You are now an account recovery officer": "Artık bir hesap kurtarma yetkilisisiniz", + "%s asks to recover their account. Compare the words with them before you approve.": "%s hesabını kurtarmak istiyor. Onaylamadan önce kelimeleri onunla karşılaştırın.", + "A user": "Bir kullanıcı", + "Your account recovery request was declined": "Hesap kurtarma isteğiniz reddedildi", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Hesap kurtarmanız hazır. Keepiq'i istediğiniz tarayıcıda açın.", + "{user} asks to unlock a new device once. They keep their master password.": "{user}, yeni bir cihazın kilidinin bir kez açılmasını istiyor. Ana parola aynı kalır.", + "Ask your organisation instead": "Bunun yerine kuruluşunuza sorun", + "The request ended. Ask again or use your master password.": "İstek sona erdi. Yeniden isteyin ya da ana parolanızı kullanın.", + "Added by {user}": "{user} tarafından eklendi", + "Editor": "Düzenleyici", + "Manager": "Yönetici", + "Role of {member}": "{member} rolü", + "Team folders you manage": "Yönettiğiniz ekip klasörleri", + "Viewer": "Görüntüleyici", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "Bu gizli bilgilerin bir kopyası sizde yok, bu yüzden yeni üyeler henüz bunları almadı. Sahibi bunları paylaşabilir: {names}", + "Admin areas": "Yönetim alanları", + "Give a group only the parts of Keepiq administration it needs.": "Bir gruba Keepiq yönetiminin yalnızca ihtiyaç duyduğu bölümlerini verin.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Yönetim yetkileri sayfasında bir gruba bir veya daha fazla alan devredin. Örnek yöneticileri her alana sahiptir.", + "Open administration privileges": "Yönetim yetkilerini aç", + "Policies": "İlkeler", + "Applications and machine access": "Uygulamalar ve makine erişimi", + "People and offboarding": "Kişiler ve ayrılanlar", + "Audit and compliance": "Denetim ve uyumluluk", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "sürüm, sertifika yetkilisi, ekler, çevrimdışı önbellek, sızıntı denetimi, gizli bilgi türleri ve yedekler", + "master password, organisation password, vault policies, rotation, version history and trash": "ana parola, kuruluş parolası, kasa ilkeleri, rotasyon, sürüm geçmişi ve çöp kutusu", + "application queue, application requests and machine leases": "uygulama kuyruğu, uygulama istekleri ve makine kiralamaları", + "team offboarding, encryption suites and admin handover": "ekipten ayrılanlar, şifreleme paketleri ve yönetici devralması", + "audit log, compliance reports, SIEM export and honey alerts": "denetim günlüğü, uyumluluk raporları, SIEM dışa aktarımı ve tuzak uyarıları", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Bir gizli bilginin kaç sürümünün ne kadar süre saklandığı ve silinen gizli bilgilerin çöp kutusunda ne kadar kaldığı.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Şifreli ekler için sınırlar; sunucu bunları depolanan şifreli baytlarda uygular.", + "Type the suite ID again to confirm": "Onaylamak için paket kimliğini yeniden yazın", + "This does not match the suite ID.": "Bu, paket kimliğiyle eşleşmiyor.", + "Confirm with your master password": "Ana parolanızla onaylayın", + "Confirm": "Onayla", + "That master password is not right.": "Bu ana parola doğru değil.", + "You are sharing with someone new. Enter your master password to confirm.": "Yeni biriyle paylaşıyorsunuz. Onaylamak için ana parolanızı girin.", + "Enter your master password to confirm this share.": "Bu paylaşımı onaylamak için ana parolanızı girin.", + "Enter your master password to confirm this delegation.": "Bu yetki devrini onaylamak için ana parolanızı girin.", + "Approve {member}": "{member} onayla", + "Recipient": "Alıcı", + "No vault yet": "Henüz kasası yok", + "No matching users": "Eşleşen kullanıcı yok", + "Partner organisations": "Ortak kuruluşlar", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Başka bir Keepiq ile sır paylaşın. İki yönetici de birbirini ekler ve kaydetmeden önce kök parmak izlerini telefonda veya yüz yüze karşılaştırır.", + "Federation needs Nextcloud 33 or later.": "Federasyon için Nextcloud 33 veya üstü gerekir.", + "Your root fingerprint": "Kök parmak iziniz", + "No partners yet.": "Henüz ortak yok.", + "Users here may share to this partner": "Buradaki kullanıcılar bu ortakla paylaşabilir", + "This partner may share to users here": "Bu ortak buradaki kullanıcılarla paylaşabilir", + "Partner address": "Ortağın adresi", + "Check partner": "Ortağı denetle", + "Partner root fingerprint": "Ortağın kök parmak izi", + "I compared this fingerprint with the partner's administrator": "Bu parmak izini ortağın yöneticisiyle karşılaştırdım", + "Add partner": "Ortak ekle", + "A secret from another organisation": "Başka bir kuruluştan bir gizli", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "%1$s, \"%2$s\" öğesini sizinle paylaştı. Diğer kuruluşlardan gelenler altında kabul edin.", + "Incoming from other organisations": "Diğer kuruluşlardan gelenler", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Ortak kuruluşlardaki kişiler sizinle bir gizli paylaşabilir. Kasanızda salt okunur bir kopya tutmak için kabul edin.", + "Nothing shared with you yet": "Henüz sizinle hiçbir şey paylaşılmadı", + "Secrets that people in partner organisations share with you appear here.": "Ortak kuruluşlardaki kişilerin sizinle paylaştığı gizliler burada görünür.", + "From {sender}": "Gönderen: {sender}", + "Accept": "Kabul et", + "Open in vault": "Kasada aç", + "The other organisation did not hand over the secret. Try again later.": "Diğer kuruluş gizliyi teslim etmedi. Daha sonra yeniden deneyin.", + "Set up your vault before you accept a shared secret.": "Paylaşılan bir gizliyi kabul etmeden önce kasanızı kurun.", + "Something went wrong. Try again.": "Bir şeyler ters gitti. Yeniden deneyin.", + "Waiting for your answer": "Yanıtınız bekleniyor", + "In your vault, read-only": "Kasanızda, salt okunur", + "Withdrawn by the sender": "Gönderen tarafından geri çekildi", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "{sender} bunu başka bir kuruluştan paylaştı. Okuyabilirsiniz, ancak değiştiremez veya paylaşamazsınız.", + "Someone": "Biri", + "Share with someone at another organisation": "Başka bir kuruluştaki biriyle paylaş", + "Their account at the other organisation": "Kişinin diğer kuruluştaki hesabı", + "Check account": "Hesabı denetle", + "Certificate fingerprint of {account}": "{account} hesabının sertifika parmak izi", + "Compare it with them by phone if you want to be sure.": "Emin olmak istiyorsanız bunu kişiyle telefonda karşılaştırın.", + "Shared. {account} can accept it in their own vault.": "Paylaşıldı. {account} bunu kendi kasasında kabul edebilir.", + "The certificate could not be verified. Nothing was shared.": "Sertifika doğrulanamadı. Hiçbir şey paylaşılmadı.", + "That organisation is not one of your partners.": "Bu kuruluş ortaklarınızdan biri değil.", + "No one with that account can receive secrets from you.": "Bu hesaba sahip hiç kimse sizden gizli alamaz.", + "The other organisation did not answer. Try again later.": "Diğer kuruluş yanıt vermedi. Daha sonra yeniden deneyin.", + "This secret is already shared with that account.": "Bu gizli zaten bu hesapla paylaşılmış.", + "Other organisations": "Diğer kuruluşlar", + "Receive secrets from other organisations": "Diğer kuruluşlardan gizli al", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Ortak kuruluşlardaki kişiler böylece hesabınızı bulabilir ve sizinle gizli paylaşabilir. Her birini kendiniz kabul edersiniz.", + "Shared": "Paylaşıldı", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Duraklatıldı: alıcının sertifikası veya ortaklık değişti. İptal edin veya yeniden paylaşın.", + "Their organisation did not get the last change. Revoke it or share again.": "Alıcının kuruluşu son değişikliği almadı. İptal edin veya yeniden paylaşın.", + "Being withdrawn": "Geri çekiliyor", + "Shared with another organisation": "Başka bir kuruluşla paylaşıldı", + "Change sent to another organisation": "Değişiklik başka bir kuruluşa gönderildi", + "Share with another organisation revoked": "Başka bir kuruluşla paylaşım kaldırıldı", + "Share with another organisation paused": "Başka bir kuruluşla paylaşım duraklatıldı", + "Another organisation did not get a change": "Başka bir kuruluş bir değişikliği almadı", + "Secret received from another organisation": "Başka bir kuruluştan gizli alındı", + "Secret from another organisation accepted": "Başka bir kuruluştan gelen gizli kabul edildi", + "Secret from another organisation declined": "Başka bir kuruluştan gelen gizli reddedildi", + "Copy from another organisation updated": "Başka bir kuruluştan gelen kopya güncellendi", + "Copy from another organisation removed": "Başka bir kuruluştan gelen kopya kaldırıldı", + "Declined: they removed their copy. Share again if they need it.": "Reddedildi: alıcı kendi kopyasını kaldırdı. Gerekirse yeniden paylaşın.", + "Recipient at another organisation removed their copy": "Başka bir kuruluştaki alıcı kendi kopyasını kaldırdı", + "Removed the user from %n team folder.": "Kullanıcı %n takım klasöründen çıkarıldı.", + "Removed the user from %n team folders.": "Kullanıcı %n takım klasöründen çıkarıldı.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Kullanıcı %n takım klasöründen çıkarıldı." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Geri yüklenen bir kopya sona ermiş bir paylaşımdan geliyor. Salt okunur kalır.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Geri yüklenen bir kopyayı paylaşan kuruluşa ulaşılamadı. Kopya salt okunur kalır ve onların değişikliklerini izlemez.", + "Recipient at another organisation restored their copy": "Başka bir kuruluştaki alıcı kendi kopyasını geri yükledi" }, "plurals": null } diff --git a/l10n/uk.js b/l10n/uk.js index 0016efe52..94aab1578 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1182,7 +1182,492 @@ OC.L10N.register( "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.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен.", + "Shared with groups": "Надано доступ групам", + "Not shared with any group yet.": "Ще не надано жодній групі.", + "Revoke the share with {group}": "Відкликати доступ для {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Надано групі {group}: {received} учасників отримали, {skipped} ні, бо в них ще не налаштовано шифрування.", + "Search groups": "Шукати групи", + "Failed to share": "Не вдалося надати доступ", + "Columns": "Стовпці", + "Column {number}": "Стовпець {number}", + "Map one column to Name. Every secret needs a name.": "Зіставте один стовпець із назвою. Кожен секрет повинен мати назву.", + "Notes": "Нотатки", + "Do not import": "Не імпортувати", + "Hide this value": "Приховати це значення", + "Show this value": "Показати це значення", + "Defaults": "Типові значення", + "New secrets start as this type, and your secret list opens in this view.": "Нові секрети створюються з цим типом, а список секретів відкривається в цьому вигляді.", + "Default item type": "Типовий тип елемента", + "Cards": "Картки", + "Table": "Таблиця", + "Could not save your default": "Не вдалося зберегти типове значення", + "Recently used": "Нещодавно використані", + "Opened": "Відкрито", + "You have not opened any secrets yet": "Ви ще не відкривали жодного секрету", + "Could not delete the item type.": "Не вдалося видалити тип елемента.", + "Could not load the item types.": "Не вдалося завантажити типи елементів.", + "Could not save the item type.": "Не вдалося зберегти тип елемента.", + "Delete item type": "Видалити тип елемента", + "Edit item type": "Редагувати тип елемента", + "Fields": "Поля", + "Fields: {count}": "Поля: {count}", + "Hidden": "Приховане", + "Item types": "Типи елементів", + "Move up": "Вгору", + "New item type": "Новий тип елемента", + "No item types defined yet.": "Типи елементів ще не визначено.", + "Required": "Обовʼязкове", + "Text": "Текст", + "This field is required": "Це поле обовʼязкове", + "Web address": "Вебадреса", + "{label} (required)": "{label} (обовʼязково)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Видалити «{name}»? Секрети цього типу залишаться читабельними й стануть елементами Логін.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типи елементів, які ви визначите тут, зʼявляться в усіх у вікні Новий секрет із вибраними вами полями.", + "Secret moved to the trash": "Секрет переміщено до кошика", + "Secret restored from the trash": "Секрет відновлено з кошика", + "Secret deleted for good": "Секрет видалено назавжди", + "Secret archived": "Секрет архівовано", + "Secret unarchived": "Секрет вилучено з архіву", + "Unarchive": "Вилучити з архіву", + "Could not archive the secret": "Не вдалося архівувати секрет", + "Could not unarchive the secret": "Не вдалося вилучити секрет з архіву", + "Archive {count} secrets": "Архівувати секрети: {count}", + "Unarchive {count} secrets": "Вилучити з архіву секрети: {count}", + "Restore {count} secrets": "Відновити секрети: {count}", + "Delete {count} secrets for good": "Видалити назавжди секрети: {count}", + "Done for {ok} of {total} secrets": "Готово для {ok} з {total} секретів", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архівовані секрети зникають зі списку сховища, пошуку, автозаповнення та звіту про стан. Спільний доступ до них зберігається. Їх можна знайти в Архіві.", + "These secrets come back to the vault list, search and autofill.": "Ці секрети повертаються до списку сховища, пошуку та автозаповнення.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ці секрети повертаються до списку сховища. Попередній спільний доступ не повертається, тож за потреби поділіться ними знову.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Це видалить секрети разом із вкладеннями та історією версій. Скасувати це не можна.", + "Delete for good": "Видалити назавжди", + "Trash": "Кошик", + "The trash is empty": "Кошик порожній", + "No archived secrets": "Немає архівованих секретів", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Видалені секрети чекають тут до кінця строку зберігання, після чого видаляються назавжди.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архівуйте секрет на його панелі відомостей, щоб прибрати його зі списку сховища, пошуку та автозаповнення.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Обмеження для зашифрованих вкладень (застосовуються на сервері до збережених зашифрованих байтів), зберігання історії версій і скільки часу видалені секрети залишаються в кошику.", + "Days a deleted secret stays in the trash (1 to 365)": "Скільки днів видалений секрет залишається в кошику (від 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Секрет буде переміщено до кошика, а спільний доступ до нього одразу припиниться. Його можна відновити з кошика до кінця строку зберігання: 30 днів, якщо адміністратор не змінив цей строк.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Секрети буде переміщено до кошика ({count}), а спільний доступ до них одразу припиниться. Їх можна відновити з кошика до кінця строку зберігання.", + "Remove {name} from favourites": "Прибрати {name} з обраного", + "Add {name} to favourites": "Додати {name} до обраного", + "Could not change the favourite": "Не вдалося змінити обране", + "Remove from favourites": "Прибрати з обраного", + "Add to favourites": "Додати до обраного", + "Tags": "Мітки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Мітки не шифруються. Адміністратори сервера можуть їх читати, як і назви тек.", + "Favourites": "Обране", + "Filter by tag": "Фільтр за міткою", + "All tags": "Усі мітки", + "Last used": "Останнє використання", + "Tags for {count} secrets": "Мітки для {count} секретів", + "Tag": "Мітка", + "Remove tag": "Прибрати мітку", + "Add tag": "Додати мітку", + "Could not change the tags. Try again.": "Не вдалося змінити мітки. Спробуйте ще раз.", + "Could not approve the application. It is still in the queue.": "Не вдалося схвалити заявку. Вона досі в черзі.", + "Could not reject the application. It is still in the queue.": "Не вдалося відхилити заявку. Вона досі в черзі.", + "Removed the user from {count} team folders.": "Користувача вилучено з командних тек: {count}.", + "Approve a share": "Схвалити спільний доступ", + "This approval link is incomplete. Open it again from the notification.": "Посилання для схвалення неповне. Відкрийте його знову зі сповіщення.", + "Deny": "Відхилити", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} приєднався до групи, з якою ви ділитеся секретом. Поділитися секретом і з ним?", + "{requester} asks you to share a secret with {user}.": "{requester} просить вас поділитися секретом з {user}.", + "Shared. The recipient can now open the secret.": "Надано доступ. Тепер отримувач може відкрити секрет.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Отримувач ще не налаштував Keepiq, тому доступ не надано. Спробуйте знову, коли він це зробить.", + "Could not share the secret. Only its owner can approve this.": "Не вдалося поділитися секретом. Схвалити це може лише його власник.", + "Could not share the secret. Try again.": "Не вдалося поділитися секретом. Спробуйте ще раз.", + "Denied. Nothing was shared.": "Відхилено. Доступ не надано.", + "Could not deny the request. Try again.": "Не вдалося відхилити запит. Спробуйте ще раз.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просить вас поділитися секретом \"%2$s\" з %3$s.", + "Expires on (optional)": "Спливає (необов'язково)", + "Hand over to": "Передати", + "Choose a recipient": "Виберіть отримувача", + "Hand over temporarily": "Передати тимчасово", + "Expiry rules": "Правила терміну дії", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Вкажіть, скільки можуть діяти паролі одного типу елементів або однієї теки і коли нагадувати. Якщо застосовується кілька дат, враховується найраніша.", + "Delete rule": "Видалити правило", + "Set by your administrator": "Встановлено адміністратором", + "No expiry rules yet.": "Правил терміну дії ще немає.", + "Applies to": "Застосовується до", + "Item type": "Тип елемента", + "Maximum age in days (empty for reminders only)": "Максимальний вік у днях (порожньо лише для нагадувань)", + "Remind me this many days before, comma separated": "За скільки днів нагадати, через кому", + "Save rule": "Зберегти правило", + "An item type": "Тип елемента", + "A folder": "Тека", + "Folder {name}": "Тека {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Спливає через {days} дн.", + "Reminders {days} days before": "Нагадування за {days} дн.", + "Could not save the expiry rule.": "Не вдалося зберегти правило терміну дії.", + "Could not delete the expiry rule.": "Не вдалося видалити правило терміну дії.", + "All statuses": "Усі статуси", + "Compromised": "Скомпрометовано", + "Could not load the members.": "Не вдалося завантажити учасників.", + "Emergency contact": "Екстрений контакт", + "Leaving user": "Користувач, що йде", + "No": "Ні", + "No users match this filter.": "Жоден користувач не відповідає цьому фільтру.", + "Not set up": "Не налаштовано", + "Revoke suite": "Відкликати набір", + "Revoked": "Відкликано", + "Search users": "Шукати користувачів", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Перегляньте, хто з користувачів налаштував сховище. Почніть звільнення або відкличте набір із рядка.", + "Successor": "Наступник", + "Team folders": "Командні теки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Користувач досі в групі {groups}, яка є учасником командної теки. Вилучіть його з групи або вимкніть обліковий запис.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис.", + "Vault status": "Статус сховища", + "Yes": "Так", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Експорт до CXF НЕ ЗАШИФРОВАНИЙ. Кожен пароль і логін будуть читабельними як звичайний текст у завантаженому файлі. Зберігайте файл у надійному місці та вилучіть його одразу після використання.", + "Root certificate expiring soon": "Термін дії кореневого сертифіката скоро спливе", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Термін дії кореневого сертифіката сховища спливає через %1$d дн. Оновіть його до цього. Оновлення заново підписує кожен набір шифрування.", + "Compromise recovery aborted": "Відновлення після компрометації перервано", + "Key rotation ended by a compromise revoke": "Зміну ключа завершено відкликанням через компрометацію", + "Encryption suite revoke refused": "Відкликання набору шифрування відхилено", + "Master password proof refused": "Підтвердження головного пароля відхилено", + "Your current master password": "Ваш поточний головний пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n екстрений контакт мав запит на доступ в очікуванні, коли зміна ключа його видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ці екстрені контакти не перенесено на ваш новий ключ. Їхній екстрений доступ видалено. Додайте їх знову в розділі Екстрений доступ, якщо вони вам ще потрібні.", + "Renew root certificate": "Оновити кореневий сертифікат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Буде створено нові кореневий і проміжний сертифікати. Кожен активний набір шифрування буде підписано заново. Цю дію не можна скасувати.", + "Renew root": "Оновити корінь", + "Root renewed. {n} encryption suites signed again.": "Корінь оновлено. Заново підписано наборів шифрування: {n}.", + "Could not renew the root certificate.": "Не вдалося оновити кореневий сертифікат.", + "Lease policy for this application": "Політика оренди для цього застосунку", + "In force now: {default} seconds by default, {max} seconds at most.": "Зараз діє: типово {default} секунд, не більше {max} секунд.", + "Leases are not renewable": "Оренду не можна продовжувати", + "Lease policy saved.": "Політику оренди збережено.", + "Leave a field empty to use the instance value.": "Залиште поле порожнім, щоб використати значення екземпляра.", + "Instance value: {value}": "Значення екземпляра: {value}", + "Renewal": "Продовження", + "Use the instance value ({value})": "Використати значення екземпляра ({value})", + "Allowed": "Дозволено", + "Not allowed": "Не дозволено", + "Save lease policy": "Зберегти політику оренди", + "Only an administrator can change this policy.": "Змінити цю політику може лише адміністратор.", + "Could not save the lease policy.": "Не вдалося зберегти політику оренди.", + "{member} got access from {confirmer}.": "{member} отримав доступ від {confirmer}.", + "Automatically confirm new team folder members": "Автоматично підтверджувати нових учасників командних тек", + "Gave %n new member access to a team folder.": "%n новий учасник отримав доступ до командної теки.", + "Gave %n new members access to a team folder.": "Нові учасники (%n) отримали доступ до командної теки.", + "Give new team folder members access without waiting for the folder owner.": "Надавайте доступ новим учасникам, не чекаючи на власника теки.", + "New team folder members": "Нові учасники командних тек", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Власник або учасник із правом запису підтверджує їх із відкритого сховища. Keepiq ніколи не розшифровує на сервері.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Очікування, поки учасник із правом запису відкриє Keepiq. Можна поділитися й зараз.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Частину реакції на компрометацію не виконано ({failed} крок(ів)). Перевірте журнал сервера, а потім знову відкличте набір, щоб завершити її.", + "This also revoked suite {suite} and ended key migration {migration}.": "Це також відкликало набір {suite} і завершило міграцію ключів {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Відкликання другого набору видалило %n контакт аварійного доступу.", + "Revoking the second suite deleted %n emergency-access contacts.": "Відкликання другого набору видалило %n контактів аварійного доступу.", + "A suite revoked as compromised cannot be reinstated.": "Набір, відкликаний як скомпрометований, не можна відновити.", + "Archives to keep": "Скільки архівів зберігати", + "Back up every vault automatically": "Автоматично створювати копію кожного сховища", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Створюйте копію кожного сховища за розкладом. Архіви містять лише шифротекст і відновлюються через occ.", + "Back up now": "Створити копію зараз", + "Backup public key (PEM, optional)": "Відкритий ключ резервної копії (PEM, необов'язково)", + "Backup requested for the next cron run": "Копію замовлено на наступний запуск cron", + "Encrypted": "Зашифровано", + "Every (hours)": "Кожні (годин)", + "Last backup {when} failed: {error}": "Остання копія {when} не вдалася: {error}", + "Last backup {when} succeeded.": "Остання копія {when} створена.", + "No archives yet.": "Архівів ще немає.", + "Size": "Розмір", + "Vault backups": "Резервні копії сховища", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "З ключем кожен архів шифрується для нього. Зберігайте закритий ключ поза цим сервером: він потрібен для перевірки чи відновлення.", + "Written": "Записано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n користувач в області дії ще не має двофакторного входу й не може відкрити сховище, поки це ввімкнено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервні коди не враховуються. Якщо ваші користувачі входять через постачальника ідентичності з власним другим фактором, виключіть їхні групи.", + "Block personal vault export": "Заборонити експорт особистого сховища", + "Keep work logins in team folders": "Зберігати робочі облікові дані в командних теках", + "Move to a team folder": "Перемістити до командної теки", + "Not in a team folder": "Не в командній теці", + "Only for these groups (empty is everyone)": "Лише для цих груп (порожньо означає всіх)", + "Require two-factor login before the vault opens": "Вимагати двофакторний вхід перед відкриттям сховища", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила для кожного сховища. Кожне діє для всіх або лише для вибраних груп.", + "Secret types that belong in a team folder": "Типи секретів, що належать до командної теки", + "Set up two-factor login": "Налаштувати двофакторний вхід", + "Team folder you can write to": "Командна тека, до якої ви можете записувати", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Користувачі не можуть завантажити резервну копію, CSV чи файл перенесення. Їхній пакет особистих даних лишається доступним.", + "Users cannot save these secret types in a personal folder.": "Користувачі не можуть зберігати ці типи секретів в особистій теці.", + "Vault policies": "Правила сховища", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша організація не дозволяє експорт особистого сховища. Ваш пакет особистих даних у налаштуваннях лишається доступним.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша організація зберігає ці секрети в командній теці. Перемістіть кожен до командної теки.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша організація зберігає цей тип секрету в командній теці. Виберіть одну зі своїх командних тек або ту, до якої ви можете записувати.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша організація вимагає двофакторний вхід, перш ніж ви зможете відкрити сховище.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Користувачі обирають, як довго розширення залишається розблокованим під час бездіяльності. Ви встановлюєте найбільший час, який можна обрати.", + "Longest idle time before the extension locks": "Найбільший час бездіяльності до блокування розширення", + "1 minute": "1 хвилина", + "5 minutes": "5 хвилин", + "15 minutes": "15 хвилин", + "1 hour": "1 година", + "4 hours": "4 години", + "Connector": "Конектор", + "Directory (tenant) ID": "ІД каталогу (орендаря)", + "Application (client) ID": "ІД застосунку (клієнта)", + "Data collection rule immutable ID": "Незмінний ІД правила збору даних", + "Stream name": "Назва потоку", + "Splunk index (optional)": "Індекс Splunk (необов'язково)", + "Sourcetype (optional)": "Sourcetype (необов'язково)", + "Leave blank to keep the current one": "Залиште порожнім, щоб зберегти поточне значення", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF через syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Кінцева точка збору даних (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Секрет клієнта (лише запис)", + "HEC token (write-only)": "Токен HEC (лише запис)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Пересилайте дозволені події аудиту до Splunk, Microsoft Sentinel, приймача syslog або вебхука. Повідомлення містять лише очищені метадані: жодне секретне значення, ім'я, логін чи шифротекст ніколи не залишає сервер.", + "%n change waiting to sync": "%n зміна очікує синхронізації", + "%n changes waiting to sync": "%n змін очікують синхронізації", + "Changes that could not sync": "Зміни, які не вдалося синхронізувати", + "Choose a version": "Вибрати версію", + "Copy value": "Копіювати значення", + "Deleted": "Видалено", + "Discard": "Відкинути", + "Keep my offline change": "Залишити мою зміну без мережі", + "Keep the server version": "Залишити версію з сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без мережі Keepiq доступний лише для читання. Адміністратор не ввімкнув редагування без мережі.", + "Let users edit secrets offline": "Дозволити користувачам редагувати секрети без мережі", + "Not synced yet": "Ще не синхронізовано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Зміни без мережі залишаються на пристрої, зашифровані для користувача, і синхронізуються під час наступного розблокування в мережі. Спільний доступ, теки та вкладення й далі потребують з'єднання.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без мережі. Редагування, переміщення та видалення залишаються на цьому пристрої й синхронізуються, коли ви знову будете в мережі. Спільний доступ і вкладення потребують з'єднання.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без мережі. Ваші зміни залишаються на цьому пристрої й синхронізуються, коли ви знову будете в мережі. Остання синхронізація {when}.", + "Open my changes": "Відкрити мої зміни", + "Sharing needs a connection": "Спільний доступ потребує з'єднання", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Хтось змінив цей секрет на сервері після створення вашої копії без мережі. Виберіть, яку версію залишити.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронізуйте або відкиньте зміни без мережі, перш ніж замінювати ключі.", + "That password did not open your changes.": "Цей пароль не відкрив ваші зміни.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Знімок без мережі зберігає зашифровані секрети (відкриваються лише ключем, отриманим з головного пароля користувача, точно як на сервері) і шифрує назви, URL та назви тек під час зберігання. Доступ без мережі лише для читання, якщо нижче ви не дозволите редагування без мережі. Вимкніть це для пристроїв, які ніколи не мають кешувати облікові дані; вимкнення очищає наявні кеші під час наступного завантаження.", + "The previous vault copy is gone, so these changes cannot be opened.": "Попередньої копії сховища більше немає, тому ці зміни не можна відкрити.", + "The server version": "Версія з сервера", + "This secret changed while you were offline": "Цей секрет змінився, поки ви були без мережі", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ви видалили цей секрет без мережі, але відтоді його змінили на сервері. Виберіть, яку версію залишити.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваші ключі змінено на іншому пристрої. Введіть попередній головний пароль, щоб синхронізувати зміни без мережі, або відкиньте їх.", + "Your offline change": "Ваша зміна без мережі", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": ["%n екстрений контакт мав запит на доступ в очікуванні, коли зміна ключа його видалила. Перевірте, хто запитував, перш ніж знову когось додавати.","Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати.","Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати."], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": ["%n елемент не можна подати у CXF, він буде пропущений.","%n елементів не можна подати у CXF, вони будуть пропущені.","%n елементів не можна подати у CXF, вони будуть пропущені."], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": ["%n старішу версію було відкинуто, оскільки перенести можна лише нещодавню історію.","%n старіших версій було відкинуто, оскільки перенести можна лише нещодавню історію.","%n старіших версій було відкинуто, оскільки перенести можна лише нещодавню історію."], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": ["Ще потрібно зашифрувати та надати %n копію секрету.","Ще потрібно зашифрувати та надати %n копій секретів.","Ще потрібно зашифрувати та надати %n копій секретів."], + "_%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 секрет не вдалося розшифрувати, і його немає в цьому експорті.","%n секретів не вдалося розшифрувати, і їх немає в цьому експорті.","%n секретів не вдалося розшифрувати, і їх немає в цьому експорті."], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": ["%n секрет не вдалося розшифрувати вашим старим ключем, тому він не був перенесений.","%n секретів не вдалося розшифрувати вашим старим ключем, тому вони не були перенесені.","%n секретів не вдалося розшифрувати вашим старим ключем, тому вони не були перенесені."], + "_%n secret did not migrate._::_%n secrets did not migrate._": ["%n секрет не був перенесений.","%n секретів не були перенесені.","%n секретів не були перенесені."], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": ["%n секрет усе ще зашифрований вашим попереднім ключем.","%n секретів усе ще зашифровані вашим попереднім ключем.","%n секретів усе ще зашифровані вашим попереднім ключем."], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": ["%n секрет пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову.","%n секретів пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову.","%n секретів пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову."], + "_%n secret_::_%n secrets_": ["%n секрет","%n секретів","%n секретів"], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": ["%n користувач в області дії ще не має двофакторного входу й не може відкрити сховище, поки це ввімкнено.","Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено.","Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено."], + "_Emergency access for %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 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."], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": ["Усе одно завершити, втративши доступ до %n секрета","Усе одно завершити, втративши доступ до %n секретів","Усе одно завершити, втративши доступ до %n секретів"], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": ["%n новий учасник отримав доступ до командної теки.","Нові учасники (%n) отримали доступ до командної теки.","Нові учасники (%n) отримали доступ до командної теки."], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": ["Ротацію ключа завершено. %n секрет було перешифровано вашим новим ключем.","Ротацію ключа завершено. %n секретів було перешифровано вашим новим ключем.","Ротацію ключа завершено. %n секретів було перешифровано вашим новим ключем."], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": ["Відкликання другого набору видалило %n контакт аварійного доступу.","Відкликання другого набору видалило %n контактів аварійного доступу.","Відкликання другого набору видалило %n контактів аварійного доступу."], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": ["Відкликання цього набору видалило %n контакт аварійного доступу.","Відкликання цього набору видалило %n контактів аварійного доступу.","Відкликання цього набору видалило %n контактів аварійного доступу."], + "_seen %n time in breaches_::_seen %n times in breaches_": ["зустрічається у витоках %n раз","зустрічається у витоках %n разів","зустрічається у витоках %n разів"], + "_shared with %n secret_::_shared with %n secrets_": ["надано %n секрету","надано %n секретам","надано %n секретам"], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.","This also 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."], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": ["Ця тека містить %n секрет безпосередньо.","Ця тека містить %n секретів безпосередньо.","Ця тека містить %n секретів безпосередньо."], + "_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._": ["Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.","Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.","Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні."], + "_%n change waiting to sync_::_%n changes waiting to sync_": ["%n зміна очікує синхронізації","%n змін очікують синхронізації","%n змін очікують синхронізації"], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": ["Користувач досі в групі {groups}, яка є учасником командної теки. Вилучіть його з групи або вимкніть обліковий запис.","Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис.","Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис."], + "Allow approval from another device": "Дозволити підтвердження з іншого пристрою", + "App": "Застосунок", + "Approve a new device": "Підтвердити новий пристрій", + "Approve from another device": "Підтвердити з іншого пристрою", + "Asked at": "Запитано о", + "Check that the new device shows these words:": "Переконайтеся, що новий пристрій показує ці слова:", + "Denied. If you did not ask, end your other sessions:": "Відхилено. Якщо ви цього не запитували, завершіть інші сеанси:", + "Device": "Пристрій", + "IP address": "IP-адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Дозволяє користувачам розблокувати новий браузер, підтвердивши його з пристрою, на якому Keepiq уже розблоковано.", + "New device approval": "Підтвердження нових пристроїв", + "Nextcloud security settings": "Налаштування безпеки Nextcloud", + "Only approve a device you are using right now.": "Підтверджуйте лише пристрій, яким користуєтеся просто зараз.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Відкрийте Keepiq на пристрої, де його розблоковано, і підтвердьте цей пристрій. Переконайтеся, що там показано ті самі слова:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Пристрій, що підтверджує, запечатує ключ розблокування для нового пристрою. Сервер лише передає його і не може його відкрити.", + "The master password is not right, or the request has ended.": "Головний пароль неправильний, або запит завершився.", + "The request expired. Ask again or use your master password.": "Термін дії запиту минув. Запитайте знову або скористайтеся головним паролем.", + "The request was denied.": "Запит відхилено.", + "Too many requests. Try again in an hour or use your master password.": "Забагато запитів. Спробуйте знову за годину або скористайтеся головним паролем.", + "Unknown device": "Невідомий пристрій", + "Web app": "Вебзастосунок", + "A device": "Пристрій", + "A new device asks to open your vault": "Новий пристрій просить відкрити ваше сховище", + "%s asks to be approved. Only approve a device you are using right now.": "%s просить підтвердження. Підтверджуйте лише пристрій, яким користуєтеся просто зараз.", + "Access ends on (optional)": "Доступ закінчується (необов'язково)", + "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.": "Застосунки Keepiq не показуватимуть і не копіюватимуть пароль. Людина з технічними навичками все одно може прочитати його на своєму пристрої. Змініть його, коли доступ закінчиться.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Цей секрет лише для використання. Увійдіть через розширення браузера Keepiq.", + "Until {date}": "До {date}", + "Use only": "Лише використання", + "Use only (can sign in, cannot view or copy)": "Лише використання (може увійти, не може переглянути чи скопіювати)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ви можете увійти з цими обліковими даними через розширення браузера Keepiq. Власник вирішив не дозволяти вам переглядати чи копіювати їх.", + "Your access ends on {date}": "Ваш доступ закінчується {date}", + "Your access to this secret has ended": "Ваш доступ до цього секрету закінчився", + "Your access to \"%s\" ends tomorrow": "Ваш доступ до «%s» закінчується завтра", + "Your access to \"%s\" has ended": "Ваш доступ до «%s» закінчився", + "%1$s no longer has access to \"%2$s\"": "%1$s більше не має доступу до «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s міг(ла) бачити цей пароль. Змініть його, якщо %1$s більше не має його знати.", + "%s could not view this password in Keepiq.": "%s не зміг(ла) переглянути цей пароль у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} з {threshold} підтверджень", + "a recovery officer": "відповідальний за відновлення", + "Account recovery": "Відновлення облікового запису", + "Approvals needed": "Потрібно підтверджень", + "Ask {user} which words they see, by phone or in person. They must be:": "Запитайте в {user}, які слова він бачить, телефоном або особисто. Вони мають бути такими:", + "Check again": "Перевірити ще раз", + "Create the recovery key": "Створити ключ відновлення", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Створіть ключ відновлення. Ваш браузер створює його й дає кожному відповідальному копію, яку може відкрити лише він.", + "Decline": "Відхилити", + "Enrol in account recovery": "Приєднатися до відновлення облікового запису", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Приєднайтеся, щоб організація могла допомогти вам повернути сховище, якщо ви забудете головний пароль.", + "Every user is enrolled": "Усі користувачі приєдналися", + "Finish the recovery in the browser you asked from.": "Завершіть відновлення в браузері, з якого ви його запитали.", + "Forgot your master password?": "Забули головний пароль?", + "Hand the key over": "Передати ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дозвольте користувачам, які забули головний пароль, повернути сховище з підтвердженням призначених вами відповідальних за відновлення.", + "New master password": "Новий головний пароль", + "No one is asking to recover their account.": "Ніхто не просить відновити обліковий запис.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа відновлення ще немає. Один із відповідальних створює його у своїх налаштуваннях Keepiq.", + "Off": "Вимкнено", + "Officer {user} has no encryption set up yet.": "Відповідальний {user} ще не налаштував шифрування.", + "Officers (user IDs, separated by commas)": "Відповідальні (ID користувачів через кому)", + "Policy": "Політика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Опублікуйте цей відбиток усередині організації, щоб користувачі могли перевірити його перед приєднанням.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Відновлено за допомогою {officer}. Змініть ключ сховища зараз у Налаштування, Безпека: \"Мій головний пароль скомпрометовано\".", + "Recovery key fingerprint: {fingerprint}": "Відбиток ключа відновлення: {fingerprint}", + "Recovery officer": "Відповідальний за відновлення", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Вилучені відповідальні втрачають свою копію зараз, але могли відкрити її раніше. Попросіть відповідального створити новий ключ відновлення.", + "Repeat the new master password": "Повторіть новий головний пароль", + "Retire this recovery key": "Вивести цей ключ відновлення з ужитку", + "Set the new master password": "Встановити новий головний пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертифікат відновлення видано не цим Keepiq. Не приєднуйтеся та повідомте адміністратора.", + "The words match, approve": "Слова збігаються, підтвердити", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Цей користувач приєднався до відновлення облікового запису. Відновлення зберігає його секрети; відкликання видаляє його приєднання.", + "Users may enrol": "Користувачі можуть приєднуватися", + "Withdraw from account recovery": "Вийти з відновлення облікового запису", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Ви приєдналися до відновлення облікового запису. Відбиток ключа відновлення: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Ви приєдналися. Якщо ви забудете головний пароль, організація може допомогти вам повернути сховище.", + "Your key is back. Choose a new master password.": "Ваш ключ повернуто. Виберіть новий головний пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваших відповідальних за відновлення повідомлено. Прочитайте їм ці слова, коли вони зателефонують або зустрінуться з вами:", + "You are now an account recovery officer": "Тепер ви відповідальний за відновлення облікових записів", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просить відновити обліковий запис. Звірте з ним слова перед підтвердженням.", + "A user": "Користувач", + "Your account recovery request was declined": "Ваш запит на відновлення облікового запису відхилено", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Відновлення облікового запису готове. Відкрийте Keepiq у браузері, з якого ви його запитали.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просить один раз розблокувати новий пристрій. Головний пароль залишається тим самим.", + "Ask your organisation instead": "Краще звернутися до своєї організації", + "The request ended. Ask again or use your master password.": "Запит завершено. Надішліть його знову або скористайтеся головним паролем.", + "Added by {user}": "Додано користувачем {user}", + "Editor": "Редактор", + "Manager": "Менеджер", + "Role of {member}": "Роль {member}", + "Team folders you manage": "Командні теки, якими ви керуєте", + "Viewer": "Читач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас немає копії цих секретів, тому нові учасники їх ще не отримали. Власник може поділитися ними: {names}", + "Admin areas": "Області адміністрування", + "Give a group only the parts of Keepiq administration it needs.": "Надайте групі лише ті частини адміністрування Keepiq, які їй потрібні.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегуйте одну чи кілька областей групі на сторінці прав адміністрування. Адміністратори екземпляра мають усі області.", + "Open administration privileges": "Відкрити права адміністрування", + "Policies": "Політики", + "Applications and machine access": "Застосунки та доступ машин", + "People and offboarding": "Люди та звільнення", + "Audit and compliance": "Аудит і відповідність", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версія, центр сертифікації, вкладення, офлайн-кеш, перевірка витоків, типи секретів і резервні копії", + "master password, organisation password, vault policies, rotation, version history and trash": "головний пароль, пароль організації, політики сховища, ротація, історія версій і кошик", + "application queue, application requests and machine leases": "черга застосунків, запити застосунків і оренди машин", + "team offboarding, encryption suites and admin handover": "звільнення з команди, набори шифрування та передача адміністратору", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аудиту, звіти про відповідність, експорт у SIEM і сповіщення про приманки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Скільки версій секрету зберігається, як довго і як довго видалені секрети залишаються в кошику.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Обмеження для зашифрованих вкладень, які сервер застосовує до збережених зашифрованих байтів.", + "Type the suite ID again to confirm": "Введіть ID набору ще раз для підтвердження", + "This does not match the suite ID.": "Це не збігається з ID набору.", + "Confirm with your master password": "Підтвердьте головним паролем", + "Confirm": "Підтвердити", + "That master password is not right.": "Цей головний пароль неправильний.", + "You are sharing with someone new. Enter your master password to confirm.": "Ви ділитеся з новою людиною. Введіть головний пароль для підтвердження.", + "Enter your master password to confirm this share.": "Введіть головний пароль, щоб підтвердити цей спільний доступ.", + "Enter your master password to confirm this delegation.": "Введіть головний пароль, щоб підтвердити це делегування.", + "Approve {member}": "Схвалити {member}", + "Recipient": "Отримувач", + "No vault yet": "Ще немає сховища", + "No matching users": "Немає відповідних користувачів", + "Partner organisations": "Партнерські організації", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обмінюйтеся секретами з іншим Keepiq. Обидва адміністратори додають одне одного й перед збереженням звіряють кореневі відбитки телефоном або особисто.", + "Federation needs Nextcloud 33 or later.": "Для федерації потрібен Nextcloud 33 або новіший.", + "Your root fingerprint": "Ваш кореневий відбиток", + "No partners yet.": "Партнерів ще немає.", + "Users here may share to this partner": "Користувачі тут можуть ділитися з цим партнером", + "This partner may share to users here": "Цей партнер може ділитися з користувачами тут", + "Partner address": "Адреса партнера", + "Check partner": "Перевірити партнера", + "Partner root fingerprint": "Кореневий відбиток партнера", + "I compared this fingerprint with the partner's administrator": "Я звірив цей відбиток з адміністратором партнера", + "Add partner": "Додати партнера", + "A secret from another organisation": "Секрет з іншої організації", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Користувач %1$s надав вам доступ до \"%2$s\". Прийміть його в розділі Вхідні з інших організацій.", + "Incoming from other organisations": "Вхідні з інших організацій", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Люди з партнерських організацій можуть надати вам доступ до секрету. Прийміть його, щоб зберігати копію лише для читання у своєму сховищі.", + "Nothing shared with you yet": "Вам ще нічого не надано", + "Secrets that people in partner organisations share with you appear here.": "Тут з’являються секрети, до яких люди з партнерських організацій надають вам доступ.", + "From {sender}": "Від {sender}", + "Accept": "Прийняти", + "Open in vault": "Відкрити у сховищі", + "The other organisation did not hand over the secret. Try again later.": "Інша організація не передала секрет. Спробуйте пізніше.", + "Set up your vault before you accept a shared secret.": "Налаштуйте сховище, перш ніж приймати спільний секрет.", + "Something went wrong. Try again.": "Щось пішло не так. Спробуйте ще раз.", + "Waiting for your answer": "Очікує на вашу відповідь", + "In your vault, read-only": "У вашому сховищі, лише для читання", + "Withdrawn by the sender": "Відкликано відправником", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Користувач {sender} надав доступ до цього з іншої організації. Ви можете його читати, але не змінювати й не поширювати.", + "Someone": "Хтось", + "Share with someone at another organisation": "Поділитися з людиною з іншої організації", + "Their account at the other organisation": "Обліковий запис цієї людини в іншій організації", + "Check account": "Перевірити обліковий запис", + "Certificate fingerprint of {account}": "Відбиток сертифіката облікового запису {account}", + "Compare it with them by phone if you want to be sure.": "Звірте його з цією людиною телефоном, якщо хочете бути впевненими.", + "Shared. {account} can accept it in their own vault.": "Доступ надано. {account} може прийняти секрет у своєму сховищі.", + "The certificate could not be verified. Nothing was shared.": "Не вдалося перевірити сертифікат. Нічого не передано.", + "That organisation is not one of your partners.": "Ця організація не входить до ваших партнерів.", + "No one with that account can receive secrets from you.": "Ніхто з цим обліковим записом не може отримувати від вас секрети.", + "The other organisation did not answer. Try again later.": "Інша організація не відповіла. Спробуйте пізніше.", + "This secret is already shared with that account.": "Доступ до цього секрету вже надано цьому обліковому запису.", + "Other organisations": "Інші організації", + "Receive secrets from other organisations": "Отримувати секрети з інших організацій", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тоді люди з партнерських організацій зможуть знайти ваш обліковий запис і надавати вам доступ до секретів. Кожен із них ви приймаєте самі.", + "Shared": "Доступ надано", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Призупинено: змінився їхній сертифікат або партнерство. Доступ можна відкликати або надати знову.", + "Their organisation did not get the last change. Revoke it or share again.": "Їхня організація не отримала останню зміну. Доступ можна відкликати або надати знову.", + "Being withdrawn": "Відкликається", + "Shared with another organisation": "Спільний доступ надано іншій організації", + "Change sent to another organisation": "Зміну надіслано іншій організації", + "Share with another organisation revoked": "Спільний доступ для іншої організації відкликано", + "Share with another organisation paused": "Спільний доступ для іншої організації призупинено", + "Another organisation did not get a change": "Інша організація не отримала зміну", + "Secret received from another organisation": "Отримано секрет з іншої організації", + "Secret from another organisation accepted": "Секрет з іншої організації прийнято", + "Secret from another organisation declined": "Секрет з іншої організації відхилено", + "Copy from another organisation updated": "Копію з іншої організації оновлено", + "Copy from another organisation removed": "Копію з іншої організації вилучено", + "Declined: they removed their copy. Share again if they need it.": "Відхилено: отримувач вилучив свою копію. Надайте доступ знову, якщо він потрібен.", + "Recipient at another organisation removed their copy": "Отримувач з іншої організації вилучив свою копію", + "Removed the user from %n team folder.": "Користувача вилучено з %n командної теки.", + "Removed the user from %n team folders.": "Користувача вилучено з командних тек: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": ["Користувача вилучено з %n командної теки.","Користувача вилучено з командних тек: %n.","Користувача вилучено з командних тек: %n."], + "A restored copy came from a share that has ended. It stays read-only.": "Відновлена копія походить зі спільного доступу, який завершився. Вона залишається лише для читання.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не вдалося зв’язатися з організацією, яка поділилася відновленою копією. Копія залишається лише для читання й не отримує їхніх змін.", + "Recipient at another organisation restored their copy": "Отримувач з іншої організації відновив свою копію" }, - "nplurals=2; plural=(n != 1);" + "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ) diff --git a/l10n/uk.json b/l10n/uk.json index d59ba95c4..ee0559445 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1181,7 +1181,592 @@ "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.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен." + "Your key rotation removed this contact's emergency access. Designate them again if you still want them.": "Ротація ключа видалила надзвичайний доступ цього контакту. Призначте його знову, якщо він вам ще потрібен.", + "Shared with groups": "Надано доступ групам", + "Not shared with any group yet.": "Ще не надано жодній групі.", + "Revoke the share with {group}": "Відкликати доступ для {group}", + "Shared with {group}: {received} members received it, {skipped} did not because they have no encryption set up yet.": "Надано групі {group}: {received} учасників отримали, {skipped} ні, бо в них ще не налаштовано шифрування.", + "Search groups": "Шукати групи", + "Failed to share": "Не вдалося надати доступ", + "Columns": "Стовпці", + "Column {number}": "Стовпець {number}", + "Map one column to Name. Every secret needs a name.": "Зіставте один стовпець із назвою. Кожен секрет повинен мати назву.", + "Notes": "Нотатки", + "Do not import": "Не імпортувати", + "Hide this value": "Приховати це значення", + "Show this value": "Показати це значення", + "Defaults": "Типові значення", + "New secrets start as this type, and your secret list opens in this view.": "Нові секрети створюються з цим типом, а список секретів відкривається в цьому вигляді.", + "Default item type": "Типовий тип елемента", + "Cards": "Картки", + "Table": "Таблиця", + "Could not save your default": "Не вдалося зберегти типове значення", + "Recently used": "Нещодавно використані", + "Opened": "Відкрито", + "You have not opened any secrets yet": "Ви ще не відкривали жодного секрету", + "Could not delete the item type.": "Не вдалося видалити тип елемента.", + "Could not load the item types.": "Не вдалося завантажити типи елементів.", + "Could not save the item type.": "Не вдалося зберегти тип елемента.", + "Delete item type": "Видалити тип елемента", + "Edit item type": "Редагувати тип елемента", + "Fields": "Поля", + "Fields: {count}": "Поля: {count}", + "Hidden": "Приховане", + "Item types": "Типи елементів", + "Move up": "Вгору", + "New item type": "Новий тип елемента", + "No item types defined yet.": "Типи елементів ще не визначено.", + "Required": "Обовʼязкове", + "Text": "Текст", + "This field is required": "Це поле обовʼязкове", + "Web address": "Вебадреса", + "{label} (required)": "{label} (обовʼязково)", + "Delete “{name}”? Secrets of this type stay readable and become Login items.": "Видалити «{name}»? Секрети цього типу залишаться читабельними й стануть елементами Логін.", + "Item types you define here appear in everyone’s New secret dialog, with the fields you choose.": "Типи елементів, які ви визначите тут, зʼявляться в усіх у вікні Новий секрет із вибраними вами полями.", + "Secret moved to the trash": "Секрет переміщено до кошика", + "Secret restored from the trash": "Секрет відновлено з кошика", + "Secret deleted for good": "Секрет видалено назавжди", + "Secret archived": "Секрет архівовано", + "Secret unarchived": "Секрет вилучено з архіву", + "Unarchive": "Вилучити з архіву", + "Could not archive the secret": "Не вдалося архівувати секрет", + "Could not unarchive the secret": "Не вдалося вилучити секрет з архіву", + "Archive {count} secrets": "Архівувати секрети: {count}", + "Unarchive {count} secrets": "Вилучити з архіву секрети: {count}", + "Restore {count} secrets": "Відновити секрети: {count}", + "Delete {count} secrets for good": "Видалити назавжди секрети: {count}", + "Done for {ok} of {total} secrets": "Готово для {ok} з {total} секретів", + "Archived secrets leave the vault list, search, autofill and the health report. They keep their shares. You find them under Archive.": "Архівовані секрети зникають зі списку сховища, пошуку, автозаповнення та звіту про стан. Спільний доступ до них зберігається. Їх можна знайти в Архіві.", + "These secrets come back to the vault list, search and autofill.": "Ці секрети повертаються до списку сховища, пошуку та автозаповнення.", + "These secrets come back to the vault list. Their old shares do not come back, so share them again where needed.": "Ці секрети повертаються до списку сховища. Попередній спільний доступ не повертається, тож за потреби поділіться ними знову.", + "This deletes the secrets with their attachments and version history. This cannot be undone.": "Це видалить секрети разом із вкладеннями та історією версій. Скасувати це не можна.", + "Delete for good": "Видалити назавжди", + "Trash": "Кошик", + "The trash is empty": "Кошик порожній", + "No archived secrets": "Немає архівованих секретів", + "Deleted secrets wait here until the retention period ends, then they are deleted for good.": "Видалені секрети чекають тут до кінця строку зберігання, після чого видаляються назавжди.", + "Archive a secret from its detail panel to keep it out of the vault list, search and autofill.": "Архівуйте секрет на його панелі відомостей, щоб прибрати його зі списку сховища, пошуку та автозаповнення.", + "Limits for encrypted file attachments (enforced server-side in stored ciphertext bytes), version-history retention and how long deleted secrets stay in the trash.": "Обмеження для зашифрованих вкладень (застосовуються на сервері до збережених зашифрованих байтів), зберігання історії версій і скільки часу видалені секрети залишаються в кошику.", + "Days a deleted secret stays in the trash (1 to 365)": "Скільки днів видалений секрет залишається в кошику (від 1 до 365)", + "This moves the secret to the trash and ends its shares now. You can restore it from the trash until the retention period ends, which is 30 days unless your administrator changed it.": "Секрет буде переміщено до кошика, а спільний доступ до нього одразу припиниться. Його можна відновити з кошика до кінця строку зберігання: 30 днів, якщо адміністратор не змінив цей строк.", + "This moves {count} secrets to the trash and ends their shares now. You can restore them from the trash until the retention period ends.": "Секрети буде переміщено до кошика ({count}), а спільний доступ до них одразу припиниться. Їх можна відновити з кошика до кінця строку зберігання.", + "Remove {name} from favourites": "Прибрати {name} з обраного", + "Add {name} to favourites": "Додати {name} до обраного", + "Could not change the favourite": "Не вдалося змінити обране", + "Remove from favourites": "Прибрати з обраного", + "Add to favourites": "Додати до обраного", + "Tags": "Мітки", + "Tags are not encrypted. Server administrators can read them, as they can folder names.": "Мітки не шифруються. Адміністратори сервера можуть їх читати, як і назви тек.", + "Favourites": "Обране", + "Filter by tag": "Фільтр за міткою", + "All tags": "Усі мітки", + "Last used": "Останнє використання", + "Tags for {count} secrets": "Мітки для {count} секретів", + "Tag": "Мітка", + "Remove tag": "Прибрати мітку", + "Add tag": "Додати мітку", + "Could not change the tags. Try again.": "Не вдалося змінити мітки. Спробуйте ще раз.", + "Could not approve the application. It is still in the queue.": "Не вдалося схвалити заявку. Вона досі в черзі.", + "Could not reject the application. It is still in the queue.": "Не вдалося відхилити заявку. Вона досі в черзі.", + "Removed the user from {count} team folders.": "Користувача вилучено з командних тек: {count}.", + "Approve a share": "Схвалити спільний доступ", + "This approval link is incomplete. Open it again from the notification.": "Посилання для схвалення неповне. Відкрийте його знову зі сповіщення.", + "Deny": "Відхилити", + "{user} joined a group you share a secret with. Share the secret with them too?": "{user} приєднався до групи, з якою ви ділитеся секретом. Поділитися секретом і з ним?", + "{requester} asks you to share a secret with {user}.": "{requester} просить вас поділитися секретом з {user}.", + "Shared. The recipient can now open the secret.": "Надано доступ. Тепер отримувач може відкрити секрет.", + "The recipient has not set up Keepiq yet, so nothing was shared. Try again once they have.": "Отримувач ще не налаштував Keepiq, тому доступ не надано. Спробуйте знову, коли він це зробить.", + "Could not share the secret. Only its owner can approve this.": "Не вдалося поділитися секретом. Схвалити це може лише його власник.", + "Could not share the secret. Try again.": "Не вдалося поділитися секретом. Спробуйте ще раз.", + "Denied. Nothing was shared.": "Відхилено. Доступ не надано.", + "Could not deny the request. Try again.": "Не вдалося відхилити запит. Спробуйте ще раз.", + "%1$s asks you to share the secret \"%2$s\" with %3$s.": "%1$s просить вас поділитися секретом \"%2$s\" з %3$s.", + "Expires on (optional)": "Спливає (необов'язково)", + "Hand over to": "Передати", + "Choose a recipient": "Виберіть отримувача", + "Hand over temporarily": "Передати тимчасово", + "Expiry rules": "Правила терміну дії", + "Set how long passwords of one item type or in one folder may live, and when to be reminded. When several dates apply, the earliest one counts.": "Вкажіть, скільки можуть діяти паролі одного типу елементів або однієї теки і коли нагадувати. Якщо застосовується кілька дат, враховується найраніша.", + "Delete rule": "Видалити правило", + "Set by your administrator": "Встановлено адміністратором", + "No expiry rules yet.": "Правил терміну дії ще немає.", + "Applies to": "Застосовується до", + "Item type": "Тип елемента", + "Maximum age in days (empty for reminders only)": "Максимальний вік у днях (порожньо лише для нагадувань)", + "Remind me this many days before, comma separated": "За скільки днів нагадати, через кому", + "Save rule": "Зберегти правило", + "An item type": "Тип елемента", + "A folder": "Тека", + "Folder {name}": "Тека {name}", + "Type {name}": "Тип {name}", + "Expires after {days} days": "Спливає через {days} дн.", + "Reminders {days} days before": "Нагадування за {days} дн.", + "Could not save the expiry rule.": "Не вдалося зберегти правило терміну дії.", + "Could not delete the expiry rule.": "Не вдалося видалити правило терміну дії.", + "All statuses": "Усі статуси", + "Compromised": "Скомпрометовано", + "Could not load the members.": "Не вдалося завантажити учасників.", + "Emergency contact": "Екстрений контакт", + "Leaving user": "Користувач, що йде", + "No": "Ні", + "No users match this filter.": "Жоден користувач не відповідає цьому фільтру.", + "Not set up": "Не налаштовано", + "Revoke suite": "Відкликати набір", + "Revoked": "Відкликано", + "Search users": "Шукати користувачів", + "See which users have set up a vault. Start offboarding or revoke a suite from a row.": "Перегляньте, хто з користувачів налаштував сховище. Почніть звільнення або відкличте набір із рядка.", + "Successor": "Наступник", + "Team folders": "Командні теки", + "The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account.": "Користувач досі в групі {groups}, яка є учасником командної теки. Вилучіть його з групи або вимкніть обліковий запис.", + "The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account.": "Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис.", + "Vault status": "Статус сховища", + "Yes": "Так", + "A CXF export is UNENCRYPTED. Every password and login will be readable as plain text in the downloaded file. Store it securely and delete it immediately after use.": "Експорт до CXF НЕ ЗАШИФРОВАНИЙ. Кожен пароль і логін будуть читабельними як звичайний текст у завантаженому файлі. Зберігайте файл у надійному місці та вилучіть його одразу після використання.", + "Root certificate expiring soon": "Термін дії кореневого сертифіката скоро спливе", + "The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.": "Термін дії кореневого сертифіката сховища спливає через %1$d дн. Оновіть його до цього. Оновлення заново підписує кожен набір шифрування.", + "Compromise recovery aborted": "Відновлення після компрометації перервано", + "Key rotation ended by a compromise revoke": "Зміну ключа завершено відкликанням через компрометацію", + "Encryption suite revoke refused": "Відкликання набору шифрування відхилено", + "Master password proof refused": "Підтвердження головного пароля відхилено", + "Your current master password": "Ваш поточний головний пароль", + "%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back.": "%n екстрений контакт мав запит на доступ в очікуванні, коли зміна ключа його видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back.": "Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "These emergency contacts were not carried to your new key. Their emergency access was removed. Add them again from Emergency Access if you still want them.": "Ці екстрені контакти не перенесено на ваш новий ключ. Їхній екстрений доступ видалено. Додайте їх знову в розділі Екстрений доступ, якщо вони вам ще потрібні.", + "Renew root certificate": "Оновити кореневий сертифікат", + "This creates a new root and intermediate certificate. Every active encryption suite is signed again. You cannot undo this.": "Буде створено нові кореневий і проміжний сертифікати. Кожен активний набір шифрування буде підписано заново. Цю дію не можна скасувати.", + "Renew root": "Оновити корінь", + "Root renewed. {n} encryption suites signed again.": "Корінь оновлено. Заново підписано наборів шифрування: {n}.", + "Could not renew the root certificate.": "Не вдалося оновити кореневий сертифікат.", + "Lease policy for this application": "Політика оренди для цього застосунку", + "In force now: {default} seconds by default, {max} seconds at most.": "Зараз діє: типово {default} секунд, не більше {max} секунд.", + "Leases are not renewable": "Оренду не можна продовжувати", + "Lease policy saved.": "Політику оренди збережено.", + "Leave a field empty to use the instance value.": "Залиште поле порожнім, щоб використати значення екземпляра.", + "Instance value: {value}": "Значення екземпляра: {value}", + "Renewal": "Продовження", + "Use the instance value ({value})": "Використати значення екземпляра ({value})", + "Allowed": "Дозволено", + "Not allowed": "Не дозволено", + "Save lease policy": "Зберегти політику оренди", + "Only an administrator can change this policy.": "Змінити цю політику може лише адміністратор.", + "Could not save the lease policy.": "Не вдалося зберегти політику оренди.", + "{member} got access from {confirmer}.": "{member} отримав доступ від {confirmer}.", + "Automatically confirm new team folder members": "Автоматично підтверджувати нових учасників командних тек", + "Gave %n new member access to a team folder.": "%n новий учасник отримав доступ до командної теки.", + "Gave %n new members access to a team folder.": "Нові учасники (%n) отримали доступ до командної теки.", + "Give new team folder members access without waiting for the folder owner.": "Надавайте доступ новим учасникам, не чекаючи на власника теки.", + "New team folder members": "Нові учасники командних тек", + "The owner or a member with write access confirms them from their open vault. Keepiq never decrypts on the server.": "Власник або учасник із правом запису підтверджує їх із відкритого сховища. Keepiq ніколи не розшифровує на сервері.", + "Waiting for a member with write access to open Keepiq. You can also share now.": "Очікування, поки учасник із правом запису відкриє Keepiq. Можна поділитися й зараз.", + "Part of the compromise response failed ({failed} step(s)). Check the server log, then force-revoke the suite again to finish it.": "Частину реакції на компрометацію не виконано ({failed} крок(ів)). Перевірте журнал сервера, а потім знову відкличте набір, щоб завершити її.", + "This also revoked suite {suite} and ended key migration {migration}.": "Це також відкликало набір {suite} і завершило міграцію ключів {migration}.", + "Revoking the second suite deleted %n emergency-access contact.": "Відкликання другого набору видалило %n контакт аварійного доступу.", + "Revoking the second suite deleted %n emergency-access contacts.": "Відкликання другого набору видалило %n контактів аварійного доступу.", + "A suite revoked as compromised cannot be reinstated.": "Набір, відкликаний як скомпрометований, не можна відновити.", + "Archives to keep": "Скільки архівів зберігати", + "Back up every vault automatically": "Автоматично створювати копію кожного сховища", + "Back up every vault on a schedule. Archives hold ciphertext only and are restored with occ.": "Створюйте копію кожного сховища за розкладом. Архіви містять лише шифротекст і відновлюються через occ.", + "Back up now": "Створити копію зараз", + "Backup public key (PEM, optional)": "Відкритий ключ резервної копії (PEM, необов'язково)", + "Backup requested for the next cron run": "Копію замовлено на наступний запуск cron", + "Encrypted": "Зашифровано", + "Every (hours)": "Кожні (годин)", + "Last backup {when} failed: {error}": "Остання копія {when} не вдалася: {error}", + "Last backup {when} succeeded.": "Остання копія {when} створена.", + "No archives yet.": "Архівів ще немає.", + "Size": "Розмір", + "Vault backups": "Резервні копії сховища", + "With a key, every archive is encrypted to it. Keep the private key off this server: you need it to verify or restore.": "З ключем кожен архів шифрується для нього. Зберігайте закритий ключ поза цим сервером: він потрібен для перевірки чи відновлення.", + "Written": "Записано", + "%n user in scope has no two-factor login yet and cannot open the vault while this is on.": "%n користувач в області дії ще не має двофакторного входу й не може відкрити сховище, поки це ввімкнено.", + "%n users in scope have no two-factor login yet and cannot open the vault while this is on.": "Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено.", + "Backup codes do not count. If your users sign in through an identity provider with its own second factor, leave their groups out.": "Резервні коди не враховуються. Якщо ваші користувачі входять через постачальника ідентичності з власним другим фактором, виключіть їхні групи.", + "Block personal vault export": "Заборонити експорт особистого сховища", + "Keep work logins in team folders": "Зберігати робочі облікові дані в командних теках", + "Move to a team folder": "Перемістити до командної теки", + "Not in a team folder": "Не в командній теці", + "Only for these groups (empty is everyone)": "Лише для цих груп (порожньо означає всіх)", + "Require two-factor login before the vault opens": "Вимагати двофакторний вхід перед відкриттям сховища", + "Rules for every vault. Each applies to everyone, or only to the groups you choose.": "Правила для кожного сховища. Кожне діє для всіх або лише для вибраних груп.", + "Secret types that belong in a team folder": "Типи секретів, що належать до командної теки", + "Set up two-factor login": "Налаштувати двофакторний вхід", + "Team folder you can write to": "Командна тека, до якої ви можете записувати", + "Users cannot download a backup, CSV or transfer file. Their personal data package stays available.": "Користувачі не можуть завантажити резервну копію, CSV чи файл перенесення. Їхній пакет особистих даних лишається доступним.", + "Users cannot save these secret types in a personal folder.": "Користувачі не можуть зберігати ці типи секретів в особистій теці.", + "Vault policies": "Правила сховища", + "Your organisation does not allow exporting your personal vault. Your personal data package in your settings stays available.": "Ваша організація не дозволяє експорт особистого сховища. Ваш пакет особистих даних у налаштуваннях лишається доступним.", + "Your organisation keeps these secrets in a team folder. Move each one into a team folder.": "Ваша організація зберігає ці секрети в командній теці. Перемістіть кожен до командної теки.", + "Your organisation keeps this type of secret in a team folder. Pick one of your team folders, or one you can write to.": "Ваша організація зберігає цей тип секрету в командній теці. Виберіть одну зі своїх командних тек або ту, до якої ви можете записувати.", + "Your organisation requires two-factor login before you can open your vault.": "Ваша організація вимагає двофакторний вхід, перш ніж ви зможете відкрити сховище.", + "Allow passphrases made of words": "Allow passphrases made of words", + "Capitalise each word": "Capitalise each word", + "Include a number": "Include a number", + "Kind of key": "Kind of key", + "Number of words": "Number of words", + "Passphrase": "Passphrase", + "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.": "Set a minimum quality for secret values. The browser checks it before encryption, so the server never sees a value.", + "Separator": "Separator", + "Users pick how long the extension stays unlocked while idle. You set the longest they may pick.": "Користувачі обирають, як довго розширення залишається розблокованим під час бездіяльності. Ви встановлюєте найбільший час, який можна обрати.", + "Longest idle time before the extension locks": "Найбільший час бездіяльності до блокування розширення", + "1 minute": "1 хвилина", + "5 minutes": "5 хвилин", + "15 minutes": "15 хвилин", + "1 hour": "1 година", + "4 hours": "4 години", + "Connector": "Конектор", + "Directory (tenant) ID": "ІД каталогу (орендаря)", + "Application (client) ID": "ІД застосунку (клієнта)", + "Data collection rule immutable ID": "Незмінний ІД правила збору даних", + "Stream name": "Назва потоку", + "Splunk index (optional)": "Індекс Splunk (необов'язково)", + "Sourcetype (optional)": "Sourcetype (необов'язково)", + "Leave blank to keep the current one": "Залиште порожнім, щоб зберегти поточне значення", + "Splunk HTTP Event Collector": "Splunk HTTP Event Collector", + "Microsoft Sentinel": "Microsoft Sentinel", + "CEF over syslog": "CEF через syslog", + "Syslog JSON": "Syslog JSON", + "Webhook JSON": "Webhook JSON", + "Data collection endpoint (https URL)": "Кінцева точка збору даних (https URL)", + "HTTP Event Collector URL (https)": "URL HTTP Event Collector (https)", + "Client secret (write-only)": "Секрет клієнта (лише запис)", + "HEC token (write-only)": "Токен HEC (лише запис)", + "Forward whitelisted audit events to Splunk, Microsoft Sentinel, a syslog listener or a webhook. Payloads carry sanitized metadata only: no secret value, name, login or ciphertext ever leaves the server.": "Пересилайте дозволені події аудиту до Splunk, Microsoft Sentinel, приймача syslog або вебхука. Повідомлення містять лише очищені метадані: жодне секретне значення, ім'я, логін чи шифротекст ніколи не залишає сервер.", + "%n change waiting to sync": "%n зміна очікує синхронізації", + "%n changes waiting to sync": "%n змін очікують синхронізації", + "Changes that could not sync": "Зміни, які не вдалося синхронізувати", + "Choose a version": "Вибрати версію", + "Copy value": "Копіювати значення", + "Deleted": "Видалено", + "Discard": "Відкинути", + "Keep my offline change": "Залишити мою зміну без мережі", + "Keep the server version": "Залишити версію з сервера", + "Keepiq is read-only offline. Your administrator has not turned on offline edits.": "Без мережі Keepiq доступний лише для читання. Адміністратор не ввімкнув редагування без мережі.", + "Let users edit secrets offline": "Дозволити користувачам редагувати секрети без мережі", + "Not synced yet": "Ще не синхронізовано", + "Offline changes are kept on the device, encrypted to the user, and sync at the next online unlock. Sharing, folders and attachments still need a connection.": "Зміни без мережі залишаються на пристрої, зашифровані для користувача, і синхронізуються під час наступного розблокування в мережі. Спільний доступ, теки та вкладення й далі потребують з'єднання.", + "Offline. Edits, moves and deletes stay on this device and sync when you are back online. Sharing and attachments need a connection.": "Без мережі. Редагування, переміщення та видалення залишаються на цьому пристрої й синхронізуються, коли ви знову будете в мережі. Спільний доступ і вкладення потребують з'єднання.", + "Offline. Your changes stay on this device and sync when you are back online. Last synced {when}.": "Без мережі. Ваші зміни залишаються на цьому пристрої й синхронізуються, коли ви знову будете в мережі. Остання синхронізація {when}.", + "Open my changes": "Відкрити мої зміни", + "Sharing needs a connection": "Спільний доступ потребує з'єднання", + "Someone changed this secret on the server after your offline copy was made. Choose which version to keep.": "Хтось змінив цей секрет на сервері після створення вашої копії без мережі. Виберіть, яку версію залишити.", + "Sync or discard your offline changes before you rotate your keys.": "Синхронізуйте або відкиньте зміни без мережі, перш ніж замінювати ключі.", + "That password did not open your changes.": "Цей пароль не відкрив ваші зміни.", + "The offline snapshot stores secret ciphertext (openable only with the user's master-password-derived key, exactly as on the server) and encrypts secret names, URLs and folder names at rest. Offline access is read-only unless you allow offline edits below. Disable this for endpoints that must never cache credentials; disabling purges existing caches on next load.": "Знімок без мережі зберігає зашифровані секрети (відкриваються лише ключем, отриманим з головного пароля користувача, точно як на сервері) і шифрує назви, URL та назви тек під час зберігання. Доступ без мережі лише для читання, якщо нижче ви не дозволите редагування без мережі. Вимкніть це для пристроїв, які ніколи не мають кешувати облікові дані; вимкнення очищає наявні кеші під час наступного завантаження.", + "The previous vault copy is gone, so these changes cannot be opened.": "Попередньої копії сховища більше немає, тому ці зміни не можна відкрити.", + "The server version": "Версія з сервера", + "This secret changed while you were offline": "Цей секрет змінився, поки ви були без мережі", + "You deleted this secret offline, but it was changed on the server since. Choose which version to keep.": "Ви видалили цей секрет без мережі, але відтоді його змінили на сервері. Виберіть, яку версію залишити.", + "Your keys were changed on another device. Enter your previous master password to sync the changes you made offline, or discard them.": "Ваші ключі змінено на іншому пристрої. Введіть попередній головний пароль, щоб синхронізувати зміни без мережі, або відкиньте їх.", + "Your offline change": "Ваша зміна без мережі", + "_%n emergency contact had an access request pending when your key rotation removed it. Check who asked before you add anyone back._::_%n emergency contacts had an access request pending when your key rotation removed them. Check who asked before you add anyone back._": [ + "%n екстрений контакт мав запит на доступ в очікуванні, коли зміна ключа його видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати.", + "Екстрені контакти (%n) мали запит на доступ в очікуванні, коли зміна ключа їх видалила. Перевірте, хто запитував, перш ніж знову когось додавати." + ], + "_%n item cannot be represented in CXF and will be skipped._::_%n items cannot be represented in CXF and will be skipped._": [ + "%n елемент не можна подати у CXF, він буде пропущений.", + "%n елементів не можна подати у CXF, вони будуть пропущені.", + "%n елементів не можна подати у CXF, вони будуть пропущені." + ], + "_%n older version was dropped because only recent history can be carried across._::_%n older versions were dropped because only recent history can be carried across._": [ + "%n старішу версію було відкинуто, оскільки перенести можна лише нещодавню історію.", + "%n старіших версій було відкинуто, оскільки перенести можна лише нещодавню історію.", + "%n старіших версій було відкинуто, оскільки перенести можна лише нещодавню історію." + ], + "_%n secret copy still needs to be encrypted and shared._::_%n secret copies still need to be encrypted and shared._": [ + "Ще потрібно зашифрувати та надати %n копію секрету.", + "Ще потрібно зашифрувати та надати %n копій секретів.", + "Ще потрібно зашифрувати та надати %n копій секретів." + ], + "_%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 секрет не вдалося розшифрувати, і його немає в цьому експорті.", + "%n секретів не вдалося розшифрувати, і їх немає в цьому експорті.", + "%n секретів не вдалося розшифрувати, і їх немає в цьому експорті." + ], + "_%n secret could not be decrypted with your old key, so it did not migrate._::_%n secrets could not be decrypted with your old key, so they did not migrate._": [ + "%n секрет не вдалося розшифрувати вашим старим ключем, тому він не був перенесений.", + "%n секретів не вдалося розшифрувати вашим старим ключем, тому вони не були перенесені.", + "%n секретів не вдалося розшифрувати вашим старим ключем, тому вони не були перенесені." + ], + "_%n secret did not migrate._::_%n secrets did not migrate._": [ + "%n секрет не був перенесений.", + "%n секретів не були перенесені.", + "%n секретів не були перенесені." + ], + "_%n secret is still encrypted under your previous key._::_%n secrets are still encrypted under your previous key._": [ + "%n секрет усе ще зашифрований вашим попереднім ключем.", + "%n секретів усе ще зашифровані вашим попереднім ключем.", + "%n секретів усе ще зашифровані вашим попереднім ключем." + ], + "_%n secret was skipped because the successor holds no copy yet — add the successor to the folder and re-run._::_%n secrets were skipped because the successor holds no copy yet — add the successor to the folder and re-run._": [ + "%n секрет пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову.", + "%n секретів пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову.", + "%n секретів пропущено, оскільки наступник ще не має копії — додайте наступника до теки та запустіть знову." + ], + "_%n secret_::_%n secrets_": [ + "%n секрет", + "%n секретів", + "%n секретів" + ], + "_%n user in scope has no two-factor login yet and cannot open the vault while this is on._::_%n users in scope have no two-factor login yet and cannot open the vault while this is on._": [ + "%n користувач в області дії ще не має двофакторного входу й не може відкрити сховище, поки це ввімкнено.", + "Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено.", + "Користувачі в області дії (%n) ще не мають двофакторного входу й не можуть відкрити сховище, поки це ввімкнено." + ], + "_Emergency access for %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 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." + ], + "_Finish anyway, losing access to %n secret_::_Finish anyway, losing access to %n secrets_": [ + "Усе одно завершити, втративши доступ до %n секрета", + "Усе одно завершити, втративши доступ до %n секретів", + "Усе одно завершити, втративши доступ до %n секретів" + ], + "_Gave %n new member access to a team folder._::_Gave %n new members access to a team folder._": [ + "%n новий учасник отримав доступ до командної теки.", + "Нові учасники (%n) отримали доступ до командної теки.", + "Нові учасники (%n) отримали доступ до командної теки." + ], + "_Key rotation finished. %n secret was re-encrypted under your new key._::_Key rotation finished. %n secrets were re-encrypted under your new key._": [ + "Ротацію ключа завершено. %n секрет було перешифровано вашим новим ключем.", + "Ротацію ключа завершено. %n секретів було перешифровано вашим новим ключем.", + "Ротацію ключа завершено. %n секретів було перешифровано вашим новим ключем." + ], + "_Revoking the second suite deleted %n emergency-access contact._::_Revoking the second suite deleted %n emergency-access contacts._": [ + "Відкликання другого набору видалило %n контакт аварійного доступу.", + "Відкликання другого набору видалило %n контактів аварійного доступу.", + "Відкликання другого набору видалило %n контактів аварійного доступу." + ], + "_Revoking this suite deleted %n emergency-access contact._::_Revoking this suite deleted %n emergency-access contacts._": [ + "Відкликання цього набору видалило %n контакт аварійного доступу.", + "Відкликання цього набору видалило %n контактів аварійного доступу.", + "Відкликання цього набору видалило %n контактів аварійного доступу." + ], + "_seen %n time in breaches_::_seen %n times in breaches_": [ + "зустрічається у витоках %n раз", + "зустрічається у витоках %n разів", + "зустрічається у витоках %n разів" + ], + "_shared with %n secret_::_shared with %n secrets_": [ + "надано %n секрету", + "надано %n секретам", + "надано %n секретам" + ], + "_This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active._::_This also 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 contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.", + "This also 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." + ], + "_This folder contains %n secret directly._::_This folder contains %n secrets directly._": [ + "Ця тека містить %n секрет безпосередньо.", + "Ця тека містить %n секретів безпосередньо.", + "Ця тека містить %n секретів безпосередньо." + ], + "_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._": [ + "Ротація ключа видалила %n екстрений контакт. Перевірте «Надзвичайний доступ» і додайте його знову, якщо він вам ще потрібен.", + "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні.", + "Ротація ключа видалила %n екстрених контактів. Перевірте «Надзвичайний доступ» і додайте їх знову, якщо вони вам ще потрібні." + ], + "_%n change waiting to sync_::_%n changes waiting to sync_": [ + "%n зміна очікує синхронізації", + "%n змін очікують синхронізації", + "%n змін очікують синхронізації" + ], + "_The user is still in group {groups}, which is a member of a team folder. Remove them from the group or disable the account._::_The user is still in groups {groups}, which are members of team folders. Remove them from the groups or disable the account._": [ + "Користувач досі в групі {groups}, яка є учасником командної теки. Вилучіть його з групи або вимкніть обліковий запис.", + "Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис.", + "Користувач досі в групах {groups}, які є учасниками командних тек. Вилучіть його з груп або вимкніть обліковий запис." + ], + "Allow approval from another device": "Дозволити підтвердження з іншого пристрою", + "App": "Застосунок", + "Approve a new device": "Підтвердити новий пристрій", + "Approve from another device": "Підтвердити з іншого пристрою", + "Asked at": "Запитано о", + "Check that the new device shows these words:": "Переконайтеся, що новий пристрій показує ці слова:", + "Denied. If you did not ask, end your other sessions:": "Відхилено. Якщо ви цього не запитували, завершіть інші сеанси:", + "Device": "Пристрій", + "IP address": "IP-адреса", + "Let users unlock a new browser by approving it from a device where Keepiq is already unlocked.": "Дозволяє користувачам розблокувати новий браузер, підтвердивши його з пристрою, на якому Keepiq уже розблоковано.", + "New device approval": "Підтвердження нових пристроїв", + "Nextcloud security settings": "Налаштування безпеки Nextcloud", + "Only approve a device you are using right now.": "Підтверджуйте лише пристрій, яким користуєтеся просто зараз.", + "Open Keepiq on a device where it is unlocked and approve this one. Check that it shows the same words:": "Відкрийте Keepiq на пристрої, де його розблоковано, і підтвердьте цей пристрій. Переконайтеся, що там показано ті самі слова:", + "The approving device seals the unlock key to the new device. The server only passes it on and cannot open it.": "Пристрій, що підтверджує, запечатує ключ розблокування для нового пристрою. Сервер лише передає його і не може його відкрити.", + "The master password is not right, or the request has ended.": "Головний пароль неправильний, або запит завершився.", + "The request expired. Ask again or use your master password.": "Термін дії запиту минув. Запитайте знову або скористайтеся головним паролем.", + "The request was denied.": "Запит відхилено.", + "Too many requests. Try again in an hour or use your master password.": "Забагато запитів. Спробуйте знову за годину або скористайтеся головним паролем.", + "Unknown device": "Невідомий пристрій", + "Web app": "Вебзастосунок", + "A device": "Пристрій", + "A new device asks to open your vault": "Новий пристрій просить відкрити ваше сховище", + "%s asks to be approved. Only approve a device you are using right now.": "%s просить підтвердження. Підтверджуйте лише пристрій, яким користуєтеся просто зараз.", + "Access ends on (optional)": "Доступ закінчується (необов'язково)", + "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.": "Застосунки Keepiq не показуватимуть і не копіюватимуть пароль. Людина з технічними навичками все одно може прочитати його на своєму пристрої. Змініть його, коли доступ закінчиться.", + "This secret is use-only. Sign in through the Keepiq browser extension.": "Цей секрет лише для використання. Увійдіть через розширення браузера Keepiq.", + "Until {date}": "До {date}", + "Use only": "Лише використання", + "Use only (can sign in, cannot view or copy)": "Лише використання (може увійти, не може переглянути чи скопіювати)", + "You can sign in with this login through the Keepiq browser extension. Its owner chose not to let you view or copy it.": "Ви можете увійти з цими обліковими даними через розширення браузера Keepiq. Власник вирішив не дозволяти вам переглядати чи копіювати їх.", + "Your access ends on {date}": "Ваш доступ закінчується {date}", + "Your access to this secret has ended": "Ваш доступ до цього секрету закінчився", + "Your access to \"%s\" ends tomorrow": "Ваш доступ до «%s» закінчується завтра", + "Your access to \"%s\" has ended": "Ваш доступ до «%s» закінчився", + "%1$s no longer has access to \"%2$s\"": "%1$s більше не має доступу до «%2$s»", + "%1$s could see this password. Rotate it if %1$s should no longer know it.": "%1$s міг(ла) бачити цей пароль. Змініть його, якщо %1$s більше не має його знати.", + "%s could not view this password in Keepiq.": "%s не зміг(ла) переглянути цей пароль у Keepiq.", + "{approvals} of {threshold} approvals": "{approvals} з {threshold} підтверджень", + "a recovery officer": "відповідальний за відновлення", + "Account recovery": "Відновлення облікового запису", + "Approvals needed": "Потрібно підтверджень", + "Ask {user} which words they see, by phone or in person. They must be:": "Запитайте в {user}, які слова він бачить, телефоном або особисто. Вони мають бути такими:", + "Check again": "Перевірити ще раз", + "Create the recovery key": "Створити ключ відновлення", + "Create the recovery key. Your browser makes it and gives each officer a copy only they can open.": "Створіть ключ відновлення. Ваш браузер створює його й дає кожному відповідальному копію, яку може відкрити лише він.", + "Decline": "Відхилити", + "Enrol in account recovery": "Приєднатися до відновлення облікового запису", + "Enrol so your organisation can help you get your vault back if you forget your master password.": "Приєднайтеся, щоб організація могла допомогти вам повернути сховище, якщо ви забудете головний пароль.", + "Every user is enrolled": "Усі користувачі приєдналися", + "Finish the recovery in the browser you asked from.": "Завершіть відновлення в браузері, з якого ви його запитали.", + "Forgot your master password?": "Забули головний пароль?", + "Hand the key over": "Передати ключ", + "Let users who forgot their master password get their vault back, approved by recovery officers you name.": "Дозвольте користувачам, які забули головний пароль, повернути сховище з підтвердженням призначених вами відповідальних за відновлення.", + "New master password": "Новий головний пароль", + "No one is asking to recover their account.": "Ніхто не просить відновити обліковий запис.", + "No recovery key yet. One of the officers creates it in their Keepiq settings.": "Ключа відновлення ще немає. Один із відповідальних створює його у своїх налаштуваннях Keepiq.", + "Off": "Вимкнено", + "Officer {user} has no encryption set up yet.": "Відповідальний {user} ще не налаштував шифрування.", + "Officers (user IDs, separated by commas)": "Відповідальні (ID користувачів через кому)", + "Policy": "Політика", + "Publish this fingerprint internally, so users can check it before they enrol.": "Опублікуйте цей відбиток усередині організації, щоб користувачі могли перевірити його перед приєднанням.", + "Recovered with help from {officer}. Rotate your vault key now in Settings, Security: \"My master password was compromised\".": "Відновлено за допомогою {officer}. Змініть ключ сховища зараз у Налаштування, Безпека: \"Мій головний пароль скомпрометовано\".", + "Recovery key fingerprint: {fingerprint}": "Відбиток ключа відновлення: {fingerprint}", + "Recovery officer": "Відповідальний за відновлення", + "Removed officers lose their copy now, but may have opened it before. Have an officer create a new recovery key.": "Вилучені відповідальні втрачають свою копію зараз, але могли відкрити її раніше. Попросіть відповідального створити новий ключ відновлення.", + "Repeat the new master password": "Повторіть новий головний пароль", + "Retire this recovery key": "Вивести цей ключ відновлення з ужитку", + "Set the new master password": "Встановити новий головний пароль", + "The recovery certificate is not issued by this Keepiq. Do not enrol and tell your administrator.": "Сертифікат відновлення видано не цим Keepiq. Не приєднуйтеся та повідомте адміністратора.", + "The words match, approve": "Слова збігаються, підтвердити", + "This user is enrolled in account recovery. Recovering keeps their secrets; revoking deletes their enrolment.": "Цей користувач приєднався до відновлення облікового запису. Відновлення зберігає його секрети; відкликання видаляє його приєднання.", + "Users may enrol": "Користувачі можуть приєднуватися", + "Withdraw from account recovery": "Вийти з відновлення облікового запису", + "You are enrolled in account recovery. Recovery key fingerprint: {fingerprint}": "Ви приєдналися до відновлення облікового запису. Відбиток ключа відновлення: {fingerprint}", + "You are enrolled. If you forget your master password, your organisation can help you get your vault back.": "Ви приєдналися. Якщо ви забудете головний пароль, організація може допомогти вам повернути сховище.", + "Your key is back. Choose a new master password.": "Ваш ключ повернуто. Виберіть новий головний пароль.", + "Your recovery officers have been told. Read them these words when they call or meet you:": "Ваших відповідальних за відновлення повідомлено. Прочитайте їм ці слова, коли вони зателефонують або зустрінуться з вами:", + "You are now an account recovery officer": "Тепер ви відповідальний за відновлення облікових записів", + "%s asks to recover their account. Compare the words with them before you approve.": "%s просить відновити обліковий запис. Звірте з ним слова перед підтвердженням.", + "A user": "Користувач", + "Your account recovery request was declined": "Ваш запит на відновлення облікового запису відхилено", + "Your account recovery is ready. Open Keepiq in the browser you asked from.": "Відновлення облікового запису готове. Відкрийте Keepiq у браузері, з якого ви його запитали.", + "{user} asks to unlock a new device once. They keep their master password.": "{user} просить один раз розблокувати новий пристрій. Головний пароль залишається тим самим.", + "Ask your organisation instead": "Краще звернутися до своєї організації", + "The request ended. Ask again or use your master password.": "Запит завершено. Надішліть його знову або скористайтеся головним паролем.", + "Added by {user}": "Додано користувачем {user}", + "Editor": "Редактор", + "Manager": "Менеджер", + "Role of {member}": "Роль {member}", + "Team folders you manage": "Командні теки, якими ви керуєте", + "Viewer": "Читач", + "You hold no copy of these secrets, so the new members did not get them yet. The owner can share them: {names}": "У вас немає копії цих секретів, тому нові учасники їх ще не отримали. Власник може поділитися ними: {names}", + "Admin areas": "Області адміністрування", + "Give a group only the parts of Keepiq administration it needs.": "Надайте групі лише ті частини адміністрування Keepiq, які їй потрібні.", + "Delegate one or more areas to a group on the administration privileges page. Instance administrators hold every area.": "Делегуйте одну чи кілька областей групі на сторінці прав адміністрування. Адміністратори екземпляра мають усі області.", + "Open administration privileges": "Відкрити права адміністрування", + "Policies": "Політики", + "Applications and machine access": "Застосунки та доступ машин", + "People and offboarding": "Люди та звільнення", + "Audit and compliance": "Аудит і відповідність", + "version, certificate authority, attachments, offline cache, breach check, secret types and backups": "версія, центр сертифікації, вкладення, офлайн-кеш, перевірка витоків, типи секретів і резервні копії", + "master password, organisation password, vault policies, rotation, version history and trash": "головний пароль, пароль організації, політики сховища, ротація, історія версій і кошик", + "application queue, application requests and machine leases": "черга застосунків, запити застосунків і оренди машин", + "team offboarding, encryption suites and admin handover": "звільнення з команди, набори шифрування та передача адміністратору", + "audit log, compliance reports, SIEM export and honey alerts": "журнал аудиту, звіти про відповідність, експорт у SIEM і сповіщення про приманки", + "How many versions of a secret are kept, for how long, and how long deleted secrets stay in the trash.": "Скільки версій секрету зберігається, як довго і як довго видалені секрети залишаються в кошику.", + "Limits for encrypted file attachments, enforced on the server in stored ciphertext bytes.": "Обмеження для зашифрованих вкладень, які сервер застосовує до збережених зашифрованих байтів.", + "Type the suite ID again to confirm": "Введіть ID набору ще раз для підтвердження", + "This does not match the suite ID.": "Це не збігається з ID набору.", + "Confirm with your master password": "Підтвердьте головним паролем", + "Confirm": "Підтвердити", + "That master password is not right.": "Цей головний пароль неправильний.", + "You are sharing with someone new. Enter your master password to confirm.": "Ви ділитеся з новою людиною. Введіть головний пароль для підтвердження.", + "Enter your master password to confirm this share.": "Введіть головний пароль, щоб підтвердити цей спільний доступ.", + "Enter your master password to confirm this delegation.": "Введіть головний пароль, щоб підтвердити це делегування.", + "Approve {member}": "Схвалити {member}", + "Recipient": "Отримувач", + "No vault yet": "Ще немає сховища", + "No matching users": "Немає відповідних користувачів", + "Partner organisations": "Партнерські організації", + "Exchange secrets with another Keepiq. Both administrators add each other and compare the root fingerprints by phone or in person before saving.": "Обмінюйтеся секретами з іншим Keepiq. Обидва адміністратори додають одне одного й перед збереженням звіряють кореневі відбитки телефоном або особисто.", + "Federation needs Nextcloud 33 or later.": "Для федерації потрібен Nextcloud 33 або новіший.", + "Your root fingerprint": "Ваш кореневий відбиток", + "No partners yet.": "Партнерів ще немає.", + "Users here may share to this partner": "Користувачі тут можуть ділитися з цим партнером", + "This partner may share to users here": "Цей партнер може ділитися з користувачами тут", + "Partner address": "Адреса партнера", + "Check partner": "Перевірити партнера", + "Partner root fingerprint": "Кореневий відбиток партнера", + "I compared this fingerprint with the partner's administrator": "Я звірив цей відбиток з адміністратором партнера", + "Add partner": "Додати партнера", + "A secret from another organisation": "Секрет з іншої організації", + "%1$s shared \"%2$s\" with you. Accept it under Incoming from other organisations.": "Користувач %1$s надав вам доступ до \"%2$s\". Прийміть його в розділі Вхідні з інших організацій.", + "Incoming from other organisations": "Вхідні з інших організацій", + "People in partner organisations can share a secret with you. Accept it to keep a read-only copy in your vault.": "Люди з партнерських організацій можуть надати вам доступ до секрету. Прийміть його, щоб зберігати копію лише для читання у своєму сховищі.", + "Nothing shared with you yet": "Вам ще нічого не надано", + "Secrets that people in partner organisations share with you appear here.": "Тут з’являються секрети, до яких люди з партнерських організацій надають вам доступ.", + "From {sender}": "Від {sender}", + "Accept": "Прийняти", + "Open in vault": "Відкрити у сховищі", + "The other organisation did not hand over the secret. Try again later.": "Інша організація не передала секрет. Спробуйте пізніше.", + "Set up your vault before you accept a shared secret.": "Налаштуйте сховище, перш ніж приймати спільний секрет.", + "Something went wrong. Try again.": "Щось пішло не так. Спробуйте ще раз.", + "Waiting for your answer": "Очікує на вашу відповідь", + "In your vault, read-only": "У вашому сховищі, лише для читання", + "Withdrawn by the sender": "Відкликано відправником", + "{sender} shared this from another organisation. You can read it, but not change or share it.": "Користувач {sender} надав доступ до цього з іншої організації. Ви можете його читати, але не змінювати й не поширювати.", + "Someone": "Хтось", + "Share with someone at another organisation": "Поділитися з людиною з іншої організації", + "Their account at the other organisation": "Обліковий запис цієї людини в іншій організації", + "Check account": "Перевірити обліковий запис", + "Certificate fingerprint of {account}": "Відбиток сертифіката облікового запису {account}", + "Compare it with them by phone if you want to be sure.": "Звірте його з цією людиною телефоном, якщо хочете бути впевненими.", + "Shared. {account} can accept it in their own vault.": "Доступ надано. {account} може прийняти секрет у своєму сховищі.", + "The certificate could not be verified. Nothing was shared.": "Не вдалося перевірити сертифікат. Нічого не передано.", + "That organisation is not one of your partners.": "Ця організація не входить до ваших партнерів.", + "No one with that account can receive secrets from you.": "Ніхто з цим обліковим записом не може отримувати від вас секрети.", + "The other organisation did not answer. Try again later.": "Інша організація не відповіла. Спробуйте пізніше.", + "This secret is already shared with that account.": "Доступ до цього секрету вже надано цьому обліковому запису.", + "Other organisations": "Інші організації", + "Receive secrets from other organisations": "Отримувати секрети з інших організацій", + "People in partner organisations can then find your account and share secrets with you. You accept each one yourself.": "Тоді люди з партнерських організацій зможуть знайти ваш обліковий запис і надавати вам доступ до секретів. Кожен із них ви приймаєте самі.", + "Shared": "Доступ надано", + "Paused: their certificate or the partnership changed. Revoke it or share again.": "Призупинено: змінився їхній сертифікат або партнерство. Доступ можна відкликати або надати знову.", + "Their organisation did not get the last change. Revoke it or share again.": "Їхня організація не отримала останню зміну. Доступ можна відкликати або надати знову.", + "Being withdrawn": "Відкликається", + "Shared with another organisation": "Спільний доступ надано іншій організації", + "Change sent to another organisation": "Зміну надіслано іншій організації", + "Share with another organisation revoked": "Спільний доступ для іншої організації відкликано", + "Share with another organisation paused": "Спільний доступ для іншої організації призупинено", + "Another organisation did not get a change": "Інша організація не отримала зміну", + "Secret received from another organisation": "Отримано секрет з іншої організації", + "Secret from another organisation accepted": "Секрет з іншої організації прийнято", + "Secret from another organisation declined": "Секрет з іншої організації відхилено", + "Copy from another organisation updated": "Копію з іншої організації оновлено", + "Copy from another organisation removed": "Копію з іншої організації вилучено", + "Declined: they removed their copy. Share again if they need it.": "Відхилено: отримувач вилучив свою копію. Надайте доступ знову, якщо він потрібен.", + "Recipient at another organisation removed their copy": "Отримувач з іншої організації вилучив свою копію", + "Removed the user from %n team folder.": "Користувача вилучено з %n командної теки.", + "Removed the user from %n team folders.": "Користувача вилучено з командних тек: %n.", + "_Removed the user from %n team folder._::_Removed the user from %n team folders._": [ + "Користувача вилучено з %n командної теки.", + "Користувача вилучено з командних тек: %n.", + "Користувача вилучено з командних тек: %n." + ], + "A restored copy came from a share that has ended. It stays read-only.": "Відновлена копія походить зі спільного доступу, який завершився. Вона залишається лише для читання.", + "The organisation that shared a restored copy could not be reached. The copy stays read-only and does not follow their changes.": "Не вдалося зв’язатися з організацією, яка поділилася відновленою копією. Копія залишається лише для читання й не отримує їхніх змін.", + "Recipient at another organisation restored their copy": "Отримувач з іншої організації відновив свою копію" }, "plurals": null } diff --git a/lib/AppInfo/AdminAreaRegistrar.php b/lib/AppInfo/AdminAreaRegistrar.php new file mode 100644 index 000000000..25bd72703 --- /dev/null +++ b/lib/AppInfo/AdminAreaRegistrar.php @@ -0,0 +1,76 @@ + + * @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\AppInfo; + +use OCA\Keepiq\Settings\AdminSettings; +use OCA\Keepiq\Settings\ApplicationAdminSettings; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCA\Keepiq\Settings\PolicyAdminSettings; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +/** + * Binds every admin area id to an instance of exactly that class. MUST run + * after the AppHost engine, like DomainOverrideRegistrar. + */ +final class AdminAreaRegistrar { + /** + * Register the five admin area classes as themselves + * (admin-scoped-roles D1). + * + * The AppHost engine binds `AdminSettings` to an instance of its generic + * class, so `get_class()` on the delegation page and in + * `IManager::getAllowedAdminSettings()` named the generic, never Keepiq's + * class, and a delegation could never satisfy + * `#[AuthorizedAdminSetting(AdminSettings::class)]`. Registering the + * concrete classes here, after the engine, makes the registered instance + * the class a guard names. + * + * @param IRegistrationContext $context The registration context + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.2 + */ + public function register(IRegistrationContext $context): void { + $context->registerService( + AdminSettings::class, + static fn ($c) => new AdminSettings( + l10n: $c->get(\OCP\IL10N::class), + initialState: $c->get(\OCP\AppFramework\Services\IInitialState::class), + appManager: $c->get(\OCP\App\IAppManager::class), + appConfig: $c->get(\OCP\IAppConfig::class), + ) + ); + + foreach ([PolicyAdminSettings::class, ApplicationAdminSettings::class, PeopleAdminSettings::class, AuditAdminSettings::class] as $area) { + $context->registerService( + $area, + static fn ($c) => new $area( + l10n: $c->get(\OCP\IL10N::class), + initialState: $c->get(\OCP\AppFramework\Services\IInitialState::class), + ) + ); + } + }//end register() +}//end class diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9260c8356..42754e706 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -164,6 +164,13 @@ public function register(IRegistrationContext $context): void { // ORDER MATTERS here: a registerService() for an id the AppHost engine // already aliased only wins when it runs after that call. (new DomainOverrideRegistrar())->register(context: $context); + // The five admin areas, as themselves, so a delegation of an area + // satisfies the guard that names it (admin-scoped-roles D1). + (new AdminAreaRegistrar())->register(context: $context); + + // MCP opt-in (hermiq-ai-tooling): three metadata-only read tools for AI + // agents, registered only when OpenRegister is enabled. + (new McpRegistrar())->register(context: $context); // Domain event wiring, one registrar per trigger family. Each is // independent: a listener graph can be extended without touching the @@ -204,5 +211,8 @@ public function boot(IBootContext $context): void { OpenRegisterAutoloader::reportFailure( logger: $context->getServerContainer()->get(LoggerInterface::class) ); + + // The OCM provider of federated sharing registers at boot. + (new PlatformIntegrationRegistrar())->boot(context: $context); }//end boot() }//end class diff --git a/lib/AppInfo/DomainOverrideRegistrar.php b/lib/AppInfo/DomainOverrideRegistrar.php index e6e92ba1d..7c1f365fb 100644 --- a/lib/AppInfo/DomainOverrideRegistrar.php +++ b/lib/AppInfo/DomainOverrideRegistrar.php @@ -67,6 +67,9 @@ public function register(IRegistrationContext $context): void { userSession: $c->get(\OCP\IUserSession::class), logger: $c->get(\Psr\Log\LoggerInterface::class), eventDispatcher: $c->get(\OCP\EventDispatcher\IEventDispatcher::class), + // Container-built, so the password policy service it holds + // carries the vault policies (admin-vault-policies D1). + adminSettings: $c->get('OCA\Keepiq\Service\AdminSettingsService'), ) ); // SettingsControllerFactory spells out every argument, the integriq diff --git a/lib/AppInfo/FederationEventRegistrar.php b/lib/AppInfo/FederationEventRegistrar.php new file mode 100644 index 000000000..220333ed8 --- /dev/null +++ b/lib/AppInfo/FederationEventRegistrar.php @@ -0,0 +1,89 @@ + + * @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\AppInfo; + +use OCA\Keepiq\Federation\KeepiqSecretFederationProvider; +use OCA\Keepiq\Listener\FederationOcmDiscoveryListener; +use OCA\Keepiq\Listener\FederationOcmRequestListener; +use OCA\Keepiq\Service\FederatedShareService; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; +use OCP\OCM\Events\OCMEndpointRequestEvent; + +/** + * Wires the federation OCM listeners. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +final class FederationEventRegistrar { + /** + * Bind the OCM discovery and endpoint listeners. + * + * @param IRegistrationContext $context The registration context + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function register(IRegistrationContext $context): void { + $context->registerEventListener( + event: LocalOCMDiscoveryEvent::class, + listener: FederationOcmDiscoveryListener::class + ); + $context->registerEventListener( + event: OCMEndpointRequestEvent::class, + listener: FederationOcmRequestListener::class + ); + }//end register() + + /** + * Register the `keepiq-secret` OCM provider (task 3.1). Only where the + * OCM endpoint event exists (Nextcloud 33 and later): below that, + * federation stays off and no share of this type is accepted. + * + * @param IBootContext $context The boot context + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function boot(IBootContext $context): void { + if (class_exists(OCMEndpointRequestEvent::class) === false) { + return; + } + + $container = $context->getAppContainer(); + $context->injectFn( + static function (ICloudFederationProviderManager $manager) use ($container): void { + $manager->addCloudFederationProvider( + FederatedShareService::RESOURCE_TYPE, + 'Keepiq secret', + static fn () => $container->get(KeepiqSecretFederationProvider::class) + ); + } + ); + }//end boot() +}//end class diff --git a/lib/AppInfo/McpRegistrar.php b/lib/AppInfo/McpRegistrar.php new file mode 100644 index 000000000..65b478c75 --- /dev/null +++ b/lib/AppInfo/McpRegistrar.php @@ -0,0 +1,74 @@ + + * @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\AppInfo; + +use Closure; +use OCA\Keepiq\Mcp\KeepiqScannableServices; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +/** + * Registers Keepiq's MCP opt-in: the IMcpScannableServices::keepiq alias that + * tells OpenRegister's scanner which classes carry #[McpTool]. Only when + * OpenRegister is present and enabled; otherwise nothing is registered and + * Keepiq has no MCP surface at all. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-surface-is-exposed-only-through-the-scannable-services-opt-in + */ +class McpRegistrar { + + /** + * The alias OpenRegister enumerates, IMcpScannableServices::. + * + * @var string + */ + public const ALIAS = 'OCA\\OpenRegister\\Mcp\\IMcpScannableServices::keepiq'; + + /** + * Constructor. + * + * @param Closure|null $openRegisterPresent Answers whether OpenRegister is + * enabled; OpenRegisterAutoloader::register() when null. + * + * @return void + */ + public function __construct(private ?Closure $openRegisterPresent = null) { + }//end __construct() + + /** + * Register the alias when OpenRegister is there. + * + * @param IRegistrationContext $context The registration context + * + * @return bool Whether the alias was registered + * + * @SuppressWarnings(PHPMD.StaticAccess) The prelude is static by design (see OpenRegisterAutoloader). + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-surface-is-exposed-only-through-the-scannable-services-opt-in + */ + public function register(IRegistrationContext $context): bool { + $present = $this->openRegisterPresent ?? static fn (): bool => OpenRegisterAutoloader::register(); + if ($present() !== true) { + return false; + } + + $context->registerServiceAlias(self::ALIAS, KeepiqScannableServices::class); + return true; + }//end register() +}//end class diff --git a/lib/AppInfo/PlatformIntegrationRegistrar.php b/lib/AppInfo/PlatformIntegrationRegistrar.php index 3337b5e86..5c1d3c431 100644 --- a/lib/AppInfo/PlatformIntegrationRegistrar.php +++ b/lib/AppInfo/PlatformIntegrationRegistrar.php @@ -23,17 +23,19 @@ namespace OCA\Keepiq\AppInfo; use OCA\Keepiq\Middleware\JwtAuthMiddleware; +use OCA\Keepiq\Middleware\OcsRefusalMiddleware; use OCA\Keepiq\Middleware\VaultKeyProofMiddleware; use OCA\Keepiq\Notification\KeepiqNotifier; use OCA\Keepiq\Search\SecretSearchProvider; +use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IRegistrationContext; /** * Plugs Keepiq into Nextcloud's own extension points. * - * These three registrations are not Keepiq domain wiring — they are the - * places where the PLATFORM calls into this app: the unified-search bar, the - * notification renderer, and the request pipeline. They are grouped because + * These registrations are not Keepiq domain wiring — they are the places + * where the PLATFORM calls into this app: the unified-search bar, the + * notification renderer, the request pipeline and Open Cloud Mesh. They are grouped because * they share that direction of control and because each one is a single * class handed to a core registry, with no ordering relationship to the * domain listeners or the AppHost plumbing. @@ -69,5 +71,30 @@ public function register(IRegistrationContext $context): void { // passes through untouched. $context->registerMiddleware(VaultKeyProofMiddleware::class); + // Keeps a refusal of a Keepiq OCSController visible: Nextcloud's + // OCSMiddleware rewrites a 403 there into an HTTP 200 OCS envelope, so + // the controller's own 403 leaves as 428 with an `error` code. + $context->registerMiddleware(OcsRefusalMiddleware::class); + + // Open Cloud Mesh: Nextcloud's OCM discovery and endpoint-request + // events, through which partner instances reach the federation + // endpoints (sharing-federated-recipients). + (new FederationEventRegistrar())->register(context: $context); + }//end register() + + /** + * Boot-time wiring of the platform extension points: the Open Cloud + * Mesh provider for federated secrets, which Nextcloud registers through + * a service rather than a registration context. + * + * @param IBootContext $context The boot context + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function boot(IBootContext $context): void { + (new FederationEventRegistrar())->boot(context: $context); + }//end boot() }//end class diff --git a/lib/AppInfo/SettingsControllerFactory.php b/lib/AppInfo/SettingsControllerFactory.php index e3e0f913a..242a96817 100644 --- a/lib/AppInfo/SettingsControllerFactory.php +++ b/lib/AppInfo/SettingsControllerFactory.php @@ -17,7 +17,7 @@ * * @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 + * @spec openspec/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 @@ -28,8 +28,9 @@ namespace OCA\Keepiq\AppInfo; use OCA\Keepiq\Controller\SettingsController; -use OCA\Keepiq\Service\Connection\ConnectionReporter; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\SettingsService; +use OCA\Keepiq\Service\TwoFactorGate; use OCP\IRequest; use OCP\IUserSession; use Psr\Container\ContainerInterface; @@ -41,7 +42,7 @@ * 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 + * @spec openspec/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 { @@ -52,14 +53,15 @@ final class SettingsControllerFactory { * * @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 + * @spec openspec/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), + areas: $container->get(AdminAreaAuthorizer::class), + twoFactor: $container->get(TwoFactorGate::class), ); }//end __invoke() }//end class diff --git a/lib/AppInfo/SuiteLifecycleEventRegistrar.php b/lib/AppInfo/SuiteLifecycleEventRegistrar.php index 9013b0a1d..b6ad9f63d 100644 --- a/lib/AppInfo/SuiteLifecycleEventRegistrar.php +++ b/lib/AppInfo/SuiteLifecycleEventRegistrar.php @@ -30,8 +30,8 @@ use OCA\Keepiq\Listener\EmergencyAccessSuiteRevocationListener; use OCA\Keepiq\Listener\EmergencyAccessSuiteRotationListener; use OCA\Keepiq\Listener\EncryptionSuiteRevokedListener; +use OCA\Keepiq\Listener\RecoverySuiteListener; 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; @@ -43,9 +43,7 @@ * 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). + * not significant. * * Grouped as one registrar because all the listeners share a single trigger * family (a suite started migrating, finished migrating, or was revoked) and @@ -54,8 +52,8 @@ * * @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 + * size of that graph and grows by one with each listener it wires. + * Splitting it * would fragment one trigger family across files without reducing any real * dependency. */ @@ -102,20 +100,10 @@ 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 - ); + // The administrator's compromise cascade is NOT a listener here. It + // runs from CompromiseContainmentService, called by the force-revoke + // itself, so it can read the blast radius before any revoke sweeps it + // and report its failures to the administrator (keepiq#863, #864). // Emergency access — invalidate/clear recovery envelopes on a grantor's // suite rotation (compromise recovery) or revocation, and invalidate @@ -129,5 +117,16 @@ public function register(IRegistrationContext $context): void { listener: EmergencyAccessSuiteRevocationListener::class ); + // Organisation account recovery: enrolments and open requests follow + // the suite (crypto-organisation-account-recovery D7). + $context->registerEventListener( + event: SuiteMigrationCompletedEvent::class, + listener: RecoverySuiteListener::class + ); + $context->registerEventListener( + event: EncryptionSuiteRevokedEvent::class, + listener: RecoverySuiteListener::class + ); + }//end register() }//end class diff --git a/lib/Attribute/VaultKeyProofRequired.php b/lib/Attribute/VaultKeyProofRequired.php index 2ab4c463b..b391b3210 100644 --- a/lib/Attribute/VaultKeyProofRequired.php +++ b/lib/Attribute/VaultKeyProofRequired.php @@ -55,6 +55,10 @@ class VaultKeyProofRequired { * obtained for one guarded operation cannot be * presented to another. The client requests its * challenge with the same string. + * @param string $exemption Optional class name of a VaultKeyProofExemption. + * When it says the request is exempt, no proof is + * needed (keepiq#818: a share to a recipient the + * caller already shares with). Empty means always. * * @return void */ @@ -62,6 +66,7 @@ public function __construct( private array $binds = [], private string $subject = 'active', private string $purpose = '', + private string $exemption = '', ) { }//end __construct() @@ -91,4 +96,15 @@ public function getSubject(): string { public function getPurpose(): string { return $this->purpose; }//end getPurpose() + + /** + * The class name of the exemption that may waive the proof, or ''. + * + * @return string + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + public function getExemption(): string { + return $this->exemption; + }//end getExemption() }//end class diff --git a/lib/BackgroundJob/CheckRootCertificateExpiry.php b/lib/BackgroundJob/CheckRootCertificateExpiry.php index 75ae6ea48..f9aebeaa6 100644 --- a/lib/BackgroundJob/CheckRootCertificateExpiry.php +++ b/lib/BackgroundJob/CheckRootCertificateExpiry.php @@ -21,12 +21,15 @@ namespace OCA\Keepiq\BackgroundJob; -use DateTime; use Exception; use OCA\Keepiq\Db\CACertificateMapper; +use OCA\Keepiq\Service\NotificationService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use OCP\IGroupManager; use Psr\Log\LoggerInterface; +use Throwable; /** * Daily check: notify admins when the root certificate is approaching expiry. @@ -35,12 +38,21 @@ class CheckRootCertificateExpiry extends TimedJob { private const NOTIFICATION_THRESHOLDS = [90, 30, 7]; + /** + * App-config key holding ":" of the + * last announcement. + */ + private const ANNOUNCED_KEY = 'ca_root_expiry_announced'; + /** * Constructor for CheckRootCertificateExpiry. * * @param ITimeFactory $time The time factory * @param CACertificateMapper $caCertMapper The CA certificate mapper * @param LoggerInterface $logger The logger interface + * @param IGroupManager $groupManager The group manager (admin recipients) + * @param NotificationService $notificationService The notification dispatcher + * @param IAppConfig $appConfig Remembers which threshold was announced * * @return void */ @@ -48,6 +60,9 @@ public function __construct( ITimeFactory $time, private CACertificateMapper $caCertMapper, private LoggerInterface $logger, + private IGroupManager $groupManager, + private NotificationService $notificationService, + private IAppConfig $appConfig, ) { parent::__construct(time: $time); $this->setInterval(seconds: 86400); @@ -77,22 +92,85 @@ protected function run($argument): void { return; } - $daysUntilExpiry = (int)$expiresAt->diff(new DateTime())->format('%r%a'); + // Days from NOW until expiry, positive while the root is valid. The + // former expiry->diff(now) ran backwards, so no threshold ever matched + // and no admin heard about an expiring root (keepiq#741). + $now = $this->time->getDateTime(); + $daysUntilExpiry = (int)floor(($expiresAt->getTimestamp() - $now->getTimestamp()) / 86400); - foreach (self::NOTIFICATION_THRESHOLDS as $threshold) { - $lowerBound = 0; - if ($threshold === 90) { - $lowerBound = 30; - } elseif ($threshold === 30) { - $lowerBound = 7; - } + $threshold = $this->thresholdFor(daysUntilExpiry: $daysUntilExpiry); + if ($threshold === null) { + return; + } + + $this->logger->warning( + "Keepiq: Root certificate expires in {$daysUntilExpiry} days (threshold: {$threshold})" + ); + + // Announce each threshold of each root once, not every day: the key + // names the root's expiry, so a renewed root starts over. + $announced = $expiresAt->getTimestamp().':'.$threshold; + if ($this->appConfig->getValueString('keepiq', self::ANNOUNCED_KEY, '') === $announced) { + return; + } + + $this->notifyAdmins(daysUntilExpiry: $daysUntilExpiry, threshold: $threshold); + $this->appConfig->setValueString('keepiq', self::ANNOUNCED_KEY, $announced); + }//end run() + /** + * The threshold a day count falls in: (30, 90] is 90, (7, 30] is 30 and + * (0, 7] is 7. Null outside them, and for an expired root. + * + * @param int $daysUntilExpiry Days until the root expires + * + * @return int|null + * + * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-1 + */ + private function thresholdFor(int $daysUntilExpiry): ?int { + $lowerBound = 0; + foreach (array_reverse(self::NOTIFICATION_THRESHOLDS) as $threshold) { if ($daysUntilExpiry <= $threshold && $daysUntilExpiry > $lowerBound) { + return $threshold; + } + + $lowerBound = $threshold; + } + + return null; + }//end thresholdFor() + + /** + * Notify every admin that the root is expiring. + * + * @param int $daysUntilExpiry Days until the root expires + * @param int $threshold The threshold the day count falls in + * + * @return void + * + * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-1 + */ + private function notifyAdmins(int $daysUntilExpiry, int $threshold): void { + $adminGroup = $this->groupManager->get('admin'); + if ($adminGroup === null) { + return; + } + + foreach ($adminGroup->getUsers() as $admin) { + try { + $this->notificationService->notify( + subject: 'ca_root_expiring', + recipientId: $admin->getUID(), + params: ['days_left' => $daysUntilExpiry, 'threshold' => $threshold], + objectType: 'ca_root', + objectId: 'threshold-'.$threshold, + ); + } catch (Throwable $exception) { $this->logger->warning( - "Keepiq: Root certificate expires in {$daysUntilExpiry} days (threshold: {$threshold})" + 'Keepiq: root certificate expiry notification failed: '.$exception::class ); - break; } } - }//end run() + }//end notifyAdmins() }//end class diff --git a/lib/BackgroundJob/DeliverSiemEventsJob.php b/lib/BackgroundJob/DeliverSiemEventsJob.php index 2574ce42f..1953ff201 100644 --- a/lib/BackgroundJob/DeliverSiemEventsJob.php +++ b/lib/BackgroundJob/DeliverSiemEventsJob.php @@ -24,6 +24,7 @@ namespace OCA\Keepiq\BackgroundJob; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\SiemFailureOutcome; use OCA\Keepiq\Service\SiemService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; @@ -69,7 +70,7 @@ protected function run($argument): void { $this->siemService->deliverDue(); } catch (Throwable $exception) { $this->logger->warning( - 'Keepiq: SIEM delivery drain failed: ' . $exception->getMessage(), + 'Keepiq: SIEM delivery drain failed: '.(new SiemFailureOutcome())->classOf(exception: $exception), ['app' => Application::APP_ID] ); } diff --git a/lib/BackgroundJob/ExpireDeviceApprovalsJob.php b/lib/BackgroundJob/ExpireDeviceApprovalsJob.php new file mode 100644 index 000000000..d658ab7e3 --- /dev/null +++ b/lib/BackgroundJob/ExpireDeviceApprovalsJob.php @@ -0,0 +1,78 @@ + + * @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\BackgroundJob; + +use DateTime; +use OCA\Keepiq\Service\DeviceApprovalService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The device approval expiry sweep, every five minutes. + */ +class ExpireDeviceApprovalsJob extends TimedJob { + + /** + * Constructor for ExpireDeviceApprovalsJob. + * + * @param ITimeFactory $time The time factory + * @param DeviceApprovalService $approvals The approval service + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + ITimeFactory $time, + private DeviceApprovalService $approvals, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 300); + }//end __construct() + + /** + * Expire lapsed requests. + * + * @param mixed $argument Unused job argument + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Mandated by TimedJob::run(). + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + protected function run($argument): void { + try { + $this->approvals->expireLapsed(now: new DateTime()); + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: device approval expiry failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end run() +}//end class diff --git a/lib/BackgroundJob/ExpireRecoveryRequestsJob.php b/lib/BackgroundJob/ExpireRecoveryRequestsJob.php new file mode 100644 index 000000000..fda41c9c1 --- /dev/null +++ b/lib/BackgroundJob/ExpireRecoveryRequestsJob.php @@ -0,0 +1,78 @@ + + * @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\BackgroundJob; + +use DateTime; +use OCA\Keepiq\Service\RecoveryRequestService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The account recovery expiry sweep, every hour. + */ +class ExpireRecoveryRequestsJob extends TimedJob { + + /** + * Constructor for ExpireRecoveryRequestsJob. + * + * @param ITimeFactory $time The time factory + * @param RecoveryRequestService $requests The request service + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + ITimeFactory $time, + private RecoveryRequestService $requests, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 3600); + }//end __construct() + + /** + * Expire lapsed recovery requests. + * + * @param mixed $argument Unused job argument + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Mandated by TimedJob::run(). + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-a-recovery-request-carries-a-one-time-key-and-a-verification-phrase + */ + protected function run($argument): void { + try { + $this->requests->expireLapsed(now: new DateTime()); + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: account recovery expiry failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end run() +}//end class diff --git a/lib/BackgroundJob/ExpireSharesJob.php b/lib/BackgroundJob/ExpireSharesJob.php new file mode 100644 index 000000000..00a01455c --- /dev/null +++ b/lib/BackgroundJob/ExpireSharesJob.php @@ -0,0 +1,128 @@ + + * @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\BackgroundJob; + +use DateInterval; +use DateTime; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\ShareExpiryService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * The share expiry sweep. + */ +class ExpireSharesJob extends TimedJob { + + /** + * App config key holding the end of the last warning window, so each + * holder is warned once however often the job runs. + * + * @var string + */ + public const WARNED_UNTIL_KEY = 'share_access_warned_until'; + + /** + * Constructor for ExpireSharesJob. + * + * @param ITimeFactory $time The time factory + * @param ShareExpiryService $expiry The expiry service + * @param IAppConfig $appConfig The app config (warning window) + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + ITimeFactory $time, + private ShareExpiryService $expiry, + private IAppConfig $appConfig, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 900); + }//end __construct() + + /** + * Warn, then expire. Fail-soft: a failed warning never blocks expiry. + * + * @param mixed $argument Unused job argument + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Mandated by TimedJob::run(). + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + protected function run($argument): void { + $now = new DateTime(); + $this->warn(now: $now); + + try { + $ended = $this->expiry->expire(now: $now); + if ($ended > 0) { + $this->logger->info('Keepiq: ended access to ' . $ended . ' shared secret(s)', ['app' => 'keepiq']); + } + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: share expiry failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end run() + + /** + * Warn the holders whose access ends between the end of the previous + * window and a day from now. + * + * @param DateTime $now The current time + * + * @return void + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-people-are-told-before-and-when-access-ends + */ + private function warn(DateTime $now): void { + $until = (clone $now)->add(new DateInterval('P1D')); + $from = $now; + $saved = $this->appConfig->getValueInt(Application::APP_ID, self::WARNED_UNTIL_KEY, 0); + if ($saved > $now->getTimestamp()) { + $from = (new DateTime())->setTimestamp($saved); + } + + try { + $this->expiry->warnEnding(from: $from, to: $until); + $this->appConfig->setValueInt(Application::APP_ID, self::WARNED_UNTIL_KEY, $until->getTimestamp()); + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: share expiry warning failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end warn() +}//end class diff --git a/lib/BackgroundJob/PurgeTrashedSecretsJob.php b/lib/BackgroundJob/PurgeTrashedSecretsJob.php new file mode 100644 index 000000000..7cb35a7a6 --- /dev/null +++ b/lib/BackgroundJob/PurgeTrashedSecretsJob.php @@ -0,0 +1,177 @@ + + * @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\BackgroundJob; + +use DateTime; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretTrashMapper; +use OCA\Keepiq\Service\SecretService; +use OCA\Keepiq\Service\SecretTrashService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Daily purge of the trash past its retention. + */ +class PurgeTrashedSecretsJob extends TimedJob { + /** + * Rows read per batch. + * + * @var int + */ + private const BATCH = 500; + + /** + * Batches per run, so one run stays bounded. + * + * @var int + */ + private const MAX_BATCHES = 20; + + /** + * Constructor for PurgeTrashedSecretsJob. + * + * @param ITimeFactory $time The time factory + * @param SecretTrashMapper $trashMapper Reads the trash across users + * @param SecretService $secretService The full delete cascade + * @param IAppConfig $appConfig The app config (retention) + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + ITimeFactory $time, + private SecretTrashMapper $trashMapper, + private SecretService $secretService, + private IAppConfig $appConfig, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 86400); + }//end __construct() + + /** + * The days a trashed secret is kept, clamped to 1 to 365. + * + * @return int + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + public function retentionDays(): int { + $days = $this->appConfig->getValueInt( + Application::APP_ID, + 'trash_retention_days', + SecretTrashService::RETENTION_DEFAULT + ); + + return max(SecretTrashService::RETENTION_MIN, min(SecretTrashService::RETENTION_MAX, $days)); + }//end retentionDays() + + /** + * Purge every secret trashed longer ago than the retention, fail-soft + * per secret. + * + * @param DateTime $now The current instant + * + * @return int The number of secrets purged + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + public function purgeExpired(DateTime $now): int { + $cutoff = (clone $now)->modify('-'.$this->retentionDays().' days'); + $purged = 0; + for ($batch = 0; $batch < self::MAX_BATCHES; $batch++) { + $rows = $this->trashMapper->findTrashedBefore(cutoff: $cutoff, limit: self::BATCH); + $done = 0; + foreach ($rows as $secret) { + $done += $this->purgeOne(secret: $secret); + } + + $purged += $done; + if (count($rows) < self::BATCH || $done === 0) { + break; + } + } + + return $purged; + }//end purgeExpired() + + /** + * Run the purge (fail-soft). + * + * @param mixed $argument Unused job argument + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $argument is mandated by + * OCP\BackgroundJob\TimedJob::run(); this job carries no cron payload. + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + protected function run($argument): void { + try { + $purged = $this->purgeExpired(now: $this->time->getDateTime()); + if ($purged > 0) { + $this->logger->info( + 'Keepiq: purged '.$purged.' secrets from the trash', + ['app' => Application::APP_ID] + ); + } + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: trash purge failed: '.$exception->getMessage(), + ['app' => Application::APP_ID] + ); + } + }//end run() + + /** + * Purge one expired secret; a failure is logged and skipped. + * + * @param Secret $secret The trashed secret past retention + * + * @return int 1 when purged, 0 when it failed + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + private function purgeOne(Secret $secret): int { + try { + $this->secretService->delete($secret->getId(), $secret->getOwnerId(), 'retention'); + } catch (Throwable $e) { + $this->logger->warning( + 'Keepiq: trash purge failed for secret '.$secret->getId().': '.$e->getMessage(), + ['app' => Application::APP_ID] + ); + return 0; + } + + return 1; + }//end purgeOne() +}//end class diff --git a/lib/BackgroundJob/RenewIntermediateCertificate.php b/lib/BackgroundJob/RenewIntermediateCertificate.php index a9c90af86..aab8054bb 100644 --- a/lib/BackgroundJob/RenewIntermediateCertificate.php +++ b/lib/BackgroundJob/RenewIntermediateCertificate.php @@ -21,7 +21,6 @@ namespace OCA\Keepiq\BackgroundJob; -use DateTime; use Exception; use OCA\Keepiq\Db\CACertificateMapper; use OCA\Keepiq\Service\CertificateAuthorityService; @@ -78,7 +77,12 @@ protected function run($argument): void { return; } - $daysUntilExpiry = (int)$expiresAt->diff(new DateTime())->format('%r%a'); + // Days from NOW until expiry: positive while the certificate is still + // valid. The former expiry->diff(now) ran backwards and read negative + // for every valid certificate, so this job renewed every night and + // re-signed every suite each time (keepiq#741). + $now = $this->time->getDateTime(); + $daysUntilExpiry = (int)floor(($expiresAt->getTimestamp() - $now->getTimestamp()) / 86400); if ($daysUntilExpiry > 30) { return; } @@ -86,7 +90,7 @@ protected function run($argument): void { $this->logger->info("Keepiq: Intermediate certificate expires in {$daysUntilExpiry} days, auto-renewing"); try { - $count = $this->caService->renewIntermediate(forced: false); + $count = $this->caService->renewIntermediate(); $this->logger->info("Keepiq: Intermediate auto-renewed, {$count} suites re-signed"); } catch (Exception $e) { $this->logger->error( diff --git a/lib/BackgroundJob/RetryFederatedNotificationsJob.php b/lib/BackgroundJob/RetryFederatedNotificationsJob.php new file mode 100644 index 000000000..286bfbfbd --- /dev/null +++ b/lib/BackgroundJob/RetryFederatedNotificationsJob.php @@ -0,0 +1,84 @@ + + * @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\BackgroundJob; + +use DateTime; +use OCA\Keepiq\Service\FederatedNotificationDelivery; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Retries failed federated share notifications. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ +class RetryFederatedNotificationsJob extends TimedJob { + /** + * Constructor for RetryFederatedNotificationsJob. + * + * @param ITimeFactory $time The time factory + * @param FederatedNotificationDelivery $delivery Delivers with backoff + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + ITimeFactory $time, + private FederatedNotificationDelivery $delivery, + private LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 60); + }//end __construct() + + /** + * Retry what is due. + * + * @param mixed $argument Unused job argument + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by TimedJob. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + protected function run($argument): void { + try { + $delivered = $this->delivery->retryDue(now: new DateTime()); + if ($delivered > 0) { + $this->logger->info('Keepiq: delivered ' . $delivered . ' federated share notification(s) on retry', ['app' => 'keepiq']); + } + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: federated notification retry failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end run() +}//end class diff --git a/lib/BackgroundJob/ScanCertificateExpiryJob.php b/lib/BackgroundJob/ScanCertificateExpiryJob.php index 418f44453..1f0131332 100644 --- a/lib/BackgroundJob/ScanCertificateExpiryJob.php +++ b/lib/BackgroundJob/ScanCertificateExpiryJob.php @@ -101,8 +101,10 @@ protected function run($argument): void { try { $this->scanOne(suite: $suite, now: $now); } catch (Throwable $exception) { + // The class, not the message: a message is unbounded and can + // carry whatever the failing call put in it (keepiq#728). $this->logger->warning( - 'Keepiq: certificate expiry scan failed for suite ' . $suite->getId() . ': ' . $exception->getMessage(), + 'Keepiq: certificate expiry scan failed for suite '.$suite->getId().': '.$exception::class, ['app' => Application::APP_ID] ); } diff --git a/lib/BackgroundJob/ScanExpiringSecretsJob.php b/lib/BackgroundJob/ScanExpiringSecretsJob.php index 6d29ec546..6b4a0dc12 100644 --- a/lib/BackgroundJob/ScanExpiringSecretsJob.php +++ b/lib/BackgroundJob/ScanExpiringSecretsJob.php @@ -106,8 +106,10 @@ protected function run($argument): void { try { $this->scanOne(secret: $secret, thresholds: $thresholds, now: $now); } catch (Throwable $exception) { + // The class, not the message: a message is unbounded and can + // carry whatever the failing call put in it (keepiq#728). $this->logger->warning( - 'Keepiq: expiry scan failed for secret ' . $secret->getId() . ': ' . $exception->getMessage(), + 'Keepiq: expiry scan failed for secret '.$secret->getId().': '.$exception::class, ['app' => Application::APP_ID] ); } @@ -163,7 +165,10 @@ private function scanOne(Secret $secret, array $thresholds, \DateTimeInterface $ }//end if // Approaching: the daily cadence means daysLeft passes each integer - // exactly once, so an exact threshold match is naturally deduped. + // exactly once, so an exact threshold match is naturally deduped. A + // policy that sets its own reminder days replaces the instance-wide + // thresholds for the secrets it scopes (keepiq#746). + $thresholds = ($this->rotationService->reminderDaysFor(secret: $secret) ?? $thresholds); if (in_array($daysLeft, array_map('intval', $thresholds), true) === true) { $this->notificationService->notify( subject: 'secret_expiring', diff --git a/lib/BackgroundJob/ScheduledVaultBackupJob.php b/lib/BackgroundJob/ScheduledVaultBackupJob.php new file mode 100644 index 000000000..51ce82b00 --- /dev/null +++ b/lib/BackgroundJob/ScheduledVaultBackupJob.php @@ -0,0 +1,80 @@ + + * @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\BackgroundJob; + +use OCA\Keepiq\Backup\BackupService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJob; +use OCP\BackgroundJob\TimedJob; +use Throwable; + +/** + * Hourly check that runs a vault backup when due. + */ +class ScheduledVaultBackupJob extends TimedJob { + /** + * Constructor. + * + * @param ITimeFactory $time The time factory + * @param BackupService $backups The backup service + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + ITimeFactory $time, + private BackupService $backups, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 3600); + $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE); + }//end __construct() + + /** + * Back up when due. A failure is recorded, audited and logged by the + * service; it never breaks the cron run. + * + * @param mixed $argument The job argument (unused) + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $argument is mandated by TimedJob::run(). + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + protected function run($argument): void { + if ($this->backups->isDue() === false) { + return; + } + + try { + $this->backups->createBackup(); + } catch (Throwable) { + // Recorded as the last status and as a BACKUP_FAILED audit entry. + return; + } + }//end run() +}//end class diff --git a/lib/Backup/ArchiveCipher.php b/lib/Backup/ArchiveCipher.php new file mode 100644 index 000000000..d4839524d --- /dev/null +++ b/lib/Backup/ArchiveCipher.php @@ -0,0 +1,337 @@ + + * @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\Backup; + +use InvalidArgumentException; +use phpseclib4\Crypt\PublicKeyLoader; +use phpseclib4\Crypt\RSA; +use RuntimeException; +use Throwable; + +/** + * Encrypts and decrypts backup archives. + * + * @SuppressWarnings(PHPMD.StaticAccess) phpseclib's key loader is static by design. + */ +class ArchiveCipher { + /** + * The first bytes of an encrypted archive. + */ + public const MAGIC = "KEEPIQ-BACKUP-ENC-1\n"; + + /** + * Plaintext bytes per segment. + */ + private const SEGMENT = 1048576; + + /** + * Whether a file is an encrypted archive. + * + * @param string $path The file + * + * @return bool + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-can-be-encrypted-to-an-administrator-held-key + */ + public function isEncrypted(string $path): bool { + $handle = fopen($path, 'rb'); + if ($handle === false) { + return false; + } + + $head = (string)fread($handle, strlen(self::MAGIC)); + fclose($handle); + + return $head === self::MAGIC; + }//end isEncrypted() + + /** + * Whether a PEM is a usable RSA public key or certificate. + * + * @param string $pem The PEM + * + * @return bool + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function isValidPublicKey(string $pem): bool { + try { + $this->publicKey(pem: $pem); + return true; + } catch (Throwable) { + return false; + } + }//end isValidPublicKey() + + /** + * Encrypt a plaintext archive file to a public key. + * + * @param string $plainPath The plaintext archive + * @param string $outPath Where to write the encrypted archive + * @param string $publicPem The recipient public key or certificate + * + * @return void + * + * @throws InvalidArgumentException When the key is unusable + * @throws RuntimeException When a file cannot be read or written + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-can-be-encrypted-to-an-administrator-held-key + */ + public function encryptFile(string $plainPath, string $outPath, string $publicPem): void { + $contentKey = random_bytes(32); + $wrapped = $this->publicKey(pem: $publicPem)->encrypt($contentKey); + $header = (string)json_encode( + [ + 'format' => 'keepiq-vault-backup-enc-v1', + 'cipher' => 'AES-256-GCM', + 'keyWrap' => 'RSA-OAEP-SHA256', + 'segmentSize' => self::SEGMENT, + 'wrappedKey' => base64_encode((string)$wrapped), + ] + ); + + $source = $this->open(path: $plainPath, mode: 'rb'); + $sink = $this->open(path: $outPath, mode: 'wb'); + fwrite($sink, self::MAGIC . pack('N', strlen($header)) . $header); + + $index = 0; + $chunk = (string)fread($source, self::SEGMENT); + do { + $next = (string)fread($source, self::SEGMENT); + $final = ($next === ''); + $nonce = random_bytes(12); + $tag = ''; + $cipher = openssl_encrypt( + $chunk, + 'aes-256-gcm', + $contentKey, + OPENSSL_RAW_DATA, + $nonce, + $tag, + $this->aad(index: $index, final: $final) + ); + if ($cipher === false) { + throw new RuntimeException('Archive encryption failed'); + } + + fwrite($sink, pack('N', strlen($cipher)) . $nonce . $cipher . $tag); + $chunk = $next; + $index++; + } while ($final === false); + + fclose($source); + fclose($sink); + }//end encryptFile() + + /** + * Decrypt an encrypted archive with the private key. + * + * @param string $encPath The encrypted archive + * @param string $outPath Where to write the plaintext archive + * @param string $privatePem The recipient private key + * + * @return void + * + * @throws InvalidArgumentException When the key is wrong or the archive was changed + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-can-be-encrypted-to-an-administrator-held-key + */ + public function decryptFile(string $encPath, string $outPath, string $privatePem): void { + $source = $this->open(path: $encPath, mode: 'rb'); + try { + if ((string)fread($source, strlen(self::MAGIC)) !== self::MAGIC) { + throw new InvalidArgumentException('Not an encrypted Keepiq backup'); + } + + $header = json_decode((string)fread($source, (int)(unpack('N', (string)fread($source, 4))[1] ?? 0)), true); + $contentKey = $this->unwrapKey(header: (array)$header, privatePem: $privatePem); + $this->decryptSegments(source: $source, outPath: $outPath, contentKey: $contentKey); + } finally { + fclose($source); + } + }//end decryptFile() + + /** + * Unwrap the content key with the administrator's private key. + * + * @param array $header The archive header + * @param string $privatePem The private key + * + * @return string The 32-byte content key + * + * @throws InvalidArgumentException When the key does not open the archive + */ + private function unwrapKey(array $header, string $privatePem): string { + try { + $private = PublicKeyLoader::load($privatePem); + $wrapped = base64_decode((string)($header['wrappedKey'] ?? ''), true); + $contentKey = null; + if ($private instanceof RSA\PrivateKey && $wrapped !== false) { + $contentKey = $private->withPadding(RSA::ENCRYPTION_OAEP)->withHash('sha256')->withMGFHash('sha256') + ->decrypt($wrapped); + } + } catch (Throwable) { + $contentKey = null; + } + + if (is_string($contentKey) === false || strlen($contentKey) !== 32) { + throw new InvalidArgumentException('The key does not open this backup'); + } + + return $contentKey; + }//end unwrapKey() + + /** + * Decrypt every segment into the output file, refusing any change, + * reorder or cut. + * + * @param resource $source The archive, positioned after the header + * @param string $outPath Where to write the plaintext + * @param string $contentKey The content key + * + * @return void + * + * @throws InvalidArgumentException When a segment fails or the last one is missing + */ + private function decryptSegments($source, string $outPath, string $contentKey): void { + $sink = $this->open(path: $outPath, mode: 'wb'); + $index = 0; + $sawFinal = false; + try { + while (($lengthBytes = (string)fread($source, 4)) !== '') { + $length = (int)(unpack('N', $lengthBytes)[1] ?? 0); + $nonce = (string)fread($source, 12); + $cipher = ''; + if ($length > 0) { + $cipher = (string)fread($source, $length); + } + + $tag = (string)fread($source, 16); + $final = $this->atEnd(handle: $source); + $plain = openssl_decrypt($cipher, 'aes-256-gcm', $contentKey, OPENSSL_RAW_DATA, $nonce, $tag, $this->aad(index: $index, final: $final)); + if ($plain === false) { + throw new InvalidArgumentException('The backup was changed or cut off'); + } + + fwrite($sink, $plain); + $sawFinal = $final; + $index++; + } + } finally { + fclose($sink); + } + + if ($sawFinal === false) { + throw new InvalidArgumentException('The backup was changed or cut off'); + } + }//end decryptSegments() + + /** + * The additional data that binds a segment to its place. + * + * @param int $index The segment index + * @param bool $final Whether it is the last segment + * + * @return string + */ + private function aad(int $index, bool $final): string { + $flag = "\x00"; + if ($final === true) { + $flag = "\x01"; + } + + return pack('J', $index) . $flag; + }//end aad() + + /** + * Whether a stream has no more bytes, without consuming any. + * + * @param resource $handle The stream + * + * @return bool + */ + private function atEnd($handle): bool { + $position = ftell($handle); + $probe = fread($handle, 1); + if ($probe === false || $probe === '') { + return true; + } + + fseek($handle, (int)$position); + + return false; + }//end atEnd() + + /** + * Load a public key from a PEM key or certificate, OAEP-SHA256 ready. + * + * @param string $pem The PEM + * + * @return RSA\PublicKey + * + * @throws InvalidArgumentException When it is not an RSA public key + */ + private function publicKey(string $pem): RSA\PublicKey { + $resource = openssl_pkey_get_public($pem); + if ($resource === false) { + throw new InvalidArgumentException('Not a public key or certificate'); + } + + $details = openssl_pkey_get_details($resource); + $key = PublicKeyLoader::load((string)($details['key'] ?? '')); + if (($key instanceof RSA\PublicKey) === false) { + throw new InvalidArgumentException('Only RSA keys are supported'); + } + + return $key->withPadding(RSA::ENCRYPTION_OAEP)->withHash('sha256')->withMGFHash('sha256'); + }//end publicKey() + + /** + * Open a file or fail loudly. + * + * @param string $path The file + * @param string $mode The fopen mode + * + * @return resource + * + * @throws RuntimeException When it cannot be opened + */ + private function open(string $path, string $mode) { + $handle = fopen($path, $mode); + if ($handle === false) { + throw new RuntimeException('Cannot open ' . $path); + } + + return $handle; + }//end open() +}//end class diff --git a/lib/Backup/ArchiveReader.php b/lib/Backup/ArchiveReader.php new file mode 100644 index 000000000..9090afd10 --- /dev/null +++ b/lib/Backup/ArchiveReader.php @@ -0,0 +1,141 @@ + + * @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\Backup; + +use InvalidArgumentException; +use ZipArchive; + +/** + * Reads and verifies backup archives. + */ +class ArchiveReader { + /** + * Verify an archive and return its manifest. + * + * @param string $zipPath The plaintext zip + * + * @return array The manifest + * + * @throws InvalidArgumentException When the archive is not a complete, unchanged backup + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function verify(string $zipPath): array { + $zip = $this->open(zipPath: $zipPath); + $manifest = json_decode((string)$zip->getFromName('manifest.json'), true); + if (is_array($manifest) === false || ($manifest['format'] ?? '') !== ArchiveWriter::FORMAT) { + $zip->close(); + throw new InvalidArgumentException('Not a Keepiq vault backup (no valid manifest)'); + } + + $files = (array)($manifest['files'] ?? []); + foreach (BackupTableRegistry::TABLES as $table) { + if (isset($files['tables/' . $table . '.jsonl']) === false) { + $zip->close(); + throw new InvalidArgumentException('The backup has no data for table ' . $table); + } + } + + foreach ($files as $name => $meta) { + $content = $zip->getFromName((string)$name); + if ($content === false || hash('sha256', $content) !== ($meta['sha256'] ?? '')) { + $zip->close(); + throw new InvalidArgumentException('Checksum mismatch in ' . $name); + } + } + + $zip->close(); + + return $manifest; + }//end verify() + + /** + * The stored rows of one table. + * + * @param string $zipPath The verified zip + * @param string $table The unprefixed table + * + * @return iterable> + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function rows(string $zipPath, string $table): iterable { + $zip = $this->open(zipPath: $zipPath); + $stream = $zip->getStream('tables/' . $table . '.jsonl'); + if ($stream === false) { + $zip->close(); + return; + } + + while (($line = fgets($stream)) !== false) { + $line = trim($line); + if ($line !== '') { + yield (array)json_decode($line, true); + } + } + + fclose($stream); + $zip->close(); + }//end rows() + + /** + * The attachment blobs, by blob ref. + * + * @param string $zipPath The verified zip + * + * @return iterable + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function blobs(string $zipPath): iterable { + $zip = $this->open(zipPath: $zipPath); + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = (string)$zip->getNameIndex($index); + if (str_starts_with($name, 'blobs/') === true) { + yield substr($name, 6) => (string)$zip->getFromIndex($index); + } + } + + $zip->close(); + }//end blobs() + + /** + * Open a zip or say it is not one. + * + * @param string $zipPath The zip + * + * @return ZipArchive + * + * @throws InvalidArgumentException When it is not a readable zip + */ + private function open(string $zipPath): ZipArchive { + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::RDONLY) !== true) { + throw new InvalidArgumentException('Not a readable backup archive'); + } + + return $zip; + }//end open() +}//end class diff --git a/lib/Backup/ArchiveStore.php b/lib/Backup/ArchiveStore.php new file mode 100644 index 000000000..239c65c1d --- /dev/null +++ b/lib/Backup/ArchiveStore.php @@ -0,0 +1,167 @@ + + * @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\Backup; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IConfig; +use OCP\ITempManager; + +/** + * Archive and blob storage plus scratch files. + */ +class ArchiveStore { + /** + * The app data folder holding the archives. + */ + public const FOLDER = 'backups'; + + /** + * Constructor. + * + * @param IAppDataFactory $appDataFactory App data storage + * @param IConfig $config Data directory and instance id + * @param ITempManager $tempManager Scratch files + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IAppDataFactory $appDataFactory, + private IConfig $config, + private ITempManager $tempManager, + ) { + }//end __construct() + + /** + * The archive folder, created on first use. + * + * @return ISimpleFolder + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function archives(): ISimpleFolder { + return $this->folder(name: self::FOLDER); + }//end archives() + + /** + * The attachment blob folder, created on first use. + * + * @return ISimpleFolder + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function blobs(): ISimpleFolder { + return $this->folder(name: ArchiveWriter::BLOB_FOLDER); + }//end blobs() + + /** + * A new temporary file path. + * + * @param string $suffix The file suffix + * + * @return string + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function tempFile(string $suffix): string { + return (string)$this->tempManager->getTemporaryFile($suffix); + }//end tempFile() + + /** + * A new temporary directory. + * + * @return string + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function tempFolder(): string { + return (string)$this->tempManager->getTemporaryFolder('keepiq-backup'); + }//end tempFolder() + + /** + * A local file for an archive given by stored name or by path on disk. + * + * @param string $nameOrPath An archive name from the list, or a file path + * + * @return string A readable local path + * + * @throws InvalidArgumentException When no such archive exists + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function localCopy(string $nameOrPath): string { + if (is_file($nameOrPath) === true) { + return $nameOrPath; + } + + try { + $file = $this->archives()->getFile(basename($nameOrPath)); + } catch (NotFoundException) { + throw new InvalidArgumentException('No such backup: ' . $nameOrPath); + } + + $local = $this->tempFile(suffix: '.keepiq-backup'); + file_put_contents($local, $file->getContent()); + + return $local; + }//end localCopy() + + /** + * Where Nextcloud keeps an archive on disk, for copying off-site. + * + * @param string $name The archive name + * + * @return string + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function diskPath(string $name): string { + $dataDir = rtrim((string)$this->config->getSystemValue('datadirectory', ''), '/'); + $instanceId = (string)$this->config->getSystemValue('instanceid', ''); + + return $dataDir . '/appdata_' . $instanceId . '/' . Application::APP_ID . '/' . self::FOLDER . '/' . $name; + }//end diskPath() + + /** + * An app data folder, created when absent. + * + * @param string $name The folder name + * + * @return ISimpleFolder + */ + private function folder(string $name): ISimpleFolder { + $appData = $this->appDataFactory->get(Application::APP_ID); + try { + return $appData->getFolder($name); + } catch (NotFoundException) { + return $appData->newFolder($name); + } + }//end folder() +}//end class diff --git a/lib/Backup/ArchiveWriter.php b/lib/Backup/ArchiveWriter.php new file mode 100644 index 000000000..308df7215 --- /dev/null +++ b/lib/Backup/ArchiveWriter.php @@ -0,0 +1,168 @@ +.jsonl` (one stored row per line, written as rows are + * read), `blobs/` (attachment ciphertext as stored) and a + * `manifest.json` with the format, app version, schema fingerprint, + * creation time, instance id, and per file the row count and SHA-256. + * + * Rows are copied exactly as stored: secret values stay RSA ciphertext, + * private keys stay wrapped with the master password. No plaintext exists on + * the server to write. + * + * @category Backup + * @package OCA\Keepiq\Backup + * + * @author Conduction Development Team + * @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\Backup; + +use DateTime; +use OCA\Keepiq\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\IConfig; +use RuntimeException; +use ZipArchive; + +/** + * Writes a backup archive from the live tables and blobs. + */ +class ArchiveWriter { + /** + * The archive format name in the manifest. + */ + public const FORMAT = 'keepiq-vault-backup-v1'; + + /** + * The attachment blob folder in the app data (AttachmentService::BLOB_FOLDER). + */ + public const BLOB_FOLDER = 'attachments'; + + /** + * Constructor. + * + * @param TableStore $tables The table rows + * @param IAppDataFactory $appDataFactory Attachment blob storage + * @param IAppManager $appManager The app version + * @param IConfig $config The instance id + * @param SchemaFingerprint $fingerprint The installed schema fingerprint + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private TableStore $tables, + private IAppDataFactory $appDataFactory, + private IAppManager $appManager, + private IConfig $config, + private SchemaFingerprint $fingerprint, + ) { + }//end __construct() + + /** + * Write a complete archive to a local zip path. + * + * @param string $zipPath Where to write the zip + * @param string $workDir A scratch directory for the table files + * + * @return array The manifest + * + * @throws RuntimeException When the zip cannot be written + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-hold-ciphertext-and-metadata-only + */ + public function write(string $zipPath, string $workDir): array { + $zip = new ZipArchive(); + if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Cannot create the backup archive'); + } + + $files = []; + foreach (BackupTableRegistry::TABLES as $table) { + $local = $workDir . '/' . $table . '.jsonl'; + $rows = $this->dumpTable(table: $table, localPath: $local); + $zip->addFile($local, 'tables/' . $table . '.jsonl'); + $files['tables/' . $table . '.jsonl'] = ['rows' => $rows, 'sha256' => (string)hash_file('sha256', $local)]; + } + + foreach ($this->blobs() as $name => $content) { + $zip->addFromString('blobs/' . $name, $content); + $files['blobs/' . $name] = ['rows' => 1, 'sha256' => hash('sha256', $content)]; + } + + $manifest = [ + 'format' => self::FORMAT, + 'appVersion' => $this->appManager->getAppVersion(Application::APP_ID), + 'schemaFingerprint' => $this->fingerprint->current(), + 'createdAt' => (new DateTime())->format('c'), + 'instanceId' => (string)$this->config->getSystemValue('instanceid', ''), + 'files' => $files, + ]; + $zip->addFromString('manifest.json', (string)json_encode($manifest, JSON_PRETTY_PRINT)); + + if ($zip->close() !== true) { + throw new RuntimeException('Cannot finish the backup archive'); + } + + return $manifest; + }//end write() + + /** + * Stream one table into a JSON lines file. + * + * @param string $table The unprefixed table + * @param string $localPath The file to write + * + * @return int The row count + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-hold-ciphertext-and-metadata-only + */ + private function dumpTable(string $table, string $localPath): int { + $handle = fopen($localPath, 'wb'); + if ($handle === false) { + throw new RuntimeException('Cannot write ' . $localPath); + } + + $rows = 0; + foreach ($this->tables->rows(table: $table) as $row) { + fwrite($handle, json_encode($row, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) . "\n"); + $rows++; + } + + fclose($handle); + + return $rows; + }//end dumpTable() + + /** + * Every attachment blob, by blob ref. + * + * @return iterable + */ + private function blobs(): iterable { + try { + $folder = $this->appDataFactory->get(Application::APP_ID)->getFolder(self::BLOB_FOLDER); + } catch (NotFoundException) { + return; + } + + foreach ($folder->getDirectoryListing() as $file) { + yield $file->getName() => $file->getContent(); + } + }//end blobs() +}//end class diff --git a/lib/Backup/BackupService.php b/lib/Backup/BackupService.php new file mode 100644 index 000000000..5a79452e0 --- /dev/null +++ b/lib/Backup/BackupService.php @@ -0,0 +1,290 @@ + + * @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\Backup; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Service\AuditService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Creates, lists and prunes vault backup archives. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The service joins the + * archive, storage, settings and audit sides of one backup run. + */ +class BackupService { + /** + * The app data folder holding the archives. + */ + public const FOLDER = ArchiveStore::FOLDER; + + public const LAST_RUN = 'backup_last_run_at'; + public const LAST_STATUS = 'backup_last_status'; + public const LAST_ERROR = 'backup_last_error'; + public const RUN_REQUESTED = 'backup_run_requested'; + + /** + * Constructor. + * + * @param ArchiveWriter $writer Writes the archive + * @param ArchiveCipher $cipher Encrypts it to the recipient key + * @param BackupSettings $settings The schedule, retention and key + * @param ArchiveStore $store Archive storage and scratch files + * @param IAppConfig $appConfig Last-run status + * @param ITimeFactory $time The clock + * @param AuditService $audit The audit trail + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private ArchiveWriter $writer, + private ArchiveCipher $cipher, + private BackupSettings $settings, + private ArchiveStore $store, + private IAppConfig $appConfig, + private ITimeFactory $time, + private AuditService $audit, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether the scheduled job should run a backup now. + * + * @return bool + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function isDue(): bool { + $settings = $this->settings->read(); + if ($settings[BackupSettings::ENABLED] === false) { + return false; + } + + if ($this->appConfig->getValueBool(Application::APP_ID, self::RUN_REQUESTED, false) === true) { + return true; + } + + $lastRun = $this->appConfig->getValueInt(Application::APP_ID, self::LAST_RUN, 0); + + return ($this->time->getTime() - $lastRun) >= ($settings[BackupSettings::INTERVAL] * 3600); + }//end isDue() + + /** + * Ask the next cron run to back up, whatever the interval says. + * + * @return void + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function requestRun(): void { + $this->appConfig->setValueBool(Application::APP_ID, self::RUN_REQUESTED, true); + }//end requestRun() + + /** + * Run one backup now. + * + * @return array{name:string,encrypted:bool,size:int} + * + * @throws Throwable When the backup failed (recorded and audited first) + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function createBackup(): array { + $this->appConfig->setValueBool(Application::APP_ID, self::RUN_REQUESTED, false); + $this->appConfig->setValueInt(Application::APP_ID, self::LAST_RUN, $this->time->getTime()); + + try { + $result = $this->writeArchive(); + } catch (Throwable $exception) { + $this->appConfig->setValueString(Application::APP_ID, self::LAST_STATUS, 'failed'); + $this->appConfig->setValueString(Application::APP_ID, self::LAST_ERROR, substr($exception->getMessage(), 0, 500)); + $this->record(eventType: AuditEventTypes::BACKUP_FAILED, name: '', metadata: ['error' => substr($exception->getMessage(), 0, 200)]); + $this->logger->error('Keepiq vault backup failed: ' . $exception->getMessage(), ['exception' => $exception]); + throw $exception; + } + + $this->appConfig->setValueString(Application::APP_ID, self::LAST_STATUS, 'ok'); + $this->appConfig->setValueString(Application::APP_ID, self::LAST_ERROR, ''); + $this->record( + eventType: AuditEventTypes::BACKUP_CREATED, + name: $result['name'], + metadata: ['archive' => $result['name'], 'encrypted' => $result['encrypted'], 'bytes' => $result['size']] + ); + $this->prune(keep: $this->settings->read()[BackupSettings::RETENTION]); + + return $result; + }//end createBackup() + + /** + * The stored archives, newest first. Never their content. + * + * @return array + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function listArchives(): array { + $archives = []; + foreach ($this->store->archives()->getDirectoryListing() as $file) { + $archives[] = [ + 'name' => $file->getName(), + 'size' => $file->getSize(), + 'createdAt' => $file->getMTime(), + 'encrypted' => str_ends_with($file->getName(), '.enc'), + 'path' => $this->store->diskPath(name: $file->getName()), + ]; + } + + usort($archives, static fn (array $a, array $b): int => strcmp($b['name'], $a['name'])); + + return $archives; + }//end listArchives() + + /** + * The last run, for the admin section. + * + * @return array{lastRunAt:int,lastStatus:string,lastError:string,runRequested:bool} + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function status(): array { + $appId = Application::APP_ID; + + return [ + 'lastRunAt' => $this->appConfig->getValueInt($appId, self::LAST_RUN, 0), + 'lastStatus' => $this->appConfig->getValueString($appId, self::LAST_STATUS, ''), + 'lastError' => $this->appConfig->getValueString($appId, self::LAST_ERROR, ''), + 'runRequested' => $this->appConfig->getValueBool($appId, self::RUN_REQUESTED, false), + ]; + }//end status() + + /** + * A local file for an archive given by stored name or by path on disk. + * + * @param string $nameOrPath An archive name from the list, or a file path + * + * @return string A readable local path + * + * @throws \InvalidArgumentException When no such archive exists + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function localCopy(string $nameOrPath): string { + return $this->store->localCopy(nameOrPath: $nameOrPath); + }//end localCopy() + + /** + * Remove the oldest archives beyond the retention count. + * + * @param int $keep How many to keep + * + * @return int How many were removed + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function prune(int $keep): int { + $files = $this->store->archives()->getDirectoryListing(); + usort($files, static fn ($a, $b): int => strcmp($b->getName(), $a->getName())); + $removed = 0; + foreach (array_slice($files, max(1, $keep)) as $file) { + $file->delete(); + $removed++; + } + + return $removed; + }//end prune() + + /** + * Write, optionally encrypt, and store one archive. + * + * @return array{name:string,encrypted:bool,size:int} + */ + private function writeArchive(): array { + $workDir = $this->store->tempFolder(); + $zipPath = $this->store->tempFile(suffix: '.zip'); + $this->writer->write(zipPath: $zipPath, workDir: $workDir); + + $publicKey = $this->settings->read()[BackupSettings::PUBLIC_KEY]; + $encrypted = ($publicKey !== ''); + $finalPath = $zipPath; + if ($encrypted === true) { + $finalPath = $this->store->tempFile(suffix: '.enc'); + $this->cipher->encryptFile(plainPath: $zipPath, outPath: $finalPath, publicPem: $publicKey); + } + + $name = 'keepiq-backup-' . date('Ymd-His', $this->time->getTime()) . '.zip'; + if ($encrypted === true) { + $name .= '.enc'; + } + + $handle = fopen($finalPath, 'rb'); + if ($handle === false) { + throw new RuntimeException('Cannot read the written archive'); + } + + $this->store->archives()->newFile($name)->putContent($handle); + $size = (int)filesize($finalPath); + + return ['name' => $name, 'encrypted' => $encrypted, 'size' => $size]; + }//end writeArchive() + + /** + * Record a backup audit event as the system. + * + * @param string $eventType The event type + * @param string $name The archive name + * @param array $metadata Counts, names and flags only + * + * @return void + */ + private function record(string $eventType, string $name, array $metadata): void { + try { + $this->audit->record( + (new AuditEventFactory())->forSystem( + eventType: $eventType, + objectType: 'backup', + objectId: $name, + objectName: $name, + metadata: $metadata, + ) + ); + } catch (Throwable $exception) { + $this->logger->error('Keepiq: backup audit entry could not be recorded: ' . $exception->getMessage()); + } + }//end record() +}//end class diff --git a/lib/Backup/BackupSettings.php b/lib/Backup/BackupSettings.php new file mode 100644 index 000000000..4cce538c0 --- /dev/null +++ b/lib/Backup/BackupSettings.php @@ -0,0 +1,173 @@ + + * @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\Backup; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCP\IAppConfig; + +/** + * Reads and validates the backup settings. + */ +class BackupSettings { + public const ENABLED = 'backup_enabled'; + public const INTERVAL = 'backup_interval_hours'; + public const RETENTION = 'backup_retention_count'; + public const PUBLIC_KEY = 'backup_recipient_public_key'; + + public const INTERVAL_DEFAULT = 24; + public const INTERVAL_MAX = 8760; + public const RETENTION_DEFAULT = 7; + public const RETENTION_MAX = 365; + + /** + * Constructor. + * + * @param IAppConfig $appConfig The app config + * @param ArchiveCipher $cipher Validates the recipient key + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IAppConfig $appConfig, + private ArchiveCipher $cipher, + ) { + }//end __construct() + + /** + * The current backup settings. + * + * @return array{backup_enabled:bool,backup_interval_hours:int,backup_retention_count:int,backup_recipient_public_key:string} + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function read(): array { + $appId = Application::APP_ID; + + return [ + self::ENABLED => $this->appConfig->getValueBool($appId, self::ENABLED, false), + self::INTERVAL => $this->appConfig->getValueInt($appId, self::INTERVAL, self::INTERVAL_DEFAULT), + self::RETENTION => $this->appConfig->getValueInt($appId, self::RETENTION, self::RETENTION_DEFAULT), + self::PUBLIC_KEY => $this->appConfig->getValueString($appId, self::PUBLIC_KEY, ''), + ]; + }//end read() + + /** + * Validate and store the backup keys present in an admin save. + * + * @param array $data The admin-settings input + * + * @return void + * + * @throws InvalidArgumentException On an out-of-range value or an unusable key + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + public function update(array $data): void { + $appId = Application::APP_ID; + $writes = []; + + if (array_key_exists(self::ENABLED, $data) === true) { + $enabled = $this->validBool(key: self::ENABLED, value: $data[self::ENABLED]); + $writes[] = fn () => $this->appConfig->setValueBool($appId, self::ENABLED, $enabled); + } + + foreach ([self::INTERVAL => self::INTERVAL_MAX, self::RETENTION => self::RETENTION_MAX] as $key => $max) { + if (array_key_exists($key, $data) === true) { + $value = $this->validCount(key: $key, value: $data[$key], max: $max); + $writes[] = fn () => $this->appConfig->setValueInt($appId, $key, $value); + } + } + + if (array_key_exists(self::PUBLIC_KEY, $data) === true) { + $pem = $this->validKey(value: $data[self::PUBLIC_KEY]); + $writes[] = fn () => $this->appConfig->setValueString($appId, self::PUBLIC_KEY, $pem); + } + + foreach ($writes as $write) { + $write(); + } + }//end update() + + /** + * A boolean setting. + * + * @param string $key The key + * @param mixed $value The submitted value + * + * @return bool + * + * @throws InvalidArgumentException When it is not a boolean + */ + private function validBool(string $key, mixed $value): bool { + $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($bool === null) { + throw new InvalidArgumentException($key . ' must be true or false'); + } + + return $bool; + }//end validBool() + + /** + * A whole number from 1 to a maximum. + * + * @param string $key The key + * @param mixed $value The submitted value + * @param int $max The maximum + * + * @return int + * + * @throws InvalidArgumentException When out of range + */ + private function validCount(string $key, mixed $value, int $max): int { + $count = filter_var($value, FILTER_VALIDATE_INT); + if ($count === false || $count < 1 || $count > $max) { + throw new InvalidArgumentException($key . ' must be between 1 and ' . $max); + } + + return $count; + }//end validCount() + + /** + * An RSA public key or certificate in PEM form, or '' to clear it. + * + * @param mixed $value The submitted value + * + * @return string + * + * @throws InvalidArgumentException When it is not a usable key + */ + private function validKey(mixed $value): string { + $pem = trim((string)$value); + if ($pem !== '' && $this->cipher->isValidPublicKey(pem: $pem) === false) { + throw new InvalidArgumentException(self::PUBLIC_KEY . ' must be an RSA public key or certificate in PEM form'); + } + + return $pem; + }//end validKey() +}//end class diff --git a/lib/Backup/BackupTableRegistry.php b/lib/Backup/BackupTableRegistry.php new file mode 100644 index 000000000..309ae93dc --- /dev/null +++ b/lib/Backup/BackupTableRegistry.php @@ -0,0 +1,93 @@ + + * @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\Backup; + +/** + * The tables in a vault backup, without the `keepiq_` prefix. + */ +final class BackupTableRegistry { + /** + * The table prefix every Keepiq table carries. + */ + public const PREFIX = 'keepiq_'; + + /** + * Every Keepiq table, unprefixed, in a stable order. + * + * @var string[] + */ + public const TABLES = [ + 'app_lease_policies', + 'applications', + 'attachment_grants', + 'attachments', + 'audit_log', + 'ca_certs', + 'certificate_metadata', + 'compliance_reports', + 'dashboard_settings', + 'device_approvals', + 'emergency_contacts', + 'enc_suites', + 'ephemeral_sends', + 'expiry_policies', + 'federated_inbound', + 'federated_shares', + 'federation_partners', + 'folders', + 'group_shares', + 'honey_alerts', + 'honey_flags', + 'link_shares', + 'machine_leases', + 'migration_failures', + 'passkey_credentials', + 'recovery_approvals', + 'recovery_enrolments', + 'recovery_keys', + 'recovery_officers', + 'recovery_requests', + 'rotation_flags', + 'secret_delegations', + 'secret_requests', + 'secret_tags', + 'secret_types', + 'secret_versions', + 'secrets', + 'share_targets', + 'siem_queue', + 'siem_sinks', + 'suite_migr', + 'team_folder_members', + 'team_folders', + 'used_proofs', + ]; + + /** + * Tables with an autoincrement `id`, whose sequence a restore resets. + * + * @var string[] + */ + public const AUTOINCREMENT = ['audit_log', 'migration_failures', 'secret_tags', 'used_proofs']; +}//end class diff --git a/lib/Backup/RestoreService.php b/lib/Backup/RestoreService.php new file mode 100644 index 000000000..723179186 --- /dev/null +++ b/lib/Backup/RestoreService.php @@ -0,0 +1,353 @@ + + * @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\Backup; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Service\AuditService; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\Security\ICrypto; +use Throwable; + +/** + * Plans, checks and runs a restore. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) A restore joins the archive, + * database, blob, crypto, user and audit sides by nature. + */ +class RestoreService { + /** + * Why a restore refuses to start while maintenance mode is on. + */ + public const MAINTENANCE_ALREADY_ON = 'Maintenance mode is already on. The restore switches it on and off by itself, ' + . 'and switching it off at the end would cut short maintenance someone else started. ' + . 'Run occ maintenance:mode --off when that work is done, then restore again.'; + + /** + * Constructor. + * + * @param ArchiveReader $reader Verifies and reads the archive + * @param ArchiveCipher $cipher Decrypts an encrypted archive + * @param TableStore $tables Counts and replaces table rows + * @param SchemaFingerprint $fingerprint The installed schema + * @param ArchiveStore $store Blob storage and scratch files + * @param IConfig $config Maintenance mode + * @param IUserManager $userManager Owners that no longer exist + * @param ICrypto $crypto The instance secret probe + * @param AuditService $audit The audit trail + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private ArchiveReader $reader, + private ArchiveCipher $cipher, + private TableStore $tables, + private SchemaFingerprint $fingerprint, + private ArchiveStore $store, + private IConfig $config, + private IUserManager $userManager, + private ICrypto $crypto, + private AuditService $audit, + ) { + }//end __construct() + + /** + * Open an archive: decrypt when needed, then verify every checksum. + * + * @param string $localPath The archive file + * @param string|null $keyFile The private key file for an encrypted archive + * + * @return array{zip:string,manifest:array} + * + * @throws InvalidArgumentException When it is encrypted without a key, the key is wrong, or a check fails + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function open(string $localPath, ?string $keyFile): array { + $zip = $localPath; + if ($this->cipher->isEncrypted(path: $localPath) === true) { + if ($keyFile === null || $keyFile === '') { + throw new InvalidArgumentException('The archive is encrypted. Pass the private key with --key-file.'); + } + + if (is_readable($keyFile) === false) { + throw new InvalidArgumentException('Cannot read the key file ' . $keyFile); + } + + $zip = $this->store->tempFile(suffix: '.zip'); + $this->cipher->decryptFile(encPath: $localPath, outPath: $zip, privatePem: (string)file_get_contents($keyFile)); + } + + return ['zip' => $zip, 'manifest' => $this->reader->verify(zipPath: $zip)]; + }//end open() + + /** + * The checks a restore must pass, as refusal messages; empty means go. + * + * @param array $manifest The verified manifest + * @param bool $force Whether the administrator forces an older archive + * + * @return string[] + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function refusals(array $manifest, bool $force): array { + $refusals = []; + if ($this->maintenanceIsOn() === true) { + $refusals[] = self::MAINTENANCE_ALREADY_ON; + } + + if (($manifest['schemaFingerprint'] ?? '') !== $this->fingerprint->current()) { + $refusals[] = 'The archive was written by a different Keepiq schema. Restore it on the same Keepiq version.'; + } + + if ($force === false && $this->isOlderThanNewestAuditEntry(manifest: $manifest) === true) { + $refusals[] = 'The archive is older than the newest audit entry. Pass --force to roll back on purpose.'; + } + + return $refusals; + }//end refusals() + + /** + * Whether the archive was written before the newest audit entry, so a + * restore would roll back later changes. + * + * @param array $manifest The verified manifest + * + * @return bool + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function isOlderThanNewestAuditEntry(array $manifest): bool { + $newest = $this->tables->newestAuditEntry(); + $createdAt = (string)($manifest['createdAt'] ?? ''); + if ($newest === null || $createdAt === '') { + return false; + } + + return new DateTime($createdAt) < new DateTime($newest); + }//end isOlderThanNewestAuditEntry() + + /** + * Per table: rows now, and rows in the archive. + * + * @param array $manifest The verified manifest + * + * @return array + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function compare(array $manifest): array { + $counts = []; + foreach (BackupTableRegistry::TABLES as $table) { + $counts[$table] = [ + 'current' => $this->tables->count(table: $table), + 'archive' => (int)($manifest['files']['tables/' . $table . '.jsonl']['rows'] ?? 0), + ]; + } + + return $counts; + }//end compare() + + /** + * What the administrator must know before confirming (design D5). + * + * @param string $zip The verified archive + * @param array $manifest Its manifest + * + * @return string[] + * + * @spec openspec/specs/vault-backups/spec.md#requirement-a-restore-returns-ciphertext-that-still-needs-each-users-key + */ + public function warnings(string $zip, array $manifest): array { + $warnings = [ + 'Every change made after ' . ($manifest['createdAt'] ?? 'the backup') . ' is lost, including key rotations.', + 'Every user unlocks with the master password that was valid when the backup ran.', + ]; + + if ($this->instanceSecretOpensCaKeys(zip: $zip) === false) { + $warnings[] = 'The CA keys in this archive cannot be read with this instance\'s secret. ' + . 'Restore on the instance that wrote it, or with the same secret in config.php.'; + } + + $missing = $this->missingOwners(zip: $zip); + if ($missing !== []) { + $warnings[] = 'These owners no longer exist in Nextcloud; their rows are restored anyway: ' + . implode(', ', $missing); + } + + return $warnings; + }//end warnings() + + /** + * Switch maintenance mode on, replace every Keepiq table, then the + * attachment blobs, audit, and always switch maintenance mode off again. + * + * Nextcloud loads no app commands while maintenance mode is on, so the + * restore command cannot ask the administrator to switch it on first. + * + * @param string $zip The verified archive + * @param array $manifest Its manifest + * @param string $archiveName The name for the audit entry + * + * @return array{rows:int,blobs:int} + * + * @throws InvalidArgumentException When maintenance mode is already on + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function restore(string $zip, array $manifest, string $archiveName): array { + if ($this->maintenanceIsOn() === true) { + throw new InvalidArgumentException(self::MAINTENANCE_ALREADY_ON); + } + + $this->config->setSystemValue('maintenance', true); + try { + return $this->replace(zip: $zip, manifest: $manifest, archiveName: $archiveName); + } finally { + $this->config->setSystemValue('maintenance', false); + } + }//end restore() + + /** + * Whether Nextcloud maintenance mode is on. + * + * @return bool + */ + private function maintenanceIsOn(): bool { + return (bool)$this->config->getSystemValue('maintenance', false) === true; + }//end maintenanceIsOn() + + /** + * Replace every Keepiq table, then the attachment blobs, and audit. + * + * @param string $zip The verified archive + * @param array $manifest Its manifest + * @param string $archiveName The name for the audit entry + * + * @return array{rows:int,blobs:int} + */ + private function replace(string $zip, array $manifest, string $archiveName): array { + $written = $this->tables->replaceAll( + rowsFor: fn (string $table): iterable => $this->reader->rows(zipPath: $zip, table: $table) + ); + + $folder = $this->store->blobs(); + foreach ($folder->getDirectoryListing() as $file) { + $file->delete(); + } + + $blobs = 0; + foreach ($this->reader->blobs(zipPath: $zip) as $name => $content) { + $folder->newFile((string)$name)->putContent($content); + $blobs++; + } + + $rows = array_sum($written); + $this->audit->record( + (new AuditEventFactory())->forSystem( + eventType: AuditEventTypes::BACKUP_RESTORED, + objectType: 'backup', + objectId: $archiveName, + objectName: $archiveName, + metadata: [ + 'archive' => $archiveName, + 'createdAt' => (string)($manifest['createdAt'] ?? ''), + 'tables' => count($written), + 'rows' => $rows, + 'blobs' => $blobs, + ], + ) + ); + + return ['rows' => $rows, 'blobs' => $blobs]; + }//end replace() + + /** + * Whether this instance's secret opens the first CA key in the archive. + * + * @param string $zip The archive + * + * @return bool True when it opens, or when there is no CA key to try + */ + private function instanceSecretOpensCaKeys(string $zip): bool { + foreach ($this->reader->rows(zipPath: $zip, table: 'ca_certs') as $row) { + $encrypted = (string)($row['private_key'] ?? ''); + if ($encrypted === '') { + continue; + } + + try { + $this->crypto->decrypt($encrypted); + return true; + } catch (Throwable) { + return false; + } + } + + return true; + }//end instanceSecretOpensCaKeys() + + /** + * User owners in the archive that Nextcloud no longer knows. + * + * @param string $zip The archive + * + * @return string[] + */ + private function missingOwners(string $zip): array { + $owners = []; + foreach (['secrets', 'enc_suites'] as $table) { + foreach ($this->reader->rows(zipPath: $zip, table: $table) as $row) { + if (($row['owner_type'] ?? '') === 'user') { + $owners[(string)$row['owner_id']] = true; + } + } + } + + $missing = []; + foreach (array_keys($owners) as $owner) { + if ($this->userManager->userExists((string)$owner) === false) { + $missing[] = (string)$owner; + } + } + + sort($missing); + + return $missing; + }//end missingOwners() +}//end class diff --git a/lib/Backup/SchemaFingerprint.php b/lib/Backup/SchemaFingerprint.php new file mode 100644 index 000000000..65c1cc9c8 --- /dev/null +++ b/lib/Backup/SchemaFingerprint.php @@ -0,0 +1,73 @@ + + * @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\Backup; + +use OCA\Keepiq\AppInfo\Application; +use OCP\IDBConnection; + +/** + * Computes the installed schema fingerprint. + */ +class SchemaFingerprint { + /** + * Constructor. + * + * @param IDBConnection $db The database + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IDBConnection $db, + ) { + }//end __construct() + + /** + * The fingerprint of the installed schema. + * + * @return string + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-hold-ciphertext-and-metadata-only + */ + public function current(): string { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select('version') + ->from('migrations') + ->where($qb->expr()->eq('app', $qb->createNamedParameter(Application::APP_ID))) + ->executeQuery(); + $versions = []; + while (($row = $result->fetch()) !== false) { + $versions[] = (string)$row['version']; + } + + $result->closeCursor(); + sort($versions); + + return hash('sha256', implode(',', BackupTableRegistry::TABLES) . '|' . implode(',', $versions)); + }//end current() +}//end class diff --git a/lib/Backup/TableStore.php b/lib/Backup/TableStore.php new file mode 100644 index 000000000..a5ab84f5b --- /dev/null +++ b/lib/Backup/TableStore.php @@ -0,0 +1,191 @@ + + * @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\Backup; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Reads and replaces Keepiq table rows. + */ +class TableStore { + /** + * Constructor. + * + * @param IDBConnection $db The database + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IDBConnection $db, + ) { + }//end __construct() + + /** + * Stream the stored rows of a table. + * + * @param string $table The unprefixed table + * + * @return iterable> + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-hold-ciphertext-and-metadata-only + */ + public function rows(string $table): iterable { + $result = $this->db->getQueryBuilder()->select('*')->from(BackupTableRegistry::PREFIX . $table)->executeQuery(); + while (($row = $result->fetch()) !== false) { + yield $row; + } + + $result->closeCursor(); + }//end rows() + + /** + * The number of rows in a table. + * + * @param string $table The unprefixed table + * + * @return int + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function count(string $table): int { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select($qb->func()->count('*', 'row_count'))->from(BackupTableRegistry::PREFIX . $table)->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + + return $count; + }//end count() + + /** + * The newest audit entry time, or null for an empty log. + * + * @return string|null + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function newestAuditEntry(): ?string { + $qb = $this->db->getQueryBuilder(); + $result = $qb->select($qb->func()->max('occurred_at'))->from(BackupTableRegistry::PREFIX . 'audit_log')->executeQuery(); + $value = $result->fetchOne(); + $result->closeCursor(); + + if ($value === false || $value === null) { + return null; + } + + return (string)$value; + }//end newestAuditEntry() + + /** + * Replace all rows of every table inside ONE transaction: either every + * table holds the archive rows afterwards, or none changed. + * + * @param callable(string):iterable> $rowsFor Rows per unprefixed table + * + * @return array Rows written per table + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + public function replaceAll(callable $rowsFor): array { + $written = []; + $this->db->beginTransaction(); + try { + foreach (BackupTableRegistry::TABLES as $table) { + $this->db->getQueryBuilder()->delete(BackupTableRegistry::PREFIX . $table)->executeStatement(); + $written[$table] = 0; + foreach ($rowsFor($table) as $row) { + $qb = $this->db->getQueryBuilder(); + $values = []; + foreach ($row as $column => $value) { + $values[(string)$column] = $qb->createNamedParameter($value, self::parameterType(value: $value)); + } + + $qb->insert(BackupTableRegistry::PREFIX . $table)->values($values)->executeStatement(); + $written[$table]++; + } + } + + $this->db->commit(); + } catch (\Throwable $exception) { + $this->db->rollBack(); + throw $exception; + }//end try + + $this->resetSequences(); + + return $written; + }//end replaceAll() + + /** + * The binding type for one archived value. + * + * PostgreSQL returns boolean columns as PHP booleans and the archive keeps + * them. Bound as a string, `false` reaches PostgreSQL as '' and the insert + * is refused, so each value is bound with the type it carries. + * + * @param mixed $value The archived value + * + * @return mixed The IQueryBuilder parameter type + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + private static function parameterType(mixed $value): mixed { + if (is_bool($value) === true) { + return IQueryBuilder::PARAM_BOOL; + } + + if (is_int($value) === true) { + return IQueryBuilder::PARAM_INT; + } + + if ($value === null) { + return IQueryBuilder::PARAM_NULL; + } + + return IQueryBuilder::PARAM_STR; + }//end parameterType() + + /** + * On PostgreSQL an explicit id insert leaves the sequence behind; move it + * past the restored maximum so the next insert does not collide. + * + * @return void + */ + private function resetSequences(): void { + if ($this->db->getDatabaseProvider() !== IDBConnection::PLATFORM_POSTGRES) { + return; + } + + foreach (BackupTableRegistry::AUTOINCREMENT as $table) { + $name = '*PREFIX*' . BackupTableRegistry::PREFIX . $table; + $this->db->executeQuery( + "SELECT setval(pg_get_serial_sequence('" . $name . "', 'id'), COALESCE((SELECT MAX(id) FROM " . $name . '), 0) + 1, false)' + ); + } + }//end resetSequences() +}//end class diff --git a/lib/Command/BackupCreate.php b/lib/Command/BackupCreate.php new file mode 100644 index 000000000..8f7ffdc2b --- /dev/null +++ b/lib/Command/BackupCreate.php @@ -0,0 +1,90 @@ + + * @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\Command; + +use OCA\Keepiq\Backup\BackupService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; + +/** + * occ keepiq:backup:create + */ +class BackupCreate extends Command { + /** + * Constructor. + * + * @param BackupService $backups The backup service + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private BackupService $backups, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name and help. + * + * @return void + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function configure(): void { + $this->setName(name: 'keepiq:backup:create') + ->setDescription(description: 'Write a backup of every Keepiq vault now'); + }//end configure() + + /** + * Run the backup. + * + * @param InputInterface $input The input + * @param OutputInterface $output The output + * + * @return int + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $input is mandated by Command::execute(). + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $result = $this->backups->createBackup(); + } catch (Throwable $exception) { + $output->writeln('Backup failed: ' . $exception->getMessage() . ''); + return 1; + } + + $encryption = 'not encrypted'; + if ($result['encrypted'] === true) { + $encryption = 'encrypted'; + } + + $output->writeln(sprintf('Wrote %s (%d bytes, %s)', $result['name'], $result['size'], $encryption)); + + return 0; + }//end execute() +}//end class diff --git a/lib/Command/BackupList.php b/lib/Command/BackupList.php new file mode 100644 index 000000000..eed2605e1 --- /dev/null +++ b/lib/Command/BackupList.php @@ -0,0 +1,94 @@ + + * @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\Command; + +use OCA\Keepiq\Backup\BackupService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * occ keepiq:backup:list + */ +class BackupList extends Command { + /** + * Constructor. + * + * @param BackupService $backups The backup service + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private BackupService $backups, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name and help. + * + * @return void + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function configure(): void { + $this->setName(name: 'keepiq:backup:list') + ->setDescription(description: 'List the stored Keepiq vault backups'); + }//end configure() + + /** + * Print the archive table. + * + * @param InputInterface $input The input + * @param OutputInterface $output The output + * + * @return int + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $input is mandated by Command::execute(). + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $rows = []; + foreach ($this->backups->listArchives() as $archive) { + $encrypted = 'no'; + if ($archive['encrypted'] === true) { + $encrypted = 'yes'; + } + + $rows[] = [ + $archive['name'], + (string)$archive['size'], + date('Y-m-d H:i:s', $archive['createdAt']), + $encrypted, + $archive['path'], + ]; + } + + (new Table($output))->setHeaders(['Name', 'Bytes', 'Time', 'Encrypted', 'Path'])->setRows($rows)->render(); + + return 0; + }//end execute() +}//end class diff --git a/lib/Command/BackupRestore.php b/lib/Command/BackupRestore.php new file mode 100644 index 000000000..70fd84341 --- /dev/null +++ b/lib/Command/BackupRestore.php @@ -0,0 +1,222 @@ + + * @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\Command; + +use InvalidArgumentException; +use Throwable; +use OCA\Keepiq\Backup\BackupService; +use OCA\Keepiq\Backup\RestoreService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; + +/** + * occ keepiq:backup:restore [--key-file=] [--dry-run] [--force] + */ +class BackupRestore extends Command { + /** + * Constructor. + * + * @param BackupService $backups Finds the archive + * @param RestoreService $restore Checks and runs the restore + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private BackupService $backups, + private RestoreService $restore, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name, argument and options. + * + * @return void + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function configure(): void { + $this->setName(name: 'keepiq:backup:restore') + ->setDescription(description: 'Restore every Keepiq vault from a backup, in maintenance mode it switches on and off itself') + ->addArgument(name: 'file', mode: InputArgument::REQUIRED, description: 'An archive name from keepiq:backup:list, or a file path') + ->addOption(name: 'key-file', mode: InputOption::VALUE_REQUIRED, description: 'The private key for an encrypted archive') + ->addOption(name: 'dry-run', mode: InputOption::VALUE_NONE, description: 'Show current and archive row counts and change nothing') + ->addOption(name: 'force', mode: InputOption::VALUE_NONE, description: 'Restore an archive older than the newest audit entry'); + }//end configure() + + /** + * Check, show, confirm, restore. + * + * @param InputInterface $input The input + * @param OutputInterface $output The output + * + * @return int + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + * @spec openspec/specs/vault-backups/spec.md#requirement-a-restore-returns-ciphertext-that-still-needs-each-users-key + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $file = (string)$input->getArgument('file'); + try { + $opened = $this->restore->open( + localPath: $this->backups->localCopy(nameOrPath: $file), + keyFile: self::keyFile(input: $input) + ); + } catch (InvalidArgumentException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + return 1; + } + + $force = (bool)$input->getOption('force'); + $dryRun = (bool)$input->getOption('dry-run'); + + // A dry run changes nothing, so the age rule is a notice there, not a refusal. + $refusals = $this->restore->refusals(manifest: $opened['manifest'], force: ($force === true || $dryRun === true)); + foreach ($refusals as $refusal) { + $output->writeln('' . $refusal . ''); + } + + if ($refusals !== []) { + return 1; + } + + $this->printCounts(manifest: $opened['manifest'], output: $output); + if ($dryRun === true) { + return $this->finishDryRun(manifest: $opened['manifest'], force: $force, output: $output); + } + + foreach ($this->restore->warnings(zip: $opened['zip'], manifest: $opened['manifest']) as $warning) { + $output->writeln('' . $warning . ''); + } + + if ($this->confirmed(input: $input, output: $output) === false) { + $output->writeln('Restore cancelled. Nothing changed.'); + return 1; + } + + return $this->runRestore(opened: $opened, archiveName: basename($file), output: $output); + }//end execute() + + /** + * Print the rows per table now and in the archive. + * + * @param array $manifest The verified manifest + * @param OutputInterface $output The output + * + * @return void + */ + private function printCounts(array $manifest, OutputInterface $output): void { + $rows = []; + foreach ($this->restore->compare(manifest: $manifest) as $table => $counts) { + $rows[] = [$table, (string)$counts['current'], (string)$counts['archive']]; + } + + (new Table($output))->setHeaders(['Table', 'Rows now', 'Rows in archive'])->setRows($rows)->render(); + }//end printCounts() + + /** + * End a dry run: name the age rule a real restore would apply. + * + * @param array $manifest The verified manifest + * @param bool $force Whether --force was given + * @param OutputInterface $output The output + * + * @return int + */ + private function finishDryRun(array $manifest, bool $force, OutputInterface $output): int { + if ($force === false && $this->restore->isOlderThanNewestAuditEntry(manifest: $manifest) === true) { + $output->writeln('The archive is older than the newest audit entry. A real restore needs --force to roll back on purpose.'); + } + + $output->writeln('Dry run: nothing changed.'); + return 0; + }//end finishDryRun() + + /** + * Whether the administrator confirms the restore. + * + * @param InputInterface $input The input + * @param OutputInterface $output The output + * + * @return bool + */ + private function confirmed(InputInterface $input, OutputInterface $output): bool { + $helper = $this->getHelper(name: 'question'); + if (($helper instanceof QuestionHelper) === false) { + return false; + } + + return $helper->ask($input, $output, new ConfirmationQuestion('Replace every Keepiq vault with this backup? [y/N] ', false)) === true; + }//end confirmed() + + /** + * Run the restore, which switches maintenance mode on and always off again. + * + * @param array{zip:string,manifest:array} $opened The opened archive + * @param string $archiveName The name for the audit entry + * @param OutputInterface $output The output + * + * @return int + */ + private function runRestore(array $opened, string $archiveName, OutputInterface $output): int { + $output->writeln('Switching maintenance mode on for the restore.'); + try { + $result = $this->restore->restore(zip: $opened['zip'], manifest: $opened['manifest'], archiveName: $archiveName); + } catch (Throwable $exception) { + $output->writeln('Restore failed: ' . $exception->getMessage() . ''); + $output->writeln('Maintenance mode is off again.'); + return 1; + } + + $output->writeln('Maintenance mode is off again.'); + $output->writeln(sprintf('Restored %d rows and %d attachment blobs.', $result['rows'], $result['blobs'])); + + return 0; + }//end runRestore() + + /** + * The --key-file option, or null when not given. + * + * @param InputInterface $input The input + * + * @return string|null + */ + private static function keyFile(InputInterface $input): ?string { + $keyFile = $input->getOption('key-file'); + if ($keyFile === null) { + return null; + } + + return (string)$keyFile; + }//end keyFile() +}//end class diff --git a/lib/Command/BackupVerify.php b/lib/Command/BackupVerify.php new file mode 100644 index 000000000..7e2772596 --- /dev/null +++ b/lib/Command/BackupVerify.php @@ -0,0 +1,112 @@ + + * @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\Command; + +use InvalidArgumentException; +use OCA\Keepiq\Backup\BackupService; +use OCA\Keepiq\Backup\RestoreService; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * occ keepiq:backup:verify [--key-file=] + */ +class BackupVerify extends Command { + /** + * Constructor. + * + * @param BackupService $backups Finds the archive + * @param RestoreService $restore Opens and verifies it + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private BackupService $backups, + private RestoreService $restore, + ) { + parent::__construct(); + }//end __construct() + + /** + * Name, argument and options. + * + * @return void + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function configure(): void { + $this->setName(name: 'keepiq:backup:verify') + ->setDescription(description: 'Check a Keepiq vault backup: format and every checksum') + ->addArgument(name: 'file', mode: InputArgument::REQUIRED, description: 'An archive name from keepiq:backup:list, or a file path') + ->addOption(name: 'key-file', mode: InputOption::VALUE_REQUIRED, description: 'The private key for an encrypted archive'); + }//end configure() + + /** + * Verify the archive. + * + * @param InputInterface $input The input + * @param OutputInterface $output The output + * + * @return int + * + * @spec openspec/specs/vault-backups/spec.md#requirement-archives-are-verified-and-restored-from-the-command-line + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $opened = $this->restore->open( + localPath: $this->backups->localCopy(nameOrPath: (string)$input->getArgument('file')), + keyFile: self::keyFile(input: $input) + ); + } catch (InvalidArgumentException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + return 1; + } + + $manifest = $opened['manifest']; + $output->writeln('The archive is complete and unchanged.'); + $output->writeln('Written: ' . (string)($manifest['createdAt'] ?? '') . ', Keepiq ' . (string)($manifest['appVersion'] ?? '')); + $output->writeln('Files checked: ' . count((array)($manifest['files'] ?? []))); + + return 0; + }//end execute() + + /** + * The --key-file option, or null when not given. + * + * @param InputInterface $input The input + * + * @return string|null + */ + private static function keyFile(InputInterface $input): ?string { + $keyFile = $input->getOption('key-file'); + if ($keyFile === null) { + return null; + } + + return (string)$keyFile; + }//end keyFile() +}//end class diff --git a/lib/Controller/AdminApplicationController.php b/lib/Controller/AdminApplicationController.php new file mode 100644 index 000000000..8c3d4c0c7 --- /dev/null +++ b/lib/Controller/AdminApplicationController.php @@ -0,0 +1,316 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application as KeepiqApp; +use OCA\Keepiq\Db\Application; +use OCA\Keepiq\Db\ApplicationMapper; +use OCA\Keepiq\Service\ApplicationService; +use OCA\Keepiq\Service\LeaseService; +use OCA\Keepiq\Settings\ApplicationAdminSettings; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `/api/v1/admin/applications`. The area guard runs in Nextcloud's + * middleware, so every service call below runs as an administrator. + */ +class AdminApplicationController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param ApplicationService $applications The application service the admin screen uses + * @param LeaseService $leases The lease policy service + * @param ApplicationMapper $applicationMapper Existence check for the lease policy + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private ApplicationService $applications, + private LeaseService $leases, + private ApplicationMapper $applicationMapper, + private IUserSession $userSession, + ) { + parent::__construct(appName: KeepiqApp::APP_ID, request: $request); + }//end __construct() + + /** + * Every application. + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function index(): JSONResponse { + return new JSONResponse( + data: array_map( + static fn (Application $application): array => $application->jsonSerialize(), + $this->applications->listForUser($this->actor(), true) + ) + ); + }//end index() + + /** + * One application, with its public certificate when it is active. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function show(string $id): JSONResponse { + try { + $application = $this->applications->get($id, $this->actor(), true)->jsonSerialize(); + } catch (InvalidArgumentException $exception) { + return $this->notFound(message: $exception->getMessage()); + } + + // The public certificate of an active application, so a script that + // registered it from a CSR can read what Keepiq signed. + $application['certificate'] = null; + if ($application['status'] === 'active') { + $application['certificate'] = $this->applications->getCertificate(applicationId: $id); + } + + return new JSONResponse(data: $application); + }//end show() + + /** + * Register an application. An administrator's registration is active at + * once, so it needs no separate approval (application-mgmt). + * + * @param string $name The application name + * @param string|null $description An optional description + * @param string $type internal or external + * @param string|null $csr An optional PKCS#10 CSR in PEM + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function create( + string $name = '', + ?string $description = null, + string $type = Application::TYPE_EXTERNAL, + ?string $csr = null, + ): JSONResponse { + if (trim($name) === '') { + return new JSONResponse(data: ['message' => 'name is required'], statusCode: Http::STATUS_BAD_REQUEST); + } + + try { + $entity = $this->applications->register( + name: $name, + description: $description, + type: $type, + csr: $csr, + userId: $this->actor(), + isAdmin: true + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $entity->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end create() + + /** + * Approve a pending application, recording the caller as approver. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function approve(string $id): JSONResponse { + try { + return new JSONResponse(data: $this->applications->approve(applicationId: $id, adminUserId: $this->actor(), isAdmin: true)->jsonSerialize()); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end approve() + + /** + * Reject a pending application. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function reject(string $id): JSONResponse { + try { + $this->applications->reject(applicationId: $id, adminUserId: $this->actor(), isAdmin: true); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: ['status' => 'rejected', 'id' => $id]); + }//end reject() + + /** + * Delete an application and its vault. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function destroy(string $id): JSONResponse { + try { + $this->applications->delete(applicationId: $id, isAdmin: true); + } catch (InvalidArgumentException $exception) { + return $this->notFound(message: $exception->getMessage()); + } + + return new JSONResponse(data: ['status' => 'deleted', 'id' => $id]); + }//end destroy() + + /** + * The application's lease policy: its override and the effective values. + * + * @param string $id The application id + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function getLeasePolicy(string $id): JSONResponse { + if ($this->exists(id: $id) === false) { + return $this->notFound(message: 'Application not found'); + } + + return new JSONResponse(data: $this->leases->policyView(applicationId: $id)); + }//end getLeasePolicy() + + /** + * Set the application's lease policy override; null inherits. + * + * @param string $id The application id + * @param int|null $defaultTtl Default lease TTL in seconds, at least 60 + * @param int|null $maxTtl Maximum lease TTL in seconds, at least 60 + * @param bool|null $renewable Whether a lease may be renewed + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function setLeasePolicy(string $id, ?int $defaultTtl = null, ?int $maxTtl = null, ?bool $renewable = null): JSONResponse { + if ($this->exists(id: $id) === false) { + return $this->notFound(message: 'Application not found'); + } + + try { + $this->leases->setPolicyOverride(applicationId: $id, defaultTtl: $defaultTtl, maxTtl: $maxTtl, renewable: $renewable); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $this->leases->policyView(applicationId: $id)); + }//end setLeasePolicy() + + /** + * The acting administrator's uid. + * + * @return string + */ + private function actor(): string { + return (string)$this->userSession->getUser()?->getUID(); + }//end actor() + + /** + * Whether an application exists. + * + * @param string $id The application id + * + * @return bool + */ + private function exists(string $id): bool { + try { + $this->applicationMapper->findById($id); + } catch (DoesNotExistException) { + return false; + } + + return true; + }//end exists() + + /** + * A 404 with a message. + * + * @param string $message The message + * + * @return JSONResponse + */ + private function notFound(string $message): JSONResponse { + return new JSONResponse(data: ['message' => $message], statusCode: Http::STATUS_NOT_FOUND); + }//end notFound() +}//end class diff --git a/lib/Controller/AdminAreaSettingsController.php b/lib/Controller/AdminAreaSettingsController.php new file mode 100644 index 000000000..a39cb51c6 --- /dev/null +++ b/lib/Controller/AdminAreaSettingsController.php @@ -0,0 +1,226 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\Connection\ConnectionReporter; +use OCA\Keepiq\Service\SettingsService; +use OCA\Keepiq\Settings\AdminSettings; +use OCA\Keepiq\Settings\ApplicationAdminSettings; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCA\Keepiq\Settings\PolicyAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * One GET and one PUT per settings-bearing admin area. + */ +class AdminAreaSettingsController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param SettingsService $settingsService The settings service + * @param ConnectionReporter|null $connectionReporter Asks integriq to look again after a breach check save, or nothing when absent. + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private SettingsService $settingsService, + private ?ConnectionReporter $connectionReporter = null, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Read the General area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(AdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function getGeneralSettings(): JSONResponse { + return $this->readArea(area: 'general'); + }//end getGeneralSettings() + + /** + * Write the General area settings (admin-scoped-roles D2). + * + * 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/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + * @spec openspec/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)] + #[UserRateLimit(limit: 60, period: 60)] + public function updateGeneralSettings(): JSONResponse { + $data = $this->request->getParams(); + $response = $this->writeArea(area: 'general', data: $data); + + // The same test AdminSettingsService uses to decide it wrote the key. + if ($response->getStatus() === Http::STATUS_OK && isset($data['breach_check_enabled']) === true) { + $this->connectionReporter?->breachCheckSaved(); + } + + return $response; + }//end updateGeneralSettings() + + /** + * Read the Policies area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(PolicyAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(PolicyAdminSettings::class)] + public function getPolicySettings(): JSONResponse { + return $this->readArea(area: 'policies'); + }//end getPolicySettings() + + /** + * Write the Policies area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(PolicyAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(PolicyAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function updatePolicySettings(): JSONResponse { + return $this->writeArea(area: 'policies', data: $this->request->getParams()); + }//end updatePolicySettings() + + /** + * Read the Applications area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + public function getApplicationSettings(): JSONResponse { + return $this->readArea(area: 'applications'); + }//end getApplicationSettings() + + /** + * Write the Applications area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(ApplicationAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(ApplicationAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function updateApplicationSettings(): JSONResponse { + return $this->writeArea(area: 'applications', data: $this->request->getParams()); + }//end updateApplicationSettings() + + /** + * Read the Audit area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function getAuditSettings(): JSONResponse { + return $this->readArea(area: 'audit'); + }//end getAuditSettings() + + /** + * Write the Audit area settings (admin-scoped-roles D2). + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 60, period: 60)] + public function updateAuditSettings(): JSONResponse { + return $this->writeArea(area: 'audit', data: $this->request->getParams()); + }//end updateAuditSettings() + + /** + * One area's settings as a response. + * + * @param string $area The area key + * + * @return JSONResponse + */ + private function readArea(string $area): JSONResponse { + return new JSONResponse(data: $this->settingsService->getAreaSettings(area: $area)); + }//end readArea() + + /** + * Write one area's keys; a key of another area or an out-of-bounds value + * answers 400 and writes nothing of the failing group. + * + * @param string $area The area key + * @param array $data The request parameters + * + * @return JSONResponse + */ + private function writeArea(string $area, array $data): JSONResponse { + try { + $result = $this->settingsService->updateAreaSettings(area: $area, data: $data); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse(data: $result); + }//end writeArea() +}//end class diff --git a/lib/Controller/AdminAuditController.php b/lib/Controller/AdminAuditController.php new file mode 100644 index 000000000..0453fe31b --- /dev/null +++ b/lib/Controller/AdminAuditController.php @@ -0,0 +1,267 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AuditService; +use OCA\Keepiq\Service\ComplianceReportService; +use OCA\Keepiq\Service\Siem\SiemSinkRequest; +use OCA\Keepiq\Service\SiemService; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `/api/v1/admin/audit`, `/compliance/reports` and `/siem/sinks`. + */ +class AdminAuditController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param AuditService $audit The audit query the admin screen uses + * @param ComplianceReportService $reports The compliance reports + * @param SiemService $siem The SIEM sinks + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private AuditService $audit, + private ComplianceReportService $reports, + private SiemService $siem, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Audit events, filtered and paged like the admin screen. + * + * @param string|null $eventType Event type filter + * @param string|null $actor Actor filter + * @param string|null $objectType Object type filter + * @param string|null $objectId Object id filter + * @param string|null $from ISO 8601 lower bound + * @param string|null $to ISO 8601 upper bound + * @param int $page 1-based page + * @param int $limit Page size + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function events( + ?string $eventType = null, + ?string $actor = null, + ?string $objectType = null, + ?string $objectId = null, + ?string $from = null, + ?string $to = null, + int $page = 1, + int $limit = 50, + ): JSONResponse { + $filters = [ + 'eventType' => $eventType, + 'actor' => $actor, + 'objectType' => $objectType, + 'objectId' => $objectId, + 'from' => $from, + 'to' => $to, + ]; + + return new JSONResponse(data: $this->audit->adminQuery($filters, $page, $limit)); + }//end events() + + /** + * The compliance reports, newest first. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function reports(): JSONResponse { + return new JSONResponse( + data: array_map( + static fn (object $report): array => [ + 'id' => $report->getId(), + 'generatedBy' => $report->getGeneratedBy(), + 'generatedAt' => $report->getGeneratedAt()?->format('c'), + 'appVersion' => $report->getAppVersion(), + ], + $this->reports->listReports() + ) + ); + }//end reports() + + /** + * Generate a compliance report now. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 10, period: 60)] + public function generateReport(): JSONResponse { + return new JSONResponse( + data: $this->reports->generate(adminUid: $this->actor())->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + }//end generateReport() + + /** + * One compliance report. + * + * @param string $id The report id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function showReport(string $id): JSONResponse { + try { + return new JSONResponse(data: $this->reports->getReport(id: $id)->jsonSerialize()); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Report not found'], statusCode: Http::STATUS_NOT_FOUND); + } + }//end showReport() + + /** + * The SIEM sinks. A sink's HMAC secret and connector credential never + * appear in its serialized form. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + public function sinks(): JSONResponse { + return new JSONResponse( + data: array_map(static fn (object $sink): array => $sink->jsonSerialize(), $this->siem->listSinks()) + ); + }//end sinks() + + /** + * Create a SIEM sink; the body is the admin screen's. + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function createSink(): JSONResponse { + try { + $sink = $this->siem->createSink(adminUid: $this->actor(), params: (new SiemSinkRequest(request: $this->request))->forCreate()); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $sink->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end createSink() + + /** + * Update a SIEM sink. + * + * @param string $id The sink id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function updateSink(string $id): JSONResponse { + try { + $sink = $this->siem->updateSink(adminUid: $this->actor(), sinkId: $id, params: (new SiemSinkRequest(request: $this->request))->forUpdate()); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Sink not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $sink->jsonSerialize()); + }//end updateSink() + + /** + * Delete a SIEM sink. + * + * @param string $id The sink id + * + * @AuthorizedAdminSetting(AuditAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-returns-metadata-only + */ + #[AuthorizedAdminSetting(AuditAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function destroySink(string $id): JSONResponse { + try { + $this->siem->deleteSink(adminUid: $this->actor(), sinkId: $id); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Sink not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['deleted' => true]); + }//end destroySink() + + /** + * The acting administrator's uid. + * + * @return string + */ + private function actor(): string { + return (string)$this->userSession->getUser()?->getUID(); + }//end actor() +}//end class diff --git a/lib/Controller/AdminIndexController.php b/lib/Controller/AdminIndexController.php new file mode 100644 index 000000000..d6e280ff8 --- /dev/null +++ b/lib/Controller/AdminIndexController.php @@ -0,0 +1,132 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AdminAreaAuthorizer; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `GET /api/v1/admin`: the version and every path. + */ +class AdminIndexController extends Controller { + /** + * The admin API version this index describes. + * + * @var int + */ + public const API_VERSION = 1; + + /** + * Every v1 path, with its method and admin area. Force revocation and + * suite reinstatement are left out on purpose: both need a fresh password + * confirmation that a stored app password cannot give (design D4). + * + * @var array + */ + public const PATHS = [ + ['method' => 'GET', 'path' => '/api/v1/admin', 'area' => 'any'], + ['method' => 'GET', 'path' => '/api/v1/admin/policies', 'area' => 'policies'], + ['method' => 'PUT', 'path' => '/api/v1/admin/policies', 'area' => 'policies'], + ['method' => 'GET', 'path' => '/api/v1/admin/members', 'area' => 'people'], + ['method' => 'GET', 'path' => '/api/v1/admin/suites', 'area' => 'people'], + ['method' => 'POST', 'path' => '/api/v1/admin/offboarding', 'area' => 'people'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications/{id}', 'area' => 'applications'], + ['method' => 'DELETE', 'path' => '/api/v1/admin/applications/{id}', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications/{id}/approve', 'area' => 'applications'], + ['method' => 'POST', 'path' => '/api/v1/admin/applications/{id}/reject', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/applications/{id}/lease-policy', 'area' => 'applications'], + ['method' => 'PUT', 'path' => '/api/v1/admin/applications/{id}/lease-policy', 'area' => 'applications'], + ['method' => 'GET', 'path' => '/api/v1/admin/audit', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/compliance/reports', 'area' => 'audit'], + ['method' => 'POST', 'path' => '/api/v1/admin/compliance/reports', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/compliance/reports/{id}', 'area' => 'audit'], + ['method' => 'GET', 'path' => '/api/v1/admin/siem/sinks', 'area' => 'audit'], + ['method' => 'POST', 'path' => '/api/v1/admin/siem/sinks', 'area' => 'audit'], + ['method' => 'PUT', 'path' => '/api/v1/admin/siem/sinks/{id}', 'area' => 'audit'], + ['method' => 'DELETE', 'path' => '/api/v1/admin/siem/sinks/{id}', 'area' => 'audit'], + ]; + + /** + * Constructor. + * + * @param IRequest $request The request + * @param IUserSession $userSession The session user + * @param AdminAreaAuthorizer $areas The admin areas the caller holds + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private IUserSession $userSession, + private AdminAreaAuthorizer $areas, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The admin API index, for any holder of at least one admin area. One + * attribute names one class, so "any area" is checked here instead. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-versioned-admin-api-index + */ + #[NoAdminRequired] + public function index(): JSONResponse { + $userId = $this->userSession->getUser()?->getUID(); + $held = []; + if ($userId !== null) { + $held = $this->areas->areasOf(userId: $userId); + } + + if ($held === []) { + return new JSONResponse(data: ['message' => 'The admin API needs a Keepiq admin area'], statusCode: Http::STATUS_FORBIDDEN); + } + + return new JSONResponse( + data: [ + 'apiVersion' => self::API_VERSION, + 'versions' => ['v1'], + 'areas' => $held, + 'paths' => self::PATHS, + 'notOffered' => [ + 'suite force revocation and reinstatement: both need a fresh password confirmation, which an app password cannot give', + ], + ] + ); + }//end index() +}//end class diff --git a/lib/Controller/AdminPeopleController.php b/lib/Controller/AdminPeopleController.php new file mode 100644 index 000000000..d4696483b --- /dev/null +++ b/lib/Controller/AdminPeopleController.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Service\TeamFolderService; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * `GET /api/v1/admin/suites` and `POST /api/v1/admin/offboarding`. + */ +class AdminPeopleController extends Controller { + /** + * The largest suite page a script may ask for. + * + * @var int + */ + private const MAX_LIMIT = 500; + + /** + * Constructor. + * + * @param IRequest $request The request + * @param EncryptionSuiteMapper $suites The suite rows + * @param TeamFolderService $teamFolders The offboarding entry point the admin screen uses + * @param IUserSession $userSession The acting administrator + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private EncryptionSuiteMapper $suites, + private TeamFolderService $teamFolders, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Active suites, one page, metadata only: never the private key blob or + * the certificate. + * + * @param int $limit Page size, 1 to 500 + * @param int $offset Rows to skip + * + * @AuthorizedAdminSetting(PeopleAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + public function suites(int $limit = 100, int $offset = 0): JSONResponse { + $limit = max(1, min(self::MAX_LIMIT, $limit)); + $offset = max(0, $offset); + + return new JSONResponse( + data: [ + 'limit' => $limit, + 'offset' => $offset, + 'results' => array_map( + static fn (EncryptionSuite $suite): array => self::suiteRow(suite: $suite), + $this->suites->findAllActiveWithLimit($limit, $offset) + ), + ] + ); + }//end suites() + + /** + * Offboard a leaver: the same service call as the admin screen, which + * also checks the People area itself. + * + * @param string $leavingUserId The user being offboarded + * @param string $successorUserId The user taking over owned team secrets + * + * @AuthorizedAdminSetting(PeopleAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-api/spec.md#requirement-admin-api-covers-the-administration-jobs + */ + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + #[UserRateLimit(limit: 30, period: 60)] + public function offboard(string $leavingUserId = '', string $successorUserId = ''): JSONResponse { + try { + $summary = $this->teamFolders->offboard( + leavingUserId: $leavingUserId, + successorUserId: $successorUserId, + adminId: (string)$this->userSession->getUser()?->getUID() + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $summary); + }//end offboard() + + /** + * The metadata of one suite. + * + * @param EncryptionSuite $suite The suite + * + * @return array + */ + private static function suiteRow(EncryptionSuite $suite): array { + $row = $suite->jsonSerialize(); + + return [ + 'id' => $row['id'], + 'ownerType' => $row['ownerType'], + 'ownerId' => $row['ownerId'], + 'status' => $row['status'], + 'createdAt' => $row['createdAt'], + 'revokedAt' => $row['revokedAt'], + 'revokedBy' => $row['revokedBy'], + 'reinstatedAt' => $row['reinstatedAt'], + 'reinstatedBy' => $row['reinstatedBy'], + ]; + }//end suiteRow() +}//end class diff --git a/lib/Controller/ApplicationCertificateController.php b/lib/Controller/ApplicationCertificateController.php new file mode 100644 index 000000000..7ffca0baf --- /dev/null +++ b/lib/Controller/ApplicationCertificateController.php @@ -0,0 +1,100 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application as KeepiqApp; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Service\MachineSecretEnvelopeService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AnonRateLimit; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * The calling application's own certificate, on the Bearer machine surface. + */ +class ApplicationCertificateController extends ApplicationApiController { + /** + * Constructor. + * + * @param IRequest $request The request + * @param EncryptionSuiteMapper $suites The application's suites + * @param MachineSecretEnvelopeService $envelopes The fingerprint the envelopes carry + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private EncryptionSuiteMapper $suites, + private MachineSecretEnvelopeService $envelopes, + ) { + parent::__construct(appName: KeepiqApp::APP_ID, request: $request); + }//end __construct() + + /** + * The application's active certificate (public, PEM), its suite and the + * `sha256:` fingerprint over its DER, computed exactly as the envelope's + * `certificateFingerprint`. Only the Bearer-authenticated application + * itself reaches its own certificate; JwtAuthMiddleware binds it. + * + * @PublicPage + * @NoCSRFRequired + * + * @return JSONResponse + * + * @spec openspec/specs/secret-store-api/spec.md#requirement-an-application-reads-its-own-certificate + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 30, period: 60)] + public function show(): JSONResponse { + $application = $this->getApplication(); + if ($application === null) { + return new JSONResponse(data: ['message' => 'Bearer token required'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $suite = $this->suites->findActiveByOwner('application', (string)$application->getId()); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'No active encryption suite'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse( + data: [ + 'applicationId' => $application->getId(), + 'suiteId' => $suite->getId(), + 'certificate' => $suite->getCertificate(), + 'certificateFingerprint' => $this->envelopes->certificateFingerprint(suiteId: (string)$suite->getId()), + ] + ); + }//end show() +}//end class diff --git a/lib/Controller/ApplicationController.php b/lib/Controller/ApplicationController.php index 705359a37..c178ca556 100644 --- a/lib/Controller/ApplicationController.php +++ b/lib/Controller/ApplicationController.php @@ -27,7 +27,9 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application as KeepiqApp; use OCA\Keepiq\Db\Application; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\ApplicationService; +use OCA\Keepiq\Settings\ApplicationAdminSettings; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -36,7 +38,6 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -50,7 +51,7 @@ class ApplicationController extends OCSController { * @param IRequest $request The request object * @param ApplicationService $service The application service * @param IUserSession $session The user session - * @param IGroupManager $groupManager The group manager + * @param AdminAreaAuthorizer $areas Whether the caller holds the Applications admin area * @param IAppConfig $appConfig The app config * * @return void @@ -59,7 +60,7 @@ public function __construct( IRequest $request, private ApplicationService $service, private IUserSession $session, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, private IAppConfig $appConfig, ) { parent::__construct(appName: KeepiqApp::APP_ID, request: $request); @@ -82,7 +83,7 @@ public function index(): JSONResponse { } $uid = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($uid); + $isAdmin = $this->areas->holds(userId: $uid, areaClass: ApplicationAdminSettings::class); $apps = $this->service->listForUser($uid, $isAdmin); @@ -107,7 +108,7 @@ public function pending(): JSONResponse { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } - $isAdmin = $this->groupManager->isAdmin($user->getUID()); + $isAdmin = $this->areas->holds(userId: $user->getUID(), areaClass: ApplicationAdminSettings::class); try { $pending = $this->service->listPending($isAdmin); @@ -142,7 +143,7 @@ public function show(string $id): JSONResponse { } $uid = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($uid); + $isAdmin = $this->areas->holds(userId: $uid, areaClass: ApplicationAdminSettings::class); try { $entity = $this->service->get($id, $uid, $isAdmin); @@ -231,7 +232,7 @@ public function create( $isAdmin = false; if ($user !== null) { $uid = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($uid); + $isAdmin = $this->areas->holds(userId: $uid, areaClass: ApplicationAdminSettings::class); } try { @@ -272,7 +273,7 @@ public function approve(string $id): JSONResponse { } $uid = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($uid); + $isAdmin = $this->areas->holds(userId: $uid, areaClass: ApplicationAdminSettings::class); try { $entity = $this->service->approve(applicationId: $id, adminUserId: $uid, isAdmin: $isAdmin); @@ -307,7 +308,7 @@ public function reject(string $id): JSONResponse { } $uid = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($uid); + $isAdmin = $this->areas->holds(userId: $uid, areaClass: ApplicationAdminSettings::class); try { $this->service->reject(applicationId: $id, adminUserId: $uid, isAdmin: $isAdmin); @@ -341,7 +342,7 @@ public function destroy(string $id): JSONResponse { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } - $isAdmin = $this->groupManager->isAdmin($user->getUID()); + $isAdmin = $this->areas->holds(userId: $user->getUID(), areaClass: ApplicationAdminSettings::class); try { $this->service->delete(applicationId: $id, isAdmin: $isAdmin); diff --git a/lib/Controller/ApplicationRequestAdminController.php b/lib/Controller/ApplicationRequestAdminController.php index 72e550a23..4c0d9d920 100644 --- a/lib/Controller/ApplicationRequestAdminController.php +++ b/lib/Controller/ApplicationRequestAdminController.php @@ -44,12 +44,13 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application as KeepiqApp; use OCA\Keepiq\Db\SecretRequest; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\ApplicationRequestAdminService; +use OCA\Keepiq\Settings\ApplicationAdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; use Throwable; @@ -64,7 +65,7 @@ class ApplicationRequestAdminController extends Controller { * @param IRequest $request The request * @param ApplicationRequestAdminService $service The admin-scoped request service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager Resolves administrator membership + * @param AdminAreaAuthorizer $areas Whether the caller holds the Applications admin area * * @return void * @@ -74,7 +75,7 @@ public function __construct( IRequest $request, private ApplicationRequestAdminService $service, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: KeepiqApp::APP_ID, request: $request); }//end __construct() @@ -105,7 +106,7 @@ public function __construct( */ private function requireAdminUid(): ?string { $user = $this->userSession->getUser(); - if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + if ($user === null || $this->areas->holds(userId: $user->getUID(), areaClass: ApplicationAdminSettings::class) === false) { return null; } diff --git a/lib/Controller/ApplicationSecretsController.php b/lib/Controller/ApplicationSecretsController.php index 61a0154fb..d64fcf96c 100644 --- a/lib/Controller/ApplicationSecretsController.php +++ b/lib/Controller/ApplicationSecretsController.php @@ -64,7 +64,8 @@ * - `GET /api/v1/app/secrets/{id}` — fetch by id (envelope, ETag/304). * - `GET /api/v1/app/secrets/by-name/{name}`— fetch by name (404 / envelope / 409). * - `POST /api/v1/app/secrets` — create (client-encrypted). - * - `PUT /api/v1/app/secrets/{id}` — replace ciphertext (client-encrypted). + * - `PUT /api/v1/app/secrets/{id}` — replace ciphertext (client-encrypted; + * If-Match → 412 on a stale ETag). * * Responses contain ciphertext only — the calling application decrypts * with its private key. @@ -332,6 +333,11 @@ public function update(string $id): JSONResponse { return $this->unauthorized(); } + $precondition = $this->checkIfMatch(id: $id, applicationId: $application->getId()); + if ($precondition !== null) { + return $precondition; + } + try { $secret = $this->secretService->updateByApplication( id: $id, @@ -350,6 +356,45 @@ public function update(string $id): JSONResponse { return $this->responseService->envelope(secret: $secret, applicationId: $application->getId()); }//end update() + /** + * Enforce an If-Match precondition on a write-back. + * + * Without the header nothing is checked, as before. With it, the write + * goes ahead only when the header names the secret's current strong ETag + * (or is `*`); otherwise the answer is 412 with the current ETag, and + * nothing is written. A secret of another vault is the usual 404, so the + * precondition is no existence oracle. + * + * @param string $id The secret ID + * @param string $applicationId The calling application id + * + * @return JSONResponse|null The refusal, or null when the write may go ahead + * + * @spec openspec/specs/secret-store-api/spec.md + */ + private function checkIfMatch(string $id, string $applicationId): ?JSONResponse { + $header = trim($this->request->getHeader('If-Match')); + if ($header === '') { + return null; + } + + $secret = $this->loadOwnedOrNull(id: $id, applicationId: $applicationId); + if ($secret === null) { + return $this->notFound(); + } + + if ($this->envelopeService->ifMatchHolds(secret: $secret, header: $header) === true) { + return null; + } + + $response = new JSONResponse( + data: ['message' => 'The secret changed since it was read; read it again before writing'], + statusCode: Http::STATUS_PRECONDITION_FAILED + ); + $response->addHeader('ETag', $this->envelopeService->etag($secret)); + return $response; + }//end checkIfMatch() + /** * Load a secret only when it is owned by the given application, else * null — the caller maps null to a 404, never an existence-revealing diff --git a/lib/Controller/AuditController.php b/lib/Controller/AuditController.php index f3bec1872..9488b48ca 100644 --- a/lib/Controller/AuditController.php +++ b/lib/Controller/AuditController.php @@ -29,7 +29,8 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\SecretMapper; use OCA\Keepiq\Service\AuditService; -use OCA\Keepiq\Settings\AdminSettings; +use OCA\Keepiq\Service\RecentlyUsedService; +use OCA\Keepiq\Settings\AuditAdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; @@ -51,6 +52,7 @@ class AuditController extends Controller { * @param AuditService $auditService The audit service * @param SecretMapper $secretMapper The secret mapper (for ownership checks) * @param IUserSession $userSession The user session + * @param RecentlyUsedService $recentlyUsed The Recently used widget source * * @return void */ @@ -59,6 +61,7 @@ public function __construct( private AuditService $auditService, private SecretMapper $secretMapper, private IUserSession $userSession, + private RecentlyUsedService $recentlyUsed, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -155,6 +158,28 @@ public function mine(int $page = 1, int $limit = 50): JSONResponse { ); }//end mine() + /** + * The session user's recently read secrets, for the dashboard widget. + * + * Scoped to the session user twice over: the audit query reads that user's + * own read events, and each secret must still be owned by that user. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/vault-recently-used/spec.md#requirement-recently-used-on-the-dashboard + */ + #[NoAdminRequired] + public function recent(): JSONResponse { + $userId = $this->uid(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: $this->recentlyUsed->forUser(userId: $userId)); + }//end recent() + /** * Admin instance-wide audit view: filterable, paginated, with total count. * @@ -170,13 +195,13 @@ public function mine(int $page = 1, int $limit = 50): JSONResponse { * @param int $page 1-based page number * @param int $limit Page size (default 50) * - * @AuthorizedAdminSetting(AdminSettings::class) + * @AuthorizedAdminSetting(AuditAdminSettings::class) * * @return JSONResponse * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-4.1 */ - #[AuthorizedAdminSetting(AdminSettings::class)] + #[AuthorizedAdminSetting(AuditAdminSettings::class)] public function index( ?string $eventType = null, ?string $actor = null, diff --git a/lib/Controller/BackupAdminController.php b/lib/Controller/BackupAdminController.php new file mode 100644 index 000000000..532e75bb2 --- /dev/null +++ b/lib/Controller/BackupAdminController.php @@ -0,0 +1,131 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use InvalidArgumentException; +use OCA\Keepiq\Backup\BackupService; +use OCA\Keepiq\Backup\BackupSettings; +use OCA\Keepiq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Admin-only backup status, list and "Back up now". + */ +class BackupAdminController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param BackupService $backups The backup service + * @param BackupSettings $settings The schedule, retention and key + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private BackupService $backups, + private BackupSettings $settings, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The last run and the archive list, without the disk paths and never + * any archive content. + * + * @AuthorizedAdminSetting(AdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function index(): JSONResponse { + $archives = array_map( + static fn (array $archive): array => [ + 'name' => $archive['name'], + 'size' => $archive['size'], + 'createdAt' => $archive['createdAt'], + 'encrypted' => $archive['encrypted'], + ], + $this->backups->listArchives() + ); + + return new JSONResponse( + data: [ + 'settings' => $this->settings->read(), + 'status' => $this->backups->status(), + 'archives' => $archives, + ] + ); + }//end index() + + /** + * Ask the next cron run to back up. + * + * @AuthorizedAdminSetting(AdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function run(): JSONResponse { + $this->backups->requestRun(); + + return new JSONResponse(data: ['requested' => true], statusCode: Http::STATUS_ACCEPTED); + }//end run() + + /** + * Save the backup settings; a bad value is refused with 400 and nothing + * is written. + * + * @AuthorizedAdminSetting(AdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/vault-backups/spec.md#requirement-administrator-schedules-vault-backups + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function update(): JSONResponse { + try { + $this->settings->update(data: $this->request->getParams()); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $this->settings->read()); + }//end update() +}//end class diff --git a/lib/Controller/BreachProxyController.php b/lib/Controller/BreachProxyController.php index 25a4f682c..2d409d286 100644 --- a/lib/Controller/BreachProxyController.php +++ b/lib/Controller/BreachProxyController.php @@ -96,7 +96,7 @@ class BreachProxyController extends Controller { * * @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 + * @spec openspec/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, @@ -138,6 +138,11 @@ public function __construct( * gate-7 correctly stops treating that 403 as a guard once it requires a * 403 to have consulted the caller. * + * The prefix arrives in the POST body, never in the request URI + * (keepiq#866). Nextcloud stamps every log line, and the web server every + * access-log line, with the request URI next to the user id; a prefix in + * the path paired the two on any line written during this request. + * * 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, @@ -149,10 +154,10 @@ public function __construct( * @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 + * @spec openspec/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 { + public function range(string $prefix = ''): DataResponse { if ($this->userSession->getUser() === null) { return new DataResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } @@ -193,8 +198,10 @@ public function range(string $prefix): DataResponse { } catch (Throwable $e) { // 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. + // message names the upstream URL, which ends in the prefix, and + // Nextcloud stamps every line with the user who typed the password + // and with the request URI, which is why the prefix travels in the + // body and not in this route's path (keepiq#866). // 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. diff --git a/lib/Controller/CACertificateController.php b/lib/Controller/CACertificateController.php index 1390d57b4..465de0cec 100644 --- a/lib/Controller/CACertificateController.php +++ b/lib/Controller/CACertificateController.php @@ -71,6 +71,8 @@ public function getStatus(): JSONResponse { * @AuthorizedAdminSetting(AdminSettings::class) * * @return JSONResponse + * + * @spec openspec/specs/certificate-lifecycle/spec.md#requirement-ca-health-on-the-admin-dashboard */ #[AuthorizedAdminSetting(AdminSettings::class)] public function health(): JSONResponse { @@ -111,7 +113,7 @@ public function retryBootstrap(): JSONResponse { #[AuthorizedAdminSetting(AdminSettings::class)] public function renewIntermediate(): JSONResponse { try { - $count = $this->caService->renewIntermediate(forced: true); + $count = $this->caService->renewIntermediateRevokingOld(); return new JSONResponse( data: [ 'message' => "Intermediate renewed, {$count} suites re-signed", diff --git a/lib/Controller/CertificateController.php b/lib/Controller/CertificateController.php index 723445a7d..8410a3352 100644 --- a/lib/Controller/CertificateController.php +++ b/lib/Controller/CertificateController.php @@ -28,13 +28,14 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\CertificateLifecycleService; +use OCA\Keepiq\Settings\AdminSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -48,7 +49,7 @@ class CertificateController extends OCSController { * @param IRequest $request The request object * @param CertificateLifecycleService $service The lifecycle service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin scope) + * @param AdminAreaAuthorizer $areas Whether the caller holds the General admin area * * @return void */ @@ -56,7 +57,7 @@ public function __construct( IRequest $request, private CertificateLifecycleService $service, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -78,7 +79,7 @@ private function uid(): ?string { * @return bool */ private function isAdmin(string $uid): bool { - return $this->groupManager->isAdmin($uid); + return $this->areas->holds(userId: $uid, areaClass: AdminSettings::class); }//end isAdmin() /** @@ -116,6 +117,8 @@ public function inventory(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/certificate-lifecycle/spec.md#scenario-client-submits-parsed-metadata-for-a-stored-certificate */ #[NoAdminRequired] public function submitMetadata( @@ -163,6 +166,8 @@ public function submitMetadata( * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/certificate-lifecycle/spec.md#requirement-guided-renewal-by-certificate-origin */ #[NoAdminRequired] public function renewalChecklist(string $secretId): JSONResponse { @@ -189,6 +194,8 @@ public function renewalChecklist(string $secretId): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/certificate-lifecycle/spec.md#scenario-suite-certificate-re-issued-preserving-its-public-key */ #[NoAdminRequired] public function reissueSuite(string $suiteId): JSONResponse { diff --git a/lib/Controller/ComplianceReportController.php b/lib/Controller/ComplianceReportController.php index 261043385..2ec3937b7 100644 --- a/lib/Controller/ComplianceReportController.php +++ b/lib/Controller/ComplianceReportController.php @@ -25,13 +25,14 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\ComplianceReport; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\ComplianceReportService; +use OCA\Keepiq\Settings\AuditAdminSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -45,7 +46,7 @@ class ComplianceReportController extends OCSController { * @param IRequest $request The request object * @param ComplianceReportService $service The compliance service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin gate) + * @param AdminAreaAuthorizer $areas Whether the caller holds the Audit admin area * * @return void */ @@ -53,7 +54,7 @@ public function __construct( IRequest $request, private ComplianceReportService $service, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -66,7 +67,7 @@ public function __construct( */ private function adminUid(): ?string { $user = $this->userSession->getUser(); - if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + if ($user === null || $this->areas->holds(userId: $user->getUID(), areaClass: AuditAdminSettings::class) === false) { return null; } @@ -113,6 +114,8 @@ public function generate(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-immutable-timestamped-evidence-snapshot */ #[NoAdminRequired] public function index(): JSONResponse { @@ -141,6 +144,8 @@ public function index(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-immutable-timestamped-evidence-snapshot */ #[NoAdminRequired] public function show(string $id): JSONResponse { @@ -161,6 +166,8 @@ public function show(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-org-level-metadata-only-compliance-report */ #[NoAdminRequired] public function metrics(): JSONResponse { @@ -181,6 +188,8 @@ public function metrics(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-csv-and-pdf-export */ #[NoAdminRequired] public function exported(string $id, string $format = ''): JSONResponse { diff --git a/lib/Controller/DashboardController.php b/lib/Controller/DashboardController.php index 4856e2b07..a9517c070 100644 --- a/lib/Controller/DashboardController.php +++ b/lib/Controller/DashboardController.php @@ -22,6 +22,7 @@ namespace OCA\Keepiq\Controller; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\DashboardSummaryService; use OCP\App\IAppManager; use OCP\AppFramework\Controller; @@ -31,7 +32,6 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -48,7 +48,7 @@ class DashboardController extends Controller { * @param IAppManager $appManager The app manager (version source) * @param DashboardSummaryService $summaryService The dashboard summary aggregator * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager + * @param AdminAreaAuthorizer $areas Whether the caller holds the Applications admin area * * @return void */ @@ -59,7 +59,7 @@ public function __construct( private IAppManager $appManager, private DashboardSummaryService $summaryService, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -81,7 +81,7 @@ public function summary(): JSONResponse { } $userId = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($userId); + $isAdmin = $this->areas->holds(userId: $userId, areaClass: AdminAreaAuthorizer::APPLICATIONS); return new JSONResponse(data: $this->summaryService->fetchSummary(userId: $userId, isAdmin: $isAdmin)); }//end summary() @@ -145,6 +145,7 @@ public function page(): TemplateResponse { * @return TemplateResponse * * @spec exclude Vue history-mode fallback — delegates to page(); pure framework plumbing, no domain logic. + * @contract exclude renders the SPA page, not an API response; pinned by RoutesWithoutOpenRegisterTest. */ public function catchAll(): TemplateResponse { return $this->page(); diff --git a/lib/Controller/DelegationController.php b/lib/Controller/DelegationController.php index 8502ee464..7ef090ecc 100644 --- a/lib/Controller/DelegationController.php +++ b/lib/Controller/DelegationController.php @@ -27,7 +27,10 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Exception\ForbiddenException; use OCA\Keepiq\Service\DelegationService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; @@ -102,8 +105,10 @@ public function index(string $secretId): JSONResponse { * @return JSONResponse * * @spec openspec/changes/implement-user-sharing/tasks.md#9.4 + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof */ #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['secretId', 'delegatedTo'], purpose: VaultKeyProofService::PURPOSE_DELEGATION_CREATE)] public function create(string $secretId, string $delegatedTo): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { @@ -116,6 +121,9 @@ public function create(string $secretId, string $delegatedTo): JSONResponse { delegatedTo: $delegatedTo, initiatedBy: $user->getUID() ); + } catch (ForbiddenException $exception) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $exception) { return new JSONResponse( data: ['message' => $exception->getMessage()], @@ -144,7 +152,7 @@ public function create(string $secretId, string $delegatedTo): JSONResponse { * a request body flag that switches which check runs is the shape that * makes a takeover look like an ordinary delegation in the audit trail. * - * The service enforces the rest — vault_admin membership, that the + * The service enforces the rest — the People admin area, that the * initiator is not already the owner, and that they already hold a share * of the secret. A handover widens WHO may act on a secret already shared * with the admin; it never grants reach over a secret they cannot see. @@ -156,8 +164,10 @@ public function create(string $secretId, string $delegatedTo): JSONResponse { * @return JSONResponse * * @spec openspec/specs/user-sharing/spec.md#requirement-ownership-delegation + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof */ #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['secretId'], purpose: VaultKeyProofService::PURPOSE_DELEGATION_HANDOVER)] public function handover(string $secretId): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { @@ -189,7 +199,8 @@ public function handover(string $secretId): JSONResponse { * to know whether to offer the takeover, which is half of why the * handover path stayed unreachable. * - * Reports group membership only — never a per-secret decision. The + * Reports the People area check only (admin-scoped-roles D5), never a + * per-secret decision. The * per-secret preconditions live in the service and are enforced on the * write, so a stale or spoofed `true` here buys nothing. * @@ -207,7 +218,7 @@ public function capabilities(): JSONResponse { } return new JSONResponse( - data: ['isVaultAdmin' => $this->delegationService->isVaultAdmin(userId: $user->getUID())] + data: ['canHandover' => $this->delegationService->canHandover(userId: $user->getUID())] ); }//end capabilities() diff --git a/lib/Controller/DeviceApprovalController.php b/lib/Controller/DeviceApprovalController.php new file mode 100644 index 000000000..9aba9ae4d --- /dev/null +++ b/lib/Controller/DeviceApprovalController.php @@ -0,0 +1,220 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\DeviceApprovalService; +use OCA\Keepiq\Service\VaultKeyProofService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * New device approval (crypto-new-device-approval). Every route acts on the + * caller's own requests only; another user's request answers like an unknown id. + */ +class DeviceApprovalController extends Controller { + + /** + * Constructor for DeviceApprovalController. + * + * @param IRequest $request The request + * @param DeviceApprovalService $approvals The approval service + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private DeviceApprovalService $approvals, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Whether device approval is on, so the lock screen knows to offer it. + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + #[NoAdminRequired] + public function status(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: ['enabled' => $this->approvals->isEnabled()]); + }//end status() + + /** + * A locked device asks to be approved. At most three per user per hour. + * + * @param string $publicKey The one-time X25519 public key (base64) + * @param string $clientKind `web` or `extension` + * @param string $deviceLabel The device label + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 3, period: 3600)] + public function create(string $publicKey = '', string $clientKind = 'web', string $deviceLabel = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $created = $this->approvals->create( + userId: $user->getUID(), + publicKey: $publicKey, + clientKind: $clientKind, + label: $deviceLabel, + address: $this->request->getRemoteAddress(), + agent: (string)$this->request->getHeader('User-Agent'), + ); + } 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); + } + + return new JSONResponse(data: $created, statusCode: Http::STATUS_CREATED); + }//end create() + + /** + * The caller's open requests, for the approval dialog. + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-both-devices-show-the-same-verification-phrase + */ + #[NoAdminRequired] + public function pending(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse( + data: array_map(static fn ($row) => $row->jsonSerialize(), $this->approvals->pending(userId: $user->getUID())) + ); + }//end pending() + + /** + * Deny one of the caller's open requests. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + #[NoAdminRequired] + public function deny(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->approvals->deny(id: $id, userId: $user->getUID()); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['status' => 'denied']); + }//end deny() + + /** + * Approve one of the caller's open requests with the sealed unlock key. + * Needs a vault-key proof from the caller's active suite, bound to the + * request id and the sealed key, so an unlocked tab cannot approve alone. + * + * @param string $id The request + * @param string $sealedUnlockKey The unlock key sealed to the request key + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-approval-seals-the-unlock-key-and-needs-proof-of-the-master-password + */ + #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['id', 'sealedUnlockKey'], subject: 'active', purpose: VaultKeyProofService::PURPOSE_APPROVE_DEVICE)] + public function approve(string $id, string $sealedUnlockKey = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->approvals->approve(id: $id, userId: $user->getUID(), sealedUnlockKey: $sealedUnlockKey); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: ['status' => 'approved']); + }//end approve() + + /** + * The requesting device's poll, with its request secret in the + * `X-Keepiq-Request-Secret` header (kept out of URLs and access logs). + * Carries the sealed key once. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ + #[NoAdminRequired] + public function show(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse( + data: $this->approvals->pickup( + id: $id, + userId: $user->getUID(), + requestSecret: (string)$this->request->getHeader('X-Keepiq-Request-Secret') + ) + ); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + }//end show() +}//end class diff --git a/lib/Controller/DiscoveryController.php b/lib/Controller/DiscoveryController.php index 0980634e7..1c0d7bddb 100644 --- a/lib/Controller/DiscoveryController.php +++ b/lib/Controller/DiscoveryController.php @@ -44,6 +44,7 @@ use OCA\Keepiq\AppInfo\Application as KeepiqApp; use OCA\Keepiq\Service\AudiencePolicy; +use OCA\Keepiq\Service\FederationRootService; use OCA\Keepiq\Service\JwtAuthService; use OCA\Keepiq\Service\MachineSecretEnvelopeService; use OCP\AppFramework\Controller; @@ -98,6 +99,7 @@ class DiscoveryController extends Controller { * @param IURLGenerator $urlGenerator The URL generator * @param IAppConfig|null $appConfig The app config (lease policy advert) * @param LoggerInterface|null $logger Logger for the deprecated-path warning + * @param FederationRootService|null $federationRoot Root fingerprint and version gate for federation * * @return void */ @@ -106,6 +108,7 @@ public function __construct( private IURLGenerator $urlGenerator, private ?IAppConfig $appConfig = null, private ?LoggerInterface $logger = null, + private ?FederationRootService $federationRoot = null, ) { parent::__construct(appName: KeepiqApp::APP_ID, request: $request); }//end __construct() @@ -127,6 +130,7 @@ public function __construct( * @return JSONResponse * * @spec openspec/changes/openconnector-secret-store-api/specs/secret-store-api/spec.md + * @spec openspec/specs/secret-store-api/spec.md#requirement-an-application-reads-its-own-certificate */ #[PublicPage] #[NoCSRFRequired] @@ -176,10 +180,25 @@ public function document(): JSONResponse { 'create' => $this->urlGenerator->linkToRoute('keepiq.applicationSecrets.index'), 'update' => $this->urlGenerator->linkToRoute('keepiq.applicationSecrets.index') . '/{id}', ], + // Federated recipients (sharing-federated-recipients D1): a + // partner's administrator reads this root fingerprint and + // compares it out of band before pinning this instance. + 'federation' => [ + 'enabled' => ($this->federationRoot?->isSupported() === true), + 'rootFingerprint' => $this->federationRoot?->localRootFingerprint(), + ], + // Additive capabilities of this apiVersion: PUT honours + // If-Match (412 on a stale ETag), and the envelope carries + // secret.expiresAt. + 'conditionalWrite' => true, + 'expiresAt' => true, // What this instance actually emits today. The successor is // announced separately rather than listed here, because // listing a format nothing writes would be a lie a consumer // could reasonably act on. + // The calling application's own certificate and fingerprint + // (app-own-certificate); Bearer-authenticated. + 'certificate' => $this->urlGenerator->linkToRoute('keepiq.applicationCertificate.show'), 'envelopeFormats' => [MachineSecretEnvelopeService::FORMAT], 'upcomingEnvelopeFormats' => [ [ diff --git a/lib/Controller/EmergencyAccessController.php b/lib/Controller/EmergencyAccessController.php index e00edab45..47c36299c 100644 --- a/lib/Controller/EmergencyAccessController.php +++ b/lib/Controller/EmergencyAccessController.php @@ -160,7 +160,7 @@ public function granteeCertificate(string $granteeUserId): JSONResponse { * * @return JSONResponse * - * @spec openspec/changes/harden-vault-key-material-guards/specs/emergency-access/spec.md#requirement-designate-emergency-contact + * @spec openspec/specs/emergency-access/spec.md#requirement-designate-emergency-contact */ #[NoAdminRequired] #[VaultKeyProofRequired(binds: ['granteeUserId', 'waitPeriodDays', 'recoveryEnvelope'], purpose: VaultKeyProofService::PURPOSE_EMERGENCY_DESIGNATE)] diff --git a/lib/Controller/EncryptionSuiteController.php b/lib/Controller/EncryptionSuiteController.php index 1e7e7aff7..c0cc6b3ff 100644 --- a/lib/Controller/EncryptionSuiteController.php +++ b/lib/Controller/EncryptionSuiteController.php @@ -25,13 +25,16 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Exception\ConflictException; +use OCA\Keepiq\Exception\ReinstateRefusedException; use OCA\Keepiq\Exception\SuiteMigrationInProgressException; use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Db\SuiteMigration; +use OCA\Keepiq\Service\CompromiseContainmentService; 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 OCA\Keepiq\Settings\PeopleAdminSettings; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -58,6 +61,12 @@ * 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. + * @SuppressWarnings(PHPMD.TooManyPublicMethods) 11 against a threshold of 10. + * The eleventh is reenrol(): the same enrolment as create(), but it carries + * #[PasswordConfirmationRequired] for a user whose suites were all revoked + * (keepiq#860). Nextcloud reads that attribute per action, so the stricter + * path needs its own method; a separate controller would have to duplicate + * the private enrol() body it shares with create(). */ class EncryptionSuiteController extends OCSController { /** @@ -69,6 +78,8 @@ class EncryptionSuiteController extends OCSController { * @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\TwoFactorGate $twoFactor The two-factor vault policy (admin-vault-policies D3) + * @param CompromiseContainmentService $containment The compromise containment (force-revoke cascade) * @param \OCA\Keepiq\Service\PasskeyService|null $passkeyService The passkey service (passkey vault login; null when unwired) * * @return void @@ -80,6 +91,8 @@ public function __construct( private IUserSession $userSession, private VaultKeyProofService $proofService, private EmergencyEnvelopeInvalidationService $emergencyService, + private \OCA\Keepiq\Service\TwoFactorGate $twoFactor, + private CompromiseContainmentService $containment, private ?\OCA\Keepiq\Service\PasskeyService $passkeyService = null, ) { parent::__construct(appName: Application::APP_ID, request: $request); @@ -93,6 +106,7 @@ public function __construct( * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login */ #[NoAdminRequired] public function index(): JSONResponse { @@ -104,9 +118,14 @@ public function index(): JSONResponse { $userId = $user->getUID(); $suites = $this->suiteService->getSuitesByOwner(ownerType: 'user', ownerId: $userId); + // The two-factor policy withholds the wrapped private key, and only + // that: other screens still read status and certificates here + // (admin-vault-policies D3). + $blocked = $this->twoFactor->blocks(userId: $userId); + return new JSONResponse( data: array_map( - static fn ($suite) => $suite->jsonSerialize(), + static fn ($suite) => self::withholdKey(suite: $suite->jsonSerialize(), blocked: $blocked), $suites ) ); @@ -122,6 +141,7 @@ public function index(): JSONResponse { * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login */ #[NoAdminRequired] public function show(string $id): JSONResponse { @@ -132,7 +152,8 @@ public function show(string $id): JSONResponse { try { $suite = $this->suiteService->getSuite($id); $this->validateOwnership(suite: $suite); - return new JSONResponse(data: $suite->jsonSerialize()); + $blocked = $this->twoFactor->blocks(userId: (string)$this->userSession->getUser()?->getUID()); + return new JSONResponse(data: self::withholdKey(suite: $suite->jsonSerialize(), blocked: $blocked)); } catch (Exception $e) { return new JSONResponse( data: ['message' => $e->getMessage()], @@ -141,6 +162,28 @@ public function show(string $id): JSONResponse { } }//end show() + /** + * Drop the wrapped private key from a serialized suite when the + * two-factor policy blocks the owner, and say why. + * + * @param array $suite The serialized suite + * @param bool $blocked Whether the policy blocks the owner + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login + */ + private static function withholdKey(array $suite, bool $blocked): array { + if ($blocked === false) { + return $suite; + } + + unset($suite['privateKey']); + $suite['unlockBlocked'] = \OCA\Keepiq\Service\TwoFactorGate::CODE; + + return $suite; + }//end withholdKey() + /** * Whether both halves of the submitted key material are present. * @@ -170,6 +213,12 @@ private function hasKeyMaterial(?string $publicKey, ?string $encryptedPrivateKey /** * Create a new EncryptionSuite for the current user. * + * Refused for a user whose earlier suite was revoked or replaced and who has + * no active suite now: that is exactly the state a force-revoke leaves, and + * a stolen session could otherwise enrol a key pair it owns and become the + * user's identity for every new share (keepiq#860). Such a user enrols + * through reenrol(), which asks for their Nextcloud password first. + * * @param string $publicKey The PEM-encoded public key * @param string $encryptedPrivateKey The encrypted private key * @@ -178,6 +227,8 @@ private function hasKeyMaterial(?string $publicKey, ?string $encryptedPrivateKey * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login + * @spec openspec/specs/encryption-suites/spec.md#requirement-re-enrolment-after-a-revocation-requires-a-fresh-password-confirmation */ #[NoAdminRequired] public function create( @@ -189,6 +240,86 @@ public function create( return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } + if ($this->needsFreshConfirmation(userId: $user->getUID()) === true) { + return new JSONResponse( + data: [ + 'error' => 'reauthentication_required', + 'message' => 'Your previous vault key was revoked. Confirm your password to set up a new one.', + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return $this->enrol(userId: $user->getUID(), publicKey: $publicKey, encryptedPrivateKey: $encryptedPrivateKey); + }//end create() + + /** + * Create a new EncryptionSuite after a revocation, behind a fresh password confirmation. + * + * The same enrolment as create(), guarded by Nextcloud sudo so that a + * session alone cannot replace a revoked identity (keepiq#860). Single + * sign-on accounts that cannot confirm a password pass the guard; ending the + * user's sessions on a compromise force-revoke is what covers them. + * + * @param string $publicKey The PEM-encoded public key + * @param string $encryptedPrivateKey The encrypted private key + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-re-enrolment-after-a-revocation-requires-a-fresh-password-confirmation + */ + #[NoAdminRequired] + #[PasswordConfirmationRequired] + public function reenrol( + ?string $publicKey = null, + ?string $encryptedPrivateKey = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return $this->enrol(userId: $user->getUID(), publicKey: $publicKey, encryptedPrivateKey: $encryptedPrivateKey); + }//end reenrol() + + /** + * Whether the user has only retired suites: revoked or replaced, none active. + * + * @param string $userId The Nextcloud user + * + * @return bool + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-re-enrolment-after-a-revocation-requires-a-fresh-password-confirmation + */ + private function needsFreshConfirmation(string $userId): bool { + $retired = false; + foreach ($this->suiteService->getSuitesByOwner(ownerType: 'user', ownerId: $userId) as $suite) { + if ($suite->getStatus() === 'active') { + return false; + } + + if (in_array($suite->getStatus(), ['revoked', 'compromised'], true) === true) { + $retired = true; + } + } + + return $retired; + }//end needsFreshConfirmation() + + /** + * Enrol a new suite for a user: the body create() and reenrol() share. + * + * @param string $userId The Nextcloud user + * @param string|null $publicKey The PEM-encoded public key + * @param string|null $encryptedPrivateKey The encrypted private key + * + * @return JSONResponse + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-suite-creation-on-first-login + */ + private function enrol(string $userId, ?string $publicKey, ?string $encryptedPrivateKey): JSONResponse { // Validate required params HERE so a missing body returns 400, not a 500 // from the framework dispatcher failing to bind non-nullable arguments. if ($this->hasKeyMaterial(publicKey: $publicKey, encryptedPrivateKey: $encryptedPrivateKey) === false) { @@ -198,7 +329,17 @@ public function create( ); } - $userId = $user->getUID(); + // No first suite without a second factor when the policy applies + // (admin-vault-policies D3). + if ($this->twoFactor->blocks(userId: $userId) === true) { + return new JSONResponse( + data: [ + 'message' => 'Your organisation requires two-factor login before you can open your vault', + 'code' => \OCA\Keepiq\Service\TwoFactorGate::CODE, + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } // Reject suite creation while a key-compromise migration is in progress — // the old suite must finish re-encrypting data before a new one is registered. @@ -213,8 +354,8 @@ public function create( $suite = $this->suiteService->createSuite( ownerType: 'user', ownerId: $userId, - publicKeyPem: $publicKey, - encryptedPrivateKey: $encryptedPrivateKey + publicKeyPem: (string)$publicKey, + encryptedPrivateKey: (string)$encryptedPrivateKey ); return new JSONResponse(data: $suite->jsonSerialize(), statusCode: Http::STATUS_CREATED); } catch (ConflictException $e) { @@ -238,8 +379,14 @@ public function create( statusCode: Http::STATUS_SERVICE_UNAVAILABLE ); }//end try - }//end create() + }//end enrol() + #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['encryptedPrivateKey'], + subject: 'routeParam:id', + purpose: VaultKeyProofService::PURPOSE_UPDATE_PRIVATE_KEY + )] /** * Update the encrypted private key (routine password change). * @@ -251,18 +398,34 @@ public function create( * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/encryption-suites/spec.md#requirement-master-password-change-routine + * @spec openspec/specs/encryption-suites/spec.md#requirement-master-password-change-routine */ - #[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); $this->validateOwnership(suite: $suite); + // Only an active suite, and never one that is either end of an open + // migration (keepiq#869). During compromise recovery whoever holds the + // leaked old password can sign this proof with the old key; a re-wrap + // of the old envelope under a password only they know would strand + // every record the owner has not migrated yet. On a revoked suite a + // re-wrap followed by a reinstate hands the suite back under that + // password. The routine password change runs on the active suite + // outside any migration, so it is untouched by both checks. + if ($suite->getStatus() !== 'active') { + return new JSONResponse( + data: [ + 'error' => 'suite_not_active', + 'message' => 'Only an active suite can have its private key re-wrapped.', + ], + statusCode: Http::STATUS_CONFLICT + ); + } + + $this->migrationService->assertNoMigrationInProgress(suiteId: $id); + $suite->setPrivateKey($encryptedPrivateKey); // A routine master-password change re-wraps the private key under a // new AES key, so every stored passkey unlock envelope now wraps a @@ -273,12 +436,17 @@ public function updatePrivateKey(string $id, string $encryptedPrivateKey): JSONR $this->passkeyService?->markStaleOnPasswordChange($suite->getOwnerId()); 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 (Exception $e) { return new JSONResponse( data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN ); - } + }//end try }//end updatePrivateKey() /** @@ -315,7 +483,7 @@ public function updatePrivateKey(string $id, string $encryptedPrivateKey): JSONR * 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/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] @@ -389,21 +557,33 @@ public function revoke(string $id, string $reason, bool $acceptEmergencyLoss = f /** * Reinstate a revoked EncryptionSuite (admin only). * + * Reinstating re-opens every secret under the key, so it carries the same + * Nextcloud sudo as force-revoke: sudo used to guard only the safe direction + * (keepiq#865). A suite revoked as compromised, or whose owner already has + * another active suite, is refused with 409 (see reinstateSuite()). + * * @param string $id The suite ID * - * @AuthorizedAdminSetting(AdminSettings::class) + * @AuthorizedAdminSetting(PeopleAdminSettings::class) * * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated */ - #[AuthorizedAdminSetting(AdminSettings::class)] + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + #[PasswordConfirmationRequired] public function reinstate(string $id): JSONResponse { $userId = $this->userSession->getUser()->getUID(); try { $suite = $this->suiteService->reinstateSuite(id: $id, reinstatedBy: $userId); return new JSONResponse(data: $suite->jsonSerialize()); + } catch (ReinstateRefusedException $e) { + return new JSONResponse( + data: ['error' => $e->getError(), 'message' => $e->getMessage()], + statusCode: Http::STATUS_CONFLICT + ); } catch (InvalidArgumentException $e) { return new JSONResponse( data: ['message' => $e->getMessage()], @@ -432,11 +612,19 @@ public function reinstate(string $id): JSONResponse { * the owner path's acceptEmergencyLoss). Only the count crosses the wire — the * contacts' identities stay grantor-private. * + * The administrator also types the suite id, echoed as `confirmSuiteId`, and + * the request is refused with 400 before anything else when it is missing or + * differs (keepiq#871). Unlike sudo mode, this holds on every user backend: + * Nextcloud skips #[PasswordConfirmationRequired] for SSO logins and accepts + * a confirmation from the last 30 minutes. + * * @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) + * @param string $confirmSuiteId The suite id the administrator typed to confirm; + * must equal $id (keepiq#871) * - * @AuthorizedAdminSetting(AdminSettings::class) + * @AuthorizedAdminSetting(PeopleAdminSettings::class) * * @return JSONResponse * @@ -445,11 +633,16 @@ public function reinstate(string $id): JSONResponse { * 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 + * @spec openspec/specs/encryption-suites/spec.md#requirement-administrator-force-revocation */ - #[AuthorizedAdminSetting(AdminSettings::class)] + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] #[PasswordConfirmationRequired] - public function forceRevoke(string $id, string $reason, bool $markCompromised = false): JSONResponse { + public function forceRevoke( + string $id, + string $reason, + bool $markCompromised = false, + string $confirmSuiteId = '', + ): JSONResponse { $admin = $this->userSession->getUser(); if ($admin === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); @@ -457,7 +650,21 @@ public function forceRevoke(string $id, string $reason, bool $markCompromised = $adminUid = $admin->getUID(); + // The typed suite id is the backend-independent confirmation: sudo mode + // is skipped on SSO backends (keepiq#871). Checked before anything else. + if ($confirmSuiteId === '' || hash_equals(known_string: $id, user_string: $confirmSuiteId) === false) { + $this->suiteService->recordRevokeRefused(suiteId: $id, actorId: $adminUid, reasonCode: 'confirmation_mismatch', markCompromised: $markCompromised); + return new JSONResponse( + data: [ + 'error' => 'confirmation_mismatch', + 'message' => 'Type the suite id to confirm the force-revoke', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + if (trim($reason) === '') { + $this->suiteService->recordRevokeRefused(suiteId: $id, actorId: $adminUid, reasonCode: 'empty_reason', markCompromised: $markCompromised); return new JSONResponse( data: ['message' => 'A non-empty reason is required'], statusCode: Http::STATUS_BAD_REQUEST @@ -465,14 +672,18 @@ public function forceRevoke(string $id, string $reason, bool $markCompromised = } try { + if ($markCompromised === true) { + return new JSONResponse( + data: $this->forceRevokeAsCompromise(suiteId: $id, reason: $reason, adminUid: $adminUid) + ); + } + // 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); - } + // A COMPROMISE force-revoke is the exception (above): there the + // migration is ended instead, or whoever is being contained could + // block the containment for good by leaving a migration open. + $this->migrationService->assertNoMigrationInProgress(suiteId: $id); // Read BEFORE revokeSuite(): the EncryptionSuiteRevokedEvent cascade // clears the grantor's emergency envelopes, so the usable count is @@ -483,31 +694,29 @@ public function forceRevoke(string $id, string $reason, bool $markCompromised = id: $id, reason: $reason, revokedBy: $adminUid, - markCompromised: $markCompromised, + markCompromised: false, emergencyContactsDestroyed: $emergencyCount, ); + $this->containment->notifyEmergencyAccessCleared(suite: $suite, count: $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); - + $data['warning'] = 'The revoked user may still know these secrets; consider rotating them.'; return new JSONResponse(data: $data); } catch (SuiteMigrationInProgressException $e) { + $this->suiteService->recordRevokeRefused(suiteId: $id, actorId: $adminUid, reasonCode: 'migration_in_progress', markCompromised: $markCompromised); return new JSONResponse( data: ['error' => 'migration_in_progress', 'message' => $e->getMessage()], statusCode: Http::STATUS_CONFLICT ); } catch (RuntimeException $e) { + $this->suiteService->recordRevokeRefused(suiteId: $id, actorId: $adminUid, reasonCode: 'forbidden', markCompromised: $markCompromised); return new JSONResponse( data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN ); } catch (InvalidArgumentException $e) { + $this->suiteService->recordRevokeRefused(suiteId: $id, actorId: $adminUid, reasonCode: 'invalid_argument', markCompromised: $markCompromised); return new JSONResponse( data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST @@ -516,47 +725,120 @@ public function forceRevoke(string $id, string $reason, bool $markCompromised = }//end forceRevoke() /** - * Revoke the other end of the suite's in-progress migration, then end it. + * Force-revoke a suite as compromised, and contain what its key reached. * - * 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. + * The blast radius is collected from BOTH ends of an open migration before + * either is revoked, because each revoke's cascade deletes the ShareTargets + * the lookup reads (keepiq#864). Containment then stamps and warns, revokes + * the user's link shares and passkeys (LinkShareService::deleteByUserId via + * MigrationService, keepiq#858) and ends their sessions (keepiq#860). Its + * failures are counted and returned as `cascadeIncomplete`, so the + * administrator is not told containment ran when part of it did not + * (keepiq#863). * - * @param string $suiteId The suite just force-revoked - * @param string $reason The admin's reason, reused for the other end + * @param string $suiteId The suite to revoke + * @param string $reason The administrator's reason * @param string $adminUid The acting administrator * - * @return array `terminatedMigration` and `alsoRevokedSuite`, or empty when no migration was open + * @return array The response body * - * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account */ - private function endMigrationForCompromise(string $suiteId, string $reason, string $adminUid): array { + private function forceRevokeAsCompromise(string $suiteId, string $reason, string $adminUid): array { $migration = $this->migrationService->findInProgressForSuite(suiteId: $suiteId); - if ($migration === null) { - return []; + $otherId = null; + if ($migration !== null) { + $otherId = $migration->getOldSuiteId(); + if ($otherId === $suiteId) { + $otherId = $migration->getNewSuiteId(); + } } - $otherId = $migration->getOldSuiteId(); - if ($otherId === $suiteId) { - $otherId = $migration->getNewSuiteId(); + $radius = $this->containment->collect(suiteIds: array_values(array_filter([$suiteId, $otherId]))); + + // Read BEFORE revokeSuite(): the revoke cascade clears the envelopes. + $emergencyCount = $this->emergencyService->countUsableForGrantorSuite($suiteId); + $suite = $this->suiteService->revokeSuite( + id: $suiteId, + reason: $reason, + revokedBy: $adminUid, + markCompromised: true, + emergencyContactsDestroyed: $emergencyCount, + ); + + $data = $suite->jsonSerialize(); + $data['emergencyContactsDestroyed'] = $emergencyCount; + + $cleared = $emergencyCount; + if ($migration !== null && $otherId !== null) { + $ended = $this->endMigrationForCompromise( + migration: $migration, + otherId: $otherId, + reason: $reason, + adminUid: $adminUid + ); + $cleared += $ended['alsoRevokedEmergencyContactsDestroyed']; + $data += $ended; } + $this->containment->notifyEmergencyAccessCleared(suite: $suite, count: $cleared); + + $tally = $this->containment->contain(radius: $radius, suite: $suite, revokedBy: $adminUid); + $data['cascade'] = $tally; + $data['cascadeIncomplete'] = $tally['failed'] > 0; + + return $data; + }//end forceRevokeAsCompromise() + + /** + * 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 SuiteMigration $migration The in-progress migration + * @param string $otherId Its end the administrator did not name + * @param string $reason The admin's reason, reused for the other end + * @param string $adminUid The acting administrator + * + * @return array{terminatedMigration: string, alsoRevokedSuite: string, alsoRevokedEmergencyContactsDestroyed: int} + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + */ + private function endMigrationForCompromise( + SuiteMigration $migration, + string $otherId, + string $reason, + string $adminUid, + ): array { + $otherCount = $this->emergencyService->countUsableForGrantorSuite($otherId); $this->suiteService->revokeSuite( id: $otherId, reason: $reason, revokedBy: $adminUid, markCompromised: true, - emergencyContactsDestroyed: $this->emergencyService->countUsableForGrantorSuite($otherId), + emergencyContactsDestroyed: $otherCount, ); - $this->migrationService->terminateForCompromise(migration: $migration); + $this->migrationService->terminateForCompromise(migration: $migration, actorId: $adminUid); - return ['terminatedMigration' => $migration->getId(), 'alsoRevokedSuite' => $otherId]; + return [ + 'terminatedMigration' => (string)$migration->getId(), + 'alsoRevokedSuite' => $otherId, + 'alsoRevokedEmergencyContactsDestroyed' => $otherCount, + ]; }//end endMigrationForCompromise() + #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['publicKey', 'encryptedPrivateKey'], + subject: 'active', + purpose: VaultKeyProofService::PURPOSE_COMPROMISE_RECOVERY + )] /** * Initiate compromise recovery: create new suite and migration record. * @@ -569,13 +851,8 @@ private function endMigrationForCompromise(string $suiteId, string $reason, stri * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 * @spec openspec/changes/implement-link-sharing/tasks.md#5.2 + * @spec openspec/specs/encryption-suites/spec.md#requirement-master-password-change-compromise-recovery */ - #[NoAdminRequired] - #[VaultKeyProofRequired( - binds: ['publicKey', 'encryptedPrivateKey'], - subject: 'active', - purpose: VaultKeyProofService::PURPOSE_COMPROMISE_RECOVERY - )] public function compromiseRecovery( string $publicKey, string $encryptedPrivateKey, @@ -691,7 +968,7 @@ public function compromiseRecovery( * * @return JSONResponse * - * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring */ #[NoAdminRequired] public function proofChallenge(string $id, ?string $purpose = null): JSONResponse { diff --git a/lib/Controller/EphemeralSendAccessController.php b/lib/Controller/EphemeralSendAccessController.php index 21d690daf..80355132f 100644 --- a/lib/Controller/EphemeralSendAccessController.php +++ b/lib/Controller/EphemeralSendAccessController.php @@ -83,6 +83,8 @@ public function peek(string $token): JSONResponse { * @param string $token The URL token * * @return JSONResponse + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-anonymous-recipient-access-with-no-account */ #[PublicPage] #[NoCSRFRequired] @@ -102,6 +104,8 @@ public function access(string $token): JSONResponse { * @param string $token The URL token * * @return JSONResponse + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-burn-after-read-and-optional-expiry */ #[PublicPage] #[NoCSRFRequired] @@ -120,6 +124,8 @@ public function confirm(string $token): JSONResponse { * @param string $token The URL token * * @return JSONResponse + * + * @spec openspec/specs/ephemeral-send/spec.md#scenario-brute-force-attempts-burn-the-send */ #[PublicPage] #[NoCSRFRequired] diff --git a/lib/Controller/EphemeralSendController.php b/lib/Controller/EphemeralSendController.php index 9550e183a..57d27c4dd 100644 --- a/lib/Controller/EphemeralSendController.php +++ b/lib/Controller/EphemeralSendController.php @@ -90,6 +90,8 @@ public function create(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-manage-and-revoke-sends */ #[NoAdminRequired] public function index(): JSONResponse { @@ -114,6 +116,8 @@ public function index(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-manage-and-revoke-sends */ #[NoAdminRequired] public function destroy(string $id): JSONResponse { diff --git a/lib/Controller/ExportController.php b/lib/Controller/ExportController.php index 5b21f68b4..6de654d32 100644 --- a/lib/Controller/ExportController.php +++ b/lib/Controller/ExportController.php @@ -32,6 +32,7 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Event\SecretExportedEvent; +use OCA\Keepiq\Service\VaultPolicyService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -64,6 +65,7 @@ class ExportController extends Controller { * @param IRequest $request The request * @param IUserSession $userSession The user session * @param IEventDispatcher $dispatcher The event dispatcher + * @param VaultPolicyService $vaultPolicies The vault policies (export ban) * * @return void */ @@ -71,6 +73,7 @@ public function __construct( IRequest $request, private IUserSession $userSession, private IEventDispatcher $dispatcher, + private VaultPolicyService $vaultPolicies, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -86,7 +89,8 @@ public function __construct( * * @return JSONResponse * - * @spec openspec/changes/secret-export-gdpr/specs/secret-export/spec.md + * @spec openspec/specs/secret-export/spec.md + * @spec openspec/specs/vault-policies/spec.md#requirement-personal-vault-export-can-be-blocked * * @no-admin-idor-exempt no object is addressed. The three parameters are an * export mode, a scope and a count, each validated against a fixed @@ -101,6 +105,20 @@ public function events(): JSONResponse { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } + // The export ban (admin-vault-policies D2). Every export mode reports + // here BEFORE the browser offers the file, so refusing the report + // aborts the download. The GDPR access package never reports a mode + // and stays available. + if ($this->vaultPolicies->appliesTo(policy: VaultPolicyService::EXPORT_DISABLED, userId: $user->getUID()) === true) { + return new JSONResponse( + data: [ + 'message' => 'Your organisation does not allow exporting your personal vault', + 'code' => 'export_disabled_by_policy', + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } + $mode = (string)$this->request->getParam('mode', ''); $scope = (string)$this->request->getParam('scope', ''); $count = (int)$this->request->getParam('secretCount', 0); diff --git a/lib/Controller/ExtensionController.php b/lib/Controller/ExtensionController.php index 5d2f1090c..642e0cdaa 100644 --- a/lib/Controller/ExtensionController.php +++ b/lib/Controller/ExtensionController.php @@ -29,6 +29,8 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Service\AdminSettingsService; +use OCP\App\IAppManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -75,6 +77,8 @@ class ExtensionController extends Controller { * @param IRequest $request The request * @param SecretMapper $secretMapper The secret mapper * @param IUserSession $userSession The user session + * @param AdminSettingsService $adminSettings The admin settings (extension idle maximum) + * @param IAppManager $appManager The app manager (server version for the pairing handshake) * * @return void */ @@ -82,6 +86,8 @@ public function __construct( IRequest $request, private SecretMapper $secretMapper, private IUserSession $userSession, + private AdminSettingsService $adminSettings, + private IAppManager $appManager, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -106,6 +112,9 @@ private function uid(): ?string { * IS the Nextcloud app-password, revocable from NC security settings. * * @return JSONResponse + * + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-pairing-against-the-nextcloud-session + * @spec openspec/specs/extension-store-release/spec.md#requirement-the-extension-checks-the-server-version-on-pairing */ #[NoAdminRequired] #[NoCSRFRequired] @@ -120,17 +129,22 @@ public function pair(): JSONResponse { 'ok' => true, 'user' => $uid, 'apiVersion' => 1, + // The extension compares this with its minimum and asks for a + // server update instead of failing on a missing route. + 'serverVersion' => $this->appManager->getAppVersion(Application::APP_ID), 'capabilities' => ['match', 'autofill', 'passkey-provider', 'totp'], ] ); }//end pair() /** - * Unpair: pairing is the NC app-password, so unpairing is revoking it in - * Nextcloud security settings. This endpoint is a no-op acknowledgement the - * extension calls to clear its own local state. + * Unpair: pairing is the NC app-password. The extension deletes it itself + * through Nextcloud's DELETE /ocs/v2.php/core/apppassword right after this + * call, which needs no Keepiq code. This endpoint is the acknowledgement. * * @return JSONResponse + * + * @spec openspec/specs/browser-extension-autofill/spec.md#scenario-revocation-is-native */ #[NoAdminRequired] #[NoCSRFRequired] @@ -142,6 +156,25 @@ public function unpair(): JSONResponse { return new JSONResponse(data: ['ok' => true, 'note' => 'Revoke the app-password in Nextcloud security settings to fully unpair.']); }//end unpair() + /** + * The organisation's extension policy: the longest idle lock delay a user + * may pick. The extension reads it on every unlock and uses the lower of + * the user's choice and this maximum. + * + * @return JSONResponse + * + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-user-chosen-idle-lock-period-with-an-administrator-maximum + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function policy(): JSONResponse { + if ($this->uid() === null) { + return new JSONResponse(data: ['error' => 'unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: ['maxIdleMinutes' => $this->adminSettings->extensionMaxIdleMinutes()]); + }//end policy() + /** * Coarse registrable domain (eTLD+1 approximation) of a host, used as the * superset match term. The extension refines precisely client-side. @@ -174,6 +207,8 @@ private function registrableDomain(string $host): string { * @param string $host The active tab host or origin * * @return JSONResponse + * + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-url-matched-listing-decrypt-on-demand */ #[NoAdminRequired] #[NoCSRFRequired] diff --git a/lib/Controller/FederatedInboundController.php b/lib/Controller/FederatedInboundController.php new file mode 100644 index 000000000..7b305d0af --- /dev/null +++ b/lib/Controller/FederatedInboundController.php @@ -0,0 +1,144 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\FederatedInboundService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; +use RuntimeException; + +/** + * The recipient's inbound federated shares. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ +class FederatedInboundController extends Controller { + /** + * Constructor for FederatedInboundController. + * + * @param IRequest $request The request object + * @param FederatedInboundService $inbound The receiving side + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + IRequest $request, + private FederatedInboundService $inbound, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The user's inbound shares, newest first. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + #[NoAdminRequired] + public function index(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $rows = $this->inbound->listFor(userId: $user->getUID()); + + return new JSONResponse(data: array_map(static fn ($row) => $row->jsonSerialize(), $rows)); + }//end index() + + /** + * Accept a pending share: the server pulls the ciphertext and stores a + * read-only copy in the user's vault. + * + * @param string $id The inbound share + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + #[NoAdminRequired] + public function accept(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $row = $this->inbound->accept(id: $id, userId: $user->getUID()); + } catch (NotFoundException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (RuntimeException $exception) { + $status = Http::STATUS_BAD_GATEWAY; + if ($exception->getMessage() === 'no_suite') { + $status = Http::STATUS_CONFLICT; + } + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: $status); + } + + return new JSONResponse(data: $row->jsonSerialize()); + }//end accept() + + /** + * Decline a pending share. + * + * @param string $id The inbound share + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + #[NoAdminRequired] + public function decline(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $row = $this->inbound->decline(id: $id, userId: $user->getUID()); + } catch (NotFoundException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: $row->jsonSerialize()); + }//end decline() +}//end class diff --git a/lib/Controller/FederatedShareController.php b/lib/Controller/FederatedShareController.php new file mode 100644 index 000000000..bc2202455 --- /dev/null +++ b/lib/Controller/FederatedShareController.php @@ -0,0 +1,267 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\FederatedShareService; +use OCA\Keepiq\Service\VaultKeyProofService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; +use RuntimeException; + +/** + * Owner-side federated share routes. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedShareController extends Controller { + /** + * Constructor for FederatedShareController. + * + * @param IRequest $request The request object + * @param FederatedShareService $shares The outbound federated shares + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + IRequest $request, + private FederatedShareService $shares, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Share one of the user's secrets with a user of an outbound partner. + * + * Every federated recipient is a new party, so the vault key proof of + * user-sharing ("Sharing with a new party requires a verified key proof") + * is always asked. + * + * @param string $secretId The owner's secret + * @param string $recipientCloudId The recipient's cloud id + * @param string $certFingerprint SHA-256 of the recipient certificate the browser verified + * @param string $key The value, encrypted for the recipient + * @param string|null $login The login, encrypted for the recipient + * @param string|null $additionalFields The additional fields, encrypted for the recipient + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['secretId', 'recipientCloudId'], purpose: VaultKeyProofService::PURPOSE_SHARE_NEW_RECIPIENT)] + public function create( + string $secretId, + string $recipientCloudId = '', + string $certFingerprint = '', + string $key = '', + ?string $login = null, + ?string $additionalFields = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $share = $this->shares->create( + secretId: $secretId, + userId: $user->getUID(), + recipientCloudId: $recipientCloudId, + certFingerprint: $certFingerprint, + ciphertext: ['key' => $key, 'login' => $login, 'additionalFields' => $additionalFields], + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } catch (RuntimeException $exception) { + return $this->refusal(exception: $exception); + } + + return new JSONResponse(data: $share->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end create() + + /** + * The federated shares of one of the user's own secrets. + * + * @param string $secretId The owner's secret + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + #[NoAdminRequired] + public function index(string $secretId): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $rows = $this->shares->listForSecret(secretId: $secretId, userId: $user->getUID()); + } catch (NotFoundException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: array_map(static fn ($row) => $row->jsonSerialize(), $rows)); + }//end index() + + /** + * Replace a share's ciphertext after the owner changed the secret; the + * recipient's instance is told to pull again (task 4.1). + * + * @param string $id The federated share + * @param string $certFingerprint SHA-256 of the certificate the browser verified now + * @param string $key The value, encrypted for the recipient + * @param string|null $login The login, encrypted for the recipient + * @param string|null $additionalFields The additional fields, encrypted for the recipient + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-password-change-reaches-bob + */ + #[NoAdminRequired] + public function update( + string $id, + string $certFingerprint = '', + string $key = '', + ?string $login = null, + ?string $additionalFields = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $row = $this->shares->update( + shareId: $id, + userId: $user->getUID(), + certFingerprint: $certFingerprint, + ciphertext: ['key' => $key, 'login' => $login, 'additionalFields' => $additionalFields], + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } catch (RuntimeException $exception) { + return $this->refusal(exception: $exception); + } + + return new JSONResponse(data: $row->jsonSerialize()); + }//end update() + + /** + * Revoke a share; the recipient's instance deletes its copy (task 4.2). + * + * @param string $id The federated share + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-revocation-removes-bobs-copy + */ + #[NoAdminRequired] + public function destroy(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->shares->revoke(shareId: $id, userId: $user->getUID()); + } catch (RuntimeException $exception) { + return $this->refusal(exception: $exception); + } + + return new JSONResponse(data: ['revoked' => $id]); + }//end destroy() + + /** + * Suspend a share whose recipient certificate no longer verifies in the + * owner's browser (task 4.2). + * + * @param string $id The federated share + * @param string $reason Why, as the verifier reported it + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + #[NoAdminRequired] + public function suspend(string $id, string $reason = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $row = $this->shares->suspend(shareId: $id, userId: $user->getUID(), reason: $reason); + } catch (RuntimeException $exception) { + return $this->refusal(exception: $exception); + } + + return new JSONResponse(data: $row->jsonSerialize()); + }//end suspend() + + /** + * Map a service refusal to its status: not found, forbidden (a read-only + * copy), federation unavailable below Nextcloud 33, or a partner that + * did not take the share. + * + * @param RuntimeException $exception The refusal + * + * @return JSONResponse + */ + private function refusal(RuntimeException $exception): JSONResponse { + $status = match (true) { + $exception instanceof NotFoundException => Http::STATUS_NOT_FOUND, + $exception instanceof ForbiddenException => Http::STATUS_FORBIDDEN, + $exception->getMessage() === 'federation_unavailable' => Http::STATUS_CONFLICT, + default => Http::STATUS_BAD_GATEWAY, + }; + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: $status); + }//end refusal() +}//end class diff --git a/lib/Controller/FederationController.php b/lib/Controller/FederationController.php new file mode 100644 index 000000000..5a7bbb2f2 --- /dev/null +++ b/lib/Controller/FederationController.php @@ -0,0 +1,129 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\FederatedCertificateService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; +use RuntimeException; + +/** + * Federated recipient certificate lookup for vault owners. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +class FederationController extends Controller { + /** + * Constructor for FederationController. + * + * @param IRequest $request The request object + * @param FederatedCertificateService $certificates The federated certificate lookup + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + IRequest $request, + private FederatedCertificateService $certificates, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Look up a federated recipient's certificate at their partner instance. + * + * @param string $cloudId The recipient's cloud id + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Reads no object of this instance: the cloud id names a user on + * another instance, the outbound partner allowlist in FederatedCertificateService::lookup() + * decides whether any call is made, and the partner answers only through its own inbound + * and opt-in checks. + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 60)] + public function recipientCertificate(string $cloudId = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $this->certificates->lookup(cloudId: $cloudId, userId: $user->getUID())); + } catch (InvalidArgumentException $exception) { + $status = Http::STATUS_NOT_FOUND; + if ($exception->getMessage() === 'not_a_partner') { + $status = Http::STATUS_FORBIDDEN; + } + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: $status); + } catch (RuntimeException $exception) { + $status = Http::STATUS_BAD_GATEWAY; + if ($exception->getMessage() === 'federation_unavailable') { + $status = Http::STATUS_CONFLICT; + } + + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: $status); + }//end try + }//end recipientCertificate() + + /** + * Whether the share dialog may offer a recipient at another + * organisation. + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Reads no object: it answers whether any outbound partner exists, + * the same for every signed-in user. + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-no-partner-no-federation + */ + #[NoAdminRequired] + public function status(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: ['outbound' => $this->certificates->outboundAvailable()]); + }//end status() +}//end class diff --git a/lib/Controller/FederationPartnerController.php b/lib/Controller/FederationPartnerController.php new file mode 100644 index 000000000..0454a1ebf --- /dev/null +++ b/lib/Controller/FederationPartnerController.php @@ -0,0 +1,219 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\FederatedShareService; +use OCA\Keepiq\Service\FederationPartnerService; +use OCA\Keepiq\Service\FederationRootService; +use OCA\Keepiq\Settings\AdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Partner allowlist administration. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ +class FederationPartnerController extends Controller { + /** + * Constructor for FederationPartnerController. + * + * @param IRequest $request The request object + * @param FederationPartnerService $partners The partner allowlist + * @param FederationRootService $root The local root and version gate + * @param IUserSession $userSession The user session + * @param FederatedShareService $federatedShares Suspends shares to a removed partner + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + IRequest $request, + private FederationPartnerService $partners, + private FederationRootService $root, + private IUserSession $userSession, + private FederatedShareService $federatedShares, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The partners, this instance's own root fingerprint for the partner's + * administrator to compare, and whether this Nextcloud can federate. + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function index(): JSONResponse { + return new JSONResponse( + data: [ + 'supported' => $this->root->isSupported(), + 'localRootFingerprint' => $this->root->localRootFingerprint(), + 'partners' => array_map( + static fn ($partner): array => $partner->jsonSerialize(), + $this->partners->all() + ), + ] + ); + }//end index() + + /** + * Read a partner's root fingerprint from its discovery document, for the + * administrator to compare before adding it. + * + * @param string $url The partner address + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + public function preview(string $url = ''): JSONResponse { + if ($this->root->isSupported() === false) { + return $this->unsupported(); + } + + try { + return new JSONResponse(data: $this->partners->preview(url: $url)); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end preview() + + /** + * Add a partner with the fingerprint the administrator confirmed. + * + * @param string $url The partner address + * @param string $rootFingerprint The fingerprint the administrator compared + * @param bool|null $allowOutbound Whether users here may share to the partner (absent is no) + * @param bool|null $allowInbound Whether the partner may look up and deliver here (absent is no) + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + #[PasswordConfirmationRequired] + public function create( + string $url = '', + string $rootFingerprint = '', + ?bool $allowOutbound = null, + ?bool $allowInbound = null, + ): JSONResponse { + if ($this->root->isSupported() === false) { + return $this->unsupported(); + } + + try { + $partner = $this->partners->add( + url: $url, + confirmedFingerprint: $rootFingerprint, + allowOutbound: $allowOutbound === true, + allowInbound: $allowInbound === true, + adminId: (string)$this->userSession->getUser()?->getUID(), + ); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: $partner->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end create() + + /** + * Change a partner's directions. + * + * @param string $id The partner UUID + * @param bool|null $allowOutbound Whether users here may share to the partner (absent is no) + * @param bool|null $allowInbound Whether the partner may look up and deliver here (absent is no) + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + #[PasswordConfirmationRequired] + public function update(string $id, ?bool $allowOutbound = null, ?bool $allowInbound = null): JSONResponse { + try { + $partner = $this->partners->update( + id: $id, + allowOutbound: $allowOutbound === true, + allowInbound: $allowInbound === true + ); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: $partner->jsonSerialize()); + }//end update() + + /** + * Remove a partner. + * + * @param string $id The partner UUID + * + * @return JSONResponse + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + #[AuthorizedAdminSetting(AdminSettings::class)] + #[PasswordConfirmationRequired] + public function destroy(string $id): JSONResponse { + try { + $this->partners->remove(id: $id); + } catch (DoesNotExistException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + // Shares to that organisation stop being served, and their owners + // see them suspended (sharing-federated-recipients task 4.2). + $this->federatedShares->suspendForPartner(partnerId: $id); + + return new JSONResponse(data: ['removed' => $id]); + }//end destroy() + + /** + * The answer below Nextcloud 33. + * + * @return JSONResponse + */ + private function unsupported(): JSONResponse { + return new JSONResponse( + data: ['message' => 'Federation needs Nextcloud 33 or later'], + statusCode: Http::STATUS_CONFLICT + ); + }//end unsupported() +}//end class diff --git a/lib/Controller/GdprController.php b/lib/Controller/GdprController.php index 6f3e16939..12a656b9a 100644 --- a/lib/Controller/GdprController.php +++ b/lib/Controller/GdprController.php @@ -89,7 +89,7 @@ public function __construct( * * @return JSONResponse * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ #[NoAdminRequired] public function metadata(): JSONResponse { @@ -128,7 +128,7 @@ public function metadata(): JSONResponse { * * @return JSONResponse * - * @spec openspec/changes/harden-vault-key-material-guards/specs/gdpr-compliance/spec.md#requirement-account-data-deletion + * @spec openspec/specs/gdpr-compliance/spec.md#requirement-account-data-deletion */ #[NoAdminRequired] #[VaultKeyProofRequired(binds: ['confirmation'], subject: 'active', purpose: VaultKeyProofService::PURPOSE_DELETE_ACCOUNT_DATA)] diff --git a/lib/Controller/GroupShareController.php b/lib/Controller/GroupShareController.php index 081d78557..ca41973e6 100644 --- a/lib/Controller/GroupShareController.php +++ b/lib/Controller/GroupShareController.php @@ -26,9 +26,14 @@ namespace OCA\Keepiq\Controller; +use DateTime; +use DateTimeZone; use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; use OCA\Keepiq\Service\GroupShareService; +use OCA\Keepiq\Service\ShareRestriction; +use OCA\Keepiq\Service\ShareRestrictionRules; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; @@ -92,15 +97,21 @@ public function index(string $secretId): JSONResponse { * * @param string $secretId The source secret ID * @param string $groupId The Nextcloud group ID + * @param bool $useOnly Whether the members may only use the value + * @param string|null $expiresAt When the members' access ends (ISO 8601) * * @NoAdminRequired * * @return JSONResponse * * @spec openspec/changes/implement-user-sharing/tasks.md#9.2 + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $useOnly is a request body + * field the server stores, not a mode switch. */ #[NoAdminRequired] - public function create(string $secretId, string $groupId): JSONResponse { + public function create(string $secretId, string $groupId, bool $useOnly = false, ?string $expiresAt = null): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); @@ -110,8 +121,16 @@ public function create(string $secretId, string $groupId): JSONResponse { $result = $this->groupShareService->createGroupShare( secretId: $secretId, groupId: $groupId, - userId: $user->getUID() + userId: $user->getUID(), + restriction: (new ShareRestrictionRules())->fromRequest( + useOnly: $useOnly, + expiresAt: $expiresAt, + now: new DateTime('now', new DateTimeZone('UTC')) + ) ); + } catch (ForbiddenException $exception) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $exception) { return new JSONResponse( data: ['message' => $exception->getMessage()], @@ -123,6 +142,7 @@ public function create(string $secretId, string $groupId): JSONResponse { data: [ 'groupShare' => $result['groupShare']->jsonSerialize(), 'members' => $result['members'], + 'skipped' => $result['skipped'], ], statusCode: Http::STATUS_CREATED ); diff --git a/lib/Controller/HoneyController.php b/lib/Controller/HoneyController.php index 74c6951e2..29dc61a79 100644 --- a/lib/Controller/HoneyController.php +++ b/lib/Controller/HoneyController.php @@ -29,13 +29,14 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\HoneyAlert; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\HoneyCredentialService; +use OCA\Keepiq\Settings\AuditAdminSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -49,7 +50,7 @@ class HoneyController extends OCSController { * @param IRequest $request The request object * @param HoneyCredentialService $service The honey service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin scope) + * @param AdminAreaAuthorizer $areas Whether the caller holds the Audit admin area * * @return void */ @@ -57,7 +58,7 @@ public function __construct( IRequest $request, private HoneyCredentialService $service, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -79,7 +80,7 @@ private function uid(): ?string { * @return bool */ private function isAdmin(string $uid): bool { - return $this->groupManager->isAdmin($uid); + return $this->areas->holds(userId: $uid, areaClass: AuditAdminSettings::class); }//end isAdmin() /** @@ -125,6 +126,8 @@ public function flag(string $id, string $note = ''): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ #[NoAdminRequired] public function unflag(string $id): JSONResponse { @@ -152,6 +155,8 @@ public function unflag(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ #[NoAdminRequired] public function status(string $id): JSONResponse { @@ -180,6 +185,8 @@ public function status(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ #[NoAdminRequired] public function alerts(): JSONResponse { @@ -204,6 +211,8 @@ public function alerts(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-alert-storms-are-rate-limited-and-per-accessor-snoozable */ #[NoAdminRequired] public function acknowledge(string $id): JSONResponse { @@ -233,6 +242,8 @@ public function acknowledge(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-alert-storms-are-rate-limited-and-per-accessor-snoozable */ #[NoAdminRequired] public function snooze(string $id, int $hours = 24): JSONResponse { diff --git a/lib/Controller/ImportController.php b/lib/Controller/ImportController.php index bb0614142..b53f6ac8e 100644 --- a/lib/Controller/ImportController.php +++ b/lib/Controller/ImportController.php @@ -92,7 +92,7 @@ public function __construct( * * @return JSONResponse * - * @spec openspec/changes/secret-import/specs/secret-import/spec.md#requirement-chunked-batch-commit + * @spec openspec/specs/secret-import/spec.md#requirement-chunked-batch-commit */ #[NoAdminRequired] public function batchCreate(?array $items = null): JSONResponse { diff --git a/lib/Controller/KeyGeneratorController.php b/lib/Controller/KeyGeneratorController.php index a6dc857b8..468597e9d 100644 --- a/lib/Controller/KeyGeneratorController.php +++ b/lib/Controller/KeyGeneratorController.php @@ -5,6 +5,11 @@ * * API controller exposing the stateless key generator. * + * Deprecated for the app's own use: the web app and the browser extension + * generate keys in the browser (`src/generator/generator.js`), so a + * generated value is never seen by the server. The endpoint stays for API + * clients that still call it, and will be removed in a later release. + * * @category Controller * @package OCA\Keepiq\Controller * @@ -74,8 +79,13 @@ public function __construct( * carry a default — which is the only thing this rule fires on. Splitting the * method would split the route and change the HTTP contract. * @SuppressWarnings(PHPMD.LongVariable) $includeSpecialCharacters is the wire - * field name posted by src/dialogs/KeyGeneratorModal.vue; because the router - * binds by name, shortening the parameter would break the frontend contract. + * field name of this endpoint's request body; because the router binds by + * name, shortening the parameter would break the API contract. + * + * @deprecated The app generates in the browser (src/generator/generator.js); + * kept for API clients only. + * + * @spec openspec/specs/key-generator/spec.md#requirement-configuration-fields */ #[NoAdminRequired] public function generate( diff --git a/lib/Controller/LeaseAdminController.php b/lib/Controller/LeaseAdminController.php index c604cea41..bd97b698d 100644 --- a/lib/Controller/LeaseAdminController.php +++ b/lib/Controller/LeaseAdminController.php @@ -30,13 +30,13 @@ use OCA\Keepiq\Db\ApplicationMapper; use OCA\Keepiq\Db\MachineLease; use OCA\Keepiq\Db\MachineLeaseMapper; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\LeaseService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -52,7 +52,7 @@ class LeaseAdminController extends OCSController { * @param MachineLeaseMapper $leaseMapper The lease mapper * @param ApplicationMapper $applicationMapper The application mapper (owner guard) * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin check) + * @param AdminAreaAuthorizer $areas Whether the caller holds the Applications admin area * * @return void */ @@ -62,7 +62,7 @@ public function __construct( private MachineLeaseMapper $leaseMapper, private ApplicationMapper $applicationMapper, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: KeepiqApp::APP_ID, request: $request); }//end __construct() @@ -130,6 +130,49 @@ public function revoke(string $leaseId): JSONResponse { return new JSONResponse(data: $lease->jsonSerialize()); }//end revoke() + /** + * Read an application's lease policy (admin or registrant only). + * + * Answers the effective policy, the stored override and the instance + * values, plus whether the caller may change it: only an admin may + * (setPolicy()), the registrant sees it read-only. Everyone else gets + * the same 404 as a nonexistent application. + * + * @param string $id The application id + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-admin-lease-ttl-policy + */ + #[NoAdminRequired] + public function getPolicy(string $id): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($this->mayManageApplication(applicationId: $id, userId: $userId) === false) { + return $this->notFound(); + } + + // An admin passes the guard without a lookup, so check existence here: + // a policy view for an application that does not exist is a 404 too. + try { + $this->applicationMapper->findById($id); + } catch (DoesNotExistException) { + return $this->notFound(); + } + + return new JSONResponse( + data: array_merge( + $this->leaseService->policyView(applicationId: $id), + ['canEdit' => $this->areas->holds(userId: $userId, areaClass: AdminAreaAuthorizer::APPLICATIONS)] + ) + ); + }//end getPolicy() + /** * Store a per-application lease-policy override (admin only). * @@ -141,6 +184,8 @@ public function revoke(string $leaseId): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-admin-lease-ttl-policy */ #[NoAdminRequired] public function setPolicy(string $id, ?int $defaultTtl = null, ?int $maxTtl = null, ?bool $renewable = null): JSONResponse { @@ -149,7 +194,7 @@ public function setPolicy(string $id, ?int $defaultTtl = null, ?int $maxTtl = nu return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } - if ($this->groupManager->isAdmin($userId) === false) { + if ($this->areas->holds(userId: $userId, areaClass: AdminAreaAuthorizer::APPLICATIONS) === false) { return $this->notFound(); } @@ -183,7 +228,7 @@ public function setPolicy(string $id, ?int $defaultTtl = null, ?int $maxTtl = nu * @return bool */ private function mayManageApplication(string $applicationId, string $userId): bool { - if ($this->groupManager->isAdmin($userId) === true) { + if ($this->areas->holds(userId: $userId, areaClass: AdminAreaAuthorizer::APPLICATIONS) === true) { return true; } diff --git a/lib/Controller/LinkShareController.php b/lib/Controller/LinkShareController.php index 1acc1e4d3..767f58390 100644 --- a/lib/Controller/LinkShareController.php +++ b/lib/Controller/LinkShareController.php @@ -26,6 +26,7 @@ use Exception; use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; use OCA\Keepiq\Service\EncryptionSuiteService; use OCA\Keepiq\Service\LinkShareService; use OCP\AppFramework\Db\DoesNotExistException; @@ -39,6 +40,11 @@ /** * Authenticated API controller for link share CRUD. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Each refusal of a link share + * (not found, invalid input, a read-only copy from another organisation) + * is its own exception class mapped to its own status; the thirteenth is + * the read-only refusal of sharing-federated-recipients task 3.4. */ class LinkShareController extends OCSController { /** @@ -159,6 +165,13 @@ public function create( expiresAt: $expiry, userId: $userId ); + } catch (DoesNotExistException) { + // Not the owner and not a delegate, or no such secret: the same + // answer either way (keepiq#214). + return new JSONResponse(data: ['message' => 'Secret not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException $e) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $e) { return new JSONResponse( data: ['message' => $e->getMessage()], diff --git a/lib/Controller/MachineLeaseController.php b/lib/Controller/MachineLeaseController.php index ebcb9539d..a8f06d67e 100644 --- a/lib/Controller/MachineLeaseController.php +++ b/lib/Controller/MachineLeaseController.php @@ -4,8 +4,9 @@ * Keepiq Machine Lease Controller * * Bearer-authenticated lease surface for a registered application - * (machine-secret-leases §4.1): list own leases, renew, and self-revoke - * under `/api/v1/app/leases/*`. JwtAuthMiddleware resolves the calling + * (machine-secret-leases §4.1): list own leases and self-revoke under + * `/api/v1/app/leases/*`. There is no renew route: fetching the secret again + * is the one renewal path (keepiq#753). JwtAuthMiddleware resolves the calling * Application before any handler runs; cross-application access returns * the SAME 404 as a nonexistent lease (no existence oracle). * @@ -25,7 +26,6 @@ namespace OCA\Keepiq\Controller; -use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application as KeepiqApp; use OCA\Keepiq\Db\MachineLease; use OCA\Keepiq\Service\LeaseService; @@ -80,38 +80,6 @@ public function index(): JSONResponse { ); }//end index() - /** - * Renew one of the calling application's leases. - * - * @param string $id The lease UUID - * - * @return JSONResponse - * - * @spec openspec/changes/machine-secret-leases/specs/machine-secret-leases/spec.md#requirement-lease-renewal - */ - #[PublicPage] - #[NoCSRFRequired] - #[AnonRateLimit(limit: 30, period: 60)] - public function renew(string $id): JSONResponse { - $application = $this->getApplication(); - if ($application === null) { - return $this->unauthorized(); - } - - try { - $lease = $this->leaseService->renew(leaseId: $id, applicationId: $application->getId()); - } catch (DoesNotExistException) { - return $this->notFound(); - } catch (InvalidArgumentException $exception) { - return new JSONResponse( - data: ['message' => $exception->getMessage()], - statusCode: Http::STATUS_CONFLICT - ); - } - - return new JSONResponse(data: $lease->jsonSerialize()); - }//end renew() - /** * Self-revoke one of the calling application's leases. * diff --git a/lib/Controller/MemberOverviewController.php b/lib/Controller/MemberOverviewController.php new file mode 100644 index 000000000..c324d606d --- /dev/null +++ b/lib/Controller/MemberOverviewController.php @@ -0,0 +1,87 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\MemberOverviewService; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Admin-only member overview. + */ +class MemberOverviewController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param MemberOverviewService $service The member overview service + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private MemberOverviewService $service, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * List users with their vault status, paged and filtered. + * + * @param string $status Vault status filter, or '' for every status + * @param string $search Search on user id or display name + * @param int $limit Page size (default 50, at most 200) + * @param int $offset Rows to skip + * + * @AuthorizedAdminSetting(PeopleAdminSettings::class) + * + * @return JSONResponse + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + #[AuthorizedAdminSetting(PeopleAdminSettings::class)] + public function index( + string $status = '', + string $search = '', + int $limit = MemberOverviewService::DEFAULT_LIMIT, + int $offset = 0, + ): JSONResponse { + try { + return new JSONResponse( + data: $this->service->list(status: $status, search: $search, limit: $limit, offset: $offset) + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end index() +}//end class diff --git a/lib/Controller/MigrationController.php b/lib/Controller/MigrationController.php index ade99a5eb..b19bc1a21 100644 --- a/lib/Controller/MigrationController.php +++ b/lib/Controller/MigrationController.php @@ -118,6 +118,12 @@ public function getStatus(): JSONResponse { } }//end getStatus() + #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['id', 'hasErrors', 'acceptUnrecoverable'], + subject: 'migrationOldSuite', + purpose: VaultKeyProofService::PURPOSE_COMPLETE_MIGRATION + )] /** * Complete a migration. * @@ -135,13 +141,8 @@ public function getStatus(): JSONResponse { * Splitting the method would split the route and change the HTTP contract. * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-4 + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-migration-always-has-a-way-to-terminate */ - #[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) { @@ -208,15 +209,23 @@ public function complete(string $id, bool $hasErrors = false, ?int $acceptUnreco * active. Permitted only while no record has been committed to the new suite; * once records have moved the server refuses and points at resuming. * + * Guarded by a proof over the NEW suite's key (keepiq#859). Undoing a + * containment step must not be possible with the credential the step + * contains: a stolen session could otherwise poll the migration status and + * abort the owner's recovery every time it started, and a leaked old + * password must not be enough either. Only whoever holds the key the + * rotation is moving to can call it off. + * * @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 + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves */ #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['id'], subject: 'migrationNewSuite', purpose: VaultKeyProofService::PURPOSE_ABORT_MIGRATION)] public function abort(string $id): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { @@ -226,7 +235,7 @@ public function abort(string $id): JSONResponse { try { $this->requireOwnMigration(migrationId: $id, userId: $user->getUID()); - $result = $this->migrationService->abortMigration(migrationId: $id); + $result = $this->migrationService->abortMigration(migrationId: $id, actorId: $user->getUID()); return new JSONResponse(data: $result); } catch (ForbiddenException $e) { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); diff --git a/lib/Controller/OfflineController.php b/lib/Controller/OfflineController.php index 954e873a0..5eabf4584 100644 --- a/lib/Controller/OfflineController.php +++ b/lib/Controller/OfflineController.php @@ -92,6 +92,10 @@ public function manifest(): JSONResponse { return new JSONResponse(data: ['message' => 'No active encryption suite'], statusCode: Http::STATUS_NOT_FOUND); } + // The offline client needs the edit rule while it has no server to ask + // (offline-edit-queue). + $manifest['offlineEditsEnabled'] = $this->appConfig->getValueBool(Application::APP_ID, 'offline_edits_enabled', false); + return new JSONResponse(data: $manifest); }//end manifest() }//end class diff --git a/lib/Controller/PasskeyController.php b/lib/Controller/PasskeyController.php index 795edf0d1..b9c4b36cf 100644 --- a/lib/Controller/PasskeyController.php +++ b/lib/Controller/PasskeyController.php @@ -106,6 +106,8 @@ public function index(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkey-enrollment-requires-an-unlocked-vault */ #[NoAdminRequired] public function challenge(): JSONResponse { @@ -128,10 +130,14 @@ public function challenge(): JSONResponse { * @param string $label User nickname * @param string $transports Comma-joined transports * @param string $aaguid Authenticator model id + * @param string $clientKind web (default) or extension + * @param string $rpId The relying party id (required for an extension credential) * * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkey-enrollment-requires-an-unlocked-vault */ #[NoAdminRequired] public function create( @@ -142,6 +148,8 @@ public function create( string $label = '', string $transports = '', string $aaguid = '', + string $clientKind = 'web', + string $rpId = '', ): JSONResponse { $uid = $this->uid(); if ($uid === null) { @@ -159,6 +167,8 @@ public function create( 'label' => $label, 'transports' => $transports, 'aaguid' => $aaguid, + 'clientKind' => $clientKind, + 'rpId' => $rpId, ], ); } catch (InvalidArgumentException $exception) { @@ -170,20 +180,25 @@ public function create( /** * The unlock options for the lock screen (active envelopes + salts + - * a fresh challenge; stale/revoked refused). + * a fresh challenge; stale/revoked refused), scoped to the asking client. + * + * @param string $client web (default) or extension + * @param string $rpId The extension's relying party id * * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passwordless-unlock-derives-the-unlock-key-client-side */ #[NoAdminRequired] - public function loginOptions(): JSONResponse { + public function loginOptions(string $client='web', string $rpId=''): JSONResponse { $uid = $this->uid(); if ($uid === null) { return $this->unauth(); } - return new JSONResponse(data: $this->service->loginOptions($uid)); + return new JSONResponse(data: $this->service->loginOptions(uid: $uid, client: $client, rpId: $rpId)); }//end loginOptions() /** @@ -194,6 +209,8 @@ public function loginOptions(): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkeys-are-manageable-revocable-and-owner-scoped */ #[NoAdminRequired] public function used(string $id): JSONResponse { @@ -215,6 +232,8 @@ public function used(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkeys-are-manageable-revocable-and-owner-scoped */ #[NoAdminRequired] public function destroy(string $id): JSONResponse { diff --git a/lib/Controller/RecipientStatusController.php b/lib/Controller/RecipientStatusController.php new file mode 100644 index 000000000..44fadc6d9 --- /dev/null +++ b/lib/Controller/RecipientStatusController.php @@ -0,0 +1,111 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\RecipientStatusService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Recipient status for the share dialog's user search. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-recipient-search-marks-who-cannot-receive-a-share + */ +class RecipientStatusController extends Controller { + /** + * Constructor for RecipientStatusController. + * + * @param IRequest $request The request object + * @param RecipientStatusService $recipientStatus The recipient status service + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + IRequest $request, + private RecipientStatusService $recipientStatus, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Which of the given users, all from the caller's sharee search for + * `search`, hold an active vault. Ids that search does not return are + * left out of the answer, so the endpoint cannot be used to probe users + * the caller may not share with. + * + * @param string $search The term the caller searched for + * @param array $userIds The user ids from that search's result + * + * @NoAdminRequired + * + * @no-admin-idor-exempt Reads no object by a caller-chosen id: RecipientStatusService::statusFor() + * reruns Nextcloud's sharee search as the caller and answers only for ids that search returns, + * which is exactly the set the caller may already see in the share dialog. + * + * @return JSONResponse `{recipients: [{userId, hasSuite}]}` + * + * @spec openspec/specs/user-sharing/spec.md#requirement-recipient-search-marks-who-cannot-receive-a-share + */ + #[NoAdminRequired] + public function status(string $search = '', array $userIds = []): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $requested = array_values( + array_unique( + array_filter( + $userIds, + static fn (mixed $candidate): bool => (is_string($candidate) === true && $candidate !== '') + ) + ) + ); + if (count($requested) > RecipientStatusService::MAX_USERS) { + return new JSONResponse( + data: [ + 'message' => sprintf( + 'At most %d users may be looked up at once, %d given', + RecipientStatusService::MAX_USERS, + count($requested) + ), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse( + data: ['recipients' => $this->recipientStatus->statusFor(search: $search, userIds: $requested)] + ); + }//end status() +}//end class diff --git a/lib/Controller/RecoveryAdminController.php b/lib/Controller/RecoveryAdminController.php new file mode 100644 index 000000000..427422c18 --- /dev/null +++ b/lib/Controller/RecoveryAdminController.php @@ -0,0 +1,162 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\RecoveryAudit; +use OCA\Keepiq\Service\RecoveryEnrolmentService; +use OCA\Keepiq\Service\RecoveryKeyService; +use OCA\Keepiq\Service\RecoveryPolicyService; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\Attribute\PasswordConfirmationRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Administrator settings of organisation account recovery + * (crypto-organisation-account-recovery task 1.2, 2.3). Admin only; every + * change also needs a fresh Nextcloud password confirmation. + */ +class RecoveryAdminController extends Controller { + + /** + * Constructor for RecoveryAdminController. + * + * @param IRequest $request The request + * @param RecoveryPolicyService $policy The policy service + * @param RecoveryKeyService $keys The recovery keys + * @param RecoveryEnrolmentService $enrolments The enrolments (suite warning) + * @param RecoveryAudit $audit The audit trail + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private RecoveryPolicyService $policy, + private RecoveryKeyService $keys, + private RecoveryEnrolmentService $enrolments, + private RecoveryAudit $audit, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The policy, officers, threshold and the active key's fingerprint. + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + #[AuthorizedAdminSetting(settings: PeopleAdminSettings::class)] + public function show(): JSONResponse { + $key = $this->keys->activeKey(); + $active = null; + if ($key !== null) { + $active = [ + 'id' => $key->getId(), + 'fingerprint' => $key->getFingerprint(), + 'createdBy' => $key->getCreatedBy(), + 'createdAt' => $key->getCreatedAt()?->format('c'), + ]; + } + + return new JSONResponse(data: $this->policy->settings() + ['activeKey' => $active]); + }//end show() + + /** + * Set the policy, officers and threshold. + * + * @param string $policy The policy + * @param string[] $officers The officer user ids + * @param int $threshold The approval threshold + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + #[AuthorizedAdminSetting(settings: PeopleAdminSettings::class)] + #[PasswordConfirmationRequired] + public function update(string $policy = 'off', array $officers = [], int $threshold = 1): JSONResponse { + try { + $settings = $this->policy->update(policy: $policy, officers: $officers, threshold: $threshold); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + $this->audit->record( + actorId: (string)$this->userSession->getUser()?->getUID(), + eventType: RecoveryAudit::SETTINGS_CHANGED, + objectId: 'settings', + metadata: [ + 'policy' => $settings['policy'], + 'threshold' => $settings['threshold'], + 'officerCount' => count($settings['officers']), + ] + ); + + return new JSONResponse(data: $settings); + }//end update() + + /** + * Retire a recovery key. + * + * @param string $id The key + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + #[AuthorizedAdminSetting(settings: PeopleAdminSettings::class)] + #[PasswordConfirmationRequired] + public function retireKey(string $id): JSONResponse { + try { + $this->keys->retire(recoveryKeyId: $id, adminUid: (string)$this->userSession->getUser()?->getUID()); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['status' => 'retired']); + }//end retireKey() + + /** + * Whether a suite's owner is enrolled through that suite, for the warning + * before a force-revocation. + * + * @param string $suiteId The suite + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-force-revocation-warns-about-enrolled-users + */ + #[AuthorizedAdminSetting(settings: PeopleAdminSettings::class)] + public function enrolled(string $suiteId = ''): JSONResponse { + return new JSONResponse(data: ['enrolled' => $this->enrolments->isSuiteEnrolled(suiteId: $suiteId)]); + }//end enrolled() +}//end class diff --git a/lib/Controller/RecoveryOfficerController.php b/lib/Controller/RecoveryOfficerController.php new file mode 100644 index 000000000..8b8149805 --- /dev/null +++ b/lib/Controller/RecoveryOfficerController.php @@ -0,0 +1,289 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\RecoveryKeyService; +use OCA\Keepiq\Service\RecoveryPolicyService; +use OCA\Keepiq\Service\RecoveryRequestService; +use OCA\Keepiq\Service\VaultKeyProofService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * The recovery officer's routes (crypto-organisation-account-recovery 2.1, + * 4.2, 4.3). Every route checks in the body that the caller is a named + * officer; handoff material answers anyone it is not for like an unknown id. + */ +class RecoveryOfficerController extends Controller { + + /** + * Constructor for RecoveryOfficerController. + * + * @param IRequest $request The request + * @param RecoveryPolicyService $policy The officers + * @param RecoveryKeyService $keys The recovery keys + * @param RecoveryRequestService $requests The requests + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private RecoveryPolicyService $policy, + private RecoveryKeyService $keys, + private RecoveryRequestService $requests, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The caller's officer view: whether they are an officer, the officers + * and their certificates (to wrap a new key for), the active key, and + * the open requests. + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + #[NoAdminRequired] + public function overview(): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + if ($this->policy->isOfficer(userId: $uid) === false) { + return new JSONResponse(data: ['officer' => false]); + } + + return new JSONResponse( + data: [ + 'officer' => true, + 'officers' => $this->policy->officers(), + 'threshold' => $this->policy->threshold(), + 'key' => $this->keys->publicInfo(), + 'requests' => $this->requests->forOfficer(officerUid: $uid), + ] + ); + }//end overview() + + /** + * Store a new recovery key: its public half and one wrapped copy per officer. + * + * @param string $publicKey The recovery public key PEM + * @param array $copies Officer uid => wrapped private key + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + #[NoAdminRequired] + public function createKey(string $publicKey = '', array $copies = []): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $key = $this->keys->createKey(officerUid: $uid, publicKeyPem: $publicKey, copies: $copies); + } 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); + } + + return new JSONResponse(data: $key->jsonSerialize(), statusCode: Http::STATUS_CREATED); + }//end createKey() + + /** + * The caller's own wrapped copy of the active key (or of `keyId`). + * + * @param string $keyId The key, or '' for the active one + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + #[NoAdminRequired] + public function ownCopy(string $keyId = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $recoveryKeyId = null; + if ($keyId !== '') { + $recoveryKeyId = $keyId; + } + + try { + $copy = $this->keys->ownCopy(officerUid: $uid, recoveryKeyId: $recoveryKeyId); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse( + data: ['recoveryKeyId' => $copy->getRecoveryKeyId(), 'wrappedPrivateKey' => $copy->getWrappedPrivateKey()] + ); + }//end ownCopy() + + /** + * Replace the caller's own copy after their suite rotated. + * + * @param string $keyId The key + * @param string $wrappedPrivateKey The copy wrapped to the new suite + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + #[NoAdminRequired] + public function replaceOwnCopy(string $keyId, string $wrappedPrivateKey = ''): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->keys->replaceOwnCopy(officerUid: $uid, recoveryKeyId: $keyId, wrapped: $wrappedPrivateKey); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse(data: ['status' => 'replaced']); + }//end replaceOwnCopy() + + /** + * Approve a request. Needs a vault-key proof from the officer's active + * suite, so a session alone cannot approve. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + #[NoAdminRequired] + #[VaultKeyProofRequired(binds: ['id'], subject: 'active', purpose: VaultKeyProofService::PURPOSE_APPROVE_ACCOUNT_RECOVERY)] + public function approve(string $id): JSONResponse { + return $this->run(action: fn (string $uid): array => ['status' => $this->requests->approve(id: $id, officerUid: $uid)]); + }//end approve() + + /** + * Decline a request. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + #[NoAdminRequired] + public function decline(string $id): JSONResponse { + return $this->run( + action: function (string $uid) use ($id): array { + $this->requests->decline(id: $id, officerUid: $uid); + return ['status' => 'declined']; + } + ); + }//end decline() + + /** + * The handoff material, for an approving officer of an approved request. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + #[NoAdminRequired] + public function handoff(string $id): JSONResponse { + return $this->run(action: fn (string $uid): array => $this->requests->handoff(id: $id, officerUid: $uid)); + }//end handoff() + + /** + * Post the private key sealed to the request key. + * + * @param string $id The request + * @param string $sealedResult The sealed private key + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + #[NoAdminRequired] + public function postSealed(string $id, string $sealedResult = ''): JSONResponse { + return $this->run( + action: function (string $uid) use ($id, $sealedResult): array { + $this->requests->postSealed(id: $id, officerUid: $uid, sealed: $sealedResult); + return ['status' => 'handed-off']; + } + ); + }//end postSealed() + + /** + * Run an officer action and map refusals: not an officer is 403, + * anything it is not for is 404, a malformed body is 400. + * + * @param callable $action Receives the caller's uid + * + * @return JSONResponse + */ + private function run(callable $action): JSONResponse { + $uid = $this->uid(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $action($uid)); + } catch (ForbiddenException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end run() + + /** + * The caller's uid. + * + * @return string|null + */ + private function uid(): ?string { + return $this->userSession->getUser()?->getUID(); + }//end uid() +}//end class diff --git a/lib/Controller/RecoveryUserController.php b/lib/Controller/RecoveryUserController.php new file mode 100644 index 000000000..1fa31cd25 --- /dev/null +++ b/lib/Controller/RecoveryUserController.php @@ -0,0 +1,183 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\RecoveryEnrolmentService; +use OCA\Keepiq\Service\RecoveryRequestService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * A user's own enrolment and recovery request (crypto-organisation-account- + * recovery 3.1, 4.1, 4.5). Every route acts on the caller's own records only. + */ +class RecoveryUserController extends Controller { + + /** + * Constructor for RecoveryUserController. + * + * @param IRequest $request The request + * @param RecoveryEnrolmentService $enrolments The enrolment service + * @param RecoveryRequestService $requests The request service + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private RecoveryEnrolmentService $enrolments, + private RecoveryRequestService $requests, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The caller's enrolment status and the active key to enrol with. + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + #[NoAdminRequired] + public function enrolment(): JSONResponse { + return $this->run(action: fn (string $uid): array => $this->enrolments->status(userId: $uid)); + }//end enrolment() + + /** + * Enrol with an envelope built in the caller's browser. + * + * @param string $recoveryKeyId The active key + * @param string $envelope The hybrid envelope of the caller's private key + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + #[NoAdminRequired] + public function enrol(string $recoveryKeyId = '', string $envelope = ''): JSONResponse { + return $this->run( + action: function (string $uid) use ($recoveryKeyId, $envelope): array { + $this->enrolments->enrol(userId: $uid, recoveryKeyId: $recoveryKeyId, envelope: $envelope); + return $this->enrolments->status(userId: $uid); + } + ); + }//end enrol() + + /** + * Withdraw, refused under the required policy. + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + #[NoAdminRequired] + public function withdraw(): JSONResponse { + return $this->run( + action: function (string $uid): array { + $this->enrolments->withdraw(userId: $uid); + return $this->enrolments->status(userId: $uid); + } + ); + }//end withdraw() + + /** + * File a request from the lock screen with the browser's one-time key. + * + * @param string $publicKey The one-time X25519 public key (base64) + * @param string $purpose `password` (forgot it) or `device` (unlock a new device once) + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-a-recovery-request-carries-a-one-time-key-and-a-verification-phrase + */ + #[NoAdminRequired] + public function createRequest(string $publicKey = '', string $purpose = 'password'): JSONResponse { + return $this->run( + action: fn (string $uid): array => $this->requests->create( + userId: $uid, + publicKey: $publicKey, + purpose: $purpose + )->jsonSerialize(), + successStatus: Http::STATUS_CREATED + ); + }//end createRequest() + + /** + * The caller's latest request, with the sealed result once it exists. + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + #[NoAdminRequired] + public function myRequest(): JSONResponse { + return $this->run(action: fn (string $uid): array => ['request' => $this->requests->forUser(userId: $uid)]); + }//end myRequest() + + /** + * Mark the caller's request fulfilled, after the suite was re-wrapped. + * + * @param string $id The request + * + * @return JSONResponse + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-user-is-told-what-happened-and-offered-a-rotation + */ + #[NoAdminRequired] + public function complete(string $id): JSONResponse { + return $this->run(action: fn (string $uid): array => ['handledBy' => $this->requests->complete(id: $id, userId: $uid)]); + }//end complete() + + /** + * Run a user action and map refusals. + * + * @param callable $action Receives the caller's uid + * @param int $successStatus The status of a success + * + * @return JSONResponse + */ + private function run(callable $action, int $successStatus = Http::STATUS_OK): JSONResponse { + $uid = $this->userSession->getUser()?->getUID(); + if ($uid === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $action($uid), statusCode: $successStatus); + } catch (ForbiddenException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end run() +}//end class diff --git a/lib/Controller/RotationController.php b/lib/Controller/RotationController.php index 78b81a220..9d58aad2a 100644 --- a/lib/Controller/RotationController.php +++ b/lib/Controller/RotationController.php @@ -156,6 +156,8 @@ public function getExpiry(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-expiry-policies-with-admin-default-and-user-override */ #[NoAdminRequired] public function policies(): JSONResponse { @@ -219,6 +221,8 @@ public function upsertPolicy(string $scope, string $scopeId, ?int $maxAgeDays = * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-expiry-policies-with-admin-default-and-user-override */ #[NoAdminRequired] public function destroyPolicy(string $id): JSONResponse { @@ -334,6 +338,8 @@ public function markRotated(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-rotate-after-breach-and-rotate-after-compromise-flagging */ #[NoAdminRequired] public function dismissFlag(string $id): JSONResponse { diff --git a/lib/Controller/SecretController.php b/lib/Controller/SecretController.php index 862bfba3b..c3cea3592 100644 --- a/lib/Controller/SecretController.php +++ b/lib/Controller/SecretController.php @@ -39,6 +39,10 @@ /** * Authenticated API controller for Secret CRUD. + * + * Its 403 refusals leave as 428 with the policy code as `error`: Nextcloud's + * OCSMiddleware would turn a 403 of an OCSController into an HTTP 200 OCS + * envelope, so OcsRefusalMiddleware re-statuses it first. */ class SecretController extends OCSController { /** @@ -89,6 +93,9 @@ private function uid(): ?string { * @param int $page Page number (1-based) * @param int $limit Items per page * @param string|null $typeId Filter by secret-type ID (omit = all types) + * @param string $state live (default), trashed, archived or kept (live and archived) + * @param string|null $favourite '1' or 'true' for only the user's starred secrets + * @param string|null $tag Only secrets the user tagged with this tag * * @NoAdminRequired * @@ -96,6 +103,11 @@ private function uid(): ?string { * * @spec openspec/changes/implement-secrets/tasks.md#task-4.1 * @spec openspec/changes/passkey-item-type/specs/passkey-item-type/spec.md#requirement-passkey-listing-filtering-and-site-associated-presentation + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) The parameters are the + * route's query string, bound by the framework one by one. */ #[NoAdminRequired] public function index( @@ -106,14 +118,23 @@ public function index( int $page = 1, int $limit = SecretService::DEFAULT_LIMIT, ?string $typeId = null, + string $state = 'live', + ?string $favourite = null, + ?string $tag = null, ): JSONResponse { $userId = $this->uid(); if ($userId === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } - $result = $this->secretService->list($userId, $folderId, $sort, $direction, $page, $limit, $typeId); - if ($search !== null && trim($search) !== '') { + try { + $onlyFavourites = in_array(strtolower((string)$favourite), ['1', 'true'], true); + $result = $this->secretService->list($userId, $folderId, $sort, $direction, $page, $limit, $typeId, $state, $onlyFavourites, $tag); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + if ($state === 'live' && $search !== null && trim($search) !== '') { $result = $this->secretService->search($userId, $search, $page, $limit); } @@ -130,6 +151,7 @@ public function index( * @return JSONResponse * * @spec openspec/changes/implement-secrets/tasks.md#task-4.1 + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder */ #[NoAdminRequired] public function show(string $id): JSONResponse { @@ -146,7 +168,11 @@ public function show(string $id): JSONResponse { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } - return new JSONResponse(data: $secret->jsonSerialize()); + // The holder's tags ride along for the edit dialog (vault-favourites-tags-and-last-used). + $data = $secret->jsonSerialize(); + $tagged = $this->secretService->withTags([$data], $userId); + + return new JSONResponse(data: ($tagged[0] ?? $data)); }//end show() /** @@ -206,8 +232,9 @@ public function create( // 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); + // ForbiddenException: the folder belongs to another user (keepiq#795), + // or a vault policy refused the write (admin-vault-policies D4). + return $this->forbidden(exception: $e); } catch (WriteLockedException $e) { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: self::STATUS_LOCKED); } catch (InvalidArgumentException $e) { @@ -274,91 +301,21 @@ private function createOwnedSecret( }//end createOwnedSecret() /** - * Update a secret. Only the supplied fields are changed. - * - * @param string $id The secret ID - * @param string|null $name The new name - * @param string|null $url The new URL - * @param string|null $typeId The new type ID - * @param string|null $folderId The new folder ID - * @param string|null $key The new RSA-encrypted key blob - * @param string|null $login The new RSA-encrypted login blob - * @param string|null $additionalFields The new RSA-encrypted additional fields blob + * A 403 for a refused write, with the policy code when a vault policy + * refused it (admin-vault-policies D4). * - * @NoAdminRequired + * @param ForbiddenException|SuiteBlockedException $exception The refusal * * @return JSONResponse * - * @spec openspec/changes/implement-secrets/tasks.md#task-4.1 - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) Each parameter is read indirectly via the - * variable-variable ${$field} loop that forwards only fields present in the request. + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders */ - #[NoAdminRequired] - public function update( - string $id, - ?string $name = null, - ?string $url = null, - ?string $typeId = null, - ?string $folderId = null, - ?string $key = null, - ?string $login = null, - ?string $additionalFields = null, - ): JSONResponse { - $userId = $this->uid(); - if ($userId === null) { - return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); - } - - // Only forward fields that were explicitly provided in the request. - $data = []; - foreach (['name', 'url', 'typeId', 'folderId', 'key', 'login', 'additionalFields'] as $field) { - if ($this->request->getParam($field, '__unset__') !== '__unset__') { - $data[$field] = ${$field}; - } - } - - try { - $secret = $this->secretService->update($id, $data, $userId); - } 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 (WriteLockedException $e) { - return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: self::STATUS_LOCKED); - } catch (InvalidArgumentException $e) { - return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); - } - - return new JSONResponse(data: $secret->jsonSerialize()); - }//end update() - - /** - * Delete a secret (cascades to its link shares). - * - * @param string $id The secret ID - * - * @NoAdminRequired - * - * @return JSONResponse - * - * @spec openspec/changes/implement-secrets/tasks.md#task-4.1 - */ - #[NoAdminRequired] - public function destroy(string $id): JSONResponse { - $userId = $this->uid(); - if ($userId === null) { - return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); - } - - try { - $this->secretService->delete($id, $userId); - } 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); + private function forbidden(ForbiddenException|SuiteBlockedException $exception): JSONResponse { + $data = ['message' => $exception->getMessage()]; + if ($exception instanceof ForbiddenException && $exception->policyCode() !== null) { + $data['code'] = $exception->policyCode(); } - return new JSONResponse(data: ['status' => 'deleted']); - }//end destroy() + return new JSONResponse(data: $data, statusCode: Http::STATUS_FORBIDDEN); + }//end forbidden() }//end class diff --git a/lib/Controller/SecretOrganisationController.php b/lib/Controller/SecretOrganisationController.php new file mode 100644 index 000000000..fe6048ec3 --- /dev/null +++ b/lib/Controller/SecretOrganisationController.php @@ -0,0 +1,154 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use Closure; +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\SecretOrganisationService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Favourites, tags and last used on the caller's own secrets. + */ +class SecretOrganisationController extends Controller { + /** + * Constructor for SecretOrganisationController. + * + * @param IRequest $request The request + * @param SecretOrganisationService $organisation The organisation service + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private SecretOrganisationService $organisation, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Star or unstar one of the caller's secrets. + * + * @param string $id The secret ID + * @param bool $favourite The new star + * + * @return JSONResponse + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The flag is the request body + * `{favourite: bool}`; it is the data, not a mode switch. + */ + #[NoAdminRequired] + public function favourite(string $id, bool $favourite = false): JSONResponse { + return $this->run(action: fn (string $userId): array => $this->organisation->setFavourite($id, $userId, $favourite)); + }//end favourite() + + /** + * Replace the tags on one of the caller's secrets. + * + * @param string $id The secret ID + * @param array $tags The tags + * + * @return JSONResponse + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + #[NoAdminRequired] + public function tags(string $id, array $tags = []): JSONResponse { + return $this->run( + action: fn (string $userId): array => ['id' => $id, 'tags' => $this->organisation->setTags($id, $userId, $tags)] + ); + }//end tags() + + /** + * The caller's tags with how many live secrets carry each. + * + * @return JSONResponse + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + #[NoAdminRequired] + public function tagIndex(): JSONResponse { + return $this->run(action: fn (string $userId): array => ['tags' => $this->organisation->listTags($userId)]); + }//end tagIndex() + + /** + * Record that the paired browser extension filled one of the caller's + * secrets. Same authentication as the extension's other routes. + * + * @param string $id The secret ID + * + * @return JSONResponse + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function used(string $id): JSONResponse { + return $this->run( + action: function (string $userId) use ($id): array { + $this->organisation->markUsed($id, $userId); + return ['status' => 'recorded']; + } + ); + }//end used() + + /** + * Run one holder action and map its refusals onto HTTP statuses. + * + * @param Closure $action Receives the user id, returns the response body + * + * @return JSONResponse + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + private function run(Closure $action): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $action($user->getUID())); + } catch (NotFoundException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + }//end run() +}//end class diff --git a/lib/Controller/SecretTrashController.php b/lib/Controller/SecretTrashController.php new file mode 100644 index 000000000..cc014d2f9 --- /dev/null +++ b/lib/Controller/SecretTrashController.php @@ -0,0 +1,190 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use Closure; +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Exception\StaleWriteException; +use OCA\Keepiq\Service\SecretTrashService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Trash and archive actions on one secret. + */ +class SecretTrashController extends Controller { + /** + * Constructor for SecretTrashController. + * + * @param IRequest $request The request + * @param SecretTrashService $trashService The trash service + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private SecretTrashService $trashService, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Delete a secret: it moves to the trash and its shares end now. The + * response keeps `status: deleted` for existing clients and adds + * `trashed: true`. + * + * @param string $id The secret ID + * @param string|null $baseUpdatedAt The version an offline delete was made from (409 when it changed) + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-deleting-a-secret-moves-it-to-the-trash + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ + #[NoAdminRequired] + public function trash(string $id, ?string $baseUpdatedAt=null): JSONResponse { + return $this->run( + action: function (string $userId) use ($id, $baseUpdatedAt): array { + $this->trashService->trash($id, $userId, $baseUpdatedAt); + return ['status' => 'deleted', 'trashed' => true]; + } + ); + }//end trash() + + /** + * Take a secret out of the trash. The body is the secret; a restored + * read-only copy from another organisation adds `federatedShare` + * (`resumed`, `ended` or `unreachable`). + * + * @param string $id The secret ID + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + * @spec openspec/specs/federated-sharing/spec.md#scenario-the-owner-revoked-the-share-meanwhile + */ + #[NoAdminRequired] + public function restore(string $id): JSONResponse { + return $this->run( + action: function (string $userId) use ($id): array { + $restored = $this->trashService->restore($id, $userId); + $body = $restored['secret']->jsonSerialize(); + if ($restored['federatedShare'] !== null) { + $body['federatedShare'] = $restored['federatedShare']; + } + + return $body; + } + ); + }//end restore() + + /** + * Delete a trashed secret for good. + * + * @param string $id The secret ID + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + #[NoAdminRequired] + public function purge(string $id): JSONResponse { + return $this->run( + action: function (string $userId) use ($id): array { + $this->trashService->purge($id, $userId); + return ['status' => 'purged']; + } + ); + }//end purge() + + /** + * Archive a secret. + * + * @param string $id The secret ID + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + #[NoAdminRequired] + public function archive(string $id): JSONResponse { + return $this->run(action: fn (string $userId) => $this->trashService->archive($id, $userId)); + }//end archive() + + /** + * Bring an archived secret back into the vault. + * + * @param string $id The secret ID + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + #[NoAdminRequired] + public function unarchive(string $id): JSONResponse { + return $this->run(action: fn (string $userId) => $this->trashService->unarchive($id, $userId)); + }//end unarchive() + + /** + * Run one owner action and map its refusals onto HTTP statuses. + * + * @param Closure $action Receives the user id, returns the response body + * + * @return JSONResponse + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + private function run(Closure $action): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $action($user->getUID())); + } 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 (StaleWriteException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage(), 'current' => $e->getCurrent()->jsonSerialize()], + statusCode: Http::STATUS_CONFLICT + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_CONFLICT); + } + }//end run() +}//end class diff --git a/lib/Controller/SecretTypeController.php b/lib/Controller/SecretTypeController.php index 15ba289cb..c34ba072c 100644 --- a/lib/Controller/SecretTypeController.php +++ b/lib/Controller/SecretTypeController.php @@ -27,12 +27,13 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Exception\ConflictException; use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\SecretTypeService; +use OCA\Keepiq\Settings\AdminSettings; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -46,7 +47,7 @@ class SecretTypeController extends OCSController { * @param IRequest $request The request object * @param SecretTypeService $typeService The secret type service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin check) + * @param AdminAreaAuthorizer $areas Whether the caller holds the General admin area * * @return void */ @@ -54,7 +55,7 @@ public function __construct( IRequest $request, private SecretTypeService $typeService, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -88,25 +89,34 @@ public function index(): JSONResponse { * @param string $name The unique type name * @param string $label The human-readable label * @param string $scope The scope (user or global) + * @param array|null $fields The fields an item of this type carries * * @NoAdminRequired * * @return JSONResponse * * @spec openspec/changes/implement-secrets/tasks.md#task-4.2 + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions */ #[NoAdminRequired] - public function create(string $name, string $label, string $scope = 'user'): JSONResponse { + public function create(string $name, string $label, string $scope='user', ?array $fields=null): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } $userId = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($userId); + $isAdmin = $this->areas->holds(userId: $userId, areaClass: AdminSettings::class); try { - $type = $this->typeService->createType($name, $label, $scope, $userId, $isAdmin); + $type = $this->typeService->createType( + name: $name, + label: $label, + scope: $scope, + userId: $userId, + isAdmin: $isAdmin, + fields: $fields, + ); } catch (ForbiddenException $e) { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (ConflictException $e) { @@ -123,25 +133,33 @@ public function create(string $name, string $label, string $scope = 'user'): JSO * * @param string $id The type ID * @param string $label The new label + * @param array|null $fields The new field list, or null to keep it * * @NoAdminRequired * * @return JSONResponse * * @spec openspec/changes/implement-secrets/tasks.md#task-4.2 + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions */ #[NoAdminRequired] - public function update(string $id, string $label): JSONResponse { + public function update(string $id, string $label, ?array $fields=null): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } $userId = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($userId); + $isAdmin = $this->areas->holds(userId: $userId, areaClass: AdminSettings::class); try { - $type = $this->typeService->updateType($id, $label, $userId, $isAdmin); + $type = $this->typeService->updateType( + id: $id, + label: $label, + userId: $userId, + isAdmin: $isAdmin, + fields: $fields, + ); } catch (ForbiddenException $e) { return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $e) { @@ -170,7 +188,7 @@ public function destroy(string $id): JSONResponse { } $userId = $user->getUID(); - $isAdmin = $this->groupManager->isAdmin($userId); + $isAdmin = $this->areas->holds(userId: $userId, areaClass: AdminSettings::class); try { $this->typeService->deleteType($id, $userId, $isAdmin); diff --git a/lib/Controller/SecretUpdateController.php b/lib/Controller/SecretUpdateController.php new file mode 100644 index 000000000..65bc8e886 --- /dev/null +++ b/lib/Controller/SecretUpdateController.php @@ -0,0 +1,161 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Exception\StaleWriteException; +use OCA\Keepiq\Exception\WriteLockedException; +use OCA\Keepiq\Service\SecretService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Updates one secret. + * + * @spec openspec/specs/secrets/spec.md#requirement-update-secret + */ +class SecretUpdateController extends OCSController { + /** + * HTTP 423 Locked status code. + * + * @var int + */ + private const STATUS_LOCKED = 423; + + /** + * The fields a client may change; only those present in the request are + * forwarded, so an absent field stays as it is. + * + * @var string[] + */ + private const FIELDS = ['name', 'url', 'typeId', 'folderId', 'key', 'login', 'additionalFields']; + + /** + * Constructor. + * + * @param IRequest $request The request object + * @param SecretService $secretService The secret service + * @param IUserSession $userSession The user session + * + * @return void + */ + public function __construct( + IRequest $request, + private SecretService $secretService, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Update a secret. Only the fields present in the request change: `name`, + * `url`, `typeId`, `folderId`, the ciphertext `key`, `login` and + * `additionalFields`, plus `mergedPending` (request-filled blobs the client + * merged, keepiq#750) and `baseUpdatedAt` (the version an offline edit was + * made from). + * + * @param string $id The secret ID + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/secrets/spec.md#requirement-update-secret + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ + #[NoAdminRequired] + public function update(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $secret = $this->secretService->update($id, $this->requestedChanges(), $user->getUID()); + } catch (StaleWriteException $e) { + return new JSONResponse( + data: ['message' => $e->getMessage(), 'current' => $e->getCurrent()->jsonSerialize()], + statusCode: Http::STATUS_CONFLICT + ); + } catch (NotFoundException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException $e) { + // A vault policy refusal carries its code (admin-vault-policies D4). + $body = ['message' => $e->getMessage()]; + if ($e->policyCode() !== null) { + $body['code'] = $e->policyCode(); + } + + return new JSONResponse(data: $body, statusCode: Http::STATUS_FORBIDDEN); + } catch (WriteLockedException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: self::STATUS_LOCKED); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + }//end try + + return new JSONResponse(data: $secret->jsonSerialize()); + }//end update() + + /** + * The changes the request carries: each updatable field that is present + * (an explicit null clears it), and the merge count and base version when + * given. + * + * @return array + * + * @spec openspec/specs/secrets/spec.md#requirement-update-secret + */ + private function requestedChanges(): array { + $data = []; + foreach (self::FIELDS as $field) { + $value = $this->request->getParam($field, '__unset__'); + if ($value === null) { + $data[$field] = null; + } else if ($value !== '__unset__') { + $data[$field] = (string)$value; + } + } + + $mergedPending = $this->request->getParam('mergedPending'); + if ($mergedPending !== null) { + $data['mergedPending'] = (int)$mergedPending; + } + + $baseUpdatedAt = $this->request->getParam('baseUpdatedAt'); + if ($baseUpdatedAt !== null && $baseUpdatedAt !== '') { + $data['baseUpdatedAt'] = (string)$baseUpdatedAt; + } + + return $data; + }//end requestedChanges() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 694c60e93..99524998d 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -23,9 +23,10 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; -use OCA\Keepiq\Service\Connection\ConnectionReporter; +use OCA\Keepiq\Service\AdminAreaAuthorizer; use OCA\Keepiq\Service\SettingsService; use OCA\Keepiq\Settings\AdminSettings; +use OCA\Keepiq\Settings\PolicyAdminSettings; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; @@ -44,17 +45,19 @@ 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. + * @param AdminAreaAuthorizer|null $areas The admin areas the session user holds, for the settings payload + * @param \OCA\Keepiq\Service\TwoFactorGate|null $twoFactor The two-factor gap count (admin-vault-policies §1.3) * * @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 + * @spec openspec/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, + private ?AdminAreaAuthorizer $areas = null, + private ?\OCA\Keepiq\Service\TwoFactorGate $twoFactor = null, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -67,6 +70,7 @@ public function __construct( * @return JSONResponse * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-5 + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.5 */ #[NoAdminRequired] public function index(): JSONResponse { @@ -74,9 +78,13 @@ public function index(): JSONResponse { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } - return new JSONResponse( - data: $this->settingsService->getSettings() - ); + // The admin areas the user holds (admin-scoped-roles §2.5), so the UI + // offers an admin panel only to someone its endpoint lets through. + // Display only: every endpoint checks its own area. + $settings = $this->settingsService->getSettings(); + $settings['adminAreas'] = ($this->areas?->areasOf(userId: $this->userSession->getUser()->getUID()) ?? []); + + return new JSONResponse(data: $settings); }//end index() /** @@ -99,12 +107,15 @@ public function index(): JSONResponse { * the refreshed settings map (stored keys plus the `openregisters` and * `isAdmin` metadata flags read by the settings UI). * + * It writes the master password floor, so it is guarded by the Policies + * area (admin-scoped-roles D2). + * * A rejected value answers 400 rather than the `{success: true}` envelope. * Before #192 an unwritable value was indistinguishable from a stored one, * because the write loop simply never matched and the envelope was * unconditional; a bounded key that fails validation must now say so. * - * @AuthorizedAdminSetting(AdminSettings::class) + * @AuthorizedAdminSetting(PolicyAdminSettings::class) * * @return JSONResponse The refreshed settings, wrapped as `{success, config}`. * @@ -112,7 +123,7 @@ public function index(): JSONResponse { * Served by AppHost Generics (Scenario: Admin settings page still renders * through the generic section) */ - #[AuthorizedAdminSetting(AdminSettings::class)] + #[AuthorizedAdminSetting(PolicyAdminSettings::class)] public function update(): JSONResponse { $data = $this->request->getParams(); @@ -148,7 +159,7 @@ public function update(): JSONResponse { * DISPATCHED method, so delegating to `update()` does not inherit its * posture. Both entry points therefore declare the same admin gate. * - * @AuthorizedAdminSetting(AdminSettings::class) + * @AuthorizedAdminSetting(PolicyAdminSettings::class) * * @return JSONResponse The refreshed settings, wrapped as `{success, config}`. * @@ -156,7 +167,7 @@ public function update(): JSONResponse { * Served by AppHost Generics (Scenario: Admin settings page still renders * through the generic section) */ - #[AuthorizedAdminSetting(AdminSettings::class)] + #[AuthorizedAdminSetting(PolicyAdminSettings::class)] public function create(): JSONResponse { return $this->update(); }//end create() @@ -181,53 +192,26 @@ public function load(): JSONResponse { }//end load() /** - * Get admin-scoped settings (implement-dashboard-settings §2.2). + * How many users a two-factor vault policy for these groups covers, and + * how many have no second factor yet (admin-vault-policies §1.3). Counts + * only, admin only. * - * @AuthorizedAdminSetting(AdminSettings::class) - * - * @return JSONResponse + * @param array $groups The group scope; empty means everyone * - * @spec openspec/changes/implement-dashboard-settings/tasks.md#task-2.2 - */ - #[AuthorizedAdminSetting(AdminSettings::class)] - public function getAdminSettings(): JSONResponse { - return new JSONResponse(data: $this->settingsService->getAdminSettings()); - }//end getAdminSettings() - - /** - * 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) + * @AuthorizedAdminSetting(PolicyAdminSettings::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 + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group */ - #[AuthorizedAdminSetting(AdminSettings::class)] - public function updateAdminSettings(): JSONResponse { - $data = $this->request->getParams(); - - try { - $result = $this->settingsService->updateAdminSettings($data); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['message' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); + #[AuthorizedAdminSetting(PolicyAdminSettings::class)] + public function twoFactorGaps(array $groups = []): JSONResponse { + if ($this->twoFactor === null) { + return new JSONResponse(data: ['message' => 'Unavailable'], statusCode: Http::STATUS_SERVICE_UNAVAILABLE); } - // 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() + return new JSONResponse(data: $this->twoFactor->gapReport(groupIds: array_values(array_map('strval', $groups)))); + }//end twoFactorGaps() /** * Get the current user's preferences (implement-dashboard-settings §2.3). diff --git a/lib/Controller/ShareController.php b/lib/Controller/ShareController.php index 538694b68..7133a3e0a 100644 --- a/lib/Controller/ShareController.php +++ b/lib/Controller/ShareController.php @@ -23,9 +23,17 @@ namespace OCA\Keepiq\Controller; +use DateTime; +use DateTimeZone; use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Attribute\VaultKeyProofRequired; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Service\KnownShareRecipientExemption; +use OCA\Keepiq\Service\ShareRestriction; +use OCA\Keepiq\Service\ShareRestrictionRules; use OCA\Keepiq\Service\ShareService; +use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; @@ -35,6 +43,9 @@ /** * Authenticated API controller for ShareTarget CRUD. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One public method per share + * operation; the use-only change added the restriction update. */ class ShareController extends OCSController { /** @@ -98,6 +109,14 @@ public function index(string $secretId): JSONResponse { ); }//end index() + // The attributes sit above the docblock: the spec-coverage gate reads the + // docblock directly above a declaration, and stops at a multi-line attribute. + #[NoAdminRequired] + #[VaultKeyProofRequired( + binds: ['secretId', 'targetUserId'], + purpose: VaultKeyProofService::PURPOSE_SHARE_NEW_RECIPIENT, + exemption: KnownShareRecipientExemption::class + )] /** * Create a share target. * @@ -109,19 +128,27 @@ public function index(string $secretId): JSONResponse { * @param string $targetUserId The recipient Nextcloud user ID * @param string $recipientSecretId The recipient's encrypted Secret copy ID * @param string|null $groupShareId Optional group-share linkage + * @param bool $useOnly Whether the recipient may only use the value (direct shares) + * @param string|null $expiresAt When the recipient's access ends (ISO 8601, direct shares) * * @NoAdminRequired * * @return JSONResponse * * @spec openspec/changes/implement-user-sharing/tasks.md#task-9.1 + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $useOnly is a request body + * field the server stores, not a mode switch. */ - #[NoAdminRequired] public function create( string $secretId, string $targetUserId, string $recipientSecretId, ?string $groupShareId = null, + bool $useOnly = false, + ?string $expiresAt = null, ): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { @@ -134,8 +161,16 @@ public function create( targetUserId: $targetUserId, recipientSecretId: $recipientSecretId, groupShareId: $groupShareId, - userId: $user->getUID() + userId: $user->getUID(), + restriction: (new ShareRestrictionRules())->fromRequest( + useOnly: $useOnly, + expiresAt: $expiresAt, + now: new DateTime('now', new DateTimeZone('UTC')) + ) ); + } catch (ForbiddenException $e) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $e) { return new JSONResponse( data: ['message' => $e->getMessage()], @@ -146,6 +181,54 @@ public function create( return new JSONResponse(data: $share->jsonSerialize(), statusCode: Http::STATUS_CREATED); }//end create() + /** + * Change the use-only flag and end date of a direct share. Owner or + * delegate only; the recipient is refused, because the source is not + * theirs. + * + * @param string $id The share-target row ID + * @param bool $useOnly Whether the recipient may only use the value + * @param string|null $expiresAt When the recipient's access ends (ISO 8601, null clears it) + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $useOnly is a request body + * field the server stores, not a mode switch. + */ + #[NoAdminRequired] + public function update(string $id, bool $useOnly = false, ?string $expiresAt = null): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $restriction = (new ShareRestrictionRules())->fromRequest( + useOnly: $useOnly, + expiresAt: $expiresAt, + now: new DateTime('now', new DateTimeZone('UTC')) + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + try { + $share = $this->shareService->updateRestriction( + shareId: $id, + restriction: $restriction, + userId: $user->getUID() + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse(data: ['message' => $e->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } + + return new JSONResponse(data: $share->jsonSerialize()); + }//end update() + /** * Revoke a share target. * @@ -207,6 +290,9 @@ public function createBatch( groupShareId: $groupShareId, userId: $user->getUID() ); + } catch (ForbiddenException $exception) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $exception) { return new JSONResponse( data: ['message' => $exception->getMessage()], @@ -251,6 +337,9 @@ public function sync( expectedUpdatedAt: $expectedUpdatedAt, userId: $user->getUID() ); + } catch (ForbiddenException $exception) { + // A copy from another organisation (sharing-federated-recipients task 3.4). + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); } catch (InvalidArgumentException $exception) { return new JSONResponse( data: ['message' => $exception->getMessage()], @@ -273,8 +362,10 @@ public function sync( * @return JSONResponse * * @spec openspec/changes/bulk-actions/specs/bulk-actions/spec.md#requirement-bulk-share + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof */ #[NoAdminRequired] + #[VaultKeyProofRequired(purpose: VaultKeyProofService::PURPOSE_SHARE_REGISTER_BATCH)] public function registerBatch(array $shares = []): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { diff --git a/lib/Controller/SiemSinkController.php b/lib/Controller/SiemSinkController.php index ef22007df..c9ddedc10 100644 --- a/lib/Controller/SiemSinkController.php +++ b/lib/Controller/SiemSinkController.php @@ -27,13 +27,15 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\SiemSink; +use OCA\Keepiq\Service\AdminAreaAuthorizer; +use OCA\Keepiq\Service\Siem\SiemSinkRequest; use OCA\Keepiq\Service\SiemService; +use OCA\Keepiq\Settings\AuditAdminSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCSController; -use OCP\IGroupManager; use OCP\IRequest; use OCP\IUserSession; @@ -47,7 +49,7 @@ class SiemSinkController extends OCSController { * @param IRequest $request The request object * @param SiemService $service The SIEM service * @param IUserSession $userSession The user session - * @param IGroupManager $groupManager The group manager (admin gate) + * @param AdminAreaAuthorizer $areas Whether the caller holds the Audit admin area * * @return void */ @@ -55,7 +57,7 @@ public function __construct( IRequest $request, private SiemService $service, private IUserSession $userSession, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -68,7 +70,7 @@ public function __construct( */ private function adminUid(): ?string { $user = $this->userSession->getUser(); - if ($user === null || $this->groupManager->isAdmin($user->getUID()) === false) { + if ($user === null || $this->areas->holds(userId: $user->getUID(), areaClass: AuditAdminSettings::class) === false) { return null; } @@ -111,38 +113,19 @@ public function index(): JSONResponse { }//end index() /** - * Create a sink. - * - * @param string $name Display name - * @param string $type 'syslog' or 'webhook' - * @param string $endpoint host:port (syslog) or https URL (webhook) - * @param bool $tls TLS transport for syslog - * @param string $hmacSecret Optional write-only webhook HMAC secret - * @param array $categoryFilter Optional category slugs; empty = all - * @param int $queueCap Per-sink pending-queue cap - * @param bool $enabled Whether delivery is active + * Create a sink. The body carries name, type (syslog, webhook, splunk_hec + * or sentinel), endpoint, tls, hmacSecret, categoryFilter, queueCap, + * enabled, format, credential and connectorOptions; SiemSinkRequest reads + * them with their defaults. * * @NoAdminRequired * * @return JSONResponse * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $tls and $enabled are not behaviour - * switches inside this method: they are two fields of the sink being created, - * bound by name out of the JSON request body by the Nextcloud router and passed - * straight into the params array. Nothing here branches on either. Removing them - * would remove them from the HTTP contract. + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink */ #[NoAdminRequired] - public function create( - string $name = '', - string $type = '', - string $endpoint = '', - bool $tls = true, - string $hmacSecret = '', - array $categoryFilter = [], - int $queueCap = 1000, - bool $enabled = true, - ): JSONResponse { + public function create(): JSONResponse { $adminUid = $this->adminUid(); if ($adminUid === null) { return $this->forbidden(); @@ -151,16 +134,7 @@ public function create( try { $sink = $this->service->createSink( adminUid: $adminUid, - params: [ - 'name' => $name, - 'type' => $type, - 'endpoint' => $endpoint, - 'tls' => $tls, - 'hmacSecret' => $hmacSecret, - 'categoryFilter' => $categoryFilter, - 'queueCap' => $queueCap, - 'enabled' => $enabled, - ], + params: (new SiemSinkRequest(request: $this->request))->forCreate(), ); } catch (InvalidArgumentException $exception) { return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); @@ -170,108 +144,39 @@ public function create( }//end create() /** - * Update a sink; a blank hmacSecret preserves the stored one (§3.2). + * Update a sink with the fields the body supplies; a blank hmacSecret or + * credential keeps the stored one (§3.2). * * @param string $id The sink UUID - * @param string $name Display name - * @param string $endpoint Endpoint (blank preserves) - * @param bool|null $tls TLS transport, null preserves - * @param string $hmacSecret Write-only secret (blank preserves) - * @param array|null $categoryFilter Category slugs, null preserves - * @param int|null $queueCap Queue cap, null preserves - * @param bool|null $enabled Active flag, null preserves * * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-credentials-are-write-only-and-encrypted-at-rest */ #[NoAdminRequired] - public function update( - string $id, - string $name = '', - string $endpoint = '', - ?bool $tls = null, - string $hmacSecret = '', - ?array $categoryFilter = null, - ?int $queueCap = null, - ?bool $enabled = null, - ): JSONResponse { + public function update(string $id): JSONResponse { $adminUid = $this->adminUid(); if ($adminUid === null) { return $this->forbidden(); } - $params = $this->collectSinkChanges( - name: $name, - endpoint: $endpoint, - tls: $tls, - hmacSecret: $hmacSecret, - categoryFilter: $categoryFilter, - queueCap: $queueCap, - enabled: $enabled - ); - try { - $sink = $this->service->updateSink(adminUid: $adminUid, sinkId: $id, params: $params); + $sink = $this->service->updateSink( + adminUid: $adminUid, + sinkId: $id, + params: (new SiemSinkRequest(request: $this->request))->forUpdate() + ); } catch (DoesNotExistException) { return new JSONResponse(data: ['message' => 'Sink not found'], statusCode: Http::STATUS_NOT_FOUND); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); } return new JSONResponse(data: $sink->jsonSerialize()); }//end update() - /** - * Collect the sink fields the caller actually supplied. An empty string - * or a null means "leave unchanged"; hmacSecret is always forwarded - * because the service treats '' as "keep the stored secret". - * - * @param string $name The new display name, or '' - * @param string $endpoint The new endpoint URL, or '' - * @param bool|null $tls The new TLS flag, or null - * @param string $hmacSecret The new HMAC secret, or '' - * @param array|null $categoryFilter The new category filter, or null - * @param int|null $queueCap The new queue cap, or null - * @param bool|null $enabled The new enabled flag, or null - * - * @return array The changed fields only. - */ - private function collectSinkChanges( - string $name, - string $endpoint, - ?bool $tls, - string $hmacSecret, - ?array $categoryFilter, - ?int $queueCap, - ?bool $enabled, - ): array { - $params = ['hmacSecret' => $hmacSecret]; - if ($name !== '') { - $params['name'] = $name; - } - - if ($endpoint !== '') { - $params['endpoint'] = $endpoint; - } - - if ($tls !== null) { - $params['tls'] = $tls; - } - - if ($categoryFilter !== null) { - $params['categoryFilter'] = $categoryFilter; - } - - if ($queueCap !== null) { - $params['queueCap'] = $queueCap; - } - - if ($enabled !== null) { - $params['enabled'] = $enabled; - } - - return $params; - }//end collectSinkChanges() - /** * Delete a sink and its queued events. * @@ -280,6 +185,8 @@ private function collectSinkChanges( * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ #[NoAdminRequired] public function destroy(string $id): JSONResponse { @@ -305,6 +212,8 @@ public function destroy(string $id): JSONResponse { * @NoAdminRequired * * @return JSONResponse + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ #[NoAdminRequired] public function test(string $id): JSONResponse { diff --git a/lib/Controller/TeamFolderContributionController.php b/lib/Controller/TeamFolderContributionController.php new file mode 100644 index 000000000..28d96ee7d --- /dev/null +++ b/lib/Controller/TeamFolderContributionController.php @@ -0,0 +1,188 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\OrgOwnershipGuard; +use OCA\Keepiq\Service\TeamFolderContributionService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Write-grade contributions and ownership findings. + */ +class TeamFolderContributionController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request + * @param IUserSession $userSession The user session + * @param TeamFolderContributionService $contributions Write-grade member contributions + * @param OrgOwnershipGuard $ownership The ownership policy findings + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private IUserSession $userSession, + private TeamFolderContributionService $contributions, + private OrgOwnershipGuard $ownership, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * The session user id, or null when anonymous. + * + * @return string|null + */ + private function sessionUserId(): ?string { + return $this->userSession->getUser()?->getUID(); + }//end sessionUserId() + + /** + * The team folders the session user may contribute to (write grade, not + * owned), for the secret form's folder picker (admin-vault-policies §4.3). + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + #[NoAdminRequired] + public function contributable(): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: $this->contributions->contributable(userId: $userId)); + }//end contributable() + + /** + * The session user's own secrets that break the team folder ownership + * policy, for the health report (admin-vault-policies D6). Metadata + * only, scoped to the session user; empty when the policy does not + * apply. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/vault-policies/spec.md#requirement-users-see-personal-items-that-break-the-ownership-policy + */ + #[NoAdminRequired] + public function ownershipFindings(): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse(data: $this->ownership->findings(userId: $userId)); + }//end ownershipFindings() + + /** + * The public certificates a write-grade member encrypts a contribution + * for (admin-vault-policies §4.3). Refused to anyone without write. + * + * @param string $id The TeamFolder UUID + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + #[NoAdminRequired] + public function contributionContext(string $id): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + return new JSONResponse(data: $this->contributions->context(teamFolderId: $id, userId: $userId)); + } catch (NotFoundException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } + }//end contributionContext() + + /** + * A write-grade member saves a new secret into a team folder they do not + * own (admin-vault-policies D5). The body carries the owner ciphertext + * (`key`, `login`, `additionalFields`), metadata and one `copies` row per + * member. The grade is checked in the service before anything is stored; + * a read-grade member or a non-member gets 403. + * + * @param string $id The TeamFolder UUID + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + #[NoAdminRequired] + public function contribute(string $id): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + $data = []; + foreach (['name', 'url', 'typeId', 'folderId', 'key', 'login', 'additionalFields', 'copies'] as $field) { + $data[$field] = $this->request->getParam($field); + } + + try { + $result = $this->contributions->contribute(teamFolderId: $id, data: $data, userId: $userId); + } catch (NotFoundException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_NOT_FOUND); + } catch (ForbiddenException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_FORBIDDEN); + } catch (InvalidArgumentException $exception) { + return new JSONResponse(data: ['message' => $exception->getMessage()], statusCode: Http::STATUS_BAD_REQUEST); + } + + return new JSONResponse( + data: ['secret' => $result['secret']->jsonSerialize(), 'copies' => $result['copies']], + statusCode: Http::STATUS_CREATED + ); + }//end contribute() +}//end class diff --git a/lib/Controller/TeamFolderController.php b/lib/Controller/TeamFolderController.php index b830deb4c..92c093230 100644 --- a/lib/Controller/TeamFolderController.php +++ b/lib/Controller/TeamFolderController.php @@ -32,6 +32,7 @@ use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Service\TeamFolderConfirmationService; use OCA\Keepiq\Service\TeamFolderService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -50,6 +51,7 @@ class TeamFolderController extends OCSController { * @param IRequest $request The request object * @param TeamFolderService $teamFolderService The team-folder service * @param IUserSession $userSession The user session + * @param TeamFolderConfirmationService $confirmations Automatic member confirmation * * @return void */ @@ -57,6 +59,7 @@ public function __construct( IRequest $request, private TeamFolderService $teamFolderService, private IUserSession $userSession, + private TeamFolderConfirmationService $confirmations, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -150,6 +153,33 @@ public function destroy(string $id): JSONResponse { return new JSONResponse(data: ['revoked' => $revoked]); }//end destroy() + /** + * The team folders where the session user may confirm waiting members + * (admin-auto-confirm-members D4). Scoped to the session user: owned + * folders, and folders where the user holds `write` on the source and a + * current copy. Empty when the admin switch is off. + * + * @NoAdminRequired + * + * @return JSONResponse + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-pending-confirmations-are-served-to-authorised-confirmers-only + */ + #[NoAdminRequired] + public function pendingConfirmations(): JSONResponse { + $userId = $this->sessionUserId(); + if ($userId === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + return new JSONResponse( + data: [ + 'enabled' => $this->confirmations->isEnabled(), + 'folders' => $this->confirmations->pendingConfirmations(userId: $userId), + ] + ); + }//end pendingConfirmations() + /** * Reconciliation: expected fan-out state + missing (secret × * recipient) pairs for the browser to encrypt (self-healing after a @@ -194,6 +224,7 @@ public function reconcile(string $id): JSONResponse { * @return JSONResponse * * @spec openspec/changes/team-folder-sharing/tasks.md#2.4 + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe */ #[NoAdminRequired] public function registerShares(string $id, array $shares): JSONResponse { @@ -203,9 +234,12 @@ public function registerShares(string $id, array $shares): JSONResponse { } try { - $result = $this->teamFolderService->registerFanOutShares( + // The owner keeps the plain fan-out path; a write-grade member is + // accepted only with the auto-confirm switch on, row by row + // (admin-auto-confirm-members §2.2). + $result = $this->confirmations->registerShares( teamFolderId: $id, - shares: $shares, + rows: $shares, userId: $userId ); } catch (InvalidArgumentException $exception) { @@ -221,7 +255,7 @@ public function registerShares(string $id, array $shares): JSONResponse { /** * Admin offboarding: revoke a leaver's team-derived access and * transfer their owned team secrets to a successor. Authorization - * (instance admin or vault_admin) is asserted in the service body. + * (instance admin or People area holder) is asserted in the service body. * * @param string $leavingUserId The user being offboarded * @param string $successorUserId The successor user diff --git a/lib/Controller/TeamFolderMemberController.php b/lib/Controller/TeamFolderMemberController.php index e38a04a5b..73bf2bcf8 100644 --- a/lib/Controller/TeamFolderMemberController.php +++ b/lib/Controller/TeamFolderMemberController.php @@ -33,8 +33,14 @@ namespace OCA\Keepiq\Controller; +use DateTime; +use DateTimeZone; use InvalidArgumentException; use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\ManagerOnlyException; +use OCA\Keepiq\Exception\OwnerOnlyException; +use OCA\Keepiq\Service\ShareRestriction; +use OCA\Keepiq\Service\ShareRestrictionRules; use OCA\Keepiq\Service\TeamFolderService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -108,15 +114,27 @@ public function members(string $id): JSONResponse { * @param string $id The TeamFolder UUID * @param string $memberType The member type (`user`|`group`) * @param string $memberId The Nextcloud user or group ID + * @param bool $useOnly Whether the member may only use the values (read grade only) + * @param string|null $expiresAt When the membership ends (ISO 8601) * * @NoAdminRequired * * @return JSONResponse * * @spec openspec/changes/team-folder-sharing/tasks.md#4.1 + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.2 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $useOnly is a request body + * field the server stores, not a mode switch. */ #[NoAdminRequired] - public function addMember(string $id, string $memberType, string $memberId): JSONResponse { + public function addMember( + string $id, + string $memberType, + string $memberId, + bool $useOnly = false, + ?string $expiresAt = null, + ): JSONResponse { $userId = $this->sessionUserId(); if ($userId === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); @@ -127,7 +145,12 @@ public function addMember(string $id, string $memberType, string $memberId): JSO teamFolderId: $id, memberType: $memberType, memberId: $memberId, - userId: $userId + userId: $userId, + restriction: (new ShareRestrictionRules())->fromRequest( + useOnly: $useOnly, + expiresAt: $expiresAt, + now: new DateTime('now', new DateTimeZone('UTC')) + ) ); } catch (InvalidArgumentException $exception) { return new JSONResponse( @@ -173,10 +196,7 @@ public function removeMember(string $id, string $memberId): JSONResponse { userId: $userId ); } catch (InvalidArgumentException $exception) { - return new JSONResponse( - data: ['message' => $exception->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); + return $this->refusal(exception: $exception); } return new JSONResponse(data: ['revoked' => $revoked]); @@ -225,34 +245,86 @@ public function approveJoin(string $id, string $newMemberId): JSONResponse { * @param string $id The team folder UUID * @param string $memberId The membership row UUID * @param string $grade The grade (`read`|`write`) + * @param bool|null $useOnly Whether the member may only use the values (null = leave both options) + * @param string|null $expiresAt When the membership ends (ISO 8601; sent with useOnly) * * @NoAdminRequired * * @return JSONResponse * - * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-or-write-grade + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-write-or-manage-grade + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.2 */ #[NoAdminRequired] - public function setMemberGrade(string $id, string $memberId, string $grade = ''): JSONResponse { + public function setMemberGrade( + string $id, + string $memberId, + string $grade = '', + ?bool $useOnly = null, + ?string $expiresAt = null, + ): JSONResponse { $user = $this->userSession->getUser(); if ($user === null) { return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); } try { + $restriction = null; + if ($useOnly !== null) { + $restriction = (new ShareRestrictionRules())->fromRequest( + useOnly: $useOnly, + expiresAt: $expiresAt, + now: new DateTime('now', new DateTimeZone('UTC')) + ); + } + $member = $this->teamFolderService->setMemberGrade( teamFolderId: $id, memberId: $memberId, grade: $grade, ownerId: $user->getUID(), + restriction: $restriction, ); } catch (InvalidArgumentException $exception) { - return new JSONResponse( - data: ['message' => $exception->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); + return $this->refusal(exception: $exception); } return new JSONResponse(data: $member->jsonSerialize()); }//end setMemberGrade() + + /** + * The response for a refused membership change: a change only the owner + * may make is forbidden (`owner_only`), so is one by a member who is + * neither owner nor manager (`manager_only`); anything else is a bad + * request. + * The 403 leaves as 428 through OcsRefusalMiddleware, so the browser sees + * the refusal and its code. + * + * @param InvalidArgumentException $exception The refusal + * + * @return JSONResponse + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-only-the-owner-governs-managers-and-the-folder-itself + * @spec openspec/specs/folder-permission-grades/spec.md#scenario-non-owner-cannot-change-a-grade + */ + private function refusal(InvalidArgumentException $exception): JSONResponse { + if ($exception instanceof OwnerOnlyException) { + return new JSONResponse( + data: ['message' => $exception->getMessage(), 'error' => OwnerOnlyException::CODE], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + if ($exception instanceof ManagerOnlyException) { + return new JSONResponse( + data: ['message' => $exception->getMessage(), 'error' => ManagerOnlyException::CODE], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return new JSONResponse( + data: ['message' => $exception->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end refusal() }//end class diff --git a/lib/Controller/UseOnlyController.php b/lib/Controller/UseOnlyController.php new file mode 100644 index 000000000..23b331153 --- /dev/null +++ b/lib/Controller/UseOnlyController.php @@ -0,0 +1,87 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Controller; + +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Exception\NotFoundException; +use OCA\Keepiq\Service\UseOnlyUseRecorder; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Use recording for use-only copies (sharing-use-only-and-expiring-shares §3.3). + */ +class UseOnlyController extends Controller { + + /** + * Constructor for UseOnlyController. + * + * @param IRequest $request The request + * @param UseOnlyUseRecorder $recorder The use recorder + * @param IUserSession $userSession The user session + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IRequest $request, + private UseOnlyUseRecorder $recorder, + private IUserSession $userSession, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Record that the caller's browser extension filled one of the caller's + * use-only copies. The recorder refuses any id the caller does not hold + * as a use-only copy with the answer an unknown id gets. + * + * Same authentication as the extension's other routes (app password, no + * CSRF token). + * + * @param string $id The recipient copy ID + * + * @return JSONResponse + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-each-use-is-recorded + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function used(string $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(data: ['message' => 'Unauthorized'], statusCode: Http::STATUS_UNAUTHORIZED); + } + + try { + $this->recorder->recordUse(copyId: $id, userId: $user->getUID()); + } catch (NotFoundException) { + return new JSONResponse(data: ['message' => 'Not found'], statusCode: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['status' => 'recorded']); + }//end used() +}//end class diff --git a/lib/Controller/WebManifestController.php b/lib/Controller/WebManifestController.php index d852d2f6f..5838adf4e 100644 --- a/lib/Controller/WebManifestController.php +++ b/lib/Controller/WebManifestController.php @@ -34,6 +34,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataDisplayResponse; +use OCP\Defaults; use OCP\IRequest; use OCP\IURLGenerator; @@ -46,12 +47,14 @@ class WebManifestController extends Controller { * * @param IRequest $request The HTTP request * @param IURLGenerator $urlGenerator Builds absolute asset + scope URLs + * @param Defaults $defaults The instance theming, for the browser chrome colour * * @return void */ public function __construct( IRequest $request, private IURLGenerator $urlGenerator, + private Defaults $defaults, ) { parent::__construct(appName: Application::APP_ID, request: $request); }//end __construct() @@ -78,16 +81,22 @@ public function manifest(): DataDisplayResponse { $scope = $this->urlGenerator->linkToRoute('keepiq.dashboard.page'); $maskable = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'pwa-icon-maskable.svg')); $anyIcon = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'pwa-icon.svg')); + // Raster copies: some mobile browsers only offer to install with PNG + // icons at 192 and 512 pixels. + $pngIcon = fn (string $name): string => $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath(Application::APP_ID, $name) + ); + // The instance theme colour (nldesign or the admin's theming), so an + // organisation theme reaches the browser chrome too. + $themeColor = $this->defaults->getColorPrimary(); $manifest = [ 'name' => 'Keepiq', 'short_name' => 'Keepiq', - 'description' => 'Encrypted secrets manager — your zero-knowledge vault.', + 'description' => 'Encrypted secrets manager and zero-knowledge vault.', 'display' => 'standalone', - // NL Design System / brand tokens (cobalt) — the app icon is a - // brand asset; the browser chrome colour matches it. - 'theme_color' => '#21468B', - 'background_color' => '#21468B', + 'theme_color' => $themeColor, + 'background_color' => $themeColor, 'start_url' => $startUrl, 'scope' => $scope, 'orientation' => 'portrait-primary', @@ -96,6 +105,10 @@ public function manifest(): DataDisplayResponse { ['src' => $anyIcon, 'sizes' => '512x512', 'type' => 'image/svg+xml', 'purpose' => 'any'], ['src' => $maskable, 'sizes' => '192x192', 'type' => 'image/svg+xml', 'purpose' => 'maskable'], ['src' => $maskable, 'sizes' => '512x512', 'type' => 'image/svg+xml', 'purpose' => 'maskable'], + ['src' => $pngIcon('pwa-icon-192.png'), 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any'], + ['src' => $pngIcon('pwa-icon-512.png'), 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any'], + ['src' => $pngIcon('pwa-icon-maskable-192.png'), 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'maskable'], + ['src' => $pngIcon('pwa-icon-maskable-512.png'), 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'maskable'], ], 'shortcuts' => [ [ diff --git a/lib/Db/DeviceApproval.php b/lib/Db/DeviceApproval.php new file mode 100644 index 000000000..89aacc2e5 --- /dev/null +++ b/lib/Db/DeviceApproval.php @@ -0,0 +1,220 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * A request from a new device to be unlocked by an approval from an unlocked + * one (crypto-new-device-approval D1). Holds only public material and, between + * approval and pickup, the unlock key sealed to the request's one-time key. + * + * @method string getUserId() + * @method void setUserId(string $userId) + * @method string getClientKind() + * @method void setClientKind(string $clientKind) + * @method string getDeviceLabel() + * @method void setDeviceLabel(string $deviceLabel) + * @method string getRequesterIp() + * @method void setRequesterIp(string $requesterIp) + * @method string getRequesterAgent() + * @method void setRequesterAgent(string $requesterAgent) + * @method string getRequestPublicKey() + * @method void setRequestPublicKey(string $requestPublicKey) + * @method string getRequestSecretHash() + * @method void setRequestSecretHash(string $requestSecretHash) + * @method string getStatus() + * @method void setStatus(string $status) + * @method DateTime|null getCreatedAt() + * @method void setCreatedAt(DateTime $createdAt) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(DateTime $expiresAt) + * @method DateTime|null getDecidedAt() + * @method void setDecidedAt(?DateTime $decidedAt) + * @method string|null getSealedUnlockKey() + * @method void setSealedUnlockKey(?string $sealedUnlockKey) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class DeviceApproval extends Entity implements JsonSerializable { + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_DENIED = 'denied'; + + public const STATUS_EXPIRED = 'expired'; + + public const STATUS_CONSUMED = 'consumed'; + + /** + * The user the vault belongs to. + * + * @var string + */ + protected string $userId = ''; + + /** + * The requesting client: `web` or `extension`. + * + * @var string + */ + protected string $clientKind = ''; + + /** + * A label for the device (browser and operating system). + * + * @var string + */ + protected string $deviceLabel = ''; + + /** + * The IP address the request came from. + * + * @var string + */ + protected string $requesterIp = ''; + + /** + * The user agent the request came with. + * + * @var string + */ + protected string $requesterAgent = ''; + + /** + * The one-time X25519 public key (base64, raw 32 bytes). + * + * @var string + */ + protected string $requestPublicKey = ''; + + /** + * SHA-256 (hex) of the request secret only the requesting client knows. + * + * @var string + */ + protected string $requestSecretHash = ''; + + /** + * One of the STATUS_* values. + * + * @var string + */ + protected string $status = ''; + + /** + * When the request was made. + * + * @var DateTime|null + */ + protected ?DateTime $createdAt = null; + + /** + * When the request expires unless decided. + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + + /** + * When it was approved or denied. + * + * @var DateTime|null + */ + protected ?DateTime $decidedAt = null; + + /** + * The unlock key sealed to the request key (HPKE, base64 JSON), held + * only between approval and pickup. + * + * @var string|null + */ + protected ?string $sealedUnlockKey = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for DeviceApproval. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'userId', type: 'string'); + $this->addType(fieldName: 'clientKind', type: 'string'); + $this->addType(fieldName: 'deviceLabel', type: 'string'); + $this->addType(fieldName: 'requesterIp', type: 'string'); + $this->addType(fieldName: 'requesterAgent', type: 'string'); + $this->addType(fieldName: 'requestPublicKey', type: 'string'); + $this->addType(fieldName: 'requestSecretHash', type: 'string'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); + $this->addType(fieldName: 'decidedAt', type: 'datetime'); + $this->addType(fieldName: 'sealedUnlockKey', type: 'string'); + }//end __construct() + + /** + * What the approving device is shown: never the secret hash or the sealed key. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'clientKind' => $this->clientKind, + 'deviceLabel' => $this->deviceLabel, + 'requesterIp' => $this->requesterIp, + 'requestPublicKey' => $this->requestPublicKey, + 'status' => $this->status, + 'createdAt' => $this->createdAt?->format('c'), + 'expiresAt' => $this->expiresAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/DeviceApprovalMapper.php b/lib/Db/DeviceApprovalMapper.php new file mode 100644 index 000000000..41820f817 --- /dev/null +++ b/lib/Db/DeviceApprovalMapper.php @@ -0,0 +1,110 @@ + + * @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\Db; + +use DateTime; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Mapper for DeviceApproval entities. + * + * @extends QBMapper + */ +class DeviceApprovalMapper extends QBMapper { + + /** + * Constructor for DeviceApprovalMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_device_approvals', entityClass: DeviceApproval::class); + }//end __construct() + + /** + * Find a request by id. + * + * @param string $id The request id + * + * @return DeviceApproval + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): DeviceApproval { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * A user's pending requests that have not expired, newest first. + * + * @param string $userId The user + * @param DateTime $now The current time + * + * @return DeviceApproval[] + */ + public function findPendingForUser(string $userId, DateTime $now): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(DeviceApproval::STATUS_PENDING))) + ->andWhere($qb->expr()->gt('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('created_at', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findPendingForUser() + + /** + * Requests still pending or approved-but-unclaimed past their expiry. + * + * @param DateTime $now The current time + * + * @return DeviceApproval[] + */ + public function findLapsed(DateTime $now): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->in( + 'status', + $qb->createNamedParameter( + [DeviceApproval::STATUS_PENDING, DeviceApproval::STATUS_APPROVED], + IQueryBuilder::PARAM_STR_ARRAY + ) + ) + ) + ->andWhere($qb->expr()->lte('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + + return $this->findEntities(query: $qb); + }//end findLapsed() +}//end class diff --git a/lib/Db/EmergencyContactMapper.php b/lib/Db/EmergencyContactMapper.php index c17448376..33edba550 100644 --- a/lib/Db/EmergencyContactMapper.php +++ b/lib/Db/EmergencyContactMapper.php @@ -28,6 +28,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @@ -83,6 +84,30 @@ public function findByGrantor(string $grantorUserId): array { return $this->findEntities(query: $qb); }//end findByGrantor() + /** + * Delete every relationship the user is part of, as grantor or as grantee + * (GDPR Art. 17 erasure). A grantor row holds the user's private key + * escrowed to the grantee, so it must not survive the erasure. + * + * @param string $userId The Nextcloud user ID + * + * @return int The number of rows deleted + * + * @spec openspec/specs/gdpr-compliance/spec.md + */ + public function deleteByUser(string $userId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where( + $qb->expr()->orX( + $qb->expr()->eq('grantor_user_id', $qb->createNamedParameter($userId)), + $qb->expr()->eq('grantee_user_id', $qb->createNamedParameter($userId)) + ) + ); + + return $qb->executeStatement(); + }//end deleteByUser() + /** * List all relationships where the user is the grantee (incoming access). * @@ -168,4 +193,43 @@ public function findByGranteeSuite(string $granteeSuiteId): array { return $this->findEntities(query: $qb); }//end findByGranteeSuite() + + /** + * The users among the given ones who have set up an emergency contact + * that is in force (granted, accepted or active), in one query. + * + * @param string[] $userIds The user IDs to check + * + * @return string[] The grantor user IDs that have a contact in force + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + public function grantorsWithContact(array $userIds): array { + if ($userIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct('grantor_user_id') + ->from($this->getTableName()) + ->where( + $qb->expr()->in('grantor_user_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->andWhere( + $qb->expr()->in( + 'state', + $qb->createNamedParameter(['granted', 'accepted', 'active'], IQueryBuilder::PARAM_STR_ARRAY) + ) + ); + + $grantors = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $grantors[] = (string)$row['grantor_user_id']; + } + + $result->closeCursor(); + + return $grantors; + }//end grantorsWithContact() }//end class diff --git a/lib/Db/EncryptionSuiteMapper.php b/lib/Db/EncryptionSuiteMapper.php index 898cae51f..8a3d21ff5 100644 --- a/lib/Db/EncryptionSuiteMapper.php +++ b/lib/Db/EncryptionSuiteMapper.php @@ -31,6 +31,13 @@ * Mapper for EncryptionSuite entities. * * @extends QBMapper + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) 11 against a threshold of 10. + * A mapper's public surface IS its query set. The eleventh, + * latestInactiveStatusByOwners(), answers the member overview's batch + * question (admin-member-overview-and-offboarding) in one query instead + * of one per user; splitting suite queries across two mappers over one + * table would only move them. */ class EncryptionSuiteMapper extends QBMapper { /** @@ -279,7 +286,7 @@ public function countActiveByOwnerType(string $ownerType): int { * * @return int The number of rows deleted * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteByOwnerUser(string $ownerId): int { $qb = $this->db->getQueryBuilder(); @@ -289,4 +296,46 @@ public function deleteByOwnerUser(string $ownerId): int { return $qb->executeStatement(); }//end deleteByOwnerUser() + + /** + * The status of each owner's newest NON-active suite, in one query. + * + * Used for owners who have no active suite: it tells "revoked" from + * "compromised" from "never set up". Only identifiers and the status + * column are read, never the certificate or the wrapped private key. + * + * @param string $ownerType The owner type + * @param string[] $ownerIds The owner IDs to look up + * + * @return array Newest non-active status, keyed by owner ID + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + public function latestInactiveStatusByOwners(string $ownerType, array $ownerIds): array { + if ($ownerIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('owner_id', 'status') + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter($ownerType))) + ->andWhere( + $qb->expr()->in('owner_id', $qb->createNamedParameter($ownerIds, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->andWhere($qb->expr()->neq('status', $qb->createNamedParameter('active'))) + ->orderBy('created_at', 'DESC') + ->addOrderBy('id', 'DESC'); + + $statuses = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + // First row per owner wins: the sort puts the newest first. + $statuses[(string)$row['owner_id']] ??= (string)$row['status']; + } + + $result->closeCursor(); + + return $statuses; + }//end latestInactiveStatusByOwners() }//end class diff --git a/lib/Db/ExpiringGrantQueries.php b/lib/Db/ExpiringGrantQueries.php new file mode 100644 index 000000000..fb63da407 --- /dev/null +++ b/lib/Db/ExpiringGrantQueries.php @@ -0,0 +1,58 @@ + + * @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\Db; + +use DateTime; +use OCP\DB\QueryBuilder\IQueryBuilder; + +/** + * The end-date query shared by the three grant mappers (share targets, + * group shares and team-folder memberships), which all carry `expires_at`. + * + * Used by a QBMapper subclass: relies on its `$db`, `getTableName()` and + * `findEntities()`. + */ +trait ExpiringGrantQueries { + + /** + * The grants whose end date falls in (from, to]. A null `from` means + * every grant whose end date is at or before `to`, so + * `findEndingBetween(null, $now)` lists the expired grants. + * + * @param DateTime|null $from Exclusive lower bound (null = none) + * @param DateTime $to Inclusive upper bound + * + * @return array The grant entities + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + public function findEndingBetween(?DateTime $from, DateTime $to): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->isNotNull('expires_at')) + ->andWhere($qb->expr()->lte('expires_at', $qb->createNamedParameter($to, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + if ($from !== null) { + $qb->andWhere($qb->expr()->gt('expires_at', $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + } + + return $this->findEntities(query: $qb); + }//end findEndingBetween() +}//end trait diff --git a/lib/Db/ExpiryPolicy.php b/lib/Db/ExpiryPolicy.php index e9ca1ebf1..7f8db6740 100644 --- a/lib/Db/ExpiryPolicy.php +++ b/lib/Db/ExpiryPolicy.php @@ -173,8 +173,10 @@ public function jsonSerialize(): array { * The reminder thresholds as a decoded array (null when unset). * * @return int[]|null + * + * @spec openspec/changes/rotation-expiry-policies/specs/rotation-expiry-policies/spec.md */ - private function decodedReminderDays(): ?array { + public function decodedReminderDays(): ?array { if ($this->reminderDays === null) { return null; } diff --git a/lib/Db/FederatedInbound.php b/lib/Db/FederatedInbound.php new file mode 100644 index 000000000..6ab79d223 --- /dev/null +++ b/lib/Db/FederatedInbound.php @@ -0,0 +1,219 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One inbound federated share. + * + * @method string getRecipientUid() + * @method void setRecipientUid(string $recipientUid) + * @method string getSenderCloudId() + * @method void setSenderCloudId(string $senderCloudId) + * @method string getPartnerId() + * @method void setPartnerId(string $partnerId) + * @method string getRemoteShareId() + * @method void setRemoteShareId(string $remoteShareId) + * @method string getName() + * @method void setName(string $name) + * @method string getSharedSecretEnc() + * @method void setSharedSecretEnc(string $sharedSecretEnc) + * @method string|null getSecretId() + * @method void setSecretId(?string $secretId) + * @method string getStatus() + * @method void setStatus(string $status) + * @method DateTime|null getReceivedAt() + * @method void setReceivedAt(DateTime $receivedAt) + * @method DateTime|null getUpdatedAt() + * @method void setUpdatedAt(?DateTime $updatedAt) + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedInbound extends Entity implements JsonSerializable { + /** + * Announced, not yet accepted. + * + * @var string + */ + public const STATUS_PENDING = 'pending'; + + /** + * Accepted: the read-only copy is in the recipient's vault. + * + * @var string + */ + public const STATUS_ACCEPTED = 'accepted'; + + /** + * Declined by the recipient. + * + * @var string + */ + public const STATUS_DECLINED = 'declined'; + + /** + * Revoked by the sender. + * + * @var string + */ + public const STATUS_REVOKED = 'revoked'; + + /** + * The local user the share was made to. + * + * @var string + */ + protected string $recipientUid = ''; + + /** + * The owner's cloud id on the sending instance. + * + * @var string + */ + protected string $senderCloudId = ''; + + /** + * The partner instance that sent the share. + * + * @var string + */ + protected string $partnerId = ''; + + /** + * The share id on the sending instance (the OCM providerId). + * + * @var string + */ + protected string $remoteShareId = ''; + + /** + * The secret's plain name, as the sender announced it. + * + * @var string + */ + protected string $name = ''; + + /** + * The share's shared secret, encrypted with ICrypto. + * + * @var string + */ + protected string $sharedSecretEnc = ''; + + /** + * The local read-only copy once accepted, or null. + * + * @var string|null + */ + protected ?string $secretId = null; + + /** + * One of the STATUS_ constants. + * + * @var string + */ + protected string $status = self::STATUS_PENDING; + + /** + * When the share arrived. + * + * @var DateTime|null + */ + protected ?DateTime $receivedAt = null; + + /** + * When the state last changed. + * + * @var DateTime|null + */ + protected ?DateTime $updatedAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor: declare column types for QBMapper hydration. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'recipientUid', type: 'string'); + $this->addType(fieldName: 'senderCloudId', type: 'string'); + $this->addType(fieldName: 'partnerId', type: 'string'); + $this->addType(fieldName: 'remoteShareId', type: 'string'); + $this->addType(fieldName: 'name', type: 'string'); + $this->addType(fieldName: 'sharedSecretEnc', type: 'string'); + $this->addType(fieldName: 'secretId', type: 'string'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'receivedAt', type: 'datetime'); + $this->addType(fieldName: 'updatedAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the recipient's list: never the shared secret. + * + * @return array + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'senderCloudId' => $this->senderCloudId, + 'name' => $this->name, + 'secretId' => $this->secretId, + 'status' => $this->status, + 'receivedAt' => $this->receivedAt?->format(DATE_ATOM), + 'updatedAt' => $this->updatedAt?->format(DATE_ATOM), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FederatedInboundMapper.php b/lib/Db/FederatedInboundMapper.php new file mode 100644 index 000000000..58f5a774c --- /dev/null +++ b/lib/Db/FederatedInboundMapper.php @@ -0,0 +1,145 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for the keepiq_federated_inbound table. + * + * @template-extends QBMapper + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedInboundMapper extends QBMapper { + /** + * Constructor for FederatedInboundMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_federated_inbound', entityClass: FederatedInbound::class); + }//end __construct() + + /** + * Find an inbound share by its UUID. + * + * @param string $id The inbound share UUID + * + * @return FederatedInbound + * + * @throws DoesNotExistException When no row matches + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function findById(string $id): FederatedInbound { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * The share a partner announced under its own share id. + * + * @param string $partnerId The sending partner + * @param string $remoteShareId The share id on the sending instance + * + * @return FederatedInbound + * + * @throws DoesNotExistException When no row matches + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function findByRemote(string $partnerId, string $remoteShareId): FederatedInbound { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('partner_id', $qb->createNamedParameter($partnerId))) + ->andWhere($qb->expr()->eq('remote_share_id', $qb->createNamedParameter($remoteShareId))); + + return $this->findEntity(query: $qb); + }//end findByRemote() + + /** + * The shares announced under one remote share id, from any partner + * (normally one: share ids are UUIDs). + * + * @param string $remoteShareId The share id on the sending instance + * + * @return FederatedInbound[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function findByRemoteShareId(string $remoteShareId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('remote_share_id', $qb->createNamedParameter($remoteShareId))); + + return $this->findEntities(query: $qb); + }//end findByRemoteShareId() + + /** + * The inbound shares whose copy is this secret (at most one in practice). + * + * @param string $secretId The recipient's copy + * + * @return FederatedInbound[] + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ + public function findBySecretId(string $secretId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('secret_id', $qb->createNamedParameter($secretId))); + + return $this->findEntities(query: $qb); + }//end findBySecretId() + + /** + * Every inbound share of one local user, newest first. + * + * @param string $recipientUid The local user + * + * @return FederatedInbound[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function findByRecipient(string $recipientUid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('recipient_uid', $qb->createNamedParameter($recipientUid))) + ->orderBy('received_at', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findByRecipient() +}//end class diff --git a/lib/Db/FederatedShare.php b/lib/Db/FederatedShare.php new file mode 100644 index 000000000..8d2eb0e1d --- /dev/null +++ b/lib/Db/FederatedShare.php @@ -0,0 +1,289 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One outbound federated share. + * + * @method string getSourceSecretId() + * @method void setSourceSecretId(string $sourceSecretId) + * @method string getOwnerId() + * @method void setOwnerId(string $ownerId) + * @method string getRecipientCloudId() + * @method void setRecipientCloudId(string $recipientCloudId) + * @method string getPartnerId() + * @method void setPartnerId(string $partnerId) + * @method string getRecipientCertFingerprint() + * @method void setRecipientCertFingerprint(string $recipientCertFingerprint) + * @method string|null getKey() + * @method void setKey(?string $key) + * @method string|null getLogin() + * @method void setLogin(?string $login) + * @method string|null getAdditionalFields() + * @method void setAdditionalFields(?string $additionalFields) + * @method string getSharedSecretHash() + * @method void setSharedSecretHash(string $sharedSecretHash) + * @method string getStatus() + * @method void setStatus(string $status) + * @method string|null getPendingNotification() + * @method void setPendingNotification(?string $pendingNotification) + * @method int getNotifyAttempts() + * @method void setNotifyAttempts(int $notifyAttempts) + * @method DateTime|null getNextNotifyAt() + * @method void setNextNotifyAt(?DateTime $nextNotifyAt) + * @method DateTime|null getCreatedAt() + * @method void setCreatedAt(DateTime $createdAt) + * @method DateTime|null getUpdatedAt() + * @method void setUpdatedAt(DateTime $updatedAt) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the design's DB columns. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedShare extends Entity implements JsonSerializable { + /** + * The share is live: the recipient's server may pull it. + * + * @var string + */ + public const STATUS_ACTIVE = 'active'; + + /** + * The partner was removed or the recipient's certificate no longer + * verifies: nothing is served until the owner revokes or shares again. + * + * @var string + */ + public const STATUS_SUSPENDED = 'suspended'; + + /** + * A notification could not be delivered after the last retry. + * + * @var string + */ + public const STATUS_FAILED = 'failed'; + + /** + * The owner revoked it; the SHARE_UNSHARED notification is still on its + * way. Nothing is served; the row goes once the notification arrives. + * + * @var string + */ + public const STATUS_REVOKED = 'revoked'; + + /** + * The recipient removed the copy they had accepted + * (sharing-federated-recipients task 4.4). Nothing is served or sent; + * the owner may share again. + * + * @var string + */ + public const STATUS_DECLINED = 'declined'; + + /** + * The owner's secret this share copies. + * + * @var string + */ + protected string $sourceSecretId = ''; + + /** + * The owner's user id. + * + * @var string + */ + protected string $ownerId = ''; + + /** + * The recipient's federated cloud id. + * + * @var string + */ + protected string $recipientCloudId = ''; + + /** + * The partner instance the recipient belongs to. + * + * @var string + */ + protected string $partnerId = ''; + + /** + * Lowercase hex SHA-256 of the recipient certificate the ciphertext was made for. + * + * @var string + */ + protected string $recipientCertFingerprint = ''; + + /** + * The value, encrypted in the owner's browser for the recipient. + * + * @var string|null + */ + protected ?string $key = null; + + /** + * The login, encrypted for the recipient. + * + * @var string|null + */ + protected ?string $login = null; + + /** + * The additional fields, encrypted for the recipient. + * + * @var string|null + */ + protected ?string $additionalFields = null; + + /** + * Lowercase hex SHA-256 of the share's shared secret; the secret itself is + * never stored on the sending side. + * + * @var string + */ + protected string $sharedSecretHash = ''; + + /** + * One of the STATUS_ constants. + * + * @var string + */ + protected string $status = self::STATUS_ACTIVE; + + /** + * An OCM notification still to deliver (SHARE_UPDATED, SHARE_UNSHARED), or null. + * + * @var string|null + */ + protected ?string $pendingNotification = null; + + /** + * How many times delivery of the pending notification failed. + * + * @var integer + */ + protected int $notifyAttempts = 0; + + /** + * When the retry job tries the pending notification again. + * + * @var DateTime|null + */ + protected ?DateTime $nextNotifyAt = null; + + /** + * When the share was made. + * + * @var DateTime|null + */ + protected ?DateTime $createdAt = null; + + /** + * When the ciphertext or the state last changed. + * + * @var DateTime|null + */ + protected ?DateTime $updatedAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor: declare column types for QBMapper hydration. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'sourceSecretId', type: 'string'); + $this->addType(fieldName: 'ownerId', type: 'string'); + $this->addType(fieldName: 'recipientCloudId', type: 'string'); + $this->addType(fieldName: 'partnerId', type: 'string'); + $this->addType(fieldName: 'recipientCertFingerprint', type: 'string'); + $this->addType(fieldName: 'key', type: 'string'); + $this->addType(fieldName: 'login', type: 'string'); + $this->addType(fieldName: 'additionalFields', type: 'string'); + $this->addType(fieldName: 'sharedSecretHash', type: 'string'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'pendingNotification', type: 'string'); + $this->addType(fieldName: 'notifyAttempts', type: 'integer'); + $this->addType(fieldName: 'nextNotifyAt', type: 'datetime'); + $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'updatedAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the owner's API: identifiers and state only, never the + * ciphertext or the shared secret hash. + * + * @return array + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'sourceSecretId' => $this->sourceSecretId, + 'recipientCloudId' => $this->recipientCloudId, + 'partnerId' => $this->partnerId, + 'recipientCertFingerprint' => $this->recipientCertFingerprint, + 'status' => $this->status, + 'pendingNotification' => $this->pendingNotification, + 'notifyAttempts' => $this->notifyAttempts, + 'createdAt' => $this->createdAt?->format(DATE_ATOM), + 'updatedAt' => $this->updatedAt?->format(DATE_ATOM), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FederatedShareMapper.php b/lib/Db/FederatedShareMapper.php new file mode 100644 index 000000000..027f9b8d0 --- /dev/null +++ b/lib/Db/FederatedShareMapper.php @@ -0,0 +1,128 @@ + + * @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\Db; + +use DateTime; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Mapper for the keepiq_federated_shares table. + * + * @template-extends QBMapper + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedShareMapper extends QBMapper { + /** + * Constructor for FederatedShareMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_federated_shares', entityClass: FederatedShare::class); + }//end __construct() + + /** + * Find a share by its UUID. + * + * @param string $id The share UUID + * + * @return FederatedShare + * + * @throws DoesNotExistException When no row matches + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function findById(string $id): FederatedShare { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * Every federated share of one source secret. + * + * @param string $sourceSecretId The owner's secret + * + * @return FederatedShare[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function findBySourceSecret(string $sourceSecretId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('source_secret_id', $qb->createNamedParameter($sourceSecretId))) + ->orderBy('created_at', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findBySourceSecret() + + /** + * Every federated share to recipients of one partner. + * + * @param string $partnerId The partner UUID + * + * @return FederatedShare[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function findByPartner(string $partnerId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('partner_id', $qb->createNamedParameter($partnerId))); + + return $this->findEntities(query: $qb); + }//end findByPartner() + + /** + * Shares with a notification due for another delivery attempt. + * + * @param DateTime $now The current time + * @param int $limit The most rows to return + * + * @return FederatedShare[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function findDueNotifications(DateTime $now, int $limit): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->isNotNull('pending_notification')) + ->andWhere($qb->expr()->lte('next_notify_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))) + ->orderBy('next_notify_at', 'ASC') + ->setMaxResults($limit); + + return $this->findEntities(query: $qb); + }//end findDueNotifications() +}//end class diff --git a/lib/Db/FederationPartner.php b/lib/Db/FederationPartner.php new file mode 100644 index 000000000..955f50454 --- /dev/null +++ b/lib/Db/FederationPartner.php @@ -0,0 +1,163 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * Entity representing one federation partner. + * + * @method string getBaseUrl() + * @method void setBaseUrl(string $baseUrl) + * @method string getHost() + * @method void setHost(string $host) + * @method string getRootFingerprint() + * @method void setRootFingerprint(string $rootFingerprint) + * @method bool|null getAllowOutbound() + * @method void setAllowOutbound(bool $allowOutbound) + * @method bool|null getAllowInbound() + * @method void setAllowInbound(bool $allowInbound) + * @method string getAddedBy() + * @method void setAddedBy(string $addedBy) + * @method DateTime|null getAddedAt() + * @method void setAddedAt(DateTime $addedAt) + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ +class FederationPartner extends Entity implements JsonSerializable { + /** + * The partner's base URL, `https://host[:port][/path]`, no trailing slash. + * + * @var string + */ + protected string $baseUrl = ''; + + /** + * The partner's host (with `:port` when not 443), as an OCM signer names it. + * + * @var string + */ + protected string $host = ''; + + /** + * Lowercase hex SHA-256 of the partner's Keepiq root certificate (DER). + * + * @var string + */ + protected string $rootFingerprint = ''; + + /** + * Whether users here may share secrets to this partner. + * + * @var boolean|null + */ + protected ?bool $allowOutbound = false; + + /** + * Whether this partner may look up users here and deliver secrets. + * + * @var boolean|null + */ + protected ?bool $allowInbound = false; + + /** + * The administrator who added the partner. + * + * @var string + */ + protected string $addedBy = ''; + + /** + * When the partner was added. + * + * @var DateTime|null + */ + protected ?DateTime $addedAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor: declare column types for QBMapper hydration. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'baseUrl', type: 'string'); + $this->addType(fieldName: 'host', type: 'string'); + $this->addType(fieldName: 'rootFingerprint', type: 'string'); + $this->addType(fieldName: 'allowOutbound', type: 'boolean'); + $this->addType(fieldName: 'allowInbound', type: 'boolean'); + $this->addType(fieldName: 'addedBy', type: 'string'); + $this->addType(fieldName: 'addedAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the admin API. + * + * @return array + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'baseUrl' => $this->baseUrl, + 'host' => $this->host, + 'rootFingerprint' => $this->rootFingerprint, + 'allowOutbound' => $this->allowOutbound === true, + 'allowInbound' => $this->allowInbound === true, + 'addedBy' => $this->addedBy, + 'addedAt' => $this->addedAt?->format(DATE_ATOM), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/FederationPartnerMapper.php b/lib/Db/FederationPartnerMapper.php new file mode 100644 index 000000000..e13ce956d --- /dev/null +++ b/lib/Db/FederationPartnerMapper.php @@ -0,0 +1,101 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for the keepiq_federation_partners table. + * + * @template-extends QBMapper + */ +class FederationPartnerMapper extends QBMapper { + /** + * Constructor for FederationPartnerMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_federation_partners', entityClass: FederationPartner::class); + }//end __construct() + + /** + * Find a partner by its UUID. + * + * @param string $id The partner UUID + * + * @return FederationPartner + * + * @throws DoesNotExistException When no row matches + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function findById(string $id): FederationPartner { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * Find a partner by its host, as an OCM signer or a cloud id names it. + * + * @param string $host The lowercase host, with `:port` when not 443 + * + * @return FederationPartner + * + * @throws DoesNotExistException When no partner has that host + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function findByHost(string $host): FederationPartner { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('host', $qb->createNamedParameter($host))); + + return $this->findEntity(query: $qb); + }//end findByHost() + + /** + * All partners, oldest first. + * + * @return FederationPartner[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function findAllPartners(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->orderBy('added_at', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findAllPartners() +}//end class diff --git a/lib/Db/FolderMapper.php b/lib/Db/FolderMapper.php index 0657954a6..7cdfc2abe 100644 --- a/lib/Db/FolderMapper.php +++ b/lib/Db/FolderMapper.php @@ -242,7 +242,7 @@ public function getSubtreeIds(string $folderId): array { * * @return int The number of rows deleted * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteByOwnerUser(string $ownerId): int { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/GroupShare.php b/lib/Db/GroupShare.php index c12a5c1f8..9bd108340 100644 --- a/lib/Db/GroupShare.php +++ b/lib/Db/GroupShare.php @@ -38,6 +38,10 @@ * @method void setCreatedBy(string $createdBy) * @method DateTime|null getCreatedAt() * @method void setCreatedAt(DateTime $createdAt) + * @method bool|null getUseOnly() + * @method void setUseOnly(bool $useOnly) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) */ class GroupShare extends Entity implements JsonSerializable { @@ -69,6 +73,21 @@ class GroupShare extends Entity implements JsonSerializable { */ protected ?DateTime $createdAt = null; + /** + * Whether the recipient may only use the value, not view or copy it + * (sharing-use-only-and-expiring-shares D1). + * + * @var boolean|null + */ + protected ?bool $useOnly = false; + + /** + * When the access this grant gives ends (nullable = no end). + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + /** * The UUID primary key. * @@ -107,6 +126,8 @@ public function __construct() { $this->addType(fieldName: 'groupId', type: 'string'); $this->addType(fieldName: 'createdBy', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'useOnly', type: 'boolean'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); }//end __construct() /** @@ -121,6 +142,8 @@ public function jsonSerialize(): array { 'groupId' => $this->groupId, 'createdBy' => $this->createdBy, 'createdAt' => $this->createdAt?->format('c'), + 'useOnly' => ($this->useOnly === true), + 'expiresAt' => $this->expiresAt?->format('c'), ]; }//end jsonSerialize() }//end class diff --git a/lib/Db/GroupShareMapper.php b/lib/Db/GroupShareMapper.php index 3f16c1edb..723c98649 100644 --- a/lib/Db/GroupShareMapper.php +++ b/lib/Db/GroupShareMapper.php @@ -32,6 +32,8 @@ * @extends QBMapper */ class GroupShareMapper extends QBMapper { + use ExpiringGrantQueries; + /** * Constructor for GroupShareMapper. * diff --git a/lib/Db/PasskeyCredential.php b/lib/Db/PasskeyCredential.php index a9fa493ee..802735cd0 100644 --- a/lib/Db/PasskeyCredential.php +++ b/lib/Db/PasskeyCredential.php @@ -51,6 +51,10 @@ * @method void setTransports(?string $transports) * @method string|null getAaguid() * @method void setAaguid(?string $aaguid) + * @method string getClientKind() + * @method void setClientKind(string $clientKind) + * @method string|null getRpId() + * @method void setRpId(?string $rpId) * @method string getStatus() * @method void setStatus(string $status) * @method DateTime|null getLastUsedAt() @@ -123,6 +127,21 @@ class PasskeyCredential extends Entity implements JsonSerializable { */ protected ?string $aaguid = null; + /** + * Which client owns the credential: web (the web app) or extension. + * + * @var string + */ + protected string $clientKind = 'web'; + + /** + * The WebAuthn relying party id the credential is bound to (the + * extension id for an extension credential; null on older web rows). + * + * @var string|null + */ + protected ?string $rpId = null; + /** * One of: active | stale | revoked. * @@ -187,6 +206,8 @@ public function __construct() { $this->addType(fieldName: 'label', type: 'string'); $this->addType(fieldName: 'transports', type: 'string'); $this->addType(fieldName: 'aaguid', type: 'string'); + $this->addType(fieldName: 'clientKind', type: 'string'); + $this->addType(fieldName: 'rpId', type: 'string'); $this->addType(fieldName: 'status', type: 'string'); $this->addType(fieldName: 'lastUsedAt', type: 'datetime'); $this->addType(fieldName: 'createdAt', type: 'datetime'); @@ -205,6 +226,7 @@ public function jsonSerialize(): array { 'label' => $this->label, 'transports' => $this->transports, 'aaguid' => $this->aaguid, + 'clientKind' => $this->clientKind, 'status' => $this->status, 'lastUsedAt' => $this->lastUsedAt?->format('c'), 'createdAt' => $this->createdAt?->format('c'), diff --git a/lib/Db/RecoveryApproval.php b/lib/Db/RecoveryApproval.php new file mode 100644 index 000000000..b72a545fa --- /dev/null +++ b/lib/Db/RecoveryApproval.php @@ -0,0 +1,124 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One officer's decision on one request; unique per request and officer. + * + * @method string getRequestId() + * @method void setRequestId(string $requestId) + * @method string getOfficerUid() + * @method void setOfficerUid(string $officerUid) + * @method string getDecision() + * @method void setDecision(string $decision) + * @method DateTime|null getDecidedAt() + * @method void setDecidedAt(?DateTime $decidedAt) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class RecoveryApproval extends Entity implements JsonSerializable { + + /** + * The request. + * + * @var string + */ + protected string $requestId = ''; + + /** + * The officer. + * + * @var string + */ + protected string $officerUid = ''; + + /** + * `approve` or `decline`. + * + * @var string + */ + protected string $decision = ''; + + /** + * When. + * + * @var DateTime|null + */ + protected ?DateTime $decidedAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for RecoveryApproval. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'requestId', type: 'string'); + $this->addType(fieldName: 'officerUid', type: 'string'); + $this->addType(fieldName: 'decision', type: 'string'); + $this->addType(fieldName: 'decidedAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the API. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'requestId' => $this->requestId, + 'officerUid' => $this->officerUid, + 'decision' => $this->decision, + 'decidedAt' => $this->decidedAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecoveryApprovalMapper.php b/lib/Db/RecoveryApprovalMapper.php new file mode 100644 index 000000000..ffff2e676 --- /dev/null +++ b/lib/Db/RecoveryApprovalMapper.php @@ -0,0 +1,79 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for RecoveryApproval entities. + * + * @extends QBMapper + */ +class RecoveryApprovalMapper extends QBMapper { + + /** + * Constructor for RecoveryApprovalMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_recovery_approvals', entityClass: RecoveryApproval::class); + }//end __construct() + + /** + * Find a row by id. + * + * @param string $id The id + * + * @return RecoveryApproval + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): RecoveryApproval { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * The decisions on one request. + * + * @param string $requestId The request + * + * @return RecoveryApproval[] + */ + public function findByRequest(string $requestId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('request_id', $qb->createNamedParameter($requestId))); + + return $this->findEntities(query: $qb); + }//end findByRequest() +}//end class diff --git a/lib/Db/RecoveryEnrolment.php b/lib/Db/RecoveryEnrolment.php new file mode 100644 index 000000000..3f6d5e7db --- /dev/null +++ b/lib/Db/RecoveryEnrolment.php @@ -0,0 +1,135 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * A user's suite private key wrapped to the recovery certificate + * (crypto-organisation-account-recovery D1). + * + * @method string getUserId() + * @method void setUserId(string $userId) + * @method string getSuiteId() + * @method void setSuiteId(string $suiteId) + * @method string getRecoveryKeyId() + * @method void setRecoveryKeyId(string $recoveryKeyId) + * @method string getEnvelope() + * @method void setEnvelope(string $envelope) + * @method DateTime|null getEnrolledAt() + * @method void setEnrolledAt(?DateTime $enrolledAt) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class RecoveryEnrolment extends Entity implements JsonSerializable { + + /** + * The enrolled user. + * + * @var string + */ + protected string $userId = ''; + + /** + * The suite whose private key is wrapped. + * + * @var string + */ + protected string $suiteId = ''; + + /** + * The recovery key it is wrapped to. + * + * @var string + */ + protected string $recoveryKeyId = ''; + + /** + * The hybrid envelope JSON. + * + * @var string + */ + protected string $envelope = ''; + + /** + * When the user enrolled. + * + * @var DateTime|null + */ + protected ?DateTime $enrolledAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for RecoveryEnrolment. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'userId', type: 'string'); + $this->addType(fieldName: 'suiteId', type: 'string'); + $this->addType(fieldName: 'recoveryKeyId', type: 'string'); + $this->addType(fieldName: 'envelope', type: 'string'); + $this->addType(fieldName: 'enrolledAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the API. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'userId' => $this->userId, + 'suiteId' => $this->suiteId, + 'recoveryKeyId' => $this->recoveryKeyId, + 'enrolledAt' => $this->enrolledAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecoveryEnrolmentMapper.php b/lib/Db/RecoveryEnrolmentMapper.php new file mode 100644 index 000000000..ede76c876 --- /dev/null +++ b/lib/Db/RecoveryEnrolmentMapper.php @@ -0,0 +1,112 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for RecoveryEnrolment entities. + * + * @extends QBMapper + */ +class RecoveryEnrolmentMapper extends QBMapper { + + /** + * Constructor for RecoveryEnrolmentMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_recovery_enrolments', entityClass: RecoveryEnrolment::class); + }//end __construct() + + /** + * Find a row by id. + * + * @param string $id The id + * + * @return RecoveryEnrolment + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): RecoveryEnrolment { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * A user's enrolments, newest first. + * + * @param string $userId The user + * + * @return RecoveryEnrolment[] + */ + public function findByUser(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->orderBy('enrolled_at', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findByUser() + + /** + * The enrolments of one suite. + * + * @param string $suiteId The suite + * + * @return RecoveryEnrolment[] + */ + public function findBySuite(string $suiteId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('suite_id', $qb->createNamedParameter($suiteId))); + + return $this->findEntities(query: $qb); + }//end findBySuite() + + /** + * The enrolments wrapped to one recovery key. + * + * @param string $recoveryKeyId The recovery key + * + * @return RecoveryEnrolment[] + */ + public function findByKey(string $recoveryKeyId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('recovery_key_id', $qb->createNamedParameter($recoveryKeyId))); + + return $this->findEntities(query: $qb); + }//end findByKey() +}//end class diff --git a/lib/Db/RecoveryKey.php b/lib/Db/RecoveryKey.php new file mode 100644 index 000000000..212527399 --- /dev/null +++ b/lib/Db/RecoveryKey.php @@ -0,0 +1,158 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * An organisation recovery key (crypto-organisation-account-recovery D2): + * only its certificate; the private key exists only wrapped per officer. + * + * @method string getCertificate() + * @method void setCertificate(string $certificate) + * @method string getFingerprint() + * @method void setFingerprint(string $fingerprint) + * @method int getThreshold() + * @method void setThreshold(int $threshold) + * @method string getStatus() + * @method void setStatus(string $status) + * @method string getCreatedBy() + * @method void setCreatedBy(string $createdBy) + * @method DateTime|null getCreatedAt() + * @method void setCreatedAt(?DateTime $createdAt) + * @method DateTime|null getRetiredAt() + * @method void setRetiredAt(?DateTime $retiredAt) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class RecoveryKey extends Entity implements JsonSerializable { + + /** + * The recovery certificate PEM, issued by the instance CA. + * + * @var string + */ + protected string $certificate = ''; + + /** + * SHA-256 of the certificate (hex), for out-of-band checks. + * + * @var string + */ + protected string $fingerprint = ''; + + /** + * Distinct officer approvals a recovery needs. + * + * @var int + */ + protected int $threshold = 0; + + /** + * `active`, `retiring` or `retired`. + * + * @var string + */ + protected string $status = ''; + + /** + * The officer who created it. + * + * @var string + */ + protected string $createdBy = ''; + + /** + * When it was created. + * + * @var DateTime|null + */ + protected ?DateTime $createdAt = null; + + /** + * When it was retired. + * + * @var DateTime|null + */ + protected ?DateTime $retiredAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for RecoveryKey. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'certificate', type: 'string'); + $this->addType(fieldName: 'fingerprint', type: 'string'); + $this->addType(fieldName: 'threshold', type: 'integer'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'createdBy', type: 'string'); + $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'retiredAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the API. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'certificate' => $this->certificate, + 'fingerprint' => $this->fingerprint, + 'threshold' => $this->threshold, + 'status' => $this->status, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt?->format('c'), + 'retiredAt' => $this->retiredAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecoveryKeyMapper.php b/lib/Db/RecoveryKeyMapper.php new file mode 100644 index 000000000..a80bd2db6 --- /dev/null +++ b/lib/Db/RecoveryKeyMapper.php @@ -0,0 +1,80 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for RecoveryKey entities. + * + * @extends QBMapper + */ +class RecoveryKeyMapper extends QBMapper { + + /** + * Constructor for RecoveryKeyMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_recovery_keys', entityClass: RecoveryKey::class); + }//end __construct() + + /** + * Find a row by id. + * + * @param string $id The id + * + * @return RecoveryKey + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): RecoveryKey { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * Recovery keys in one status, newest first. + * + * @param string $status The status + * + * @return RecoveryKey[] + */ + public function findByStatus(string $status): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('status', $qb->createNamedParameter($status))) + ->orderBy('created_at', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findByStatus() +}//end class diff --git a/lib/Db/RecoveryOfficer.php b/lib/Db/RecoveryOfficer.php new file mode 100644 index 000000000..7eb34c1dd --- /dev/null +++ b/lib/Db/RecoveryOfficer.php @@ -0,0 +1,146 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * One officer's copy of a recovery private key, wrapped to that officer's + * suite certificate with the hybrid envelope. Returned only to that officer. + * + * @method string getRecoveryKeyId() + * @method void setRecoveryKeyId(string $recoveryKeyId) + * @method string getOfficerUid() + * @method void setOfficerUid(string $officerUid) + * @method string getOfficerSuiteId() + * @method void setOfficerSuiteId(string $officerSuiteId) + * @method string getWrappedPrivateKey() + * @method void setWrappedPrivateKey(string $wrappedPrivateKey) + * @method string getAddedBy() + * @method void setAddedBy(string $addedBy) + * @method DateTime|null getAddedAt() + * @method void setAddedAt(?DateTime $addedAt) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class RecoveryOfficer extends Entity implements JsonSerializable { + + /** + * The recovery key. + * + * @var string + */ + protected string $recoveryKeyId = ''; + + /** + * The officer. + * + * @var string + */ + protected string $officerUid = ''; + + /** + * The officer suite the copy is wrapped to. + * + * @var string + */ + protected string $officerSuiteId = ''; + + /** + * The wrapped recovery private key (envelope JSON). + * + * @var string + */ + protected string $wrappedPrivateKey = ''; + + /** + * Who stored the copy. + * + * @var string + */ + protected string $addedBy = ''; + + /** + * When it was stored. + * + * @var DateTime|null + */ + protected ?DateTime $addedAt = null; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for RecoveryOfficer. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'recoveryKeyId', type: 'string'); + $this->addType(fieldName: 'officerUid', type: 'string'); + $this->addType(fieldName: 'officerSuiteId', type: 'string'); + $this->addType(fieldName: 'wrappedPrivateKey', type: 'string'); + $this->addType(fieldName: 'addedBy', type: 'string'); + $this->addType(fieldName: 'addedAt', type: 'datetime'); + }//end __construct() + + /** + * Serialize for the API. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'recoveryKeyId' => $this->recoveryKeyId, + 'officerUid' => $this->officerUid, + 'officerSuiteId' => $this->officerSuiteId, + 'addedBy' => $this->addedBy, + 'addedAt' => $this->addedAt?->format('c'), + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecoveryOfficerMapper.php b/lib/Db/RecoveryOfficerMapper.php new file mode 100644 index 000000000..8f5e7ecbc --- /dev/null +++ b/lib/Db/RecoveryOfficerMapper.php @@ -0,0 +1,131 @@ + + * @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\Db; + +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * Mapper for RecoveryOfficer entities. + * + * @extends QBMapper + */ +class RecoveryOfficerMapper extends QBMapper { + + /** + * Constructor for RecoveryOfficerMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_recovery_officers', entityClass: RecoveryOfficer::class); + }//end __construct() + + /** + * Find a row by id. + * + * @param string $id The id + * + * @return RecoveryOfficer + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): RecoveryOfficer { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * Every officer copy of one recovery key. + * + * @param string $recoveryKeyId The recovery key + * + * @return RecoveryOfficer[] + */ + public function findByKey(string $recoveryKeyId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('recovery_key_id', $qb->createNamedParameter($recoveryKeyId))); + + return $this->findEntities(query: $qb); + }//end findByKey() + + /** + * One officer's copy of one recovery key. + * + * @param string $recoveryKeyId The recovery key + * @param string $officerUid The officer + * + * @return RecoveryOfficer + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findForKeyAndOfficer(string $recoveryKeyId, string $officerUid): RecoveryOfficer { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('recovery_key_id', $qb->createNamedParameter($recoveryKeyId))) + ->andWhere($qb->expr()->eq('officer_uid', $qb->createNamedParameter($officerUid))); + + return $this->findEntity(query: $qb); + }//end findForKeyAndOfficer() + + /** + * Every copy one officer holds. + * + * @param string $officerUid The officer + * + * @return RecoveryOfficer[] + */ + public function findByOfficer(string $officerUid): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('officer_uid', $qb->createNamedParameter($officerUid))); + + return $this->findEntities(query: $qb); + }//end findByOfficer() + + /** + * Delete every copy one officer holds. + * + * @param string $officerUid The officer + * + * @return void + */ + public function deleteByOfficer(string $officerUid): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('officer_uid', $qb->createNamedParameter($officerUid))); + + $qb->executeStatement(); + }//end deleteByOfficer() +}//end class diff --git a/lib/Db/RecoveryRequest.php b/lib/Db/RecoveryRequest.php new file mode 100644 index 000000000..998eb626b --- /dev/null +++ b/lib/Db/RecoveryRequest.php @@ -0,0 +1,201 @@ + + * @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\Db; + +use DateTime; +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * A request to recover an account (D3, D4). + * + * @method string getUserId() + * @method void setUserId(string $userId) + * @method string getSuiteId() + * @method void setSuiteId(string $suiteId) + * @method string getEnrolmentId() + * @method void setEnrolmentId(string $enrolmentId) + * @method string getRequestPublicKey() + * @method void setRequestPublicKey(string $requestPublicKey) + * @method string getStatus() + * @method void setStatus(string $status) + * @method DateTime|null getCreatedAt() + * @method void setCreatedAt(?DateTime $createdAt) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) + * @method string|null getHandledBy() + * @method void setHandledBy(?string $handledBy) + * @method string|null getSealedResult() + * @method void setSealedResult(?string $sealedResult) + * @method DateTime|null getFulfilledAt() + * @method void setFulfilledAt(?DateTime $fulfilledAt) + * @method string getPurpose() + * @method void setPurpose(string $purpose) + * + * @SuppressWarnings(PHPMD.LongVariable) Property names mirror the columns. + */ +class RecoveryRequest extends Entity implements JsonSerializable { + + /** + * The user who forgot their master password. + * + * @var string + */ + protected string $userId = ''; + + /** + * The suite to recover. + * + * @var string + */ + protected string $suiteId = ''; + + /** + * The enrolment it uses. + * + * @var string + */ + protected string $enrolmentId = ''; + + /** + * The one-time X25519 public key (base64). + * + * @var string + */ + protected string $requestPublicKey = ''; + + /** + * `pending`, `approved`, `fulfilled`, `declined` or `expired`. + * + * @var string + */ + protected string $status = ''; + + /** + * When it was filed. + * + * @var DateTime|null + */ + protected ?DateTime $createdAt = null; + + /** + * When it expires. + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + + /** + * The officer who posted the sealed result. + * + * @var string|null + */ + protected ?string $handledBy = null; + + /** + * The private key sealed to the request key, until completion. + * + * @var string|null + */ + protected ?string $sealedResult = null; + + /** + * When the user completed it. + * + * @var DateTime|null + */ + protected ?DateTime $fulfilledAt = null; + + /** + * `password` (forgot the master password) or `device` (unlock a new + * device once, crypto-new-device-approval D6). + * + * @var string + */ + protected string $purpose = ''; + + /** + * The UUID primary key. + * + * @var string + */ + public $id = ''; + + /** + * Get the UUID primary key. + * + * @return string + */ + public function getId(): string { + return (string)$this->id; + }//end getId() + + /** + * Set the UUID primary key. + * + * @param string $id The UUID + * + * @return void + */ + public function setId($id): void { + $this->setter(name: 'id', args: [$id]); + }//end setId() + + /** + * Constructor for RecoveryRequest. + * + * @return void + */ + public function __construct() { + $this->addType(fieldName: 'id', type: 'string'); + $this->addType(fieldName: 'userId', type: 'string'); + $this->addType(fieldName: 'suiteId', type: 'string'); + $this->addType(fieldName: 'enrolmentId', type: 'string'); + $this->addType(fieldName: 'requestPublicKey', type: 'string'); + $this->addType(fieldName: 'status', type: 'string'); + $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); + $this->addType(fieldName: 'handledBy', type: 'string'); + $this->addType(fieldName: 'sealedResult', type: 'string'); + $this->addType(fieldName: 'fulfilledAt', type: 'datetime'); + $this->addType(fieldName: 'purpose', type: 'string'); + }//end __construct() + + /** + * Serialize for the API. + * + * @return array + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'userId' => $this->userId, + 'suiteId' => $this->suiteId, + 'enrolmentId' => $this->enrolmentId, + 'requestPublicKey' => $this->requestPublicKey, + 'status' => $this->status, + 'createdAt' => $this->createdAt?->format('c'), + 'expiresAt' => $this->expiresAt?->format('c'), + 'handledBy' => $this->handledBy, + 'fulfilledAt' => $this->fulfilledAt?->format('c'), + 'purpose' => $this->purpose, + ]; + }//end jsonSerialize() +}//end class diff --git a/lib/Db/RecoveryRequestMapper.php b/lib/Db/RecoveryRequestMapper.php new file mode 100644 index 000000000..e2b0a9c32 --- /dev/null +++ b/lib/Db/RecoveryRequestMapper.php @@ -0,0 +1,133 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Db; + +use DateTime; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Mapper for RecoveryRequest entities. + * + * @extends QBMapper + */ +class RecoveryRequestMapper extends QBMapper { + + /** + * Constructor for RecoveryRequestMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_recovery_requests', entityClass: RecoveryRequest::class); + }//end __construct() + + /** + * Find a row by id. + * + * @param string $id The id + * + * @return RecoveryRequest + * + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + */ + public function findById(string $id): RecoveryRequest { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity(query: $qb); + }//end findById() + + /** + * Requests in any of the given statuses, oldest first. + * + * @param array $statuses The statuses + * + * @return RecoveryRequest[] + */ + public function findByStatuses(array $statuses): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('status', $qb->createNamedParameter($statuses, IQueryBuilder::PARAM_STR_ARRAY))) + ->orderBy('created_at', 'ASC'); + + return $this->findEntities(query: $qb); + }//end findByStatuses() + + /** + * A user's requests, newest first. + * + * @param string $userId The user + * + * @return RecoveryRequest[] + */ + public function findByUser(string $userId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->orderBy('created_at', 'DESC'); + + return $this->findEntities(query: $qb); + }//end findByUser() + + /** + * The requests of one suite. + * + * @param string $suiteId The suite + * + * @return RecoveryRequest[] + */ + public function findBySuite(string $suiteId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('suite_id', $qb->createNamedParameter($suiteId))); + + return $this->findEntities(query: $qb); + }//end findBySuite() + + /** + * Open requests past their expiry. + * + * @param array $statuses The open statuses + * @param DateTime $now The current time + * + * @return RecoveryRequest[] + */ + public function findLapsed(array $statuses, DateTime $now): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('status', $qb->createNamedParameter($statuses, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($qb->expr()->lte('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATETIME_MUTABLE))); + + return $this->findEntities(query: $qb); + }//end findLapsed() +}//end class diff --git a/lib/Db/Secret.php b/lib/Db/Secret.php index 3e6e8b62c..53f3aaed0 100644 --- a/lib/Db/Secret.php +++ b/lib/Db/Secret.php @@ -25,7 +25,9 @@ namespace OCA\Keepiq\Db; use DateTime; +use InvalidArgumentException; use JsonSerializable; +use OCA\Keepiq\Exception\ForbiddenException; use OCP\AppFramework\Db\Entity; /** @@ -62,6 +64,24 @@ * @method DateTime|null getTombstonedAt() * @method void setTombstonedAt(?DateTime $tombstonedAt) * @method string|null getTombstoneReason() + * @method DateTime|null getTrashedAt() + * @method void setTrashedAt(?DateTime $trashedAt) + * @method DateTime|null getArchivedAt() + * @method void setArchivedAt(?DateTime $archivedAt) + * @method bool|null getIsFavourite() + * @method void setIsFavourite(bool $isFavourite) + * @method DateTime|null getLastUsedAt() + * @method void setLastUsedAt(?DateTime $lastUsedAt) + * @method bool|null getUseOnly() + * @method void setUseOnly(bool $useOnly) + * @method DateTime|null getAccessExpiresAt() + * @method void setAccessExpiresAt(?DateTime $accessExpiresAt) + * @method bool|null getReadOnly() + * @method void setReadOnly(bool $readOnly) + * @method string|null getFederatedSource() + * @method void setFederatedSource(?string $federatedSource) + * @method string|null getPendingAdditionalFields() + * @method void setPendingAdditionalFields(?string $pendingAdditionalFields) * @method void setTombstoneReason(?string $tombstoneReason) * @method DateTime|null getCreatedAt() * @method void setCreatedAt(DateTime $createdAt) @@ -72,6 +92,31 @@ */ class Secret extends Entity implements JsonSerializable { + /** + * What every share path answers when asked to share a use-only or + * time-limited copy onward (sharing-use-only-and-expiring-shares D4). + * + * @var string + */ + public const ONWARD_SHARE_REFUSAL = 'A use-only or time-limited copy cannot be shared onward'; + + /** + * What every write and share path answers for a copy received from + * another organisation (sharing-federated-recipients task 3.4). + * + * @var string + */ + public const READ_ONLY_REFUSAL = 'A copy from another organisation is read-only'; + + /** + * What the holder of a read-only copy may still change: where it is filed + * (sharing-federated-recipients task 3.5). `baseUpdatedAt` only names the + * version an offline move was made from. + * + * @var string[] + */ + public const FILING_FIELDS = ['folderId', 'baseUpdatedAt']; + /** * The plaintext secret name. * @@ -190,6 +235,85 @@ class Secret extends Entity implements JsonSerializable { */ protected ?DateTime $tombstonedAt = null; + /** + * When the owner moved this secret to the trash (nullable = not trashed). + * + * A trashed secret keeps its ciphertext, attachments and versions until + * it is restored or purged (vault-trash-and-archive D1). + * + * @var DateTime|null + */ + protected ?DateTime $trashedAt = null; + + /** + * When the owner archived this secret (nullable = not archived). + * + * @var DateTime|null + */ + protected ?DateTime $archivedAt = null; + + /** + * Whether the holder of this row starred it (vault-favourites-tags-and-last-used D1). + * + * Per row, so a recipient's copy carries its own star. + * + * @var boolean|null + */ + protected ?bool $isFavourite = false; + + /** + * When the holder last opened the value or filled it from the extension + * (nullable = never). + * + * @var DateTime|null + */ + protected ?DateTime $lastUsedAt = null; + + /** + * Whether this recipient copy is use-only: Keepiq's clients fill it but + * never show or copy its value (sharing-use-only-and-expiring-shares D1). + * Materialised from the grants by ShareRestrictionResolver. + * + * @var boolean|null + */ + protected ?bool $useOnly = false; + + /** + * When the holder's access to this recipient copy ends (nullable = no + * end). Not to be confused with expiresAt, which is credential expiry. + * + * @var DateTime|null + */ + protected ?DateTime $accessExpiresAt = null; + + /** + * Whether this is a copy received from another organisation, which its + * holder may read but never change or pass on + * (sharing-federated-recipients D4, "Remote copies are read-only"). + * + * @var boolean|null + */ + protected ?bool $readOnly = false; + + /** + * The cloud id of the user on a partner instance who shared this copy, + * or null for a secret that did not arrive by federation. + * + * @var string|null + */ + protected ?string $federatedSource = null; + + /** + * Extra-field blobs a secret request filled in that the owner has not + * merged yet: a JSON list of ciphertexts, each encrypted to the owner's + * suite (nullable = none). The filler cannot read the owner's own blob, + * so the owner's browser merges these into it on the next open + * (keepiq#750). + * + * @var string|null + */ + protected ?string $pendingAdditionalFields = null; + /** * The non-personal reason a copy was tombstoned (nullable). * @@ -264,6 +388,15 @@ public function __construct() { $this->addType(fieldName: 'migrationError', type: 'string'); $this->addType(fieldName: 'tombstonedAt', type: 'datetime'); $this->addType(fieldName: 'tombstoneReason', type: 'string'); + $this->addType(fieldName: 'trashedAt', type: 'datetime'); + $this->addType(fieldName: 'archivedAt', type: 'datetime'); + $this->addType(fieldName: 'isFavourite', type: 'boolean'); + $this->addType(fieldName: 'lastUsedAt', type: 'datetime'); + $this->addType(fieldName: 'useOnly', type: 'boolean'); + $this->addType(fieldName: 'accessExpiresAt', type: 'datetime'); + $this->addType(fieldName: 'readOnly', type: 'boolean'); + $this->addType(fieldName: 'federatedSource', type: 'string'); + $this->addType(fieldName: 'pendingAdditionalFields', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); $this->addType(fieldName: 'updatedAt', type: 'datetime'); }//end __construct() @@ -294,6 +427,82 @@ public function holdsNoValues(): bool { && (string)$this->url === ''; }//end holdsNoValues() + /** + * Whether this is a use-only or time-limited recipient copy, which no + * share path may use as a source (sharing-use-only-and-expiring-shares D4). + * + * @return bool + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-an-expiring-copy-cannot-be-shared-onward + */ + public function isRestrictedCopy(): bool { + return $this->useOnly === true || $this->accessExpiresAt !== null || $this->readOnly === true; + }//end isRestrictedCopy() + + /** + * Refuse any change or onward share of a copy received from another + * organisation (sharing-federated-recipients task 3.4). + * + * @return void + * + * @throws ForbiddenException When it is a read-only federated copy + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-remote-copies-are-read-only + */ + public function assertNotReadOnly(): void { + if ($this->readOnly === true) { + throw new ForbiddenException(message: self::READ_ONLY_REFUSAL); + } + }//end assertNotReadOnly() + + /** + * Refuse this secret as the source of a share when it is a use-only or + * time-limited copy (sharing-use-only-and-expiring-shares D4). + * + * @return void + * + * @throws InvalidArgumentException When it is a restricted copy + * @throws ForbiddenException When it is a read-only copy from another organisation + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce + */ + public function assertOnwardShareable(): void { + $this->assertNotReadOnly(); + if ($this->isRestrictedCopy() === true) { + throw new InvalidArgumentException(self::ONWARD_SHARE_REFUSAL); + } + }//end assertOnwardShareable() + + /** + * Refuse an edit of a use-only copy by its holder + * (sharing-use-only-and-expiring-shares D4), and any change of a + * read-only copy from another organisation except filing it in a folder + * (sharing-federated-recipients task 3.5). + * + * @param string[]|null $fields The fields the edit changes; null for an unspecified edit + * + * @return self The same secret, for chaining after a load + * + * @throws ForbiddenException When it is a use-only copy, or a read-only copy and more than its folder changes + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-files-his-copy-in-a-folder + */ + public function assertEditableByHolder(?array $fields = null): self { + $filingOnly = $fields !== null + && in_array('folderId', $fields, true) === true + && array_diff($fields, self::FILING_FIELDS) === []; + if ($filingOnly === false) { + $this->assertNotReadOnly(); + } + + if ($this->useOnly === true) { + throw new ForbiddenException(message: 'A use-only copy cannot be changed'); + } + + return $this; + }//end assertEditableByHolder() + /** * Serialize the entity to an array for the API, including encrypted blobs. * @@ -320,9 +529,74 @@ public function jsonSerialize(): array { 'possiblyCompromisedAt' => $this->possiblyCompromisedAt?->format('c'), 'tombstonedAt' => $this->tombstonedAt?->format('c'), 'tombstoneReason' => $this->tombstoneReason, + 'trashedAt' => $this->trashedAt?->format('c'), + 'archivedAt' => $this->archivedAt?->format('c'), + 'favourite' => ($this->isFavourite === true), + 'lastUsedAt' => $this->lastUsedAt?->format('c'), + 'useOnly' => ($this->useOnly === true), + 'accessExpiresAt' => $this->accessExpiresAt?->format('c'), + 'readOnly' => ($this->readOnly === true), + 'federatedSource' => $this->federatedSource, + 'pendingAdditionalFields' => $this->pendingAdditionalFieldList(), ]; }//end jsonSerialize() + /** + * The pending extra-field ciphertexts as a list (empty when none). + * + * @return list + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + public function pendingAdditionalFieldList(): array { + if ($this->pendingAdditionalFields === null || $this->pendingAdditionalFields === '') { + return []; + } + + $decoded = json_decode($this->pendingAdditionalFields, true); + if (is_array($decoded) === false) { + return []; + } + + return array_values(array_filter($decoded, 'is_string')); + }//end pendingAdditionalFieldList() + + /** + * Store the pending extra-field ciphertexts (null when the list is empty). + * + * @param list $ciphertexts The pending ciphertexts, oldest first + * + * @return void + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + public function setPendingAdditionalFieldList(array $ciphertexts): void { + if ($ciphertexts === []) { + $this->setPendingAdditionalFields(null); + return; + } + + $this->setPendingAdditionalFields(json_encode(array_values($ciphertexts))); + }//end setPendingAdditionalFieldList() + + /** + * Drop the oldest $count pending blobs: the ones the owner's client read + * and merged into the blob it just wrote (keepiq#750). + * + * @param int $count How many pending blobs were merged + * + * @return void + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + public function dropMergedPending(int $count): void { + if ($count <= 0) { + return; + } + + $this->setPendingAdditionalFieldList(array_slice($this->pendingAdditionalFieldList(), $count)); + }//end dropMergedPending() + /** * Serialize only plaintext metadata, omitting encrypted blobs. * @@ -353,6 +627,14 @@ public function jsonSerializeBlocked(string $blockedReason): array { 'possiblyCompromisedAt' => $this->possiblyCompromisedAt?->format('c'), 'migrationError' => $this->migrationError, 'unrecoverable' => ($this->migrationError !== null), + 'trashedAt' => $this->trashedAt?->format('c'), + 'archivedAt' => $this->archivedAt?->format('c'), + 'favourite' => ($this->isFavourite === true), + 'lastUsedAt' => $this->lastUsedAt?->format('c'), + 'useOnly' => ($this->useOnly === true), + 'accessExpiresAt' => $this->accessExpiresAt?->format('c'), + 'readOnly' => ($this->readOnly === true), + 'federatedSource' => $this->federatedSource, 'createdAt' => $this->createdAt?->format('c'), 'updatedAt' => $this->updatedAt?->format('c'), ]; diff --git a/lib/Db/SecretListOrganisation.php b/lib/Db/SecretListOrganisation.php new file mode 100644 index 000000000..016f181e8 --- /dev/null +++ b/lib/Db/SecretListOrganisation.php @@ -0,0 +1,148 @@ + + * @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\Db; + +use OCP\DB\QueryBuilder\IQueryBuilder; + +/** + * Narrows and orders a query on keepiq_secrets for one holder. + */ +class SecretListOrganisation { + /** + * The order term that puts rows never used after used ones, in either + * direction and on every database (their NULL ordering differs). + * + * @var string + */ + public const NEVER_USED_LAST = 'CASE WHEN last_used_at IS NULL THEN 1 ELSE 0 END'; + + /** + * The filters a request means: `favourite` => true and/or `tag` => the + * normalised tag. An unset, false or empty filter adds nothing. + * + * @param bool|null $favourite Only starred rows when true + * @param string|null $tag Only rows carrying this tag + * + * @return array{favourite?: true, tag?: string} + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function conditions(?bool $favourite, ?string $tag): array { + $conditions = []; + if ($favourite === true) { + $conditions['favourite'] = true; + } + + $tag = mb_strtolower(trim((string)$tag)); + if ($tag !== '') { + $conditions['tag'] = $tag; + } + + return $conditions; + }//end conditions() + + /** + * Apply the filters to a query on keepiq_secrets of one holder. + * + * @param IQueryBuilder $qb The query to narrow + * @param string $ownerId The holder whose tags count + * @param bool|null $favourite Only starred rows when true + * @param string|null $tag Only rows carrying this tag + * + * @return void + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function apply(IQueryBuilder $qb, string $ownerId, ?bool $favourite, ?string $tag): void { + $conditions = $this->conditions(favourite: $favourite, tag: $tag); + if (isset($conditions['favourite']) === true) { + $qb->andWhere($qb->expr()->eq('is_favourite', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))); + } + + if (isset($conditions['tag']) === true) { + $sub = $qb->getConnection()->getQueryBuilder(); + $sub->select('secret_id') + ->from('keepiq_secret_tags') + ->where($sub->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))) + ->andWhere($sub->expr()->eq('tag', $qb->createNamedParameter($conditions['tag']))); + $qb->andWhere($qb->expr()->in('id', $qb->createFunction($sub->getSQL()))); + } + }//end apply() + + /** + * The order terms for a sort column: last used puts never-used rows + * last; every column but name breaks ties by name. + * + * @param string $column An allow-listed sort column + * @param string $direction ASC or DESC + * + * @return list + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + public function orderTerms(string $column, string $direction): array { + if ($column === 'name') { + return [['name', $direction]]; + } + + $terms = []; + if ($column === 'last_used_at') { + $terms[] = [self::NEVER_USED_LAST, 'ASC']; + } + + $terms[] = [$column, $direction]; + $terms[] = ['name', 'ASC']; + + return $terms; + }//end orderTerms() + + /** + * Apply the order terms to a query. + * + * @param IQueryBuilder $qb The query to order + * @param string $column An allow-listed sort column + * @param string $direction ASC or DESC + * + * @return void + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + public function order(IQueryBuilder $qb, string $column, string $direction): void { + $first = true; + foreach ($this->orderTerms(column: $column, direction: $direction) as [$term, $dir]) { + $sort = $term; + if ($term === self::NEVER_USED_LAST) { + $sort = $qb->createFunction($term); + } + + if ($first === true) { + $qb->orderBy($sort, $dir); + $first = false; + continue; + } + + $qb->addOrderBy($sort, $dir); + } + }//end order() +}//end class diff --git a/lib/Db/SecretMapper.php b/lib/Db/SecretMapper.php index 05bbb8bb3..770ea7f73 100644 --- a/lib/Db/SecretMapper.php +++ b/lib/Db/SecretMapper.php @@ -38,8 +38,45 @@ * focused query the service layer composes (find/count/search/cascade); * splitting the mapper would scatter the secrets table's access in one * place across several classes for no benefit. + * @SuppressWarnings(PHPMD.TooManyMethods) Same reason: one focused query per + * method; the favourite and last-used writes belong with the table. */ class SecretMapper extends QBMapper { + /** + * Neither trashed nor archived: the everyday vault. + * + * @var string + */ + public const STATE_LIVE = 'live'; + + /** + * In the trash, waiting for restore or purge. + * + * @var string + */ + public const STATE_TRASHED = 'trashed'; + + /** + * Archived and not trashed. + * + * @var string + */ + public const STATE_ARCHIVED = 'archived'; + + /** + * Everything that is not trashed (live and archived): what an export carries. + * + * @var string + */ + public const STATE_KEPT = 'kept'; + + /** + * The states a list request may name. + * + * @var string[] + */ + public const LIST_STATES = [self::STATE_LIVE, self::STATE_TRASHED, self::STATE_ARCHIVED, self::STATE_KEPT]; + /** * The columns a list may be sorted by (allow-list to prevent injection). * @@ -50,6 +87,7 @@ class SecretMapper extends QBMapper { 'url', 'created_at', 'updated_at', + 'last_used_at', ]; /** @@ -97,6 +135,64 @@ private function resolveSortColumn(?string $sort): string { return 'name'; }//end resolveSortColumn() + /** + * Leave out every recipient copy whose access has ended + * (sharing-use-only-and-expiring-shares D5): the copy stops being + * served at its end date, not at the next run of the expiry job. + * + * @param IQueryBuilder $qb The query to narrow + * + * @return void + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-the-server-stops-serving-an-expired-copy-at-its-end-date + */ + private function excludeAccessExpired(IQueryBuilder $qb): void { + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('access_expires_at'), + $qb->expr()->gt( + 'access_expires_at', + $qb->createNamedParameter(new DateTime(), IQueryBuilder::PARAM_DATETIME_MUTABLE) + ) + ) + ); + }//end excludeAccessExpired() + + /** + * The recipient copies whose access ends in (from, to]. A null `from` + * lists every copy whose access ended at or before `to`: the expiry + * job's clean-up set. A window a day ahead gives the copies to warn. + * + * @param DateTime|null $from Exclusive lower bound (null = none) + * @param DateTime $to Inclusive upper bound + * + * @return Secret[] + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + public function findAccessEndingBetween(?DateTime $from, DateTime $to): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->isNotNull('access_expires_at')) + ->andWhere( + $qb->expr()->lte( + 'access_expires_at', + $qb->createNamedParameter($to, IQueryBuilder::PARAM_DATETIME_MUTABLE) + ) + ); + if ($from !== null) { + $qb->andWhere( + $qb->expr()->gt( + 'access_expires_at', + $qb->createNamedParameter($from, IQueryBuilder::PARAM_DATETIME_MUTABLE) + ) + ); + } + + return $this->findEntities(query: $qb); + }//end findAccessEndingBetween() + /** * Find secrets owned by an owner, with optional folder filter, sort, and * pagination. @@ -109,8 +205,19 @@ private function resolveSortColumn(?string $sort): string { * @param int $limit Maximum rows * @param int $offset Row offset * @param string|null $typeId Filter by secret-type ID (null = all types) + * @param string|null $state One of the STATE_* constants; null = every row, + * trashed and archived included (GDPR, account + * deletion and key rotation need them all) + * @param bool|null $favourite Only the holder's starred rows when true + * @param string|null $tag Only rows the holder tagged with this tag * * @return Secret[] + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each argument is one optional + * filter of the paged list; a parameter object would only rename them. */ public function findByOwner( string $ownerType, @@ -121,6 +228,9 @@ public function findByOwner( int $limit = 1000, int $offset = 0, ?string $typeId = null, + ?string $state = null, + ?bool $favourite = null, + ?string $tag = null, ): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -136,13 +246,18 @@ public function findByOwner( $qb->andWhere($qb->expr()->eq('type_id', $qb->createNamedParameter($typeId))); } + (new SecretStateFilter())->apply(qb: $qb, state: $state); + $this->excludeAccessExpired(qb: $qb); + $organisation = new SecretListOrganisation(); + $organisation->apply(qb: $qb, ownerId: $ownerId, favourite: $favourite, tag: $tag); + $dir = 'ASC'; if (strtolower($direction) === 'desc') { $dir = 'DESC'; } - $qb->orderBy($this->resolveSortColumn(sort: $sort), $dir) - ->setMaxResults($limit) + $organisation->order(qb: $qb, column: $this->resolveSortColumn(sort: $sort), direction: $dir); + $qb->setMaxResults($limit) ->setFirstResult($offset); return $this->findEntities(query: $qb); @@ -255,14 +370,23 @@ public function findByOwnerUpdatedSince( * @param string $ownerId The owner ID * @param string|null $folderId Filter by folder ID (null = no folder filter) * @param string|null $typeId Filter by secret-type ID (null = all types) + * @param string|null $state One of the STATE_* constants; null = every row + * @param bool|null $favourite Only the holder's starred rows when true + * @param string|null $tag Only rows the holder tagged with this tag * * @return int + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder */ public function countByOwner( string $ownerType, string $ownerId, ?string $folderId = null, ?string $typeId = null, + ?string $state = null, + ?bool $favourite = null, + ?string $tag = null, ): int { $qb = $this->db->getQueryBuilder(); $qb->select($qb->func()->count('*', 'cnt')) @@ -278,6 +402,10 @@ public function countByOwner( $qb->andWhere($qb->expr()->eq('type_id', $qb->createNamedParameter($typeId))); } + (new SecretStateFilter())->apply(qb: $qb, state: $state); + $this->excludeAccessExpired(qb: $qb); + (new SecretListOrganisation())->apply(qb: $qb, ownerId: $ownerId, favourite: $favourite, tag: $tag); + $result = $qb->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -407,7 +535,10 @@ public function searchByNameOrUrl(string $ownerType, string $ownerId, string $te $qb->expr()->iLike('url', $qb->createNamedParameter($like)) ) ) + ->andWhere($qb->expr()->isNull('trashed_at')) + ->andWhere($qb->expr()->isNull('archived_at')) ->setMaxResults(max(1, $limit)); + $this->excludeAccessExpired(qb: $qb); return $this->findEntities(query: $qb); }//end searchByNameOrUrl() @@ -436,8 +567,11 @@ public function findForUnifiedSearch(string $userId, string $term, int $limit): $qb->expr()->iLike('url', $qb->createNamedParameter($like)) ) ) + ->andWhere($qb->expr()->isNull('trashed_at')) + ->andWhere($qb->expr()->isNull('archived_at')) ->orderBy('name', 'ASC') ->setMaxResults($limit); + $this->excludeAccessExpired(qb: $qb); return $this->findEntities(query: $qb); }//end findForUnifiedSearch() @@ -501,7 +635,7 @@ public function reassignType(string $oldTypeId, string $newTypeId): void { * * @return int The number of rows deleted * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteByOwnerUser(string $ownerId): int { $qb = $this->db->getQueryBuilder(); @@ -512,6 +646,25 @@ public function deleteByOwnerUser(string $ownerId): int { return $qb->executeStatement(); }//end deleteByOwnerUser() + /** + * Delete every secret attributed to an application + * (application-mgmt "Delete Application" cascade). Idempotent. + * + * @param string $applicationId The application ID + * + * @return int The number of rows deleted + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application + */ + public function deleteByOwnerApplication(string $applicationId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter('application'))) + ->andWhere($qb->expr()->eq('owner_id', $qb->createNamedParameter($applicationId))); + + return $qb->executeStatement(); + }//end deleteByOwnerApplication() + /** * Mark a recipient copy as a tombstoned, detached share-copy. * @@ -524,7 +677,7 @@ public function deleteByOwnerUser(string $ownerId): int { * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function tombstone(string $secretId, string $reason): void { $qb = $this->db->getQueryBuilder(); @@ -544,7 +697,7 @@ public function tombstone(string $secretId, string $reason): void { * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function reassignOwner(string $secretId, string $newOwnerId): void { $qb = $this->db->getQueryBuilder(); @@ -728,4 +881,89 @@ public function findBySuiteForOwner( return $this->findEntities(query: $qb); }//end findBySuiteForOwner() + /** + * Star or unstar one holder's row. Keyed by owner as well as id, so a + * row the user does not hold is never touched. + * + * @param string $id The row + * @param string $ownerId The holder (a user) + * @param bool $favourite The new star + * + * @return int The number of rows changed (0 or 1) + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + */ + public function setFavourite(string $id, string $ownerId, bool $favourite): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('is_favourite', $qb->createNamedParameter($favourite, IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('owner_type', $qb->createNamedParameter('user'))) + ->andWhere($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))); + + return $qb->executeStatement(); + }//end setFavourite() + + /** + * Record that the holder opened or filled a row. Only `last_used_at` is + * written: `updated_at` stays, so a sync lock or a "changed" sort is not + * disturbed by a read. + * + * @param string $id The row + * @param string $ownerId The holder (a user) + * @param DateTime $usedAt When it was used + * + * @return int The number of rows changed (0 or 1) + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + public function markUsed(string $id, string $ownerId, DateTime $usedAt): int { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->getTableName()) + ->set('last_used_at', $qb->createNamedParameter($usedAt->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))) + ->andWhere($qb->expr()->eq('owner_type', $qb->createNamedParameter('user'))) + ->andWhere($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))); + + return $qb->executeStatement(); + }//end markUsed() + + /** + * Count the live secret rows each user owns, in one grouped query. + * + * Tombstoned rows are left out. Users without a row are absent from the + * result; the caller reads them as zero. + * + * @param string[] $ownerIds The user IDs to count for + * + * @return array Row count keyed by user ID + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + public function countByUserOwners(array $ownerIds): array { + if ($ownerIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('owner_id') + ->selectAlias($qb->func()->count('id'), 'row_count') + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter('user'))) + ->andWhere( + $qb->expr()->in('owner_id', $qb->createNamedParameter($ownerIds, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->andWhere($qb->expr()->isNull('tombstoned_at')) + ->groupBy('owner_id'); + + $counts = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $counts[(string)$row['owner_id']] = (int)$row['row_count']; + } + + $result->closeCursor(); + + return $counts; + }//end countByUserOwners() }//end class diff --git a/lib/Db/SecretRequestMapper.php b/lib/Db/SecretRequestMapper.php index 5b9009143..bbd597c4a 100644 --- a/lib/Db/SecretRequestMapper.php +++ b/lib/Db/SecretRequestMapper.php @@ -328,7 +328,7 @@ public function unlockAndUpdateSuite(string $oldEncryptionSuiteId, string $newEn * * @return int The number of rows deleted * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteByCreatedBy(string $userId): int { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/SecretStateFilter.php b/lib/Db/SecretStateFilter.php new file mode 100644 index 000000000..fa0e67ab0 --- /dev/null +++ b/lib/Db/SecretStateFilter.php @@ -0,0 +1,78 @@ + + * @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\Db; + +use InvalidArgumentException; +use OCP\DB\QueryBuilder\IQueryBuilder; + +/** + * Applies a SecretMapper::STATE_* restriction to a query on keepiq_secrets. + */ +class SecretStateFilter { + /** + * Restrict a query to one state; null leaves it unrestricted. + * + * @param IQueryBuilder $qb The query to narrow + * @param string|null $state One of the SecretMapper::STATE_* constants, or null + * + * @return void + * + * @throws InvalidArgumentException When the state is unknown + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + public function apply(IQueryBuilder $qb, ?string $state): void { + foreach ($this->conditions(state: $state) as $column => $isSet) { + if ($isSet === true) { + $qb->andWhere($qb->expr()->isNotNull($column)); + continue; + } + + $qb->andWhere($qb->expr()->isNull($column)); + } + }//end apply() + + /** + * The conditions a state means: column => true (must be set) or false + * (must be null). Null state means no condition. + * + * @param string|null $state One of the SecretMapper::STATE_* constants, or null + * + * @return array + * + * @throws InvalidArgumentException When the state is unknown + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + public function conditions(?string $state): array { + return match ($state) { + null => [], + SecretMapper::STATE_LIVE => ['trashed_at' => false, 'archived_at' => false], + SecretMapper::STATE_TRASHED => ['trashed_at' => true], + SecretMapper::STATE_ARCHIVED => ['trashed_at' => false, 'archived_at' => true], + SecretMapper::STATE_KEPT => ['trashed_at' => false], + default => throw new InvalidArgumentException('Unknown secret state: '.$state), + }; + }//end conditions() +}//end class diff --git a/lib/Db/SecretTag.php b/lib/Db/SecretTag.php new file mode 100644 index 000000000..31f22c8af --- /dev/null +++ b/lib/Db/SecretTag.php @@ -0,0 +1,71 @@ + + * @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\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * A tag on a secret row. + * + * @method string getSecretId() + * @method void setSecretId(string $secretId) + * @method string getOwnerId() + * @method void setOwnerId(string $ownerId) + * @method string getTag() + * @method void setTag(string $tag) + */ +class SecretTag extends Entity { + /** + * The tagged secret row. + * + * @var string + */ + protected string $secretId = ''; + + /** + * The holder of that row, who set the tag. + * + * @var string + */ + protected string $ownerId = ''; + + /** + * The normalised tag: trimmed, lowercase, at most 32 characters. + * + * @var string + */ + protected string $tag = ''; + + /** + * Register the column types. + * + * @return void + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function __construct() { + $this->addType(fieldName: 'secretId', type: 'string'); + $this->addType(fieldName: 'ownerId', type: 'string'); + $this->addType(fieldName: 'tag', type: 'string'); + }//end __construct() +}//end class diff --git a/lib/Db/SecretTagMapper.php b/lib/Db/SecretTagMapper.php new file mode 100644 index 000000000..7a7beb095 --- /dev/null +++ b/lib/Db/SecretTagMapper.php @@ -0,0 +1,181 @@ + + * @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\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use Throwable; + +/** + * Mapper for SecretTag entities. + * + * @extends QBMapper + */ +class SecretTagMapper extends QBMapper { + /** + * Constructor for SecretTagMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_secret_tags', entityClass: SecretTag::class); + }//end __construct() + + /** + * The tags on a set of a holder's rows, keyed by secret id. Rows without + * tags are absent from the result. + * + * @param string $ownerId The holder + * @param string[] $secretIds The rows + * + * @return array> + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function findTagsBySecretIds(string $ownerId, array $secretIds): array { + $tags = []; + foreach (array_chunk(array_values(array_unique($secretIds)), 500) as $chunk) { + $qb = $this->db->getQueryBuilder(); + $qb->select('secret_id', 'tag') + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))) + ->andWhere($qb->expr()->in('secret_id', $qb->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY))) + ->orderBy('tag', 'ASC'); + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $tags[(string)$row['secret_id']][] = (string)$row['tag']; + } + + $result->closeCursor(); + } + + return $tags; + }//end findTagsBySecretIds() + + /** + * Replace the tags on one of the holder's rows, in one transaction. + * + * @param string $secretId The row + * @param string $ownerId The holder + * @param list $tags The normalised tags + * + * @return void + * + * @throws Throwable When the write fails; nothing is changed then + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function replaceForSecret(string $secretId, string $ownerId, array $tags): void { + $this->db->beginTransaction(); + try { + $this->deleteBySecret(secretId: $secretId); + foreach ($tags as $tag) { + $row = new SecretTag(); + $row->setSecretId($secretId); + $row->setOwnerId($ownerId); + $row->setTag($tag); + $this->insert(entity: $row); + } + + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + } + }//end replaceForSecret() + + /** + * The holder's tags with the number of their live (not trashed, not + * archived) secrets carrying each, alphabetically. + * + * @param string $ownerId The holder + * + * @return list + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function countByOwner(string $ownerId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('t.tag') + ->selectAlias($qb->func()->count('t.id'), 'cnt') + ->from($this->getTableName(), 't') + ->innerJoin('t', 'keepiq_secrets', 's', $qb->expr()->eq('s.id', 't.secret_id')) + ->where($qb->expr()->eq('t.owner_id', $qb->createNamedParameter($ownerId))) + ->andWhere($qb->expr()->eq('s.owner_type', $qb->createNamedParameter('user'))) + ->andWhere($qb->expr()->eq('s.owner_id', $qb->createNamedParameter($ownerId))) + ->andWhere($qb->expr()->isNull('s.trashed_at')) + ->andWhere($qb->expr()->isNull('s.archived_at')) + ->groupBy('t.tag') + ->orderBy('t.tag', 'ASC'); + + $tags = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $tags[] = ['tag' => (string)$row['tag'], 'count' => (int)$row['cnt']]; + } + + $result->closeCursor(); + + return $tags; + }//end countByOwner() + + /** + * Delete every tag on one row (the row is deleted for good). + * + * @param string $secretId The row + * + * @return int The number of tags removed + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function deleteBySecret(string $secretId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('secret_id', $qb->createNamedParameter($secretId))); + + return $qb->executeStatement(); + }//end deleteBySecret() + + /** + * Delete every tag a holder set (account deletion). + * + * @param string $ownerId The holder + * + * @return int The number of tags removed + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function deleteByOwner(string $ownerId): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId))); + + return $qb->executeStatement(); + }//end deleteByOwner() +}//end class diff --git a/lib/Db/SecretTrashMapper.php b/lib/Db/SecretTrashMapper.php new file mode 100644 index 000000000..3f73e29c9 --- /dev/null +++ b/lib/Db/SecretTrashMapper.php @@ -0,0 +1,73 @@ + + * @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\Db; + +use DateTime; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Finds trashed secrets past a cutoff. + * + * @extends QBMapper + */ +class SecretTrashMapper extends QBMapper { + /** + * Constructor for SecretTrashMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_secrets', entityClass: Secret::class); + }//end __construct() + + /** + * Find user-owned secrets trashed before a cutoff, oldest first. + * + * @param DateTime $cutoff Rows trashed before this instant + * @param int $limit Maximum rows per batch + * + * @return Secret[] + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + public function findTrashedBefore(DateTime $cutoff, int $limit = 500): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('owner_type', $qb->createNamedParameter('user'))) + ->andWhere($qb->expr()->isNotNull('trashed_at')) + ->andWhere( + $qb->expr()->lt('trashed_at', $qb->createNamedParameter($cutoff, IQueryBuilder::PARAM_DATETIME_MUTABLE)) + ) + ->orderBy('trashed_at', 'ASC') + ->setMaxResults(max(1, $limit)); + + return $this->findEntities(query: $qb); + }//end findTrashedBefore() +}//end class diff --git a/lib/Db/SecretType.php b/lib/Db/SecretType.php index e753273b0..235a2c8f4 100644 --- a/lib/Db/SecretType.php +++ b/lib/Db/SecretType.php @@ -38,6 +38,8 @@ * @method void setScope(string $scope) * @method string|null getOwnerId() * @method void setOwnerId(?string $ownerId) + * @method string|null getFields() + * @method void setFields(?string $fields) * @method DateTime|null getCreatedAt() * @method void setCreatedAt(DateTime $createdAt) */ @@ -71,6 +73,14 @@ class SecretType extends Entity implements JsonSerializable { */ protected ?string $ownerId = null; + /** + * The fields an item of this type carries, as JSON (admin-18); null for + * a type without a field list, such as the built-in types. + * + * @var string|null + */ + protected ?string $fields = null; + /** * When the type was created. * @@ -116,9 +126,48 @@ public function __construct() { $this->addType(fieldName: 'label', type: 'string'); $this->addType(fieldName: 'scope', type: 'string'); $this->addType(fieldName: 'ownerId', type: 'string'); + $this->addType(fieldName: 'fields', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); }//end __construct() + /** + * The field list, decoded; an empty list when the type has none. + * + * @return list + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ + public function getFieldList(): array { + if ($this->fields === null || $this->fields === '') { + return []; + } + + $decoded = json_decode($this->fields, true); + if (is_array($decoded) === false) { + return []; + } + + return $decoded; + }//end getFieldList() + + /** + * Store a field list; an empty list is stored as null. + * + * @param list $fields The fields + * + * @return void + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ + public function setFieldList(array $fields): void { + $json = null; + if ($fields !== []) { + $json = json_encode(array_values($fields), JSON_THROW_ON_ERROR); + } + + $this->setFields($json); + }//end setFieldList() + /** * Serialize the entity to an array for the API. * @@ -131,6 +180,7 @@ public function jsonSerialize(): array { 'label' => $this->label, 'scope' => $this->scope, 'ownerId' => $this->ownerId, + 'fields' => $this->getFieldList(), 'createdAt' => $this->createdAt?->format('c'), ]; }//end jsonSerialize() diff --git a/lib/Db/ShareTarget.php b/lib/Db/ShareTarget.php index 9da75439c..4c4b4293a 100644 --- a/lib/Db/ShareTarget.php +++ b/lib/Db/ShareTarget.php @@ -44,6 +44,10 @@ * @method void setCreatedBy(string $createdBy) * @method DateTime|null getCreatedAt() * @method void setCreatedAt(DateTime $createdAt) + * @method bool|null getUseOnly() + * @method void setUseOnly(bool $useOnly) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) */ class ShareTarget extends Entity implements JsonSerializable { @@ -96,6 +100,21 @@ class ShareTarget extends Entity implements JsonSerializable { */ protected ?DateTime $createdAt = null; + /** + * Whether the recipient may only use the value, not view or copy it + * (sharing-use-only-and-expiring-shares D1). + * + * @var boolean|null + */ + protected ?bool $useOnly = false; + + /** + * When the access this grant gives ends (nullable = no end). + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + /** * The UUID primary key. * @@ -137,6 +156,8 @@ public function __construct() { $this->addType(fieldName: 'teamFolderId', type: 'string'); $this->addType(fieldName: 'createdBy', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); + $this->addType(fieldName: 'useOnly', type: 'boolean'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); }//end __construct() /** @@ -154,6 +175,8 @@ public function jsonSerialize(): array { 'teamFolderId' => $this->teamFolderId, 'createdBy' => $this->createdBy, 'createdAt' => $this->createdAt?->format('c'), + 'useOnly' => ($this->useOnly === true), + 'expiresAt' => $this->expiresAt?->format('c'), ]; }//end jsonSerialize() }//end class diff --git a/lib/Db/ShareTargetMapper.php b/lib/Db/ShareTargetMapper.php index 53daa6746..7688538e2 100644 --- a/lib/Db/ShareTargetMapper.php +++ b/lib/Db/ShareTargetMapper.php @@ -32,6 +32,8 @@ * @extends QBMapper */ class ShareTargetMapper extends QBMapper { + use ExpiringGrantQueries; + /** * Constructor for ShareTargetMapper. * @@ -135,6 +137,37 @@ public function findBySourceSecretAndTargetUser(string $sourceSecretId, string $ return $this->findEntity(query: $qb); }//end findBySourceSecretAndTargetUser() + /** + * Whether $createdBy already holds a DIRECT share (no group share, no team + * folder) of any secret with $targetUserId. Drives the keepiq#818 proof + * exemption: a share to a recipient the caller already shares with directly + * needs no vault-key proof, a share to anyone else does. Group and team + * folder rows do not count, because those are created without a proof. + * + * @param string $createdBy The sharing user + * @param string $targetUserId The recipient + * + * @return bool + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + public function hasDirectShareBetween(string $createdBy, string $targetUserId): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->getTableName()) + ->where($qb->expr()->eq('created_by', $qb->createNamedParameter($createdBy))) + ->andWhere($qb->expr()->eq('target_user_id', $qb->createNamedParameter($targetUserId))) + ->andWhere($qb->expr()->isNull('group_share_id')) + ->andWhere($qb->expr()->isNull('team_folder_id')) + ->setMaxResults(1); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return $row !== false; + }//end hasDirectShareBetween() + /** * Delete every share target where the recipient is the given user. * @@ -156,6 +189,36 @@ public function deleteByTargetUser(string $targetUserId): void { $qb->executeStatement(); }//end deleteByTargetUser() + /** + * Delete a recipient's ShareTargets whose copy is sealed under one suite. + * + * The suite-revocation sweep. Scoped to the revoked suite on purpose: a + * compromise force-revoke during a migration revokes two suites of the same + * user, one after the other. An unscoped sweep on the first revoke also + * deleted the rows for the copies on the second suite, so the compromise + * cascade could no longer find their sources' owners (keepiq#864). + * + * @param string $targetUserId The recipient Nextcloud user ID + * @param string $suiteId The revoked suite the copies are sealed under + * + * @return void + * + * @spec openspec/specs/user-sharing/spec.md#requirement-encryptionsuite-compromise-shared-copy-migration-and-owner-notification + */ + public function deleteByTargetUserAndSuite(string $targetUserId, string $suiteId): void { + $qb = $this->db->getQueryBuilder(); + $sub = $this->db->getQueryBuilder(); + $sub->select('id') + ->from('keepiq_secrets') + ->where($sub->expr()->eq('encryption_suite_id', $qb->createNamedParameter($suiteId))); + + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('target_user_id', $qb->createNamedParameter($targetUserId))) + ->andWhere($qb->expr()->in('secret_id', $qb->createFunction($sub->getSQL()))); + + $qb->executeStatement(); + }//end deleteByTargetUserAndSuite() + /** * Reverse-lookup a ShareTarget by the recipient Secret copy ID. * diff --git a/lib/Db/SiemSink.php b/lib/Db/SiemSink.php index bca9d147a..5bd99f888 100644 --- a/lib/Db/SiemSink.php +++ b/lib/Db/SiemSink.php @@ -43,6 +43,12 @@ * @method void setHmacSecretEnc(?string $hmacSecretEnc) * @method string|null getCategoryFilter() * @method void setCategoryFilter(?string $categoryFilter) + * @method string getFormat() + * @method void setFormat(string $format) + * @method string|null getCredentialEnc() + * @method void setCredentialEnc(?string $credentialEnc) + * @method string|null getConnectorOptions() + * @method void setConnectorOptions(?string $connectorOptions) * @method int getQueueCap() * @method void setQueueCap(int $queueCap) * @method string|null getLastDeliveryStatus() @@ -115,6 +121,29 @@ class SiemSink extends Entity implements JsonSerializable { */ protected ?string $categoryFilter = null; + /** + * Message format: json (default) or cef (syslog only). + * + * @var string + */ + protected string $format = 'json'; + + /** + * The connector credential (Splunk HEC token or Sentinel client secret), + * encrypted with ICrypto. Never serialized. + * + * @var string|null + */ + protected ?string $credentialEnc = null; + + /** + * Non-secret connector settings as JSON (index, sourcetype, tenant id, + * client id, data collection endpoint, rule id, stream, authority host). + * + * @var string|null + */ + protected ?string $connectorOptions = null; + /** * Bounded queue capacity (drop-oldest beyond). * @@ -226,6 +255,9 @@ public function __construct() { $this->addType(fieldName: 'tls', type: 'boolean'); $this->addType(fieldName: 'hmacSecretEnc', type: 'string'); $this->addType(fieldName: 'categoryFilter', type: 'string'); + $this->addType(fieldName: 'format', type: 'string'); + $this->addType(fieldName: 'credentialEnc', type: 'string'); + $this->addType(fieldName: 'connectorOptions', type: 'string'); $this->addType(fieldName: 'queueCap', type: 'integer'); $this->addType(fieldName: 'lastDeliveryStatus', type: 'string'); $this->addType(fieldName: 'lastSuccessAt', type: 'datetime'); @@ -256,6 +288,26 @@ public function categoryFilterArray(): ?array { return null; }//end categoryFilterArray() + /** + * The connector options as an array (empty when none are stored). + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md + */ + public function connectorOptionsArray(): array { + if ($this->connectorOptions === null || $this->connectorOptions === '') { + return []; + } + + $decoded = json_decode($this->connectorOptions, true); + if (is_array($decoded) === false) { + return []; + } + + return array_map('strval', $decoded); + }//end connectorOptionsArray() + /** * API shape — the HMAC secret NEVER appears here (§1.3), only * whether one is set. @@ -272,6 +324,10 @@ public function jsonSerialize(): array { 'tls' => $this->tls, 'hasHmacSecret' => ($this->hmacSecretEnc !== null && $this->hmacSecretEnc !== ''), 'categoryFilter' => $this->categoryFilterArray(), + 'format' => $this->format, + 'connectorOptions' => $this->connectorOptionsArray(), + // Write-only: whether a credential is stored, never the value. + 'hasCredential' => ($this->credentialEnc !== null && $this->credentialEnc !== ''), 'queueCap' => $this->queueCap, 'lastDeliveryStatus' => $this->lastDeliveryStatus, 'lastSuccessAt' => $this->lastSuccessAt?->format('c'), diff --git a/lib/Db/SuiteMigrationMapper.php b/lib/Db/SuiteMigrationMapper.php index fb578e142..cfb014cf9 100644 --- a/lib/Db/SuiteMigrationMapper.php +++ b/lib/Db/SuiteMigrationMapper.php @@ -129,7 +129,7 @@ public function hasInProgress(string $oldSuiteId): bool { * * @return int The number of rows deleted * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteBySuiteIds(array $suiteIds): int { if ($suiteIds === []) { diff --git a/lib/Db/TeamFolderMember.php b/lib/Db/TeamFolderMember.php index 1a3b31009..8ea457aa4 100644 --- a/lib/Db/TeamFolderMember.php +++ b/lib/Db/TeamFolderMember.php @@ -42,6 +42,10 @@ * @method void setCreatedAt(DateTime $createdAt) * @method string getGrade() * @method void setGrade(string $grade) + * @method bool|null getUseOnly() + * @method void setUseOnly(bool $useOnly) + * @method DateTime|null getExpiresAt() + * @method void setExpiresAt(?DateTime $expiresAt) */ class TeamFolderMember extends Entity implements JsonSerializable { @@ -94,6 +98,21 @@ class TeamFolderMember extends Entity implements JsonSerializable { */ protected string $grade = ''; + /** + * Whether the recipient may only use the value, not view or copy it + * (sharing-use-only-and-expiring-shares D1). + * + * @var boolean|null + */ + protected ?bool $useOnly = false; + + /** + * When the access this grant gives ends (nullable = no end). + * + * @var DateTime|null + */ + protected ?DateTime $expiresAt = null; + /** * The UUID primary key. * @@ -134,16 +153,36 @@ public function __construct() { $this->addType(fieldName: 'addedBy', type: 'string'); $this->addType(fieldName: 'createdAt', type: 'datetime'); $this->addType(fieldName: 'grade', type: 'string'); + $this->addType(fieldName: 'useOnly', type: 'boolean'); + $this->addType(fieldName: 'expiresAt', type: 'datetime'); }//end __construct() /** - * The effective grade — an unset/legacy row reads as `read`. + * The grades a membership can carry, lowest first + * (sharing-team-folder-manager-role D1): Viewer, Editor, Manager. + * + * @var string[] + */ + public const GRADES = ['read', 'write', 'manage']; + + /** + * The grades that may update values for the whole team: `write` and + * everything above it. + * + * @var string[] + */ + public const WRITE_GRADES = ['write', 'manage']; + + /** + * The effective grade — an unset/legacy/unknown row reads as `read`. * * @return string + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-write-or-manage-grade */ public function effectiveGrade(): string { - if ($this->grade === 'write') { - return 'write'; + if (in_array($this->grade, self::GRADES, true) === true) { + return $this->grade; } return 'read'; @@ -163,6 +202,8 @@ public function jsonSerialize(): array { 'addedBy' => $this->addedBy, 'createdAt' => $this->createdAt?->format('c'), 'grade' => $this->effectiveGrade(), + 'useOnly' => ($this->useOnly === true), + 'expiresAt' => $this->expiresAt?->format('c'), ]; }//end jsonSerialize() }//end class diff --git a/lib/Db/TeamFolderMemberMapper.php b/lib/Db/TeamFolderMemberMapper.php index cb86f91f5..feeefd2e6 100644 --- a/lib/Db/TeamFolderMemberMapper.php +++ b/lib/Db/TeamFolderMemberMapper.php @@ -25,6 +25,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @@ -33,6 +34,8 @@ * @template-extends QBMapper */ class TeamFolderMemberMapper extends QBMapper { + use ExpiringGrantQueries; + /** * Constructor for TeamFolderMemberMapper. * @@ -152,4 +155,39 @@ public function deleteByTeamFolder(string $teamFolderId): void { ->where($qb->expr()->eq('team_folder_id', $qb->createNamedParameter($teamFolderId))); $qb->executeStatement(); }//end deleteByTeamFolder() + + /** + * Count the direct user-type memberships of each user, in one grouped query. + * + * @param string[] $userIds The user IDs to count for + * + * @return array Membership count keyed by user ID + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + public function countUserMemberships(array $userIds): array { + if ($userIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('member_id') + ->selectAlias($qb->func()->count('id'), 'row_count') + ->from($this->getTableName()) + ->where($qb->expr()->eq('member_type', $qb->createNamedParameter('user'))) + ->andWhere( + $qb->expr()->in('member_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)) + ) + ->groupBy('member_id'); + + $counts = []; + $result = $qb->executeQuery(); + while (($row = $result->fetch()) !== false) { + $counts[(string)$row['member_id']] = (int)$row['row_count']; + } + + $result->closeCursor(); + + return $counts; + }//end countUserMemberships() }//end class diff --git a/lib/Db/UsedProofNonce.php b/lib/Db/UsedProofNonce.php new file mode 100644 index 000000000..489bebd12 --- /dev/null +++ b/lib/Db/UsedProofNonce.php @@ -0,0 +1,62 @@ + + * @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\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * A consumed vault-key-proof challenge. + * + * @method string getNonceHash() + * @method void setNonceHash(string $nonceHash) + * @method int getExpiresAt() + * @method void setExpiresAt(int $expiresAt) + */ +class UsedProofNonce extends Entity { + /** + * SHA-256 of the challenge, hex. + * + * @var string + */ + protected string $nonceHash = ''; + + /** + * When the challenge expires, as a Unix timestamp. After that the + * challenge is refused on its own, so the row can be swept. + * + * @var integer + */ + protected int $expiresAt = 0; + + /** + * Register the column types. + * + * @return void + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + public function __construct() { + $this->addType(fieldName: 'nonceHash', type: 'string'); + $this->addType(fieldName: 'expiresAt', type: 'integer'); + }//end __construct() +}//end class diff --git a/lib/Db/UsedProofNonceMapper.php b/lib/Db/UsedProofNonceMapper.php new file mode 100644 index 000000000..997b35289 --- /dev/null +++ b/lib/Db/UsedProofNonceMapper.php @@ -0,0 +1,108 @@ + + * @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\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception as DbException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Mapper for consumed vault-key-proof challenges. + * + * @extends QBMapper + */ +class UsedProofNonceMapper extends QBMapper { + /** + * Constructor for UsedProofNonceMapper. + * + * @param IDBConnection $db The database connection + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct(IDBConnection $db) { + parent::__construct(db: $db, tableName: 'keepiq_used_proofs', entityClass: UsedProofNonce::class); + }//end __construct() + + /** + * Claim a challenge. True the first time, false on every later call. + * + * @param string $nonceHash SHA-256 of the challenge, hex + * @param int $expiresAt When the challenge expires (Unix time) + * + * @return bool Whether this call was the first to claim it + * + * @throws DbException On any database failure other than the duplicate + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + public function claim(string $nonceHash, int $expiresAt): bool { + $qb = $this->db->getQueryBuilder(); + $qb->insert($this->getTableName()) + ->values( + [ + 'nonce_hash' => $qb->createNamedParameter($nonceHash), + 'expires_at' => $qb->createNamedParameter($expiresAt, IQueryBuilder::PARAM_INT), + ] + ); + + try { + $qb->executeStatement(); + } catch (DbException $e) { + if ($e->getReason() === DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION + || $e->getReason() === DbException::REASON_CONSTRAINT_VIOLATION + ) { + return false; + } + + throw $e; + } + + return true; + }//end claim() + + /** + * Delete every claim whose challenge has expired. + * + * An expired challenge is refused on its own, so its row protects + * nothing any more. + * + * @param int $now The current Unix time + * + * @return int How many rows were deleted + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + public function deleteExpired(int $now): int { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->lt('expires_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))); + + return $qb->executeStatement(); + }//end deleteExpired() +}//end class diff --git a/lib/Event/Audit/AuditEvent.php b/lib/Event/Audit/AuditEvent.php index 319b82bee..e0d56bb4b 100644 --- a/lib/Event/Audit/AuditEvent.php +++ b/lib/Event/Audit/AuditEvent.php @@ -40,6 +40,7 @@ class AuditEvent extends Event { public const ACTOR_APPLICATION = 'application'; public const ACTOR_SYSTEM = 'system'; public const ACTOR_LINK_VISITOR = 'link_visitor'; + public const ACTOR_MCP = 'mcp'; /** * Constructor for AuditEvent. @@ -154,6 +155,32 @@ public static function forLinkVisitor( return new self(self::ACTOR_LINK_VISITOR, null, $eventType, $objectType, $objectId, $objectName, $metadata); }//end forLinkVisitor() + /** + * Build an event actored by an AI agent through an MCP tool, for the + * Nextcloud user it acts for (hermiq-ai-tooling). + * + * @param string $actorId The user the agent acts for + * @param string $eventType The event type + * @param string $objectType The object type + * @param string|null $objectId The object id + * @param string|null $objectName The object name + * @param array $metadata The metadata + * + * @return self + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-invocations-are-audited-as-agent-reads + */ + public static function forMcp( + string $actorId, + string $eventType, + string $objectType, + ?string $objectId = null, + ?string $objectName = null, + array $metadata = [], + ): self { + return new self(self::ACTOR_MCP, $actorId, $eventType, $objectType, $objectId, $objectName, $metadata); + }//end forMcp() + /** * Get the actor type. * diff --git a/lib/Event/Audit/AuditEventFactory.php b/lib/Event/Audit/AuditEventFactory.php index 62a59749a..f097b9feb 100644 --- a/lib/Event/Audit/AuditEventFactory.php +++ b/lib/Event/Audit/AuditEventFactory.php @@ -36,7 +36,7 @@ /** * Builds AuditEvent instances through instance methods. * - * @SuppressWarnings(PHPMD.StaticAccess) The four methods below are the ONE + * @SuppressWarnings(PHPMD.StaticAccess) The methods below are the ONE * place in the app that reaches AuditEvent's static named constructors; every * other call site goes through this factory. Collapsing the constructors into * this class instead would duplicate the ACTOR_* mapping and break the tests @@ -89,6 +89,31 @@ public function forApplication( return AuditEvent::forApplication($actorId, $eventType, $objectType, $objectId, $objectName, $metadata); }//end forApplication() + /** + * Build an event actored by an AI agent through the MCP surface. + * + * @param string $actorId The user the agent acts for + * @param string $eventType The event type + * @param string $objectType The object type + * @param string|null $objectId The object id + * @param string|null $objectName The object name + * @param array $metadata The metadata + * + * @return AuditEvent + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-invocations-are-audited-as-agent-reads + */ + public function forMcp( + string $actorId, + string $eventType, + string $objectType, + ?string $objectId = null, + ?string $objectName = null, + array $metadata = [], + ): AuditEvent { + return AuditEvent::forMcp($actorId, $eventType, $objectType, $objectId, $objectName, $metadata); + }//end forMcp() + /** * Build an event with no human/application actor (background/system). * diff --git a/lib/Event/Audit/AuditEventTypes.php b/lib/Event/Audit/AuditEventTypes.php index 91c3ec515..622cabcfa 100644 --- a/lib/Event/Audit/AuditEventTypes.php +++ b/lib/Event/Audit/AuditEventTypes.php @@ -35,6 +35,14 @@ final class AuditEventTypes { public const SECRET_UPDATED = 'secret.updated'; public const SECRET_READ = 'secret.read'; public const SECRET_DELETED = 'secret.deleted'; + // Trash and archive (vault-trash-and-archive): ids and the item name only. + public const SECRET_TRASHED = 'secret.trashed'; + public const SECRET_RESTORED = 'secret.restored'; + public const SECRET_PURGED = 'secret.purged'; + public const SECRET_ARCHIVED = 'secret.archived'; + public const SECRET_UNARCHIVED = 'secret.unarchived'; + // The holder of a use-only copy filled it (sharing-use-only-and-expiring-shares §3.3). + public const SECRET_USED = 'secret.used'; // Folder. public const FOLDER_DELETED_CASCADE = 'folder.deleted_cascade'; @@ -71,6 +79,18 @@ final class AuditEventTypes { public const SUITE_REINSTATED = 'suite.reinstated'; public const SUITE_RECOVERY_STARTED = 'suite.recovery_started'; public const SUITE_RECOVERY_COMPLETED = 'suite.recovery_completed'; + // The owner aborted their own recovery before any record moved (keepiq#859). + public const SUITE_RECOVERY_ABORTED = 'suite.recovery_aborted'; + // A compromise force-revoke ended an open migration (keepiq#870). + public const SUITE_MIGRATION_TERMINATED = 'suite.migration_terminated'; + // An administrator force-revoke was refused (keepiq#870): an attack on the + // containment path shows up as refusals, not as successes. + public const SUITE_REVOKE_REFUSED = 'suite.revoke_refused'; + + // Vault-key proof (keepiq#870). A refused proof is exactly what a + // session-only attacker probing a guarded route produces. The proof, + // the nonce and the signature are never recorded. + public const KEY_PROOF_REFUSED = 'key_proof.refused'; // Application. public const APPLICATION_REGISTERED = 'application.registered'; @@ -124,6 +144,12 @@ final class AuditEventTypes { // Org password policy (org-password-policies §3.1) — config values // only, never secret data. public const PASSWORD_POLICY_UPDATED = 'password_policy.updated'; + // Vault policies (admin-vault-policies §1.1): before and after snapshot. + public const VAULT_POLICY_UPDATED = 'vault_policy.updated'; + // Scheduled vault backups (admin-scheduled-vault-backups §2.3). + public const BACKUP_CREATED = 'backup.created'; + public const BACKUP_FAILED = 'backup.failed'; + public const BACKUP_RESTORED = 'backup.restored'; // Compliance reporting (compliance-reporting §5.1) — identifiers + // export format only, never an aggregate body. @@ -148,6 +174,8 @@ final class AuditEventTypes { public const TEAM_FOLDER_MEMBER_ADDED = 'team_folder.member_added'; public const TEAM_FOLDER_MEMBER_REMOVED = 'team_folder.member_removed'; public const TEAM_FOLDER_OFFBOARDED = 'team_folder.offboarded'; + // Automatic member confirmation (admin-auto-confirm-members D6). + public const TEAM_FOLDER_MEMBERS_CONFIRMED = 'team_folder.members_confirmed'; // Folder permission grades (folder-permission-grades §3.3). public const TEAM_FOLDER_GRADE_CHANGED = 'team_folder.grade_changed'; @@ -158,6 +186,10 @@ final class AuditEventTypes { public const SIEM_SINK_DELETED = 'siem.sink_deleted'; public const SIEM_SINK_TESTED = 'siem.sink_tested'; + // An AI agent called a Keepiq MCP read tool (hermiq-ai-tooling): the tool + // name and a result count, never an entry name, subject or value. + public const MCP_TOOL_INVOKED = 'mcp.tool_invoked'; + // Certificate lifecycle (certificate-lifecycle §5) — identifiers // only; no PEM, key, or secret value is ever recorded. public const CERTIFICATE_REISSUED = 'certificate.reissued'; @@ -167,6 +199,44 @@ final class AuditEventTypes { // high-severity tripwire marker; channel only, never secret data. public const HONEY_ACCESSED = 'honey.accessed'; + // New device approval (crypto-new-device-approval D5), identifiers only. + public const DEVICE_APPROVAL_REQUESTED = 'device_approval.requested'; + public const DEVICE_APPROVAL_APPROVED = 'device_approval.approved'; + public const DEVICE_APPROVAL_DENIED = 'device_approval.denied'; + public const DEVICE_APPROVAL_EXPIRED = 'device_approval.expired'; + public const DEVICE_APPROVAL_PICKED_UP = 'device_approval.picked_up'; + + // Organisation account recovery (crypto-organisation-account-recovery 5.2), + // identifiers only: never an envelope, a wrapped copy or a sealed result. + public const RECOVERY_SETTINGS_CHANGED = 'recovery.settings_changed'; + public const RECOVERY_KEY_CREATED = 'recovery.key_created'; + public const RECOVERY_KEY_RETIRED = 'recovery.key_retired'; + public const RECOVERY_ENROLLED = 'recovery.enrolled'; + public const RECOVERY_WITHDRAWN = 'recovery.withdrawn'; + public const RECOVERY_REQUESTED = 'recovery.requested'; + public const RECOVERY_APPROVED = 'recovery.approved'; + public const RECOVERY_DECLINED = 'recovery.declined'; + public const RECOVERY_HANDED_OFF = 'recovery.handed_off'; + public const RECOVERY_COMPLETED = 'recovery.completed'; + public const RECOVERY_EXPIRED = 'recovery.expired'; + + // Federated sharing (sharing-federated-recipients 4.3), both sides, + // identifiers only: never ciphertext, the shared secret or its hash. + public const FEDERATED_SHARE_SENT = 'federated_share.sent'; + public const FEDERATED_SHARE_UPDATED = 'federated_share.updated'; + public const FEDERATED_SHARE_REVOKED = 'federated_share.revoked'; + public const FEDERATED_SHARE_SUSPENDED = 'federated_share.suspended'; + public const FEDERATED_SHARE_FAILED = 'federated_share.failed'; + public const FEDERATED_SHARE_RECEIVED = 'federated_share.received'; + public const FEDERATED_SHARE_ACCEPTED = 'federated_share.accepted'; + public const FEDERATED_SHARE_DECLINED = 'federated_share.declined'; + public const FEDERATED_COPY_UPDATED = 'federated_share.copy_updated'; + public const FEDERATED_COPY_REMOVED = 'federated_share.copy_removed'; + // The owner's side of a recipient deleting their copy (task 4.4). + public const FEDERATED_SHARE_RECIPIENT_DECLINED = 'federated_share.recipient_declined'; + // The owner's side of a recipient restoring a declined copy from the trash. + public const FEDERATED_SHARE_RECIPIENT_RESUMED = 'federated_share.recipient_resumed'; + /** * Metadata keys that MUST NEVER appear in any audit entry, in any position. * Recording any of these is rejected with an exception — defense in depth so @@ -200,6 +270,12 @@ final class AuditEventTypes { self::SECRET_UPDATED => ['changedFields'], self::SECRET_READ => [], self::SECRET_DELETED => [], + self::SECRET_TRASHED => [], + self::SECRET_RESTORED => [], + self::SECRET_PURGED => ['reason'], + self::SECRET_ARCHIVED => [], + self::SECRET_UNARCHIVED => [], + self::SECRET_USED => ['copyId'], self::FOLDER_DELETED_CASCADE => ['secretCount', 'subfolderCount'], self::SHARE_GRANTED => ['recipientType', 'recipientId'], self::SHARE_REVOKED => ['recipientType', 'recipientId'], @@ -220,8 +296,14 @@ final class AuditEventTypes { self::REQUEST_EXPIRED => [], self::SUITE_REVOKED => ['reason', 'markCompromised', 'emergencyContactsDestroyed'], self::SUITE_REINSTATED => [], - self::SUITE_RECOVERY_STARTED => [], + self::SUITE_RECOVERY_STARTED => ['migrationId', 'newSuiteId'], self::SUITE_RECOVERY_COMPLETED => ['reSuitedCount'], + self::SUITE_RECOVERY_ABORTED => ['migrationId', 'newSuiteId'], + self::SUITE_MIGRATION_TERMINATED => ['migrationId', 'oldSuiteId', 'newSuiteId'], + self::SUITE_REVOKE_REFUSED => ['reasonCode', 'markCompromised'], + // The guarded route, its purpose and why the proof was refused; never + // the proof, the nonce or the signature. + self::KEY_PROOF_REFUSED => ['route', 'purpose', 'reason'], self::APPLICATION_REGISTERED => [], self::APPLICATION_APPROVED => [], self::APPLICATION_REJECTED => ['reason'], @@ -251,6 +333,11 @@ final class AuditEventTypes { self::POLICY_EXPIRY_CHANGED => ['scope', 'scopeId'], // Org password policy — before/after config values (§3.1). self::PASSWORD_POLICY_UPDATED => ['before', 'after'], + self::VAULT_POLICY_UPDATED => ['before', 'after'], + // Backups: archive name, flags, sizes and counts only (§2.3). + self::BACKUP_CREATED => ['archive', 'encrypted', 'bytes'], + self::BACKUP_FAILED => ['error'], + self::BACKUP_RESTORED => ['archive', 'createdAt', 'tables', 'rows', 'blobs'], // Compliance reporting — identifiers + format only (§5.1). self::COMPLIANCE_REPORT_GENERATED => ['reportId'], self::COMPLIANCE_REPORT_EXPORTED => ['reportId', 'format'], @@ -268,19 +355,59 @@ final class AuditEventTypes { self::TEAM_FOLDER_UNSHARED => ['folderId', 'revokedCount'], self::TEAM_FOLDER_MEMBER_ADDED => ['memberType', 'memberId'], self::TEAM_FOLDER_MEMBER_REMOVED => ['memberType', 'memberId', 'revokedCount'], - self::TEAM_FOLDER_OFFBOARDED => ['leavingUserId', 'successorUserId', 'revokedCount', 'transferredCount'], + self::TEAM_FOLDER_OFFBOARDED => [ + 'leavingUserId', + 'successorUserId', + 'revokedCount', + 'transferredCount', + // Member offboarding (admin-member-overview-and-offboarding §1.3): counts and group ids only. + 'membershipsRemovedCount', + 'coveringGroupIds', + ], // Grade changes — identifiers + the new grade only (§3.3). self::TEAM_FOLDER_GRADE_CHANGED => ['memberType', 'memberId', 'grade'], + // Automatic confirmation: counts only, the actor is the confirmer. + self::TEAM_FOLDER_MEMBERS_CONFIRMED => ['confirmedCount', 'memberCount'], // SIEM sinks — sink id/type/outcome only (§5.1). self::SIEM_SINK_CREATED => ['sinkId', 'type'], - self::SIEM_SINK_UPDATED => ['sinkId'], + self::SIEM_SINK_UPDATED => ['sinkId', 'type'], self::SIEM_SINK_DELETED => ['sinkId'], self::SIEM_SINK_TESTED => ['sinkId', 'outcome'], + self::MCP_TOOL_INVOKED => ['tool', 'resultCount'], // Certificate lifecycle — identifiers only, never PEM/key. self::CERTIFICATE_REISSUED => ['suiteId'], self::CERTIFICATE_RENEWAL_MARKED => [], // Honey tripwire — the access channel only (§D6). self::HONEY_ACCESSED => ['channel'], + self::DEVICE_APPROVAL_REQUESTED => ['clientKind'], + self::DEVICE_APPROVAL_APPROVED => [], + self::DEVICE_APPROVAL_DENIED => [], + self::DEVICE_APPROVAL_EXPIRED => [], + self::DEVICE_APPROVAL_PICKED_UP => [], + self::RECOVERY_SETTINGS_CHANGED => ['policy', 'threshold', 'officerCount'], + self::RECOVERY_KEY_CREATED => [], + self::RECOVERY_KEY_RETIRED => [], + self::RECOVERY_ENROLLED => ['suiteId'], + self::RECOVERY_WITHDRAWN => [], + self::RECOVERY_REQUESTED => ['userId'], + self::RECOVERY_APPROVED => ['userId', 'approvals', 'threshold'], + self::RECOVERY_DECLINED => ['userId'], + self::RECOVERY_HANDED_OFF => ['userId'], + self::RECOVERY_COMPLETED => ['handledBy'], + self::RECOVERY_EXPIRED => ['userId'], + // The other side by cloud id and partner, the share by id; nothing else. + self::FEDERATED_SHARE_SENT => ['federatedShareId', 'recipientCloudId', 'partnerId'], + self::FEDERATED_SHARE_UPDATED => ['federatedShareId', 'recipientCloudId', 'partnerId'], + self::FEDERATED_SHARE_REVOKED => ['federatedShareId', 'recipientCloudId', 'partnerId'], + self::FEDERATED_SHARE_SUSPENDED => ['federatedShareId', 'recipientCloudId', 'partnerId', 'reason'], + self::FEDERATED_SHARE_FAILED => ['federatedShareId', 'recipientCloudId', 'partnerId', 'notification'], + self::FEDERATED_SHARE_RECEIVED => ['inboundShareId', 'senderCloudId', 'partnerId'], + self::FEDERATED_SHARE_ACCEPTED => ['inboundShareId', 'senderCloudId', 'partnerId', 'copyId'], + self::FEDERATED_SHARE_DECLINED => ['inboundShareId', 'senderCloudId', 'partnerId', 'copyId'], + self::FEDERATED_COPY_UPDATED => ['inboundShareId', 'senderCloudId', 'partnerId', 'copyId'], + self::FEDERATED_COPY_REMOVED => ['inboundShareId', 'senderCloudId', 'partnerId', 'copyId'], + self::FEDERATED_SHARE_RECIPIENT_DECLINED => ['federatedShareId', 'recipientCloudId', 'partnerId'], + self::FEDERATED_SHARE_RECIPIENT_RESUMED => ['federatedShareId', 'recipientCloudId', 'partnerId'], ]; /** diff --git a/lib/Exception/ForbiddenException.php b/lib/Exception/ForbiddenException.php index 9db025802..57e69cbaa 100644 --- a/lib/Exception/ForbiddenException.php +++ b/lib/Exception/ForbiddenException.php @@ -29,4 +29,17 @@ * Thrown when a requester is not authorised to perform an operation. */ class ForbiddenException extends RuntimeException { + /** + * The machine-readable policy code a refusal carries, if any. A plain + * ownership refusal has none; a vault policy refusal names its policy + * (admin-vault-policies), so a controller can return it without knowing + * the subclass. + * + * @return string|null + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function policyCode(): ?string { + return null; + }//end policyCode() }//end class diff --git a/lib/Exception/KeyProofRequiredException.php b/lib/Exception/KeyProofRequiredException.php index 6f4f54401..e0e0d2251 100644 --- a/lib/Exception/KeyProofRequiredException.php +++ b/lib/Exception/KeyProofRequiredException.php @@ -7,7 +7,7 @@ * a missing, malformed, expired, mis-purposed, mis-bound, or unverifiable * signature over the server-issued challenge. Every failure path throws this * one type with no detail leaked about which check failed, and the middleware - * maps it to an HTTP 403 carrying the machine-readable code `key_proof_required` + * maps it to an HTTP 428 carrying the machine-readable code `key_proof_required` * so the client knows to obtain a challenge and retry rather than giving up. * * @category Exception diff --git a/lib/Exception/ManagerOnlyException.php b/lib/Exception/ManagerOnlyException.php new file mode 100644 index 000000000..6a4de7db5 --- /dev/null +++ b/lib/Exception/ManagerOnlyException.php @@ -0,0 +1,41 @@ + + * @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; + +use InvalidArgumentException; + +/** + * A membership change only the owner or a manager may make. + * + * @spec openspec/specs/folder-permission-grades/spec.md#scenario-non-owner-cannot-change-a-grade + */ +class ManagerOnlyException extends InvalidArgumentException { + /** + * The machine-readable code the refusal carries. + */ + public const CODE = 'manager_only'; +}//end class diff --git a/lib/Exception/OwnerOnlyException.php b/lib/Exception/OwnerOnlyException.php new file mode 100644 index 000000000..af3c279f7 --- /dev/null +++ b/lib/Exception/OwnerOnlyException.php @@ -0,0 +1,40 @@ + + * @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; + +use InvalidArgumentException; + +/** + * A membership change only the team folder's owner may make. + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-only-the-owner-governs-managers-and-the-folder-itself + */ +class OwnerOnlyException extends InvalidArgumentException { + /** + * The machine-readable code the refusal carries. + */ + public const CODE = 'owner_only'; +}//end class diff --git a/lib/Exception/PolicyViolationException.php b/lib/Exception/PolicyViolationException.php new file mode 100644 index 000000000..25ac1845e --- /dev/null +++ b/lib/Exception/PolicyViolationException.php @@ -0,0 +1,58 @@ + + * @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 vault policy refuses a write. + */ +class PolicyViolationException extends ForbiddenException { + /** + * Constructor. + * + * @param string $policyCode The policy code, for example org_ownership_required + * @param string $message The human-readable reason + * + * @return void + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function __construct( + public readonly string $policyCode, + string $message, + ) { + parent::__construct(message: $message); + }//end __construct() + + /** + * The policy code, for example org_ownership_required. + * + * @return string|null + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function policyCode(): ?string { + return $this->policyCode; + }//end policyCode() +}//end class diff --git a/lib/Exception/ReinstateRefusedException.php b/lib/Exception/ReinstateRefusedException.php new file mode 100644 index 000000000..7168bb4d1 --- /dev/null +++ b/lib/Exception/ReinstateRefusedException.php @@ -0,0 +1,55 @@ + + * @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; + +/** + * A reinstate refused for a reason the administrator has to see. + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated + */ +class ReinstateRefusedException extends ConflictException { + /** + * Constructor. + * + * @param string $error A stable machine-readable reason for the response + * @param string $message The human-readable explanation + * + * @return void + */ + public function __construct(private string $error, string $message) { + parent::__construct(message: $message); + }//end __construct() + + /** + * The machine-readable reason. + * + * @return string + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated + */ + public function getError(): string { + return $this->error; + }//end getError() +}//end class diff --git a/lib/Exception/SiemDeliveryException.php b/lib/Exception/SiemDeliveryException.php new file mode 100644 index 000000000..4815535ca --- /dev/null +++ b/lib/Exception/SiemDeliveryException.php @@ -0,0 +1,60 @@ + + * @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 + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Exception; + +use RuntimeException; + +/** + * A sink that did not take a payload. + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ +class SiemDeliveryException extends RuntimeException { + /** + * Constructor for SiemDeliveryException. + * + * @param string $message A fixed description that names no endpoint + * @param int|null $httpStatus The sink's HTTP status, or null when nothing answered + * + * @return void + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + public function __construct(string $message, private ?int $httpStatus = null) { + parent::__construct(message: $message); + }//end __construct() + + /** + * The sink's HTTP status, or null when nothing answered. + * + * @return int|null + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + public function getHttpStatus(): ?int { + return $this->httpStatus; + }//end getHttpStatus() +}//end class diff --git a/lib/Exception/StaleWriteException.php b/lib/Exception/StaleWriteException.php new file mode 100644 index 000000000..9fa6202ac --- /dev/null +++ b/lib/Exception/StaleWriteException.php @@ -0,0 +1,52 @@ + + * @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; + +use OCA\Keepiq\Db\Secret; +use RuntimeException; + +/** + * Thrown when a write names the version it was based on (`baseUpdatedAt`) and + * the secret changed since. Nothing was written; the caller answers 409 with + * the current row so the client can let the user choose (offline-edit-queue). + * + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ +class StaleWriteException extends RuntimeException { + /** + * Constructor. + * + * @param Secret $current The secret as it is now + * + * @return void + */ + public function __construct(private Secret $current) { + parent::__construct(message: 'This secret changed since your copy was made'); + }//end __construct() + + /** + * The secret as it is now. + * + * @return Secret + */ + public function getCurrent(): Secret { + return $this->current; + }//end getCurrent() +}//end class diff --git a/lib/Federation/KeepiqSecretFederationProvider.php b/lib/Federation/KeepiqSecretFederationProvider.php new file mode 100644 index 000000000..d8dafad8a --- /dev/null +++ b/lib/Federation/KeepiqSecretFederationProvider.php @@ -0,0 +1,147 @@ + + * @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\Federation; + +use OCA\Keepiq\Service\FederatedCopyDeclineService; +use OCA\Keepiq\Service\FederatedDeclineReceiver; +use OCA\Keepiq\Service\FederatedRemoteChangeService; +use OCA\Keepiq\Service\FederatedShareReceiver; +use OCA\Keepiq\Service\FederatedShareService; +use OCP\Federation\ISignedCloudFederationProvider; +use OCP\Federation\ICloudFederationShare; + +/** + * Receives Keepiq secrets shared from partner instances. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class KeepiqSecretFederationProvider implements ISignedCloudFederationProvider { + /** + * Constructor for KeepiqSecretFederationProvider. + * + * @param FederatedShareReceiver $receiver Takes incoming shares in + * @param FederatedRemoteChangeService $remoteChanges Applies updates and revocations + * @param FederatedDeclineReceiver|null $declines The owner's side of a recipient deleting their copy + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedShareReceiver $receiver, + private FederatedRemoteChangeService $remoteChanges, + private ?FederatedDeclineReceiver $declines = null, + ) { + }//end __construct() + + /** + * The resource type this provider handles. + * + * @return string + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function getShareType() { + return FederatedShareService::RESOURCE_TYPE; + }//end getShareType() + + /** + * Store an incoming share as pending, or refuse it. + * + * @param ICloudFederationShare $share The share + * + * @return string The local inbound share id + * + * @throws \OCP\Federation\Exceptions\ProviderCouldNotAddShareException When refused + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-non-partner-cannot-deliver + */ + public function shareReceived(ICloudFederationShare $share) { + return $this->receiver->receive(share: $share); + }//end shareReceived() + + /** + * Handle a notification about a share. + * + * @param string $notificationType The notification type + * @param string $providerId The share id on the sender + * @param array $notification The payload + * + * @return array Nothing is sent back: always an empty array. + * + * @throws \OCP\Share\Exceptions\ShareNotFound For every refusal + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-restores-his-copy + */ + public function notificationReceived(string $notificationType, string $providerId, array $notification) { + // The recipient removed their copy: the owner's side (task 4.4). + if ($notificationType === FederatedCopyDeclineService::SHARE_DECLINED && $this->declines !== null) { + return $this->declines->handle(providerId: $providerId, notification: $notification); + } + + // The recipient restored a declined copy and takes the share back. + if ($notificationType === FederatedCopyDeclineService::SHARE_ACCEPTED && $this->declines !== null) { + return $this->declines->resume(providerId: $providerId, notification: $notification); + } + + return $this->remoteChanges->handle(type: $notificationType, providerId: $providerId, notification: $notification); + }//end notificationReceived() + + /** + * Whose signature a notification carries: the sender of the share the + * presented hash belongs to (an update or unshare from the owner), else + * the recipient of the share the presented secret belongs to (a decline + * from the recipient, task 4.4). Nextcloud 35 verifies a notification's + * signature against it; without it no RFC 9421 signed notification can + * be read. + * + * @param string $sharedSecret What the notification presents (the secret's SHA-256, or the secret in a decline) + * @param array $payload The notification, with `providerId` + * + * @return string The other side's cloud id, or '' when no share matches + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function getFederationIdFromSharedSecret(string $sharedSecret, array $payload): string { + $sender = $this->remoteChanges->senderOf(presented: $sharedSecret, notification: $payload); + if ($sender !== '' || $this->declines === null) { + return $sender; + } + + return $this->declines->recipientOf(presented: $sharedSecret, notification: $payload); + }//end getFederationIdFromSharedSecret() + + /** + * Keepiq shares go to users only. + * + * @return string[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function getSupportedShareTypes() { + return ['user']; + }//end getSupportedShareTypes() +}//end class diff --git a/lib/Listener/EncryptionSuiteRevokedListener.php b/lib/Listener/EncryptionSuiteRevokedListener.php index 0caace77f..7d54e0b7d 100644 --- a/lib/Listener/EncryptionSuiteRevokedListener.php +++ b/lib/Listener/EncryptionSuiteRevokedListener.php @@ -9,7 +9,8 @@ * - promotes any temporary SecretDelegations the suite owner had created * to permanent so the delegate-as-de-facto-owner survives the * revocation (the original owner's Secret copies become inaccessible - * when the suite is gone). + * when the suite is gone), except on a compromise force-revoke, which + * revokes them instead (keepiq#817, ADR-005). * * @category Listener * @package OCA\Keepiq\Listener @@ -83,9 +84,16 @@ public function handle(Event $event): void { $userId = $event->getOwnerId(); try { - // The ex-recipient can no longer decrypt anything; sweep - // every ShareTarget where they were the recipient. - $this->shareTargetMapper->deleteByTargetUser(targetUserId: $userId); + // The ex-recipient can no longer decrypt the copies sealed under + // the revoked suite; sweep the ShareTargets for those copies. + // Scoped to this suite: a compromise force-revoke during a + // migration revokes a second suite right after this one, and an + // unscoped sweep here removed the rows the cascade needs to find + // the owners of the copies on that second suite (keepiq#864). + $this->shareTargetMapper->deleteByTargetUserAndSuite( + targetUserId: $userId, + suiteId: $event->getSuiteId() + ); } catch (Throwable $exception) { $this->logger->warning( 'Keepiq: EncryptionSuiteRevokedListener share-target sweep failed for ' @@ -94,6 +102,15 @@ public function handle(Event $event): void { ); } + if ($event->getCompromised() === true) { + // A compromise force-revoke cuts the user's temporary delegations + // instead of promoting them: one made from a stolen session is a + // foothold, and promoting it would make it permanent during the + // incident response (keepiq#817, ADR-005). Permanent ones stay. + $this->revokeTemporaryDelegations(event: $event); + return; + } + try { $promoted = $this->delegationService->makePermanent(originalOwnerId: $userId); if ($promoted > 0) { @@ -111,4 +128,36 @@ public function handle(Event $event): void { ); } }//end handle() + + /** + * Revoke the temporary delegations of a user whose suite was revoked as + * compromised. Fail-soft like the other cascade steps. + * + * @param EncryptionSuiteRevokedEvent $event The compromise revoke event + * + * @return void + * + * @spec openspec/specs/user-sharing/spec.md#requirement-permanent-transfer-on-suite-revocation + */ + private function revokeTemporaryDelegations(EncryptionSuiteRevokedEvent $event): void { + try { + $revoked = $this->delegationService->revokeTemporary( + originalOwnerId: $event->getOwnerId(), + revokedBy: $event->getRevokedBy() + ); + if ($revoked > 0) { + $this->logger->info( + 'Keepiq: revoked ' . $revoked . ' temporary delegations after the compromise revoke of ' + . $event->getSuiteId() . ' (owner=' . $event->getOwnerId() . ')', + ['app' => 'keepiq'] + ); + } + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: EncryptionSuiteRevokedListener delegation-revoke failed for ' + . $event->getOwnerId() . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end revokeTemporaryDelegations() }//end class diff --git a/lib/Listener/FederationOcmDiscoveryListener.php b/lib/Listener/FederationOcmDiscoveryListener.php new file mode 100644 index 000000000..3ffa573d3 --- /dev/null +++ b/lib/Listener/FederationOcmDiscoveryListener.php @@ -0,0 +1,71 @@ + + * @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\Listener; + +use OCA\Keepiq\Service\FederatedCertificateService; +use OCA\Keepiq\Service\FederationPartnerService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; + +/** + * Adds the `keepiq` capability to the local OCM discovery. + * + * @template-implements IEventListener + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +class FederationOcmDiscoveryListener implements IEventListener { + /** + * Constructor for FederationOcmDiscoveryListener. + * + * @param FederationPartnerService $partners The partner allowlist + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederationPartnerService $partners, + ) { + }//end __construct() + + /** + * Add the capability when a partner exists. + * + * @param Event $event The dispatched event + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function handle(Event $event): void { + if ($event instanceof LocalOCMDiscoveryEvent === false || $this->partners->hasAny() === false) { + return; + } + + $event->addCapability(FederatedCertificateService::OCM_CAPABILITY); + }//end handle() +}//end class diff --git a/lib/Listener/FederationOcmRequestListener.php b/lib/Listener/FederationOcmRequestListener.php new file mode 100644 index 000000000..aa12436bb --- /dev/null +++ b/lib/Listener/FederationOcmRequestListener.php @@ -0,0 +1,123 @@ + and + * dispatches OCMEndpointRequestEvent with the signer as getRemote(). This + * listener acts only on the `keepiq` capability. + * + * POST /ocm/keepiq/recipient-certificate answers a recipient's certificate + * and CA chain, or the one unknown-recipient answer for every refusal: an + * unsigned call, a signer that is no inbound partner, a user who does not + * exist here, has not opted in, or holds no active suite. + * + * POST /ocm/keepiq/shares/{id} answers a federated share's ciphertext to + * the recipient's partner presenting the share's shared secret (D4), and + * the same unknown answer to anyone else. + * + * @category Listener + * @package OCA\Keepiq\Listener + * + * @author Conduction Development Team + * @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\Listener; + +use OCA\Keepiq\Service\FederatedCertificateService; +use OCA\Keepiq\Service\FederatedShareService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\OCM\Events\OCMEndpointRequestEvent; + +/** + * Serves /ocm/keepiq/... for verified partners. + * + * @template-implements IEventListener + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +class FederationOcmRequestListener implements IEventListener { + /** + * Constructor for FederationOcmRequestListener. + * + * @param FederatedCertificateService $certificates The federated certificate lookup + * @param FederatedShareService $shares The outbound federated shares + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedCertificateService $certificates, + private FederatedShareService $shares, + ) { + }//end __construct() + + /** + * Answer a Keepiq OCM request; leave every other capability alone. + * + * @param Event $event The dispatched event + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function handle(Event $event): void { + if ($event instanceof OCMEndpointRequestEvent === false + || $event->getRequestedCapability() !== FederatedCertificateService::OCM_CAPABILITY + ) { + return; + } + + $answer = $this->answerFor(event: $event); + if ($answer !== null) { + $event->setResponse(new JSONResponse(data: $answer)); + return; + } + + // One answer for every refusal and every unknown path, so nothing + // here tells a caller why. + $event->setResponse( + new JSONResponse(data: ['message' => 'Unknown recipient'], statusCode: Http::STATUS_NOT_FOUND) + ); + }//end handle() + + /** + * The answer to a Keepiq OCM request, or null for the unknown answer. + * + * @param OCMEndpointRequestEvent $event The request + * + * @return array|null + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + private function answerFor(OCMEndpointRequestEvent $event): ?array { + if (strtoupper($event->getUsedMethod()) !== 'POST') { + return null; + } + + $path = $event->getPath(); + if ($path === '/recipient-certificate') { + return $this->certificates->answer(signer: $event->getRemote(), payload: $event->getPayload()); + } + + if (preg_match('#^/shares/([0-9a-f-]{36})$#', $path, $match) === 1) { + return $this->shares->answerPull(signer: $event->getRemote(), shareId: $match[1], payload: $event->getPayload()); + } + + return null; + }//end answerFor() +}//end class diff --git a/lib/Listener/MarksCompromisedSecrets.php b/lib/Listener/MarksCompromisedSecrets.php index 9be027661..f33d70383 100644 --- a/lib/Listener/MarksCompromisedSecrets.php +++ b/lib/Listener/MarksCompromisedSecrets.php @@ -6,7 +6,7 @@ * The part of the suite-compromise cascade that both compromise listeners * share: resolving a shared copy to its SOURCE Secret, and stamping and * flagging a Secret as possibly compromised. SuiteCompromiseListener (a - * completed compromise migration) and SuiteCompromiseOnRevokeListener (an + * completed compromise migration) and CompromiseContainmentService (an * administrator force-revoke) must treat a shared source the same way, so the * logic lives here once (keepiq#802). * @@ -42,15 +42,16 @@ 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. + * logged and does not stop the cascade for the other Secrets; the return + * value lets a caller count it (keepiq#863). * * @param Secret $secret The Secret to mark * - * @return void + * @return bool True when the Secret was stamped and flagged * - * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + * @spec openspec/specs/encryption-suites/spec.md#requirement-administrator-force-revocation */ - private function stampAndFlag(Secret $secret): void { + private function stampAndFlag(Secret $secret): bool { try { if ($secret->getPossiblyCompromisedAt() === null) { $secret->setPossiblyCompromisedAt(new DateTime()); @@ -61,11 +62,13 @@ private function stampAndFlag(Secret $secret): void { secretId: $secret->getId(), reason: 'suite_compromise' ); + return true; } catch (Throwable $exception) { $this->logger->warning( 'Keepiq: could not mark secret ' . $secret->getId() . ' possibly compromised: ' . $exception->getMessage(), ['app' => 'keepiq'] ); + return false; } }//end stampAndFlag() @@ -82,7 +85,7 @@ private function stampAndFlag(Secret $secret): void { * * @return Secret * - * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + * @spec openspec/specs/encryption-suites/spec.md#requirement-administrator-force-revocation */ private function resolveTarget(Secret $secret): Secret { try { diff --git a/lib/Listener/RecoverySuiteListener.php b/lib/Listener/RecoverySuiteListener.php new file mode 100644 index 000000000..bfc111d34 --- /dev/null +++ b/lib/Listener/RecoverySuiteListener.php @@ -0,0 +1,88 @@ + + * @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\Listener; + +use OCA\Keepiq\Event\EncryptionSuiteRevokedEvent; +use OCA\Keepiq\Event\SuiteMigrationCompletedEvent; +use OCA\Keepiq\Service\RecoveryEnrolmentService; +use OCA\Keepiq\Service\RecoveryRequestService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Enrolments follow the suite (crypto-organisation-account-recovery D7): + * after a rotation the old suite's enrolment goes (the rotating browser + * already enrolled the new one), and a revoked suite loses its enrolment + * and its open requests. + * + * @implements IEventListener + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ +class RecoverySuiteListener implements IEventListener { + + /** + * Constructor for RecoverySuiteListener. + * + * @param RecoveryEnrolmentService $enrolments The enrolments + * @param RecoveryRequestService $requests The requests + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private RecoveryEnrolmentService $enrolments, + private RecoveryRequestService $requests, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Remove what no longer matches a live suite. + * + * @param Event $event The dispatched event + * + * @return void + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + public function handle(Event $event): void { + try { + if ($event instanceof SuiteMigrationCompletedEvent) { + $this->enrolments->deleteForSuite(suiteId: $event->getOldSuiteId()); + return; + } + + if ($event instanceof EncryptionSuiteRevokedEvent) { + $this->enrolments->deleteForSuite(suiteId: $event->getSuiteId()); + $this->requests->endForSuite(suiteId: $event->getSuiteId()); + } + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: account recovery clean-up after a suite change failed: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + } + }//end handle() +}//end class diff --git a/lib/Listener/SuiteCompromiseOnRevokeListener.php b/lib/Listener/SuiteCompromiseOnRevokeListener.php deleted file mode 100644 index 918a4f240..000000000 --- a/lib/Listener/SuiteCompromiseOnRevokeListener.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @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 index fe424f3a9..9ea6b814d 100644 --- a/lib/Listener/SuiteMigrationAbortedListener.php +++ b/lib/Listener/SuiteMigrationAbortedListener.php @@ -63,7 +63,7 @@ public function __construct( * * @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 + * @spec openspec/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) { diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index f88bc601e..d42aaa2f3 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -60,7 +60,7 @@ public function __construct( * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function handle(Event $event): void { if (($event instanceof UserDeletedEvent) === false) { diff --git a/lib/Mcp/EntryMetadataTools.php b/lib/Mcp/EntryMetadataTools.php new file mode 100644 index 000000000..d33c66540 --- /dev/null +++ b/lib/Mcp/EntryMetadataTools.php @@ -0,0 +1,153 @@ + + * @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\Mcp; + +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Service\SecretService; +use OCA\OpenRegister\Mcp\Attribute\McpTool; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * keepiq.listEntries: the caller's entries as metadata only. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-metadata-only-entry-listing-tool + */ +class EntryMetadataTools { + + /** + * Most entries one call returns. + * + * @var int + */ + private const LIMIT = 200; + + /** + * Constructor. + * + * @param SecretService $secretService The vault read path + * @param EncryptionSuiteMapper $suiteMapper To tell a vault without an active suite + * @param McpToolContext $context The principal and the audit + * @param MetadataAllowList $allowList The keys a result may carry + * + * @return void + */ + public function __construct( + private SecretService $secretService, + private EncryptionSuiteMapper $suiteMapper, + private McpToolContext $context, + private MetadataAllowList $allowList = new MetadataAllowList(), + ) { + }//end __construct() + + #[McpTool( + name: 'listEntries', + description: 'List the entries in your Keepiq vault as metadata: name, URL, type, folder and dates. ' + . 'Never returns a password, login or other secret value.', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + scope: 'read', + subject: 'entry', + action: 'list' + )] + /** + * List the entries in your Keepiq vault: names, URLs, types, folders and + * dates. Never a password, login or any other secret value. Filter by + * folder, type or a name or URL search. + * + * @param string|null $folderId Only entries in this folder + * @param string|null $typeId Only entries of this type + * @param string|null $query Only entries whose name or URL matches + * + * @return array{entries: list>, total: int} + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-metadata-only-entry-listing-tool + */ + public function listEntries(?string $folderId = null, ?string $typeId = null, ?string $query = null): array { + $userId = $this->context->userId(); + $entries = []; + if ($this->hasActiveSuite(userId: $userId) === true) { + $entries = $this->rows(userId: $userId, folderId: $folderId, typeId: $typeId, query: $query); + } + + $this->context->audit(userId: $userId, tool: 'listEntries', resultCount: count($entries)); + return ['entries' => $entries, 'total' => count($entries)]; + }//end listEntries() + + /** + * The projected rows. + * + * @param string $userId The principal + * @param string|null $folderId Folder filter + * @param string|null $typeId Type filter + * @param string|null $query Name or URL search + * + * @return list> + */ + private function rows(string $userId, ?string $folderId, ?string $typeId, ?string $query): array { + if ($query !== null && trim($query) !== '') { + $found = array_filter( + $this->secretService->search(userId: $userId, term: $query, page: 1, limit: self::LIMIT)['items'], + static fn (array $row): bool => ($folderId === null || ($row['folderId'] ?? null) === $folderId) + && ($typeId === null || ($row['typeId'] ?? null) === $typeId) + ); + return $this->project(items: $found); + } + + $items = $this->secretService->list( + userId: $userId, + folderId: $folderId, + sort: null, + direction: 'asc', + page: 1, + limit: self::LIMIT, + typeId: $typeId + )['items']; + return $this->project(items: $items); + }//end rows() + + /** + * Project each row onto the entry allow-list. + * + * @param array> $items The rows + * + * @return list> + */ + private function project(array $items): array { + return array_values(array_map(fn (array $row): array => $this->allowList->project(row: $row, type: 'entry'), $items)); + }//end project() + + /** + * Whether the user has an active encryption suite; without one the tool + * answers an empty list, not an error. + * + * @param string $userId The principal + * + * @return bool + */ + private function hasActiveSuite(string $userId): bool { + try { + $this->suiteMapper->findActiveByOwner('user', $userId); + return true; + } catch (DoesNotExistException) { + return false; + } + }//end hasActiveSuite() +}//end class diff --git a/lib/Mcp/ExpiryReportTools.php b/lib/Mcp/ExpiryReportTools.php new file mode 100644 index 000000000..e099f2581 --- /dev/null +++ b/lib/Mcp/ExpiryReportTools.php @@ -0,0 +1,208 @@ + + * @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\Mcp; + +use DateTime; +use DateTimeInterface; +use InvalidArgumentException; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Service\CertificateLifecycleService; +use OCA\OpenRegister\Mcp\Attribute\McpTool; +use OCP\AppFramework\Utility\ITimeFactory; + +/** + * keepiq.expiryReport: the caller's certificates and secrets that expire + * within a window, and those already expired. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-expiry-report-tool + */ +class ExpiryReportTools { + + /** + * Constructor. + * + * @param CertificateLifecycleService $certificates The certificate inventory + * @param SecretMapper $secretMapper The caller's secrets (expiry dates) + * @param ITimeFactory $time The clock + * @param McpToolContext $context The principal and the audit + * @param MetadataAllowList $allowList The keys a result may carry + * + * @return void + */ + public function __construct( + private CertificateLifecycleService $certificates, + private SecretMapper $secretMapper, + private ITimeFactory $time, + private McpToolContext $context, + private MetadataAllowList $allowList = new MetadataAllowList(), + ) { + }//end __construct() + + #[McpTool( + name: 'expiryReport', + description: 'List your Keepiq certificates and secrets that expire within the given number of days ' + . '(default 30, at most 365), and those already expired, with subject, issuer, serial, expiry ' + . 'date and days remaining. Never returns a certificate body, key or secret value.', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + scope: 'read', + subject: 'entry', + action: 'list' + )] + /** + * Report what expires within withinDays, plus what already expired. + * + * @param int $withinDays The window in days, 0 to 365 + * + * @return array{withinDays: int, certificates: list>, secrets: list>} + * + * @throws InvalidArgumentException On a window outside 0..365 + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-expiry-report-tool + */ + public function expiryReport(int $withinDays = 30): array { + if ($withinDays < 0 || $withinDays > 365) { + throw new InvalidArgumentException('withinDays must be between 0 and 365'); + } + + $userId = $this->context->userId(); + $now = $this->time->getDateTime(); + $until = (clone $now)->modify('+' . $withinDays . ' days'); + + [$certificates, $certificateIds] = $this->expiringCertificates(userId: $userId, now: $now, until: $until); + + $secrets = $this->expiringSecrets(userId: $userId, now: $now, until: $until, skip: $certificateIds); + + $this->context->audit(userId: $userId, tool: 'expiryReport', resultCount: (count($certificates) + count($secrets))); + return ['withinDays' => $withinDays, 'certificates' => $certificates, 'secrets' => $secrets]; + }//end expiryReport() + + /** + * The user's live secrets that lapse by $until, leaving out the ids in + * $skip (stored certificates, already in the certificate list). + * + * @param string $userId The principal + * @param DateTime $now Now + * @param DateTime $until The end of the window + * @param array $skip Ids to leave out + * + * @return list> + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-expiry-report-tool + */ + private function expiringSecrets(string $userId, DateTime $now, DateTime $until, array $skip): array { + $secrets = []; + foreach ($this->secretMapper->findByOwner(ownerType: 'user', ownerId: $userId, state: SecretMapper::STATE_LIVE) as $secret) { + $when = $secret->getExpiresAt(); + if ($when === null || $when > $until || isset($skip[$secret->getId()]) === true) { + continue; + } + + $secrets[] = $this->allowList->project( + row: [ + 'id' => $secret->getId(), + 'name' => $secret->getName(), + 'expiresAt' => $when->format(DateTimeInterface::ATOM), + 'daysRemaining' => $this->daysBetween(from: $now, to: $when), + 'expired' => ($when < $now), + ], + type: 'expiringSecret' + ); + } + + return $secrets; + }//end expiringSecrets() + + /** + * The user's stored certificates that lapse by $until, and the ids of + * every stored certificate (so the secret list does not repeat them). + * + * @param string $userId The principal + * @param DateTime $now Now + * @param DateTime $until The end of the window + * + * @return array{0: list>, 1: array} + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-expiry-report-tool + */ + private function expiringCertificates(string $userId, DateTime $now, DateTime $until): array { + $certificates = []; + $certificateIds = []; + foreach ($this->certificates->inventory(userId: $userId, isAdmin: false)['stored'] as $row) { + $metadata = ($row['metadata'] ?? []); + $notAfter = ($metadata['notAfter'] ?? null) ?? ($row['expiresAt'] ?? null); + $when = $this->parse(value: $notAfter); + $certificateIds[(string)$row['id']] = true; + if ($when === null || $when > $until) { + continue; + } + + $certificates[] = $this->allowList->project( + row: [ + 'id' => $row['id'], + 'name' => $row['name'], + 'subject' => ($metadata['subject'] ?? null), + 'issuer' => ($metadata['issuer'] ?? null), + 'serial' => ($metadata['serial'] ?? null), + 'fingerprintSha256' => ($metadata['fingerprintSha256'] ?? null), + 'notAfter' => $when->format(DateTimeInterface::ATOM), + 'daysRemaining' => $this->daysBetween(from: $now, to: $when), + 'expired' => ($when < $now), + ], + type: 'certificate' + ); + }//end foreach + + return [$certificates, $certificateIds]; + }//end expiringCertificates() + + /** + * Parse an ISO 8601 value. + * + * @param mixed $value The value + * + * @return DateTime|null + */ + private function parse(mixed $value): ?DateTime { + if (is_string($value) === false || $value === '') { + return null; + } + + $parsed = date_create_from_format(DateTimeInterface::ATOM, $value); + if ($parsed === false) { + return null; + } + + return $parsed; + }//end parse() + + /** + * Whole days from one moment to another; negative when past. + * + * @param DateTime $from The start + * @param DateTime $to The end + * + * @return int + */ + private function daysBetween(DateTime $from, DateTime $to): int { + return (int)floor(($to->getTimestamp() - $from->getTimestamp()) / 86400); + }//end daysBetween() +}//end class diff --git a/lib/Mcp/KeepiqScannableServices.php b/lib/Mcp/KeepiqScannableServices.php new file mode 100644 index 000000000..28635d561 --- /dev/null +++ b/lib/Mcp/KeepiqScannableServices.php @@ -0,0 +1,54 @@ + + * @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\Mcp; + +use OCA\OpenRegister\Mcp\IMcpScannableServices; + +/** + * Tells OpenRegister which Keepiq classes its scanner may reflect for + * #[McpTool] methods: exactly the three metadata read facades. Registered + * under the IMcpScannableServices::keepiq alias in Application::register(). + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-surface-is-exposed-only-through-the-scannable-services-opt-in + */ +class KeepiqScannableServices implements IMcpScannableServices { + + /** + * The scannable classes. + * + * @var list + */ + public const CLASSES = [ + EntryMetadataTools::class, + ExpiryReportTools::class, + RotationStatusTools::class, + ]; + + /** + * The classes OpenRegister may scan. + * + * @return list + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-surface-is-exposed-only-through-the-scannable-services-opt-in + */ + public function getScannableServiceClasses(): array { + return self::CLASSES; + }//end getScannableServiceClasses() +}//end class diff --git a/lib/Mcp/McpToolContext.php b/lib/Mcp/McpToolContext.php new file mode 100644 index 000000000..92fcc028f --- /dev/null +++ b/lib/Mcp/McpToolContext.php @@ -0,0 +1,93 @@ + + * @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\Mcp; + +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserSession; +use RuntimeException; + +/** + * The invoking principal of a tool call, and the audit record of the call: + * actor `mcp`, the principal, the tool name and the result count. Never an + * entry name, subject or value. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-invocations-are-audited-as-agent-reads + */ +class McpToolContext { + + /** + * Constructor. + * + * @param IUserSession $userSession The session the agent acts in + * @param IEventDispatcher $dispatcher The audit event dispatcher + * @param AuditEventFactory $events Builds the audit event + * + * @return void + */ + public function __construct( + private IUserSession $userSession, + private IEventDispatcher $dispatcher, + private AuditEventFactory $events = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * The session user: the only principal a tool ever reads for. + * + * @return string + * + * @throws RuntimeException Without a session user + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-metadata-only-entry-listing-tool + */ + public function userId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new RuntimeException('Keepiq MCP tools need a signed-in user'); + } + + return $user->getUID(); + }//end userId() + + /** + * Record one tool invocation. + * + * @param string $userId The principal + * @param string $tool The tool name + * @param int $resultCount How many rows the tool returned + * + * @return void + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-invocations-are-audited-as-agent-reads + */ + public function audit(string $userId, string $tool, int $resultCount): void { + $this->dispatcher->dispatchTyped( + $this->events->forMcp( + actorId: $userId, + eventType: AuditEventTypes::MCP_TOOL_INVOKED, + objectType: 'mcp_tool', + objectId: $tool, + metadata: ['tool' => $tool, 'resultCount' => $resultCount], + ) + ); + }//end audit() +}//end class diff --git a/lib/Mcp/MetadataAllowList.php b/lib/Mcp/MetadataAllowList.php new file mode 100644 index 000000000..9de12479b --- /dev/null +++ b/lib/Mcp/MetadataAllowList.php @@ -0,0 +1,81 @@ + + * @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\Mcp; + +use InvalidArgumentException; + +/** + * The only keys a Keepiq MCP tool result may carry, per result type. + * + * An allow-list fails closed: a column added to Secret later does not reach + * an agent until it is named here, in a change to the mcp-metadata-surface + * capability. Secret values, ciphertext (key, login, additionalFields) and + * encryptionSuiteId are on no list. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-no-tool-ever-returns-secret-material + */ +final class MetadataAllowList { + + /** + * Allowed keys per result type. + * + * @var array> + */ + public const KEYS = [ + 'entry' => ['id', 'name', 'url', 'typeId', 'folderId', 'expiresAt', 'keyUpdatedAt', 'possiblyCompromisedAt', 'tombstonedAt'], + 'certificate' => ['id', 'name', 'subject', 'issuer', 'serial', 'notAfter', 'daysRemaining', 'expired', 'fingerprintSha256'], + 'expiringSecret' => ['id', 'name', 'expiresAt', 'daysRemaining', 'expired'], + 'rotationFlag' => ['id', 'name', 'reason', 'status', 'flaggedAt', 'keyUpdatedAtAtFlag'], + ]; + + /** + * Keep only the allow-listed keys of one row, in allow-list order. + * Values that are not scalars or null are dropped too, so a nested + * structure cannot carry anything past the list. + * + * @param array $row The source row + * @param string $type One of the KEYS result types + * + * @return array + * + * @throws InvalidArgumentException On an unknown result type + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-no-tool-ever-returns-secret-material + */ + public function project(array $row, string $type): array { + if (isset(self::KEYS[$type]) === false) { + throw new InvalidArgumentException('Unknown MCP result type: ' . $type); + } + + $out = []; + foreach (self::KEYS[$type] as $key) { + if (array_key_exists($key, $row) === false) { + continue; + } + + $value = $row[$key]; + if ($value === null || is_scalar($value) === true) { + $out[$key] = $value; + } + } + + return $out; + }//end project() +}//end class diff --git a/lib/Mcp/RotationStatusTools.php b/lib/Mcp/RotationStatusTools.php new file mode 100644 index 000000000..1ec64446a --- /dev/null +++ b/lib/Mcp/RotationStatusTools.php @@ -0,0 +1,122 @@ + + * @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\Mcp; + +use OCA\Keepiq\Db\RotationFlag; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Service\RotationFlagService; +use OCA\OpenRegister\Mcp\Attribute\McpTool; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * keepiq.rotationStatus: the caller's open rotation flags and counts. It + * changes no flag: rotating stays a client-side re-encryption plus a human + * "mark rotated" in the app. + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-rotation-status-tool + */ +class RotationStatusTools { + + /** + * Constructor. + * + * @param RotationFlagService $flags The rotation flags + * @param SecretMapper $secretMapper For the entry names + * @param McpToolContext $context The principal and the audit + * @param MetadataAllowList $allowList The keys a result may carry + * + * @return void + */ + public function __construct( + private RotationFlagService $flags, + private SecretMapper $secretMapper, + private McpToolContext $context, + private MetadataAllowList $allowList = new MetadataAllowList(), + ) { + }//end __construct() + + #[McpTool( + name: 'rotationStatus', + description: 'List the open rotation flags in your Keepiq vault with entry name, reason and dates, and ' + . 'count how many are open, overdue by policy and from a compromised key. Changes nothing.', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + scope: 'read', + subject: 'rotation_flag', + action: 'list' + )] + /** + * The caller's open flags and their counts. + * + * @return array{flags: list>, counts: array{open: int, overdue: int, compromised: int}} + * + * @spec openspec/specs/mcp-metadata-surface/spec.md#requirement-rotation-status-tool + */ + public function rotationStatus(): array { + $userId = $this->context->userId(); + $rows = []; + $counts = ['open' => 0, 'overdue' => 0, 'compromised' => 0]; + foreach ($this->flags->openFlags(userId: $userId) as $flag) { + if ($flag instanceof RotationFlag === false) { + continue; + } + + $counts['open']++; + if ($flag->getReason() === 'policy_expiry') { + $counts['overdue']++; + } + + if ($flag->getReason() === 'suite_compromise') { + $counts['compromised']++; + } + + $rows[] = $this->allowList->project( + row: [ + 'id' => $flag->getSecretId(), + 'name' => $this->nameOf(secretId: $flag->getSecretId()), + 'reason' => $flag->getReason(), + 'status' => $flag->getStatus(), + 'flaggedAt' => $flag->getFlaggedAt()?->format('c'), + 'keyUpdatedAtAtFlag' => $flag->getKeyUpdatedAtAtFlag()?->format('c'), + ], + type: 'rotationFlag' + ); + }//end foreach + + $this->context->audit(userId: $userId, tool: 'rotationStatus', resultCount: count($rows)); + return ['flags' => $rows, 'counts' => $counts]; + }//end rotationStatus() + + /** + * The entry name of a flagged secret, null when it is gone. + * + * @param string $secretId The secret + * + * @return string|null + */ + private function nameOf(string $secretId): ?string { + try { + return $this->secretMapper->findById($secretId)->getName(); + } catch (DoesNotExistException) { + return null; + } + }//end nameOf() +}//end class diff --git a/lib/Middleware/OcsRefusalMiddleware.php b/lib/Middleware/OcsRefusalMiddleware.php new file mode 100644 index 000000000..4b41be472 --- /dev/null +++ b/lib/Middleware/OcsRefusalMiddleware.php @@ -0,0 +1,93 @@ + + * @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\Middleware; + +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCSController; + +/** + * Turns every 403 of a Keepiq OCSController into a 428 the OCS layer leaves alone. + * + * Nextcloud runs every app middleware's afterController before OCSMiddleware's, + * so the status is changed before the rewrite looks at it. That holds for a + * refusal a Keepiq controller returns and for one a middleware returns, + * Nextcloud's own included (not an admin, password confirmation required): + * Nextcloud only asks the middlewares that already ran beforeController to + * handle an exception, but it runs afterController on all of them. So every + * refusal on a Keepiq OCS route reaches the client as 428 with an `error`; + * before, Nextcloud's own ones arrived as an HTTP 200 envelope too. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ +class OcsRefusalMiddleware extends Middleware { + /** + * The status every refusal leaves with. + */ + public const REFUSAL_STATUS = Http::STATUS_PRECONDITION_REQUIRED; + + /** + * Re-status a 403 on a Keepiq OCSController as 428 with an `error` code. + * + * @param mixed $controller The controller + * @param string $methodName The method + * @param Response $response The controller's response + * + * @return Response + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) $methodName is mandated by + * OCP\AppFramework\Middleware::afterController(). + * + * @spec openspec/changes/archive/2026-10-04-harden-vault-key-material-guards/tasks.md#task-6.5 + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-only-the-owner-governs-managers-and-the-folder-itself + */ + public function afterController($controller, $methodName, Response $response): Response { + if (($controller instanceof OCSController) === false + || ($response instanceof JSONResponse) === false + || $response->getStatus() !== Http::STATUS_FORBIDDEN + ) { + return $response; + } + + $data = $response->getData(); + if (is_array($data) === false) { + $data = ['message' => (string)$data]; + } + + if (isset($data['error']) === false) { + $data['error'] = $data['code'] ?? 'forbidden'; + } + + $response->setData($data); + $response->setStatus(self::REFUSAL_STATUS); + return $response; + }//end afterController() +}//end class diff --git a/lib/Middleware/VaultKeyProofMiddleware.php b/lib/Middleware/VaultKeyProofMiddleware.php index d3758822c..70c2328c5 100644 --- a/lib/Middleware/VaultKeyProofMiddleware.php +++ b/lib/Middleware/VaultKeyProofMiddleware.php @@ -35,15 +35,19 @@ use OCA\Keepiq\Attribute\VaultKeyProofRequired; use OCA\Keepiq\Db\EncryptionSuite; use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; use OCA\Keepiq\Exception\KeyProofRequiredException; use OCA\Keepiq\Service\EncryptionSuiteService; +use OCA\Keepiq\Service\VaultKeyProofExemption; use OCA\Keepiq\Service\VaultKeyProofService; use OCP\AppFramework\Controller; -use OCP\AppFramework\Http; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Middleware; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IRequest; use OCP\IUserSession; +use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use ReflectionMethod; use Throwable; @@ -78,6 +82,10 @@ class VaultKeyProofMiddleware extends Middleware { * @param VaultKeyProofService $proofService Verifies the proof * @param SuiteMigrationMapper $migrationMapper Resolves a migration's old suite * @param LoggerInterface $logger Records every refused proof + * @param IEventDispatcher|null $eventDispatcher Carries the refusal to the audit trail + * @param AuditEventFactory $auditEvents Builds the refusal's audit event + * @param ContainerInterface|null $container Resolves an attribute's exemption; + * without it no exemption applies * * @return void */ @@ -88,6 +96,9 @@ public function __construct( private VaultKeyProofService $proofService, private SuiteMigrationMapper $migrationMapper, private LoggerInterface $logger, + private ?IEventDispatcher $eventDispatcher = null, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + private ?ContainerInterface $container = null, ) { }//end __construct() @@ -116,6 +127,10 @@ public function beforeController($controller, $methodName): void { } $userId = $user->getUID(); + if ($this->isExempt(attribute: $attribute, userId: $userId) === true) { + return; + } + $certificate = $this->subjectCertificate(attribute: $attribute, userId: $userId); $boundValues = []; @@ -134,7 +149,11 @@ public function beforeController($controller, $methodName): void { }//end beforeController() /** - * Translate a failed guard into a 403 the client can act on. + * Translate a failed guard into a 428 the client can act on. + * + * 428 Precondition Required, not 403: Nextcloud's OCSMiddleware rewrites a + * 403 of an OCSController into an HTTP 200 OCS envelope without the + * `error`, and most guarded routes are on one (measured live, 4 Oct 2026). * * @param Controller $controller The controller * @param string $methodName The method @@ -147,6 +166,9 @@ public function beforeController($controller, $methodName): void { * @SuppressWarnings(PHPMD.UnusedFormalParameter) $controller and $methodName * are mandated by OCP\AppFramework\Middleware::afterException(), which this * overrides; only the exception is acted on. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + * @spec openspec/changes/archive/2026-10-04-harden-vault-key-material-guards/tasks.md#task-6.5 */ public function afterException($controller, $methodName, Throwable $exception): JSONResponse { if (($exception instanceof KeyProofRequiredException) === false) { @@ -154,27 +176,69 @@ public function afterException($controller, $methodName, Throwable $exception): } // A refusal is exactly what a session-only attacker produces, so it must - // leave a record rather than only a 403 (#804 review). + // leave a record rather than only a refusal (#804 review): in nextcloud.log, + // and in the audit trail the SIEM export reads (keepiq#870). + $userId = $this->userSession->getUser()?->getUID(); + $route = $controller::class . '::' . $methodName; + $purpose = $this->attributeFor(controller: $controller, methodName: $methodName)?->getPurpose(); $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(), + 'userId' => $userId, + 'route' => $route, + 'purpose' => $purpose, 'reason' => $exception->getMessage(), ] ); + $this->auditRefusal(userId: $userId, route: $route, purpose: $purpose, reason: $exception->getMessage()); return new JSONResponse( data: [ 'error' => 'key_proof_required', 'message' => $exception->getMessage(), ], - statusCode: Http::STATUS_FORBIDDEN + statusCode: OcsRefusalMiddleware::REFUSAL_STATUS ); }//end afterException() + /** + * Record a refused proof in the audit trail. + * + * The reason is one of the fixed messages KeyProofRequiredException is + * thrown with, never request data; the proof, nonce and signature are not + * recorded. Without a session user (the framework's own auth refused + * first) there is nobody to attribute it to, and no record is written. + * + * @param string|null $userId The acting user + * @param string $route Controller::method of the guarded route + * @param string|null $purpose The guarded operation's purpose + * @param string $reason Why the proof was refused + * + * @return void + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + private function auditRefusal(?string $userId, string $route, ?string $purpose, string $reason): void { + if ($userId === null) { + return; + } + + $this->eventDispatcher?->dispatchTyped( + $this->auditEvents->forUser( + actorId: $userId, + eventType: AuditEventTypes::KEY_PROOF_REFUSED, + objectType: 'key_proof', + objectId: $purpose, + metadata: [ + 'route' => $route, + 'purpose' => $purpose, + 'reason' => $reason, + ], + ) + ); + }//end auditRefusal() + /** * The #[VaultKeyProofRequired] attribute on the method, or null. * @@ -193,6 +257,40 @@ private function attributeFor($controller, string $methodName): ?VaultKeyProofRe return $attributes[0]->newInstance(); }//end attributeFor() + /** + * Whether the attribute's exemption waives the proof for this request + * (keepiq#818). Fails closed: no exemption declared, no container, a class + * that is not a VaultKeyProofExemption, or any error means a proof is needed. + * + * @param VaultKeyProofRequired $attribute The guard declaration + * @param string $userId The acting user + * + * @return bool + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + private function isExempt(VaultKeyProofRequired $attribute, string $userId): bool { + $class = $attribute->getExemption(); + if ($class === '' || $this->container === null) { + return false; + } + + try { + $exemption = $this->container->get($class); + if (($exemption instanceof VaultKeyProofExemption) === false) { + return false; + } + + return $exemption->exempts(request: $this->request, userId: $userId) === true; + } catch (Throwable $e) { + $this->logger->warning( + 'Keepiq: vault key proof exemption {class} failed; requiring a proof', + ['app' => 'keepiq', 'class' => $class, 'exception' => $e] + ); + return false; + } + }//end isExempt() + /** * Resolve the certificate whose public key verifies the proof. * @@ -232,7 +330,7 @@ private function subjectCertificate(VaultKeyProofRequired $attribute, string $us * * @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 + * @spec openspec/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') { diff --git a/lib/Migration/Version001000Date20260908000000.php b/lib/Migration/Version001000Date20260908000000.php index 9f8e2cfb9..1db6463b2 100644 --- a/lib/Migration/Version001000Date20260908000000.php +++ b/lib/Migration/Version001000Date20260908000000.php @@ -35,6 +35,9 @@ * * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Most of the class is the SCHEMA + * constant, one entry per table; new tables are declared here by rule. + * * @psalm-type ColumnSpec = array{0: string, 1: string, 2: array} * @psalm-type IndexSpec = array{0: string, 1: list} * @psalm-type TableSpec = array{columns: list, primary: list, @@ -299,6 +302,31 @@ class Version001000Date20260908000000 extends SimpleMigrationStep { ], 'uniqueIndexes' => [], ], + // New device approval (crypto-new-device-approval); also added to + // existing installs by Version001009Date20261002182000. + 'device_approvals' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['user_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['client_kind', Types::STRING, ['notnull' => true, 'length' => 16]], + ['device_label', Types::STRING, ['notnull' => true, 'length' => 255]], + ['requester_ip', Types::STRING, ['notnull' => true, 'length' => 64]], + ['requester_agent', Types::STRING, ['notnull' => true, 'length' => 512]], + ['request_public_key', Types::TEXT, ['notnull' => true]], + ['request_secret_hash', Types::STRING, ['notnull' => true, 'length' => 64]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16]], + ['created_at', Types::DATETIME, ['notnull' => true]], + ['expires_at', Types::DATETIME, ['notnull' => true]], + ['decided_at', Types::DATETIME, ['notnull' => false]], + ['sealed_unlock_key', Types::TEXT, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_dev_appr_user_idx', ['user_id', 'status']], + ['keepiq_dev_appr_exp_idx', ['status', 'expires_at']], + ], + 'uniqueIndexes' => [], + ], 'ephemeral_sends' => [ 'columns' => [ ['id', Types::STRING, ['notnull' => true, 'length' => 36]], @@ -344,6 +372,74 @@ class Version001000Date20260908000000 extends SimpleMigrationStep { ['keepiq_ep_scope_uniq', ['owner_id', 'scope', 'scope_id']], ], ], + 'federated_inbound' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recipient_uid', Types::STRING, ['notnull' => true, 'length' => 64]], + ['sender_cloud_id', Types::STRING, ['notnull' => true, 'length' => 255]], + ['partner_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['remote_share_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['name', Types::STRING, ['notnull' => true, 'length' => 255]], + ['shared_secret_enc', Types::TEXT, ['notnull' => true]], + ['secret_id', Types::STRING, ['notnull' => false, 'length' => 36]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'pending']], + ['received_at', Types::DATETIME, ['notnull' => true]], + ['updated_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_fi_recipient_idx', ['recipient_uid']], + ['keepiq_fi_secret_idx', ['secret_id']], + ], + 'uniqueIndexes' => [ + ['keepiq_fi_remote_uniq', ['partner_id', 'remote_share_id']], + ], + ], + 'federated_shares' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['source_secret_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['owner_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['recipient_cloud_id', Types::STRING, ['notnull' => true, 'length' => 255]], + ['partner_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recipient_cert_fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]], + ['key', Types::TEXT, ['notnull' => false]], + ['login', Types::TEXT, ['notnull' => false]], + ['additional_fields', Types::TEXT, ['notnull' => false]], + ['shared_secret_hash', Types::STRING, ['notnull' => true, 'length' => 64]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'active']], + ['pending_notification', Types::STRING, ['notnull' => false, 'length' => 32]], + ['notify_attempts', Types::INTEGER, ['notnull' => true, 'default' => 0]], + ['next_notify_at', Types::DATETIME, ['notnull' => false]], + ['created_at', Types::DATETIME, ['notnull' => true]], + ['updated_at', Types::DATETIME, ['notnull' => true]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_fs_source_idx', ['source_secret_id']], + ['keepiq_fs_owner_idx', ['owner_id']], + ['keepiq_fs_partner_idx', ['partner_id']], + ['keepiq_fs_notify_idx', ['next_notify_at']], + ], + 'uniqueIndexes' => [], + ], + 'federation_partners' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['base_url', Types::STRING, ['notnull' => true, 'length' => 255]], + ['host', Types::STRING, ['notnull' => true, 'length' => 255]], + ['root_fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]], + ['allow_outbound', Types::BOOLEAN, ['notnull' => false, 'default' => false]], + ['allow_inbound', Types::BOOLEAN, ['notnull' => false, 'default' => false]], + ['added_by', Types::STRING, ['notnull' => true, 'length' => 64]], + ['added_at', Types::DATETIME, ['notnull' => true]], + ], + 'primary' => ['id'], + 'indexes' => [], + 'uniqueIndexes' => [ + ['keepiq_fedp_host_uniq', ['host']], + ], + ], 'folders' => [ 'columns' => [ ['id', Types::STRING, ['notnull' => true, 'length' => 36]], @@ -545,6 +641,96 @@ class Version001000Date20260908000000 extends SimpleMigrationStep { ], 'uniqueIndexes' => [], ], + // Organisation account recovery (crypto-organisation-account-recovery); + // also added to existing installs by Version001010Date20261002183000. + 'recovery_keys' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['certificate', Types::TEXT, ['notnull' => true]], + ['fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]], + ['threshold', Types::INTEGER, ['notnull' => true, 'default' => 1]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16]], + ['created_by', Types::STRING, ['notnull' => true, 'length' => 64]], + ['created_at', Types::DATETIME, ['notnull' => false]], + ['retired_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_rk_status_idx', ['status']], + ], + 'uniqueIndexes' => [], + ], + 'recovery_officers' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recovery_key_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['officer_uid', Types::STRING, ['notnull' => true, 'length' => 64]], + ['officer_suite_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['wrapped_private_key', Types::TEXT, ['notnull' => true]], + ['added_by', Types::STRING, ['notnull' => true, 'length' => 64]], + ['added_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_ro_officer_idx', ['officer_uid']], + ], + 'uniqueIndexes' => [ + ['keepiq_ro_key_officer_uniq', ['recovery_key_id', 'officer_uid']], + ], + ], + 'recovery_enrolments' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['user_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['suite_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recovery_key_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['envelope', Types::TEXT, ['notnull' => true]], + ['enrolled_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_re_user_idx', ['user_id']], + ['keepiq_re_suite_idx', ['suite_id']], + ['keepiq_re_key_idx', ['recovery_key_id']], + ], + 'uniqueIndexes' => [], + ], + 'recovery_requests' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['user_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['suite_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['enrolment_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['request_public_key', Types::TEXT, ['notnull' => true]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16]], + ['created_at', Types::DATETIME, ['notnull' => false]], + ['expires_at', Types::DATETIME, ['notnull' => false]], + ['handled_by', Types::STRING, ['notnull' => false, 'length' => 64]], + ['sealed_result', Types::TEXT, ['notnull' => false]], + ['fulfilled_at', Types::DATETIME, ['notnull' => false]], + ['purpose', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'password']], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_rr_user_idx', ['user_id']], + ['keepiq_rr_status_idx', ['status', 'expires_at']], + ], + 'uniqueIndexes' => [], + ], + 'recovery_approvals' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['request_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['officer_uid', Types::STRING, ['notnull' => true, 'length' => 64]], + ['decision', Types::STRING, ['notnull' => true, 'length' => 16]], + ['decided_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [], + 'uniqueIndexes' => [ + ['keepiq_ra_request_officer_uniq', ['request_id', 'officer_uid']], + ], + ], 'secret_requests' => [ 'columns' => [ ['id', Types::STRING, ['notnull' => true, 'length' => 36]], @@ -569,6 +755,23 @@ class Version001000Date20260908000000 extends SimpleMigrationStep { ['keepiq_sr_token_uniq', ['token']], ], ], + // Favourites, tags and last used (vault-favourites-tags-and-last-used); also + // added to existing installs by Version001003Date20261002000000. + 'secret_tags' => [ + 'columns' => [ + ['id', Types::BIGINT, ['notnull' => true, 'autoincrement' => true, 'unsigned' => true]], + ['secret_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['owner_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['tag', Types::STRING, ['notnull' => true, 'length' => 32]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_sec_tags_owner_tag', ['owner_id', 'tag']], + ], + 'uniqueIndexes' => [ + ['keepiq_sec_tags_secret_tag', ['secret_id', 'tag']], + ], + ], 'secret_types' => [ 'columns' => [ ['id', Types::STRING, ['notnull' => true, 'length' => 36]], @@ -740,6 +943,23 @@ class Version001000Date20260908000000 extends SimpleMigrationStep { ['keepiq_tfm_membership_uniq', ['team_folder_id', 'member_type', 'member_id']], ], ], + // Consumed vault-key-proof challenges, so every proof is single-use on every + // install (keepiq#868); also added to existing installs by + // Version001004Date20261002120000. + 'used_proofs' => [ + 'columns' => [ + ['id', Types::BIGINT, ['notnull' => true, 'autoincrement' => true, 'unsigned' => true]], + ['nonce_hash', Types::STRING, ['notnull' => true, 'length' => 64]], + ['expires_at', Types::BIGINT, ['notnull' => true]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_used_proofs_exp', ['expires_at']], + ], + 'uniqueIndexes' => [ + ['keepiq_used_proofs_hash', ['nonce_hash']], + ], + ], 'team_folders' => [ 'columns' => [ ['id', Types::STRING, ['notnull' => true, 'length' => 36]], diff --git a/lib/Migration/Version001001Date20260930000000.php b/lib/Migration/Version001001Date20260930000000.php new file mode 100644 index 000000000..c5890993b --- /dev/null +++ b/lib/Migration/Version001001Date20260930000000.php @@ -0,0 +1,67 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds a nullable `fields` JSON text column to the secret types table. + * + * Built-in types keep a null list and so keep their own forms. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ +class Version001001Date20260930000000 extends SimpleMigrationStep { + + /** + * Add the column when it is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_secret_types') === false) { + return null; + } + + $table = $schema->getTable('keepiq_secret_types'); + if ($table->hasColumn('fields') === true) { + return null; + } + + $table->addColumn('fields', Types::TEXT, ['notnull' => false]); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001002Date20260930120000.php b/lib/Migration/Version001002Date20260930120000.php new file mode 100644 index 000000000..28e318b16 --- /dev/null +++ b/lib/Migration/Version001002Date20260930120000.php @@ -0,0 +1,77 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds nullable `trashed_at` and `archived_at` to the secrets table and an + * index on (owner_id, trashed_at). Existing rows stay live (both null). + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-deleting-a-secret-moves-it-to-the-trash + */ +class Version001002Date20260930120000 extends SimpleMigrationStep { + + /** + * Add the columns and the index when they are missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-deleting-a-secret-moves-it-to-the-trash + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_secrets') === false) { + return null; + } + + $table = $schema->getTable('keepiq_secrets'); + $changed = false; + foreach (['trashed_at', 'archived_at'] as $column) { + if ($table->hasColumn($column) === false) { + $table->addColumn($column, Types::DATETIME, ['notnull' => false]); + $changed = true; + } + } + + if ($table->hasIndex('keepiq_secrets_owner_trash') === false) { + $table->addIndex(['owner_id', 'trashed_at'], 'keepiq_secrets_owner_trash'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001003Date20261002000000.php b/lib/Migration/Version001003Date20261002000000.php new file mode 100644 index 000000000..8d74b9f77 --- /dev/null +++ b/lib/Migration/Version001003Date20261002000000.php @@ -0,0 +1,103 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `is_favourite` and `last_used_at` to the secrets table and the + * `keepiq_secret_tags` table. Existing rows are not favourites and have + * never been used. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ +class Version001003Date20261002000000 extends SimpleMigrationStep { + + /** + * Add the columns and the tag table when they are missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_secrets') === false) { + return null; + } + + $changed = $this->addSecretColumns(schema: $schema); + if ($schema->hasTable('keepiq_secret_tags') === false) { + $table = $schema->createTable('keepiq_secret_tags'); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]); + $table->addColumn('secret_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('owner_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('tag', Types::STRING, ['notnull' => true, 'length' => 32]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['secret_id', 'tag'], 'keepiq_sec_tags_secret_tag'); + $table->addIndex(['owner_id', 'tag'], 'keepiq_sec_tags_owner_tag'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * Add `is_favourite` and `last_used_at` to the secrets table. + * + * @param ISchemaWrapper $schema The schema + * + * @return bool Whether anything was added + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + */ + private function addSecretColumns(ISchemaWrapper $schema): bool { + $table = $schema->getTable('keepiq_secrets'); + $changed = false; + if ($table->hasColumn('is_favourite') === false) { + // Nextcloud boolean columns must be nullable (Oracle has no false). + $table->addColumn('is_favourite', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $changed = true; + } + + if ($table->hasColumn('last_used_at') === false) { + $table->addColumn('last_used_at', Types::DATETIME, ['notnull' => false]); + $changed = true; + } + + return $changed; + }//end addSecretColumns() +}//end class diff --git a/lib/Migration/Version001004Date20261002120000.php b/lib/Migration/Version001004Date20261002120000.php new file mode 100644 index 000000000..a3615180b --- /dev/null +++ b/lib/Migration/Version001004Date20261002120000.php @@ -0,0 +1,69 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the `keepiq_used_proofs` table. A unique index on the hash of each + * consumed vault-key-proof challenge makes every proof single-use, with or + * without a memcache and across cluster nodes (keepiq#868). Nothing is + * migrated: proofs in flight at upgrade time live five minutes at most. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ +class Version001004Date20261002120000 extends SimpleMigrationStep { + + /** + * Create the table when it is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_used_proofs') === true) { + return null; + } + + $table = $schema->createTable('keepiq_used_proofs'); + $table->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]); + $table->addColumn('nonce_hash', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('expires_at', Types::BIGINT, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['nonce_hash'], 'keepiq_used_proofs_hash'); + $table->addIndex(['expires_at'], 'keepiq_used_proofs_exp'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001004Date20261002130000.php b/lib/Migration/Version001004Date20261002130000.php new file mode 100644 index 000000000..ac6ba0cfd --- /dev/null +++ b/lib/Migration/Version001004Date20261002130000.php @@ -0,0 +1,68 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the nullable `pending_additional_fields` column to the secrets table: + * extra-field ciphertexts a secret request filled in, kept apart from the + * owner's own blob until the owner's browser merges them (keepiq#750). + * Existing rows have nothing pending. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ +class Version001004Date20261002130000 extends SimpleMigrationStep { + + /** + * Add the column when it is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_secrets') === false) { + return null; + } + + $table = $schema->getTable('keepiq_secrets'); + if ($table->hasColumn('pending_additional_fields') === true) { + return null; + } + + $table->addColumn('pending_additional_fields', Types::TEXT, ['notnull' => false]); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001004Date20261002170000.php b/lib/Migration/Version001004Date20261002170000.php new file mode 100644 index 000000000..b6089c501 --- /dev/null +++ b/lib/Migration/Version001004Date20261002170000.php @@ -0,0 +1,79 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the connector columns to the SIEM sinks table: `format` (json or cef, + * default json, so every existing sink keeps its behaviour), `credential_enc` + * (the Splunk HEC token or Sentinel client secret, ICrypto-encrypted) and + * `connector_options` (non-secret connector settings as JSON). + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md + */ +class Version001004Date20261002170000 extends SimpleMigrationStep { + + /** + * Add the three columns once. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the schema wrapper + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Interface-mandated signature. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_siem_sinks') === false) { + return null; + } + + $table = $schema->getTable('keepiq_siem_sinks'); + $changed = false; + $columns = [ + 'format' => [Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'json']], + 'credential_enc' => [Types::TEXT, ['notnull' => false]], + 'connector_options' => [Types::TEXT, ['notnull' => false]], + ]; + foreach ($columns as $name => [$type, $columnOptions]) { + if ($table->hasColumn($name) === false) { + $table->addColumn($name, $type, $columnOptions); + $changed = true; + } + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001007Date20261002180000.php b/lib/Migration/Version001007Date20261002180000.php new file mode 100644 index 000000000..d798adfa4 --- /dev/null +++ b/lib/Migration/Version001007Date20261002180000.php @@ -0,0 +1,77 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `client_kind` (web or extension, default web) and the nullable + * `rp_id` to the passkey credentials, so the browser extension can enrol + * its own platform passkey next to the web app's and each client is offered + * only its own credentials (keepiq#784). Existing rows become web. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/extension-biometric-unlock/spec.md#requirement-enrol-a-platform-passkey-for-extension-unlock + */ +class Version001007Date20261002180000 extends SimpleMigrationStep { + + /** + * Add the columns when they are missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/extension-biometric-unlock/spec.md#requirement-enrol-a-platform-passkey-for-extension-unlock + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_passkey_credentials') === false) { + return null; + } + + $table = $schema->getTable('keepiq_passkey_credentials'); + $changed = false; + if ($table->hasColumn('client_kind') === false) { + $table->addColumn('client_kind', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'web']); + $changed = true; + } + + if ($table->hasColumn('rp_id') === false) { + $table->addColumn('rp_id', Types::STRING, ['notnull' => false, 'length' => 255]); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001008Date20261002181000.php b/lib/Migration/Version001008Date20261002181000.php new file mode 100644 index 000000000..df05cba70 --- /dev/null +++ b/lib/Migration/Version001008Date20261002181000.php @@ -0,0 +1,126 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `use_only` and `expires_at` to the three grant tables (share targets, + * group shares, team-folder memberships) and `use_only` and + * `access_expires_at` to the secrets table, where the resolver materialises + * the effective values onto each recipient copy. Existing rows stay + * unrestricted. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.1 + */ +class Version001008Date20261002181000 extends SimpleMigrationStep { + + /** + * The grant tables that carry the two flags as the sharer set them. + * + * @var string[] + */ + public const GRANT_TABLES = [ + 'keepiq_share_targets', + 'keepiq_group_shares', + 'keepiq_team_folder_members', + ]; + + /** + * The index on the secrets table that the expiry filter and job use. + * + * @var string + */ + public const ACCESS_EXPIRES_INDEX = 'keepiq_sec_access_exp_idx'; + + /** + * Add the columns where they are missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.1 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + $changed = false; + foreach (self::GRANT_TABLES as $tableName) { + if ($schema->hasTable($tableName) === false) { + continue; + } + + $changed = $this->addFlags(schema: $schema, tableName: $tableName, expiryColumn: 'expires_at') || $changed; + } + + if ($schema->hasTable('keepiq_secrets') === true) { + $changed = $this->addFlags(schema: $schema, tableName: 'keepiq_secrets', expiryColumn: 'access_expires_at') || $changed; + $table = $schema->getTable('keepiq_secrets'); + if ($table->hasIndex(self::ACCESS_EXPIRES_INDEX) === false) { + $table->addIndex(['access_expires_at'], self::ACCESS_EXPIRES_INDEX); + $changed = true; + } + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * Add `use_only` and the given expiry column to one table. + * + * @param ISchemaWrapper $schema The schema + * @param string $tableName The table + * @param string $expiryColumn The name of the end-date column + * + * @return bool Whether anything was added + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.1 + */ + private function addFlags(ISchemaWrapper $schema, string $tableName, string $expiryColumn): bool { + $table = $schema->getTable($tableName); + $changed = false; + if ($table->hasColumn('use_only') === false) { + // Nextcloud boolean columns must be nullable (Oracle has no false). + $table->addColumn('use_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $changed = true; + } + + if ($table->hasColumn($expiryColumn) === false) { + $table->addColumn($expiryColumn, Types::DATETIME, ['notnull' => false]); + $changed = true; + } + + return $changed; + }//end addFlags() +}//end class diff --git a/lib/Migration/Version001009Date20261002182000.php b/lib/Migration/Version001009Date20261002182000.php new file mode 100644 index 000000000..4f8add1f1 --- /dev/null +++ b/lib/Migration/Version001009Date20261002182000.php @@ -0,0 +1,77 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `keepiq_device_approvals` to existing installs. A fresh install gets + * it from Version001000's SCHEMA, which declares the same table. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/changes/archive/2026-10-04-crypto-new-device-approval/tasks.md#task-1.1 + */ +class Version001009Date20261002182000 extends SimpleMigrationStep { + + /** + * Create the table when it is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/changes/archive/2026-10-04-crypto-new-device-approval/tasks.md#task-1.1 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_device_approvals') === true) { + return null; + } + + $table = $schema->createTable('keepiq_device_approvals'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('user_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('client_kind', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('device_label', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('requester_ip', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('requester_agent', Types::STRING, ['notnull' => true, 'length' => 512]); + $table->addColumn('request_public_key', Types::TEXT, ['notnull' => true]); + $table->addColumn('request_secret_hash', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('created_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('expires_at', Types::DATETIME, ['notnull' => true]); + $table->addColumn('decided_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('sealed_unlock_key', Types::TEXT, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id', 'status'], 'keepiq_dev_appr_user_idx'); + $table->addIndex(['status', 'expires_at'], 'keepiq_dev_appr_exp_idx'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001010Date20261002183000.php b/lib/Migration/Version001010Date20261002183000.php new file mode 100644 index 000000000..f99abfe35 --- /dev/null +++ b/lib/Migration/Version001010Date20261002183000.php @@ -0,0 +1,139 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds the five account recovery tables to existing installs. A fresh + * install gets them from Version001000's SCHEMA, which declares the same. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/changes/archive/2026-10-04-crypto-organisation-account-recovery/tasks.md#task-1.1 + */ +class Version001010Date20261002183000 extends SimpleMigrationStep { + + /** + * Create each table that is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) One flat column list per table. + * + * @spec openspec/changes/archive/2026-10-04-crypto-organisation-account-recovery/tasks.md#task-1.1 + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + $changed = false; + + if ($schema->hasTable('keepiq_recovery_keys') === false) { + $table = $schema->createTable('keepiq_recovery_keys'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('certificate', Types::TEXT, ['notnull' => true]); + $table->addColumn('fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('threshold', Types::INTEGER, ['notnull' => true, 'default' => 1]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('created_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('created_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('retired_at', Types::DATETIME, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addIndex(['status'], 'keepiq_rk_status_idx'); + $changed = true; + } + + if ($schema->hasTable('keepiq_recovery_officers') === false) { + $table = $schema->createTable('keepiq_recovery_officers'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('recovery_key_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('officer_uid', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('officer_suite_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('wrapped_private_key', Types::TEXT, ['notnull' => true]); + $table->addColumn('added_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('added_at', Types::DATETIME, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addIndex(['officer_uid'], 'keepiq_ro_officer_idx'); + $table->addUniqueIndex(['recovery_key_id', 'officer_uid'], 'keepiq_ro_key_officer_uniq'); + $changed = true; + } + + if ($schema->hasTable('keepiq_recovery_enrolments') === false) { + $table = $schema->createTable('keepiq_recovery_enrolments'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('user_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('suite_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('recovery_key_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('envelope', Types::TEXT, ['notnull' => true]); + $table->addColumn('enrolled_at', Types::DATETIME, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'keepiq_re_user_idx'); + $table->addIndex(['suite_id'], 'keepiq_re_suite_idx'); + $table->addIndex(['recovery_key_id'], 'keepiq_re_key_idx'); + $changed = true; + } + + if ($schema->hasTable('keepiq_recovery_requests') === false) { + $table = $schema->createTable('keepiq_recovery_requests'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('user_id', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('suite_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('enrolment_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('request_public_key', Types::TEXT, ['notnull' => true]); + $table->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('created_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('expires_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('handled_by', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('sealed_result', Types::TEXT, ['notnull' => false]); + $table->addColumn('fulfilled_at', Types::DATETIME, ['notnull' => false]); + $table->addColumn('purpose', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'password']); + $table->setPrimaryKey(['id']); + $table->addIndex(['user_id'], 'keepiq_rr_user_idx'); + $table->addIndex(['status', 'expires_at'], 'keepiq_rr_status_idx'); + $changed = true; + } + + if ($schema->hasTable('keepiq_recovery_approvals') === false) { + $table = $schema->createTable('keepiq_recovery_approvals'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('request_id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('officer_uid', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('decision', Types::STRING, ['notnull' => true, 'length' => 16]); + $table->addColumn('decided_at', Types::DATETIME, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['request_id', 'officer_uid'], 'keepiq_ra_request_officer_uniq'); + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001011Date20261002230000.php b/lib/Migration/Version001011Date20261002230000.php new file mode 100644 index 000000000..5bd6400fc --- /dev/null +++ b/lib/Migration/Version001011Date20261002230000.php @@ -0,0 +1,74 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds `keepiq_federation_partners` on existing installs: the partner + * instances an administrator approved, with the pinned root fingerprint and + * the outbound and inbound permissions (sharing-federated-recipients D1). + * Fresh installs get it from Version001000's SCHEMA. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ +class Version001011Date20261002230000 extends SimpleMigrationStep { + + /** + * Create the table when it is missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + if ($schema->hasTable('keepiq_federation_partners') === true) { + return null; + } + + $table = $schema->createTable('keepiq_federation_partners'); + $table->addColumn('id', Types::STRING, ['notnull' => true, 'length' => 36]); + $table->addColumn('base_url', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('host', Types::STRING, ['notnull' => true, 'length' => 255]); + $table->addColumn('root_fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]); + // Nextcloud boolean columns must be nullable (Oracle has no false). + $table->addColumn('allow_outbound', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $table->addColumn('allow_inbound', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $table->addColumn('added_by', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('added_at', Types::DATETIME, ['notnull' => true]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['host'], 'keepiq_fedp_host_uniq'); + + return $schema; + }//end changeSchema() +}//end class diff --git a/lib/Migration/Version001012Date20261004120000.php b/lib/Migration/Version001012Date20261004120000.php new file mode 100644 index 000000000..6955badf3 --- /dev/null +++ b/lib/Migration/Version001012Date20261004120000.php @@ -0,0 +1,199 @@ + + * @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\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Adds, on existing installs, the two tables of federated sharing + * (sharing-federated-recipients, Migration section of the design): + * + * - `keepiq_federated_shares`: the sending side, one row per secret shared + * with a user of a partner instance, holding the latest ciphertext made in + * the owner's browser for that recipient and the hash of the share's shared + * secret, plus the state of a notification still to be delivered. + * - `keepiq_federated_inbound`: the receiving side, one row per share a + * partner announced, with the shared secret encrypted by ICrypto so the + * server can present it unattended. + * + * And it adds `federated_source` (the sender's cloud id) and `read_only` to + * `keepiq_secrets`. Fresh installs get the tables from Version001000's + * SCHEMA; the two columns come from this step on every install, as the + * use-only columns come from Version001008. + * + * @psalm-suppress UnusedClass Loaded by the Nextcloud migration framework. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class Version001012Date20261004120000 extends SimpleMigrationStep { + /** + * The two tables, as Version001000's SCHEMA declares them. + * + * @var array}>,primary:list,indexes:list}>,uniqueIndexes:list}>}> + */ + private const TABLES = [ + 'keepiq_federated_shares' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['source_secret_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['owner_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['recipient_cloud_id', Types::STRING, ['notnull' => true, 'length' => 255]], + ['partner_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recipient_cert_fingerprint', Types::STRING, ['notnull' => true, 'length' => 64]], + ['key', Types::TEXT, ['notnull' => false]], + ['login', Types::TEXT, ['notnull' => false]], + ['additional_fields', Types::TEXT, ['notnull' => false]], + ['shared_secret_hash', Types::STRING, ['notnull' => true, 'length' => 64]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'active']], + ['pending_notification', Types::STRING, ['notnull' => false, 'length' => 32]], + ['notify_attempts', Types::INTEGER, ['notnull' => true, 'default' => 0]], + ['next_notify_at', Types::DATETIME, ['notnull' => false]], + ['created_at', Types::DATETIME, ['notnull' => true]], + ['updated_at', Types::DATETIME, ['notnull' => true]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_fs_source_idx', ['source_secret_id']], + ['keepiq_fs_owner_idx', ['owner_id']], + ['keepiq_fs_partner_idx', ['partner_id']], + ['keepiq_fs_notify_idx', ['next_notify_at']], + ], + 'uniqueIndexes' => [], + ], + 'keepiq_federated_inbound' => [ + 'columns' => [ + ['id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['recipient_uid', Types::STRING, ['notnull' => true, 'length' => 64]], + ['sender_cloud_id', Types::STRING, ['notnull' => true, 'length' => 255]], + ['partner_id', Types::STRING, ['notnull' => true, 'length' => 36]], + ['remote_share_id', Types::STRING, ['notnull' => true, 'length' => 64]], + ['name', Types::STRING, ['notnull' => true, 'length' => 255]], + ['shared_secret_enc', Types::TEXT, ['notnull' => true]], + ['secret_id', Types::STRING, ['notnull' => false, 'length' => 36]], + ['status', Types::STRING, ['notnull' => true, 'length' => 16, 'default' => 'pending']], + ['received_at', Types::DATETIME, ['notnull' => true]], + ['updated_at', Types::DATETIME, ['notnull' => false]], + ], + 'primary' => ['id'], + 'indexes' => [ + ['keepiq_fi_recipient_idx', ['recipient_uid']], + ['keepiq_fi_secret_idx', ['secret_id']], + ], + 'uniqueIndexes' => [ + ['keepiq_fi_remote_uniq', ['partner_id', 'remote_share_id']], + ], + ], + ]; + + /** + * Create the tables and add the columns where they are missing. + * + * @param IOutput $output The migration output + * @param Closure $schemaClosure Returns the current schema + * @param array $options Migration options + * + * @return ISchemaWrapper|null + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) Signature fixed by SimpleMigrationStep. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + $schema = $schemaClosure(); + $created = $this->createMissingTables(schema: $schema); + $added = $this->addSecretColumns(schema: $schema); + + if ($created === false && $added === false) { + return null; + } + + return $schema; + }//end changeSchema() + + /** + * Create each of the two tables that is missing. + * + * @param ISchemaWrapper $schema The schema + * + * @return bool Whether a table was created + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + private function createMissingTables(ISchemaWrapper $schema): bool { + $changed = false; + foreach (self::TABLES as $name => $spec) { + if ($schema->hasTable($name) === true) { + continue; + } + + $table = $schema->createTable($name); + foreach ($spec['columns'] as [$column, $type, $columnOptions]) { + $table->addColumn($column, $type, $columnOptions); + } + + $table->setPrimaryKey($spec['primary']); + foreach ($spec['indexes'] as [$indexName, $columns]) { + $table->addIndex($columns, $indexName); + } + + foreach ($spec['uniqueIndexes'] as [$indexName, $columns]) { + $table->addUniqueIndex($columns, $indexName); + } + + $changed = true; + }//end foreach + + return $changed; + }//end createMissingTables() + + /** + * Add `federated_source` and `read_only` to secrets where missing. + * + * @param ISchemaWrapper $schema The schema + * + * @return bool Whether a column was added + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-remote-copies-are-read-only + */ + private function addSecretColumns(ISchemaWrapper $schema): bool { + if ($schema->hasTable('keepiq_secrets') === false) { + return false; + } + + $secrets = $schema->getTable('keepiq_secrets'); + $changed = false; + if ($secrets->hasColumn('federated_source') === false) { + $secrets->addColumn('federated_source', Types::STRING, ['notnull' => false, 'length' => 255]); + $changed = true; + } + + if ($secrets->hasColumn('read_only') === false) { + // Nextcloud boolean columns must be nullable (Oracle has no false). + $secrets->addColumn('read_only', Types::BOOLEAN, ['notnull' => false, 'default' => false]); + $changed = true; + } + + return $changed; + }//end addSecretColumns() +}//end class diff --git a/lib/Notification/KeepiqNotifier.php b/lib/Notification/KeepiqNotifier.php index f13b28c73..d55f3be5b 100644 --- a/lib/Notification/KeepiqNotifier.php +++ b/lib/Notification/KeepiqNotifier.php @@ -38,6 +38,13 @@ * The subject IDs here must match NotificationService::SUBJECT_SETTING_MAP. * Each branch builds a short subject line, a longer message line and a * deep-link the user clicks to land on the affected secret / queue. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) 50 against a threshold of + * 50. Nextcloud registers one INotifier per app, so every Keepiq subject + * renders here; the complexity is the sum of about twenty small branches, + * already split per subject group. The share-request and group-member + * approval actions (keepiq#747) pushed it to the threshold. Splitting the + * class would only move branches into a second class this one calls. */ class KeepiqNotifier implements INotifier { /** @@ -99,24 +106,23 @@ public function prepare(INotification $notification, string $languageCode): INot // Each renderer owns one family of subjects and reports whether it // recognised this one. The first renderer that claims the subject wins. - $handled = $this->renderSharingSubject(notification: $notification, subject: $subj, params: $params, l: $l); - if ($handled === false) { - $handled = $this->renderSecretLifecycleSubject(notification: $notification, subject: $subj, params: $params, l: $l); - } - - if ($handled === false) { - $handled = $this->renderAdminSubject(notification: $notification, subject: $subj, params: $params, l: $l); - } - - if ($handled === false) { - $handled = $this->renderVaultAccessSubject(notification: $notification, subject: $subj, params: $params, l: $l); - } - - if ($handled === false) { - throw new UnknownNotificationException(); + $renderers = [ + fn (): bool => $this->renderSharingSubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderSecretLifecycleSubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderAdminSubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderVaultAccessSubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderEmergencySubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderDeviceApprovalSubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderRecoverySubject(notification: $notification, subject: $subj, params: $params, l: $l), + fn (): bool => $this->renderAccessEndSubject(notification: $notification, subject: $subj, params: $params, l: $l), + ]; + foreach ($renderers as $render) { + if ($render() === true) { + return $notification; + } } - return $notification; + throw new UnknownNotificationException(); }//end prepare() /** @@ -140,14 +146,30 @@ private function renderSharingSubject(INotification $notification, string $subje ); $this->withSecretLink(notification: $notification, params: $params); return true; + case 'federated_share_received': + $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); + $sharedBy = (string)($params['shared_by'] ?? $l->t('a user')); + $notification->setParsedSubject((string)$l->t('A secret from another organisation')); + $notification->setParsedMessage( + (string)$l->t('%1$s shared "%2$s" with you. Accept it under Incoming from other organisations.', [$sharedBy, $secretName]) + ); + $this->withAppLink(notification: $notification, path: 'incoming'); + return true; case 'share_request': + // Sent to the owner, so sourceSecretId is the owner's own secret. + $params = self::normaliseParams(params: $params, aliases: ['requesterId' => 'requester', 'sourceSecretId' => 'secret_id']); $requester = (string)($params['requester'] ?? $l->t('a user')); $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); + $targetUserId = (string)($params['target_user_id'] ?? ''); $notification->setParsedSubject((string)$l->t('Share request')); - $notification->setParsedMessage( - (string)$l->t('%1$s requested access to the secret "%2$s".', [$requester, $secretName]) - ); + $message = (string)$l->t('%1$s requested access to the secret "%2$s".', [$requester, $secretName]); + if ($targetUserId !== '' && $targetUserId !== $requester) { + $message = (string)$l->t('%1$s asks you to share the secret "%2$s" with %3$s.', [$requester, $secretName, $targetUserId]); + } + + $notification->setParsedMessage($message); $this->withSecretLink(notification: $notification, params: $params); + $this->withShareRequestActions(notification: $notification, params: $params, l: $l); return true; case 'share_request_result': $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); @@ -162,6 +184,8 @@ private function renderSharingSubject(INotification $notification, string $subje $this->withSecretLink(notification: $notification, params: $params); return true; case 'group_member_added': + // Sent to the owner, so secretId is the owner's own secret. + $params = self::normaliseParams(params: $params, aliases: []); $groupId = (string)($params['group_id'] ?? ''); $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); $notification->setParsedSubject((string)$l->t('Group member added')); @@ -169,12 +193,132 @@ private function renderSharingSubject(INotification $notification, string $subje (string)$l->t('A new member joined the group "%1$s" — approve to share "%2$s".', [$groupId, $secretName]) ); $this->withSecretLink(notification: $notification, params: $params); + $this->withGroupMemberActions(notification: $notification, params: $params, l: $l); return true; }//end switch return false; }//end renderSharingSubject() + /** + * Render the organisation account recovery subjects + * (crypto-organisation-account-recovery 5.2). All link to the app. + * + * @param INotification $notification The notification to mutate + * @param string $subject The notification subject identifier + * @param array $params The subject parameters + * @param IL10N $l The localisation helper + * + * @return bool True when this renderer recognised the subject. + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-a-recovery-request-carries-a-one-time-key-and-a-verification-phrase + */ + private function renderRecoverySubject(INotification $notification, string $subject, array $params, IL10N $l): bool { + $texts = [ + 'recovery_officer_named' => (string)$l->t('You are now an account recovery officer'), + 'recovery_requested' => (string)$l->t( + '%s asks to recover their account. Compare the words with them before you approve.', + [(string)($params['user'] ?? $l->t('A user'))] + ), + 'recovery_declined' => (string)$l->t('Your account recovery request was declined'), + 'recovery_ready' => (string)$l->t('Your account recovery is ready. Open Keepiq in the browser you asked from.'), + ]; + if (isset($texts[$subject]) === false) { + return false; + } + + $notification->setParsedSubject($texts[$subject]); + try { + $notification->setLink( + $this->url->getAbsoluteURL($this->url->linkToRoute(Application::APP_ID . '.dashboard.page')) + ); + } catch (InvalidArgumentException) { + // The link is optional; the notification still says what happened. + } + + return true; + }//end renderRecoverySubject() + + /** + * Render a new device's request to open the vault + * (crypto-new-device-approval D5). Links to the app, where the unlocked + * vault shows the approval dialog. + * + * @param INotification $notification The notification to mutate + * @param string $subject The notification subject identifier + * @param array $params The subject parameters + * @param IL10N $l The localisation helper + * + * @return bool True when this renderer recognised the subject. + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + */ + private function renderDeviceApprovalSubject(INotification $notification, string $subject, array $params, IL10N $l): bool { + if ($subject !== 'device_approval_requested') { + return false; + } + + $label = (string)($params['device_label'] ?? ''); + if ($label === '') { + $label = (string)$l->t('A device'); + } + + $notification->setParsedSubject((string)$l->t('A new device asks to open your vault')); + $notification->setParsedMessage( + (string)$l->t('%s asks to be approved. Only approve a device you are using right now.', [$label]) + ); + try { + $notification->setLink( + $this->url->getAbsoluteURL($this->url->linkToRoute(Application::APP_ID . '.dashboard.page')) + ); + } catch (InvalidArgumentException) { + // The link is optional; the notification still says what happened. + } + + return true; + }//end renderDeviceApprovalSubject() + + /** + * Render the end-of-access subjects of shares that end by themselves + * (sharing-use-only-and-expiring-shares D6). + * + * @param INotification $notification The notification to mutate + * @param string $subject The notification subject identifier + * @param array $params The subject parameters + * @param IL10N $l The localisation helper + * + * @return bool True when this renderer recognised the subject. + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-people-are-told-before-and-when-access-ends + */ + private function renderAccessEndSubject(INotification $notification, string $subject, array $params, IL10N $l): bool { + $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); + switch ($subject) { + case 'share_access_ending': + $notification->setParsedSubject((string)$l->t('Your access to "%s" ends tomorrow', [$secretName])); + $this->withSecretLink(notification: $notification, params: $params); + return true; + case 'share_access_ended': + $notification->setParsedSubject((string)$l->t('Your access to "%s" has ended', [$secretName])); + return true; + case 'share_access_ended_owner': + $recipient = (string)($params['recipient'] ?? $l->t('a user')); + $notification->setParsedSubject( + (string)$l->t('%1$s no longer has access to "%2$s"', [$recipient, $secretName]) + ); + $message = (string)$l->t('%1$s could see this password. Rotate it if %1$s should no longer know it.', [$recipient]); + if (($params['use_only'] ?? false) === true) { + $message = (string)$l->t('%s could not view this password in Keepiq.', [$recipient]); + } + + $notification->setParsedMessage($message); + $this->withSecretLink(notification: $notification, params: $params); + return true; + }//end switch + + return false; + }//end renderAccessEndSubject() + /** * Render the secret-lifecycle subjects. All of them deep-link to a secret. * @@ -189,10 +333,34 @@ private function renderSecretLifecycleSubject(INotification $notification, strin switch ($subject) { case 'secret_compromised': $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); + $otherCount = (int)($params['other_count'] ?? 0); $notification->setParsedSubject((string)$l->t('Secret may be compromised')); - $notification->setParsedMessage( - (string)$l->t('Your secret "%s" may be compromised and requires migration.', [$secretName]) - ); + // One notice per owner, so it says how many secrets it covers: + // a single name read as "only this one" (keepiq#875). + $message = $l->t('Your secret "%s" may be compromised and requires migration.', [$secretName]); + if ($otherCount > 0) { + $message = $l->t( + 'Your secret "%1$s" and %2$d other secret(s) may be compromised and require migration.', + [$secretName, $otherCount] + ); + } + + $notification->setParsedMessage((string)$message); + $this->withSecretLink(notification: $notification, params: $params); + return true; + case 'shared_secret_compromised': + $secretName = (string)($params['secret_name'] ?? $l->t('a secret')); + $otherCount = (int)($params['other_count'] ?? 0); + $notification->setParsedSubject((string)$l->t('Shared secret may be compromised')); + $message = $l->t('The secret "%s" shared with you may be compromised. Change it where it is used.', [$secretName]); + if ($otherCount > 0) { + $message = $l->t( + 'The secret "%1$s" and %2$d other secret(s) shared with you may be compromised. Change them where they are used.', + [$secretName, $otherCount] + ); + } + + $notification->setParsedMessage((string)$message); $this->withSecretLink(notification: $notification, params: $params); return true; case 'request_fulfilled': @@ -257,6 +425,17 @@ private function renderAdminSubject(INotification $notification, string $subject ); $this->withAdminSectionLink(notification: $notification); return true; + case 'ca_root_expiring': + $rootDaysLeft = (int)($params['days_left'] ?? 0); + $notification->setParsedSubject((string)$l->t('Root certificate expiring soon')); + $notification->setParsedMessage( + (string)$l->t( + 'The vault root certificate expires in %1$d day(s). Renew it before then. Renewing re-signs every encryption suite.', + [$rootDaysLeft] + ) + ); + $this->withAdminSectionLink(notification: $notification); + return true; }//end switch return false; @@ -282,6 +461,14 @@ private function renderVaultAccessSubject(INotification $notification, string $s (string)$l->t('%s shared a team folder with you. Its secrets are now in your vault.', [$sharedBy]) ); return true; + case 'team_folder_member_confirmed': + $confirmedBy = (string)($params['confirmedBy'] ?? $l->t('a member')); + $confirmedMembers = implode(', ', array_map('strval', (array)($params['memberIds'] ?? []))); + $notification->setParsedSubject((string)$l->t('New team folder members confirmed')); + $notification->setParsedMessage( + (string)$l->t('%1$s gave %2$s access to your team folder.', [$confirmedBy, $confirmedMembers]) + ); + return true; case 'team_folder_join_request': $newMemberId = (string)($params['newMemberId'] ?? $l->t('a user')); $joinGroupId = (string)($params['groupId'] ?? ''); @@ -311,6 +498,26 @@ private function renderVaultAccessSubject(INotification $notification, string $s ) ); return true; + }//end switch + + return false; + }//end renderVaultAccessSubject() + + /** + * Render the emergency-access subjects: requests, grants that were used, + * cleared and compromised contacts. None of them carry a deep-link. + * + * @param INotification $notification The notification to mutate + * @param string $subject The notification subject identifier + * @param array $params The subject parameters + * @param IL10N $l The localisation helper + * + * @return bool True when this renderer recognised the subject. + * + * @spec openspec/specs/emergency-access/spec.md + */ + private function renderEmergencySubject(INotification $notification, string $subject, array $params, IL10N $l): bool { + switch ($subject) { case 'emergency_access_requested': $granteeName = (string)($params['grantee_name'] ?? $params['granteeUserId'] ?? $l->t('a trusted contact')); $waitDays = (int)($params['waitPeriodDays'] ?? 7); @@ -322,6 +529,26 @@ private function renderVaultAccessSubject(INotification $notification, string $s ) ); return true; + case 'emergency_grantee_compromised': + $granteeName = (string)($params['grantee_name'] ?? $params['granteeUserId'] ?? $l->t('a trusted contact')); + $notification->setParsedSubject((string)$l->t('Emergency contact compromised')); + $notification->setParsedMessage( + (string)$l->t( + '%s had approved emergency access to your vault. Their encryption key was revoked as compromised, so start a key rotation.', + [$granteeName] + ) + ); + return true; + case 'emergency_access_cleared': + $clearedCount = (int)($params['count'] ?? 0); + $notification->setParsedSubject((string)$l->t('Emergency access removed')); + $notification->setParsedMessage( + (string)$l->t( + 'An administrator revoked your vault key and deleted %d emergency contact(s). Add them again once your vault is set up.', + [$clearedCount] + ) + ); + return true; case 'emergency_access_accessed': $granteeName = (string)($params['grantee_name'] ?? $params['granteeUserId'] ?? $l->t('a trusted contact')); $notification->setParsedSubject((string)$l->t('Emergency access used')); @@ -332,7 +559,149 @@ private function renderVaultAccessSubject(INotification $notification, string $s }//end switch return false; - }//end renderVaultAccessSubject() + }//end renderEmergencySubject() + + /** + * Accept the parameter names the services actually send. + * + * The share-request and group-member services notify with camelCase keys + * (`secretName`, `requesterId`, `sourceSecretId`, `groupId`), while the + * renderers read snake_case. Both notifications therefore showed "a user" + * and "a secret" and carried no link (#747). Each camelCase key gains its + * snake_case twin, and a key whose meaning differs is mapped by name. A + * key already present is never overwritten. Applied per subject, because + * only where the recipient is the owner does a secret id name a secret + * in the recipient's own vault. + * + * @param array $params The raw subject parameters + * @param array $aliases camelCase key => snake_case key, where they differ + * + * @return array The parameters with both spellings + * + * @spec openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated + */ + private static function normaliseParams(array $params, array $aliases): array { + foreach ($params as $key => $value) { + $snake = $aliases[$key] ?? strtolower((string)preg_replace('/(? $params The normalised subject parameters + * @param IL10N $l The localisation helper + * + * @return void + * + * @spec openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated + */ + private function withShareRequestActions(INotification $notification, array $params, IL10N $l): void { + $query = [ + 'sourceSecretId' => (string)($params['secret_id'] ?? ''), + 'requesterId' => (string)($params['requester'] ?? ''), + 'targetUserId' => (string)($params['target_user_id'] ?? ''), + ]; + if (in_array('', $query, true) === true) { + return; + } + + $this->addActions( + notification: $notification, + l: $l, + approvePage: 'approvals/share-request?' . http_build_query($query), + denyApi: 'api/v1/share-requests/deny?' . http_build_query($query), + ); + }//end withShareRequestActions() + + /** + * Approve and deny actions on a new group member of a group share. + * + * @param INotification $notification The notification to mutate + * @param array $params The normalised subject parameters + * @param IL10N $l The localisation helper + * + * @return void + * + * @spec openspec/specs/user-sharing/spec.md#requirement-new-group-member-owner-notification + */ + private function withGroupMemberActions(INotification $notification, array $params, IL10N $l): void { + $groupShareId = (string)($params['group_share_id'] ?? ''); + $newMemberId = (string)($params['new_member_id'] ?? ''); + $secretId = (string)($params['secret_id'] ?? ''); + if ($groupShareId === '' || $newMemberId === '' || $secretId === '') { + return; + } + + $this->addActions( + notification: $notification, + l: $l, + approvePage: 'approvals/group-member?' . http_build_query( + ['groupShareId' => $groupShareId, 'newMemberId' => $newMemberId, 'secretId' => $secretId] + ), + denyApi: 'api/v1/group-shares/' . rawurlencode($groupShareId) . '/deny-new-member?' + . http_build_query(['newMemberId' => $newMemberId]), + ); + }//end withGroupMemberActions() + + /** + * Add a primary Approve action (a Keepiq page) and a Deny action (a POST). + * + * @param INotification $notification The notification to mutate + * @param IL10N $l The localisation helper + * @param string $approvePage The SPA path and query, relative to the app root + * @param string $denyApi The API path and query, relative to the app root + * + * @return void + */ + private function addActions(INotification $notification, IL10N $l, string $approvePage, string $denyApi): void { + try { + $appRoot = $this->url->linkToRoute(Application::APP_ID . '.dashboard.page'); + } catch (InvalidArgumentException) { + return; + } + + $approve = $notification->createAction(); + $approve->setLabel('approve') + ->setParsedLabel((string)$l->t('Approve')) + ->setLink($this->url->getAbsoluteURL($appRoot . $approvePage), 'WEB') + ->setPrimary(true); + $notification->addParsedAction($approve); + + $deny = $notification->createAction(); + $deny->setLabel('deny') + ->setParsedLabel((string)$l->t('Deny')) + ->setLink($this->url->getAbsoluteURL($appRoot . $denyApi), 'POST') + ->setPrimary(false); + $notification->addParsedAction($deny); + }//end addActions() + + /** + * Link a notification to a page of the app. + * + * @param INotification $notification The notification to mutate + * @param string $path The app path, without a leading slash + * + * @return void + */ + private function withAppLink(INotification $notification, string $path): void { + try { + $route = $this->url->linkToRoute(Application::APP_ID . '.dashboard.page') . $path; + $notification->setLink($this->url->getAbsoluteURL($route)); + } catch (InvalidArgumentException) { + // The link is optional; the notification still says what happened. + } + }//end withAppLink() /** * Attach a deep-link to the affected secret, when the params include one. diff --git a/lib/Repair/SeedDevelopmentSecrets.php b/lib/Repair/SeedDevelopmentSecrets.php index b42a91172..6c55a453a 100644 --- a/lib/Repair/SeedDevelopmentSecrets.php +++ b/lib/Repair/SeedDevelopmentSecrets.php @@ -30,6 +30,7 @@ use OCA\Keepiq\Db\FolderMapper; use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretTagMapper; use OCA\Keepiq\Db\SecretTypeMapper; use OCA\Keepiq\Service\EncryptService; use OCP\AppFramework\Db\DoesNotExistException; @@ -64,6 +65,7 @@ class SeedDevelopmentSecrets implements IRepairStep { * @param EncryptService $encryptService The encrypt service * @param IConfig $config The config interface * @param LoggerInterface $logger The logger interface + * @param SecretTagMapper|null $tagMapper The tag mapper (seeds tags on a few secrets) * * @return void */ @@ -75,6 +77,7 @@ public function __construct( private EncryptService $encryptService, private IConfig $config, private LoggerInterface $logger, + private ?SecretTagMapper $tagMapper = null, ) { }//end __construct() @@ -139,6 +142,9 @@ public function run(IOutput $output): void { 'key' => 'gh_dev_P@ssw0rd!2024', 'login' => 'dev-user', 'folder' => $workId, + 'favourite' => true, + 'tags' => ['on call'], + 'usedDaysAgo' => 0, ], [ 'name' => 'AWS Console', @@ -147,6 +153,9 @@ public function run(IOutput $output): void { 'key' => 'AKIAIOSFODNN7EXAMPLE', 'login' => 'dev-access-key', 'folder' => $workId, + 'favourite' => true, + 'tags' => ['finance', 'on call'], + 'usedDaysAgo' => 1, ], [ 'name' => 'Production Database', @@ -155,6 +164,8 @@ public function run(IOutput $output): void { 'key' => 'Pr0d-DB-$ecret!', 'login' => 'app_service', 'folder' => null, + 'tags' => ['finance'], + 'usedDaysAgo' => 3, ], [ 'name' => 'SSH Deploy Key', @@ -163,6 +174,7 @@ public function run(IOutput $output): void { 'key' => "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEA...", 'login' => 'deploy', 'folder' => null, + 'usedDaysAgo' => 7, ], [ 'name' => 'TLS Wildcard Certificate', @@ -261,7 +273,15 @@ private function createSecret(array $spec, array $typeIds, string $certificate, $secret->setOwnerId(self::DEV_USER_ID); $secret->setCreatedAt($now); $secret->setUpdatedAt($now); + // Favourites, tags and last used (vault-favourites-tags-and-last-used seed data). + $secret->setIsFavourite(($spec['favourite'] ?? false) === true); + if (isset($spec['usedDaysAgo']) === true) { + $secret->setLastUsedAt((new DateTime())->modify('-'.(int)$spec['usedDaysAgo'].' days')); + } $this->secretMapper->insert($secret); + if (($spec['tags'] ?? []) !== []) { + $this->tagMapper?->replaceForSecret($secret->getId(), self::DEV_USER_ID, $spec['tags']); + } }//end createSecret() }//end class diff --git a/lib/Sections/SettingsSection.php b/lib/Sections/SettingsSection.php index e4873aec5..dd03c94d6 100644 --- a/lib/Sections/SettingsSection.php +++ b/lib/Sections/SettingsSection.php @@ -32,7 +32,7 @@ /** * Keepiq admin-settings section — engine-backed stub (AppHost, ADR-040). * - * @psalm-suppress UnusedClass + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . */ class SettingsSection extends GenericSettingsSection { }//end class diff --git a/lib/Service/AccountDeletionService.php b/lib/Service/AccountDeletionService.php index 92809eb5c..a056726c6 100644 --- a/lib/Service/AccountDeletionService.php +++ b/lib/Service/AccountDeletionService.php @@ -103,7 +103,7 @@ public function __construct( * * @return DeletionReport The per-entity counts * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function deleteAllFor(string $userId, string $trigger = 'user-deleted'): DeletionReport { $report = new DeletionReport(); @@ -142,6 +142,10 @@ public function deleteAllFor(string $userId, string $trigger = 'user-deleted'): // Suites (cert + encrypted private key) and their migration records. $this->suiteCleanup->removeSuites(userId: $userId, report: $report); + // Key material escrowed outside the suites: emergency envelopes (both + // sides) and passkey unlock envelopes. + $this->suiteCleanup->removeEscrowedKeys(userId: $userId, report: $report); + // Settings / preferences. $this->settingMapper->deleteByUser(userId: $userId); $report->settingsDeleted = true; diff --git a/lib/Service/AccountShareCleanupService.php b/lib/Service/AccountShareCleanupService.php index 75c5c47e0..7d9ba0e1a 100644 --- a/lib/Service/AccountShareCleanupService.php +++ b/lib/Service/AccountShareCleanupService.php @@ -79,7 +79,7 @@ public function __construct( * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function transferDelegatedSecrets(string $userId, array $ownedSecrets, DeletionReport $report): void { $ownedIds = []; @@ -121,7 +121,7 @@ public function transferDelegatedSecrets(string $userId, array $ownedSecrets, De * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function detachGrantedShares(array $ownedSecrets, DeletionReport $report): void { foreach ($ownedSecrets as $secret) { @@ -157,7 +157,7 @@ public function detachGrantedShares(array $ownedSecrets, DeletionReport $report) * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function removeReceivedShares(string $userId, DeletionReport $report): void { $report->sharesRemoved = count($this->shareMapper->findByTargetUser(targetUserId: $userId)); diff --git a/lib/Service/AccountSuiteCleanupService.php b/lib/Service/AccountSuiteCleanupService.php index 49c57682e..460e83673 100644 --- a/lib/Service/AccountSuiteCleanupService.php +++ b/lib/Service/AccountSuiteCleanupService.php @@ -25,11 +25,14 @@ namespace OCA\Keepiq\Service; +use OCA\Keepiq\Db\EmergencyContactMapper; use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\PasskeyMapper; use OCA\Keepiq\Db\SuiteMigrationMapper; /** - * Removes a user's encryption suites and their migration records. + * Removes a user's encryption suites, their migration records, and the + * key material escrowed outside them (emergency envelopes, passkeys). */ class AccountSuiteCleanupService { /** @@ -37,6 +40,8 @@ class AccountSuiteCleanupService { * * @param EncryptionSuiteMapper $suiteMapper The encryption-suite mapper * @param SuiteMigrationMapper $migrationMapper The suite-migration mapper + * @param EmergencyContactMapper $emergencyMapper The emergency-contact mapper + * @param PasskeyMapper $passkeyMapper The passkey-credential mapper * * @return void * @@ -45,6 +50,8 @@ class AccountSuiteCleanupService { public function __construct( private EncryptionSuiteMapper $suiteMapper, private SuiteMigrationMapper $migrationMapper, + private EmergencyContactMapper $emergencyMapper, + private PasskeyMapper $passkeyMapper, ) { }//end __construct() @@ -56,7 +63,7 @@ public function __construct( * * @return void * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function removeSuites(string $userId, DeletionReport $report): void { $suites = $this->suiteMapper->findByOwner(ownerType: 'user', ownerId: $userId); @@ -68,4 +75,25 @@ public function removeSuites(string $userId, DeletionReport $report): void { $this->migrationMapper->deleteBySuiteIds(suiteIds: $suiteIds); $report->suitesDeleted = $this->suiteMapper->deleteByOwnerUser(ownerId: $userId); }//end removeSuites() + + /** + * Remove the key material held OUTSIDE the suite rows: every emergency + * relationship the user is part of (a grantor row escrows the user's + * private key to the grantee) and the user's passkey unlock envelopes. + * + * The suites are hard-deleted through the mapper, so the revoke listener + * that clears envelopes never runs; this step does that work directly. + * + * @param string $userId The departing user + * @param DeletionReport $report The running report + * + * @return void + * + * @spec openspec/specs/gdpr-compliance/spec.md + */ + public function removeEscrowedKeys(string $userId, DeletionReport $report): void { + $report->emergencyDeleted = $this->emergencyMapper->deleteByUser(userId: $userId); + $report->passkeysDeleted = count($this->passkeyMapper->findByOwner(ownerId: $userId)); + $this->passkeyMapper->deleteByOwner(ownerId: $userId); + }//end removeEscrowedKeys() }//end class diff --git a/lib/Service/AdminAreaAuthorizer.php b/lib/Service/AdminAreaAuthorizer.php new file mode 100644 index 000000000..6783aa187 --- /dev/null +++ b/lib/Service/AdminAreaAuthorizer.php @@ -0,0 +1,167 @@ +::class)]`; checks inside services and the + * flags that decide whether the UI offers an admin action ask this class, + * which reads the same delegations through `IManager::getAllowedAdminSettings()`. + * + * @category Service + * @package OCA\Keepiq\Service + * + * @author Conduction Development Team + * @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\Service; + +use OCA\Keepiq\Settings\AdminAreaSettings; +use OCA\Keepiq\Settings\AdminSettings; +use OCA\Keepiq\Settings\ApplicationAdminSettings; +use OCA\Keepiq\Settings\AuditAdminSettings; +use OCA\Keepiq\Settings\PeopleAdminSettings; +use OCA\Keepiq\Settings\PolicyAdminSettings; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\Settings\IManager; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Decides whether a user holds a Keepiq admin area. + */ +class AdminAreaAuthorizer { + /** + * The area classes by name, so a caller can name an area without + * depending on its settings class. + * + * @var string + */ + public const GENERAL = AdminSettings::class; + public const POLICIES = PolicyAdminSettings::class; + public const APPLICATIONS = ApplicationAdminSettings::class; + public const PEOPLE = PeopleAdminSettings::class; + public const AUDIT = AuditAdminSettings::class; + + /** + * The five area classes, keyed by area key. + * + * @var array> + */ + public const AREAS = [ + 'general' => AdminSettings::class, + 'policies' => PolicyAdminSettings::class, + 'applications' => ApplicationAdminSettings::class, + 'people' => PeopleAdminSettings::class, + 'audit' => AuditAdminSettings::class, + ]; + + /** + * Constructor. + * + * @param IGroupManager $groupManager The instance admin check + * @param IUserManager $userManager Resolves the user for the settings manager + * @param IManager $settingsManager The settings a user may see, delegations included + * @param LoggerInterface $logger Records a failed delegation lookup + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private readonly IGroupManager $groupManager, + private readonly IUserManager $userManager, + private readonly IManager $settingsManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Whether $userId holds the area of $areaClass: an instance admin holds + * every area, and a delegated user the areas delegated to one of their + * groups. No group name grants an area by itself (#1043 removed the + * `vault_admin` alias for People). + * + * Fails closed: an unknown area, an unknown user or a failing delegation + * lookup answers false. + * + * @param string $userId The user to check + * @param string $areaClass One of the five area classes + * + * @return bool + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.3 + */ + public function holds(string $userId, string $areaClass): bool { + if ($userId === '' || in_array($areaClass, self::AREAS, true) === false) { + return false; + } + + if ($this->groupManager->isAdmin($userId) === true) { + return true; + } + + return in_array($areaClass, $this->delegatedAreas(userId: $userId), true); + }//end holds() + + /** + * The area keys $userId holds, in area order. + * + * @param string $userId The user to check + * + * @return string[] + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.3 + */ + public function areasOf(string $userId): array { + $held = []; + foreach (self::AREAS as $key => $class) { + if ($this->holds(userId: $userId, areaClass: $class) === true) { + $held[] = $key; + } + } + + return $held; + }//end areasOf() + + /** + * The Keepiq area classes delegated to the user's groups. + * + * @param string $userId The user to check + * + * @return string[] + */ + private function delegatedAreas(string $userId): array { + $user = $this->userManager->get($userId); + if ($user === null) { + return []; + } + + try { + $byPriority = $this->settingsManager->getAllowedAdminSettings(AdminAreaSettings::SECTION, $user); + } catch (Throwable $e) { + // Fail closed: a lookup that cannot answer grants nothing. + $this->logger->warning('Keepiq: admin area lookup failed: ' . $e->getMessage()); + return []; + } + + $classes = []; + foreach ($byPriority as $settings) { + foreach ($settings as $setting) { + $classes[] = get_class($setting); + } + } + + return $classes; + }//end delegatedAreas() +}//end class diff --git a/lib/Service/AdminSettingsService.php b/lib/Service/AdminSettingsService.php index 9a3ea2e30..723d7d9a8 100644 --- a/lib/Service/AdminSettingsService.php +++ b/lib/Service/AdminSettingsService.php @@ -44,6 +44,13 @@ /** * Reads and validates the instance-wide Keepiq configuration. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) 51 against a threshold of + * 50, reached when the browser extension's maximum idle period joined the + * admin settings (clients-extension-unlock-lock-and-accounts). The service + * is being split per admin area by admin-scoped-roles (keepiq#774), which + * removes this; a separate service now would add a dependency to a class + * that sits at its coupling limit. */ class AdminSettingsService { /** @@ -54,6 +61,13 @@ class AdminSettingsService { private const VALID_PASSWORD_SCORES = [3, 4]; private const VALID_SESSION_TIMEOUTS = ['session', '10min', '30min']; + /** + * The session timeout when neither the admin nor the user chose one. Ten + * minutes is what the vault did in practice before 'session' meant no idle + * timer, so an unset value never switches the idle lock off (crypto-07). + */ + public const DEFAULT_SESSION_TIMEOUT = '10min'; + /** * Default audit-log retention window in days (add-secret-audit-trail §4.2). * @@ -69,6 +83,76 @@ class AdminSettingsService { */ public const AUDIT_RETENTION_MIN = 30; + /** + * The keys each settings-bearing admin area owns, except Policies, whose + * keys are POLICY_AREA_OWN_KEYS plus the password and vault policy keys + * (admin-scoped-roles, decision of 2 Oct: + * version and trash retention are vault rules, so they are Policies). + * The People area owns no settings keys, so it has no settings route. + * + * @var array + */ + public const AREA_KEYS = [ + 'general' => [ + 'ca_auto_renew_enabled', + 'breach_check_enabled', + 'offline_cache_enabled', + 'offline_edits_enabled', + 'device_approval_enabled', + 'attachment_max_bytes', + 'attachment_user_quota_bytes', + ], + 'applications' => [ + 'lease_default_ttl_seconds', + 'lease_max_ttl_seconds', + 'lease_renewable', + 'lease_revocation_blocks_refetch', + ], + 'audit' => ['audit_retention_days'], + ]; + + /** + * The Policies keys this service writes itself; the org password and + * vault policy keys come from PasswordPolicyService. + * + * @var string[] + */ + private const POLICY_AREA_OWN_KEYS = [ + 'min_password_length', + 'min_password_score', + 'default_session_timeout', + 'expiry_default_max_age_days', + 'expiry_reminder_days', + 'expiry_policy_enforced', + 'version_retention_count', + 'version_retention_days', + 'trash_retention_days', + 'extension_max_idle_minutes', + ]; + + /** + * The settings-bearing areas, in route order. + * + * @var string[] + */ + public const SETTINGS_AREAS = ['general', 'policies', 'applications', 'audit']; + + /** + * The idle lock delays the browser extension offers, in minutes + * (browser-extension-autofill, user-chosen idle lock period). The + * administrator maximum is one of these. + * + * @var int[] + */ + public const EXTENSION_IDLE_CHOICES = [1, 5, 15, 30, 60, 240]; + + /** + * The extension idle maximum when the administrator set none. + * + * @var int + */ + public const EXTENSION_MAX_IDLE_DEFAULT = 240; + /** * The org password policy. * @@ -138,7 +222,7 @@ public function getAdminSettings(): array { 'default_session_timeout' => $this->appConfig->getValueString( $appId, 'default_session_timeout', - 'session' + self::DEFAULT_SESSION_TIMEOUT ), 'ca_auto_renew_enabled' => $this->appConfig->getValueBool($appId, 'ca_auto_renew_enabled', true), 'audit_retention_days' => $this->appConfig->getValueInt( @@ -156,13 +240,14 @@ public function getAdminSettings(): array { $this->appConfig->getValueString($appId, 'expiry_reminder_days', '[30,7,1]'), true ), - 'expiry_policy_enforced' => $this->appConfig->getValueBool( - $appId, - 'expiry_policy_enforced', - false - ), 'version_retention_count' => $this->appConfig->getValueInt($appId, 'version_retention_count', 20), 'version_retention_days' => $this->appConfig->getValueInt($appId, 'version_retention_days', 365), + // Trash retention (vault-trash-and-archive D4), 1 to 365 days. + 'trash_retention_days' => $this->appConfig->getValueInt( + $appId, + 'trash_retention_days', + SecretTrashService::RETENTION_DEFAULT + ), 'attachment_max_bytes' => $this->appConfig->getValueInt( $appId, 'attachment_max_bytes', @@ -176,7 +261,7 @@ public function getAdminSettings(): array { ], // Org password policy (org-password-policies §1.1) — one reader, // shared with the user-visible getPolicy() floor. - $this->policyService->readPolicyKeys(), + $this->policyService->readAdminPolicyKeys(), [ // Machine leases (machine-secret-leases §2.4). 'lease_default_ttl_seconds' => $this->appConfig->getValueInt( @@ -195,13 +280,12 @@ public function getAdminSettings(): array { 'lease_revocation_blocks_refetch', false ), - // Offline read-only cache (offline-readonly-cache §1.1) — default on. - 'offline_cache_enabled' => $this->appConfig->getValueBool( - $appId, - 'offline_cache_enabled', - true - ), - ] + // The longest idle lock delay a user may pick in the browser extension. + 'extension_max_idle_minutes' => $this->extensionMaxIdleMinutes(), + // New device approval (crypto-new-device-approval D5), default on. + 'device_approval_enabled' => $this->appConfig->getValueBool($appId, 'device_approval_enabled', true), + ], + $this->offlineSettings() ); // Best-effort CA status; never blocks if the service is unavailable. @@ -218,6 +302,23 @@ public function getAdminSettings(): array { return $settings; }//end getAdminSettings() + /** + * The offline cache switches: offline reading (default on) and offline + * edits (default off). + * + * @return array + * + * @spec openspec/specs/offline-readonly-cache/spec.md#requirement-an-admin-can-disable-offline-caching-org-wide + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-administrators-control-offline-edits + */ + private function offlineSettings(): array { + $appId = Application::APP_ID; + return [ + 'offline_cache_enabled' => $this->appConfig->getValueBool($appId, 'offline_cache_enabled', true), + 'offline_edits_enabled' => $this->appConfig->getValueBool($appId, 'offline_edits_enabled', false), + ]; + }//end offlineSettings() + /** * Update admin-scoped settings with validation (implement-dashboard-settings §1.4). * @@ -228,31 +329,149 @@ public function getAdminSettings(): array { * @throws InvalidArgumentException On out-of-bounds values. * * @spec openspec/changes/implement-dashboard-settings/tasks.md#task-1.4 + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group */ public function updateAdminSettings(array $data): array { // Each group validates and persists one family of keys. Every guard // is independent — an absent key is left untouched, an out-of-bounds // value throws before anything in its group is written. - $this->updateAuthenticationSettings(data: $data); - $this->updateInstanceSettings(data: $data); - $this->policyService->updatePolicySettings(data: $data); - $this->updateExpirySettings(data: $data); - $this->updateLeaseSettings(data: $data); - $this->updateRetentionSettings(data: $data); + foreach (self::SETTINGS_AREAS as $area) { + $this->writeArea(area: $area, data: $data); + } return $this->getAdminSettings(); }//end updateAdminSettings() + /** + * The settings of one admin area (admin-scoped-roles D2). General also + * carries the CA status its section shows. + * + * @param string $area One of SETTINGS_AREAS + * + * @return array + * + * @throws InvalidArgumentException On an unknown area. + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + public function getAreaSettings(string $area): array { + $keys = $this->areaKeys(area: $area); + if ($area === 'general') { + $keys[] = 'ca_status'; + } + + return array_intersect_key($this->getAdminSettings(), array_flip($keys)); + }//end getAreaSettings() + + /** + * Write one admin area's keys and nothing else (admin-scoped-roles D2). + * + * A key that belongs to another area is refused rather than dropped, so + * a caller can never read a partial save as a whole one. Keys no area + * owns are ignored, as the combined write always did. + * + * @param string $area One of SETTINGS_AREAS + * @param array $data The input data + * + * @return array The area's settings after the write + * + * @throws InvalidArgumentException On an unknown area, a key of another + * area, or an out-of-bounds value. + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + public function updateAreaSettings(string $area, array $data): array { + $own = $this->areaKeys(area: $area); + foreach (self::SETTINGS_AREAS as $other) { + if ($other === $area) { + continue; + } + + $foreign = array_values(array_intersect(array_keys($data), $this->areaKeys(area: $other))); + if ($foreign !== []) { + throw new InvalidArgumentException( + $foreign[0] . ' belongs to the ' . $other . ' area, not to ' . $area + ); + } + } + + $this->writeArea(area: $area, data: array_intersect_key($data, array_flip($own))); + + return $this->getAreaSettings(area: $area); + }//end updateAreaSettings() + + /** + * The keys one area owns. + * + * @param string $area One of SETTINGS_AREAS + * + * @return string[] + * + * @throws InvalidArgumentException On an unknown area. + */ + private function areaKeys(string $area): array { + if ($area === 'policies') { + return array_merge( + self::POLICY_AREA_OWN_KEYS, + array_keys($this->policyService->readAdminPolicyKeys()) + ); + } + + if (isset(self::AREA_KEYS[$area]) === false) { + throw new InvalidArgumentException('Unknown admin settings area: ' . $area); + } + + return self::AREA_KEYS[$area]; + }//end areaKeys() + + /** + * Run the validated writers of one area. Each writer ignores absent keys. + * + * @param string $area One of SETTINGS_AREAS + * @param array $data The input data + * + * @return void + * + * @throws InvalidArgumentException On out-of-bounds values. + */ + private function writeArea(string $area, array $data): void { + switch ($area) { + case 'general': + $this->updateInstanceSettings(data: $data); + $this->updateAttachmentSettings(data: $data); + return; + case 'policies': + $this->updateAuthenticationSettings(data: $data); + $this->policyService->updatePolicySettings(data: $data); + $this->updateExpirySettings(data: $data); + $this->updateRetentionSettings(data: $data); + $this->updateTrashSettings(data: $data); + $this->updateExtensionSettings(data: $data); + return; + case 'applications': + $this->updateLeaseSettings(data: $data); + return; + case 'audit': + $this->updateAuditSettings(data: $data); + return; + default: + throw new InvalidArgumentException('Unknown admin settings area: ' . $area); + } + }//end writeArea() + /** * The user-visible policy floor for the write dialogs * (org-password-policies §1.3). * + * @param string|null $userId The session user, for the effective vault policies + * * @return array * * @spec openspec/changes/org-password-policies/specs/org-password-policies/spec.md + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group */ - public function getPolicy(): array { - return $this->policyService->getPolicy(); + public function getPolicy(?string $userId = null): array { + return $this->policyService->getPolicy(userId: $userId); }//end getPolicy() /** @@ -318,8 +537,8 @@ private function updateAuthenticationSettings(array $data): void { }//end updateAuthenticationSettings() /** - * Instance-wide switches: CA renewal, audit retention, breach checking - * and the offline read-only cache (offline-readonly-cache §1.1). + * Instance-wide switches: CA renewal, breach checking and the offline + * read-only cache (offline-readonly-cache §1.1). * * @param array $data The admin-settings input * @@ -334,18 +553,6 @@ private function updateInstanceSettings(array $data): void { $this->appConfig->setValueBool($appId, 'ca_auto_renew_enabled', (bool)$data['ca_auto_renew_enabled']); } - if (isset($data['audit_retention_days']) === true) { - $days = (int)$data['audit_retention_days']; - if ($days < self::AUDIT_RETENTION_MIN) { - throw new InvalidArgumentException( - 'audit_retention_days must be at least ' . self::AUDIT_RETENTION_MIN - . ' days — below that the audit trail cannot serve incident investigation' - ); - } - - $this->appConfig->setValueInt($appId, 'audit_retention_days', $days); - } - if (isset($data['breach_check_enabled']) === true) { $this->appConfig->setValueBool($appId, 'breach_check_enabled', (bool)$data['breach_check_enabled']); } @@ -353,8 +560,41 @@ private function updateInstanceSettings(array $data): void { if (isset($data['offline_cache_enabled']) === true) { $this->appConfig->setValueBool($appId, 'offline_cache_enabled', (bool)$data['offline_cache_enabled']); } + + if (isset($data['device_approval_enabled']) === true) { + $this->appConfig->setValueBool($appId, 'device_approval_enabled', (bool)$data['device_approval_enabled']); + } + + if (isset($data['offline_edits_enabled']) === true) { + $this->appConfig->setValueBool($appId, 'offline_edits_enabled', (bool)$data['offline_edits_enabled']); + } }//end updateInstanceSettings() + /** + * Audit retention (add-secret-audit-trail §4.2), the one Audit area key. + * + * @param array $data The admin-settings input + * + * @return void + * + * @throws InvalidArgumentException When the window is below the minimum. + */ + private function updateAuditSettings(array $data): void { + if (isset($data['audit_retention_days']) === false) { + return; + } + + $days = (int)$data['audit_retention_days']; + if ($days < self::AUDIT_RETENTION_MIN) { + throw new InvalidArgumentException( + 'audit_retention_days must be at least ' . self::AUDIT_RETENTION_MIN + . ' days — below that the audit trail cannot serve incident investigation' + ); + } + + $this->appConfig->setValueInt(Application::APP_ID, 'audit_retention_days', $days); + }//end updateAuditSettings() + /** * Expiry defaults (rotation-expiry-policies §2.2): admin max age ships * OFF (0); reminder thresholds validated as positive ints. @@ -390,10 +630,6 @@ private function updateExpirySettings(array $data): void { $this->appConfig->setValueString($appId, 'expiry_reminder_days', (string)json_encode($thresholds)); } - - if (isset($data['expiry_policy_enforced']) === true) { - $this->appConfig->setValueBool($appId, 'expiry_policy_enforced', (bool)$data['expiry_policy_enforced']); - } }//end updateExpirySettings() /** @@ -434,10 +670,8 @@ private function updateLeaseSettings(array $data): void { }//end updateLeaseSettings() /** - * Version retention (secret-version-history §4.1) and attachment limits - * (encrypted-attachments §2.5). A floor of 1 kept version preserves - * restorability; days 0 = unlimited age. Attachment limits are expressed - * and enforced in stored CIPHERTEXT bytes — what actually consumes disk. + * Version retention (secret-version-history §4.1). A floor of 1 kept + * version preserves restorability; days 0 = unlimited age. * * @param array $data The admin-settings input * @@ -465,6 +699,20 @@ private function updateRetentionSettings(array $data): void { $this->appConfig->setValueInt($appId, 'version_retention_days', $days); } + }//end updateRetentionSettings() + + /** + * Attachment limits (encrypted-attachments §2.5), in stored CIPHERTEXT + * bytes, which is what actually consumes disk. + * + * @param array $data The admin-settings input + * + * @return void + * + * @throws InvalidArgumentException On a non-positive byte count. + */ + private function updateAttachmentSettings(array $data): void { + $appId = Application::APP_ID; if (isset($data['attachment_max_bytes']) === true) { $maxBytes = (int)$data['attachment_max_bytes']; @@ -483,5 +731,75 @@ private function updateRetentionSettings(array $data): void { $this->appConfig->setValueInt($appId, 'attachment_user_quota_bytes', $quota); } - }//end updateRetentionSettings() + }//end updateAttachmentSettings() + + /** + * Validate and persist the trash retention (vault-trash-and-archive D4). + * + * @param array $data The input data + * + * @return void + * + * @throws InvalidArgumentException When the retention is outside 1 to 365 days. + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + private function updateTrashSettings(array $data): void { + if (isset($data['trash_retention_days']) === false) { + return; + } + + $days = (int)$data['trash_retention_days']; + if ($days < SecretTrashService::RETENTION_MIN || $days > SecretTrashService::RETENTION_MAX) { + throw new InvalidArgumentException('trash_retention_days must be between 1 and 365'); + } + + $this->appConfig->setValueInt(Application::APP_ID, 'trash_retention_days', $days); + }//end updateTrashSettings() + + /** + * The administrator maximum for the extension idle lock delay, in + * minutes. A stored value outside the offered delays falls back to the + * default, so a hand-edited config never switches the idle lock off. + * + * @return int + * + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-user-chosen-idle-lock-period-with-an-administrator-maximum + */ + public function extensionMaxIdleMinutes(): int { + $minutes = $this->appConfig->getValueInt( + Application::APP_ID, + 'extension_max_idle_minutes', + self::EXTENSION_MAX_IDLE_DEFAULT + ); + if (in_array($minutes, self::EXTENSION_IDLE_CHOICES, true) === false) { + return self::EXTENSION_MAX_IDLE_DEFAULT; + } + + return $minutes; + }//end extensionMaxIdleMinutes() + + /** + * Persist the extension idle maximum: one of the offered delays. + * + * @param array $data The input data + * + * @return void + * + * @throws InvalidArgumentException When the value is not an offered delay. + * + * @spec openspec/specs/browser-extension-autofill/spec.md#requirement-user-chosen-idle-lock-period-with-an-administrator-maximum + */ + private function updateExtensionSettings(array $data): void { + if (isset($data['extension_max_idle_minutes']) === false) { + return; + } + + $minutes = (int)$data['extension_max_idle_minutes']; + if (in_array($minutes, self::EXTENSION_IDLE_CHOICES, true) === false) { + throw new InvalidArgumentException('extension_max_idle_minutes must be one of 1, 5, 15, 30, 60 or 240'); + } + + $this->appConfig->setValueInt(Application::APP_ID, 'extension_max_idle_minutes', $minutes); + }//end updateExtensionSettings() }//end class diff --git a/lib/Service/ApplicationDataCleanupService.php b/lib/Service/ApplicationDataCleanupService.php new file mode 100644 index 000000000..9dc43299c --- /dev/null +++ b/lib/Service/ApplicationDataCleanupService.php @@ -0,0 +1,130 @@ + + * @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 + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service; + +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretRequest; +use OCA\Keepiq\Db\SecretRequestMapper; +use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCP\IDBConnection; +use Throwable; + +/** + * Removes an application's secrets, requests and encryption suites. + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application + */ +class ApplicationDataCleanupService { + /** + * Constructor for ApplicationDataCleanupService. + * + * @param IDBConnection $db The database connection (transaction) + * @param SecretMapper $secretMapper The secret mapper + * @param EncryptionSuiteMapper $suiteMapper The encryption-suite mapper + * @param SecretRequestMapper $requestMapper The secret-request mapper + * @param SuiteMigrationMapper $migrationMapper The suite-migration mapper + * @param SecretChildDataCleaner $childData The attachment/version/tag cascade + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IDBConnection $db, + private SecretMapper $secretMapper, + private EncryptionSuiteMapper $suiteMapper, + private SecretRequestMapper $requestMapper, + private SuiteMigrationMapper $migrationMapper, + private SecretChildDataCleaner $childData, + ) { + }//end __construct() + + /** + * Remove everything the application owns, in one transaction. + * + * @param string $applicationId The application being deleted + * + * @return array{secrets:int,suites:int,requests:int} What was removed + * + * @throws Throwable When a step fails; the transaction is rolled back + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application + */ + public function removeFor(string $applicationId): array { + $this->db->beginTransaction(); + try { + $removed = $this->removeRows(applicationId: $applicationId); + $this->db->commit(); + } catch (Throwable $e) { + $this->db->rollBack(); + throw $e; + } + + return $removed; + }//end removeFor() + + /** + * The ordered steps: child data and requests per secret, then the + * secrets, then the requests the application created, then the suites. + * + * @param string $applicationId The application being deleted + * + * @return array{secrets:int,suites:int,requests:int} What was removed + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application + */ + private function removeRows(string $applicationId): array { + $secrets = $this->secretMapper->findByOwner('application', $applicationId, null, null, 'asc', 100000, 0); + foreach ($secrets as $secret) { + $this->childData->purgeForSecret(secretId: $secret->getId()); + $this->requestMapper->deleteBySecretId(secretId: $secret->getId()); + } + + $secretCount = $this->secretMapper->deleteByOwnerApplication(applicationId: $applicationId); + $requestCount = $this->requestMapper->deleteByCreatedBy( + userId: SecretRequest::ACTOR_APPLICATION_PREFIX.$applicationId + ); + + $suites = $this->suiteMapper->findByOwner(ownerType: 'application', ownerId: $applicationId); + $suiteIds = []; + foreach ($suites as $suite) { + $suiteIds[] = $suite->getId(); + } + + $this->migrationMapper->deleteBySuiteIds(suiteIds: $suiteIds); + foreach ($suites as $suite) { + $this->suiteMapper->delete($suite); + } + + $suiteCount = count($suites); + + return ['secrets' => $secretCount, 'suites' => $suiteCount, 'requests' => $requestCount]; + }//end removeRows() +}//end class diff --git a/lib/Service/ApplicationService.php b/lib/Service/ApplicationService.php index 182cc9925..c8240f676 100644 --- a/lib/Service/ApplicationService.php +++ b/lib/Service/ApplicationService.php @@ -78,18 +78,20 @@ class ApplicationService { * @param ApplicationLifecycleService|null $lifecycle The admission transitions * @param ApplicationSuiteProvisioner|null $suiteProvisioner The EncryptionSuite provisioner * @param ApplicationAuditTrail|null $auditTrail The application audit trail + * @param ApplicationDataCleanupService|null $dataCleanup The secrets/suite/requests delete cascade * * @return void */ public function __construct( private ApplicationMapper $mapper, - private IGroupManager $groupManager, + IGroupManager $groupManager, private LoggerInterface $logger, private ?\OCA\Keepiq\Db\MachineLeaseMapper $leaseMapper = null, private ?\OCA\Keepiq\Db\ApplicationLeasePolicyMapper $leasePolicyMapper = null, ?ApplicationLifecycleService $lifecycle = null, ?ApplicationSuiteProvisioner $suiteProvisioner = null, ?ApplicationAuditTrail $auditTrail = null, + private ?ApplicationDataCleanupService $dataCleanup = null, ) { $this->suiteProvisioner = ($suiteProvisioner ?? new ApplicationSuiteProvisioner(logger: $logger)); $this->auditTrail = ($auditTrail ?? new ApplicationAuditTrail()); @@ -185,9 +187,11 @@ public function reject(string $applicationId, string $adminUserId, bool $isAdmin }//end reject() /** - * Delete an application. Admin-only. The full cascade (Secrets + - * EncryptionSuite + SecretRequests) lands with the dedicated build - * cycle once those services accept owner_type=application. + * Delete an application. Admin-only. Its secrets, secret requests and + * encryption suite go first, in one transaction (keepiq#753), so a + * failure leaves the application in place for a retry rather than + * orphaned rows nobody can see; then the row, its leases and the audit + * entry. * * @param string $applicationId The application ID * @param bool $isAdmin Whether the caller is an admin @@ -197,6 +201,7 @@ public function reject(string $applicationId, string $adminUserId, bool $isAdmin * @throws InvalidArgumentException * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-3.7 + * @spec openspec/specs/application-mgmt/spec.md#requirement-delete-application */ public function delete(string $applicationId, bool $isAdmin): void { if ($isAdmin === false) { @@ -207,6 +212,10 @@ public function delete(string $applicationId, bool $isAdmin): void { $applicationName = $entity->getName(); + // Secrets (owner_type=application), their requests, the requests + // the application created, and its encryption suite. + $this->dataCleanup?->removeFor(applicationId: $applicationId); + $this->mapper->delete($entity); // Machine-lease cascade (machine-secret-leases §1.1): lease rows @@ -261,6 +270,8 @@ public function getCertificate(string $applicationId): ?string { * @return Application * * @throws InvalidArgumentException + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-register-application */ public function get(string $applicationId, string $userId, bool $isAdmin): Application { $entity = $this->findOr400(applicationId: $applicationId); @@ -283,6 +294,8 @@ public function get(string $applicationId, string $userId, bool $isAdmin): Appli * @param bool $isAdmin Whether the caller is an admin * * @return Application[] + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-register-application */ public function listForUser(string $userId, bool $isAdmin): array { if ($isAdmin === true) { @@ -314,6 +327,8 @@ public function listForUser(string $userId, bool $isAdmin): array { * @return Application[] * * @throws InvalidArgumentException + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-approval-queue */ public function listPending(bool $isAdmin): array { if ($isAdmin === false) { @@ -327,22 +342,13 @@ public function listPending(bool $isAdmin): array { * Count the pending applications — exposed for the dashboard summary. * * @return int + * + * @spec openspec/specs/application-mgmt/spec.md#requirement-pending-applications-counter-on-dashboard */ public function countPending(): int { return $this->mapper->countPending(); }//end countPending() - /** - * Convenience helper: check whether a user is in the admin group. - * - * @param string $userId The Nextcloud user ID - * - * @return bool - */ - public function isAdmin(string $userId): bool { - return $this->groupManager->isAdmin($userId); - }//end isAdmin() - /** * Look up a row by ID, converting DoesNotExistException to a * caller-friendly InvalidArgumentException so controllers can return diff --git a/lib/Service/CertificateAuthorityService.php b/lib/Service/CertificateAuthorityService.php index e33d5549f..c6df5b6d5 100644 --- a/lib/Service/CertificateAuthorityService.php +++ b/lib/Service/CertificateAuthorityService.php @@ -225,21 +225,45 @@ public function signCsr(string $csrPem): string { }//end signCsr() /** - * Renew the intermediate certificate. + * Renew the intermediate certificate on schedule. * - * @param bool $forced If true, immediately revoke the old intermediate. + * The old intermediate is deactivated but NOT revoked: certificates it + * signed stay valid until they are re-signed. Called by the daily + * RenewIntermediateCertificate job. * * @return int Number of suites re-signed. * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $forced does not select between two - * behaviours: the rollover (generate, deactivate, re-sign) is identical either - * way. It adds one extra fact to the old intermediate — an immediate revokedAt — - * for the compromise path. The scheduled caller (RenewIntermediateCertificate) - * relies on the false default, so the default cannot be dropped. + * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-1 + */ + public function renewIntermediate(): int { + return $this->rollIntermediate(revokeOldAt: null); + }//end renewIntermediate() + + /** + * Renew the intermediate certificate and revoke the old one immediately. + * + * The compromise path: the same rollover as renewIntermediate(), plus a + * revokedAt on the old intermediate. Called by the admin force-renew + * endpoint. + * + * @return int Number of suites re-signed. * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-1 */ - public function renewIntermediate(bool $forced = false): int { + public function renewIntermediateRevokingOld(): int { + return $this->rollIntermediate(revokeOldAt: new DateTime()); + }//end renewIntermediateRevokingOld() + + /** + * Generate a new intermediate, deactivate the old one, and re-sign all + * active suites. + * + * @param DateTime|null $revokeOldAt Revocation time to stamp on the old + * intermediate, or null to leave it unrevoked + * + * @return int Number of suites re-signed. + */ + private function rollIntermediate(?DateTime $revokeOldAt): int { $root = $this->caCertificateMapper->findRoot(); $rootKey = openssl_pkey_get_private( private_key: $this->crypto->decrypt($root->getPrivateKey()) @@ -255,8 +279,8 @@ public function renewIntermediate(bool $forced = false): int { $oldIntermediate->setIsActive(false); $oldIntermediate->setSuccessorId($newIntermediate->getId()); - if ($forced === true) { - $oldIntermediate->setRevokedAt(new DateTime()); + if ($revokeOldAt !== null) { + $oldIntermediate->setRevokedAt($revokeOldAt); } $this->caCertificateMapper->update($oldIntermediate); @@ -267,12 +291,12 @@ public function renewIntermediate(bool $forced = false): int { $this->logger->info( "Keepiq: Intermediate renewed, {$resignedCount} suites re-signed", [ - 'forced' => $forced, + 'forced' => $revokeOldAt !== null, ] ); return $resignedCount; - }//end renewIntermediate() + }//end rollIntermediate() /** * Renew the root certificate and generate a new intermediate. diff --git a/lib/Service/CertificateIssuanceService.php b/lib/Service/CertificateIssuanceService.php index 707125f17..a8f277f25 100644 --- a/lib/Service/CertificateIssuanceService.php +++ b/lib/Service/CertificateIssuanceService.php @@ -395,15 +395,12 @@ private function certCarriesPublicKey(string $certPem, string $publicKeyPem): bo * a result whose public key differs is still rejected. * * @param string $oldCert The current PEM certificate to re-sign - * @param string $fallbackCn CN to use when the old cert has none + * @param string $fallbackCn CN to add when the old cert has none * @param string $intermediateCert The signing intermediate certificate (PEM) * @param string $intermediateKeyPem The decrypted intermediate private key (PEM) * * @return string|null The new PEM certificate, or null when the public key * could not be preserved. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) $fallbackCn is kept for - * signature stability; phpseclib preserves the full original subject DN. */ private function resignPreservingPublicKey( string $oldCert, @@ -419,7 +416,8 @@ private function resignPreservingPublicKey( $newCertPem = $this->assembler->resignPreservingSubject( oldCert: $oldCert, intermediateCert: $intermediateCert, - intermediateKeyPem: $intermediateKeyPem + intermediateKeyPem: $intermediateKeyPem, + fallbackCn: $fallbackCn ); // The saveX509() helper is declared `: string`, so the only failure diff --git a/lib/Service/CertificateLifecycleService.php b/lib/Service/CertificateLifecycleService.php index 00e4fe501..611705086 100644 --- a/lib/Service/CertificateLifecycleService.php +++ b/lib/Service/CertificateLifecycleService.php @@ -146,6 +146,8 @@ public function inventory(string $userId, bool $isAdmin): array { * @param string $pem The PEM certificate * * @return array|null Parsed display fields, or null + * + * @spec openspec/specs/certificate-lifecycle/spec.md#requirement-metadata-parsing-split-by-pem-readability */ public function parseCaCertificate(string $pem): ?array { // The openssl_x509_parse() call warns when handed something that is not diff --git a/lib/Service/CloudIdForm.php b/lib/Service/CloudIdForm.php new file mode 100644 index 000000000..0895cdfd1 --- /dev/null +++ b/lib/Service/CloudIdForm.php @@ -0,0 +1,75 @@ + + * @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\Service; + +/** + * Canonical form of a federated cloud id. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +final class CloudIdForm { + /** + * The canonical form, or '' when there is no `user@remote`. + * + * @param string $cloudId A cloud id + * + * @return string + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function canonical(string $cloudId): string { + $atSign = strrpos($cloudId, '@'); + if ($atSign === false || $atSign === 0) { + return ''; + } + + $remote = (string)preg_replace('#^https?://#i', '', substr($cloudId, $atSign + 1)); + $remote = strtolower(rtrim($remote, '/')); + if ($remote === '') { + return ''; + } + + return substr($cloudId, 0, $atSign) . '@' . $remote; + }//end canonical() + + /** + * Whether two cloud ids name the same user on the same instance. + * + * @param string $first A cloud id + * @param string $second Another + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function same(string $first, string $second): bool { + $canonical = $this->canonical(cloudId: $first); + + return $canonical !== '' && $canonical === $this->canonical(cloudId: $second); + }//end same() +}//end class diff --git a/lib/Service/ComplianceReportService.php b/lib/Service/ComplianceReportService.php index a1b1f35a6..58a47c271 100644 --- a/lib/Service/ComplianceReportService.php +++ b/lib/Service/ComplianceReportService.php @@ -181,6 +181,8 @@ public function generate(string $adminUid): ComplianceReport { * List snapshots, newest first. * * @return ComplianceReport[] + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-immutable-timestamped-evidence-snapshot */ public function listReports(): array { return $this->mapper->findAll(); @@ -207,6 +209,8 @@ public function getReport(string $id): ComplianceReport { * @param string $format The export format (csv|pdf) * * @return void + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-csv-and-pdf-export */ public function recordExport(string $adminUid, string $reportId, string $format): void { $this->dispatchAudit( @@ -221,6 +225,8 @@ public function recordExport(string $adminUid, string $reportId, string $format) * Recompute the warm metrics cache (daily job, §3.1). * * @return void + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-org-level-metadata-only-compliance-report */ public function refreshMetricsCache(): void { $appId = Application::APP_ID; @@ -232,6 +238,8 @@ public function refreshMetricsCache(): void { * The warm metrics cache (computed on demand when cold). * * @return array{computedAt:string|null, metrics:array} + * + * @spec openspec/specs/compliance-reporting/spec.md#requirement-org-level-metadata-only-compliance-report */ public function cachedMetrics(): array { $appId = Application::APP_ID; diff --git a/lib/Service/CompromiseBlastRadius.php b/lib/Service/CompromiseBlastRadius.php new file mode 100644 index 000000000..1217a880b --- /dev/null +++ b/lib/Service/CompromiseBlastRadius.php @@ -0,0 +1,159 @@ + + * @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\Service; + +use OCA\Keepiq\Db\Secret; + +/** + * The secrets, shared copies and emergency grantors one containment touches. + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ +final class CompromiseBlastRadius { + /** + * Every secret sealed under a revoked suite, with the secret a warning + * about it points at (the SOURCE for a shared copy, otherwise itself). + * + * @var list + */ + private array $sealed = []; + + /** + * Copies of the revoked user's own secrets held by other users. + * + * @var list + */ + private array $outbound = []; + + /** + * Grantors whose approved emergency envelope is sealed to a revoked suite. + * + * @var list + */ + private array $grantors = []; + + /** + * Lookups that failed while collecting; each is a gap in the warning. + * + * @var int + */ + private int $failures = 0; + + /** + * Record a secret sealed under a revoked suite. + * + * @param Secret $secret The secret sealed under the suite + * @param Secret $target The secret its warning points at + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function addSealed(Secret $secret, Secret $target): void { + $this->sealed[] = ['secret' => $secret, 'target' => $target]; + }//end addSealed() + + /** + * Record a copy of the revoked user's secret held by someone else. + * + * @param string $recipientId The user holding the copy + * @param Secret $copy The copy + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function addOutbound(string $recipientId, Secret $copy): void { + $this->outbound[] = ['recipientId' => $recipientId, 'copy' => $copy]; + }//end addOutbound() + + /** + * Record a grantor whose approved emergency envelope a revoked suite opens. + * + * @param string $grantorId The vault owner who designated the contact + * @param string $granteeId The revoked user, the contact + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function addGrantor(string $grantorId, string $granteeId): void { + $this->grantors[] = ['grantorId' => $grantorId, 'granteeId' => $granteeId]; + }//end addGrantor() + + /** + * Count a lookup that failed while collecting. + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function addFailure(): void { + $this->failures++; + }//end addFailure() + + /** + * The sealed secrets and their warning targets. + * + * @return list + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function getSealed(): array { + return $this->sealed; + }//end getSealed() + + /** + * The outbound copies and their holders. + * + * @return list + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function getOutbound(): array { + return $this->outbound; + }//end getOutbound() + + /** + * The exposed emergency grantors. + * + * @return list + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function getGrantors(): array { + return $this->grantors; + }//end getGrantors() + + /** + * How many lookups failed while collecting. + * + * @return int + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function getFailures(): int { + return $this->failures; + }//end getFailures() +}//end class diff --git a/lib/Service/CompromiseContainmentService.php b/lib/Service/CompromiseContainmentService.php new file mode 100644 index 000000000..caedfdb0b --- /dev/null +++ b/lib/Service/CompromiseContainmentService.php @@ -0,0 +1,468 @@ + + * @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\Service; + +use OCA\Keepiq\Db\EmergencyContact; +use OCA\Keepiq\Db\EmergencyContactMapper; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Listener\MarksCompromisedSecrets; +use OCP\Authentication\Token\IProvider; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Collect and contain the blast radius of a compromise force-revoke. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Containment is by definition + * the place where the suite, sharing, emergency-access, notification and + * session domains meet; each dependency is one thing the compromised key + * could reach. + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ +class CompromiseContainmentService { + use MarksCompromisedSecrets; + + /** + * Constructor. + * + * @param SecretMapper $secretMapper Blast-radius lookup and stamp + * @param ShareTargetMapper $shareTargetMapper Resolves shared sources and outbound copies + * @param EmergencyContactMapper $contactMapper Finds grantors exposed through the revoked key + * @param NotificationService $notificationService Warns the affected users + * @param MigrationService $migrationService Revokes the owner's link shares and passkeys + * @param IProvider $tokenProvider Ends the owner's sessions and app passwords + * @param LoggerInterface $logger The logger + * @param RotationPolicyService|null $rotationService Raises the suite_compromise rotation flags + * + * @return void + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $shareTargetMapper, + private EmergencyContactMapper $contactMapper, + private NotificationService $notificationService, + private MigrationService $migrationService, + private IProvider $tokenProvider, + private LoggerInterface $logger, + private ?RotationPolicyService $rotationService = null, + ) { + }//end __construct() + + /** + * Collect what the given suites' compromise exposes. Changes nothing. + * + * Call it before revoking any of the suites. A lookup that fails is counted + * on the result, never thrown, so one bad row cannot hide the rest. + * + * @param list $suiteIds The suites about to be revoked as compromised + * + * @return CompromiseBlastRadius + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function collect(array $suiteIds): CompromiseBlastRadius { + $radius = new CompromiseBlastRadius(); + foreach ($suiteIds as $suiteId) { + $this->collectSealed(radius: $radius, suiteId: $suiteId); + $this->collectGrantors(radius: $radius, suiteId: $suiteId); + } + + return $radius; + }//end collect() + + /** + * Stamp, flag and warn over a collected blast radius, then contain the account. + * + * Runs after the suites are revoked. Every step is contained on its own and + * counted: a failure on one secret or one notification never stops the + * rest, and the caller reports the count to the administrator (keepiq#863). + * + * @param CompromiseBlastRadius $radius Collected before the revoke + * @param EncryptionSuite $suite The suite the administrator revoked + * @param string $revokedBy The acting administrator + * + * @return array{stamped: int, notified: int, failed: int} + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function contain(CompromiseBlastRadius $radius, EncryptionSuite $suite, string $revokedBy): array { + $tally = ['stamped' => 0, 'notified' => 0, 'failed' => $radius->getFailures()]; + + $this->stampSealed(radius: $radius, tally: $tally); + $this->warnOwners(radius: $radius, suite: $suite, revokedBy: $revokedBy, tally: $tally); + $this->warnRecipients(radius: $radius, tally: $tally); + $this->warnGrantors(radius: $radius, tally: $tally); + + if ($suite->getOwnerType() === 'user') { + $this->containAccount(ownerId: (string)$suite->getOwnerId(), tally: $tally); + } + + if ($tally['failed'] > 0) { + $this->logger->error( + 'Keepiq: compromise containment for suite ' . $suite->getId() . ' is incomplete', + ['app' => 'keepiq', 'stamped' => $tally['stamped'], 'failed' => $tally['failed']] + ); + } + + return $tally; + }//end contain() + + /** + * Tell a user that an administrator's revoke deleted their emergency contacts. + * + * Revocation deletes the contacts outright, so the owner has nothing left to + * look at afterwards; this notice is the only way they learn they have to + * designate them again (keepiq#876). A count only, never identities. + * + * @param EncryptionSuite $suite The revoked suite + * @param int $count The usable contacts the revoke deleted + * + * @return bool True when a notice went out + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + public function notifyEmergencyAccessCleared(EncryptionSuite $suite, int $count): bool { + if ($count <= 0 || $suite->getOwnerType() !== 'user') { + return false; + } + + try { + return $this->notificationService->notify( + subject: 'emergency_access_cleared', + recipientId: (string)$suite->getOwnerId(), + params: ['count' => $count], + objectType: 'suite', + objectId: (string)$suite->getId(), + ); + } catch (Throwable $exception) { + $this->logger->warning( + 'Keepiq: could not tell ' . $suite->getOwnerId() . ' their emergency access was cleared: ' + . $exception->getMessage(), + ['app' => 'keepiq'] + ); + return false; + } + }//end notifyEmergencyAccessCleared() + + /** + * Collect the secrets sealed under one suite and the copies shared out of it. + * + * @param CompromiseBlastRadius $radius The radius being collected + * @param string $suiteId The suite + * + * @return void + */ + private function collectSealed(CompromiseBlastRadius $radius, string $suiteId): void { + try { + $secrets = $this->secretMapper->findByEncryptionSuiteId($suiteId); + } catch (Throwable $exception) { + $radius->addFailure(); + $this->logger->error( + 'Keepiq: could not list the secrets sealed under suite ' . $suiteId . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + return; + } + + foreach ($secrets as $secret) { + $target = $this->resolveTarget(secret: $secret); + $radius->addSealed(secret: $secret, target: $target); + if ($target === $secret) { + $this->collectOutbound(radius: $radius, secret: $secret); + } + } + }//end collectSealed() + + /** + * Collect the copies other users hold of one of the revoked user's secrets. + * + * Those recipients are the only people left who can act on the value, and + * nothing else tells them (keepiq#872). + * + * @param CompromiseBlastRadius $radius The radius being collected + * @param Secret $secret The revoked user's own secret + * + * @return void + */ + private function collectOutbound(CompromiseBlastRadius $radius, Secret $secret): void { + try { + foreach ($this->shareTargetMapper->findBySourceSecret($secret->getId()) as $shareTarget) { + $recipientId = $shareTarget->getTargetUserId(); + if ($recipientId === '' || $recipientId === $secret->getOwnerId()) { + continue; + } + + $copy = $this->secretMapper->findById($shareTarget->getSecretId()); + $radius->addOutbound(recipientId: $recipientId, copy: $copy); + } + } catch (Throwable $exception) { + $radius->addFailure(); + $this->logger->warning( + 'Keepiq: could not list the copies of secret ' . $secret->getId() . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end collectOutbound() + + /** + * Collect the grantors whose approved emergency envelope the suite can open. + * + * An approved envelope escrows the grantor's private key, sealed to the + * grantee's suite. With that suite compromised the grantor's whole vault is + * exposed (keepiq#872). + * + * @param CompromiseBlastRadius $radius The radius being collected + * @param string $suiteId The grantee's suite + * + * @return void + */ + private function collectGrantors(CompromiseBlastRadius $radius, string $suiteId): void { + try { + foreach ($this->contactMapper->findByGranteeSuite($suiteId) as $contact) { + if ($contact->getState() !== EmergencyContact::STATE_APPROVED) { + continue; + } + + $radius->addGrantor(grantorId: $contact->getGrantorUserId(), granteeId: $contact->getGranteeUserId()); + } + } catch (Throwable $exception) { + $radius->addFailure(); + $this->logger->error( + 'Keepiq: could not list the emergency grants of suite ' . $suiteId . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end collectGrantors() + + /** + * Stamp and flag every sealed secret and, for a shared copy, its source. + * + * @param CompromiseBlastRadius $radius The collected radius + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function stampSealed(CompromiseBlastRadius $radius, array &$tally): void { + $done = []; + foreach ($radius->getSealed() as $entry) { + foreach ([$entry['secret'], $entry['target']] as $secret) { + if (isset($done[$secret->getId()]) === true) { + continue; + } + + $done[$secret->getId()] = true; + if ($this->stampAndFlag(secret: $secret) === true) { + $tally['stamped']++; + continue; + } + + $tally['failed']++; + } + } + }//end stampSealed() + + /** + * Warn each owner once, naming the first secret and counting the others. + * + * One notification per owner keeps a large vault from flooding the inbox, + * but it must say how many secrets are affected, or one name reads as + * "only this one" (keepiq#875). + * + * @param CompromiseBlastRadius $radius The collected radius + * @param EncryptionSuite $suite The revoked suite + * @param string $revokedBy The acting administrator + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function warnOwners(CompromiseBlastRadius $radius, EncryptionSuite $suite, string $revokedBy, array &$tally): void { + $byOwner = []; + foreach ($radius->getSealed() as $entry) { + $target = $entry['target']; + $ownerId = (string)$target->getOwnerId(); + if ($ownerId === '') { + continue; + } + + $byOwner[$ownerId]['first'] ??= $target; + $byOwner[$ownerId]['ids'][$target->getId()] = true; + } + + foreach ($byOwner as $ownerId => $group) { + $this->send( + subject: 'secret_compromised', + recipientId: (string)$ownerId, + first: $group['first'], + count: count($group['ids']), + extra: ['suiteId' => $suite->getId(), 'revokedBy' => $revokedBy], + tally: $tally + ); + } + }//end warnOwners() + + /** + * Warn each holder of a copy of the revoked user's secrets, once. + * + * @param CompromiseBlastRadius $radius The collected radius + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function warnRecipients(CompromiseBlastRadius $radius, array &$tally): void { + $byRecipient = []; + foreach ($radius->getOutbound() as $entry) { + $byRecipient[$entry['recipientId']]['first'] ??= $entry['copy']; + $byRecipient[$entry['recipientId']]['ids'][$entry['copy']->getId()] = true; + } + + foreach ($byRecipient as $recipientId => $group) { + $this->send( + subject: 'shared_secret_compromised', + recipientId: (string)$recipientId, + first: $group['first'], + count: count($group['ids']), + extra: [], + tally: $tally + ); + } + }//end warnRecipients() + + /** + * Warn each grantor whose vault the compromised grantee could open. + * + * @param CompromiseBlastRadius $radius The collected radius + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function warnGrantors(CompromiseBlastRadius $radius, array &$tally): void { + $warned = []; + foreach ($radius->getGrantors() as $grant) { + if (isset($warned[$grant['grantorId']]) === true) { + continue; + } + + $warned[$grant['grantorId']] = true; + try { + $sent = $this->notificationService->notify( + subject: 'emergency_grantee_compromised', + recipientId: $grant['grantorId'], + params: ['granteeUserId' => $grant['granteeId']], + objectType: 'emergency_contact', + objectId: $grant['granteeId'], + ); + $tally['notified'] += (int)$sent; + } catch (Throwable $exception) { + $tally['failed']++; + $this->logger->warning( + 'Keepiq: could not warn grantor ' . $grant['grantorId'] . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end foreach + }//end warnGrantors() + + /** + * Send one aggregated secret warning, containing its own failure. + * + * @param string $subject The notification subject + * @param string $recipientId Who is warned + * @param Secret $first The secret the notice names and links + * @param int $count How many secrets the notice covers + * @param array $extra Extra subject parameters + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function send(string $subject, string $recipientId, Secret $first, int $count, array $extra, array &$tally): void { + try { + $sent = $this->notificationService->notify( + subject: $subject, + recipientId: $recipientId, + params: $extra + [ + 'secret_id' => $first->getId(), + 'secret_name' => $first->getName(), + 'other_count' => $count - 1, + ], + objectType: 'secret', + objectId: $first->getId(), + ); + $tally['notified'] += (int)$sent; + } catch (Throwable $exception) { + $tally['failed']++; + $this->logger->warning( + 'Keepiq: could not send ' . $subject . ' to ' . $recipientId . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end send() + + /** + * Contain the account itself: link shares, passkeys, sessions. + * + * The same key-material cleanup the owner's compromise recovery runs + * (keepiq#858), plus ending every session and app password. Without the + * last step a stolen session could enrol a new suite the moment the old one + * is revoked and become the user's identity for every new share + * (keepiq#860). + * + * @param string $ownerId The revoked user + * @param array{stamped: int, notified: int, failed: int} $tally Running counts + * + * @return void + */ + private function containAccount(string $ownerId, array &$tally): void { + try { + $this->migrationService->revokeKeyMaterialOfOwner(ownerId: $ownerId); + } catch (Throwable $exception) { + $tally['failed']++; + $this->logger->error( + 'Keepiq: could not revoke the link shares and passkeys of ' . $ownerId . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + + try { + $this->tokenProvider->invalidateTokensOfUser($ownerId, null); + } catch (Throwable $exception) { + $tally['failed']++; + $this->logger->error( + 'Keepiq: could not end the sessions of ' . $ownerId . ': ' . $exception->getMessage(), + ['app' => 'keepiq'] + ); + } + }//end containAccount() +}//end class diff --git a/lib/Service/ConfirmerCopyResolver.php b/lib/Service/ConfirmerCopyResolver.php new file mode 100644 index 000000000..7e8e4d2d1 --- /dev/null +++ b/lib/Service/ConfirmerCopyResolver.php @@ -0,0 +1,116 @@ + + * @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\Service; + +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Resolves a confirmer's own current write copy of a source secret. + */ +class ConfirmerCopyResolver { + /** + * Constructor. + * + * @param TeamFolderQueryService $queries Effective grades + * @param ShareTargetMapper $shareTargetMapper The confirmer's own copies + * @param SecretMapper $secretMapper Source and copy timestamps + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private TeamFolderQueryService $queries, + private ShareTargetMapper $shareTargetMapper, + private SecretMapper $secretMapper, + ) { + }//end __construct() + + /** + * The id of the confirmer's own copy of a source, when they may hand it on. + * + * Null unless the confirmer's effective grade on the source is `write` + * and their copy is not older than the source's last key change + * (design D2 and D3). + * + * @param string $sourceId The source secret + * @param string $confirmerId The confirmer + * + * @return string|null + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + public function currentWriteCopy(string $sourceId, string $confirmerId): ?string { + try { + $source = $this->secretMapper->findById(id: $sourceId); + } catch (DoesNotExistException) { + return null; + } + + if ($this->queries->resolveGrade(secret: $source, userId: $confirmerId) !== 'write') { + return null; + } + + try { + $shareRow = $this->shareTargetMapper->findBySourceSecretAndTargetUser( + sourceSecretId: $sourceId, + targetUserId: $confirmerId + ); + $copy = $this->secretMapper->findById(id: (string)$shareRow->getSecretId()); + } catch (DoesNotExistException) { + return null; + } + + if ($this->copyIsCurrent(source: $source, copy: $copy) === false) { + return null; + } + + return $copy->getId(); + }//end currentWriteCopy() + + /** + * Whether a copy is at least as new as the source's last key change. + * + * @param Secret $source The source secret + * @param Secret $copy The confirmer's copy + * + * @return bool + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function copyIsCurrent(Secret $source, Secret $copy): bool { + $keyChangedAt = $source->getKeyUpdatedAt(); + if ($keyChangedAt === null) { + return true; + } + + $copyUpdatedAt = $copy->getUpdatedAt(); + + return $copyUpdatedAt !== null && $copyUpdatedAt >= $keyChangedAt; + }//end copyIsCurrent() +}//end class diff --git a/lib/Service/Connection/ConnectionObservations.php b/lib/Service/Connection/ConnectionObservations.php index 685baf2a7..45477f85a 100644 --- a/lib/Service/Connection/ConnectionObservations.php +++ b/lib/Service/Connection/ConnectionObservations.php @@ -18,7 +18,7 @@ * * @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 + * @spec openspec/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 @@ -38,7 +38,7 @@ * 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 + * @spec openspec/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 { @@ -59,7 +59,7 @@ class ConnectionObservations { * * @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 + * @spec openspec/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) { @@ -88,7 +88,7 @@ public function breachLookup(?int $httpStatus): array { * * @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 + * @spec openspec/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) { @@ -111,7 +111,7 @@ public function siemSinksChanged(int $enabledSinks, int $sinks): ?array { * * @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 + * @spec openspec/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) { @@ -162,7 +162,7 @@ public function siemDrain(int $enabledSinks, array $delivered, int $sinks): ?arr * * @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 + * @spec openspec/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); @@ -188,7 +188,7 @@ public function siemSinkHost(string $endpoint): string { * * @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 + * @spec openspec/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) { diff --git a/lib/Service/Connection/ConnectionReporter.php b/lib/Service/Connection/ConnectionReporter.php index 276d5d341..a6713f060 100644 --- a/lib/Service/Connection/ConnectionReporter.php +++ b/lib/Service/Connection/ConnectionReporter.php @@ -19,7 +19,7 @@ * * @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 + * @spec openspec/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 @@ -44,7 +44,7 @@ * 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 + * @spec openspec/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 { @@ -129,7 +129,7 @@ class ConnectionReporter { * @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 + * @spec openspec/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, @@ -148,7 +148,7 @@ public function __construct( * * @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 + * @spec openspec/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); @@ -161,7 +161,7 @@ public function breachCheckSaved(): bool { * * @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 + * @spec openspec/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( @@ -182,7 +182,7 @@ public function reportBreachLookup(?int $httpStatus): bool { * * @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 + * @spec openspec/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) { @@ -211,7 +211,7 @@ public function siemSinksChanged(callable $enabledSinkCount, callable $sinkCount * * @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 + * @spec openspec/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( @@ -257,7 +257,7 @@ private function countSinksWhenNoneEnabled(int $enabled, callable $sinkCount): i * * @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 + * @spec openspec/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); @@ -270,7 +270,7 @@ public function httpStatusOf(Throwable $exception): ?int { * * @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 + * @spec openspec/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; diff --git a/lib/Service/DashboardService.php b/lib/Service/DashboardService.php index b11f49b4f..d4c9142f3 100644 --- a/lib/Service/DashboardService.php +++ b/lib/Service/DashboardService.php @@ -88,6 +88,8 @@ public function __construct( * @return mixed The decoded value, or null when no row exists. * * @throws InvalidArgumentException When the key is not whitelisted. + * + * @spec openspec/specs/user-settings/spec.md#requirement-default-view-preference-v1 */ public function get(string $userId, string $settingKey): mixed { $this->validateUserId(userId: $userId); @@ -108,6 +110,8 @@ public function get(string $userId, string $settingKey): mixed { * @param string $userId The Nextcloud user ID * * @return array + * + * @spec openspec/specs/user-settings/spec.md#requirement-default-view-preference-v1 */ public function listForUser(string $userId): array { $this->validateUserId(userId: $userId); @@ -180,6 +184,8 @@ public function set(string $userId, string $settingKey, mixed $value): Dashboard * @return array The full settings map for the user post-update. * * @throws InvalidArgumentException + * + * @spec openspec/specs/user-settings/spec.md#requirement-default-view-preference-v1 */ public function setMany(string $userId, array $settings): array { foreach ($settings as $key => $value) { @@ -195,6 +201,8 @@ public function setMany(string $userId, array $settings): array { * @param string $userId The Nextcloud user ID * * @return void + * + * @spec openspec/specs/user-settings/spec.md#requirement-default-view-preference-v1 */ public function deleteAllForUser(string $userId): void { $this->mapper->deleteByUser($userId); diff --git a/lib/Service/DashboardSummaryService.php b/lib/Service/DashboardSummaryService.php index a4c0ec7ec..c66ed95e4 100644 --- a/lib/Service/DashboardSummaryService.php +++ b/lib/Service/DashboardSummaryService.php @@ -127,7 +127,7 @@ public function fetchSummary(string $userId, bool $isAdmin): array { $summary = [ 'total_secrets' => $this->safeCount( - counter: fn () => $this->secretMapper?->countByOwner('user', $userId, null) ?? 0, + counter: fn () => $this->secretMapper?->countByOwner('user', $userId, null, null, SecretMapper::STATE_LIVE) ?? 0, metricId: 'total_secrets', ), 'shared_with_me_count' => $this->safeCount( diff --git a/lib/Service/DelegationAuthorizer.php b/lib/Service/DelegationAuthorizer.php index 0922e55c2..853757d42 100644 --- a/lib/Service/DelegationAuthorizer.php +++ b/lib/Service/DelegationAuthorizer.php @@ -6,7 +6,7 @@ * The authorization surface of the SecretDelegation lifecycle * (ownership-delegation spec.md, FEATURES.md V1 §17.1). One place answers * "may this delegation happen at all": the Secret must exist, the delegate - * must be named, the admin path needs vault_admin membership, and either + * must be named, the admin path needs the People admin area, and either * path needs the recipient to already hold a share — a delegation promotes * an *existing* recipient copy, it never creates access. * @@ -30,30 +30,19 @@ use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretMapper; use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Settings\PeopleAdminSettings; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\IGroupManager; /** * Authorization decisions for the SecretDelegation lifecycle. */ class DelegationAuthorizer { - /** - * The Nextcloud group whose members can initiate admin-handover - * delegations (override the owner-consent requirement). Members of - * this group still MUST already hold a share of the target Secret — - * the admin path widens *who* can create the delegation, not what - * can be delegated without a pre-existing share. - * - * @var string - */ - public const VAULT_ADMIN_GROUP = 'vault_admin'; - /** * Constructor for DelegationAuthorizer. * * @param SecretMapper $secretMapper The Secret mapper (owner lookup) * @param ShareTargetMapper|null $shareTargetMapper Pre-existing-share lookup (admin path) - * @param IGroupManager|null $groupManager Group membership check (admin path) + * @param AdminAreaAuthorizer|null $areas The People area check (admin path) * * @return void * @@ -62,7 +51,7 @@ class DelegationAuthorizer { public function __construct( private SecretMapper $secretMapper, private ?ShareTargetMapper $shareTargetMapper = null, - private ?IGroupManager $groupManager = null, + private ?AdminAreaAuthorizer $areas = null, ) { }//end __construct() @@ -110,39 +99,40 @@ public function requireDelegableSecret(string $secretId, string $delegatedTo): S }//end requireDelegableSecret() /** - * Assert that $userId is a member of the vault_admin group. + * Assert that $userId may use the admin handover path: an instance admin + * or a holder of the People and offboarding area (admin-scoped-roles D5). * * @param string $userId The candidate admin user ID * * @return void * - * @throws InvalidArgumentException When the group manager is wired - * but the user is not a vault admin. + * @throws InvalidArgumentException When the area check is wired but the + * user does not hold the People area. * - * @spec openspec/changes/implement-user-sharing/tasks.md#task-17.1 + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.4 */ - public function requireVaultAdmin(string $userId): void { - if ($this->groupManager === null) { - // No group manager wired — admin path cannot be authorized. + public function requireHandoverAdmin(string $userId): void { + if ($this->areas === null) { + // No area check wired: the admin path cannot be authorized. throw new InvalidArgumentException( message: 'Admin handover is not available in this context' ); } - if ($this->isVaultAdmin(userId: $userId) === false) { + if ($this->canHandover(userId: $userId) === false) { throw new InvalidArgumentException( - message: 'Admin handover requires membership in the vault_admin group' + message: 'Admin handover requires the People and offboarding admin area' ); } - }//end requireVaultAdmin() + }//end requireHandoverAdmin() /** * Whether $userId may use the admin handover path at all. * * Exists so the UI can decide whether to OFFER the takeover without - * duplicating the membership rule: `requireVaultAdmin()` above is written - * in terms of this predicate, so the button and the enforcement can never - * drift apart. It answers only the group question — the per-secret + * duplicating the rule: `requireHandoverAdmin()` above is written in + * terms of this predicate, so the button and the enforcement can never + * drift apart. It answers only the area question; the per-secret * preconditions (not already the owner, already holds a share) stay with * the delegation entry points, because they need the Secret. * @@ -150,15 +140,15 @@ public function requireVaultAdmin(string $userId): void { * * @return bool * - * @spec openspec/specs/user-sharing/spec.md#requirement-ownership-delegation + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.4 */ - public function isVaultAdmin(string $userId): bool { - if ($this->groupManager === null) { + public function canHandover(string $userId): bool { + if ($this->areas === null) { return false; } - return $this->groupManager->isInGroup($userId, self::VAULT_ADMIN_GROUP); - }//end isVaultAdmin() + return $this->areas->holds(userId: $userId, areaClass: PeopleAdminSettings::class); + }//end canHandover() /** * Assert that $userId already holds a share of $secretId. No-op when diff --git a/lib/Service/DelegationService.php b/lib/Service/DelegationService.php index 3434621ae..7b3cc41da 100644 --- a/lib/Service/DelegationService.php +++ b/lib/Service/DelegationService.php @@ -42,7 +42,7 @@ /** * Business logic for the SecretDelegation lifecycle (scaffold). * - * The authorization decisions — Secret existence, vault_admin membership + * The authorization decisions — Secret existence, the People admin area * and the pre-existing-share precondition — live in DelegationAuthorizer; * this class owns the delegation ROWS and their audit trail. */ @@ -139,7 +139,7 @@ public function createDelegation( * Create a temporary delegation by ADMIN POWER GRAB * (FEATURES.md V1 §17.1, ownership-delegation spec.md). * - * `initiatedBy` MUST be in the vault_admin Nextcloud group AND already + * `initiatedBy` MUST hold the People admin area AND already * hold a share of the Secret. The delegation is created with * `delegated_to = initiatedBy` so the admin's own copy is promoted. * Owner consent is NOT required, but the admin must already be a @@ -168,7 +168,7 @@ public function createAdminHandover( ): SecretDelegation { $secret = $this->authorizer->requireDelegableSecret(secretId: $secretId, delegatedTo: $delegatedTo); - $this->authorizer->requireVaultAdmin(userId: $initiatedBy); + $this->authorizer->requireHandoverAdmin(userId: $initiatedBy); if ($delegatedTo !== $initiatedBy) { throw new InvalidArgumentException( @@ -209,6 +209,9 @@ private function persistDelegation( string $delegatedTo, string $initiatedBy, ): SecretDelegation { + // A use-only or expiring copy is never handed on (D4). + $secret->assertOnwardShareable(); + $entity = new SecretDelegation(); $entity->setId(Uuid::uuid4()->toString()); $entity->setSecretId($secretId); @@ -241,18 +244,18 @@ private function persistDelegation( * Whether $userId may use the admin handover path at all. * * Exposed here so the UI can decide whether to OFFER the takeover; the - * membership rule itself has a single home in DelegationAuthorizer, so - * the button and the enforcement can never drift apart. + * rule itself has a single home in DelegationAuthorizer, so the button + * and the enforcement can never drift apart. * * @param string $userId The candidate admin user ID * * @return bool * - * @spec openspec/specs/user-sharing/spec.md#requirement-ownership-delegation + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.4 */ - public function isVaultAdmin(string $userId): bool { - return $this->authorizer->isVaultAdmin(userId: $userId); - }//end isVaultAdmin() + public function canHandover(string $userId): bool { + return $this->authorizer->canHandover(userId: $userId); + }//end canHandover() /** * Reclaim — the original owner revokes all TEMPORARY delegations they @@ -344,4 +347,41 @@ public function getDelegationsForSecret(string $secretId, string $ownerId): arra public function makePermanent(string $originalOwnerId): int { return $this->mapper->makePermanentByOriginalOwner($originalOwnerId); }//end makePermanent() + + /** + * Revoke all TEMPORARY delegations the given original owner created. + * Permanent delegations are immutable and are NOT touched. + * + * Called by the EncryptionSuiteRevoked listener when an administrator + * force-revokes the owner's suite as COMPROMISED (keepiq#817, ADR-005): + * a temporary delegation is the cheapest foothold to create from a + * stolen session, so a compromise cuts it instead of promoting it. + * Each removed row is audited as a reclaim by the revoking actor. + * + * @param string $originalOwnerId The original owner user ID + * @param string $revokedBy The actor who revoked the suite + * + * @return int The number of delegations removed. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-permanent-transfer-on-suite-revocation + */ + public function revokeTemporary(string $originalOwnerId, string $revokedBy): int { + $removed = 0; + foreach ($this->mapper->findTemporaryByOriginalOwner($originalOwnerId) as $entity) { + $this->mapper->delete($entity); + ++$removed; + + $this->dispatchAudit( + event: $this->auditEvents->forUser( + actorId: $revokedBy, + eventType: AuditEventTypes::SHARE_DELEGATION_RECLAIMED, + objectType: 'share', + objectId: $entity->getSecretId(), + metadata: ['delegatedTo' => $entity->getDelegatedTo()], + ) + ); + } + + return $removed; + }//end revokeTemporary() }//end class diff --git a/lib/Service/DeletionReport.php b/lib/Service/DeletionReport.php index 9eb5d6b94..b36145b34 100644 --- a/lib/Service/DeletionReport.php +++ b/lib/Service/DeletionReport.php @@ -94,12 +94,26 @@ class DeletionReport implements JsonSerializable { */ public bool $settingsDeleted = false; + /** + * Emergency-access relationships removed, as grantor or as grantee. + * + * @var int + */ + public int $emergencyDeleted = 0; + + /** + * Passkey unlock credentials removed. + * + * @var int + */ + public int $passkeysDeleted = 0; + /** * Serialize the report for the API response and the audit event. * * @return array * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function jsonSerialize(): array { return [ @@ -112,6 +126,8 @@ public function jsonSerialize(): array { 'requestsDeleted' => $this->requestsDeleted, 'suitesDeleted' => $this->suitesDeleted, 'settingsDeleted' => $this->settingsDeleted, + 'emergencyContactsDeleted' => $this->emergencyDeleted, + 'passkeysDeleted' => $this->passkeysDeleted, ]; }//end jsonSerialize() }//end class diff --git a/lib/Service/DeviceApprovalService.php b/lib/Service/DeviceApprovalService.php new file mode 100644 index 000000000..dc8454482 --- /dev/null +++ b/lib/Service/DeviceApprovalService.php @@ -0,0 +1,379 @@ + + * @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\Service; + +use DateInterval; +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\DeviceApproval; +use OCA\Keepiq\Db\DeviceApprovalMapper; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\Security\ISecureRandom; +use Ramsey\Uuid\Uuid; + +/** + * The server side of new device approval (crypto-new-device-approval): + * it stores requests, relays a sealed unlock key it cannot open, and gives + * it to the requesting device once. Every lookup is scoped to the request's + * own user, and a request of another user answers exactly like an unknown id. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The request lifecycle, its + * notification and its audit trail in one place. + */ +class DeviceApprovalService { + + /** + * App config switch; default on (D5). + * + * @var string + */ + public const ENABLED_KEY = 'device_approval_enabled'; + + /** + * How long a request stays open, in seconds (D1). + * + * @var int + */ + public const TTL_SECONDS = 900; + + /** + * The client kinds that may ask. + * + * @var string[] + */ + public const CLIENT_KINDS = ['web', 'extension']; + + /** + * Constructor for DeviceApprovalService. + * + * @param DeviceApprovalMapper $mapper The request mapper + * @param NotificationService $notifications The notification dispatcher + * @param IAppConfig $appConfig The app config (switch) + * @param ISecureRandom $random The secure random generator + * @param IEventDispatcher $eventDispatcher The audit dispatcher + * @param AuditEventFactory $auditEvents The audit-event factory + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private DeviceApprovalMapper $mapper, + private NotificationService $notifications, + private IAppConfig $appConfig, + private ISecureRandom $random, + private IEventDispatcher $eventDispatcher, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * Whether an administrator left device approval on. + * + * @return bool + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + public function isEnabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::ENABLED_KEY, true); + }//end isEnabled() + + /** + * Store a request from a locked device and notify the user. + * + * @param string $userId The signed-in user + * @param string $publicKey The one-time X25519 public key (base64, 32 bytes) + * @param string $clientKind `web` or `extension` + * @param string $label The device label + * @param string $address The caller's IP address + * @param string $agent The caller's user agent + * + * @return array{id:string,requestSecret:string,expiresAt:string} + * + * @throws ForbiddenException When the feature is off + * @throws InvalidArgumentException When the input is malformed + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-a-new-device-requests-approval-with-a-one-time-key + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each argument is one recorded fact of the request. + */ + public function create( + string $userId, + string $publicKey, + string $clientKind, + string $label, + string $address, + string $agent, + ): array { + if ($this->isEnabled() === false) { + throw new ForbiddenException(message: 'Device approval is turned off'); + } + + $raw = base64_decode($publicKey, true); + if ($raw === false || strlen($raw) !== 32) { + throw new InvalidArgumentException(message: 'publicKey must be a raw X25519 public key'); + } + + if (in_array($clientKind, self::CLIENT_KINDS, true) === false) { + throw new InvalidArgumentException(message: 'clientKind must be web or extension'); + } + + $secret = $this->random->generate(48, ISecureRandom::CHAR_ALPHANUMERIC); + $now = new DateTime(); + + $request = new DeviceApproval(); + $request->setId(Uuid::uuid4()->toString()); + $request->setUserId($userId); + $request->setClientKind($clientKind); + $request->setDeviceLabel(mb_substr(trim($label), 0, 255)); + $request->setRequesterIp(mb_substr($address, 0, 64)); + $request->setRequesterAgent(mb_substr($agent, 0, 512)); + $request->setRequestPublicKey($publicKey); + $request->setRequestSecretHash(hash('sha256', $secret)); + $request->setStatus(DeviceApproval::STATUS_PENDING); + $request->setCreatedAt($now); + $request->setExpiresAt((clone $now)->add(new DateInterval('PT' . self::TTL_SECONDS . 'S'))); + $request = $this->mapper->insert($request); + + $this->notifications->notify( + subject: 'device_approval_requested', + recipientId: $userId, + params: [ + 'request_id' => $request->getId(), + 'device_label' => $request->getDeviceLabel(), + ], + objectType: 'device_approval', + objectId: $request->getId(), + ); + $this->audit(request: $request, eventType: AuditEventTypes::DEVICE_APPROVAL_REQUESTED, metadata: ['clientKind' => $clientKind]); + + return [ + 'id' => $request->getId(), + 'requestSecret' => $secret, + 'expiresAt' => (string)$request->getExpiresAt()?->format('c'), + ]; + }//end create() + + /** + * The user's open requests, for the approving device. + * + * @param string $userId The user + * + * @return DeviceApproval[] + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-both-devices-show-the-same-verification-phrase + */ + public function pending(string $userId): array { + return $this->mapper->findPendingForUser($userId, new DateTime()); + }//end pending() + + /** + * Deny one of the user's open requests. + * + * @param string $id The request + * @param string $userId The user + * + * @return void + * + * @throws NotFoundException When the request is not an open request of this user + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + public function deny(string $id, string $userId): void { + $request = $this->loadOpen(id: $id, userId: $userId); + $request->setStatus(DeviceApproval::STATUS_DENIED); + $request->setDecidedAt(new DateTime()); + $this->mapper->update($request); + $this->audit(request: $request, eventType: AuditEventTypes::DEVICE_APPROVAL_DENIED); + }//end deny() + + /** + * Approve one of the user's open requests with the unlock key sealed to + * its one-time key. The vault-key proof is checked before this runs + * (VaultKeyProofRequired on the route). + * + * @param string $id The request + * @param string $userId The user + * @param string $sealedUnlockKey The HPKE-sealed unlock key + * + * @return void + * + * @throws NotFoundException When the request is not an open request of this user + * @throws InvalidArgumentException When no sealed key is given + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-approval-seals-the-unlock-key-and-needs-proof-of-the-master-password + */ + public function approve(string $id, string $userId, string $sealedUnlockKey): void { + if (trim($sealedUnlockKey) === '') { + throw new InvalidArgumentException(message: 'sealedUnlockKey is required'); + } + + $request = $this->loadOpen(id: $id, userId: $userId); + $request->setStatus(DeviceApproval::STATUS_APPROVED); + $request->setSealedUnlockKey($sealedUnlockKey); + $request->setDecidedAt(new DateTime()); + $this->mapper->update($request); + $this->audit(request: $request, eventType: AuditEventTypes::DEVICE_APPROVAL_APPROVED); + }//end approve() + + /** + * The requesting device's poll. Needs the request secret. Returns the + * sealed key once, then clears it and marks the request consumed. + * + * @param string $id The request + * @param string $userId The user + * @param string $requestSecret The secret returned at creation + * + * @return array{status:string,sealedUnlockKey?:string} + * + * @throws NotFoundException When the request is unknown, foreign, or the secret is wrong + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-pickup-is-one-time-and-unlocks-one-session + */ + public function pickup(string $id, string $userId, string $requestSecret): array { + $request = $this->loadOwn(id: $id, userId: $userId); + if ($requestSecret === '' || hash_equals($request->getRequestSecretHash(), hash('sha256', $requestSecret)) === false) { + throw new NotFoundException(message: 'Request not found'); + } + + $status = $request->getStatus(); + $open = [DeviceApproval::STATUS_PENDING, DeviceApproval::STATUS_APPROVED]; + if ($this->isLapsed(request: $request) === true && in_array($status, $open, true) === true) { + return ['status' => DeviceApproval::STATUS_EXPIRED]; + } + + $sealed = $request->getSealedUnlockKey(); + if ($status !== DeviceApproval::STATUS_APPROVED || $sealed === null || $sealed === '') { + return ['status' => $status]; + } + + $request->setSealedUnlockKey(null); + $request->setStatus(DeviceApproval::STATUS_CONSUMED); + $this->mapper->update($request); + $this->audit(request: $request, eventType: AuditEventTypes::DEVICE_APPROVAL_PICKED_UP); + + return ['status' => DeviceApproval::STATUS_APPROVED, 'sealedUnlockKey' => $sealed]; + }//end pickup() + + /** + * Mark lapsed requests expired and drop any sealed key never picked up. + * + * @param DateTime $now The current time + * + * @return int The number expired + * + * @spec openspec/specs/new-device-approval/spec.md#requirement-deny-expiry-audit-and-administrator-switch + */ + public function expireLapsed(DateTime $now): int { + $count = 0; + foreach ($this->mapper->findLapsed($now) as $request) { + $request->setStatus(DeviceApproval::STATUS_EXPIRED); + $request->setSealedUnlockKey(null); + $this->mapper->update($request); + $this->audit(request: $request, eventType: AuditEventTypes::DEVICE_APPROVAL_EXPIRED); + ++$count; + } + + return $count; + }//end expireLapsed() + + /** + * A request of this user, or the unknown-id answer. + * + * @param string $id The request + * @param string $userId The user + * + * @return DeviceApproval + * + * @throws NotFoundException + */ + private function loadOwn(string $id, string $userId): DeviceApproval { + try { + $request = $this->mapper->findById($id); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Request not found'); + } + + if ($request->getUserId() !== $userId) { + throw new NotFoundException(message: 'Request not found'); + } + + return $request; + }//end loadOwn() + + /** + * An open (pending, not expired) request of this user, or the unknown-id answer. + * + * @param string $id The request + * @param string $userId The user + * + * @return DeviceApproval + * + * @throws NotFoundException + */ + private function loadOpen(string $id, string $userId): DeviceApproval { + $request = $this->loadOwn(id: $id, userId: $userId); + if ($request->getStatus() !== DeviceApproval::STATUS_PENDING || $this->isLapsed(request: $request) === true) { + throw new NotFoundException(message: 'Request not found'); + } + + return $request; + }//end loadOpen() + + /** + * Whether a request is past its expiry. + * + * @param DeviceApproval $request The request + * + * @return bool + */ + private function isLapsed(DeviceApproval $request): bool { + $expires = $request->getExpiresAt(); + return $expires === null || $expires <= new DateTime(); + }//end isLapsed() + + /** + * Record a transition with identifiers only. + * + * @param DeviceApproval $request The request + * @param string $eventType The event type + * @param array $metadata Whitelisted metadata + * + * @return void + */ + private function audit(DeviceApproval $request, string $eventType, array $metadata = []): void { + $this->eventDispatcher->dispatchTyped( + $this->auditEvents->forUser( + actorId: $request->getUserId(), + eventType: $eventType, + objectType: 'device_approval', + objectId: $request->getId(), + metadata: $metadata, + ) + ); + }//end audit() +}//end class diff --git a/lib/Service/DirectShareRegistrar.php b/lib/Service/DirectShareRegistrar.php index 453326e02..ca541ea08 100644 --- a/lib/Service/DirectShareRegistrar.php +++ b/lib/Service/DirectShareRegistrar.php @@ -31,6 +31,10 @@ namespace OCA\Keepiq\Service; use DateTime; +use DateTimeZone; +use InvalidArgumentException; +use OCA\Keepiq\Db\GroupShareMapper; +use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretMapper; use OCA\Keepiq\Db\ShareTarget; use OCA\Keepiq\Db\ShareTargetMapper; @@ -39,6 +43,10 @@ /** * Registers batches of pre-encrypted direct shares. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The registrar validates each + * row (owner, restriction, idempotency) and writes the copy, the share row, + * the restriction and the audit entry; each collaborator is one of those. */ class DirectShareRegistrar { @@ -57,6 +65,8 @@ class DirectShareRegistrar { * @param RecipientSecretCopyFactory $copyFactory The recipient-copy factory * @param NotificationService $notificationService The notification dispatcher * @param ShareAuditTrail|null $auditTrail The share audit trail + * @param GroupShareMapper|null $groupShareMapper The group-share mapper (rows linked to a group share) + * @param ShareRestrictionResolver|null $restrictions Materialises use-only and end dates onto copies * * @return void * @@ -68,6 +78,8 @@ public function __construct( private RecipientSecretCopyFactory $copyFactory, private NotificationService $notificationService, ?ShareAuditTrail $auditTrail = null, + private ?GroupShareMapper $groupShareMapper = null, + private ?ShareRestrictionResolver $restrictions = null, ) { $this->auditTrail = ($auditTrail ?? new ShareAuditTrail()); }//end __construct() @@ -79,11 +91,14 @@ public function __construct( * team-folder fan-out registration. * * @param string $userId The sharing owner - * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey, encryptedLogin?, encryptedAdditionalFields?} + * @param array> $shares Rows {sourceSecretId, targetUserId, encryptedKey, + * encryptedLogin?, encryptedAdditionalFields?, groupShareId?, useOnly?, expiresAt?} * * @return array * * @spec openspec/specs/bulk-actions/spec.md#requirement-the-four-bulk-operations + * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 */ public function registerDirectShares(string $userId, array $shares): array { $report = []; @@ -166,6 +181,14 @@ private function registerDirectShare(string $userId, array $row): array { ]; } + if ($this->groupShareMatches(sourceSecretId: $sourceSecretId, row: $row) === false) { + return [ + 'sourceSecretId' => $sourceSecretId, + 'targetUserId' => $targetUserId, + 'status' => 'invalid', + ]; + } + return $this->createDirectShare( userId: $userId, sourceSecretId: $sourceSecretId, @@ -194,19 +217,18 @@ private function createDirectShare( string $encryptedKey, array $row, ): array { - // Per-item owner guard — a foreign secret is skipped, never a - // whole-batch failure and never an oracle. - try { - $source = $this->secretMapper->findById($sourceSecretId); - } catch (DoesNotExistException) { - $source = null; - } - - if ($source === null || $source->getOwnerType() !== 'user' || $source->getOwnerId() !== $userId) { + // Per-item owner guard: a foreign secret is skipped, never a + // whole-batch failure and never an oracle. A use-only or expiring + // copy is never a share source (D4), and a malformed restriction is + // reported, not guessed. + $source = $this->loadOwnedSource(sourceSecretId: $sourceSecretId, userId: $userId); + $restriction = $this->parseRestriction(row: $row); + $refusal = $this->refusalFor(source: $source, restriction: $restriction); + if ($refusal !== null || $source === null || $restriction === null) { return [ 'sourceSecretId' => $sourceSecretId, 'targetUserId' => $targetUserId, - 'status' => 'not_owned', + 'status' => ($refusal ?? 'invalid'), ]; } @@ -245,9 +267,17 @@ private function createDirectShare( $entity->setSourceSecretId($sourceSecretId); $entity->setTargetUserId($targetUserId); $entity->setSecretId($copy->getId()); + $entity->setGroupShareId($this->optionalString(value: ($row['groupShareId'] ?? null))); $entity->setCreatedBy($userId); $entity->setCreatedAt(new DateTime()); - $this->mapper->insert($entity); + if ($entity->getGroupShareId() === null) { + // A group-linked row takes its restriction from the group share. + $entity->setUseOnly($restriction->useOnly); + $entity->setExpiresAt($restriction->expiresAt); + } + + $persisted = $this->mapper->insert($entity); + $this->restrictions?->resolveTarget(target: $persisted); $this->auditTrail->recordBulkShareGranted( userId: $userId, @@ -264,6 +294,105 @@ private function createDirectShare( ]; }//end createDirectShare() + /** + * The caller's own user-owned source secret, or null. + * + * @param string $sourceSecretId The source secret + * @param string $userId The sharing owner + * + * @return Secret|null + */ + private function loadOwnedSource(string $sourceSecretId, string $userId): ?Secret { + try { + $source = $this->secretMapper->findById($sourceSecretId); + } catch (DoesNotExistException) { + return null; + } + + if ($source->getOwnerType() !== 'user' || $source->getOwnerId() !== $userId) { + return null; + } + + return $source; + }//end loadOwnedSource() + + /** + * A row's use-only flag and end date, or null when the date is refused. + * + * @param array $row The row + * + * @return ShareRestriction|null + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 + */ + private function parseRestriction(array $row): ?ShareRestriction { + try { + return (new ShareRestrictionRules())->fromRequest( + useOnly: ($row['useOnly'] ?? false), + expiresAt: ($row['expiresAt'] ?? null), + now: new DateTime('now', new DateTimeZone('UTC')) + ); + } catch (InvalidArgumentException) { + return null; + } + }//end parseRestriction() + + /** + * The status a row is refused with, or null when it may proceed. + * + * @param Secret|null $source The caller's own source, or null + * @param ShareRestriction|null $restriction The parsed restriction, or null + * + * @return string|null + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce + */ + private function refusalFor(?Secret $source, ?ShareRestriction $restriction): ?string { + if ($source === null) { + return 'not_owned'; + } + + if ($source->isRestrictedCopy() === true) { + return 'restricted'; + } + + if ($restriction === null) { + return 'invalid'; + } + + return null; + }//end refusalFor() + + /** + * Whether a row's optional groupShareId names a group share of the SAME + * source secret. A row without one matches; an unknown group share, one + * of another secret, or no mapper to check with does not (fail closed). + * The owner guard in createDirectShare() then covers the secret itself. + * + * @param string $sourceSecretId The row's source secret + * @param array $row The row + * + * @return bool + */ + private function groupShareMatches(string $sourceSecretId, array $row): bool { + $groupShareId = $this->optionalString(value: ($row['groupShareId'] ?? null)); + if ($groupShareId === null) { + return true; + } + + if ($this->groupShareMapper === null) { + return false; + } + + try { + $groupShare = $this->groupShareMapper->findById($groupShareId); + } catch (DoesNotExistException) { + return false; + } + + return $groupShare->getSecretId() === $sourceSecretId; + }//end groupShareMatches() + /** * Normalise an optional blob value to a non-empty string or null. * diff --git a/lib/Service/EmergencyEnvelopeInvalidationService.php b/lib/Service/EmergencyEnvelopeInvalidationService.php index 39af26959..22b42c026 100644 --- a/lib/Service/EmergencyEnvelopeInvalidationService.php +++ b/lib/Service/EmergencyEnvelopeInvalidationService.php @@ -12,8 +12,9 @@ * 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 + * runs at completion as a residual SWEEP, catching every contact the loop did + * not carry: grantee unreachable, not ticked or declined by the owner, or + * break-glass in flight (keepiq#800). Revocation of the grantor's * suite still DELETES the envelopes outright, because it produces no new key to * migrate to. * diff --git a/lib/Service/EncryptionSuiteProvisioningService.php b/lib/Service/EncryptionSuiteProvisioningService.php index 8752c6e17..7a4aee78f 100644 --- a/lib/Service/EncryptionSuiteProvisioningService.php +++ b/lib/Service/EncryptionSuiteProvisioningService.php @@ -34,7 +34,6 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Db\EncryptionSuite; use OCA\Keepiq\Db\EncryptionSuiteMapper; -use OCA\Keepiq\Exception\ConflictException; use OCA\Keepiq\Support\SuppressesDiagnostics; use OCP\IAppConfig; use OCP\IUserManager; @@ -48,6 +47,13 @@ class EncryptionSuiteProvisioningService { use SuppressesDiagnostics; + /** + * Makes the single-active-suite check and the insert atomic per owner. + * + * @var SuiteSetupGuard + */ + private SuiteSetupGuard $setupGuard; + /** * Constructor for EncryptionSuiteProvisioningService. * @@ -56,6 +62,7 @@ class EncryptionSuiteProvisioningService { * @param IAppConfig $appConfig The app config interface * @param IUserManager $userManager The user manager * @param LoggerInterface $logger The logger interface + * @param SuiteSetupGuard|null $setupGuard The per-owner single-active-suite guard * * @return void * @@ -67,7 +74,9 @@ public function __construct( private IAppConfig $appConfig, private IUserManager $userManager, private LoggerInterface $logger, + ?SuiteSetupGuard $setupGuard = null, ) { + $this->setupGuard = ($setupGuard ?? new SuiteSetupGuard(mapper: $mapper)); }//end __construct() /** @@ -80,9 +89,11 @@ public function __construct( * * @return EncryptionSuite * - * @throws ConflictException When the owner already has an active suite. Use - * createSuccessorSuite() for a compromise recovery, - * which is the one flow allowed a second active suite. + * @throws RuntimeException A ConflictException (from SuiteSetupGuard) when the + * owner already has an active suite or a setup for it + * is in progress. Use createSuccessorSuite() for a + * compromise recovery, the one flow allowed a second + * active suite. * * @spec openspec/specs/encryption-suites/spec.md#requirement-a-plain-create-refuses-to-mint-a-second-active-suite */ @@ -110,24 +121,17 @@ public function createSuite( publicKeyPem: $publicKeyPem ); - // Reported as #289 — the endpoint checked auth, parameters and the migration - // write-lock, but never whether a suite already existed, so any session could - // mint a second active suite. Resolution picks the NEWEST active suite, so new - // secrets were sealed to a key the owner was not unlocking with: they decrypt - // for nobody, and nothing reports it at the time. - $active = $this->mapper->countActiveByOwner(ownerType: $ownerType, ownerId: $ownerId); - if ($active > 0) { - throw new ConflictException( - message: 'An active EncryptionSuite already exists for this owner. ' - . 'Change the master password or start a compromise recovery instead of creating a second suite.' - ); - } - - return $this->persistSuite( + // The count and the insert run under one lock per owner, so two setups + // submitted at the same moment cannot both count zero (keepiq#751). + return $this->setupGuard->createExclusive( ownerType: $ownerType, ownerId: $ownerId, - certificate: $certificate, - encryptedPrivateKey: $encryptedPrivateKey + persist: fn (): EncryptionSuite => $this->persistSuite( + ownerType: $ownerType, + ownerId: $ownerId, + certificate: $certificate, + encryptedPrivateKey: $encryptedPrivateKey + ) ); }//end createSuite() diff --git a/lib/Service/EncryptionSuiteService.php b/lib/Service/EncryptionSuiteService.php index b15bf7156..42087ca72 100644 --- a/lib/Service/EncryptionSuiteService.php +++ b/lib/Service/EncryptionSuiteService.php @@ -28,6 +28,7 @@ use OCA\Keepiq\Event\Audit\AuditEventFactory; use OCA\Keepiq\Event\Audit\AuditEventTypes; use OCA\Keepiq\Event\EncryptionSuiteRevokedEvent; +use OCA\Keepiq\Exception\ReinstateRefusedException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; @@ -38,6 +39,12 @@ * CA certificate is EncryptionSuiteProvisioningService's job; the two * creation entry points stay here as thin forwards so callers keep one * suite-shaped service. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) 12 on development, and the + * reinstate guard (keepiq#865: a suite revoked as compromised cannot come + * back) is the 13th. It belongs in reinstateSuite(), the one path every + * reinstate takes; checking it in a caller would leave the service open. + * The guard's own audit-trail reads already live in SuiteReinstateGuard. */ class EncryptionSuiteService { /** @@ -48,6 +55,7 @@ class EncryptionSuiteService { * @param LoggerInterface $logger The logger interface * @param IEventDispatcher|null $eventDispatcher The event dispatcher * @param AuditEventFactory $auditEvents The audit-event factory + * @param SuiteReinstateGuard|null $reinstateGuard Decides whether a revoked suite may come back * * @return void */ @@ -57,6 +65,7 @@ public function __construct( private LoggerInterface $logger, private ?IEventDispatcher $eventDispatcher = null, private AuditEventFactory $auditEvents = new AuditEventFactory(), + private ?SuiteReinstateGuard $reinstateGuard = null, ) { }//end __construct() @@ -166,7 +175,7 @@ public function provisionForApplication(string $applicationId, string $csrPem): * 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 + * @spec openspec/specs/encryption-suites/spec.md#requirement-administrator-force-revocation */ public function revokeSuite( string $id, @@ -193,9 +202,10 @@ public function revokeSuite( // Implement-user-sharing §10.3 — dispatch a revocation event so // EncryptionSuiteRevokedListener can cascade share-target // 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. + // compromise flag is carried on the event for its listeners; the + // compromise cascade itself runs from CompromiseContainmentService, + // called by the administrator's force-revoke (keepiq#863). It stays + // false on the owner path, which never passes $markCompromised. if ($this->eventDispatcher !== null) { $this->eventDispatcher->dispatchTyped( new EncryptionSuiteRevokedEvent( @@ -225,17 +235,69 @@ public function revokeSuite( return $suite; }//end revokeSuite() + /** + * Record a refused revocation in the audit trail (keepiq#870). + * + * Successful revokes were audited and refusals only reached the HTTP + * response, so an attack on the containment path was invisible to the + * SIEM. The reason code is a fixed machine token chosen by the caller + * (`migration_in_progress`, `invalid_argument`, `forbidden`, + * `empty_reason`), never an exception message, so nothing a request + * supplied ends up in the trail. + * + * @param string $suiteId The suite the revoke targeted + * @param string $actorId Who asked for the revoke + * @param string $reasonCode Why it was refused, as a fixed token + * @param bool $markCompromised Whether a compromise revoke was asked for + * + * @return void + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $markCompromised is recorded + * data about the refused request, not a mode switch for this method. + * + * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-administrator-force-revocation + */ + public function recordRevokeRefused( + string $suiteId, + string $actorId, + string $reasonCode, + bool $markCompromised = false, + ): void { + $this->eventDispatcher?->dispatchTyped( + $this->auditEvents->forUser( + actorId: $actorId, + eventType: AuditEventTypes::SUITE_REVOKE_REFUSED, + objectType: 'suite', + objectId: $suiteId, + metadata: [ + 'reasonCode' => $reasonCode, + 'markCompromised' => $markCompromised, + ], + ) + ); + }//end recordRevokeRefused() + /** * Reinstate a revoked EncryptionSuite. Re-signs the public key with the active intermediate. * + * Refused for a suite revoked as compromised: reinstating it re-opens every + * secret under a key the administrator declared to be in an attacker's + * hands. ADR-005 keeps that decision off the suite row, so it is read from + * the suite's last SUITE_REVOKED audit entry; when no such entry can be + * found the reinstate is refused too, because the revocation cannot be + * shown to be a harmless one. Also refused when the owner already has + * another active suite, which would leave them with two (keepiq#865). + * * @param string $id The suite ID * @param string $reinstatedBy The user who reinstated the suite * * @return EncryptionSuite * * @throws DoesNotExistException + * @throws ReinstateRefusedException From SuiteReinstateGuard::assertReinstatable() * * @spec openspec/changes/retrofit-2026-05-25-doriath-coverage/tasks.md#task-2 + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated */ public function reinstateSuite(string $id, string $reinstatedBy): EncryptionSuite { $suite = $this->mapper->findById($id); @@ -246,6 +308,10 @@ public function reinstateSuite(string $id, string $reinstatedBy): EncryptionSuit ); } + // Without an injected guard there is no audit trail to read, so the + // default guard refuses every reinstate (fail closed). + ($this->reinstateGuard ?? new SuiteReinstateGuard(mapper: $this->mapper))->assertReinstatable(suite: $suite); + // Re-sign the existing public key with the active intermediate. $newCertificate = $this->provisioning->reissueCertificateForSuite(suite: $suite); @@ -295,10 +361,15 @@ public function markCompromised(string $id, string $compromisedBy): EncryptionSu $this->logger->warning("Keepiq: EncryptionSuite {$id} marked compromised by {$compromisedBy}"); + // COMPLETED, not STARTED: markCompromised runs only when a recovery + // finishes (MigrationService::completeMigration). It used to record + // recovery_started here, so the trail showed a start at every + // completion and never a completion (keepiq#870). The start is now + // recorded by MigrationService::initiateCompromiseRecovery. $this->eventDispatcher?->dispatchTyped( $this->auditEvents->forUser( actorId: $compromisedBy, - eventType: AuditEventTypes::SUITE_RECOVERY_STARTED, + eventType: AuditEventTypes::SUITE_RECOVERY_COMPLETED, objectType: 'suite', objectId: $id, ) diff --git a/lib/Service/EphemeralSendService.php b/lib/Service/EphemeralSendService.php index 4e48f6f13..a091f1173 100644 --- a/lib/Service/EphemeralSendService.php +++ b/lib/Service/EphemeralSendService.php @@ -224,6 +224,8 @@ private function assertPasswordFieldsConsistent( * @param string $ownerId The owner * * @return EphemeralSend[] + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-manage-and-revoke-sends */ public function listForOwner(string $ownerId): array { return $this->mapper->findByOwner(ownerId: $ownerId); @@ -239,6 +241,8 @@ public function listForOwner(string $ownerId): array { * @return void * * @throws DoesNotExistException When missing or foreign + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-manage-and-revoke-sends */ public function revoke(string $id, string $ownerId): void { $send = $this->mapper->findById($id); @@ -257,6 +261,8 @@ public function revoke(string $id, string $ownerId): void { * @return array * * @throws DoesNotExistException When missing, expired, or burned + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-anonymous-recipient-access-with-no-account */ public function peek(string $token): array { $send = $this->loadLive(token: $token); @@ -303,6 +309,8 @@ public function access(string $token): array { * @return array{burned:bool, remainingViews:int} * * @throws DoesNotExistException When missing, expired, or burned + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-burn-after-read-and-optional-expiry */ public function confirmView(string $token): array { $send = $this->loadLive(token: $token); @@ -367,6 +375,8 @@ public function reportFailure(string $token): array { * Delete TTL-elapsed and fully-burned sends (cron). * * @return int Rows purged + * + * @spec openspec/specs/ephemeral-send/spec.md#requirement-burn-after-read-and-optional-expiry */ public function purge(): int { $count = 0; diff --git a/lib/Service/ExpiredGrantRemover.php b/lib/Service/ExpiredGrantRemover.php new file mode 100644 index 000000000..ff2f19c52 --- /dev/null +++ b/lib/Service/ExpiredGrantRemover.php @@ -0,0 +1,206 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\GroupShareMapper; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTarget; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Db\TeamFolderMapper; +use OCA\Keepiq\Db\TeamFolderMemberMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Removes grants whose end date has passed through the paths that already + * own each removal (sharing-use-only-and-expiring-shares D5): a direct + * share through ShareRevocationService, a group share through + * GroupShareService, a team-folder membership through TeamFolderService. + * Each removal acts as the owner of what it removes, because that is who + * the existing guards accept. Fail-soft per grant. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One collaborator per grant + * kind and per removal path; the point of the class is to reuse the + * existing paths rather than delete rows itself. + */ +class ExpiredGrantRemover { + + /** + * Constructor for ExpiredGrantRemover. + * + * @param SecretMapper $secretMapper The secret mapper (sources) + * @param ShareTargetMapper $targetMapper The share-target mapper + * @param GroupShareMapper $groupShareMapper The group-share mapper + * @param TeamFolderMemberMapper $memberMapper The membership mapper + * @param TeamFolderMapper $teamFolderMapper The team-folder mapper (owners) + * @param ShareRestrictionResolver $restrictions The restriction resolver + * @param ShareRevocationService $revocation Revokes a direct share + * @param GroupShareService $groupShares Revokes a group share + * @param TeamFolderService $teamFolders Removes a membership + * @param LoggerInterface $logger The logger + * + * @return void + * + * @spec exclude Constructor wiring only. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor DI list, see the class note. + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $targetMapper, + private GroupShareMapper $groupShareMapper, + private TeamFolderMemberMapper $memberMapper, + private TeamFolderMapper $teamFolderMapper, + private ShareRestrictionResolver $restrictions, + private ShareRevocationService $revocation, + private GroupShareService $groupShares, + private TeamFolderService $teamFolders, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Remove every grant whose end date is at or before `now`. + * + * @param DateTime $now The cut-off + * + * @return int The number of grants removed + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + public function removeExpired(DateTime $now): int { + $removed = 0; + foreach ($this->targetMapper->findEndingBetween(from: null, to: $now) as $target) { + $removed += $this->attempt(removal: fn (): bool => $this->removeDirectShare(target: $target, now: $now)); + } + + foreach ($this->groupShareMapper->findEndingBetween(from: null, to: $now) as $groupShare) { + $removed += $this->attempt( + removal: fn (): bool => $this->asSourceOwner( + sourceSecretId: $groupShare->getSecretId(), + action: fn (string $ownerId) => $this->groupShares->revokeGroupShare( + groupShareId: $groupShare->getId(), + userId: $ownerId + ) + ) + ); + } + + foreach ($this->memberMapper->findEndingBetween(from: null, to: $now) as $membership) { + $removed += $this->attempt( + removal: function () use ($membership): bool { + $teamFolder = $this->teamFolderMapper->findById(id: $membership->getTeamFolderId()); + $this->teamFolders->removeMember( + teamFolderId: (string)$teamFolder->getId(), + membershipId: $membership->getId(), + userId: $teamFolder->getOwnerId() + ); + return true; + } + ); + } + + return $removed; + }//end removeExpired() + + /** + * Revoke one share row (and with it the recipient's copy) as the owner + * of its source. + * + * @param ShareTarget $target The share row + * + * @return bool Whether it was revoked + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + public function revokeTarget(ShareTarget $target): bool { + return $this->asSourceOwner( + sourceSecretId: $target->getSourceSecretId(), + action: fn (string $ownerId) => $this->revocation->revokeShare(shareId: (string)$target->getId(), userId: $ownerId) + ); + }//end revokeTarget() + + /** + * Revoke an expired direct share, unless another grant still gives the + * recipient access past `now` (then the copy stays, resolved to it). + * + * @param ShareTarget $target The expired direct share row + * @param DateTime $now The cut-off + * + * @return bool Whether it was revoked + */ + private function removeDirectShare(ShareTarget $target, DateTime $now): bool { + if ($target->getGroupShareId() !== null || $target->getTeamFolderId() !== null) { + return false; + } + + $effective = $this->restrictions->effectiveFor(target: $target); + if ($effective->expiresAt === null || $effective->expiresAt > $now) { + $this->restrictions->resolveTarget(target: $target); + return false; + } + + return $this->revokeTarget(target: $target); + }//end removeDirectShare() + + /** + * Run a removal as the owner of a source secret. + * + * @param string $sourceSecretId The source secret + * @param callable $action Receives the owner id + * + * @return bool False when the source is gone + */ + private function asSourceOwner(string $sourceSecretId, callable $action): bool { + try { + $ownerId = $this->secretMapper->findById($sourceSecretId)->getOwnerId(); + } catch (DoesNotExistException) { + return false; + } + + $action($ownerId); + return true; + }//end asSourceOwner() + + /** + * Run one removal fail-soft: one broken grant must not strand the rest. + * + * @param callable $removal Returns whether it removed something + * + * @return int 1 when removed, else 0 + */ + private function attempt(callable $removal): int { + try { + if ($removal() === true) { + return 1; + } + + return 0; + } catch (Throwable $exception) { + $this->logger->error( + 'Keepiq: could not remove an expired grant: ' . $exception->getMessage(), + ['exception' => $exception, 'app' => 'keepiq'] + ); + return 0; + } + }//end attempt() +}//end class diff --git a/lib/Service/FederatedCertificateService.php b/lib/Service/FederatedCertificateService.php new file mode 100644 index 000000000..ce0d2861c --- /dev/null +++ b/lib/Service/FederatedCertificateService.php @@ -0,0 +1,291 @@ + + * @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\Service; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCP\Federation\ICloudIdManager; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\OCM\IOCMDiscoveryService; +use RuntimeException; +use Throwable; + +/** + * Federated certificate lookup, answering and asking. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ +class FederatedCertificateService { + /** + * The OCM capability Keepiq advertises and answers under. + * + * @var string + */ + public const OCM_CAPABILITY = 'keepiq'; + + /** + * The user preference that opts a user in to receiving from partners. + * + * @var string + */ + public const RECEIVE_PREFERENCE = 'federation_receive'; + + /** + * Constructor for FederatedCertificateService. + * + * @param FederationPartnerService $partners The partner allowlist + * @param FederationRootService $root The local root and chain + * @param ShareService $shareService Active suite certificates + * @param ICloudIdManager $cloudIdManager Cloud id parsing + * @param IUserManager $userManager Local users + * @param IConfig $config User preferences + * @param IOCMDiscoveryService $ocmDiscovery Signed OCM requests to partners + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederationPartnerService $partners, + private FederationRootService $root, + private ShareService $shareService, + private ICloudIdManager $cloudIdManager, + private IUserManager $userManager, + private IConfig $config, + private IOCMDiscoveryService $ocmDiscovery, + ) { + }//end __construct() + + /** + * Answer a partner's certificate lookup, or null for the unknown answer. + * + * @param string|null $signer The verified signer (OCMEndpointRequestEvent::getRemote()), null when unsigned + * @param array $payload The request payload, `{cloudId}` + * + * @return array{cloudId:string,certificate:string,chain:array}|null + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function answer(?string $signer, array $payload): ?array { + if ($this->partners->inboundPartnerForSigner(signer: $signer) === null) { + return null; + } + + $userId = $this->localUserOf(cloudId: $payload['cloudId'] ?? null); + if ($userId === null) { + return null; + } + + $optedIn = $this->config->getUserValue($userId, Application::APP_ID, self::RECEIVE_PREFERENCE, '0'); + if ($optedIn !== '1') { + return null; + } + + $certificate = ($this->shareService->recipientCertificates(targetUserIds: [$userId])[$userId] ?? null); + $chain = $this->root->localChain(); + if ($certificate === null || $chain === []) { + return null; + } + + return [ + 'cloudId' => (string)$payload['cloudId'], + 'certificate' => $certificate, + 'chain' => $chain, + ]; + }//end answer() + + /** + * Whether users here can share with another organisation at all: this + * Nextcloud supports federation and at least one partner allows + * outbound shares. With none, the share dialog offers nothing. + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-no-partner-no-federation + */ + public function outboundAvailable(): bool { + if ($this->root->isSupported() === false) { + return false; + } + + foreach ($this->partners->all() as $partner) { + if ($partner->getAllowOutbound() === true) { + return true; + } + } + + return false; + }//end outboundAvailable() + + /** + * Ask an outbound partner for a recipient's certificate. + * + * @param string $cloudId The recipient's cloud id, `bob@cloud.partner.example` + * @param string $userId The owner asking; their cloud id goes along as `sender` + * + * @return array{cloudId:string,certificate:string,chain:array,partnerRootFingerprint:string} + * + * @throws InvalidArgumentException `not_a_partner` or `unknown_recipient` + * @throws RuntimeException `federation_unavailable` or `partner_unreachable` + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function lookup(string $cloudId, string $userId): array { + // The OCM call requestRemoteOcmEndpoint() arrived in Nextcloud 33 with the + // OCM endpoint event that isSupported() checks for. + if ($this->root->isSupported() === false) { + throw new RuntimeException('federation_unavailable'); + } + + try { + $resolved = $this->cloudIdManager->resolveCloudId($cloudId); + } catch (InvalidArgumentException) { + throw new InvalidArgumentException('unknown_recipient'); + } + + $partner = $this->partners->outboundPartnerForRemote(remote: $resolved->getRemote()); + if ($partner === null) { + throw new InvalidArgumentException('not_a_partner'); + } + + try { + $response = $this->ocmDiscovery->requestRemoteOcmEndpoint( + self::OCM_CAPABILITY, + $partner->getBaseUrl(), + self::OCM_CAPABILITY . '/recipient-certificate', + // Nextcloud 35 verifies an RFC 9421 signature on /ocm/ + // only when the body names an OCM address (`owner`, `sender` or + // `sharedBy`) to take the signer's origin from. + ['cloudId' => $resolved->getId(), 'sender' => $this->cloudIdManager->getCloudId($userId, null)->getId()], + 'post', + ); + $status = $response->getStatusCode(); + $body = json_decode((string)$response->getBody(), true); + } catch (Throwable $exception) { + $status = $this->statusOf(exception: $exception); + $body = null; + } + + $answer = $this->parseAnswer(status: $status, body: $body); + + return [ + 'cloudId' => $resolved->getId(), + 'certificate' => $answer['certificate'], + 'chain' => $answer['chain'], + 'partnerRootFingerprint' => $partner->getRootFingerprint(), + ]; + }//end lookup() + + /** + * Check a partner's lookup answer. + * + * @param int $status The HTTP status + * @param mixed $body The decoded body + * + * @return array{certificate:string,chain:array} + * + * @throws InvalidArgumentException `unknown_recipient` on the partner's 404 + * @throws RuntimeException `partner_unreachable` on anything else that is not a valid answer + */ + private function parseAnswer(int $status, mixed $body): array { + if ($status === 404) { + throw new InvalidArgumentException('unknown_recipient'); + } + + if ($status !== 200 || is_array($body) === false + || is_string($body['certificate'] ?? null) === false + || is_array($body['chain'] ?? null) === false + ) { + throw new RuntimeException('partner_unreachable'); + } + + return [ + 'certificate' => $body['certificate'], + 'chain' => array_values(array_filter($body['chain'], 'is_string')), + ]; + }//end parseAnswer() + + /** + * The local user a cloud id names, or null when it names no user of this + * instance. + * + * @param mixed $cloudId The cloud id from the payload + * + * @return string|null + */ + private function localUserOf(mixed $cloudId): ?string { + if (is_string($cloudId) === false || $this->cloudIdManager->isValidCloudId($cloudId) === false) { + return null; + } + + try { + $resolved = $this->cloudIdManager->resolveCloudId($cloudId); + } catch (InvalidArgumentException) { + return null; + } + + $userId = $resolved->getUser(); + if ($this->userManager->userExists($userId) === false) { + return null; + } + + // Ours only when it names this user on this instance. Compared by host, + // because an http instance writes its own cloud id with the scheme. + $own = $this->cloudIdManager->getCloudId($userId, null); + $ownHost = $this->partners->hostOf(url: $own->getRemote()); + if ($own->getUser() !== $userId || $ownHost === null || $ownHost !== $this->partners->hostOf(url: $resolved->getRemote())) { + return null; + } + + return $userId; + }//end localUserOf() + + /** + * The HTTP status a failed partner request carried, or 0. + * + * @param Throwable $exception The failure + * + * @return int + */ + private function statusOf(Throwable $exception): int { + if (method_exists($exception, 'getResponse') === true) { + $response = $exception->getResponse(); + if (is_object($response) === true && method_exists($response, 'getStatusCode') === true) { + return (int)$response->getStatusCode(); + } + } + + return 0; + }//end statusOf() +}//end class diff --git a/lib/Service/FederatedCopyDeclineService.php b/lib/Service/FederatedCopyDeclineService.php new file mode 100644 index 000000000..ac16ba519 --- /dev/null +++ b/lib/Service/FederatedCopyDeclineService.php @@ -0,0 +1,327 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederatedInboundMapper; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\Federation\ICloudFederationFactory; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\Federation\ICloudIdManager; +use OCP\Security\ICrypto; +use Throwable; + +/** + * Declines the share behind a deleted read-only copy, and takes it back when + * the copy is restored. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Declining and taking back + * one share joins the inbound row, the stored shared secret, the owner's + * cloud id, the signed OCM notification, the copy's pull and the audit; + * both directions share the one notification path. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-restores-his-copy + */ +class FederatedCopyDeclineService { + /** + * The OCM notification that the recipient declined a share. + * + * @var string + */ + public const SHARE_DECLINED = 'SHARE_DECLINED'; + + /** + * The OCM notification that the recipient accepted a share, here: took + * a declined one back by restoring the copy. + * + * @var string + */ + public const SHARE_ACCEPTED = 'SHARE_ACCEPTED'; + + /** + * A restored copy follows the owner again. + * + * @var string + */ + public const RESTORE_RESUMED = 'resumed'; + + /** + * A restored copy whose share the owner revoked or replaced: it stays + * read-only and is no longer updated. + * + * @var string + */ + public const RESTORE_ENDED = 'ended'; + + /** + * A restored copy whose owner's instance could not be reached: the share + * stays declined. + * + * @var string + */ + public const RESTORE_UNREACHABLE = 'unreachable'; + + /** + * Constructor for FederatedCopyDeclineService. + * + * @param FederatedInboundMapper $inboundMapper Inbound share rows + * @param ICrypto $crypto Opens the stored shared secret + * @param ICloudFederationProviderManager $providerManager OCM delivery + * @param ICloudFederationFactory $factory OCM notification objects + * @param ICloudIdManager $cloudIdManager The owner's instance + * @param FederatedShareAuditTrail $audit Identifier-only audit + * @param FederatedCopyService|null $copies Pulls the current value into a restored copy + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedInboundMapper $inboundMapper, + private ICrypto $crypto, + private ICloudFederationProviderManager $providerManager, + private ICloudFederationFactory $factory, + private ICloudIdManager $cloudIdManager, + private FederatedShareAuditTrail $audit, + private ?FederatedCopyService $copies = null, + ) { + }//end __construct() + + /** + * The user moved a secret to the trash or purged it. When it is their + * read-only copy of an accepted federated share, decline that share and + * tell the owner's instance. Any other secret is left alone. + * + * @param Secret $secret The secret being deleted + * @param string $userId The user deleting it + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ + public function copyDeleted(Secret $secret, string $userId): void { + if ($secret->getReadOnly() !== true) { + return; + } + + foreach ($this->inboundMapper->findBySecretId(secretId: $secret->getId()) as $row) { + if ($row->getRecipientUid() !== $userId || $row->getStatus() !== FederatedInbound::STATUS_ACCEPTED) { + continue; + } + + // The link to the copy stays while the copy is in the trash, so + // a restore can take the share back; purging drops it. + $row->setStatus(FederatedInbound::STATUS_DECLINED); + $row->setUpdatedAt(new DateTime()); + $this->inboundMapper->update(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_SHARE_DECLINED, row: $row, actorId: $userId); + + $this->tellOwner(row: $row); + } + }//end copyDeleted() + + /** + * The user purged a secret for good. Decline the share behind it when + * that did not happen at the trash, then drop the link to the copy. + * + * @param Secret $secret The secret being purged + * @param string $userId The user purging it + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ + public function copyPurged(Secret $secret, string $userId): void { + $this->copyDeleted(secret: $secret, userId: $userId); + if ($secret->getReadOnly() !== true) { + return; + } + + foreach ($this->inboundMapper->findBySecretId(secretId: $secret->getId()) as $row) { + if ($row->getRecipientUid() !== $userId) { + continue; + } + + $row->setSecretId(null); + $row->setUpdatedAt(new DateTime()); + $this->inboundMapper->update(entity: $row); + } + }//end copyPurged() + + /** + * The user restored a secret from the trash. When it is their read-only + * copy of a share they declined by trashing it, take the share back: + * tell the owner's instance, accept the inbound share again and pull the + * current value. Returns what became of the share, or null for any other + * secret. + * + * @param Secret $secret The restored secret + * @param string $userId The user restoring it + * + * @return string|null One of the RESTORE_ constants, or null + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-restores-his-copy + * @spec openspec/specs/federated-sharing/spec.md#scenario-the-owner-revoked-the-share-meanwhile + */ + public function copyRestored(Secret $secret, string $userId): ?string { + if ($secret->getReadOnly() !== true || $secret->getFederatedSource() === null) { + return null; + } + + foreach ($this->inboundMapper->findBySecretId(secretId: $secret->getId()) as $row) { + if ($row->getRecipientUid() !== $userId) { + continue; + } + + if ($row->getStatus() === FederatedInbound::STATUS_DECLINED) { + return $this->takeBack(row: $row, userId: $userId); + } + + if ($row->getStatus() === FederatedInbound::STATUS_ACCEPTED) { + return self::RESTORE_RESUMED; + } + } + + // Revoked, or a copy whose link a revocation or an older decline + // dropped: nothing to take back. + return self::RESTORE_ENDED; + }//end copyRestored() + + /** + * Ask the owner's instance to take a declined share back, and follow + * its answer. + * + * @param FederatedInbound $row The declined share + * @param string $userId The recipient + * + * @return string One of the RESTORE_ constants + */ + private function takeBack(FederatedInbound $row, string $userId): string { + $status = $this->notifyOwner(row: $row, type: self::SHARE_ACCEPTED, message: 'The recipient restored their copy'); + if ($status === null || $status >= 500) { + return self::RESTORE_UNREACHABLE; + } + + $row->setUpdatedAt(new DateTime()); + if ($status !== 201) { + // The owner's instance does not know the share any more, or + // refuses it: the share has ended. The copy keeps its last value. + $row->setStatus(FederatedInbound::STATUS_REVOKED); + $this->inboundMapper->update(entity: $row); + return self::RESTORE_ENDED; + } + + $row->setStatus(FederatedInbound::STATUS_ACCEPTED); + $row = $this->inboundMapper->update(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_SHARE_ACCEPTED, row: $row, actorId: $userId); + + try { + $this->copies?->refresh(row: $row); + } catch (Throwable) { + // The share is live again; the owner's next change pulls anyway. + } + + return self::RESTORE_RESUMED; + }//end takeBack() + + /** + * Send `SHARE_DECLINED` for a declined share to the owner's instance. + * Returns whether it took the notification. + * + * @param FederatedInbound $row The declined share + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-declines-a-pending-share + */ + public function tellOwner(FederatedInbound $row): bool { + return $this->notifyOwner(row: $row, type: self::SHARE_DECLINED, message: 'The recipient removed their copy') === 201; + }//end tellOwner() + + /** + * Send one signed notification about a share to the owner's instance, + * with the shared secret, and return the HTTP status it answered, or + * null when it could not be reached. + * + * @param FederatedInbound $row The share + * @param string $type SHARE_DECLINED or SHARE_ACCEPTED + * @param string $message A short human-readable note + * + * @return int|null + */ + private function notifyOwner(FederatedInbound $row, string $type, string $message): ?int { + try { + $sharedSecret = $this->crypto->decrypt($row->getSharedSecretEnc()); + $remote = $this->cloudIdManager->resolveCloudId($row->getSenderCloudId())->getRemote(); + } catch (Throwable) { + return null; + } + + $notification = $this->factory->getCloudFederationNotification(); + $notification->setMessage( + $type, + FederatedShareService::RESOURCE_TYPE, + $row->getRemoteShareId(), + [ + 'sharedSecret' => $sharedSecret, + // The share id lets the owner's Nextcloud find whose + // signature this is (ISignedCloudFederationProvider). + 'providerId' => $row->getRemoteShareId(), + 'message' => $message, + ] + ); + + try { + return $this->providerManager->sendCloudNotification($remote, $notification)->getStatusCode(); + } catch (Throwable) { + return null; + } + }//end notifyOwner() +}//end class diff --git a/lib/Service/FederatedCopyService.php b/lib/Service/FederatedCopyService.php new file mode 100644 index 000000000..db3ec5137 --- /dev/null +++ b/lib/Service/FederatedCopyService.php @@ -0,0 +1,198 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use Ramsey\Uuid\Uuid; +use RuntimeException; + +/** + * The recipient's read-only copy of a federated share. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ +class FederatedCopyService { + /** + * Constructor for FederatedCopyService. + * + * @param FederatedSharePuller $puller Fetches the ciphertext from the sender + * @param SecretMapper $secretMapper The copies + * @param EncryptionSuiteMapper $suiteMapper The recipient's active suite + * @param SecretTypeService $typeService The copy's type + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedSharePuller $puller, + private SecretMapper $secretMapper, + private EncryptionSuiteMapper $suiteMapper, + private SecretTypeService $typeService, + ) { + }//end __construct() + + /** + * Pull the ciphertext and store it as a new read-only copy. + * + * @param FederatedInbound $row The accepted share + * + * @return Secret The stored copy + * + * @throws RuntimeException `pull_failed` or `no_suite` + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + public function pullNew(FederatedInbound $row): Secret { + $answer = $this->puller->pull(row: $row); + $userId = $row->getRecipientUid(); + + try { + $suite = $this->suiteMapper->findActiveByOwner(ownerType: 'user', ownerId: $userId); + } catch (DoesNotExistException) { + throw new RuntimeException('no_suite'); + } + + $now = new DateTime(); + $copy = new Secret(); + $copy->setId(Uuid::uuid4()->toString()); + $copy->setTypeId($this->typeFor(typeId: $answer['typeId'], userId: $userId)); + $copy->setFolderId(null); + $copy->setEncryptionSuiteId($suite->getId()); + $copy->setOwnerType('user'); + $copy->setOwnerId($userId); + $copy->setReadOnly(true); + $copy->setFederatedSource($row->getSenderCloudId()); + $copy->setCreatedAt($now); + $this->fill(copy: $copy, answer: $answer, now: $now); + + return $this->secretMapper->insert($copy); + }//end pullNew() + + + /** + * Pull again after the sender changed the secret, and replace the copy + * whole (task 4.1). + * + * @param FederatedInbound $row The accepted share + * + * @return Secret The updated copy + * + * @throws RuntimeException `pull_failed`, or `copy_missing` when the copy is gone + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-password-change-reaches-bob + */ + public function refresh(FederatedInbound $row): Secret { + $copy = $this->copyOf(row: $row); + $answer = $this->puller->pull(row: $row); + $this->fill(copy: $copy, answer: $answer, now: new DateTime()); + + return $this->secretMapper->update($copy); + }//end refresh() + + /** + * Delete the copy after the sender revoked the share (task 4.2). A copy + * that is already gone is fine. + * + * @param FederatedInbound $row The share + * + * @return void + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-revocation-removes-bobs-copy + */ + public function remove(FederatedInbound $row): void { + try { + $this->secretMapper->delete($this->copyOf(row: $row)); + } catch (RuntimeException) { + // Already gone. + } + }//end remove() + + /** + * The share's copy: the recipient's own read-only secret from that sender. + * + * @param FederatedInbound $row The share + * + * @return Secret + * + * @throws RuntimeException `copy_missing` + */ + private function copyOf(FederatedInbound $row): Secret { + try { + $copy = $this->secretMapper->findById((string)$row->getSecretId()); + } catch (DoesNotExistException) { + throw new RuntimeException('copy_missing'); + } + + if ($copy->getOwnerId() !== $row->getRecipientUid() || $copy->getReadOnly() !== true) { + throw new RuntimeException('copy_missing'); + } + + return $copy; + }//end copyOf() + + /** + * Write a pulled answer into a copy. + * + * @param Secret $copy The copy + * @param array{name:string,url:?string,key:string,login:?string,additionalFields:?string} $answer The pulled answer + * @param DateTime $now The time of the pull + * + * @return void + */ + private function fill(Secret $copy, array $answer, DateTime $now): void { + $copy->setName($answer['name']); + $copy->setUrl($answer['url']); + $copy->setKey($answer['key']); + $copy->setLogin($answer['login']); + $copy->setAdditionalFields($answer['additionalFields']); + $copy->setUpdatedAt($now); + $copy->setKeyUpdatedAt($now); + }//end fill() + + /** + * The copy's type: the sender's when it is one this user can see (the + * system types share their ids across instances), else the default. + * + * @param string|null $typeId The sender's type id + * @param string $userId The recipient + * + * @return string + */ + private function typeFor(?string $typeId, string $userId): string { + try { + return $this->typeService->resolveTypeForSecret($typeId, $userId); + } catch (InvalidArgumentException) { + return $this->typeService->resolveTypeForSecret(null, $userId); + } + }//end typeFor() +}//end class diff --git a/lib/Service/FederatedDeclineReceiver.php b/lib/Service/FederatedDeclineReceiver.php new file mode 100644 index 000000000..4d7f6f2c0 --- /dev/null +++ b/lib/Service/FederatedDeclineReceiver.php @@ -0,0 +1,246 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\FederatedShare; +use OCA\Keepiq\Db\FederatedShareMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Share\Exceptions\ShareNotFound; +use Throwable; + +/** + * Marks an outbound share declined when its recipient removed their copy. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ +class FederatedDeclineReceiver { + /** + * Constructor for FederatedDeclineReceiver. + * + * @param FederatedShareMapper $shareMapper Outbound share rows + * @param FederationPartnerService $partners The partner allowlist + * @param IOCMDiscoveryService $ocmDiscovery The verified signer of the request + * @param FederatedShareAuditTrail $audit Identifier-only audit + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedShareMapper $shareMapper, + private FederationPartnerService $partners, + private IOCMDiscoveryService $ocmDiscovery, + private FederatedShareAuditTrail $audit, + ) { + }//end __construct() + + /** + * Apply a `SHARE_DECLINED` from the recipient's instance. + * + * @param string $providerId The share id here + * @param array $notification The payload, `{sharedSecret}` (the secret itself) + * + * @return array + * + * @throws ShareNotFound For every refusal + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ + public function handle(string $providerId, array $notification): array { + $row = $this->verifiedRow(providerId: $providerId, presented: $notification['sharedSecret'] ?? null); + + // A revocation on its way finishes as a revocation; a second decline + // changes nothing. + if ($row->getStatus() === FederatedShare::STATUS_REVOKED || $row->getStatus() === FederatedShare::STATUS_DECLINED) { + return []; + } + + $row->setStatus(FederatedShare::STATUS_DECLINED); + $row->setPendingNotification(null); + $row->setNotifyAttempts(0); + $row->setNextNotifyAt(null); + $row->setUpdatedAt(new DateTime()); + $this->shareMapper->update(entity: $row); + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_RECIPIENT_DECLINED, row: $row, actorId: null); + + return []; + }//end handle() + + /** + * Apply a `SHARE_ACCEPTED` from the recipient's instance: a declined + * share whose copy the recipient restored becomes live again. One that + * is already live stays so. + * + * @param string $providerId The share id here + * @param array $notification The payload, `{sharedSecret}` (the secret itself) + * + * @return array + * + * @throws ShareNotFound For every refusal, and for a share that ended + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-restores-his-copy + * @spec openspec/specs/federated-sharing/spec.md#scenario-the-owner-revoked-the-share-meanwhile + */ + public function resume(string $providerId, array $notification): array { + $row = $this->verifiedRow(providerId: $providerId, presented: $notification['sharedSecret'] ?? null); + + if ($row->getStatus() === FederatedShare::STATUS_ACTIVE) { + return []; + } + + if ($row->getStatus() !== FederatedShare::STATUS_DECLINED || $this->sentAgain(row: $row) === true) { + throw new ShareNotFound(); + } + + $row->setStatus(FederatedShare::STATUS_ACTIVE); + $row->setPendingNotification(null); + $row->setNotifyAttempts(0); + $row->setNextNotifyAt(null); + $row->setUpdatedAt(new DateTime()); + $this->shareMapper->update(entity: $row); + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_RECIPIENT_RESUMED, row: $row, actorId: null); + + return []; + }//end resume() + + /** + * Whether the owner shared the same secret with the same recipient again + * after the decline: then that share is the live one, not this. + * + * @param FederatedShare $row The declined share + * + * @return bool + */ + private function sentAgain(FederatedShare $row): bool { + foreach ($this->shareMapper->findBySourceSecret(sourceSecretId: $row->getSourceSecretId()) as $other) { + if ($other->getId() !== $row->getId() + && $other->getRecipientCloudId() === $row->getRecipientCloudId() + && $other->getStatus() === FederatedShare::STATUS_ACTIVE + ) { + return true; + } + } + + return false; + }//end sentAgain() + + /** + * The recipient of the share a presented shared secret belongs to, or '' + * for none. Nextcloud 35 verifies the decline's signature against it. + * + * @param string $presented The secret the recipient's instance presented + * @param array $notification The payload, with `providerId` + * + * @return string + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-deletes-his-copy + */ + public function recipientOf(string $presented, array $notification): string { + $row = $this->rowFor(providerId: (string)($notification['providerId'] ?? ''), presented: $presented); + if ($row === null) { + return ''; + } + + return $row->getRecipientCloudId(); + }//end recipientOf() + + /** + * The share, when the presented secret matches and the request is signed + * by the recipient's partner. + * + * @param string $providerId The share id here + * @param mixed $presented The presented secret + * + * @return FederatedShare + * + * @throws ShareNotFound + */ + private function verifiedRow(string $providerId, mixed $presented): FederatedShare { + $row = null; + if (is_string($presented) === true) { + $row = $this->rowFor(providerId: $providerId, presented: $presented); + } + + if ($row === null) { + throw new ShareNotFound(); + } + + try { + $signed = $this->ocmDiscovery->getIncomingSignedRequest($row->getRecipientCloudId()); + } catch (Throwable) { + throw new ShareNotFound(); + } + + $partner = null; + if ($signed !== null) { + $partner = $this->partners->outboundPartnerForRemote(remote: $signed->getOrigin()); + } + + if ($partner === null || $partner->getId() !== $row->getPartnerId()) { + throw new ShareNotFound(); + } + + return $row; + }//end verifiedRow() + + /** + * The outbound share with that id whose stored hash is the SHA-256 of the + * presented secret, or null. + * + * @param string $providerId The share id + * @param string $presented The presented secret + * + * @return FederatedShare|null + */ + private function rowFor(string $providerId, string $presented): ?FederatedShare { + if ($providerId === '' || $presented === '') { + return null; + } + + try { + $row = $this->shareMapper->findById(id: $providerId); + } catch (DoesNotExistException) { + return null; + } + + if (hash_equals($row->getSharedSecretHash(), hash('sha256', $presented)) === false) { + return null; + } + + return $row; + }//end rowFor() +}//end class diff --git a/lib/Service/FederatedInboundService.php b/lib/Service/FederatedInboundService.php new file mode 100644 index 000000000..cc8a1d736 --- /dev/null +++ b/lib/Service/FederatedInboundService.php @@ -0,0 +1,147 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederatedInboundMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * The recipient's inbound federated shares. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ +class FederatedInboundService { + /** + * Constructor for FederatedInboundService. + * + * @param FederatedInboundMapper $inboundMapper Inbound share rows + * @param FederatedCopyService $copies Pulls and stores the read-only copy + * @param FederatedShareAuditTrail $audit Identifier-only audit + * @param FederatedCopyDeclineService|null $declines Tells the owner about a decline + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedInboundMapper $inboundMapper, + private FederatedCopyService $copies, + private FederatedShareAuditTrail $audit, + private ?FederatedCopyDeclineService $declines = null, + ) { + }//end __construct() + + /** + * The user's inbound shares, newest first. + * + * @param string $userId The recipient + * + * @return FederatedInbound[] + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + public function listFor(string $userId): array { + return $this->inboundMapper->findByRecipient(recipientUid: $userId); + }//end listFor() + + /** + * Accept a pending share: pull the ciphertext and store the read-only copy. + * + * @param string $id The inbound share + * @param string $userId The recipient + * + * @return FederatedInbound + * + * @throws NotFoundException When it is not the user's pending share + * @throws \RuntimeException `pull_failed` or `no_suite` + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-accepts-a-shared-login + */ + public function accept(string $id, string $userId): FederatedInbound { + $row = $this->pendingOf(id: $id, userId: $userId); + $copy = $this->copies->pullNew(row: $row); + + $row->setSecretId($copy->getId()); + $row->setStatus(FederatedInbound::STATUS_ACCEPTED); + $row->setUpdatedAt(new DateTime()); + $row = $this->inboundMapper->update(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_SHARE_ACCEPTED, row: $row, actorId: $userId); + + return $row; + }//end accept() + + /** + * Decline a pending share. Nothing is pulled, and the owner's instance + * gets OCM `SHARE_DECLINED`, as when an accepted copy is deleted. A + * decline that does not arrive goes again at the owner's next change. + * + * @param string $id The inbound share + * @param string $userId The recipient + * + * @return FederatedInbound + * + * @throws NotFoundException When it is not the user's pending share + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-declines-a-pending-share + */ + public function decline(string $id, string $userId): FederatedInbound { + $row = $this->pendingOf(id: $id, userId: $userId); + $row->setStatus(FederatedInbound::STATUS_DECLINED); + $row->setUpdatedAt(new DateTime()); + $row = $this->inboundMapper->update(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_SHARE_DECLINED, row: $row, actorId: $userId); + $this->declines?->tellOwner(row: $row); + + return $row; + }//end decline() + + /** + * The user's own pending share, or not found. + * + * @param string $id The inbound share + * @param string $userId The recipient + * + * @return FederatedInbound + * + * @throws NotFoundException + */ + private function pendingOf(string $id, string $userId): FederatedInbound { + try { + $row = $this->inboundMapper->findById(id: $id); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Share not found'); + } + + if ($row->getRecipientUid() !== $userId || $row->getStatus() !== FederatedInbound::STATUS_PENDING) { + throw new NotFoundException(message: 'Share not found'); + } + + return $row; + }//end pendingOf() +}//end class diff --git a/lib/Service/FederatedNotificationDelivery.php b/lib/Service/FederatedNotificationDelivery.php new file mode 100644 index 000000000..8a58285eb --- /dev/null +++ b/lib/Service/FederatedNotificationDelivery.php @@ -0,0 +1,170 @@ + + * @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\Service; + +use DateInterval; +use DateTime; +use OCA\Keepiq\Db\FederatedShare; +use OCA\Keepiq\Db\FederatedShareMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; + +/** + * OCM notifications with retries. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ +class FederatedNotificationDelivery { + /** + * A share changed: the receiver pulls again. + * + * @var string + */ + public const SHARE_UPDATED = 'SHARE_UPDATED'; + + /** + * A share ended: the receiver deletes its copy. + * + * @var string + */ + public const SHARE_UNSHARED = 'SHARE_UNSHARED'; + + /** + * Attempts before a notification is given up and the share marked failed. + * + * @var int + */ + public const MAX_ATTEMPTS = 6; + + /** + * Seconds before the first retry; each later wait doubles. + * + * @var int + */ + private const FIRST_WAIT = 60; + + /** + * Constructor for FederatedNotificationDelivery. + * + * @param FederatedShareMapper $shareMapper Outbound share rows + * @param FederatedShareMessenger $messenger Sends the notification + * @param FederatedShareAuditTrail $audit Records a give-up + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedShareMapper $shareMapper, + private FederatedShareMessenger $messenger, + private FederatedShareAuditTrail $audit, + ) { + }//end __construct() + + /** + * Send a notification now; on failure keep it for the retry job. + * + * @param FederatedShare $row The share + * @param string $type SHARE_UPDATED or SHARE_UNSHARED + * + * @return bool Whether it was delivered + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function deliver(FederatedShare $row, string $type): bool { + $row->setPendingNotification($type); + $row->setNotifyAttempts(0); + + return $this->attempt(row: $row, now: new DateTime()); + }//end deliver() + + /** + * Retry every notification that is due. + * + * @param DateTime $now The current time + * @param int $limit The most shares to handle in one run + * + * @return int How many were delivered + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function retryDue(DateTime $now, int $limit = 50): int { + $delivered = 0; + foreach ($this->shareMapper->findDueNotifications(now: $now, limit: $limit) as $row) { + if ($this->attempt(row: $row, now: $now) === true) { + $delivered++; + } + } + + return $delivered; + }//end retryDue() + + /** + * One attempt at the row's pending notification. + * + * @param FederatedShare $row The share + * @param DateTime $now The current time + * + * @return bool Whether it was delivered + */ + private function attempt(FederatedShare $row, DateTime $now): bool { + $type = (string)$row->getPendingNotification(); + if ($this->messenger->notify(row: $row, type: $type) === true) { + if ($type === self::SHARE_UNSHARED) { + $this->shareMapper->delete(entity: $row); + return true; + } + + $row->setPendingNotification(null); + $row->setNotifyAttempts(0); + $row->setNextNotifyAt(null); + $this->shareMapper->update(entity: $row); + return true; + } + + $attempts = $row->getNotifyAttempts() + 1; + $row->setNotifyAttempts($attempts); + $row->setNextNotifyAt(null); + if ($attempts >= self::MAX_ATTEMPTS) { + // Given up: the owner sees the share as failed and can revoke or + // share again. The pending type stays, to say what did not arrive. + $row->setStatus(FederatedShare::STATUS_FAILED); + $this->shareMapper->update(entity: $row); + $this->audit->recordOutbound( + eventType: AuditEventTypes::FEDERATED_SHARE_FAILED, + row: $row, + actorId: null, + extra: ['notification' => $type], + ); + return false; + } + + $wait = self::FIRST_WAIT * (2 ** ($attempts - 1)); + $row->setNextNotifyAt((clone $now)->add(new DateInterval('PT' . $wait . 'S'))); + $this->shareMapper->update(entity: $row); + + return false; + }//end attempt() +}//end class diff --git a/lib/Service/FederatedRemoteChangeService.php b/lib/Service/FederatedRemoteChangeService.php new file mode 100644 index 000000000..cae94fa3d --- /dev/null +++ b/lib/Service/FederatedRemoteChangeService.php @@ -0,0 +1,258 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederatedInboundMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\ICrypto; +use OCP\Share\Exceptions\ShareNotFound; +use Throwable; + +/** + * Applies the sender's changes to the recipient's copy. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) One notification joins the + * inbound row, the partner allowlist, Nextcloud's signature check, the + * stored secret, the copy and the audit, and answers a declined share with + * the decline (task 4.4); each refusal is the same "share not found". + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ +class FederatedRemoteChangeService { + /** + * The OCM notification that a share changed. + * + * @var string + */ + private const SHARE_UPDATED = 'SHARE_UPDATED'; + + /** + * The OCM notification that a share ended. + * + * @var string + */ + private const SHARE_UNSHARED = 'SHARE_UNSHARED'; + + /** + * Constructor for FederatedRemoteChangeService. + * + * @param FederatedInboundMapper $inboundMapper Inbound share rows + * @param FederationPartnerService $partners The partner allowlist + * @param IOCMDiscoveryService $ocmDiscovery The verified signer of the request + * @param ICrypto $crypto Opens the stored shared secret + * @param FederatedCopyService $copies Refreshes or removes the copy + * @param FederatedShareAuditTrail $audit Identifier-only audit + * @param FederatedCopyDeclineService|null $declines Repeats a decline the owner did not get + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedInboundMapper $inboundMapper, + private FederationPartnerService $partners, + private IOCMDiscoveryService $ocmDiscovery, + private ICrypto $crypto, + private FederatedCopyService $copies, + private FederatedShareAuditTrail $audit, + private ?FederatedCopyDeclineService $declines = null, + ) { + }//end __construct() + + /** + * Handle one notification from the sending instance. + * + * @param string $type `SHARE_UPDATED` or `SHARE_UNSHARED`; others are acknowledged and ignored + * @param string $providerId The share id on the sender + * @param array $notification The payload, `{sharedSecret}` (its SHA-256) + * + * @return array + * + * @throws ShareNotFound For every refusal + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function handle(string $type, string $providerId, array $notification): array { + $row = $this->verifiedRow(providerId: $providerId, presented: $notification['sharedSecret'] ?? null); + + if ($type === self::SHARE_UPDATED) { + $this->applyUpdate(row: $row); + } + + if ($type === self::SHARE_UNSHARED) { + $this->applyUnshare(row: $row); + } + + return []; + }//end handle() + + /** + * Pull again when the share is accepted; a pending share pulls on + * acceptance anyway, and a declined one answers with the decline. + * + * @param FederatedInbound $row The share + * + * @return void + * + * @throws ShareNotFound When the pull fails, so the sender retries + */ + private function applyUpdate(FederatedInbound $row): void { + // The recipient deleted the copy, and the owner still sends changes: + // the decline did not arrive, so it goes again (task 4.4). + if ($row->getStatus() === FederatedInbound::STATUS_DECLINED) { + $this->declines?->tellOwner(row: $row); + return; + } + + if ($row->getStatus() !== FederatedInbound::STATUS_ACCEPTED) { + return; + } + + try { + $this->copies->refresh(row: $row); + } catch (Throwable) { + throw new ShareNotFound(); + } + + $row->setUpdatedAt(new DateTime()); + $this->inboundMapper->update(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_COPY_UPDATED, row: $row, actorId: null); + }//end applyUpdate() + + /** + * Delete the copy and mark the share revoked. + * + * @param FederatedInbound $row The share + * + * @return void + */ + private function applyUnshare(FederatedInbound $row): void { + if ($row->getStatus() === FederatedInbound::STATUS_ACCEPTED) { + $this->copies->remove(row: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_COPY_REMOVED, row: $row, actorId: null); + } + + $row->setSecretId(null); + $row->setStatus(FederatedInbound::STATUS_REVOKED); + $row->setUpdatedAt(new DateTime()); + $this->inboundMapper->update(entity: $row); + }//end applyUnshare() + + /** + * The sender of the share a notification's shared secret hash belongs to, + * or '' for none. Nextcloud 35 needs it to verify the signature + * (ISignedCloudFederationProvider::getFederationIdFromSharedSecret()). + * + * @param string $presented The hash the sender presented + * @param array $notification The payload, with `providerId` + * + * @return string + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function senderOf(string $presented, array $notification): string { + $row = $this->rowFor(providerId: (string)($notification['providerId'] ?? ''), presented: $presented); + if ($row === null) { + return ''; + } + + return $row->getSenderCloudId(); + }//end senderOf() + + /** + * The share this notification is about, when the presented hash matches + * the shared secret held here and the request is signed by that share's + * own partner, verified against the share's sender. + * + * @param string $providerId The share id on the sender + * @param mixed $presented The hash the sender presented + * + * @return FederatedInbound + * + * @throws ShareNotFound + */ + private function verifiedRow(string $providerId, mixed $presented): FederatedInbound { + $row = null; + if (is_string($presented) === true) { + $row = $this->rowFor(providerId: $providerId, presented: $presented); + } + + if ($row === null || $row->getStatus() === FederatedInbound::STATUS_REVOKED) { + throw new ShareNotFound(); + } + + try { + $signed = $this->ocmDiscovery->getIncomingSignedRequest($row->getSenderCloudId()); + } catch (Throwable) { + throw new ShareNotFound(); + } + + $partner = null; + if ($signed !== null) { + $partner = $this->partners->inboundPartnerForSigner(signer: $signed->getOrigin()); + } + + if ($partner === null || $partner->getId() !== $row->getPartnerId()) { + throw new ShareNotFound(); + } + + return $row; + }//end verifiedRow() + + /** + * The inbound share with that remote id whose shared secret hashes to + * the presented value, or null. + * + * @param string $providerId The share id on the sender + * @param string $presented The presented hash + * + * @return FederatedInbound|null + */ + private function rowFor(string $providerId, string $presented): ?FederatedInbound { + if ($providerId === '' || $presented === '') { + return null; + } + + foreach ($this->inboundMapper->findByRemoteShareId(remoteShareId: $providerId) as $row) { + try { + $held = hash('sha256', $this->crypto->decrypt($row->getSharedSecretEnc())); + } catch (Throwable) { + continue; + } + + if (hash_equals($held, $presented) === true) { + return $row; + } + } + + return null; + }//end rowFor() +}//end class diff --git a/lib/Service/FederatedShareAuditTrail.php b/lib/Service/FederatedShareAuditTrail.php new file mode 100644 index 000000000..a82c5bd74 --- /dev/null +++ b/lib/Service/FederatedShareAuditTrail.php @@ -0,0 +1,148 @@ + + * @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\Service; + +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederatedShare; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCP\EventDispatcher\IEventDispatcher; + +/** + * Identifier-only audit of federated shares. + * + * @spec openspec/changes/archive/2026-10-04-sharing-federated-recipients/tasks.md#task-4.3 + */ +class FederatedShareAuditTrail { + /** + * Constructor for FederatedShareAuditTrail. + * + * @param IEventDispatcher|null $eventDispatcher The audit event dispatcher + * @param AuditEventFactory $auditEvents Builds the events + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private ?IEventDispatcher $eventDispatcher = null, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * Record an event of the sending side, on the owner's secret. + * + * @param string $eventType One of the FEDERATED_SHARE_ types + * @param FederatedShare $row The outbound share + * @param string|null $actorId The owner, or null for the system (retries, partner removal) + * @param array $extra `reason` or `notification` + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-sharing-federated-recipients/tasks.md#task-4.3 + */ + public function recordOutbound(string $eventType, FederatedShare $row, ?string $actorId, array $extra = []): void { + $metadata = array_merge( + [ + 'federatedShareId' => $row->getId(), + 'recipientCloudId' => $row->getRecipientCloudId(), + 'partnerId' => $row->getPartnerId(), + ], + array_intersect_key($extra, array_flip(['reason', 'notification'])) + ); + + $this->dispatch( + actorId: $actorId, + eventType: $eventType, + objectId: $row->getSourceSecretId(), + metadata: $metadata, + ); + }//end recordOutbound() + + /** + * Record an event of the receiving side, on the recipient's copy when + * there is one. + * + * @param string $eventType One of the FEDERATED_ types + * @param FederatedInbound $row The inbound share + * @param string|null $actorId The recipient, or null when the sender caused it + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-sharing-federated-recipients/tasks.md#task-4.3 + */ + public function recordInbound(string $eventType, FederatedInbound $row, ?string $actorId): void { + $metadata = [ + 'inboundShareId' => $row->getId(), + 'senderCloudId' => $row->getSenderCloudId(), + 'partnerId' => $row->getPartnerId(), + ]; + if ($row->getSecretId() !== null) { + $metadata['copyId'] = $row->getSecretId(); + } + + $this->dispatch( + actorId: $actorId, + eventType: $eventType, + objectId: $row->getSecretId() ?? $row->getId(), + metadata: $metadata, + ); + }//end recordInbound() + + /** + * Dispatch one entry as the user, or as the system. + * + * @param string|null $actorId The user, or null + * @param string $eventType The event type + * @param string $objectId The secret or share + * @param array $metadata Identifiers only + * + * @return void + */ + private function dispatch(?string $actorId, string $eventType, string $objectId, array $metadata): void { + if ($this->eventDispatcher === null) { + return; + } + + $event = $this->auditEvents->forSystem( + eventType: $eventType, + objectType: 'secret', + objectId: $objectId, + metadata: $metadata, + ); + if ($actorId !== null) { + $event = $this->auditEvents->forUser( + actorId: $actorId, + eventType: $eventType, + objectType: 'secret', + objectId: $objectId, + metadata: $metadata, + ); + } + + $this->eventDispatcher->dispatchTyped($event); + }//end dispatch() +}//end class diff --git a/lib/Service/FederatedShareMessenger.php b/lib/Service/FederatedShareMessenger.php new file mode 100644 index 000000000..ba265a775 --- /dev/null +++ b/lib/Service/FederatedShareMessenger.php @@ -0,0 +1,143 @@ + + * @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\Service; + +use OCA\Keepiq\Db\FederatedShare; +use OCP\Federation\ICloudFederationFactory; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\Federation\ICloudIdManager; +use OCP\IUserManager; +use Throwable; + +/** + * OCM messages of the sending side. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedShareMessenger { + /** + * Constructor for FederatedShareMessenger. + * + * @param ICloudFederationProviderManager $providerManager OCM delivery + * @param ICloudFederationFactory $factory OCM share and notification objects + * @param ICloudIdManager $cloudIdManager The owner's cloud id + * @param IUserManager $userManager The owner's display name + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private ICloudFederationProviderManager $providerManager, + private ICloudFederationFactory $factory, + private ICloudIdManager $cloudIdManager, + private IUserManager $userManager, + ) { + }//end __construct() + + /** + * Announce a stored share to the recipient's instance. Returns whether it + * took the share. + * + * @param FederatedShare $row The stored share + * @param string $name The secret's plain name + * @param string $sharedSecret The share's shared secret + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function announce(FederatedShare $row, string $name, string $sharedSecret): bool { + $owner = $this->cloudIdManager->getCloudId($row->getOwnerId(), null)->getId(); + $displayName = (string)($this->userManager->getDisplayName($row->getOwnerId()) ?? $row->getOwnerId()); + $share = $this->factory->getCloudFederationShare( + $row->getRecipientCloudId(), + $name, + '', + $row->getId(), + $owner, + $displayName, + $owner, + $displayName, + $sharedSecret, + 'user', + FederatedShareService::RESOURCE_TYPE, + ); + // Keepiq's own protocol entry: the receiver reads the shared secret + // from it, and nothing in it points at a WebDAV resource. + $share->setProtocol(['name' => 'keepiq', 'options' => ['sharedSecret' => $sharedSecret]]); + + try { + $response = $this->providerManager->sendCloudShare($share); + } catch (Throwable) { + return false; + } + + return $response->getStatusCode() === 201; + }//end announce() + + /** + * Tell the recipient's instance that a share changed or ended. Returns + * whether it took the notification. + * + * @param FederatedShare $row The stored share + * @param string $type `SHARE_UPDATED` or `SHARE_UNSHARED` + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function notify(FederatedShare $row, string $type): bool { + $notification = $this->factory->getCloudFederationNotification(); + $notification->setMessage( + $type, + FederatedShareService::RESOURCE_TYPE, + $row->getId(), + [ + 'sharedSecret' => $row->getSharedSecretHash(), + // The share id and the owner let the receiver's Nextcloud find + // whose signature this is (ISignedCloudFederationProvider). + 'providerId' => $row->getId(), + 'sender' => $this->cloudIdManager->getCloudId($row->getOwnerId(), null)->getId(), + 'message' => $type, + ] + ); + + try { + $remote = $this->cloudIdManager->resolveCloudId($row->getRecipientCloudId())->getRemote(); + $response = $this->providerManager->sendCloudNotification($remote, $notification); + } catch (Throwable) { + return false; + } + + return $response->getStatusCode() === 201; + }//end notify() +}//end class diff --git a/lib/Service/FederatedSharePuller.php b/lib/Service/FederatedSharePuller.php new file mode 100644 index 000000000..e747d294a --- /dev/null +++ b/lib/Service/FederatedSharePuller.php @@ -0,0 +1,123 @@ +/keepiq/shares/{id}` with the share's shared secret + * in the payload, through `requestRemoteOcmEndpoint()`. The answer counts + * only when it is a ciphertext for this share's own recipient. + * + * @category Service + * @package OCA\Keepiq\Service + * + * @author Conduction Development Team + * @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\Service; + +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederationPartnerMapper; +use OCP\Federation\ICloudIdManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\ICrypto; +use RuntimeException; +use Throwable; + +/** + * Pulls ciphertext from the sending partner. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedSharePuller { + /** + * Constructor for FederatedSharePuller. + * + * @param FederationPartnerMapper $partnerMapper The partner a share came from + * @param IOCMDiscoveryService $ocmDiscovery Signed OCM requests + * @param ICrypto $crypto Opens the stored shared secret + * @param ICloudIdManager $cloudIdManager The recipient's cloud id + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederationPartnerMapper $partnerMapper, + private IOCMDiscoveryService $ocmDiscovery, + private ICrypto $crypto, + private ICloudIdManager $cloudIdManager, + ) { + }//end __construct() + + /** + * Ask the sender for the share's current ciphertext. + * + * @param FederatedInbound $row The inbound share + * + * @return array{name:string,url:?string,typeId:?string,key:string,login:?string,additionalFields:?string} + * + * @throws RuntimeException `pull_failed` for every failure + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function pull(FederatedInbound $row): array { + $recipient = $this->cloudIdManager->getCloudId($row->getRecipientUid(), null)->getId(); + try { + $partner = $this->partnerMapper->findById(id: $row->getPartnerId()); + $response = $this->ocmDiscovery->requestRemoteOcmEndpoint( + FederatedCertificateService::OCM_CAPABILITY, + $partner->getBaseUrl(), + FederatedCertificateService::OCM_CAPABILITY . '/shares/' . rawurlencode($row->getRemoteShareId()), + // `sender` names the recipient, so the sender's Nextcloud can take + // the signer's origin from it to verify an RFC 9421 signature. + ['sharedSecret' => $this->crypto->decrypt($row->getSharedSecretEnc()), 'sender' => $recipient], + 'post', + ); + $status = $response->getStatusCode(); + $body = json_decode((string)$response->getBody(), true); + } catch (Throwable) { + throw new RuntimeException('pull_failed'); + } + + if ($status !== 200 || is_array($body) === false + || is_string($body['key'] ?? null) === false || $body['key'] === '' + || (new CloudIdForm())->same((string)($body['recipientCloudId'] ?? ''), $recipient) === false + ) { + throw new RuntimeException('pull_failed'); + } + + return [ + 'name' => mb_substr((string)($body['name'] ?? $row->getName()), 0, 255), + 'url' => $this->optional(value: $body['url'] ?? null), + 'typeId' => $this->optional(value: $body['typeId'] ?? null), + 'key' => $body['key'], + 'login' => $this->optional(value: $body['login'] ?? null), + 'additionalFields' => $this->optional(value: $body['additionalFields'] ?? null), + ]; + }//end pull() + + /** + * A string value or null. + * + * @param mixed $value The value + * + * @return string|null + */ + private function optional(mixed $value): ?string { + if (is_string($value) === false || $value === '') { + return null; + } + + return $value; + }//end optional() +}//end class diff --git a/lib/Service/FederatedShareReceiver.php b/lib/Service/FederatedShareReceiver.php new file mode 100644 index 000000000..57b36777b --- /dev/null +++ b/lib/Service/FederatedShareReceiver.php @@ -0,0 +1,214 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\FederatedInbound; +use OCA\Keepiq\Db\FederatedInboundMapper; +use OCA\Keepiq\Db\FederationPartner; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Federation\Exceptions\ProviderCouldNotAddShareException; +use OCP\Federation\ICloudFederationShare; +use OCP\Federation\ICloudIdManager; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Security\ICrypto; +use Ramsey\Uuid\Uuid; +use Throwable; + +/** + * Incoming OCM shares of Keepiq secrets. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Taking a share in is one + * decision over Nextcloud's signer, cloud ids, users, preferences and + * crypto, plus the row it stores and the refusal it throws; splitting the + * checks apart would spread one security decision over several classes. + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-non-partner-cannot-deliver + */ +class FederatedShareReceiver { + /** + * The one refusal every rejected share gets, so a sender learns nothing + * about which check failed. + * + * @var string + */ + public const REFUSAL = 'Share refused'; + + /** + * Constructor for FederatedShareReceiver. + * + * @param FederatedInboundMapper $inboundMapper Inbound share rows + * @param FederationPartnerService $partners The partner allowlist + * @param IOCMDiscoveryService $ocmDiscovery The verified signer of the request + * @param ICloudIdManager $cloudIdManager Cloud id parsing + * @param IUserManager $userManager Local users + * @param IConfig $config The receive preference + * @param ICrypto $crypto Keeps the shared secret + * @param NotificationService $notifications Tells the recipient + * @param FederatedShareAuditTrail $audit Identifier-only audit + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedInboundMapper $inboundMapper, + private FederationPartnerService $partners, + private IOCMDiscoveryService $ocmDiscovery, + private ICloudIdManager $cloudIdManager, + private IUserManager $userManager, + private IConfig $config, + private ICrypto $crypto, + private NotificationService $notifications, + private FederatedShareAuditTrail $audit, + ) { + }//end __construct() + + /** + * Store an announced share as pending and notify the recipient. + * + * @param ICloudFederationShare $share The OCM share; shareWith is the local uid + * + * @return string The inbound share id + * + * @throws ProviderCouldNotAddShareException When the share is refused + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-non-partner-cannot-deliver + */ + public function receive(ICloudFederationShare $share): string { + $partner = $this->senderPartner(owner: (string)$share->getOwner()); + $userId = (string)$share->getShareWith(); + $providerId = (string)$share->getProviderId(); + $sharedSecret = (string)$share->getShareSecret(); + + if ($partner === null + || $share->getResourceType() !== FederatedShareService::RESOURCE_TYPE + || $share->getShareType() !== 'user' + || $providerId === '' || $sharedSecret === '' + || $this->mayReceive(userId: $userId) === false + || $this->known(partner: $partner, remoteShareId: $providerId) === true + ) { + throw new ProviderCouldNotAddShareException(self::REFUSAL, '', 403); + } + + $now = new DateTime(); + $row = new FederatedInbound(); + $row->setId(Uuid::uuid4()->toString()); + $row->setRecipientUid($userId); + $row->setSenderCloudId((string)$share->getOwner()); + $row->setPartnerId($partner->getId()); + $row->setRemoteShareId($providerId); + $row->setName(mb_substr((string)$share->getResourceName(), 0, 255)); + $row->setSharedSecretEnc($this->crypto->encrypt($sharedSecret)); + $row->setStatus(FederatedInbound::STATUS_PENDING); + $row->setReceivedAt($now); + $row->setUpdatedAt($now); + $this->inboundMapper->insert(entity: $row); + $this->audit->recordInbound(eventType: AuditEventTypes::FEDERATED_SHARE_RECEIVED, row: $row, actorId: null); + + $this->notifications->notify( + subject: 'federated_share_received', + recipientId: $userId, + params: ['shared_by' => $row->getSenderCloudId(), 'secret_name' => $row->getName()], + objectType: 'federated_inbound', + objectId: $row->getId(), + ); + + return $row->getId(); + }//end receive() + + /** + * The partner a share comes from: the verified signer of this request + * must be an inbound partner, and the owner's cloud id must name that + * same instance. Null for an unsigned or badly signed request. + * + * @param string $owner The owner's cloud id from the share + * + * @return FederationPartner|null + */ + private function senderPartner(string $owner): ?FederationPartner { + try { + // The owner's address names whose signature this is; Nextcloud 35 + // cannot verify an RFC 9421 signature without it. + $signed = $this->ocmDiscovery->getIncomingSignedRequest($owner); + $ownerId = $this->cloudIdManager->resolveCloudId($owner); + } catch (Throwable) { + return null; + } + + if ($signed === null) { + return null; + } + + $partner = $this->partners->inboundPartnerForSigner(signer: $signed->getOrigin()); + if ($partner === null || $this->partners->hostOf(url: $ownerId->getRemote()) !== $partner->getHost()) { + return null; + } + + return $partner; + }//end senderPartner() + + /** + * Whether a local user exists and opted in to receiving. + * + * @param string $userId The local uid + * + * @return bool + */ + private function mayReceive(string $userId): bool { + return $userId !== '' + && $this->userManager->userExists($userId) === true + && $this->config->getUserValue( + $userId, + Application::APP_ID, + FederatedCertificateService::RECEIVE_PREFERENCE, + '0' + ) === '1'; + }//end mayReceive() + + /** + * Whether this partner already announced a share under that id. + * + * @param FederationPartner $partner The sending partner + * @param string $remoteShareId The share id on the sender + * + * @return bool + */ + private function known(FederationPartner $partner, string $remoteShareId): bool { + try { + $this->inboundMapper->findByRemote(partnerId: $partner->getId(), remoteShareId: $remoteShareId); + return true; + } catch (DoesNotExistException) { + return false; + } + }//end known() +}//end class diff --git a/lib/Service/FederatedShareService.php b/lib/Service/FederatedShareService.php new file mode 100644 index 000000000..23b9c0270 --- /dev/null +++ b/lib/Service/FederatedShareService.php @@ -0,0 +1,483 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\FederatedShare; +use OCA\Keepiq\Db\FederatedShareMapper; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Federation\ICloudIdManager; +use OCP\Security\ISecureRandom; +use Ramsey\Uuid\Uuid; +use RuntimeException; + +/** + * Outbound federated shares. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The owner's side of a + * federated share joins the secret, the partner allowlist, the share row + * and the OCM messenger in one transaction-like step, and answers the + * partner's pull from the same row; each refusal is its own exception. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ +class FederatedShareService { + /** + * The OCM resource type Keepiq registers and sends. + * + * @var string + */ + public const RESOURCE_TYPE = 'keepiq-secret'; + + /** + * Length of a share's shared secret. + * + * @var int + */ + private const SHARED_SECRET_LENGTH = 64; + + /** + * Constructor for FederatedShareService. + * + * @param FederatedShareMapper $shareMapper Outbound share rows + * @param SecretMapper $secretMapper The owner's secrets + * @param FederationPartnerService $partners The partner allowlist + * @param FederationRootService $root Whether this Nextcloud can federate + * @param ICloudIdManager $cloudIdManager Cloud id parsing + * @param FederatedShareMessenger $messenger OCM messages to the recipient's instance + * @param ISecureRandom $random The shared secret + * @param FederatedNotificationDelivery $delivery Notifications with retries + * @param FederatedShareAuditTrail $audit Identifier-only audit + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederatedShareMapper $shareMapper, + private SecretMapper $secretMapper, + private FederationPartnerService $partners, + private FederationRootService $root, + private ICloudIdManager $cloudIdManager, + private FederatedShareMessenger $messenger, + private ISecureRandom $random, + private FederatedNotificationDelivery $delivery, + private FederatedShareAuditTrail $audit, + ) { + }//end __construct() + + /** + * Store the browser-made ciphertext for a federated recipient and announce + * the share over OCM. + * + * @param string $secretId The owner's secret + * @param string $userId The owner + * @param string $recipientCloudId The recipient, `bob@cloud.partner.example` + * @param string $certFingerprint SHA-256 of the certificate the browser verified and encrypted for + * @param array{key:string,login:?string,additionalFields:?string} $ciphertext Encrypted for the recipient + * + * @return FederatedShare + * + * @throws NotFoundException When the secret is not the user's own + * @throws InvalidArgumentException `invalid`, `not_a_partner`, `unknown_recipient` or `already_shared` + * @throws RuntimeException `federation_unavailable`, or `delivery_failed` when the partner refused it + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function create( + string $secretId, + string $userId, + string $recipientCloudId, + string $certFingerprint, + array $ciphertext, + ): FederatedShare { + if ($this->root->isSupported() === false) { + throw new RuntimeException('federation_unavailable'); + } + + $source = $this->ownedSecret(secretId: $secretId, userId: $userId); + // A read-only or restricted copy never leaves (task 3.4). + $source->assertOnwardShareable(); + + $certFingerprint = strtolower(trim($certFingerprint)); + if (preg_match('/^[0-9a-f]{64}$/', $certFingerprint) !== 1 || ($ciphertext['key'] ?? '') === '') { + throw new InvalidArgumentException('invalid'); + } + + try { + $recipient = $this->cloudIdManager->resolveCloudId($recipientCloudId); + } catch (InvalidArgumentException) { + throw new InvalidArgumentException('unknown_recipient'); + } + + $partner = $this->partners->outboundPartnerForRemote(remote: $recipient->getRemote()); + if ($partner === null) { + throw new InvalidArgumentException('not_a_partner'); + } + + $this->assertNotSharedWith(secretId: $secretId, recipientCloudId: $recipient->getId()); + + $sharedSecret = $this->random->generate(self::SHARED_SECRET_LENGTH, ISecureRandom::CHAR_ALPHANUMERIC); + $now = new DateTime(); + $row = new FederatedShare(); + $row->setId(Uuid::uuid4()->toString()); + $row->setSourceSecretId($secretId); + $row->setOwnerId($userId); + $row->setRecipientCloudId($recipient->getId()); + $row->setPartnerId($partner->getId()); + $row->setRecipientCertFingerprint($certFingerprint); + $row->setKey($ciphertext['key']); + $row->setLogin($ciphertext['login'] ?? null); + $row->setAdditionalFields($ciphertext['additionalFields'] ?? null); + $row->setSharedSecretHash(hash('sha256', $sharedSecret)); + $row->setStatus(FederatedShare::STATUS_ACTIVE); + $row->setNotifyAttempts(0); + $row->setCreatedAt($now); + $row->setUpdatedAt($now); + $row = $this->shareMapper->insert(entity: $row); + + if ($this->messenger->announce(row: $row, name: $source->getName(), sharedSecret: $sharedSecret) === false) { + $this->shareMapper->delete(entity: $row); + throw new RuntimeException('delivery_failed'); + } + + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_SENT, row: $row, actorId: $userId); + + return $row; + }//end create() + + /** + * Replace a share's ciphertext after the owner changed the secret, and + * tell the recipient's instance to pull again (task 4.1). The owner's + * browser made the new ciphertext for a freshly verified certificate. + * + * @param string $shareId The federated share + * @param string $userId The owner + * @param string $certFingerprint SHA-256 of the certificate the browser verified now + * @param array{key:string,login:?string,additionalFields:?string} $ciphertext Encrypted for the recipient + * + * @return FederatedShare + * + * @throws NotFoundException When it is not the user's live share + * @throws InvalidArgumentException `invalid` + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-password-change-reaches-bob + */ + public function update(string $shareId, string $userId, string $certFingerprint, array $ciphertext): FederatedShare { + $row = $this->ownedShare(shareId: $shareId, userId: $userId); + if ($row->getStatus() !== FederatedShare::STATUS_ACTIVE) { + throw new NotFoundException(message: 'Share not found'); + } + + $certFingerprint = strtolower(trim($certFingerprint)); + if (preg_match('/^[0-9a-f]{64}$/', $certFingerprint) !== 1 || ($ciphertext['key'] ?? '') === '') { + throw new InvalidArgumentException('invalid'); + } + + $row->setRecipientCertFingerprint($certFingerprint); + $row->setKey($ciphertext['key']); + $row->setLogin($ciphertext['login'] ?? null); + $row->setAdditionalFields($ciphertext['additionalFields'] ?? null); + $row->setUpdatedAt(new DateTime()); + $this->shareMapper->update(entity: $row); + + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_UPDATED, row: $row, actorId: $userId); + $this->delivery->deliver(row: $row, type: FederatedNotificationDelivery::SHARE_UPDATED); + + return $row; + }//end update() + + /** + * Tell every live federated recipient of a secret that its plain name or + * URL changed (task 4.5): their server pulls again and the copy follows. + * No new ciphertext is needed, because the pull reads the name and URL + * from the source. One notification per recipient. + * + * @param string $secretId The owner's secret + * @param string $userId The owner, for the audit + * + * @return int How many recipients were told + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-a-new-name-reaches-bob + */ + public function detailsChanged(string $secretId, string $userId): int { + $count = 0; + foreach ($this->shareMapper->findBySourceSecret(sourceSecretId: $secretId) as $row) { + if ($row->getStatus() !== FederatedShare::STATUS_ACTIVE || $row->getOwnerId() !== $userId) { + continue; + } + + $row->setUpdatedAt(new DateTime()); + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_UPDATED, row: $row, actorId: $userId); + $this->delivery->deliver(row: $row, type: FederatedNotificationDelivery::SHARE_UPDATED); + $count++; + } + + return $count; + }//end detailsChanged() + + /** + * Revoke a share: nothing is served any more, and the recipient's + * instance is told to delete its copy (task 4.2). The row goes once that + * notification arrives; until then the retry job keeps trying. + * + * @param string $shareId The federated share + * @param string $userId The owner + * + * @return void + * + * @throws NotFoundException When it is not the user's share + * + * @spec openspec/specs/federated-sharing/spec.md#scenario-revocation-removes-bobs-copy + */ + public function revoke(string $shareId, string $userId): void { + $row = $this->ownedShare(shareId: $shareId, userId: $userId); + $row->setStatus(FederatedShare::STATUS_REVOKED); + $row->setUpdatedAt(new DateTime()); + $this->shareMapper->update(entity: $row); + + $this->audit->recordOutbound(eventType: AuditEventTypes::FEDERATED_SHARE_REVOKED, row: $row, actorId: $userId); + $this->delivery->deliver(row: $row, type: FederatedNotificationDelivery::SHARE_UNSHARED); + }//end revoke() + + /** + * Suspend a share whose recipient certificate no longer verifies in the + * owner's browser (task 4.2): nothing is served until the owner revokes + * it or shares again. + * + * @param string $shareId The federated share + * @param string $userId The owner + * @param string $reason Why, as the browser reports it + * + * @return FederatedShare + * + * @throws NotFoundException When it is not the user's share + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function suspend(string $shareId, string $userId, string $reason): FederatedShare { + $row = $this->ownedShare(shareId: $shareId, userId: $userId); + + return $this->suspendRow(row: $row, actorId: $userId, reason: $reason); + }//end suspend() + + /** + * Suspend every share to users of a partner that was removed (task 4.2). + * + * @param string $partnerId The removed partner + * + * @return int How many shares were suspended + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function suspendForPartner(string $partnerId): int { + $count = 0; + foreach ($this->shareMapper->findByPartner(partnerId: $partnerId) as $row) { + if ($row->getStatus() === FederatedShare::STATUS_ACTIVE) { + $this->suspendRow(row: $row, actorId: null, reason: 'partner_removed'); + $count++; + } + } + + return $count; + }//end suspendForPartner() + + /** + * Suspend one row and record it. + * + * @param FederatedShare $row The share + * @param string|null $actorId The owner, or null for the system + * @param string $reason Why + * + * @return FederatedShare + */ + private function suspendRow(FederatedShare $row, ?string $actorId, string $reason): FederatedShare { + $reason = (string)preg_replace('/[^a-z_]/', '', strtolower($reason)); + if ($reason === '') { + $reason = 'unverified'; + } + $row->setStatus(FederatedShare::STATUS_SUSPENDED); + $row->setUpdatedAt(new DateTime()); + $this->shareMapper->update(entity: $row); + $this->audit->recordOutbound( + eventType: AuditEventTypes::FEDERATED_SHARE_SUSPENDED, + row: $row, + actorId: $actorId, + extra: ['reason' => mb_substr($reason, 0, 32)], + ); + + return $row; + }//end suspendRow() + + /** + * The user's own outbound share, or not found. + * + * @param string $shareId The share + * @param string $userId The owner + * + * @return FederatedShare + * + * @throws NotFoundException + */ + private function ownedShare(string $shareId, string $userId): FederatedShare { + try { + $row = $this->shareMapper->findById(id: $shareId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Share not found'); + } + + if ($row->getOwnerId() !== $userId) { + throw new NotFoundException(message: 'Share not found'); + } + + return $row; + }//end ownedShare() + + /** + * Refuse a second live share to the same recipient. After a suspended, + * failed or revoked one the owner shares again, as the share list tells + * them to. + * + * @param string $secretId The owner's secret + * @param string $recipientCloudId The recipient + * + * @return void + * + * @throws InvalidArgumentException `already_shared` + */ + private function assertNotSharedWith(string $secretId, string $recipientCloudId): void { + foreach ($this->shareMapper->findBySourceSecret(sourceSecretId: $secretId) as $existing) { + if ($existing->getRecipientCloudId() === $recipientCloudId + && $existing->getStatus() === FederatedShare::STATUS_ACTIVE + ) { + throw new InvalidArgumentException('already_shared'); + } + } + }//end assertNotSharedWith() + + /** + * The federated shares of one of the user's own secrets. + * + * @param string $secretId The owner's secret + * @param string $userId The owner + * + * @return FederatedShare[] + * + * @throws NotFoundException When the secret is not the user's own + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-owner-updates-reach-the-remote-copy-and-revocation-removes-it + */ + public function listForSecret(string $secretId, string $userId): array { + $this->ownedSecret(secretId: $secretId, userId: $userId); + + return $this->shareMapper->findBySourceSecret(sourceSecretId: $secretId); + }//end listForSecret() + + /** + * Answer the recipient server's pull of `/ocm/keepiq/shares/{id}`, or null + * for the unknown answer. + * + * @param string|null $signer The verified signer, null when unsigned + * @param string $shareId The share id from the path + * @param array $payload The request payload, `{sharedSecret}` + * + * @return array|null + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-federated-shares-carry-only-browser-made-ciphertext + */ + public function answerPull(?string $signer, string $shareId, array $payload): ?array { + $sharedSecret = $payload['sharedSecret'] ?? null; + if ($signer === null || is_string($sharedSecret) === false || $sharedSecret === '') { + return null; + } + + try { + $row = $this->shareMapper->findById(id: $shareId); + $source = $this->secretMapper->findById($row->getSourceSecretId()); + } catch (DoesNotExistException) { + return null; + } + + // The signer must be the recipient's own partner, still allowed to + // receive from here, and the share must be live. + $partner = $this->partners->outboundPartnerForRemote(remote: $signer); + if ($partner === null || $partner->getId() !== $row->getPartnerId() + || $row->getStatus() !== FederatedShare::STATUS_ACTIVE + || hash_equals($row->getSharedSecretHash(), hash('sha256', $sharedSecret)) === false + ) { + return null; + } + + return [ + 'shareId' => $row->getId(), + 'ownerCloudId' => $this->cloudIdManager->getCloudId($row->getOwnerId(), null)->getId(), + 'recipientCloudId' => $row->getRecipientCloudId(), + 'recipientCertFingerprint' => $row->getRecipientCertFingerprint(), + 'name' => $source->getName(), + 'url' => $source->getUrl(), + 'typeId' => $source->getTypeId(), + 'key' => $row->getKey(), + 'login' => $row->getLogin(), + 'additionalFields' => $row->getAdditionalFields(), + 'updatedAt' => $row->getUpdatedAt()?->format(DATE_ATOM), + ]; + }//end answerPull() + + /** + * The user's own secret, or not found. + * + * @param string $secretId The secret + * @param string $userId The user + * + * @return Secret + * + * @throws NotFoundException When it does not exist or is someone else's + */ + private function ownedSecret(string $secretId, string $userId): Secret { + try { + $secret = $this->secretMapper->findById($secretId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Secret not found'); + } + + if ($secret->getOwnerType() !== 'user' || $secret->getOwnerId() !== $userId) { + throw new NotFoundException(message: 'Secret not found'); + } + + return $secret; + }//end ownedSecret() +}//end class diff --git a/lib/Service/FederationPartnerService.php b/lib/Service/FederationPartnerService.php new file mode 100644 index 000000000..389636e5c --- /dev/null +++ b/lib/Service/FederationPartnerService.php @@ -0,0 +1,344 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Controller\DiscoveryController; +use OCA\Keepiq\Db\FederationPartner; +use OCA\Keepiq\Db\FederationPartnerMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Http\Client\IClientService; +use OCP\IConfig; +use Ramsey\Uuid\Uuid; +use Throwable; + +/** + * Partner allowlist: add, pin, permissions, and lookups by host. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ +class FederationPartnerService { + /** + * Seconds to wait for a partner's discovery document. + * + * @var int + */ + private const DISCOVERY_TIMEOUT = 10; + + /** + * Constructor for FederationPartnerService. + * + * @param FederationPartnerMapper $partnerMapper The partner mapper + * @param IClientService $clientService HTTP client for partner discovery + * @param IConfig $config System config (allow_local_remote_servers) + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private FederationPartnerMapper $partnerMapper, + private IClientService $clientService, + private IConfig $config, + ) { + }//end __construct() + + /** + * A URL's identity as Nextcloud's signature code names a signer: + * the lowercase host, plus `:port` when the URL names a port. + * + * @param string $url A URL or a bare host + * + * @return string|null Null when there is no host + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function hostOf(string $url): ?string { + $url = trim($url); + if ($url === '') { + return null; + } + + if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $url) !== 1) { + $url = 'https://' . $url; + } + + $host = parse_url($url, PHP_URL_HOST); + if (is_string($host) === false || $host === '') { + return null; + } + + $port = parse_url($url, PHP_URL_PORT); + $host = strtolower($host); + if (is_int($port) === true) { + $host .= ':' . $port; + } + + return $host; + }//end hostOf() + + /** + * Normalise a partner base URL: https (http only where Nextcloud allows + * local remote servers), no query or fragment, no trailing slash. + * + * @param string $url The URL the administrator typed + * + * @return string + * + * @throws InvalidArgumentException When the URL is not usable + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function normaliseBaseUrl(string $url): string { + $parts = parse_url(trim($url)); + $scheme = strtolower((string)($parts['scheme'] ?? '')); + $allowHttp = $this->config->getSystemValueBool('allow_local_remote_servers', false); + if (isset($parts['host']) === false || ($scheme !== 'https' && ($scheme !== 'http' || $allowHttp === false))) { + throw new InvalidArgumentException('The partner address must be an https URL'); + } + + if (isset($parts['user']) === true || isset($parts['query']) === true || isset($parts['fragment']) === true) { + throw new InvalidArgumentException('The partner address must not carry credentials, a query or a fragment'); + } + + $base = $scheme . '://' . strtolower($parts['host']); + if (isset($parts['port']) === true) { + $base .= ':' . $parts['port']; + } + + return $base . rtrim((string)($parts['path'] ?? ''), '/'); + }//end normaliseBaseUrl() + + /** + * Read a partner's discovery document and its federation block. + * + * @param string $url The partner address + * + * @return array{baseUrl:string,host:string,rootFingerprint:string} + * + * @throws InvalidArgumentException When the partner cannot federate + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function preview(string $url): array { + $baseUrl = $this->normaliseBaseUrl(url: $url); + try { + $response = $this->clientService->newClient()->get( + $baseUrl . '/index.php/apps/keepiq' . DiscoveryController::CANONICAL_DISCOVERY_PATH, + ['timeout' => self::DISCOVERY_TIMEOUT, 'headers' => ['Accept' => 'application/json']] + ); + $document = json_decode((string)$response->getBody(), true); + } catch (Throwable) { + throw new InvalidArgumentException('The partner could not be reached'); + } + + $federation = null; + if (is_array($document) === true) { + $federation = ($document['federation'] ?? null); + } + + $fingerprint = null; + if (is_array($federation) === true) { + $fingerprint = ($federation['rootFingerprint'] ?? null); + } + + if (is_array($federation) === false || ($federation['enabled'] ?? false) !== true + || is_string($fingerprint) === false || preg_match('/^[0-9a-f]{64}$/', $fingerprint) !== 1 + ) { + throw new InvalidArgumentException('The partner does not offer Keepiq federation'); + } + + return [ + 'baseUrl' => $baseUrl, + 'host' => (string)$this->hostOf(url: $baseUrl), + 'rootFingerprint' => $fingerprint, + ]; + }//end preview() + + /** + * Add a partner. The fingerprint the administrator confirmed must still be + * the one the partner publishes, so a change between preview and save is + * caught. + * + * @param string $url The partner address + * @param string $confirmedFingerprint The fingerprint the administrator compared + * @param bool $allowOutbound Whether users here may share to the partner + * @param bool $allowInbound Whether the partner may look up and deliver here + * @param string $adminId The administrator + * + * @return FederationPartner + * + * @throws InvalidArgumentException When the partner is invalid, known, or its fingerprint changed + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function add( + string $url, + string $confirmedFingerprint, + bool $allowOutbound, + bool $allowInbound, + string $adminId, + ): FederationPartner { + $preview = $this->preview(url: $url); + if (hash_equals($preview['rootFingerprint'], strtolower(trim($confirmedFingerprint))) === false) { + throw new InvalidArgumentException('The partner root fingerprint is not the one you confirmed'); + } + + if ($this->findByHost(host: $preview['host']) !== null) { + throw new InvalidArgumentException('This partner is already added'); + } + + $partner = new FederationPartner(); + $partner->setId(Uuid::uuid4()->toString()); + $partner->setBaseUrl($preview['baseUrl']); + $partner->setHost($preview['host']); + $partner->setRootFingerprint($preview['rootFingerprint']); + $partner->setAllowOutbound($allowOutbound); + $partner->setAllowInbound($allowInbound); + $partner->setAddedBy($adminId); + $partner->setAddedAt(new DateTime()); + + return $this->partnerMapper->insert(entity: $partner); + }//end add() + + /** + * Change a partner's directions. + * + * @param string $id The partner UUID + * @param bool $allowOutbound Whether users here may share to the partner + * @param bool $allowInbound Whether the partner may look up and deliver here + * + * @return FederationPartner + * + * @throws DoesNotExistException When the partner does not exist + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function update(string $id, bool $allowOutbound, bool $allowInbound): FederationPartner { + $partner = $this->partnerMapper->findById(id: $id); + $partner->setAllowOutbound($allowOutbound); + $partner->setAllowInbound($allowInbound); + + return $this->partnerMapper->update(entity: $partner); + }//end update() + + /** + * Remove a partner. + * + * @param string $id The partner UUID + * + * @return void + * + * @throws DoesNotExistException When the partner does not exist + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function remove(string $id): void { + $this->partnerMapper->delete(entity: $this->partnerMapper->findById(id: $id)); + }//end remove() + + /** + * Every partner. + * + * @return FederationPartner[] + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function all(): array { + return $this->partnerMapper->findAllPartners(); + }//end all() + + /** + * Whether any partner exists; with none, federation is off. + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function hasAny(): bool { + return $this->partnerMapper->findAllPartners() !== []; + }//end hasAny() + + /** + * The partner a verified OCM signer belongs to, when it may call in. + * + * @param string|null $signer The signer origin from OCMEndpointRequestEvent::getRemote(), null when unsigned + * + * @return FederationPartner|null Null for an unsigned call, a stranger, or a partner without inbound + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function inboundPartnerForSigner(?string $signer): ?FederationPartner { + if ($signer === null) { + return null; + } + + $partner = $this->findByHost(host: (string)$this->hostOf(url: $signer)); + if ($partner === null || $partner->getAllowInbound() !== true) { + return null; + } + + return $partner; + }//end inboundPartnerForSigner() + + /** + * The partner users here may share to, by the host of a cloud id's remote. + * + * @param string $remote The cloud id's remote (URL or host) + * + * @return FederationPartner|null Null when that host is no outbound partner + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function outboundPartnerForRemote(string $remote): ?FederationPartner { + $partner = $this->findByHost(host: (string)$this->hostOf(url: $remote)); + if ($partner === null || $partner->getAllowOutbound() !== true) { + return null; + } + + return $partner; + }//end outboundPartnerForRemote() + + /** + * A partner by host, or null. + * + * @param string $host The host identity + * + * @return FederationPartner|null + */ + private function findByHost(string $host): ?FederationPartner { + if ($host === '') { + return null; + } + + try { + return $this->partnerMapper->findByHost(host: $host); + } catch (DoesNotExistException) { + return null; + } + }//end findByHost() +}//end class diff --git a/lib/Service/FederationRootService.php b/lib/Service/FederationRootService.php new file mode 100644 index 000000000..85cc6f5d4 --- /dev/null +++ b/lib/Service/FederationRootService.php @@ -0,0 +1,116 @@ + + * @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\Service; + +use OCA\Keepiq\Db\CACertificateMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\OCM\Events\OCMEndpointRequestEvent; + +/** + * The local root fingerprint and CA chain for federation. + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ +class FederationRootService { + /** + * Constructor for FederationRootService. + * + * @param CACertificateMapper $caMapper The CA certificate mapper + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private CACertificateMapper $caMapper, + ) { + }//end __construct() + + /** + * Whether this Nextcloud can federate: the OCM endpoint event arrived in 33. + * + * @return bool + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function isSupported(): bool { + return class_exists(OCMEndpointRequestEvent::class); + }//end isSupported() + + /** + * Lowercase hex SHA-256 over a PEM certificate's DER bytes. + * + * @param string $pem The PEM certificate + * + * @return string|null Null when the PEM holds no certificate + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function fingerprint(string $pem): ?string { + if (preg_match('/-----BEGIN CERTIFICATE-----(.+?)-----END CERTIFICATE-----/s', $pem, $match) !== 1) { + return null; + } + + $der = base64_decode(preg_replace('/\s+/', '', $match[1]) ?? '', true); + if ($der === false || $der === '') { + return null; + } + + return hash('sha256', $der); + }//end fingerprint() + + /** + * The fingerprint of this instance's Keepiq root, or null before the CA exists. + * + * @return string|null + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-administrators-approve-and-pin-partner-instances + */ + public function localRootFingerprint(): ?string { + try { + return $this->fingerprint(pem: $this->caMapper->findRoot()->getCertificate()); + } catch (DoesNotExistException) { + return null; + } + }//end localRootFingerprint() + + /** + * The CA chain above a user certificate: the active intermediate, then the root. + * + * @return array PEM certificates, empty before the CA exists + * + * @spec openspec/specs/federated-sharing/spec.md#requirement-certificate-lookup-is-signed-allowlisted-and-verified-in-the-browser + */ + public function localChain(): array { + try { + return [ + $this->caMapper->findActiveIntermediate()->getCertificate(), + $this->caMapper->findRoot()->getCertificate(), + ]; + } catch (DoesNotExistException) { + return []; + } + }//end localChain() +}//end class diff --git a/lib/Service/GdprService.php b/lib/Service/GdprService.php index 7de99143b..b82b3c395 100644 --- a/lib/Service/GdprService.php +++ b/lib/Service/GdprService.php @@ -40,6 +40,11 @@ /** * Assembles the server-readable half of a GDPR personal-data export. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The export reads every table + * that holds the subject's data; each mapper is one section of the package. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Same reason: one mapper per + * section, injected by the container. */ class GdprService { /** @@ -68,6 +73,7 @@ class GdprService { * @param SettingsService $settingsService The settings service * @param \OCA\Keepiq\Db\AttachmentMapper|null $attachmentMapper The attachment mapper (export metadata) * @param \OCA\Keepiq\Db\AttachmentGrantMapper|null $grantMapper The attachment-grant mapper (export metadata) + * @param \OCA\Keepiq\Db\SecretTagMapper|null $tagMapper The tag mapper (export metadata) * * @return void */ @@ -81,6 +87,7 @@ public function __construct( private SettingsService $settingsService, private ?\OCA\Keepiq\Db\AttachmentMapper $attachmentMapper = null, private ?\OCA\Keepiq\Db\AttachmentGrantMapper $grantMapper = null, + private ?\OCA\Keepiq\Db\SecretTagMapper $tagMapper = null, ) { }//end __construct() @@ -97,7 +104,7 @@ public function __construct( * * @return array The versioned, self-describing metadata document * - * @spec openspec/changes/secret-export-gdpr/specs/gdpr-compliance/spec.md + * @spec openspec/specs/gdpr-compliance/spec.md */ public function collectMetadata(string $userId): array { $ownedSecrets = $this->secretMapper->findByOwner( @@ -128,9 +135,47 @@ public function collectMetadata(string $userId): array { 'requests' => $this->collectRequests(userId: $userId), 'settings' => $this->settingsService->getUserPreferences(userId: $userId), 'attachments' => $this->collectAttachments(ownedSecrets: $ownedSecrets, userId: $userId), + 'organisation' => $this->collectOrganisation(ownedSecrets: $ownedSecrets, userId: $userId), ]; }//end collectMetadata() + /** + * The subject's stars, tags and last-used times on their own rows + * (vault-favourites-tags-and-last-used), one entry per row that carries + * any of them. + * + * @param array $ownedSecrets The subject's secrets + * @param string $userId The subject + * + * @return list, lastUsedAt: string|null}> + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + private function collectOrganisation(array $ownedSecrets, string $userId): array { + $tags = []; + if ($this->tagMapper !== null && $ownedSecrets !== []) { + $tags = $this->tagMapper->findTagsBySecretIds( + $userId, + array_map(static fn (\OCA\Keepiq\Db\Secret $secret): string => (string)$secret->getId(), $ownedSecrets) + ); + } + + $entries = []; + foreach ($ownedSecrets as $secret) { + $entry = [ + 'secretId' => (string)$secret->getId(), + 'favourite' => ($secret->getIsFavourite() === true), + 'tags' => ($tags[(string)$secret->getId()] ?? []), + 'lastUsedAt' => $secret->getLastUsedAt()?->format('c'), + ]; + if ($entry['favourite'] === true || $entry['tags'] !== [] || $entry['lastUsedAt'] !== null) { + $entries[] = $entry; + } + } + + return $entries; + }//end collectOrganisation() + /** * Collect attachment records for the subject's own secrets: metadata * (encrypted filename blob, ciphertext size) plus the subject's own diff --git a/lib/Service/GroupShareService.php b/lib/Service/GroupShareService.php index 23b58ac6e..35b178a7c 100644 --- a/lib/Service/GroupShareService.php +++ b/lib/Service/GroupShareService.php @@ -40,6 +40,7 @@ use OCA\Keepiq\Db\ShareTargetMapper; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IGroupManager; +use OCP\Share\IManager as IShareManager; use Psr\Log\LoggerInterface; use Ramsey\Uuid\Uuid; @@ -50,6 +51,9 @@ * through five mappers + IGroupManager + the share/notification helpers * so the group-fan-out flow lives in one place; splitting it would * scatter the invariants over four classes. + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor DI list: the + * Nextcloud share settings and the per-share revocation path joined the + * group-share flow (sharing-02); each is a single collaborator, not options. */ class GroupShareService { /** @@ -64,6 +68,9 @@ class GroupShareService { * @param IGroupManager $groupManager The Nextcloud group manager * @param NotificationService $notificationService The notification dispatcher * @param LoggerInterface $logger The logger + * @param IShareManager $shareManager Nextcloud's share settings (group sharing on, own groups only) + * @param ShareRevocationService $revocationService Revokes one member's share and deletes its copy + * @param ShareRestrictionResolver|null $restrictions Materialises use-only and end dates onto copies * * @return void */ @@ -77,6 +84,9 @@ public function __construct( private IGroupManager $groupManager, private NotificationService $notificationService, private LoggerInterface $logger, + private IShareManager $shareManager, + private ShareRevocationService $revocationService, + private ?ShareRestrictionResolver $restrictions = null, ) { }//end __construct() @@ -90,26 +100,40 @@ public function __construct( * @param string $secretId The source Secret ID * @param string $groupId The Nextcloud group ID * @param string $userId The initiator (must be owner or delegate) + * @param ShareRestriction|null $restriction Use-only and end date for every member * - * @return array{groupShare:GroupShare,members:array} + * `skipped` counts the members (owner excluded) left out for want of an + * active EncryptionSuite, so the sharer can be told who did not get it. + * + * @return array{groupShare:GroupShare,members:array,skipped:int} * * @throws InvalidArgumentException On unauthorized / missing secret / empty group * * @spec openspec/changes/implement-user-sharing/tasks.md#4.2 + * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 */ - public function createGroupShare(string $secretId, string $groupId, string $userId): array { + public function createGroupShare( + string $secretId, + string $groupId, + string $userId, + ?ShareRestriction $restriction = null, + ): array { if ($groupId === '') { throw new InvalidArgumentException(message: 'groupId is required'); } $secret = $this->loadSecret(secretId: $secretId); $this->assertOwnerOrDelegate(secret: $secret, userId: $userId); + $secret->assertOnwardShareable(); $group = $this->groupManager->get($groupId); if ($group === null) { throw new InvalidArgumentException(message: 'Group not found'); } + $this->assertGroupShareable(groupId: $groupId, userId: $userId); + // Idempotency — one GroupShare per (secret, group). Surface the // existing one with a fresh member list rather than persisting a // duplicate. @@ -126,10 +150,21 @@ public function createGroupShare(string $secretId, string $groupId, string $user $row->setGroupId($groupId); $row->setCreatedBy($userId); $row->setCreatedAt(new DateTime()); + $row->setUseOnly(($restriction?->useOnly === true)); + $row->setExpiresAt($restriction?->expiresAt); $existing = $this->mapper->insert($row); + } elseif ($restriction !== null) { + // Sharing again with the same group changes its restriction. + $existing->setUseOnly($restriction->useOnly); + $existing->setExpiresAt($restriction->expiresAt); + $existing = $this->mapper->update($existing); + $this->restrictions?->resolveTargets( + targets: $this->bulkGrantMapper->findByGroupShare(groupShareId: $existing->getId()) + ); } $members = []; + $skipped = 0; foreach ($group->getUsers() as $user) { $candidateId = $user->getUID(); if ($candidateId === $secret->getOwnerId()) { @@ -142,6 +177,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user ownerId: $candidateId ); } catch (DoesNotExistException) { + $skipped++; continue; } @@ -154,12 +190,42 @@ public function createGroupShare(string $secretId, string $groupId, string $user return [ 'groupShare' => $existing, 'members' => $members, + 'skipped' => $skipped, ]; }//end createGroupShare() /** - * Revoke a group share — cascade-deletes every ShareTarget that was - * fanned out from it, then deletes the GroupShare row itself. + * Apply Nextcloud's own share settings to a group share: group sharing + * must be on, and when sharing is restricted to the sharer's own groups + * the sharer must be a member. A group outside that reach is reported as + * "Group not found", the same answer a missing group gets, so the reply + * does not confirm that a group the caller cannot see exists. + * + * @param string $groupId The target group + * @param string $userId The sharer + * + * @return void + * + * @throws InvalidArgumentException When the group is out of the sharer's reach + * + * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group + */ + private function assertGroupShareable(string $groupId, string $userId): void { + if ($this->shareManager->allowGroupSharing() === false) { + throw new InvalidArgumentException(message: 'Group sharing is disabled'); + } + + if ($this->shareManager->shareWithGroupMembersOnly() === true + && $this->groupManager->isInGroup($userId, $groupId) === false + ) { + throw new InvalidArgumentException(message: 'Group not found'); + } + }//end assertGroupShareable() + + /** + * Revoke a group share: revoke every member share fanned out from it + * through the share revocation path (which deletes the member's Secret + * copy, not only the ShareTarget row), then delete the GroupShare row. * * @param string $groupShareId The GroupShare row ID * @param string $userId The Nextcloud user requesting the revoke @@ -169,6 +235,7 @@ public function createGroupShare(string $secretId, string $groupId, string $user * @throws InvalidArgumentException On unauthorized / not found * * @spec openspec/changes/implement-user-sharing/tasks.md#4.3 + * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group */ public function revokeGroupShare(string $groupShareId, string $userId): void { try { @@ -180,6 +247,12 @@ public function revokeGroupShare(string $groupShareId, string $userId): void { $secret = $this->loadSecret(secretId: $entity->getSecretId()); $this->assertOwnerOrDelegate(secret: $secret, userId: $userId); + foreach ($this->bulkGrantMapper->findByGroupShare(groupShareId: $groupShareId) as $target) { + $this->revocationService->revokeShare(shareId: $target->getId(), userId: $userId); + } + + // Anything the per-share revoke could not reach (a row whose source + // moved) still goes with the group share. $this->bulkGrantMapper->deleteByGroupShare(groupShareId: $groupShareId); $this->mapper->delete($entity); @@ -220,6 +293,8 @@ public function getGroupSharesForSecret(string $secretId, string $userId): array * @param string $groupId The Nextcloud group ID * * @return string[] List of user IDs + * + * @spec openspec/specs/sharing-group/spec.md#requirement-share-with-a-group */ public function getGroupMembers(string $groupId): array { $group = $this->groupManager->get($groupId); @@ -327,7 +402,8 @@ public function approveGroupMemberShare( $shareTarget->setGroupShareId($groupShareId); $shareTarget->setCreatedBy($userId); $shareTarget->setCreatedAt(new DateTime()); - $this->shareTargetMapper->insert($shareTarget); + $persisted = $this->shareTargetMapper->insert($shareTarget); + $this->restrictions?->resolveTarget(target: $persisted); $this->notificationService->notify( subject: 'secret_shared', diff --git a/lib/Service/HoneyCredentialService.php b/lib/Service/HoneyCredentialService.php index 7857e01d5..ef2784ed5 100644 --- a/lib/Service/HoneyCredentialService.php +++ b/lib/Service/HoneyCredentialService.php @@ -74,6 +74,8 @@ public function __construct( * * @throws DoesNotExistException When the secret does not exist * @throws InvalidArgumentException When the caller may not flag it + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ public function flag(string $secretId, string $actorId, bool $isAdmin, ?string $note = null): HoneyFlag { $secret = $this->secretMapper->findById($secretId); @@ -112,6 +114,8 @@ public function flag(string $secretId, string $actorId, bool $isAdmin, ?string $ * * @throws DoesNotExistException When the secret is not flagged * @throws InvalidArgumentException When the caller may not unflag it + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ public function unflag(string $secretId, string $actorId, bool $isAdmin): void { $flag = $this->flagMapper->findBySecretId($secretId); @@ -132,6 +136,8 @@ public function unflag(string $secretId, string $actorId, bool $isAdmin): void { * @return HoneyFlag|null * * @throws InvalidArgumentException When the caller may not see it + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ public function getFlag(string $secretId, string $actorId, bool $isAdmin): ?HoneyFlag { try { @@ -154,6 +160,8 @@ public function getFlag(string $secretId, string $actorId, bool $isAdmin): ?Hone * @param bool $isAdmin Whether the caller is an admin * * @return HoneyAlert[] + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-honey-flag-is-owner-admin-only-and-invisible-to-others */ public function listAlerts(string $actorId, bool $isAdmin): array { if ($isAdmin === true) { @@ -179,6 +187,8 @@ public function listAlerts(string $actorId, bool $isAdmin): array { * * @throws DoesNotExistException When the alert is missing * @throws InvalidArgumentException When the caller may not act on it + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-alert-storms-are-rate-limited-and-per-accessor-snoozable */ public function acknowledge(string $alertId, string $actorId, bool $isAdmin): HoneyAlert { $alert = $this->guardedAlert(alertId: $alertId, actorId: $actorId, isAdmin: $isAdmin); @@ -201,6 +211,8 @@ public function acknowledge(string $alertId, string $actorId, bool $isAdmin): Ho * * @throws DoesNotExistException When the alert is missing * @throws InvalidArgumentException When the caller may not act on it + * + * @spec openspec/specs/honey-credentials/spec.md#requirement-alert-storms-are-rate-limited-and-per-accessor-snoozable */ public function snooze(string $alertId, string $actorId, bool $isAdmin, int $hours = 24): HoneyAlert { $alert = $this->guardedAlert(alertId: $alertId, actorId: $actorId, isAdmin: $isAdmin); diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 58355063c..51957abb5 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -56,6 +56,13 @@ class ImportService { */ public const MAX_FIELD_LENGTH = 4096; + /** + * Maximum character length of a name: the width of the `name` column. + * + * @var int + */ + public const MAX_NAME_LENGTH = 255; + /** * Maximum byte length of a single ciphertext blob (RSA-expanded). * @@ -95,7 +102,7 @@ public function __construct( * @throws SuiteBlockedException When the user has no active EncryptionSuite * @throws InvalidArgumentException When the chunk exceeds the item cap * - * @spec openspec/changes/secret-import/specs/secret-import/spec.md#requirement-chunked-batch-commit + * @spec openspec/specs/secret-import/spec.md#requirement-chunked-batch-commit * @spec openspec/changes/add-totp-secrets/specs/secrets/spec.md#requirement-secret-types */ public function commitChunk(array $items, string $userId): array { @@ -183,6 +190,8 @@ public function commitChunk(array $items, string $userId): array { * @return void * * @throws InvalidArgumentException When the item is invalid + * + * @spec openspec/changes/clients-extension-gaps/specs/item-name-limit/spec.md#requirement-a-name-has-at-most-255-characters */ private function validateItem(array $item): void { $name = trim((string)($item['name'] ?? '')); @@ -190,8 +199,10 @@ private function validateItem(array $item): void { throw new InvalidArgumentException('Missing name'); } - if (strlen($name) > self::MAX_FIELD_LENGTH) { - throw new InvalidArgumentException('Name exceeds the maximum length'); + // The name column holds 255 characters; a longer name would fail at + // the database instead of here. + if (mb_strlen($name) > self::MAX_NAME_LENGTH) { + throw new InvalidArgumentException('Name exceeds the maximum length of 255 characters'); } if (isset($item['url']) === true && strlen((string)$item['url']) > self::MAX_FIELD_LENGTH) { diff --git a/lib/Service/JwtAuthService.php b/lib/Service/JwtAuthService.php index b420288be..16a1a725f 100644 --- a/lib/Service/JwtAuthService.php +++ b/lib/Service/JwtAuthService.php @@ -284,6 +284,8 @@ private function issueAccessToken(Application $application): array { * @return Application|null The bound application, or null when the * token is unknown, expired, or the * application is no longer active. + * + * @spec openspec/specs/secret-store-api/spec.md#requirement-strict-own-vault-scoping */ public function validateAccessToken(string $accessToken): ?Application { if ($accessToken === '') { diff --git a/lib/Service/KeyGeneratorRegexParser.php b/lib/Service/KeyGeneratorRegexParser.php index e38a2e9c9..caaf56ce7 100644 --- a/lib/Service/KeyGeneratorRegexParser.php +++ b/lib/Service/KeyGeneratorRegexParser.php @@ -62,6 +62,8 @@ class KeyGeneratorRegexParser { * @param string $pattern The raw regex pattern * * @return string The delimited pattern + * + * @spec openspec/specs/key-generator/spec.md#requirement-regex-override */ public function delimit(string $pattern): string { if ($pattern === '') { @@ -87,6 +89,8 @@ public function delimit(string $pattern): string { * @return void * * @throws InvalidArgumentException When the pattern is invalid + * + * @spec openspec/specs/key-generator/spec.md#requirement-regex-override */ public function assertValid(string $delimited): void { set_error_handler( @@ -145,6 +149,8 @@ public function extractLength(string $regex): array { * @return string The resolved character set (each char unique) * * @throws InvalidArgumentException When no character class can be determined + * + * @spec openspec/specs/key-generator/spec.md#requirement-regex-override */ public function extractCharset(string $regex): string { if (preg_match('/\[(\^?)((?:\\\\.|[^\]\\\\])*)\]/', $regex, $matches) !== 1) { diff --git a/lib/Service/KeyGeneratorService.php b/lib/Service/KeyGeneratorService.php index 9dda2196b..95526a36b 100644 --- a/lib/Service/KeyGeneratorService.php +++ b/lib/Service/KeyGeneratorService.php @@ -175,6 +175,8 @@ private function requiredClasses(array $policy): array { * @SuppressWarnings(PHPMD.LongVariable) Same reason: the name is the wire field name * posted by src/dialogs/KeyGeneratorModal.vue and is called out above as part of * the public API contract, so it is not free to shorten. + * + * @spec openspec/specs/key-generator/spec.md#requirement-default-generation */ public function generate( int $length = 16, diff --git a/lib/Service/KnownShareRecipientExemption.php b/lib/Service/KnownShareRecipientExemption.php new file mode 100644 index 000000000..29e347aec --- /dev/null +++ b/lib/Service/KnownShareRecipientExemption.php @@ -0,0 +1,72 @@ + + * @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\Db\ShareTargetMapper; +use OCP\IRequest; +use Throwable; + +/** + * A share to a recipient the caller already shares with directly needs no proof. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ +class KnownShareRecipientExemption implements VaultKeyProofExemption { + /** + * Constructor. + * + * @param ShareTargetMapper $shareTargets The share-target mapper + * + * @return void + */ + public function __construct( + private ShareTargetMapper $shareTargets, + ) { + }//end __construct() + + /** + * Exempt when the request's `targetUserId` already receives a direct share + * from the caller. A missing recipient, or a failing lookup, is not exempt. + * + * @param IRequest $request The incoming request + * @param string $userId The acting user + * + * @return bool + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + public function exempts(IRequest $request, string $userId): bool { + $targetUserId = (string)$request->getParam('targetUserId', ''); + if ($targetUserId === '' || $targetUserId === $userId) { + return false; + } + + try { + return $this->shareTargets->hasDirectShareBetween(createdBy: $userId, targetUserId: $targetUserId); + } catch (Throwable) { + return false; + } + }//end exempts() +}//end class diff --git a/lib/Service/LeasePolicyService.php b/lib/Service/LeasePolicyService.php index 00fc98e61..b376c820e 100644 --- a/lib/Service/LeasePolicyService.php +++ b/lib/Service/LeasePolicyService.php @@ -92,6 +92,47 @@ public function effectivePolicy(string $applicationId): array { ]; }//end effectivePolicy() + /** + * The instance-wide lease policy, before any per-application override. + * + * @return array{defaultTtl:int, maxTtl:int, renewable:bool} + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-admin-lease-ttl-policy + */ + public function instancePolicy(): array { + $appId = Application::APP_ID; + + return [ + 'defaultTtl' => max(60, $this->appConfig->getValueInt($appId, 'lease_default_ttl_seconds', 900)), + 'maxTtl' => max(60, $this->appConfig->getValueInt($appId, 'lease_max_ttl_seconds', 86400)), + 'renewable' => $this->appConfig->getValueBool($appId, 'lease_renewable', true), + ]; + }//end instancePolicy() + + /** + * The stored per-application override, field by field. A null field + * inherits the instance value; no override row reads as all null. + * + * @param string $applicationId The application id + * + * @return array{defaultTtl:int|null, maxTtl:int|null, renewable:bool|null} + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-admin-lease-ttl-policy + */ + public function overrideFor(string $applicationId): array { + try { + $override = $this->policyMapper->findByApplication(applicationId: $applicationId); + } catch (DoesNotExistException) { + return ['defaultTtl' => null, 'maxTtl' => null, 'renewable' => null]; + } + + return [ + 'defaultTtl' => $override->getDefaultTtlSeconds(), + 'maxTtl' => $override->getMaxTtlSeconds(), + 'renewable' => $override->getRenewable(), + ]; + }//end overrideFor() + /** * Store a per-application policy override (admin surface). * diff --git a/lib/Service/LeaseService.php b/lib/Service/LeaseService.php index 54d918af8..d7e6cd477 100644 --- a/lib/Service/LeaseService.php +++ b/lib/Service/LeaseService.php @@ -27,7 +27,6 @@ use DateInterval; use DateTime; -use InvalidArgumentException; use OCA\Keepiq\Db\MachineLease; use OCA\Keepiq\Db\MachineLeaseMapper; use OCA\Keepiq\Event\Audit\AuditEventFactory; @@ -75,6 +74,25 @@ public function effectivePolicy(string $applicationId): array { return $this->policyService->effectivePolicy(applicationId: $applicationId); }//end effectivePolicy() + /** + * The lease policy of one application as an admin form needs it: what is + * in force, the stored override (null fields inherit), and the instance + * values those nulls fall back to. + * + * @param string $applicationId The application id + * + * @return array{effective: array, override: array, instance: array} + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-admin-lease-ttl-policy + */ + public function policyView(string $applicationId): array { + return [ + 'effective' => $this->policyService->effectivePolicy(applicationId: $applicationId), + 'override' => $this->policyService->overrideFor(applicationId: $applicationId), + 'instance' => $this->policyService->instancePolicy(), + ]; + }//end policyView() + /** * Grant a lease on fetch, or reuse the live one WITHOUT extending it * (a repeat poll must not creep the expiry; machine-secret-leases @@ -121,61 +139,6 @@ public function grantOrReuse(string $applicationId, string $secretId, ?int $requ return $lease; }//end grantOrReuse() - /** - * Renew a lease: extend to `min(now + default TTL, granted_at + max - * TTL)`. Refused past max, when non-renewable, or when the lease is - * not active. Cross-application access throws the SAME not-found as - * a nonexistent lease. - * - * @param string $leaseId The lease UUID - * @param string $applicationId The calling application (must own the lease) - * - * @return MachineLease - * - * @throws DoesNotExistException When the lease is missing or foreign - * @throws InvalidArgumentException When renewal is refused - * - * @spec openspec/changes/machine-secret-leases/specs/machine-secret-leases/spec.md#requirement-lease-renewal - */ - public function renew(string $leaseId, string $applicationId): MachineLease { - $lease = $this->loadOwned(leaseId: $leaseId, applicationId: $applicationId); - if ($lease->getStatus() !== 'active') { - throw new InvalidArgumentException('Lease is not active'); - } - - $policy = $this->effectivePolicy(applicationId: $applicationId); - if ($policy['renewable'] === false) { - throw new InvalidArgumentException('Leases are not renewable for this application'); - } - - $now = new DateTime(); - $granted = $lease->getGrantedAt() ?? $now; - $hardCap = (clone $granted)->add(new DateInterval('PT' . $policy['maxTtl'] . 'S')); - $target = (clone $now)->add(new DateInterval('PT' . $policy['defaultTtl'] . 'S')); - if ($target > $hardCap) { - $target = $hardCap; - } - - $current = $lease->getExpiresAt(); - if ($current !== null && $target <= $current) { - throw new InvalidArgumentException('Lease has reached its maximum lifetime'); - } - - $lease->setExpiresAt($target); - $lease->setRenewedCount($lease->getRenewedCount() + 1); - $lease->setLastRenewedAt($now); - $lease = $this->leaseMapper->update($lease); - - $this->dispatchAudit( - actorId: $applicationId, - eventType: AuditEventTypes::LEASE_RENEWED, - lease: $lease, - extra: ['renewedCount' => $lease->getRenewedCount()], - ); - - return $lease; - }//end renew() - /** * Revoke a lease (admin / owner / holding application) and raise a * rotation flag on the leased secret — a revocation implies the @@ -220,6 +183,8 @@ public function revoke(MachineLease $lease, string $actor): MachineLease { * @return MachineLease * * @throws DoesNotExistException When missing or foreign + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-lease-revocation-by-admin-owner-or-application */ public function loadOwned(string $leaseId, string $applicationId): MachineLease { $lease = $this->leaseMapper->findById($leaseId); @@ -240,6 +205,8 @@ public function loadOwned(string $leaseId, string $applicationId): MachineLease * @param string $secretId The fetched secret * * @return bool + * + * @spec openspec/specs/machine-secret-leases/spec.md#scenario-block-on-revoke-refuses-re-fetch-when-enabled */ public function fetchBlocked(string $applicationId, string $secretId): bool { $policy = $this->effectivePolicy(applicationId: $applicationId); @@ -284,6 +251,8 @@ public function expireDue(): int { * @param string $applicationId The application id * * @return MachineLease[] + * + * @spec openspec/specs/machine-secret-leases/spec.md#requirement-lease-revocation-by-admin-owner-or-application */ public function listForApplication(string $applicationId): array { return $this->leaseMapper->findByApplication(applicationId: $applicationId); diff --git a/lib/Service/LinkShareService.php b/lib/Service/LinkShareService.php index dc7cba680..65aaabea1 100644 --- a/lib/Service/LinkShareService.php +++ b/lib/Service/LinkShareService.php @@ -72,6 +72,7 @@ class LinkShareService { * @param LinkShareMapper $mapper The link share mapper * @param LoggerInterface $logger The logger interface * @param WriteLockService $writeLockService The compromise-recovery write lock + * @param ShareAuthorizationService $shareAuth Who may re-share a secret (keepiq#214) * @param LinkShareAuditTrail|null $auditTrail The link-share audit trail * * @return void @@ -80,16 +81,22 @@ public function __construct( private LinkShareMapper $mapper, private LoggerInterface $logger, private WriteLockService $writeLockService, + private ShareAuthorizationService $shareAuth, ?LinkShareAuditTrail $auditTrail = null, ) { $this->auditTrail = ($auditTrail ?? new LinkShareAuditTrail()); }//end __construct() /** - * Create a link share for a secret owned by the given user. - * - * The caller (controller) is responsible for confirming the user owns - * the secret and for resolving the user's active encryption suite ID. + * Create a link share for a secret the given user may re-share. + * + * A public link widens the audience beyond what the owner chose, so only + * the secret's owner, or a recipient whose share permits re-sharing (an + * active delegate, who holds share management rights), may create one + * (keepiq#214). Anyone else gets the same DoesNotExistException as a missing + * secret (from ShareAuthorizationService::assertMayReshare()), so the + * check does not reveal which secrets exist. The controller + * resolves the user's active encryption suite ID. * Ownership of the resulting link share is recorded in created_by, which * is the sole authority used by delete()/listBySecret() — this keeps the * feature self-contained and IDOR-safe. @@ -105,8 +112,11 @@ public function __construct( * @return LinkShare * * @throws InvalidArgumentException When validation fails + * @throws DoesNotExistException When the user may not re-share the secret + * @throws RuntimeException A ForbiddenException when the secret is a read-only copy from another organisation * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-3.4 + * @spec openspec/specs/link-sharing/spec.md#requirement-who-may-create-a-link-share */ public function create( string $secretId, @@ -133,6 +143,8 @@ public function create( ); } + $this->shareAuth->assertMayReshare(secretId: $secretId, userId: $userId); + $linkShare = new LinkShare(); $linkShare->setId(Uuid::uuid4()->toString()); $linkShare->setSecretId($secretId); @@ -182,6 +194,8 @@ public function create( * @return LinkShare * * @throws RuntimeException When the token is invalid, expired, or exhausted + * + * @spec openspec/specs/link-sharing/spec.md#requirement-access-via-link */ public function getByToken(string $token): LinkShare { try { @@ -292,6 +306,8 @@ public function recordFailedAttempt(string $token): void { * @param string $userId The requesting Nextcloud user ID * * @return LinkShare[] + * + * @spec openspec/specs/link-sharing/spec.md#requirement-multiple-concurrent-link-shares */ public function listBySecret(string $secretId, string $userId): array { $shares = $this->mapper->findBySecretId($secretId); diff --git a/lib/Service/MachineSecretEnvelopeService.php b/lib/Service/MachineSecretEnvelopeService.php index 38345b784..ec2e24f50 100644 --- a/lib/Service/MachineSecretEnvelopeService.php +++ b/lib/Service/MachineSecretEnvelopeService.php @@ -115,7 +115,7 @@ public function __construct( * Serialize a secret into the `doriath-machine-secret-v1` envelope. * * Returns plaintext-safe metadata (id, name, url, derived folder path, - * type, timestamps), an `encryption` block (suite id, sha256 + * type, timestamps, expiry date), an `encryption` block (suite id, sha256 * certificate fingerprint, scheme identifier), and the base64 * ciphertext fields. No decrypted value can ever be produced here — * the server holds only ciphertext. @@ -138,6 +138,9 @@ public function serialize(Secret $secret): array { 'createdAt' => $secret->getCreatedAt()?->format('c'), 'updatedAt' => $secret->getUpdatedAt()?->format('c'), 'keyUpdatedAt' => $secret->getKeyUpdatedAt()?->format('c'), + // Additive (apps-secret-sync-and-rotation-runner): lets a + // rotation runner rotate ahead of the expiry date. + 'expiresAt' => $secret->getExpiresAt()?->format('c'), ], 'encryption' => [ 'suiteId' => $secret->getEncryptionSuiteId(), @@ -203,6 +206,32 @@ public function etag(Secret $secret): string { return '"' . hash('sha256', $material) . '"'; }//end etag() + /** + * Whether an If-Match header allows a write to this secret. + * + * The header is a comma-separated list of quoted ETags, or `*`. A write is + * allowed when any listed tag is `*` or equals the secret's current ETag. + * + * @param Secret $secret The secret about to be written + * @param string $header The raw If-Match header value (not empty) + * + * @return bool True when the precondition holds + * + * @spec openspec/specs/secret-store-api/spec.md + */ + public function ifMatchHolds(Secret $secret, string $header): bool { + $current = $this->etag(secret: $secret); + foreach (explode(separator: ',', string: $header) as $candidate) { + $candidate = trim($candidate); + // Strong comparison (RFC 9110 13.1.1): a weak tag never matches. + if ($candidate === '*' || $candidate === $current) { + return true; + } + } + + return false; + }//end ifMatchHolds() + /** * Compute the sha256 fingerprint of the DER form of a suite's * certificate, prefixed `sha256:`. diff --git a/lib/Service/MemberOverviewService.php b/lib/Service/MemberOverviewService.php new file mode 100644 index 000000000..e70a63fbc --- /dev/null +++ b/lib/Service/MemberOverviewService.php @@ -0,0 +1,256 @@ + + * @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\Service; + +use InvalidArgumentException; +use OCA\Keepiq\Db\EmergencyContactMapper; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\TeamFolderMemberMapper; +use OCP\IUser; +use OCP\IUserManager; + +/** + * Lists every Nextcloud user with their Keepiq vault status. + */ +class MemberOverviewService { + /** + * The vault statuses a row can carry, and the status filter accepts. + * + * @var string[] + */ + public const STATUSES = ['none', 'active', 'revoked', 'compromised']; + + /** + * Default page size. + */ + public const DEFAULT_LIMIT = 50; + + /** + * Largest page size a caller may ask for (design risk: large instances). + */ + public const MAX_LIMIT = 200; + + /** + * How many users one scan step reads while a status filter is applied. + */ + private const SCAN_BATCH = 200; + + /** + * Constructor. + * + * @param IUserManager $userManager The Nextcloud user manager + * @param EncryptionSuiteMapper $suiteMapper Suite lookups (status and id only) + * @param SecretMapper $secretMapper Secret counts + * @param TeamFolderMemberMapper $memberMapper Direct team folder membership counts + * @param EmergencyContactMapper $contactMapper Emergency contacts in force + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IUserManager $userManager, + private EncryptionSuiteMapper $suiteMapper, + private SecretMapper $secretMapper, + private TeamFolderMemberMapper $memberMapper, + private EmergencyContactMapper $contactMapper, + ) { + }//end __construct() + + /** + * One page of the member overview. + * + * Without a status filter, the page is one user manager page. With a + * filter, users are read in batches until the page is full or the users + * run out, so `offset` counts matching rows, not users. + * + * @param string $status Vault status filter (`none`, `active`, `revoked`, `compromised`) or '' + * @param string $search Search on user id or display name, '' for everyone + * @param int $limit Page size, 1 to 200 + * @param int $offset Rows to skip, 0 or more + * + * @return array{results:array>,limit:int,offset:int,hasMore:bool} + * + * @throws InvalidArgumentException On an unknown status or an out-of-range page + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + public function list(string $status, string $search, int $limit, int $offset): array { + if ($status !== '' && in_array($status, self::STATUSES, true) === false) { + throw new InvalidArgumentException( + message: 'status must be one of: ' . implode(', ', self::STATUSES) + ); + } + + if ($limit < 1 || $limit > self::MAX_LIMIT) { + throw new InvalidArgumentException(message: 'limit must be between 1 and ' . self::MAX_LIMIT); + } + + if ($offset < 0) { + throw new InvalidArgumentException(message: 'offset must be 0 or more'); + } + + $rows = $this->pageRows(status: $status, search: $search, limit: $limit, offset: $offset); + + return [ + 'results' => array_slice($rows, 0, $limit), + 'limit' => $limit, + 'offset' => $offset, + 'hasMore' => count($rows) > $limit, + ]; + }//end list() + + /** + * The rows for one page plus one extra, which tells whether another page exists. + * + * @param string $status The vault status filter, or '' for every user + * @param string $search The user search + * @param int $limit The page size + * @param int $offset The page start + * + * @return array> + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + private function pageRows(string $status, string $search, int $limit, int $offset): array { + if ($status === '') { + return $this->resolve(users: $this->userManager->searchDisplayName($search, $limit + 1, $offset)); + } + + $rows = $this->scanForStatus(status: $status, search: $search, wanted: $offset + $limit + 1); + return array_slice($rows, $offset); + }//end pageRows() + + /** + * Read users in batches and keep the rows with the wanted status. + * + * @param string $status The vault status to keep + * @param string $search The user search + * @param int $wanted Stop once this many matching rows are found + * + * @return array> + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + private function scanForStatus(string $status, string $search, int $wanted): array { + $matched = []; + $cursor = 0; + do { + $batch = $this->userManager->searchDisplayName($search, self::SCAN_BATCH, $cursor); + foreach ($this->resolve(users: $batch) as $row) { + if ($row['vaultStatus'] === $status) { + $matched[] = $row; + } + } + + $batchSize = count($batch); + $cursor += $batchSize; + $enough = count($matched) >= $wanted; + } while ($batchSize === self::SCAN_BATCH && $enough === false); + + return $matched; + }//end scanForStatus() + + /** + * Resolve one page of users to overview rows, one query per data source. + * + * @param array $users The users of this page + * + * @return array> + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + private function resolve(array $users): array { + $userIds = []; + foreach ($users as $user) { + $userIds[] = $user->getUID(); + } + + if ($userIds === []) { + return []; + } + + $active = $this->suiteMapper->findActiveByOwners(ownerType: 'user', ownerIds: $userIds); + $inactive = $this->suiteMapper->latestInactiveStatusByOwners( + ownerType: 'user', + ownerIds: array_values(array_diff($userIds, array_keys($active))) + ); + $secretCounts = $this->secretMapper->countByUserOwners(ownerIds: $userIds); + $memberships = $this->memberMapper->countUserMemberships(userIds: $userIds); + $withContact = array_flip($this->contactMapper->grantorsWithContact(userIds: $userIds)); + + $rows = []; + foreach ($users as $user) { + $uid = $user->getUID(); + $suite = ($active[$uid] ?? null); + $rows[] = [ + 'userId' => $uid, + 'displayName' => $user->getDisplayName(), + 'enabled' => $user->isEnabled(), + 'vaultStatus' => $this->vaultStatus(hasActive: $suite !== null, inactiveStatus: ($inactive[$uid] ?? null)), + 'activeSuiteId' => $suite?->getId(), + 'suiteCreatedAt' => $suite?->getCreatedAt()?->format('c'), + 'secretCount' => ($secretCounts[$uid] ?? 0), + 'teamFolderMemberships' => ($memberships[$uid] ?? 0), + 'hasEmergencyContact' => isset($withContact[$uid]), + ]; + } + + return $rows; + }//end resolve() + + /** + * Map the suite lookups to one vault status. + * + * @param bool $hasActive Whether the user has an active suite + * @param string|null $inactiveStatus The newest non-active suite status, if any + * + * @return string One of STATUSES + * + * @spec openspec/specs/admin-member-overview/spec.md#requirement-administrator-lists-vault-status-per-user + */ + private function vaultStatus(bool $hasActive, ?string $inactiveStatus): string { + if ($hasActive === true) { + return 'active'; + } + + if ($inactiveStatus === null) { + return 'none'; + } + + if ($inactiveStatus === 'compromised') { + return 'compromised'; + } + + return 'revoked'; + }//end vaultStatus() +}//end class diff --git a/lib/Service/MigrationService.php b/lib/Service/MigrationService.php index d8926c7f1..6d8f059c3 100644 --- a/lib/Service/MigrationService.php +++ b/lib/Service/MigrationService.php @@ -25,6 +25,8 @@ use OCA\Keepiq\Db\EncryptionSuiteMapper; use OCA\Keepiq\Db\SuiteMigration; use OCA\Keepiq\Db\SuiteMigrationMapper; +use OCA\Keepiq\Event\Audit\AuditEvent; +use OCA\Keepiq\Event\Audit\AuditEventTypes; use OCA\Keepiq\Event\SuiteMigrationAbortedEvent; use OCA\Keepiq\Event\SuiteMigrationCompletedEvent; use OCA\Keepiq\Event\SuiteMigrationStartedEvent; @@ -105,6 +107,15 @@ public function initiateCompromiseRecovery(string $oldSuiteId, string $newSuiteI ); } + // The audit record of the start. It used to be written by + // markCompromised at COMPLETION, so a recovery that was started and + // then aborted left no trace at all (keepiq#870). + $this->dispatchSuiteAudit( + actorId: $this->resolveOwnerId(suiteId: $oldSuiteId), + eventType: AuditEventTypes::SUITE_RECOVERY_STARTED, + migration: $migration, + ); + return $migration; }//end initiateCompromiseRecovery() @@ -245,15 +256,16 @@ public function completeMigration( * 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 + * @param string $migrationId The migration to abort + * @param string|null $actorId Who aborted it, for the audit trail; the owner when null * * @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 + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves */ - public function abortMigration(string $migrationId): array { + public function abortMigration(string $migrationId, ?string $actorId = null): array { $migration = $this->mapper->findById($migrationId); if ($migration->getStatus() !== 'in_progress') { @@ -331,6 +343,14 @@ public function abortMigration(string $migrationId): array { ['oldSuiteId' => $migration->getOldSuiteId()] ); + // An abort undoes a containment step, so it must reach the audit trail + // and the SIEM, not only nextcloud.log (keepiq#859, keepiq#870). + $this->dispatchSuiteAudit( + actorId: ($actorId ?? $ownerId), + eventType: AuditEventTypes::SUITE_RECOVERY_ABORTED, + migration: $migration, + ); + return ( $migration->jsonSerialize() + [ 'aborted' => true, @@ -448,6 +468,24 @@ private function revokeOwnerKeyMaterial(SuiteMigration $migration, ?string $owne return; } + $this->revokeKeyMaterialOfOwner(ownerId: $ownerId); + }//end revokeOwnerKeyMaterial() + + /** + * Revoke the link shares and passkeys of an owner whose key pair is dead. + * + * Shared by the owner's compromise recovery (above) and the administrator's + * compromise force-revoke (CompromiseContainmentService), so the two paths + * cannot drift apart again: the force-revoke used to skip this step and left + * the user's link shares serving after containment (keepiq#858). + * + * @param string $ownerId The Nextcloud user whose key material is revoked + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-compromise-force-revoke-contains-the-account + */ + public function revokeKeyMaterialOfOwner(string $ownerId): void { // Cascade-revoke every link share created by this user: the public-key // fingerprint baked into each share's encrypted snapshot belongs to the // now-compromised key pair, so any outstanding link must be force-locked @@ -457,7 +495,7 @@ private function revokeOwnerKeyMaterial(SuiteMigration $migration, ?string $owne // A new key pair invalidates every passkey unlock envelope — the wrapped // unlock key can never open the new suite (passkey-vault-login §D4). $this->passkeyService?->deleteAllOnRotation($ownerId); - }//end revokeOwnerKeyMaterial() + }//end revokeKeyMaterialOfOwner() /** * Refuse to terminate while rows remain that nobody has attempted. @@ -625,7 +663,7 @@ private function resolveOwnerId(string $suiteId): ?string { * * @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 + * @spec openspec/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) { @@ -646,7 +684,7 @@ public function assertNoMigrationInProgress(string $suiteId): void { * * @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 + * @spec openspec/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) { @@ -674,12 +712,14 @@ public function findInProgressForSuite(string $suiteId): ?SuiteMigration { * before this point leaves the migration open for a retry to find. * * @param SuiteMigration $migration The in-progress migration + * @param string|null $actorId The administrator who force-revoked, for the audit trail; + * recorded as a system action when null * * @return void * - * @spec openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-in-an-in-progress-migration-cannot-be-revoked */ - public function terminateForCompromise(SuiteMigration $migration): void { + public function terminateForCompromise(SuiteMigration $migration, ?string $actorId = null): void { $migration->setStatus('terminated'); $migration->setCompletedAt(new DateTime()); $this->mapper->update($migration); @@ -698,8 +738,58 @@ public function terminateForCompromise(SuiteMigration $migration): void { ['migrationId' => $migration->getId()] ); + // The termination itself is audited (keepiq#870), apart from the two + // suite revokes the caller already recorded. + $this->dispatchSuiteAudit( + actorId: $actorId, + eventType: AuditEventTypes::SUITE_MIGRATION_TERMINATED, + migration: $migration, + ); + }//end terminateForCompromise() + /** + * Record a migration-lifecycle audit event against the old suite. + * + * The object is the old suite, the suite the vault was on when the + * migration began, so every lifecycle event of one recovery lands on the + * same object in the trail. Only ids are recorded, never key material. + * + * @param string|null $actorId The acting user; a system event when null + * @param string $eventType One of the AuditEventTypes SUITE_* constants + * @param SuiteMigration $migration The migration + * + * @return void + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-migration-can-be-aborted-before-any-record-moves + */ + private function dispatchSuiteAudit(?string $actorId, string $eventType, SuiteMigration $migration): void { + $metadata = [ + 'migrationId' => $migration->getId(), + 'oldSuiteId' => $migration->getOldSuiteId(), + 'newSuiteId' => $migration->getNewSuiteId(), + ]; + + // Built directly rather than through AuditEventFactory: the constructor + // already takes nine collaborators, and a tenth crosses PHPMD ExcessiveParameterList. + $actorType = AuditEvent::ACTOR_USER; + if ($actorId === null || $actorId === '') { + $actorType = AuditEvent::ACTOR_SYSTEM; + $actorId = null; + } + + $this->eventDispatcher?->dispatchTyped( + new AuditEvent( + actorType: $actorType, + actorId: $actorId, + eventType: $eventType, + objectType: 'suite', + objectId: $migration->getOldSuiteId(), + metadata: $metadata, + ) + ); + }//end dispatchSuiteAudit() + /** * 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 544cba5b2..ea098aa76 100644 --- a/lib/Service/MigrationWorkService.php +++ b/lib/Service/MigrationWorkService.php @@ -346,7 +346,7 @@ public function countUnrecoverable(SuiteMigration $migration): int { * * @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 + * @spec openspec/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(); diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index 32a1623a6..9aa6a5595 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -50,6 +50,8 @@ class NotificationService { 'secret_shared' => 'notify_shares', 'share_request' => 'notify_shares', 'share_request_result' => 'notify_shares', + // A partner instance shared a secret (sharing-federated-recipients D4). + 'federated_share_received' => 'notify_shares', 'group_member_added' => 'notify_group_shares', 'secret_compromised' => 'notify_security', 'request_fulfilled' => 'notify_requests', @@ -59,10 +61,20 @@ class NotificationService { // existing security-notification category (add-emergency-access §4.2). 'emergency_access_requested' => 'notify_security', 'emergency_access_accessed' => 'notify_security', + // Administrator compromise force-revoke (admin-suite-revocation): + // the holders of copies of the revoked user's secrets, the grantors + // whose vault the revoked key could open (keepiq#872), and the owner + // whose emergency contacts the revoke deleted (keepiq#876). + 'shared_secret_compromised' => 'notify_security', + 'emergency_grantee_compromised' => 'notify_security', + 'emergency_access_cleared' => 'notify_security', // Team folder sharing (team-folder-sharing §4.2): fan-out share // to a recipient; group-join approval request to the owner. 'team_folder_shared' => 'notify_shares', 'team_folder_join_request' => 'notify_group_shares', + // Automatic member confirmation (admin-auto-confirm-members D6): the + // owner learns who confirmed whom, under the same group-share toggle. + 'team_folder_member_confirmed' => 'notify_group_shares', // Rotation & expiry (rotation-expiry-policies §4): approaching // expiry reminders and the overdue/rotation-due flag. 'secret_expiring' => 'notify_security', @@ -73,9 +85,26 @@ class NotificationService { // Certificate lifecycle (certificate-lifecycle §3.2): suite // certificate approaching notAfter. 'certificate_expiring' => 'notify_security', + // The CA root approaching expiry (keepiq#741): an operational admin + // alert like siem_dead_letter, never user-suppressible. + 'ca_root_expiring' => null, // Honey credentials (honey-credentials §D3): a muted tripwire // is worthless — always pages, like app_pending. 'honey_access' => null, + // Shares that end by themselves (sharing-use-only-and-expiring-shares + // D6): the holder a day ahead and when it ended; the owner when it ended. + 'share_access_ending' => 'notify_shares', + 'share_access_ended' => 'notify_shares', + 'share_access_ended_owner' => 'notify_shares', + // New device approval (crypto-new-device-approval D5): someone + // signed in as this user asks to open the vault. Always shown. + 'device_approval_requested' => null, + // Organisation account recovery (crypto-organisation-account-recovery + // 5.2): security events, never suppressible. + 'recovery_officer_named' => null, + 'recovery_requested' => null, + 'recovery_declined' => null, + 'recovery_ready' => null, ]; /** @@ -136,11 +165,18 @@ public function notify( return false; } + // Nextcloud refuses an empty object id (InvalidValueException), so a + // notification about no particular object names its subject instead + // (found live on Nextcloud 35, keepiq#788). + if ($objectId === null || $objectId === '') { + $objectId = $subject; + } + $notification = $this->notificationManager->createNotification(); $notification->setApp(Application::APP_ID) ->setUser($recipientId) ->setDateTime(new DateTime()) - ->setObject($objectType ?? $subject, $objectId ?? '') + ->setObject($objectType ?? $subject, $objectId) ->setSubject($subject, $params); $this->notificationManager->notify($notification); diff --git a/lib/Service/OfflineEditGuard.php b/lib/Service/OfflineEditGuard.php new file mode 100644 index 000000000..6a58db461 --- /dev/null +++ b/lib/Service/OfflineEditGuard.php @@ -0,0 +1,88 @@ + + * @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\Service; + +use DateTime; +use Exception; +use InvalidArgumentException; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Exception\StaleWriteException; + +/** + * The precondition an offline edit replays with: a write that names the + * version it was made from (`baseUpdatedAt`) is refused, untouched, when the + * secret changed since (offline-edit-queue). A write without a base is not + * checked, so online clients behave as before. + * + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ +class OfflineEditGuard { + + /** + * Check an update's base and return its data without the base key. + * + * @param Secret $secret The stored secret + * @param array $data The update data, possibly with `baseUpdatedAt` + * + * @return array The data without `baseUpdatedAt` + * + * @throws StaleWriteException When the secret changed since the base + * @throws InvalidArgumentException When the base is not a date + * + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ + public function checkedUpdate(Secret $secret, array $data): array { + $this->assertUnchangedSince(secret: $secret, baseUpdatedAt: $data['baseUpdatedAt'] ?? null); + unset($data['baseUpdatedAt']); + + return $data; + }//end checkedUpdate() + + /** + * Refuse a write based on an older version of the secret. A null or empty + * base means the caller did not ask for the check. + * + * @param Secret $secret The stored secret + * @param mixed $baseUpdatedAt The `updatedAt` the client's copy was made from + * + * @return void + * + * @throws StaleWriteException When the secret changed since + * @throws InvalidArgumentException When the base is not a date + * + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ + public function assertUnchangedSince(Secret $secret, mixed $baseUpdatedAt): void { + if ($baseUpdatedAt === null || $baseUpdatedAt === '') { + return; + } + + try { + $base = new DateTime((string)$baseUpdatedAt); + } catch (Exception) { + throw new InvalidArgumentException(message: 'baseUpdatedAt must be a date'); + } + + $stored = $secret->getUpdatedAt(); + if ($stored === null || $stored->getTimestamp() !== $base->getTimestamp()) { + throw new StaleWriteException(current: $secret); + } + }//end assertUnchangedSince() +}//end class diff --git a/lib/Service/OfflineManifestService.php b/lib/Service/OfflineManifestService.php index 7c05b9541..f638174f5 100644 --- a/lib/Service/OfflineManifestService.php +++ b/lib/Service/OfflineManifestService.php @@ -55,6 +55,7 @@ class OfflineManifestService { * @param SecretMapper $secretMapper The secret mapper * @param FolderMapper $folderMapper The folder mapper * @param SecretTypeMapper $typeMapper The secret type mapper + * @param TwoFactorGate $twoFactor The two-factor vault policy * * @return void */ @@ -63,6 +64,7 @@ public function __construct( private SecretMapper $secretMapper, private FolderMapper $folderMapper, private SecretTypeMapper $typeMapper, + private TwoFactorGate $twoFactor, ) { }//end __construct() @@ -84,13 +86,23 @@ public function __construct( * than no snapshot at all. * * @spec openspec/specs/offline-readonly-cache/spec.md#requirement-online-sessions-write-through-an-encrypted-local-snapshot + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login */ public function buildForUser(string $userId): array { $suite = $this->suiteMapper->findActiveByOwner('user', $userId)->jsonSerialize(); + // The two-factor policy leaves the suite out, so an offline unlock + // is impossible too; the browser drops its snapshot on the signal + // (admin-vault-policies D3). + $unlockBlocked = null; + if ($this->twoFactor->blocks(userId: $userId) === true) { + $suite = null; + $unlockBlocked = TwoFactorGate::CODE; + } + $secrets = array_map( static fn (Secret $secret) => $secret->jsonSerialize(), - $this->secretMapper->findByOwner(ownerType: 'user', ownerId: $userId) + $this->secretMapper->findByOwner(ownerType: 'user', ownerId: $userId, state: SecretMapper::STATE_LIVE) ); $folders = array_map( @@ -110,6 +122,7 @@ public function buildForUser(string $userId): array { 'folders' => $folders, 'types' => $types, 'syncedAt' => (new DateTime())->format('c'), + 'unlockBlocked' => $unlockBlocked, ]; }//end buildForUser() diff --git a/lib/Service/OrgOwnershipGuard.php b/lib/Service/OrgOwnershipGuard.php new file mode 100644 index 000000000..937a0a2aa --- /dev/null +++ b/lib/Service/OrgOwnershipGuard.php @@ -0,0 +1,213 @@ + + * @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\Service; + +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretTypeMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Exception\PolicyViolationException; +use OCP\AppFramework\Db\DoesNotExistException; + +/** + * Refuses a covered secret outside the user's own team folders. + */ +class OrgOwnershipGuard { + /** + * The code the API returns with the refusal. + */ + public const CODE = 'org_ownership_required'; + + /** + * Constructor. + * + * @param VaultPolicyService $policies The vault policies + * @param TeamFolderQueryService $teamFolders The ancestor team folder walk + * @param SecretTypeMapper $typeMapper Resolves a type id to its name + * @param SecretMapper|null $secretMapper The user's own secrets, for the findings list + * @param ShareTargetMapper|null $shareTargetMapper Tells a received copy from an own secret + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private VaultPolicyService $policies, + private TeamFolderQueryService $teamFolders, + private SecretTypeMapper $typeMapper, + private ?SecretMapper $secretMapper = null, + private ?ShareTargetMapper $shareTargetMapper = null, + ) { + }//end __construct() + + /** + * Refuse a covered secret that would land outside an owned team folder. + * + * @param string $userId The writing user (the owner of the secret) + * @param string $typeId The resolved secret type id + * @param string|null $folderId The target folder, null for the vault root + * + * @return void + * + * @throws PolicyViolationException When the policy refuses the write + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function assertAllowed(string $userId, string $typeId, ?string $folderId): void { + if ($this->policies->appliesTo(policy: VaultPolicyService::ORG_OWNERSHIP, userId: $userId) === false) { + return; + } + + if (in_array($this->typeName(typeId: $typeId), $this->policies->ownershipTypes(), true) === false) { + return; + } + + if ($this->inOwnTeamFolder(userId: $userId, folderId: $folderId) === true) { + return; + } + + throw new PolicyViolationException( + policyCode: self::CODE, + message: 'Your organisation requires this type of secret to be kept in a team folder' + ); + }//end assertAllowed() + + /** + * A move or a type change must not take a covered secret out of the + * user's team folders; other edits are not re-checked. + * + * @param Secret $secret The secret as it will be stored + * @param Secret $before The secret before the update + * @param string $userId The writing user + * + * @return void + * + * @throws PolicyViolationException When the policy refuses the change + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function assertKept(Secret $secret, Secret $before, string $userId): void { + if ($secret->getFolderId() === $before->getFolderId() && $secret->getTypeId() === $before->getTypeId()) { + return; + } + + $this->assertAllowed(userId: $userId, typeId: (string)$secret->getTypeId(), folderId: $secret->getFolderId()); + }//end assertKept() + + /** + * The user's own live secrets that break the ownership policy: covered + * type, outside an owned team folder, and not a copy received from + * someone else. Metadata only, for the health report (design D6). + * + * @param string $userId The session user + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-users-see-personal-items-that-break-the-ownership-policy + */ + public function findings(string $userId): array { + if ($this->secretMapper === null + || $this->policies->appliesTo(policy: VaultPolicyService::ORG_OWNERSHIP, userId: $userId) === false + ) { + return []; + } + + $types = $this->policies->ownershipTypes(); + $findings = []; + foreach ($this->secretMapper->findByOwner(ownerType: 'user', ownerId: $userId, limit: 100000, state: SecretMapper::STATE_LIVE) as $secret) { + $typeId = (string)$secret->getTypeId(); + if (in_array($this->typeName(typeId: $typeId), $types, true) === false + || $this->inOwnTeamFolder(userId: $userId, folderId: $secret->getFolderId()) === true + || $this->isReceivedCopy(secretId: (string)$secret->getId()) === true + ) { + continue; + } + + $findings[] = [ + 'id' => (string)$secret->getId(), + 'name' => (string)$secret->getName(), + 'typeId' => $typeId, + 'folderId' => $secret->getFolderId(), + ]; + } + + return $findings; + }//end findings() + + /** + * Whether a folder sits in a team folder the user owns. + * + * @param string $userId The user + * @param string|null $folderId The folder, null for the root + * + * @return bool + */ + private function inOwnTeamFolder(string $userId, ?string $folderId): bool { + if ($folderId === null || $folderId === '') { + return false; + } + + foreach ($this->teamFolders->ancestorTeamFolders(folderId: $folderId) as $teamFolder) { + if ($teamFolder->getOwnerId() === $userId) { + return true; + } + } + + return false; + }//end inOwnTeamFolder() + + /** + * Whether a secret row is a copy someone shared with the user. + * + * @param string $secretId The secret + * + * @return bool + */ + private function isReceivedCopy(string $secretId): bool { + try { + $this->shareTargetMapper?->findByRecipientSecret(recipientSecretId: $secretId); + } catch (DoesNotExistException) { + return false; + } + + return $this->shareTargetMapper !== null; + }//end isReceivedCopy() + + /** + * The name of a type id, '' when it is unknown. + * + * @param string $typeId The type id + * + * @return string + */ + private function typeName(string $typeId): string { + try { + return $this->typeMapper->findById($typeId)->getName(); + } catch (DoesNotExistException) { + return ''; + } + }//end typeName() +}//end class diff --git a/lib/Service/PasskeyService.php b/lib/Service/PasskeyService.php index f751f36fe..1b4a71e9f 100644 --- a/lib/Service/PasskeyService.php +++ b/lib/Service/PasskeyService.php @@ -40,6 +40,12 @@ * Business logic for passkey vault login. */ class PasskeyService { + /** + * The clients that enrol their own credentials. + * + * @var string[] + */ + public const CLIENT_KINDS = ['web', 'extension']; /** * Constructor for PasskeyService. * @@ -74,6 +80,8 @@ public function listForOwner(string $uid): array { * A fresh 32-byte base64 WebAuthn challenge. * * @return string + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkey-enrollment-requires-an-unlocked-vault */ public function freshChallenge(): string { return base64_encode($this->secureRandom->generate(32, ISecureRandom::CHAR_ALPHANUMERIC . '+/=')); @@ -86,11 +94,13 @@ public function freshChallenge(): string { * suite's unlock-key epoch so a later password change can stale it. * * @param string $uid The calling owner - * @param array $dto {credentialId, publicKey, prfSalt, wrappedUnlockKey, label, transports, aaguid} + * @param array $dto {credentialId, publicKey, prfSalt, wrappedUnlockKey, label, transports, aaguid, clientKind, rpId} * * @return PasskeyCredential * * @throws InvalidArgumentException On a missing envelope / duplicate credential + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkey-enrollment-requires-an-unlocked-vault */ public function enroll(string $uid, array $dto): PasskeyCredential { $credentialId = (string)($dto['credentialId'] ?? ''); @@ -100,6 +110,22 @@ public function enroll(string $uid, array $dto): PasskeyCredential { throw new InvalidArgumentException('credentialId, prfSalt and wrappedUnlockKey are required'); } + // An extension credential is bound to the extension's own relying + // party, so the extension's login options can be scoped to it. + $clientKind = (string)($dto['clientKind'] ?? 'web'); + if ($clientKind === '') { + $clientKind = 'web'; + } + + $rpId = $this->optionalString(value: $dto['rpId'] ?? null); + if (in_array($clientKind, self::CLIENT_KINDS, true) === false) { + throw new InvalidArgumentException('clientKind must be web or extension'); + } + + if ($clientKind === 'extension' && $rpId === null) { + throw new InvalidArgumentException('An extension passkey needs its relying party id'); + } + if ($this->mapper->findByCredentialId($uid, $credentialId) !== null) { throw new InvalidArgumentException('This passkey is already enrolled'); } @@ -115,6 +141,8 @@ public function enroll(string $uid, array $dto): PasskeyCredential { $credential->setLabel($this->optionalString(value: $dto['label'] ?? null)); $credential->setTransports($this->optionalString(value: $dto['transports'] ?? null)); $credential->setAaguid($this->optionalString(value: $dto['aaguid'] ?? null)); + $credential->setClientKind($clientKind); + $credential->setRpId($rpId); $credential->setStatus('active'); $credential->setCreatedAt(new DateTime()); @@ -127,11 +155,20 @@ public function enroll(string $uid, array $dto): PasskeyCredential { * suite epoch, and a fresh challenge. Stale/revoked envelopes are * refused (not returned). * + * Only the asking client's credentials are offered: the web app never + * gets an extension credential, and the extension only gets the + * credentials bound to its own relying party. + * * @param string $uid The calling owner + * @param string $client web or extension + * @param string $rpId The extension's relying party id (extension only) * * @return array + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passwordless-unlock-derives-the-unlock-key-client-side + * @spec openspec/specs/extension-biometric-unlock/spec.md#requirement-extension-credentials-are-visible-and-revocable-in-the-web-app */ - public function loginOptions(string $uid): array { + public function loginOptions(string $uid, string $client='web', string $rpId=''): array { $epoch = $this->currentEpoch(uid: $uid); $credentials = []; foreach ($this->mapper->findActiveByOwner($uid) as $credential) { @@ -143,6 +180,10 @@ public function loginOptions(string $uid): array { continue; } + if ($this->offeredTo(credential: $credential, client: $client, rpId: $rpId) === false) { + continue; + } + $credentials[] = [ 'id' => $credential->getId(), 'credentialId' => $credential->getCredentialId(), @@ -166,6 +207,8 @@ public function loginOptions(string $uid): array { * @param string $id The credential UUID * * @return void + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkeys-are-manageable-revocable-and-owner-scoped */ public function recordUse(string $uid, string $id): void { try { @@ -188,6 +231,8 @@ public function recordUse(string $uid, string $id): void { * * @throws DoesNotExistException When missing * @throws InvalidArgumentException When not owned + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-passkeys-are-manageable-revocable-and-owner-scoped */ public function revoke(string $uid, string $id): void { $credential = $this->ownedCredential(uid: $uid, id: $id); @@ -201,6 +246,8 @@ public function revoke(string $uid, string $id): void { * @param string $uid The owner * * @return void + * + * @spec openspec/specs/passkey-vault-login/spec.md#requirement-envelopes-are-invalidated-when-the-unlock-key-changes */ public function markStaleOnPasswordChange(string $uid): void { $this->mapper->markOwnerStale($uid); @@ -213,11 +260,32 @@ public function markStaleOnPasswordChange(string $uid): void { * @param string $uid The owner * * @return void + * + * @spec openspec/specs/passkey-vault-login/spec.md#scenario-compromise-recovery-deletes-all-passkey-envelopes */ public function deleteAllOnRotation(string $uid): void { $this->mapper->deleteByOwner($uid); }//end deleteAllOnRotation() + /** + * Whether a credential belongs to the asking client. + * + * @param PasskeyCredential $credential The credential + * @param string $client web or extension + * @param string $rpId The asking extension's relying party id + * + * @return bool + */ + private function offeredTo(PasskeyCredential $credential, string $client, string $rpId): bool { + if ($client !== 'extension') { + return $credential->getClientKind() !== 'extension'; + } + + return $credential->getClientKind() === 'extension' + && $rpId !== '' + && $credential->getRpId() === $rpId; + }//end offeredTo() + /** * Load a credential and enforce the owner guard. * diff --git a/lib/Service/PasswordPolicyService.php b/lib/Service/PasswordPolicyService.php index 5df885078..6019aaae9 100644 --- a/lib/Service/PasswordPolicyService.php +++ b/lib/Service/PasswordPolicyService.php @@ -40,7 +40,7 @@ */ class PasswordPolicyService { /** - * The nine admin-writable policy keys. `updatePolicySettings()` audits a + * The eleven admin-writable policy keys. `updatePolicySettings()` audits a * write only when the payload touches at least one of them, and the * before/after snapshot is taken over exactly this list. * @@ -53,9 +53,12 @@ class PasswordPolicyService { 'generator_require_lower', 'generator_require_digit', 'generator_require_symbol', + 'generator_allow_passphrase', 'min_zxcvbn_score', 'block_on_hibp_hit', 'policy_exempt_types', + // Automatic member confirmation (admin-auto-confirm-members D1): off by default, audited like the rest. + 'team_folder_auto_confirm', ]; /** @@ -69,6 +72,8 @@ class PasswordPolicyService { 'generator_require_lower', 'generator_require_digit', 'generator_require_symbol', + 'generator_allow_passphrase', + 'team_folder_auto_confirm', ]; /** @@ -106,6 +111,7 @@ class PasswordPolicyService { * @param IUserSession $userSession The user session (audit actor) * @param IEventDispatcher|null $eventDispatcher The audit dispatcher (policy changes) * @param AuditEventFactory $auditEvents The audit-event factory + * @param VaultPolicyService|null $vaultPolicies The vault policies (admin-vault-policies) * * @return void * @@ -116,6 +122,7 @@ public function __construct( private IUserSession $userSession, private ?IEventDispatcher $eventDispatcher = null, private AuditEventFactory $auditEvents = new AuditEventFactory(), + private ?VaultPolicyService $vaultPolicies = null, ) { }//end __construct() @@ -135,14 +142,16 @@ public function __construct( * `Repair\InitializeSettings` write them; `getValueInt()` on a * string-typed app-config key raises a type conflict in Nextcloud. * + * @param string|null $userId The session user, for the effective vault policies + * * @return array * * @spec openspec/changes/org-password-policies/specs/org-password-policies/spec.md */ - public function getPolicy(): array { + public function getPolicy(?string $userId = null): array { $appId = Application::APP_ID; - return array_merge( + $policy = array_merge( [ 'master_password_min_length' => (int)$this->appConfig->getValueString( $appId, @@ -157,16 +166,25 @@ public function getPolicy(): array { ], $this->readPolicyKeys() ); + + if ($this->vaultPolicies !== null && $userId !== null) { + // Only whether each vault policy applies to THIS user, never the + // group lists (admin-vault-policies D1). + $policy = array_merge($policy, $this->vaultPolicies->effectiveFor(userId: $userId)); + } + + return $policy; }//end getPolicy() /** - * The nine policy keys with their stored (or default) values. This is + * The ten policy keys with their stored (or default) values. This is * the single reader both `getPolicy()` and the admin-settings payload * use, so the two can never disagree about a default. * * @return array * * @spec openspec/changes/org-password-policies/specs/org-password-policies/spec.md + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-administrator-switches-automatic-member-confirmation-on */ public function readPolicyKeys(): array { $appId = Application::APP_ID; @@ -178,15 +196,31 @@ public function readPolicyKeys(): array { 'generator_require_lower' => $this->appConfig->getValueBool($appId, 'generator_require_lower', false), 'generator_require_digit' => $this->appConfig->getValueBool($appId, 'generator_require_digit', false), 'generator_require_symbol' => $this->appConfig->getValueBool($appId, 'generator_require_symbol', false), + // Passphrases (client-side-key-generator): on unless an administrator switches them off. + 'generator_allow_passphrase' => $this->appConfig->getValueBool($appId, 'generator_allow_passphrase', true), 'min_zxcvbn_score' => $this->appConfig->getValueInt($appId, 'min_zxcvbn_score', 0), 'block_on_hibp_hit' => $this->appConfig->getValueBool($appId, 'block_on_hibp_hit', false), 'policy_exempt_types' => json_decode( $this->appConfig->getValueString($appId, 'policy_exempt_types', self::DEFAULT_EXEMPT_TYPES), true ), + 'team_folder_auto_confirm' => $this->appConfig->getValueBool($appId, 'team_folder_auto_confirm', false), ]; }//end readPolicyKeys() + /** + * The policy keys the admin page shows: the org password policy and, + * when wired, the vault policies with their group scopes. Admin only; + * the user-facing getPolicy() never carries the group lists. + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function readAdminPolicyKeys(): array { + return array_merge($this->readPolicyKeys(), $this->vaultPolicies?->read() ?? []); + }//end readAdminPolicyKeys() + /** * Validate + persist the org password-policy keys and dispatch the * `password_policy.updated` audit event with before/after values — @@ -199,8 +233,11 @@ public function readPolicyKeys(): array { * @throws InvalidArgumentException On invalid policy values * * @spec openspec/changes/org-password-policies/specs/org-password-policies/spec.md + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-administrator-switches-automatic-member-confirmation-on */ public function updatePolicySettings(array $data): void { + $this->vaultPolicies?->update(data: $data); + $touched = array_values(array_intersect(self::POLICY_KEYS, array_keys($data))); if ($touched === []) { return; diff --git a/lib/Service/RecentlyUsedService.php b/lib/Service/RecentlyUsedService.php new file mode 100644 index 000000000..8023450fa --- /dev/null +++ b/lib/Service/RecentlyUsedService.php @@ -0,0 +1,114 @@ + + * @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\Service; + +use DateTimeInterface; +use OCA\Keepiq\Db\SecretMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; + +/** + * Turns the secret.read audit rows into a short list of distinct, live secrets. + * + * @spec openspec/specs/vault-recently-used/spec.md#requirement-recently-used-on-the-dashboard + */ +class RecentlyUsedService { + + /** + * How many read events are scanned to find the distinct secrets. A secret + * opened many times in a row takes one row of the widget, not many. + * + * @var int + */ + public const SCAN_WINDOW = 50; + + /** + * How many secrets the widget shows. + * + * @var int + */ + public const SHOWN = 5; + + /** + * Constructor for RecentlyUsedService. + * + * @param AuditService $auditService The audit trail + * @param SecretMapper $secretMapper The secret mapper (ownership check) + * + * @return void + */ + public function __construct( + private AuditService $auditService, + private SecretMapper $secretMapper, + ) { + }//end __construct() + + /** + * The secrets a user read most recently, newest first, one row per secret. + * + * A secret that no longer exists, that another user now owns or that was + * tombstoned is left out, so a row always opens something. + * + * @param string $userId The user + * + * @return list + * + * @spec openspec/specs/vault-recently-used/spec.md#requirement-recently-used-on-the-dashboard + */ + public function forUser(string $userId): array { + $rows = []; + $seen = []; + foreach ($this->auditService->recentlyAccessed($userId, self::SCAN_WINDOW) as $entry) { + $secretId = $entry->getObjectId(); + if ($secretId === null || isset($seen[$secretId]) === true) { + continue; + } + + $seen[$secretId] = true; + try { + $secret = $this->secretMapper->findById($secretId); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + continue; + } + + if ($secret->getOwnerType() !== 'user' + || $secret->getOwnerId() !== $userId + || $secret->getTombstonedAt() !== null + ) { + continue; + } + + $rows[] = [ + 'id' => $secretId, + 'name' => $secret->getName(), + 'typeId' => $secret->getTypeId(), + 'lastUsedAt' => $entry->getOccurredAt()->format(DateTimeInterface::ATOM), + ]; + if (count($rows) === self::SHOWN) { + break; + } + }//end foreach + + return $rows; + }//end forUser() +}//end class diff --git a/lib/Service/RecipientStatusService.php b/lib/Service/RecipientStatusService.php new file mode 100644 index 000000000..5f8275754 --- /dev/null +++ b/lib/Service/RecipientStatusService.php @@ -0,0 +1,129 @@ + + * @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\Service; + +use OCP\Collaboration\Collaborators\ISearch; +use OCP\Share\IShare; + +/** + * Marks which users of one sharee search hold an active encryption suite. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-recipient-search-marks-who-cannot-receive-a-share + */ +class RecipientStatusService { + /** + * The most users one answer covers: one page of the share dialog's search. + * + * @var int + */ + public const MAX_USERS = 25; + + /** + * Constructor for RecipientStatusService. + * + * @param ISearch $collaboratorSearch Nextcloud's sharee search, run as the session user + * @param ShareService $shareService The share service (active suite lookup) + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private ISearch $collaboratorSearch, + private ShareService $shareService, + ) { + }//end __construct() + + /** + * For each requested user that the sharee search for `$search` returns, + * whether they hold an active suite. Requested ids the search does not + * return are left out of the answer entirely. + * + * @param string $search The term the caller searched for + * @param array $userIds Distinct user ids from that search, at most MAX_USERS + * + * @return array In the order requested + * + * @spec openspec/specs/user-sharing/spec.md#requirement-recipient-search-marks-who-cannot-receive-a-share + */ + public function statusFor(string $search, array $userIds): array { + $findable = $this->findableUserIds(search: $search); + $visible = array_values( + array_filter( + $userIds, + static fn (string $userId): bool => isset($findable[$userId]) === true + ) + ); + if ($visible === []) { + return []; + } + + $certificates = $this->shareService->recipientCertificates(targetUserIds: $visible); + + return array_map( + static fn (string $userId): array => [ + 'userId' => $userId, + 'hasSuite' => isset($certificates[$userId]) === true, + ], + $visible + ); + }//end statusFor() + + /** + * The user ids the sharee search returns for a term, as a set. Same + * shape as the share dialog's own call: users only, no lookup server, + * one page. + * + * @param string $search The search term + * + * @return array + * + * @psalm-suppress DeprecatedMethod ISearch::search() is the only form on + * Nextcloud 32 to 34; filteredSearch() arrives in 35. + */ + private function findableUserIds(string $search): array { + [$result] = $this->collaboratorSearch->search( + $search, + [IShare::TYPE_USER], + false, + self::MAX_USERS, + 0 + ); + + $rows = array_merge( + ($result['exact']['users'] ?? []), + ($result['users'] ?? []) + ); + $found = []; + foreach ($rows as $row) { + $userId = ($row['value']['shareWith'] ?? null); + if (is_string($userId) === true && $userId !== '') { + $found[$userId] = true; + } + } + + return $found; + }//end findableUserIds() +}//end class diff --git a/lib/Service/RecoveryAudit.php b/lib/Service/RecoveryAudit.php new file mode 100644 index 000000000..542747527 --- /dev/null +++ b/lib/Service/RecoveryAudit.php @@ -0,0 +1,95 @@ + + * @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\Service; + +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\EventDispatcher\IEventDispatcher; + +/** + * The audit trail of organisation account recovery, identifiers only + * (crypto-organisation-account-recovery task 5.2). The whitelist in + * AuditEventTypes drops anything else, and the forbidden keys refuse key + * material outright. + */ +class RecoveryAudit { + + public const SETTINGS_CHANGED = AuditEventTypes::RECOVERY_SETTINGS_CHANGED; + + public const KEY_CREATED = AuditEventTypes::RECOVERY_KEY_CREATED; + + public const KEY_RETIRED = AuditEventTypes::RECOVERY_KEY_RETIRED; + + public const ENROLLED = AuditEventTypes::RECOVERY_ENROLLED; + + public const WITHDRAWN = AuditEventTypes::RECOVERY_WITHDRAWN; + + public const REQUESTED = AuditEventTypes::RECOVERY_REQUESTED; + + public const APPROVED = AuditEventTypes::RECOVERY_APPROVED; + + public const DECLINED = AuditEventTypes::RECOVERY_DECLINED; + + public const HANDED_OFF = AuditEventTypes::RECOVERY_HANDED_OFF; + + public const COMPLETED = AuditEventTypes::RECOVERY_COMPLETED; + + public const EXPIRED = AuditEventTypes::RECOVERY_EXPIRED; + + /** + * Constructor for RecoveryAudit. + * + * @param IEventDispatcher $eventDispatcher The audit dispatcher + * @param AuditEventFactory $auditEvents The audit-event factory + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IEventDispatcher $eventDispatcher, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * Record one recovery step. + * + * @param string $actorId Who acted + * @param string $eventType One of the constants above + * @param string $objectId The key, enrolment or request id + * @param array $metadata Whitelisted identifiers + * + * @return void + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-user-is-told-what-happened-and-offered-a-rotation + */ + public function record(string $actorId, string $eventType, string $objectId, array $metadata = []): void { + $this->eventDispatcher->dispatchTyped( + $this->auditEvents->forUser( + actorId: $actorId, + eventType: $eventType, + objectType: 'account_recovery', + objectId: $objectId, + metadata: $metadata, + ) + ); + }//end record() +}//end class diff --git a/lib/Service/RecoveryEnrolmentService.php b/lib/Service/RecoveryEnrolmentService.php new file mode 100644 index 000000000..130079cce --- /dev/null +++ b/lib/Service/RecoveryEnrolmentService.php @@ -0,0 +1,223 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\RecoveryEnrolment; +use OCA\Keepiq\Db\RecoveryEnrolmentMapper; +use OCA\Keepiq\Exception\ForbiddenException; +use OCP\AppFramework\Db\DoesNotExistException; +use Ramsey\Uuid\Uuid; + +/** + * A user's enrolment in organisation account recovery (D1, D5): their suite + * private key wrapped, in their own browser, to the recovery certificate. + */ +class RecoveryEnrolmentService { + + /** + * Constructor for RecoveryEnrolmentService. + * + * @param RecoveryEnrolmentMapper $mapper The enrolment mapper + * @param RecoveryPolicyService $policy The policy + * @param RecoveryKeyService $keys The recovery keys + * @param EncryptionSuiteMapper $suiteMapper The suite mapper + * @param RecoveryAudit $audit The audit trail + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private RecoveryEnrolmentMapper $mapper, + private RecoveryPolicyService $policy, + private RecoveryKeyService $keys, + private EncryptionSuiteMapper $suiteMapper, + private RecoveryAudit $audit, + ) { + }//end __construct() + + /** + * What the user's browser needs: the policy, whether the user is enrolled + * to the current key and suite, and the active key to enrol with. + * + * @param string $userId The user + * + * @return array{policy:string,enrolled:bool,current:bool,key:array|null} + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public function status(string $userId): array { + $key = $this->keys->publicInfo(); + $enrolment = $this->current(userId: $userId); + + return [ + 'policy' => $this->policy->policy(), + 'enrolled' => ($enrolment !== null), + // Enrolled to the active key and the active suite: nothing to redo. + 'current' => ($enrolment !== null + && $key !== null + && $enrolment->getRecoveryKeyId() === $key['id'] + && $enrolment->getSuiteId() === $this->activeSuiteId(userId: $userId)), + 'key' => $key, + ]; + }//end status() + + /** + * The user's enrolment, if any. + * + * @param string $userId The user + * + * @return RecoveryEnrolment|null + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public function current(string $userId): ?RecoveryEnrolment { + return ($this->mapper->findByUser($userId)[0] ?? null); + }//end current() + + /** + * Enrol, replacing any earlier enrolment of the user. + * + * @param string $userId The user + * @param string $recoveryKeyId The key the envelope is wrapped to (must be active) + * @param string $envelope The hybrid envelope of the user's private key + * + * @return RecoveryEnrolment + * + * @throws ForbiddenException When recovery is off + * @throws InvalidArgumentException When the key is not the active one or the envelope is empty + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public function enrol(string $userId, string $recoveryKeyId, string $envelope): RecoveryEnrolment { + if ($this->policy->policy() === 'off') { + throw new ForbiddenException(message: 'Account recovery is off'); + } + + $active = $this->keys->activeKey(); + if ($active === null || $active->getId() !== $recoveryKeyId) { + throw new InvalidArgumentException(message: 'Enrol with the active recovery key'); + } + + $decoded = json_decode($envelope, true); + if (is_array($decoded) === false || isset($decoded['encKey'], $decoded['ct']) === false) { + throw new InvalidArgumentException(message: 'envelope is not a recovery envelope'); + } + + $suiteId = $this->activeSuiteId(userId: $userId); + if ($suiteId === '') { + throw new InvalidArgumentException(message: 'You have no active encryption suite'); + } + + foreach ($this->mapper->findByUser($userId) as $old) { + $this->mapper->delete($old); + } + + $enrolment = new RecoveryEnrolment(); + $enrolment->setId(Uuid::uuid4()->toString()); + $enrolment->setUserId($userId); + $enrolment->setSuiteId($suiteId); + $enrolment->setRecoveryKeyId($recoveryKeyId); + $enrolment->setEnvelope($envelope); + $enrolment->setEnrolledAt(new DateTime()); + $enrolment = $this->mapper->insert($enrolment); + + $this->audit->record( + actorId: $userId, + eventType: RecoveryAudit::ENROLLED, + objectId: $enrolment->getId(), + metadata: ['suiteId' => $enrolment->getSuiteId()] + ); + + return $enrolment; + }//end enrol() + + /** + * Withdraw, refused under the `required` policy. + * + * @param string $userId The user + * + * @return void + * + * @throws ForbiddenException When the policy requires enrolment + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public function withdraw(string $userId): void { + if ($this->policy->policy() === 'required') { + throw new ForbiddenException(message: 'Your organisation requires account recovery'); + } + + foreach ($this->mapper->findByUser($userId) as $enrolment) { + $this->mapper->delete($enrolment); + $this->audit->record(actorId: $userId, eventType: RecoveryAudit::WITHDRAWN, objectId: $enrolment->getId()); + } + }//end withdraw() + + /** + * Whether a suite has an enrolment (the admin's revocation warning). + * + * @param string $suiteId The suite + * + * @return bool + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-force-revocation-warns-about-enrolled-users + */ + public function isSuiteEnrolled(string $suiteId): bool { + return $suiteId !== '' && $this->mapper->findBySuite($suiteId) !== []; + }//end isSuiteEnrolled() + + /** + * Delete the enrolments of a suite (rotation done or suite revoked, D7). + * + * @param string $suiteId The suite + * + * @return int The number deleted + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + public function deleteForSuite(string $suiteId): int { + $deleted = 0; + foreach ($this->mapper->findBySuite($suiteId) as $enrolment) { + $this->mapper->delete($enrolment); + ++$deleted; + } + + return $deleted; + }//end deleteForSuite() + + /** + * The user's active suite id, or '' when there is none. + * + * @param string $userId The user + * + * @return string + */ + private function activeSuiteId(string $userId): string { + try { + return $this->suiteMapper->findActiveByOwner('user', $userId)->getId(); + } catch (DoesNotExistException) { + return ''; + } + }//end activeSuiteId() +}//end class diff --git a/lib/Service/RecoveryKeyService.php b/lib/Service/RecoveryKeyService.php new file mode 100644 index 000000000..29c838904 --- /dev/null +++ b/lib/Service/RecoveryKeyService.php @@ -0,0 +1,308 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\CACertificateMapper; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\RecoveryKey; +use OCA\Keepiq\Db\RecoveryKeyMapper; +use OCA\Keepiq\Db\RecoveryOfficer; +use OCA\Keepiq\Db\RecoveryOfficerMapper; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use Ramsey\Uuid\Uuid; + +/** + * The organisation recovery key (crypto-organisation-account-recovery D2): + * an officer's browser generates it, the instance CA certifies its public + * half, and the server stores one copy per officer, wrapped to that + * officer's suite certificate. The server never holds the private key in + * any other form, and hands each officer only their own copy. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The key lifecycle touches the + * CA, the suites and both recovery tables. + */ +class RecoveryKeyService { + + public const STATUS_ACTIVE = 'active'; + + public const STATUS_RETIRING = 'retiring'; + + public const STATUS_RETIRED = 'retired'; + + /** + * Constructor for RecoveryKeyService. + * + * @param RecoveryKeyMapper $keyMapper The recovery key mapper + * @param RecoveryOfficerMapper $officerMapper The officer copy mapper + * @param RecoveryPolicyService $policy The officers and threshold + * @param EncryptionSuiteMapper $suiteMapper The suite mapper + * @param CertificateIssuanceService $issuance The CA issuance service + * @param CACertificateMapper $caMapper The CA certificate mapper (chain) + * @param RecoveryAudit $audit The recovery audit trail + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private RecoveryKeyMapper $keyMapper, + private RecoveryOfficerMapper $officerMapper, + private RecoveryPolicyService $policy, + private EncryptionSuiteMapper $suiteMapper, + private CertificateIssuanceService $issuance, + private CACertificateMapper $caMapper, + private RecoveryAudit $audit, + ) { + }//end __construct() + + /** + * The active recovery key, or null when none exists yet. + * + * @return RecoveryKey|null + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + public function activeKey(): ?RecoveryKey { + return ($this->keyMapper->findByStatus(self::STATUS_ACTIVE)[0] ?? null); + }//end activeKey() + + /** + * What any user may see of the active key: the certificate, its + * fingerprint and the instance CA chain to check it against. + * + * @return array{id:string,certificate:string,fingerprint:string,caChain:string[]}|null + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public function publicInfo(): ?array { + $key = $this->activeKey(); + if ($key === null) { + return null; + } + + $chain = []; + try { + $chain[] = $this->caMapper->findActiveIntermediate()->getCertificate(); + $chain[] = $this->caMapper->findRoot()->getCertificate(); + } catch (DoesNotExistException) { + // No CA chain: the browser cannot check and will refuse to enrol. + $chain = []; + } + + return [ + 'id' => $key->getId(), + 'certificate' => $key->getCertificate(), + 'fingerprint' => $key->getFingerprint(), + 'caChain' => $chain, + ]; + }//end publicInfo() + + /** + * Create a new recovery key from an officer's browser: certify its public + * half and store one wrapped copy per named officer. An existing active + * key becomes `retiring`, so enrolled users re-enrol at their next unlock. + * + * @param string $officerUid The officer creating it + * @param string $publicKeyPem The recovery public key + * @param array $copies Officer uid => wrapped private key + * + * @return RecoveryKey + * + * @throws ForbiddenException When the caller is not an officer + * @throws InvalidArgumentException When a copy is missing or one is for a non-officer + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + public function createKey(string $officerUid, string $publicKeyPem, array $copies): RecoveryKey { + if ($this->policy->isOfficer(userId: $officerUid) === false) { + throw new ForbiddenException(message: 'Only a recovery officer can create the recovery key'); + } + + $officers = $this->policy->officers(); + $given = array_keys($copies); + sort($officers); + sort($given); + if ($given !== $officers) { + throw new InvalidArgumentException(message: 'Send exactly one wrapped copy for each named officer'); + } + + $certificate = $this->issuance->signPublicKey(publicKeyPem: $publicKeyPem, commonName: 'Keepiq organisation recovery'); + + foreach ($this->keyMapper->findByStatus(self::STATUS_ACTIVE) as $previous) { + $previous->setStatus(self::STATUS_RETIRING); + $this->keyMapper->update($previous); + } + + $key = new RecoveryKey(); + $key->setId(Uuid::uuid4()->toString()); + $key->setCertificate($certificate); + $key->setFingerprint(self::fingerprint(certificatePem: $certificate)); + $key->setThreshold($this->policy->threshold()); + $key->setStatus(self::STATUS_ACTIVE); + $key->setCreatedBy($officerUid); + $key->setCreatedAt(new DateTime()); + $key = $this->keyMapper->insert($key); + + foreach ($copies as $uid => $wrapped) { + $this->storeCopy(key: $key, officerUid: (string)$uid, wrapped: (string)$wrapped, addedBy: $officerUid); + } + + $this->audit->record(actorId: $officerUid, eventType: RecoveryAudit::KEY_CREATED, objectId: $key->getId()); + + return $key; + }//end createKey() + + /** + * An officer's own copy of a recovery key (the active one by default). + * + * @param string $officerUid The officer + * @param string|null $recoveryKeyId The key, or null for the active one + * + * @return RecoveryOfficer + * + * @throws NotFoundException When the officer holds no copy + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovery-private-key-is-generated-and-held-by-officers-only + */ + public function ownCopy(string $officerUid, ?string $recoveryKeyId = null): RecoveryOfficer { + $keyId = ($recoveryKeyId ?? $this->activeKey()?->getId()); + if ($keyId === null || $this->policy->isOfficer(userId: $officerUid) === false) { + throw new NotFoundException(message: 'No recovery key copy'); + } + + try { + return $this->officerMapper->findForKeyAndOfficer($keyId, $officerUid); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'No recovery key copy'); + } + }//end ownCopy() + + /** + * Replace an officer's own copy after their suite rotated (D7). + * + * @param string $officerUid The officer + * @param string $recoveryKeyId The key + * @param string $wrapped The copy wrapped to the officer's new suite + * + * @return void + * + * @throws NotFoundException When the officer holds no copy of that key + * @throws InvalidArgumentException When the officer has no active suite + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + public function replaceOwnCopy(string $officerUid, string $recoveryKeyId, string $wrapped): void { + $copy = $this->ownCopy(officerUid: $officerUid, recoveryKeyId: $recoveryKeyId); + $copy->setWrappedPrivateKey($wrapped); + $copy->setOfficerSuiteId($this->activeSuiteId(userId: $officerUid)); + $this->officerMapper->update($copy); + }//end replaceOwnCopy() + + /** + * Retire a recovery key: no new enrolments go to it. + * + * @param string $recoveryKeyId The key + * @param string $adminUid The administrator + * + * @return void + * + * @throws NotFoundException When the key does not exist + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + public function retire(string $recoveryKeyId, string $adminUid): void { + try { + $key = $this->keyMapper->findById($recoveryKeyId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Recovery key not found'); + } + + $key->setStatus(self::STATUS_RETIRED); + $key->setRetiredAt(new DateTime()); + $this->keyMapper->update($key); + $this->audit->record(actorId: $adminUid, eventType: RecoveryAudit::KEY_RETIRED, objectId: $key->getId()); + }//end retire() + + /** + * The SHA-256 fingerprint (hex) of a PEM certificate's DER bytes. + * + * @param string $certificatePem The certificate + * + * @return string + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-users-enrol-by-wrapping-their-own-key-to-the-recovery-certificate + */ + public static function fingerprint(string $certificatePem): string { + $body = preg_replace('/-----(BEGIN|END) CERTIFICATE-----|\s+/', '', $certificatePem); + $der = base64_decode((string)$body, true); + if ($der === false) { + return hash('sha256', $certificatePem); + } + + return hash('sha256', $der); + }//end fingerprint() + + /** + * Store one officer's wrapped copy. + * + * @param RecoveryKey $key The key + * @param string $officerUid The officer + * @param string $wrapped The wrapped private key + * @param string $addedBy Who stored it + * + * @return void + */ + private function storeCopy(RecoveryKey $key, string $officerUid, string $wrapped, string $addedBy): void { + if (trim($wrapped) === '') { + throw new InvalidArgumentException(message: 'A wrapped copy is empty'); + } + + $copy = new RecoveryOfficer(); + $copy->setId(Uuid::uuid4()->toString()); + $copy->setRecoveryKeyId($key->getId()); + $copy->setOfficerUid($officerUid); + $copy->setOfficerSuiteId($this->activeSuiteId(userId: $officerUid)); + $copy->setWrappedPrivateKey($wrapped); + $copy->setAddedBy($addedBy); + $copy->setAddedAt(new DateTime()); + $this->officerMapper->insert($copy); + }//end storeCopy() + + /** + * A user's active suite id. + * + * @param string $userId The user + * + * @return string + * + * @throws InvalidArgumentException When the user has no active suite + */ + private function activeSuiteId(string $userId): string { + try { + return $this->suiteMapper->findActiveByOwner('user', $userId)->getId(); + } catch (DoesNotExistException) { + throw new InvalidArgumentException(message: 'Officer ' . $userId . ' has no active encryption suite'); + } + }//end activeSuiteId() +}//end class diff --git a/lib/Service/RecoveryPolicyService.php b/lib/Service/RecoveryPolicyService.php new file mode 100644 index 000000000..6899fc373 --- /dev/null +++ b/lib/Service/RecoveryPolicyService.php @@ -0,0 +1,220 @@ + + * @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\Service; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\RecoveryOfficerMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; + +/** + * The administrator's settings for organisation account recovery + * (crypto-organisation-account-recovery D2, D5): the policy, the officers and + * the approval threshold. + */ +class RecoveryPolicyService { + + public const POLICY_KEY = 'account_recovery_policy'; + + public const OFFICERS_KEY = 'account_recovery_officers'; + + public const THRESHOLD_KEY = 'account_recovery_threshold'; + + /** + * The policy values. + * + * @var string[] + */ + public const POLICIES = ['off', 'optional', 'required']; + + /** + * Constructor for RecoveryPolicyService. + * + * @param IAppConfig $appConfig The app config + * @param EncryptionSuiteMapper $suiteMapper The suite mapper (officers need an active suite) + * @param RecoveryOfficerMapper $officerMapper The officer copies (removed officers lose theirs) + * @param NotificationService $notifications The notification dispatcher + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IAppConfig $appConfig, + private EncryptionSuiteMapper $suiteMapper, + private RecoveryOfficerMapper $officerMapper, + private NotificationService $notifications, + ) { + }//end __construct() + + /** + * The policy: `off` (default), `optional` or `required`. + * + * @return string + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + public function policy(): string { + $policy = $this->appConfig->getValueString(Application::APP_ID, self::POLICY_KEY, 'off'); + if (in_array($policy, self::POLICIES, true) === false) { + return 'off'; + } + + return $policy; + }//end policy() + + /** + * The named officers. + * + * @return string[] + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + public function officers(): array { + $decoded = json_decode($this->appConfig->getValueString(Application::APP_ID, self::OFFICERS_KEY, '[]'), true); + if (is_array($decoded) === false) { + return []; + } + + return array_values(array_filter($decoded, static fn ($uid): bool => is_string($uid) && $uid !== '')); + }//end officers() + + /** + * Whether a user is a named officer. + * + * @param string $userId The user + * + * @return bool + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + public function isOfficer(string $userId): bool { + return in_array($userId, $this->officers(), true); + }//end isOfficer() + + /** + * The approval threshold (at least 1). + * + * @return int + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + public function threshold(): int { + return max(1, $this->appConfig->getValueInt(Application::APP_ID, self::THRESHOLD_KEY, 1)); + }//end threshold() + + /** + * The settings as the admin section shows them. + * + * @return array{policy:string,officers:string[],threshold:int} + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + public function settings(): array { + return [ + 'policy' => $this->policy(), + 'officers' => $this->officers(), + 'threshold' => $this->threshold(), + ]; + }//end settings() + + /** + * Store the policy, officers and threshold. Refuses an unknown policy, an + * officer without an active suite, and a threshold outside 1..officers. + * Officers who are no longer named lose their copies of the recovery key. + * + * @param string $policy The policy + * @param string[] $officers The officer user ids + * @param int $threshold The approval threshold + * + * @return array{policy:string,officers:string[],threshold:int,removed:string[]} + * + * @throws InvalidArgumentException When a value is refused + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-administrators-name-recovery-officers-a-threshold-and-a-policy + */ + public function update(string $policy, array $officers, int $threshold): array { + if (in_array($policy, self::POLICIES, true) === false) { + throw new InvalidArgumentException(message: 'policy must be off, optional or required'); + } + + $named = array_values(array_unique(array_filter($officers, static fn ($uid): bool => is_string($uid) && $uid !== ''))); + $this->assertOfficersUsable(officers: $named); + $this->assertThresholdFits(policy: $policy, officers: $named, threshold: $threshold); + + $before = $this->officers(); + $removed = array_values(array_diff($before, $named)); + foreach ($removed as $uid) { + $this->officerMapper->deleteByOfficer($uid); + } + + $this->appConfig->setValueString(Application::APP_ID, self::POLICY_KEY, $policy); + $this->appConfig->setValueString(Application::APP_ID, self::OFFICERS_KEY, (string)json_encode($named)); + $this->appConfig->setValueInt(Application::APP_ID, self::THRESHOLD_KEY, max(1, $threshold)); + + foreach (array_diff($named, $before) as $uid) { + $this->notifications->notify(subject: 'recovery_officer_named', recipientId: $uid); + } + + return $this->settings() + ['removed' => $removed]; + }//end update() + + /** + * Refuse an officer without an active suite: they could not hold a copy. + * + * @param string[] $officers The officer user ids + * + * @return void + * + * @throws InvalidArgumentException + */ + private function assertOfficersUsable(array $officers): void { + foreach ($officers as $uid) { + try { + $this->suiteMapper->findActiveByOwner('user', $uid); + } catch (DoesNotExistException) { + throw new InvalidArgumentException(message: 'Officer ' . $uid . ' has no active encryption suite'); + } + } + }//end assertOfficersUsable() + + /** + * Refuse recovery without officers, and a threshold outside 1..officers. + * + * @param string $policy The policy + * @param string[] $officers The officer user ids + * @param int $threshold The threshold + * + * @return void + * + * @throws InvalidArgumentException + */ + private function assertThresholdFits(string $policy, array $officers, int $threshold): void { + if ($policy !== 'off' && $officers === []) { + throw new InvalidArgumentException(message: 'Name at least one officer before turning recovery on'); + } + + if ($officers !== [] && ($threshold < 1 || $threshold > count($officers))) { + throw new InvalidArgumentException(message: 'threshold must be between 1 and the number of officers'); + } + }//end assertThresholdFits() +}//end class diff --git a/lib/Service/RecoveryRequestService.php b/lib/Service/RecoveryRequestService.php new file mode 100644 index 000000000..c2fcff4b6 --- /dev/null +++ b/lib/Service/RecoveryRequestService.php @@ -0,0 +1,615 @@ + + * @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\Service; + +use DateInterval; +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\RecoveryApproval; +use OCA\Keepiq\Db\RecoveryApprovalMapper; +use OCA\Keepiq\Db\RecoveryEnrolmentMapper; +use OCA\Keepiq\Db\RecoveryRequest; +use OCA\Keepiq\Db\RecoveryRequestMapper; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use Ramsey\Uuid\Uuid; + +/** + * Recovery requests and officer approvals (crypto-organisation-account- + * recovery D3, D4, D6). The server counts distinct proven approvals and + * releases each piece of handoff material only to whoever it is for. A + * caller something is not for gets the answer an unknown request gets. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The request lifecycle across + * requests, approvals, enrolments, keys, notifications and the audit trail. + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One public method per step of + * the request lifecycle. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) Every step re-checks who may + * act on which state; keeping those checks next to each other is the point. + */ +class RecoveryRequestService { + + public const STATUS_PENDING = 'pending'; + + public const STATUS_APPROVED = 'approved'; + + public const STATUS_FULFILLED = 'fulfilled'; + + public const STATUS_DECLINED = 'declined'; + + public const STATUS_EXPIRED = 'expired'; + + /** + * Requests still in play. + * + * @var string[] + */ + public const OPEN = [self::STATUS_PENDING, self::STATUS_APPROVED]; + + /** + * Why a request was filed: a forgotten master password, or a new device + * to unlock once (crypto-new-device-approval D6). + * + * @var string[] + */ + public const PURPOSES = ['password', 'device']; + + /** + * How long a request stays open (D3): 72 hours. + * + * @var string + */ + public const TTL = 'PT72H'; + + /** + * Constructor for RecoveryRequestService. + * + * @param RecoveryRequestMapper $requests The request mapper + * @param RecoveryApprovalMapper $approvals The approval mapper + * @param RecoveryEnrolmentMapper $enrolments The enrolment mapper + * @param RecoveryEnrolmentService $enrolment The enrolment service + * @param RecoveryKeyService $keys The recovery keys + * @param RecoveryPolicyService $policy The officers and threshold + * @param NotificationService $notifications The notification dispatcher + * @param RecoveryAudit $audit The audit trail + * + * @return void + * + * @spec exclude Constructor wiring only. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor DI list. + */ + public function __construct( + private RecoveryRequestMapper $requests, + private RecoveryApprovalMapper $approvals, + private RecoveryEnrolmentMapper $enrolments, + private RecoveryEnrolmentService $enrolment, + private RecoveryKeyService $keys, + private RecoveryPolicyService $policy, + private NotificationService $notifications, + private RecoveryAudit $audit, + ) { + }//end __construct() + + /** + * File a request from the lock screen with the browser's one-time key. + * Any earlier open request of the user ends. + * + * @param string $userId The user who forgot their master password + * @param string $publicKey The one-time X25519 public key (base64, raw 32 bytes) + * @param string $purpose `password` or `device` + * + * @return RecoveryRequest + * + * @throws ForbiddenException When the user is not enrolled or recovery is off + * @throws InvalidArgumentException When the key is malformed + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-a-recovery-request-carries-a-one-time-key-and-a-verification-phrase + */ + public function create(string $userId, string $publicKey, string $purpose = 'password'): RecoveryRequest { + if (in_array($purpose, self::PURPOSES, true) === false) { + throw new InvalidArgumentException(message: 'purpose must be password or device'); + } + + $raw = base64_decode($publicKey, true); + if ($raw === false || strlen($raw) !== 32) { + throw new InvalidArgumentException(message: 'publicKey must be a raw X25519 public key'); + } + + $enrolment = $this->enrolment->current(userId: $userId); + if ($enrolment === null || $this->policy->policy() === 'off') { + throw new ForbiddenException(message: 'You are not enrolled in account recovery'); + } + + $this->endOpenRequestsOf(userId: $userId); + + $now = new DateTime(); + $request = new RecoveryRequest(); + $request->setId(Uuid::uuid4()->toString()); + $request->setUserId($userId); + $request->setSuiteId($enrolment->getSuiteId()); + $request->setEnrolmentId($enrolment->getId()); + $request->setRequestPublicKey($publicKey); + $request->setStatus(self::STATUS_PENDING); + $request->setPurpose($purpose); + $request->setCreatedAt($now); + $request->setExpiresAt((clone $now)->add(new DateInterval(self::TTL))); + $request = $this->requests->insert($request); + + $this->notifyOfficers(request: $request); + + $this->audit->record( + actorId: $userId, + eventType: RecoveryAudit::REQUESTED, + objectId: $request->getId(), + metadata: ['userId' => $userId] + ); + + return $request; + }//end create() + + /** + * The open requests an officer may act on, with the approval count. + * Never the officer's own request, never key material. + * + * @param string $officerUid The officer + * + * @return array> + * + * @throws ForbiddenException When the caller is not an officer + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + public function forOfficer(string $officerUid): array { + $this->assertOfficer(officerUid: $officerUid); + $rows = []; + foreach ($this->requests->findByStatuses(self::OPEN) as $request) { + if ($request->getUserId() === $officerUid || $this->isLapsed(request: $request) === true) { + continue; + } + + $decisions = $this->approvals->findByRequest($request->getId()); + $rows[] = [ + 'id' => $request->getId(), + 'userId' => $request->getUserId(), + 'status' => $request->getStatus(), + 'purpose' => $request->getPurpose(), + 'requestPublicKey' => $request->getRequestPublicKey(), + 'createdAt' => $request->getCreatedAt()?->format('c'), + 'expiresAt' => $request->getExpiresAt()?->format('c'), + 'approvals' => count($this->approverIds(decisions: $decisions)), + 'threshold' => $this->policy->threshold(), + 'approvedByMe' => in_array($officerUid, $this->approverIds(decisions: $decisions), true), + 'handedOff' => ($request->getHandledBy() !== null), + ]; + } + + return $rows; + }//end forOfficer() + + /** + * Count one officer's approval; move the request to approved at the + * threshold. The vault-key proof is checked before this runs. + * + * @param string $id The request + * @param string $officerUid The officer + * + * @return string The request status afterwards + * + * @throws ForbiddenException When the caller is not an officer or approves their own request + * @throws NotFoundException When the request is not open + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + public function approve(string $id, string $officerUid): string { + $this->assertOfficer(officerUid: $officerUid); + $request = $this->loadOpen(id: $id); + if ($request->getUserId() === $officerUid) { + throw new ForbiddenException(message: 'You cannot approve the recovery of your own account'); + } + + $decisions = $this->approvals->findByRequest($id); + if (in_array($officerUid, $this->approverIds(decisions: $decisions), true) === false) { + $decision = new RecoveryApproval(); + $decision->setId(Uuid::uuid4()->toString()); + $decision->setRequestId($id); + $decision->setOfficerUid($officerUid); + $decision->setDecision('approve'); + $decision->setDecidedAt(new DateTime()); + $decisions[] = $this->approvals->insert($decision); + } + + $count = count($this->approverIds(decisions: $decisions)); + if ($request->getStatus() === self::STATUS_PENDING && $count >= $this->policy->threshold()) { + $request->setStatus(self::STATUS_APPROVED); + $this->requests->update($request); + } + + $this->audit->record( + actorId: $officerUid, + eventType: RecoveryAudit::APPROVED, + objectId: $id, + metadata: ['userId' => $request->getUserId(), 'approvals' => $count, 'threshold' => $this->policy->threshold()] + ); + + return $request->getStatus(); + }//end approve() + + /** + * Decline: any officer may end a request. + * + * @param string $id The request + * @param string $officerUid The officer + * + * @return void + * + * @throws ForbiddenException When the caller is not an officer + * @throws NotFoundException When the request is not open + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-recovery-needs-a-threshold-of-proven-officer-approvals + */ + public function decline(string $id, string $officerUid): void { + $this->assertOfficer(officerUid: $officerUid); + $request = $this->loadOpen(id: $id); + $request->setStatus(self::STATUS_DECLINED); + $request->setSealedResult(null); + $this->requests->update($request); + $this->notifications->notify(subject: 'recovery_declined', recipientId: $request->getUserId()); + $this->audit->record( + actorId: $officerUid, + eventType: RecoveryAudit::DECLINED, + objectId: $id, + metadata: ['userId' => $request->getUserId()] + ); + }//end decline() + + /** + * The handoff material, only for an officer who approved an approved + * request: the user's enrolment envelope and the officer's own copy of + * the recovery key it is wrapped to. + * + * @param string $id The request + * @param string $officerUid The officer + * + * @return array{requestPublicKey:string,envelope:string,wrappedRecoveryKey:string,recoveryKeyId:string} + * + * @throws NotFoundException For every caller and state it is not for + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + public function handoff(string $id, string $officerUid): array { + $request = $this->loadForApprover(id: $id, officerUid: $officerUid); + try { + $enrolment = $this->enrolments->findById($request->getEnrolmentId()); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Request not found'); + } + + $copy = $this->keys->ownCopy(officerUid: $officerUid, recoveryKeyId: $enrolment->getRecoveryKeyId()); + + return [ + 'requestPublicKey' => $request->getRequestPublicKey(), + 'envelope' => $enrolment->getEnvelope(), + 'wrappedRecoveryKey' => $copy->getWrappedPrivateKey(), + 'recoveryKeyId' => $enrolment->getRecoveryKeyId(), + ]; + }//end handoff() + + /** + * Store the private key an approving officer sealed to the request key. + * + * @param string $id The request + * @param string $officerUid The officer + * @param string $sealed The HPKE-sealed private key + * + * @return void + * + * @throws NotFoundException For every caller and state it is not for + * @throws InvalidArgumentException When the sealed result is empty + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + public function postSealed(string $id, string $officerUid, string $sealed): void { + if (trim($sealed) === '') { + throw new InvalidArgumentException(message: 'sealedResult is required'); + } + + $request = $this->loadForApprover(id: $id, officerUid: $officerUid); + $request->setSealedResult($sealed); + $request->setHandledBy($officerUid); + $this->requests->update($request); + $this->notifications->notify(subject: 'recovery_ready', recipientId: $request->getUserId()); + $this->audit->record( + actorId: $officerUid, + eventType: RecoveryAudit::HANDED_OFF, + objectId: $id, + metadata: ['userId' => $request->getUserId()] + ); + }//end postSealed() + + /** + * The user's own latest request, with the sealed result once it exists. + * + * @param string $userId The user + * + * @return array|null + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + public function forUser(string $userId): ?array { + $request = ($this->requests->findByUser($userId)[0] ?? null); + if ($request === null) { + return null; + } + + $status = $request->getStatus(); + if (in_array($status, self::OPEN, true) === true && $this->isLapsed(request: $request) === true) { + $status = self::STATUS_EXPIRED; + } + + $view = [ + 'id' => $request->getId(), + 'status' => $status, + 'purpose' => $request->getPurpose(), + 'createdAt' => $request->getCreatedAt()?->format('c'), + 'expiresAt' => $request->getExpiresAt()?->format('c'), + 'suiteId' => $request->getSuiteId(), + 'handledBy' => $request->getHandledBy(), + ]; + if ($status === self::STATUS_APPROVED && $request->getSealedResult() !== null) { + $view['sealedResult'] = $request->getSealedResult(); + } + + return $view; + }//end forUser() + + /** + * Mark the user's request fulfilled after their browser re-wrapped the + * suite under a new master password, and delete the sealed result. + * + * @param string $id The request + * @param string $userId The user + * + * @return string The officer who handled it + * + * @throws NotFoundException For every caller and state it is not for + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-the-recovered-key-reaches-only-the-requesting-browser + */ + public function complete(string $id, string $userId): string { + $request = $this->load(id: $id); + if ($request->getUserId() !== $userId + || $request->getStatus() !== self::STATUS_APPROVED + || $request->getSealedResult() === null + ) { + throw new NotFoundException(message: 'Request not found'); + } + + $request->setStatus(self::STATUS_FULFILLED); + $request->setSealedResult(null); + $request->setFulfilledAt(new DateTime()); + $this->requests->update($request); + $handledBy = (string)$request->getHandledBy(); + $this->audit->record( + actorId: $userId, + eventType: RecoveryAudit::COMPLETED, + objectId: $id, + metadata: ['handledBy' => $handledBy] + ); + + return $handledBy; + }//end complete() + + /** + * Expire open requests past their 72 hours and drop any sealed result. + * + * @param DateTime $now The current time + * + * @return int The number expired + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-a-recovery-request-carries-a-one-time-key-and-a-verification-phrase + */ + public function expireLapsed(DateTime $now): int { + $count = 0; + foreach ($this->requests->findLapsed(self::OPEN, $now) as $request) { + $request->setStatus(self::STATUS_EXPIRED); + $request->setSealedResult(null); + $this->requests->update($request); + $this->audit->record( + actorId: $request->getUserId(), + eventType: RecoveryAudit::EXPIRED, + objectId: $request->getId(), + metadata: ['userId' => $request->getUserId()] + ); + ++$count; + } + + return $count; + }//end expireLapsed() + + /** + * End the open requests of a revoked suite (D7). + * + * @param string $suiteId The suite + * + * @return int The number ended + * + * @spec openspec/specs/organisation-account-recovery/spec.md#requirement-enrolments-and-officer-copies-follow-the-suite + */ + public function endForSuite(string $suiteId): int { + $count = 0; + foreach ($this->requests->findBySuite($suiteId) as $request) { + if (in_array($request->getStatus(), self::OPEN, true) === false) { + continue; + } + + $request->setStatus(self::STATUS_DECLINED); + $request->setSealedResult(null); + $this->requests->update($request); + ++$count; + } + + return $count; + }//end endForSuite() + + /** + * End the user's earlier open requests: one request at a time. + * + * @param string $userId The user + * + * @return void + */ + private function endOpenRequestsOf(string $userId): void { + foreach ($this->requests->findByUser($userId) as $old) { + if (in_array($old->getStatus(), self::OPEN, true) === true) { + $old->setStatus(self::STATUS_EXPIRED); + $old->setSealedResult(null); + $this->requests->update($old); + } + } + }//end endOpenRequestsOf() + + /** + * Tell every officer but the requester about a new request. + * + * @param RecoveryRequest $request The request + * + * @return void + */ + private function notifyOfficers(RecoveryRequest $request): void { + foreach ($this->policy->officers() as $officer) { + if ($officer === $request->getUserId()) { + continue; + } + + $this->notifications->notify( + subject: 'recovery_requested', + recipientId: $officer, + params: ['user' => $request->getUserId()], + objectType: 'account_recovery', + objectId: $request->getId(), + ); + } + }//end notifyOfficers() + + /** + * An approved request the officer approved, or the unknown-request answer. + * + * @param string $id The request + * @param string $officerUid The officer + * + * @return RecoveryRequest + * + * @throws NotFoundException + */ + private function loadForApprover(string $id, string $officerUid): RecoveryRequest { + $request = $this->load(id: $id); + $allowed = $this->policy->isOfficer(userId: $officerUid) + && $request->getStatus() === self::STATUS_APPROVED + && $this->isLapsed(request: $request) === false + && in_array($officerUid, $this->approverIds(decisions: $this->approvals->findByRequest($id)), true); + if ($allowed === false) { + throw new NotFoundException(message: 'Request not found'); + } + + return $request; + }//end loadForApprover() + + /** + * A pending, unexpired request, or the unknown-request answer. + * + * @param string $id The request + * + * @return RecoveryRequest + * + * @throws NotFoundException + */ + private function loadOpen(string $id): RecoveryRequest { + $request = $this->load(id: $id); + if ($request->getStatus() !== self::STATUS_PENDING || $this->isLapsed(request: $request) === true) { + throw new NotFoundException(message: 'Request not found'); + } + + return $request; + }//end loadOpen() + + /** + * A request by id, or the unknown-request answer. + * + * @param string $id The request + * + * @return RecoveryRequest + * + * @throws NotFoundException + */ + private function load(string $id): RecoveryRequest { + try { + return $this->requests->findById($id); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Request not found'); + } + }//end load() + + /** + * The distinct officers who approved. + * + * @param array $decisions The decisions + * + * @return string[] + */ + private function approverIds(array $decisions): array { + $ids = []; + foreach ($decisions as $decision) { + if ($decision->getDecision() === 'approve' && in_array($decision->getOfficerUid(), $ids, true) === false) { + $ids[] = $decision->getOfficerUid(); + } + } + + return $ids; + }//end approverIds() + + /** + * Refuse a caller who is not a named officer. + * + * @param string $officerUid The caller + * + * @return void + * + * @throws ForbiddenException + */ + private function assertOfficer(string $officerUid): void { + if ($this->policy->isOfficer(userId: $officerUid) === false) { + throw new ForbiddenException(message: 'Only a recovery officer can do this'); + } + }//end assertOfficer() + + /** + * Whether a request is past its expiry. + * + * @param RecoveryRequest $request The request + * + * @return bool + */ + private function isLapsed(RecoveryRequest $request): bool { + $expires = $request->getExpiresAt(); + return $expires === null || $expires <= new DateTime(); + }//end isLapsed() +}//end class diff --git a/lib/Service/RotationPolicyService.php b/lib/Service/RotationPolicyService.php index 549586526..e796898d4 100644 --- a/lib/Service/RotationPolicyService.php +++ b/lib/Service/RotationPolicyService.php @@ -162,6 +162,36 @@ private function configuredExpiryCandidates(Secret $secret, DateTime $base): arr return $candidates; }//end configuredExpiryCandidates() + /** + * The reminder days the policies that scope this secret ask for: the + * union of every applicable policy's own `reminder_days`, or null when + * none sets any, so the caller falls back to the instance thresholds + * (keepiq#746). + * + * @param Secret $secret The secret row + * + * @return array|null + * + * @spec openspec/changes/rotation-expiry-policies/specs/rotation-expiry-policies/spec.md + */ + public function reminderDaysFor(Secret $secret): ?array { + $days = null; + foreach ($this->policyMapper->findApplicable(ownerId: $secret->getOwnerId()) as $policy) { + $own = $policy->decodedReminderDays(); + if ($own === null || $this->policyApplies(policy: $policy, secret: $secret) === false) { + continue; + } + + $days = array_merge($days ?? [], array_map('intval', $own)); + } + + if ($days === null) { + return null; + } + + return array_values(array_unique($days)); + }//end reminderDaysFor() + /** * Whether a policy's scope matches a secret. * @@ -188,6 +218,8 @@ private function policyApplies(ExpiryPolicy $policy, Secret $secret): bool { * @param string $userId The caller * * @return ExpiryPolicy[] + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-expiry-policies-with-admin-default-and-user-override */ public function listPolicies(string $userId): array { return $this->policyMapper->findApplicable(ownerId: $userId); @@ -325,6 +357,8 @@ private function findScopedPolicy(string $userId, string $scope, string $scopeId * @return void * * @throws InvalidArgumentException On not found / foreign owner + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-expiry-policies-with-admin-default-and-user-override */ public function deletePolicy(string $policyId, string $userId): void { try { diff --git a/lib/Service/SecretChildDataCleaner.php b/lib/Service/SecretChildDataCleaner.php index 6651f8f8e..b364fb785 100644 --- a/lib/Service/SecretChildDataCleaner.php +++ b/lib/Service/SecretChildDataCleaner.php @@ -30,6 +30,7 @@ namespace OCA\Keepiq\Service; use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretTagMapper; /** * Cascades attachment, grant and version-history removal for secrets. @@ -41,6 +42,7 @@ class SecretChildDataCleaner { * @param SecretMapper $secretMapper The secret mapper * @param AttachmentService|null $attachmentService The attachment service (delete cascade) * @param SecretVersionService|null $versionService The version-history service (delete cascade) + * @param SecretTagMapper|null $tagMapper The tag mapper (delete cascade) * * @return void * @@ -50,6 +52,7 @@ public function __construct( private SecretMapper $secretMapper, private ?AttachmentService $attachmentService = null, private ?SecretVersionService $versionService = null, + private ?SecretTagMapper $tagMapper = null, ) { }//end __construct() @@ -62,7 +65,7 @@ public function __construct( * @spec exclude Predicate over injected collaborators; no spec behaviour of its own. */ public function hasCascades(): bool { - return $this->attachmentService !== null || $this->versionService !== null; + return $this->attachmentService !== null || $this->versionService !== null || $this->tagMapper !== null; }//end hasCascades() /** @@ -80,6 +83,8 @@ public function purgeForSecret(string $secretId): void { $this->attachmentService?->deleteGrantsForSecretCopy($secretId); // Version-history cascade (secret-version-history §5.2). $this->versionService?->deleteForSecret($secretId); + // The holder's tags (vault-favourites-tags-and-last-used). + $this->tagMapper?->deleteBySecret($secretId); }//end purgeForSecret() /** @@ -114,6 +119,9 @@ public function purgeForFolder(string $folderId): void { * @spec openspec/changes/encrypted-attachments/tasks.md#3.2 */ public function purgeForOwnerUser(string $userId): void { + // Every tag the user set, on rows they own or hold as a copy. + $this->tagMapper?->deleteByOwner($userId); + if ($this->hasCascades() === false) { return; } diff --git a/lib/Service/SecretOrganisationService.php b/lib/Service/SecretOrganisationService.php new file mode 100644 index 000000000..762fa7100 --- /dev/null +++ b/lib/Service/SecretOrganisationService.php @@ -0,0 +1,153 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretTagMapper; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; + +/** + * The holder's star, tags and last-used time on their own rows. + */ +class SecretOrganisationService { + /** + * Constructor for SecretOrganisationService. + * + * @param SecretMapper $mapper The secret mapper + * @param SecretTagMapper $tagMapper The tag mapper + * @param SecretTagNormaliser $normaliser The tag normaliser + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private SecretMapper $mapper, + private SecretTagMapper $tagMapper, + private SecretTagNormaliser $normaliser = new SecretTagNormaliser(), + ) { + }//end __construct() + + /** + * Star or unstar the holder's row. + * + * @param string $id The row + * @param string $userId The holder + * @param bool $favourite The new star + * + * @return array{id: string, favourite: bool} + * + * @throws NotFoundException When the user does not hold the row + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-favourite-items-per-holder + */ + public function setFavourite(string $id, string $userId, bool $favourite): array { + $this->held(id: $id, userId: $userId); + $this->mapper->setFavourite($id, $userId, $favourite); + + return ['id' => $id, 'favourite' => $favourite]; + }//end setFavourite() + + /** + * Replace the tags on the holder's row. + * + * @param string $id The row + * @param string $userId The holder + * @param array $tags The tags as sent + * + * @return list The stored tags + * + * @throws NotFoundException When the user does not hold the row + * @throws InvalidArgumentException When a tag is too long or there are too many + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function setTags(string $id, string $userId, array $tags): array { + $this->held(id: $id, userId: $userId); + $normalised = $this->normaliser->normalise(tags: $tags); + $this->tagMapper->replaceForSecret($id, $userId, $normalised); + + return $normalised; + }//end setTags() + + /** + * The holder's tags with how many live secrets carry each. + * + * @param string $userId The holder + * + * @return list + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function listTags(string $userId): array { + return $this->tagMapper->countByOwner($userId); + }//end listTags() + + /** + * Record that the holder used the row now (a fill from the extension). + * + * @param string $id The row + * @param string $userId The holder + * + * @return void + * + * @throws NotFoundException When the user does not hold the row + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + public function markUsed(string $id, string $userId): void { + $this->held(id: $id, userId: $userId); + $this->mapper->markUsed($id, $userId, new DateTime()); + }//end markUsed() + + /** + * Load a row the user holds; anything else is not found. + * + * @param string $id The row + * @param string $userId The user + * + * @return void + * + * @throws NotFoundException When the row is missing or not the user's + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used + */ + private function held(string $id, string $userId): void { + try { + $secret = $this->mapper->findById($id); + } catch (DoesNotExistException|MultipleObjectsReturnedException) { + throw new NotFoundException(message: 'Secret not found'); + } + + if ($secret->getOwnerType() !== 'user' || $secret->getOwnerId() !== $userId) { + throw new NotFoundException(message: 'Secret not found'); + } + }//end held() +}//end class diff --git a/lib/Service/SecretRequestService.php b/lib/Service/SecretRequestService.php index 1f5495c28..048a1302e 100644 --- a/lib/Service/SecretRequestService.php +++ b/lib/Service/SecretRequestService.php @@ -287,6 +287,22 @@ private function persistFilledValues( ); } + $this->writeFilledValues(secret: $secret, data: $data); + }//end persistFilledValues() + + /** + * Write the filled values through the owner's update path: the + * application's, or the user's after holding back a filled extra-field + * blob that would overwrite the owner's own (keepiq#750). + * + * @param Secret $secret The secret the request writes to + * @param array $data The values to write + * + * @return void + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + private function writeFilledValues(Secret $secret, array $data): void { $secretService = $this->container->get(SecretService::class); if ($secret->getOwnerType() === 'application') { @@ -299,12 +315,49 @@ private function persistFilledValues( return; } + $data = $this->holdBackAdditionalFields(secret: $secret, data: $data); + if ($data === []) { + return; + } + $secretService->update( id: $secret->getId(), data: $data, userId: $secret->getOwnerId() ); - }//end persistFilledValues() + }//end writeFilledValues() + + /** + * Keep a filled extra-field blob apart when the owner already has one + * (keepiq#750). The filler cannot read the owner's blob, so storing the + * filled one in its place wiped every other extra field. It is appended + * to the secret's pending list instead, and the owner's browser merges + * it on the next open. With no stored blob there is nothing to lose and + * the filled blob is written as is. + * + * @param Secret $secret The user-owned secret the request writes to + * @param array $data The values to write + * + * @return array The values still to write through update() + * + * @spec openspec/specs/secret-requests/spec.md#requirement-requestable-fields + */ + private function holdBackAdditionalFields(Secret $secret, array $data): array { + $blob = $data[SecretRequestPolicy::ADDITIONAL_BLOB] ?? null; + $stored = $secret->getAdditionalFields(); + if (is_string($blob) === false || $stored === null || $stored === '') { + return $data; + } + + $pending = $secret->pendingAdditionalFieldList(); + $pending[] = $blob; + $secret->setPendingAdditionalFieldList($pending); + $this->secretMapper->update($secret); + + unset($data[SecretRequestPolicy::ADDITIONAL_BLOB]); + + return $data; + }//end holdBackAdditionalFields() /** * Create a new pending secret request. diff --git a/lib/Service/SecretService.php b/lib/Service/SecretService.php index 759e9acf8..a46331d41 100644 --- a/lib/Service/SecretService.php +++ b/lib/Service/SecretService.php @@ -36,6 +36,7 @@ use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretDelegationMapper; use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\SecretTagMapper; use OCA\Keepiq\Event\Audit\AuditEvent; use OCA\Keepiq\Event\Audit\AuditEventFactory; use OCA\Keepiq\Event\Audit\AuditEventTypes; @@ -137,6 +138,11 @@ class SecretService { * @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 + * @param SecretTagMapper|null $tagMapper The holder's tags (vault-favourites-tags-and-last-used): + * list rows carry them, a delete removes them + * @param OrgOwnershipGuard|null $orgOwnership The team folder ownership policy (admin-vault-policies) + * @param OfflineEditGuard $editGuard Refuses an offline edit made on an older version + * @param FederatedShareService|null $federatedShares Tells partner recipients of a new name or URL * * @return void */ @@ -158,6 +164,10 @@ public function __construct( private ?RotationPolicyService $rotationService = null, private AuditEventFactory $auditEvents = new AuditEventFactory(), private ?FolderOwnershipGuard $folderOwnership = null, + private ?SecretTagMapper $tagMapper = null, + private ?OrgOwnershipGuard $orgOwnership = null, + private OfflineEditGuard $editGuard = new OfflineEditGuard(), + private ?FederatedShareService $federatedShares = null, ) { }//end __construct() @@ -263,6 +273,10 @@ public function create(array $data, string $userId, bool $allowUnfilled = false) $userId ); + // Work logins live in team folders when the policy says so + // (admin-vault-policies D4). Import commits through here too. + $this->orgOwnership?->assertAllowed(userId: $userId, typeId: $typeId, folderId: $folderId); + $now = new DateTime(); $secret = new Secret(); $secret->setId(Uuid::uuid4()->toString()); @@ -331,10 +345,15 @@ 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. + // Keepiq#873: a secret may only sit in a folder its OWNER owns, because + // the folder owner's delete purges every secret in it. The owner here + // is the application, which owns no folder, so this path refuses any + // folder exactly like the machine paths do. Without this, the writing + // user could purge the application's secret by deleting their folder. $folderId = $this->nullableString(value: $data['folderId'] ?? null); - $this->requireFolderOwnedBy(folderId: $folderId, userId: $writingUserId); + if ($folderId !== null) { + throw new InvalidArgumentException('An application cannot file a secret in a folder'); + } try { $suite = $this->suiteMapper->findActiveByOwner('application', $applicationId); @@ -519,9 +538,11 @@ public function createByApplication( * @throws InvalidArgumentException When a submitted field is invalid * * @SuppressWarnings(PHPMD.CyclomaticComplexity) Each updatable field is an - * independent, flat partial-update branch. + * independent, flat partial-update branch; every branch is pinned by + * tests/Unit/Service/SecretServiceMachineWriteTest.php (#152). * @SuppressWarnings(PHPMD.NPathComplexity) Same: the branches are - * independent partial-update guards, not nested logic. + * independent partial-update guards, not nested logic. Extracting them + * into helpers trips TooManyMethods on this class instead (measured). * * @spec openspec/changes/openconnector-secret-store-api/specs/secret-store-api/spec.md */ @@ -801,6 +822,7 @@ public function getByNameForApplication(string $name, string $applicationId): ?S * @throws SuiteBlockedException When the encryption suite is revoked/compromised * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-3.1 + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-sort-by-date-last-used */ public function get(string $id, string $userId): Secret { $secret = $this->loadOwned(id: $id, userId: $userId); @@ -822,6 +844,10 @@ public function get(string $id, string $userId): Secret { ) ); + // Opening the value is a use (vault-favourites-tags-and-last-used D3); + // the list and search never come through here. + $this->mapper->markUsed($secret->getId(), $userId, new DateTime()); + return $secret; }//end get() @@ -865,6 +891,7 @@ private function requireFolderOwnedBy(?string $folderId, string $userId): void { * @throws ForbiddenException When the secret belongs to another user * @throws WriteLockedException When a compromise-recovery migration is in progress * @throws InvalidArgumentException When a provided field is invalid + * @throws \OCA\Keepiq\Exception\StaleWriteException When `baseUpdatedAt` names an older version * * @SuppressWarnings(PHPMD.CyclomaticComplexity) Each updatable field is an * independent, flat partial-update branch. @@ -872,11 +899,18 @@ private function requireFolderOwnedBy(?string $folderId, string $userId): void { * independent partial-update guards, not nested logic. * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-3.1 + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) One partial-update guard per + * field, in the order the fields are applied; the vault policy check is one + * line. Splitting the field guards apart would scatter one update over + * several methods and the class is at its method limit. */ public function update(string $id, array $data, string $userId): Secret { $this->assertNotWriteLocked(userId: $userId); - $secret = $this->loadOwned(id: $id, userId: $userId); + $secret = $this->loadOwned(id: $id, userId: $userId)->assertEditableByHolder(fields: array_keys($data)); + + $data = $this->editGuard->checkedUpdate(secret: $secret, data: $data); // Pre-update snapshot source (secret-version-history §2.2): captured // BEFORE any mutation; persisted below only when a field actually @@ -909,6 +943,8 @@ public function update(string $id, array $data, string $userId): Secret { $secret->setTypeId($this->typeService->resolveTypeForSecret($data['typeId'], $userId)); } + $this->orgOwnership?->assertKept(secret: $secret, before: $preUpdate, userId: $userId); + if (array_key_exists('key', $data) === true) { $key = (string)$data['key']; if ($key === '') { @@ -923,15 +959,11 @@ public function update(string $id, array $data, string $userId): Secret { $secret->setKey($key); $secret->setKeyUpdatedAt(new DateTime()); - // The possibly-compromised warning says "this value was exposed, - // replace it at its source". Replacing the value is exactly what - // just happened, so the warning has been answered and is cleared. - // It is cleared HERE and nowhere else in this method on purpose: - // a rename, a folder move, a type change or a metadata edit - // leaves the exposed value in place and must leave the warning - // standing. The same-ciphertext guard above means a client that - // resends the unchanged key alongside a rename does not clear it - // either. + // The possibly-compromised warning says "replace this exposed value + // at its source"; a new value answers it, so it is cleared HERE and + // nowhere else: a rename, folder move, type change or metadata edit + // leaves the exposed value, and the warning, in place. A resent + // unchanged key does not clear it either (same-ciphertext guard). $secret->setPossiblyCompromisedAt(null); } }//end if @@ -942,6 +974,8 @@ public function update(string $id, array $data, string $userId): Secret { if (array_key_exists('additionalFields', $data) === true) { $secret->setAdditionalFields($this->nullableString(value: $data['additionalFields'])); + // Request-filled blobs the client merged into this one (keepiq#750). + $secret->dropMergedPending(count: (int)($data['mergedPending'] ?? 0)); } if ($this->shouldSnapshot(before: $preUpdate, after: $secret) === true) { @@ -951,6 +985,16 @@ public function update(string $id, array $data, string $userId): Secret { $secret->setUpdatedAt(new DateTime()); $this->mapper->update($secret); + // Recipients at partner organisations follow a new name or URL + // (sharing-federated-recipients 4.5). A new value reaches them through + // the browser's sync, which sends its own notification. + if ($this->federatedShares !== null + && array_intersect(['key', 'login', 'additionalFields'], array_keys($data)) === [] + && ($preUpdate->getName() !== $secret->getName() || $preUpdate->getUrl() !== $secret->getUrl()) + ) { + $this->federatedShares->detailsChanged(secretId: $secret->getId(), userId: $userId); + } + $changedFields = array_values( array_intersect( ['name', 'url', 'folderId', 'typeId', 'key', 'login', 'additionalFields'], @@ -976,6 +1020,8 @@ public function update(string $id, array $data, string $userId): Secret { * * @param string $id The secret ID * @param string $userId The requesting Nextcloud user ID + * @param string|null $purgeReason Set when this delete purges a trashed secret: + * 'owner' or 'retention' (vault-trash-and-archive) * * @return void * @@ -983,8 +1029,9 @@ public function update(string $id, array $data, string $userId): Secret { * @throws ForbiddenException When the secret belongs to another user * * @spec openspec/changes/add-secret-audit-trail/tasks.md#task-3.1 + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets */ - public function delete(string $id, string $userId): void { + public function delete(string $id, string $userId, ?string $purgeReason = null): void { $secret = $this->loadOwned(id: $id, userId: $userId); // Cascade to derived link shares + secret requests + user shares @@ -1023,18 +1070,40 @@ public function delete(string $id, string $userId): void { // Rotation-flag cascade (rotation-expiry-policies). $this->rotationService?->deleteForSecret($id); + // The holder's tags (vault-favourites-tags-and-last-used). + $this->tagMapper?->deleteBySecret($id); + $this->mapper->delete($secret); $this->logger->info("Keepiq: secret {$id} deleted by {$userId}"); - $this->dispatchAudit( - event: $this->auditEvents->forUser( + // A purge from the trash (vault-trash-and-archive) records its own + // event: by the owner, or by the system when the retention ran out. + $event = match ($purgeReason) { + null => $this->auditEvents->forUser( actorId: $userId, eventType: AuditEventTypes::SECRET_DELETED, objectType: 'secret', objectId: $id, objectName: $secret->getName(), - ) - ); + ), + 'retention' => $this->auditEvents->forSystem( + eventType: AuditEventTypes::SECRET_PURGED, + objectType: 'secret', + objectId: $id, + objectName: $secret->getName(), + metadata: ['reason' => $purgeReason], + ), + default => $this->auditEvents->forUser( + actorId: $userId, + eventType: AuditEventTypes::SECRET_PURGED, + objectType: 'secret', + objectId: $id, + objectName: $secret->getName(), + metadata: ['reason' => $purgeReason], + ), + }; + + $this->dispatchAudit(event: $event); }//end delete() /** @@ -1050,8 +1119,14 @@ public function delete(string $id, string $userId): void { * @param int $page The 1-based page number * @param int $limit The page size * @param string|null $typeId The secret-type filter (null = all types) + * @param string $state The trash/archive state (SecretMapper::STATE_*), live by default + * @param bool|null $favourite Only the user's starred secrets when true + * @param string|null $tag Only secrets the user tagged with this tag * * @return array{items: array>, total: int, page: int, limit: int} + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder */ public function list( string $userId, @@ -1061,16 +1136,19 @@ public function list( int $page, int $limit, ?string $typeId = null, + string $state = SecretMapper::STATE_LIVE, + ?bool $favourite = null, + ?string $tag = null, ): array { $limit = $this->clampLimit(limit: $limit); $page = max(1, $page); $offset = (($page - 1) * $limit); - $secrets = $this->mapper->findByOwner('user', $userId, $folderId, $sort, $direction, $limit, $offset, $typeId); - $total = $this->mapper->countByOwner('user', $userId, $folderId, $typeId); + $secrets = $this->mapper->findByOwner('user', $userId, $folderId, $sort, $direction, $limit, $offset, $typeId, $state, $favourite, $tag); + $total = $this->mapper->countByOwner('user', $userId, $folderId, $typeId, $state, $favourite, $tag); return [ - 'items' => array_map([$this, 'serialiseWithBlocking'], $secrets), + 'items' => $this->withTags(items: array_map([$this, 'serialiseWithBlocking'], $secrets), userId: $userId), 'total' => $total, 'page' => $page, 'limit' => $limit, @@ -1086,6 +1164,8 @@ public function list( * @param int $limit The page size * * @return array{items: array>, total: int, page: int, limit: int} + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder */ public function search(string $userId, string $term, int $page, int $limit): array { $limit = $this->clampLimit(limit: $limit); @@ -1105,7 +1185,7 @@ public function search(string $userId, string $term, int $page, int $limit): arr $window = array_slice($matched, $offset, $limit); return [ - 'items' => array_map([$this, 'serialiseWithBlocking'], $window), + 'items' => $this->withTags(items: array_map([$this, 'serialiseWithBlocking'], $window), userId: $userId), 'total' => $total, 'page' => $page, 'limit' => $limit, @@ -1128,6 +1208,8 @@ public function search(string $userId, string $term, int $page, int $limit): arr * early stop; scan to the ceiling) * * @return Secret[] + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret */ public function fuzzyMatch(string $userId, string $term, int $targetCount = 0): array { $tolerance = 2; @@ -1155,7 +1237,9 @@ public function fuzzyMatch(string $userId, string $term, int $targetCount = 0): 'name', 'asc', self::FUZZY_SCAN_PAGE_SIZE, - $offset + $offset, + null, + SecretMapper::STATE_LIVE ); if ($pageRows === []) { break; @@ -1254,6 +1338,30 @@ private function isFuzzyHit(Secret $secret, string $termLower, int $tolerance): return false; }//end isFuzzyHit() + /** + * Add the holder's tags to serialised secrets (vault-favourites-tags-and-last-used). + * Every item gets a `tags` list, empty when it has none or no tag mapper is wired. + * + * @param array> $items Serialised secrets of the user + * @param string $userId The holder + * + * @return array> + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function withTags(array $items, string $userId): array { + $tags = []; + if ($this->tagMapper !== null && $items !== []) { + $tags = $this->tagMapper->findTagsBySecretIds($userId, array_map(static fn (array $item): string => (string)$item['id'], $items)); + } + + foreach ($items as $index => $item) { + $items[$index]['tags'] = ($tags[(string)$item['id']] ?? []); + } + + return $items; + }//end withTags() + /** * Serialise a secret, withholding encrypted blobs when its suite blocks. * @@ -1328,6 +1436,13 @@ private function loadOwned(string $id, string $userId): Secret { throw new ForbiddenException(message: 'Secret belongs to another user'); } + // A copy whose access ended answers as an unknown secret + // (sharing-use-only-and-expiring-shares D5). + $accessEnds = $secret->getAccessExpiresAt(); + if ($accessEnds !== null && $accessEnds <= new DateTime()) { + throw new NotFoundException(message: 'Secret not found'); + } + return $secret; }//end loadOwned() @@ -1345,7 +1460,7 @@ private function loadOwned(string $id, string $userId): Secret { * * @throws SuiteBlockedException When no active suite exists * - * @spec openspec/changes/secret-import/specs/secret-import/spec.md#requirement-chunked-batch-commit + * @spec openspec/specs/secret-import/spec.md#requirement-chunked-batch-commit */ public function assertActiveSuite(string $userId): void { $this->getActiveSuiteOrBlock(userId: $userId); @@ -1407,14 +1522,23 @@ private function clampLimit(int $limit): int { * * @param string $id The secret UUID * @param string $userId The caller (must own the secret) + * @param string|null $baseUpdatedAt The version an offline change was made from; a + * secret changed since is refused (offline-edit-queue) * * @return Secret * * @throws NotFoundException When the secret does not exist * @throws ForbiddenException When the secret belongs to another user + * @throws \OCA\Keepiq\Exception\StaleWriteException When it changed since `baseUpdatedAt` + * + * @spec openspec/specs/rotation-expiry-policies/spec.md#requirement-per-secret-expiry-without-ciphertext-change + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently */ - public function findOwned(string $id, string $userId): Secret { - return $this->loadOwned(id: $id, userId: $userId); + public function findOwned(string $id, string $userId, ?string $baseUpdatedAt=null): Secret { + $secret = $this->loadOwned(id: $id, userId: $userId); + $this->editGuard->assertUnchangedSince(secret: $secret, baseUpdatedAt: $baseUpdatedAt); + + return $secret; }//end findOwned() /** diff --git a/lib/Service/SecretSharingRevoker.php b/lib/Service/SecretSharingRevoker.php new file mode 100644 index 000000000..d7dfbd96b --- /dev/null +++ b/lib/Service/SecretSharingRevoker.php @@ -0,0 +1,71 @@ + + * @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\Service; + +use OCA\Keepiq\Db\GroupShareMapper; +use OCA\Keepiq\Db\SecretDelegationMapper; + +/** + * Revokes the whole sharing graph of a secret. + */ +class SecretSharingRevoker { + /** + * Constructor for SecretSharingRevoker. + * + * @param LinkShareService $linkShareService Link shares of a secret + * @param SecretRequestService $secretRequestService Open secret requests of a secret + * @param ShareService $shareService User shares of a secret + * @param GroupShareMapper $groupShareMapper Group shares of a secret + * @param SecretDelegationMapper $delegationMapper Delegations of a secret + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private LinkShareService $linkShareService, + private SecretRequestService $secretRequestService, + private ShareService $shareService, + private GroupShareMapper $groupShareMapper, + private SecretDelegationMapper $delegationMapper, + ) { + }//end __construct() + + /** + * Revoke every share, request and delegation of a secret. + * + * @param string $secretId The secret ID + * + * @return void + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-deleting-a-secret-moves-it-to-the-trash + */ + public function revokeAll(string $secretId): void { + $this->linkShareService->deleteBySecretId($secretId); + $this->secretRequestService->deleteAllForSecret($secretId); + $this->shareService->deleteAllForSecret($secretId); + $this->groupShareMapper->deleteBySecret($secretId); + $this->delegationMapper->deleteBySecret($secretId); + }//end revokeAll() +}//end class diff --git a/lib/Service/SecretTagNormaliser.php b/lib/Service/SecretTagNormaliser.php new file mode 100644 index 000000000..bb21fce16 --- /dev/null +++ b/lib/Service/SecretTagNormaliser.php @@ -0,0 +1,79 @@ + + * @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\Service; + +use InvalidArgumentException; + +/** + * Turns the tags a user typed into the stored form + * (vault-favourites-tags-and-last-used D2). + */ +class SecretTagNormaliser { + /** + * The longest tag, in characters. + * + * @var int + */ + public const MAX_LENGTH = 32; + + /** + * The most tags one secret may carry. + * + * @var int + */ + public const MAX_TAGS = 20; + + /** + * Trim, lowercase, drop empty values and duplicates (first one wins). + * + * @param array $tags The tags as sent + * + * @return list + * + * @throws InvalidArgumentException When a tag is not text, is too long, or there are too many + * + * @spec openspec/changes/vault-favourites-tags-and-last-used/specs/vault-list-organisation/spec.md#requirement-tags-per-holder + */ + public function normalise(array $tags): array { + $normalised = []; + foreach ($tags as $tag) { + if (is_string($tag) === false) { + throw new InvalidArgumentException('A tag must be text'); + } + + $tag = mb_strtolower(trim($tag)); + if ($tag === '') { + continue; + } + + if (mb_strlen($tag) > self::MAX_LENGTH) { + throw new InvalidArgumentException('A tag can be at most '.self::MAX_LENGTH.' characters'); + } + + $normalised[$tag] = $tag; + } + + if (count($normalised) > self::MAX_TAGS) { + throw new InvalidArgumentException('A secret can have at most '.self::MAX_TAGS.' tags'); + } + + return array_values($normalised); + }//end normalise() +}//end class diff --git a/lib/Service/SecretTrashService.php b/lib/Service/SecretTrashService.php new file mode 100644 index 000000000..5719373a5 --- /dev/null +++ b/lib/Service/SecretTrashService.php @@ -0,0 +1,286 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Trash, restore, purge, archive and unarchive a user-owned secret. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) A delete ends every kind + * of access at once: local sharing, audit, and since + * sharing-federated-recipients 4.4 the share a read-only copy came from. + * Each is one injected collaborator; splitting them out would scatter the + * one trash step over several classes. + */ +class SecretTrashService { + /** + * Default days a trashed secret is kept (design D4). + * + * @var int + */ + public const RETENTION_DEFAULT = 30; + + /** + * Smallest retention an administrator may set. + * + * @var int + */ + public const RETENTION_MIN = 1; + + /** + * Largest retention an administrator may set. + * + * @var int + */ + public const RETENTION_MAX = 365; + + /** + * Builds the audit events this service records. + * + * @var AuditEventFactory + */ + private AuditEventFactory $auditEvents; + + /** + * Constructor for SecretTrashService. + * + * @param SecretMapper $mapper The secret mapper + * @param SecretService $secretService The secret service (the full delete cascade) + * @param SecretSharingRevoker $sharingRevoker Ends everybody else's access + * @param AuditService $auditService The audit recorder + * @param LoggerInterface $logger The logger + * @param FederatedCopyDeclineService|null $federatedDeclines Declines the share behind a deleted read-only copy + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private SecretMapper $mapper, + private SecretService $secretService, + private SecretSharingRevoker $sharingRevoker, + private AuditService $auditService, + private LoggerInterface $logger, + private ?FederatedCopyDeclineService $federatedDeclines = null, + ) { + $this->auditEvents = new AuditEventFactory(); + }//end __construct() + + /** + * Move a secret to the trash and end everybody else's access to it now. + * + * Link shares, open secret requests, user shares, group shares and + * delegations are revoked; the ciphertext, attachments, versions and + * rotation flags stay, so a restore gives the secret back whole (D2). + * A secret already in the trash is left as it is. + * + * @param string $id The secret ID + * @param string $userId The owner + * @param string|null $baseUpdatedAt The version an offline delete was made from + * + * @return Secret + * + * @throws \OCA\Keepiq\Exception\StaleWriteException When the secret changed since that version + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-deleting-a-secret-moves-it-to-the-trash + * @spec openspec/specs/offline-edit-queue/spec.md#requirement-concurrent-server-changes-are-never-overwritten-silently + */ + public function trash(string $id, string $userId, ?string $baseUpdatedAt=null): Secret { + // An offline delete names the version it was made from; a secret + // that changed since stays put (offline-edit-queue). + $secret = $this->secretService->findOwned($id, $userId, $baseUpdatedAt); + if ($secret->getTrashedAt() !== null) { + return $secret; + } + + $this->sharingRevoker->revokeAll(secretId: $id); + // A read-only copy from another organisation: the share it came + // from is declined and its owner told (sharing-federated-recipients 4.4). + $this->federatedDeclines?->copyDeleted(secret: $secret, userId: $userId); + + $secret->setTrashedAt(new DateTime()); + $secret->setArchivedAt(null); + $this->mapper->update($secret); + + $this->record(userId: $userId, type: AuditEventTypes::SECRET_TRASHED, secret: $secret); + + return $secret; + }//end trash() + + /** + * Take a secret out of the trash. It comes back unshared. A read-only + * copy from another organisation takes back the share it came from, + * when the owner still has it (sharing-federated-recipients, decision of + * 4 Oct 2026); `federatedShare` says what became of it, null for any + * other secret. + * + * @param string $id The secret ID + * @param string $userId The owner + * + * @return array{secret: Secret, federatedShare: string|null} + * + * @throws InvalidArgumentException When the secret is not in the trash + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + * @spec openspec/specs/federated-sharing/spec.md#scenario-bob-restores-his-copy + */ + public function restore(string $id, string $userId): array { + $secret = $this->loadTrashed(id: $id, userId: $userId); + $secret->setTrashedAt(null); + $this->mapper->update($secret); + + $this->record(userId: $userId, type: AuditEventTypes::SECRET_RESTORED, secret: $secret); + $federatedShare = $this->federatedDeclines?->copyRestored(secret: $secret, userId: $userId); + + return ['secret' => $secret, 'federatedShare' => $federatedShare]; + }//end restore() + + /** + * Delete a trashed secret for good, with the full delete cascade. + * + * @param string $id The secret ID + * @param string $userId The owner + * + * @return void + * + * @throws InvalidArgumentException When the secret is not in the trash + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + public function purge(string $id, string $userId): void { + $secret = $this->loadTrashed(id: $id, userId: $userId); + // A copy trashed before its share could be declined (task 4.4); the + // link a restore would have used goes with the copy. + $this->federatedDeclines?->copyPurged(secret: $secret, userId: $userId); + $this->secretService->delete($id, $userId, 'owner'); + }//end purge() + + /** + * Archive a secret: it leaves the vault list, search, autofill and the + * health report, and keeps its shares (D5). + * + * @param string $id The secret ID + * @param string $userId The owner + * + * @return Secret + * + * @throws InvalidArgumentException When the secret is in the trash + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + public function archive(string $id, string $userId): Secret { + $secret = $this->secretService->findOwned($id, $userId); + if ($secret->getTrashedAt() !== null) { + throw new InvalidArgumentException('A secret in the trash cannot be archived'); + } + + if ($secret->getArchivedAt() === null) { + $secret->setArchivedAt(new DateTime()); + $this->mapper->update($secret); + $this->record(userId: $userId, type: AuditEventTypes::SECRET_ARCHIVED, secret: $secret); + } + + return $secret; + }//end archive() + + /** + * Bring an archived secret back into the vault. + * + * @param string $id The secret ID + * @param string $userId The owner + * + * @return Secret + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-archiving-a-secret + */ + public function unarchive(string $id, string $userId): Secret { + $secret = $this->secretService->findOwned($id, $userId); + if ($secret->getArchivedAt() !== null) { + $secret->setArchivedAt(null); + $this->mapper->update($secret); + $this->record(userId: $userId, type: AuditEventTypes::SECRET_UNARCHIVED, secret: $secret); + } + + return $secret; + }//end unarchive() + + /** + * Load an owned secret that must be in the trash. + * + * @param string $id The secret ID + * @param string $userId The requester + * + * @return Secret + * + * @throws InvalidArgumentException When the secret is not in the trash + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + private function loadTrashed(string $id, string $userId): Secret { + $secret = $this->secretService->findOwned($id, $userId); + if ($secret->getTrashedAt() === null) { + throw new InvalidArgumentException('The secret is not in the trash'); + } + + return $secret; + }//end loadTrashed() + + /** + * Record a user event about a secret: ids and the item name only. + * + * @param string $userId The actor + * @param string $type The event type + * @param Secret $secret The secret + * + * @return void + * + * @spec openspec/specs/vault-trash-and-archive/spec.md#requirement-restoring-and-purging-trashed-secrets + */ + private function record(string $userId, string $type, Secret $secret): void { + try { + $this->auditService->record( + $this->auditEvents->forUser( + actorId: $userId, + eventType: $type, + objectType: 'secret', + objectId: $secret->getId(), + objectName: $secret->getName(), + ) + ); + } catch (Throwable $e) { + // Fail-soft: an audit failure never undoes the change. + $this->logger->error('Keepiq: audit entry could not be recorded: '.$e->getMessage(), ['exception' => $e]); + } + }//end record() +}//end class diff --git a/lib/Service/SecretTypeService.php b/lib/Service/SecretTypeService.php index cfce72ad6..6e60ac6b2 100644 --- a/lib/Service/SecretTypeService.php +++ b/lib/Service/SecretTypeService.php @@ -38,6 +38,28 @@ * Business logic for SecretType lifecycle. */ class SecretTypeService { + + /** + * The kinds a field of a type can have (admin-18). + * + * @var list + */ + public const FIELD_KINDS = ['text', 'hidden', 'url', 'email']; + + /** + * The most fields one type may carry. + * + * @var int + */ + public const MAX_FIELDS = 30; + + /** + * Labels a field may not take: its value is stored under the label in the + * additional-fields blob, and these names route to built-in columns. + * + * @var list + */ + private const RESERVED_LABELS = ['key', 'login', 'url']; /** * Constructor for SecretTypeService. * @@ -86,6 +108,8 @@ public function getSystemLoginType(): SecretType { * @return string The resolved, validated type ID * * @throws InvalidArgumentException When the type does not exist or is not available + * + * @spec openspec/specs/secrets/spec.md#requirement-secret-types */ public function resolveTypeForSecret(?string $typeId, string $userId): string { if ($typeId === null || $typeId === '') { @@ -113,6 +137,7 @@ public function resolveTypeForSecret(?string $typeId, string $userId): string { * @param string $scope The scope (user or global) * @param string $userId The requesting Nextcloud user ID * @param bool $isAdmin Whether the requester is an administrator + * @param array|null $fields The fields an item of this type carries (admin-18) * * @return SecretType * @@ -121,6 +146,7 @@ public function resolveTypeForSecret(?string $typeId, string $userId): string { * @throws ConflictException When the name already exists * * @spec openspec/specs/secrets/spec.md#requirement-secret-types + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions */ public function createType( string $name, @@ -128,6 +154,7 @@ public function createType( string $scope, string $userId, bool $isAdmin, + ?array $fields=null, ): SecretType { $name = trim($name); if ($name === '' || $label === '') { @@ -157,6 +184,7 @@ public function createType( $type->setLabel($label); $type->setScope($scope); $type->setOwnerId($ownerScopeId); + $type->setFieldList($this->normaliseFields(fields: $fields ?? [])); $type->setCreatedAt(new DateTime()); $this->mapper->insert($type); @@ -172,15 +200,23 @@ public function createType( * @param string $label The new label * @param string $userId The requesting Nextcloud user ID * @param bool $isAdmin Whether the requester is an administrator + * @param array|null $fields The new field list, or null to keep it (admin-18) * * @return SecretType * * @throws ForbiddenException When the type is a system type or not owned - * @throws InvalidArgumentException When the label is empty + * @throws InvalidArgumentException When the label or the field list is invalid * * @spec openspec/specs/secrets/spec.md#requirement-secret-types + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions */ - public function updateType(string $id, string $label, string $userId, bool $isAdmin): SecretType { + public function updateType( + string $id, + string $label, + string $userId, + bool $isAdmin, + ?array $fields=null, + ): SecretType { $label = trim($label); if ($label === '') { throw new InvalidArgumentException('Label is required'); @@ -189,12 +225,96 @@ public function updateType(string $id, string $label, string $userId, bool $isAd $type = $this->loadManageable(id: $id, userId: $userId, isAdmin: $isAdmin); $type->setLabel($label); + if ($fields !== null) { + $type->setFieldList($this->normaliseFields(fields: $fields)); + } + $this->mapper->update($type); $this->logger->info("Keepiq: secret type {$id} relabelled by {$userId}"); return $type; }//end updateType() + /** + * Validate a field list and bring it into its stored shape. + * + * Keys are unique, labels are unique (values are stored under the label) + * and not a built-in member name, kinds are known, at most MAX_FIELDS. + * + * @param array $fields The submitted fields + * + * @return list + * + * @throws InvalidArgumentException When the list is invalid + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ + private function normaliseFields(array $fields): array { + if (count($fields) > self::MAX_FIELDS) { + throw new InvalidArgumentException('A type can have at most '.self::MAX_FIELDS.' fields'); + } + + $out = []; + $seen = []; + foreach ($fields as $field) { + $normalised = $this->normaliseField(field: $field); + $keyId = 'k:'.$normalised['key']; + $labelId = 'l:'.mb_strtolower($normalised['label']); + if (isset($seen[$keyId]) === true || isset($seen[$labelId]) === true) { + throw new InvalidArgumentException('Field keys and labels must be unique'); + } + + $seen[$keyId] = true; + $seen[$labelId] = true; + $out[] = $normalised; + } + + return $out; + }//end normaliseFields() + + /** + * Validate one field definition and bring it into its stored shape. + * + * @param mixed $field The submitted field + * + * @return array{key: string, label: string, kind: string, required: bool} + * + * @throws InvalidArgumentException When the field is invalid + * + * @spec openspec/specs/admin-secret-types/spec.md#requirement-item-type-definitions + */ + private function normaliseField(mixed $field): array { + if (is_array($field) === false) { + throw new InvalidArgumentException('Each field must be an object'); + } + + $key = trim((string) ($field['key'] ?? '')); + $label = trim((string) ($field['label'] ?? '')); + $kind = (string) ($field['kind'] ?? ''); + if (preg_match('/^[a-z0-9][a-z0-9_-]{0,63}$/', $key) !== 1) { + throw new InvalidArgumentException('A field key must be lowercase letters, digits, dashes or underscores'); + } + + if ($label === '' || mb_strlen($label) > 128) { + throw new InvalidArgumentException('A field label is required and at most 128 characters'); + } + + if (in_array(mb_strtolower($label), self::RESERVED_LABELS, true) === true) { + throw new InvalidArgumentException("A field cannot be called '{$label}'"); + } + + if (in_array($kind, self::FIELD_KINDS, true) === false) { + throw new InvalidArgumentException("Unknown field kind '{$kind}'"); + } + + return [ + 'key' => $key, + 'label' => $label, + 'kind' => $kind, + 'required' => ($field['required'] ?? false) === true, + ]; + }//end normaliseField() + /** * Delete a custom SecretType, reassigning its secrets to the login type. * diff --git a/lib/Service/SecretVersionAccessGuard.php b/lib/Service/SecretVersionAccessGuard.php index baf9c9ec5..eb93e325d 100644 --- a/lib/Service/SecretVersionAccessGuard.php +++ b/lib/Service/SecretVersionAccessGuard.php @@ -88,10 +88,17 @@ public function isOwned(string $secretId, string $userId): bool { * @throws InvalidArgumentException On not found / not owned / suite blocked * * @spec openspec/changes/secret-version-history/specs/secret-version-history/spec.md + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce */ public function requireReadableVersion(string $versionId, string $userId): SecretVersion { $version = $this->loadOwnedVersion(versionId: $versionId, userId: $userId); + // The holder of a use-only copy never gets a version's ciphertext + // (sharing-use-only-and-expiring-shares D4). + if ($this->secretMapper->findById($version->getSecretId())->getUseOnly() === true) { + throw new InvalidArgumentException('Versions of a use-only copy are not available'); + } + if ($this->isSuiteBlocked(suiteId: $version->getEncryptionSuiteId()) === true) { throw new InvalidArgumentException( 'This version is locked because its encryption suite was revoked' diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 44b33ac5f..faf4a1687 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -108,6 +108,10 @@ class SettingsService { // Offline read-only cache per-device opt-out (offline-readonly-cache // §1.2); default on, gated behind the admin org-wide switch. 'offline_cache_optin' => '1', + // Receive secrets from other organisations (sharing-federated- + // recipients D6); default off. Until it is '1' a partner's + // certificate lookup gets the unknown-recipient answer. + 'federation_receive' => '0', ]; /** @@ -213,6 +217,37 @@ public function updateAdminSettings(array $data): array { return $this->adminSettings->updateAdminSettings(data: $data); }//end updateAdminSettings() + /** + * The settings of one admin area (admin-scoped-roles D2). + * + * @param string $area One of AdminSettingsService::SETTINGS_AREAS + * + * @return array + * + * @throws InvalidArgumentException On an unknown area. + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + public function getAreaSettings(string $area): array { + return $this->adminSettings->getAreaSettings(area: $area); + }//end getAreaSettings() + + /** + * Write one admin area's keys (admin-scoped-roles D2). + * + * @param string $area One of AdminSettingsService::SETTINGS_AREAS + * @param array $data The input data + * + * @return array The area's settings after the write + * + * @throws InvalidArgumentException On a key of another area or an out-of-bounds value. + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.1 + */ + public function updateAreaSettings(string $area, array $data): array { + return $this->adminSettings->updateAreaSettings(area: $area, data: $data); + }//end updateAreaSettings() + /** * The user-visible policy floor for the write dialogs — policy gate, * generator floor, score floor, HIBP block, and exempt types only @@ -221,9 +256,10 @@ public function updateAdminSettings(array $data): array { * @return array * * @spec openspec/changes/org-password-policies/specs/org-password-policies/spec.md + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group */ public function getPolicy(): array { - return $this->adminSettings->getPolicy(); + return $this->adminSettings->getPolicy(userId: $this->userSession->getUser()?->getUID()); }//end getPolicy() /** @@ -254,7 +290,11 @@ public function loadConfiguration(bool $force = false): array { */ public function getUserPreferences(string $userId): array { $appId = Application::APP_ID; - $adminDefault = $this->appConfig->getValueString($appId, 'default_session_timeout', 'session'); + $adminDefault = $this->appConfig->getValueString( + $appId, + 'default_session_timeout', + AdminSettingsService::DEFAULT_SESSION_TIMEOUT + ); $prefs = []; foreach (self::USER_PREF_KEYS as $key => $default) { diff --git a/lib/Service/ShareAuthorizationService.php b/lib/Service/ShareAuthorizationService.php index d5d3b133e..11e69bc53 100644 --- a/lib/Service/ShareAuthorizationService.php +++ b/lib/Service/ShareAuthorizationService.php @@ -33,7 +33,10 @@ use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretDelegationMapper; use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Exception\ForbiddenException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; /** * Authorization decisions for the secret-share lifecycle. @@ -46,6 +49,7 @@ class ShareAuthorizationService { * @param SecretDelegationMapper $delegationMapper The Delegation mapper (delegate authorization) * @param EncryptionSuiteMapper $suiteMapper The EncryptionSuite mapper (recipient precondition) * @param TeamFolderService|null $teamFolderService The team-folder service (write-grade resolution) + * @param ShareTargetMapper|null $shareTargetMapper Resolves a received copy to its source (keepiq#214) * * @return void * @@ -56,6 +60,7 @@ public function __construct( private SecretDelegationMapper $delegationMapper, private EncryptionSuiteMapper $suiteMapper, private ?TeamFolderService $teamFolderService = null, + private ?ShareTargetMapper $shareTargetMapper = null, ) { }//end __construct() @@ -124,6 +129,72 @@ public function isOwnerOrDelegate(Secret $secret, string $userId): bool { } }//end isOwnerOrDelegate() + /** + * Refuse unless $userId owns the secret or is an active delegate of it, + * the people whose share permits re-sharing (keepiq#214, a public link). + * A received copy is owned by its recipient, so a copy is judged by its + * SOURCE secret: a plain recipient fails, a delegate passes. A missing + * secret and a foreign one get the same DoesNotExistException. Fails closed + * when the share-target mapper is not wired. + * + * @param string $secretId The secret (source or received copy) id + * @param string $userId The acting user + * + * @return void + * + * @throws DoesNotExistException When the user may not re-share the secret + * @throws InvalidArgumentException When the copy is use-only or expiring + * @throws ForbiddenException When the copy is a read-only copy from another organisation + * + * @spec openspec/specs/link-sharing/spec.md#requirement-who-may-create-a-link-share + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce + */ + public function assertMayReshare(string $secretId, string $userId): void { + if ($this->shareTargetMapper === null) { + throw new DoesNotExistException('Secret not found'); + } + + try { + $copy = $this->loadSecret(secretId: $secretId); + $source = $this->sourceOf(copy: $copy); + } catch (InvalidArgumentException) { + throw new DoesNotExistException('Secret not found'); + } + + if ($this->isOwnerOrDelegate(secret: $source, userId: $userId) === false) { + throw new DoesNotExistException('Secret not found'); + } + + // A delegate's use-only or expiring copy still may not leave as a link. + $copy->assertOnwardShareable(); + }//end assertMayReshare() + + /** + * The source secret of a received copy, or the secret itself when it is + * not a copy. + * + * @param Secret $copy The secret that may be a received copy + * + * @return Secret + * + * @throws InvalidArgumentException When the copy's source cannot be loaded + */ + private function sourceOf(Secret $copy): Secret { + try { + $row = $this->shareTargetMapper?->findByRecipientSecret(recipientSecretId: $copy->getId()); + } catch (DoesNotExistException) { + return $copy; + } catch (MultipleObjectsReturnedException) { + throw new InvalidArgumentException(message: 'Ambiguous copy'); + } + + if ($row === null) { + throw new InvalidArgumentException(message: 'No share-target mapper'); + } + + return $this->loadSecret(secretId: $row->getSourceSecretId()); + }//end sourceOf() + /** * The caller's effective team-folder grade on a secret, or null when no * team folder governs it (folder-permission-grades §2.3). @@ -133,7 +204,7 @@ public function isOwnerOrDelegate(Secret $secret, string $userId): bool { * * @return string|null The grade ('read' | 'write' | …), or null. * - * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-or-write-grade + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-write-or-manage-grade */ public function resolveGrade(Secret $secret, string $userId): ?string { return $this->teamFolderService?->resolveGrade(secret: $secret, userId: $userId); diff --git a/lib/Service/ShareExpiryService.php b/lib/Service/ShareExpiryService.php new file mode 100644 index 000000000..fd9f4d594 --- /dev/null +++ b/lib/Service/ShareExpiryService.php @@ -0,0 +1,222 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTarget; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use Throwable; + +/** + * One run of the share expiry (sharing-use-only-and-expiring-shares D5, D6): + * warn the holders of copies whose access ends within a day, remove the + * grants whose end date passed, clean up copies left expired, and tell the + * holder and the owner of every copy whose access ended. + */ +class ShareExpiryService { + + /** + * Constructor for ShareExpiryService. + * + * @param SecretMapper $secretMapper The secret mapper (copies) + * @param ShareTargetMapper $targetMapper The share-target mapper + * @param ShareRestrictionResolver $restrictions The restriction resolver + * @param ExpiredGrantRemover $remover Removes expired grants through their own paths + * @param NotificationService $notifications The notification dispatcher + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $targetMapper, + private ShareRestrictionResolver $restrictions, + private ExpiredGrantRemover $remover, + private NotificationService $notifications, + ) { + }//end __construct() + + /** + * Warn every holder whose access ends in (from, to]. + * + * @param DateTime $from Exclusive lower bound + * @param DateTime $to Inclusive upper bound + * + * @return int The number of warnings sent + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-people-are-told-before-and-when-access-ends + */ + public function warnEnding(DateTime $from, DateTime $to): int { + $sent = 0; + foreach ($this->secretMapper->findAccessEndingBetween($from, $to) as $copy) { + $this->notifications->notify( + subject: 'share_access_ending', + recipientId: $copy->getOwnerId(), + params: [ + 'secret_id' => $copy->getId(), + 'secret_name' => $copy->getName(), + 'ends_at' => $copy->getAccessExpiresAt()?->format('c'), + ], + objectType: 'secret', + objectId: $copy->getId(), + ); + ++$sent; + } + + return $sent; + }//end warnEnding() + + /** + * Remove every expired grant and every copy left expired, and notify. + * + * @param DateTime $now The cut-off + * + * @return int The number of copies whose access ended in this run + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-a-background-job-removes-expired-access + */ + public function expire(DateTime $now): int { + // Remember who held what before the removals delete the copies. + $ended = []; + foreach ($this->secretMapper->findAccessEndingBetween(null, $now) as $copy) { + $ended[$copy->getId()] = [ + 'copy' => $copy, + 'target' => $this->targetOf(copyId: $copy->getId()), + ]; + } + + $this->remover->removeExpired(now: $now); + + $count = 0; + foreach ($ended as $copyId => $entry) { + if ($this->copyEnded(copyId: (string)$copyId, target: $entry['target'], now: $now) === false) { + continue; + } + + $this->notifyEnded(copy: $entry['copy'], target: $entry['target']); + ++$count; + } + + return $count; + }//end expire() + + /** + * Whether a copy that was expired at the start of the run is gone now. + * A copy a grant removal did not reach (a group-derived copy whose + * group share ended earlier, a stale value) is recomputed, and revoked + * when it is still expired. + * + * @param string $copyId The copy + * @param ShareTarget|null $target Its share row, as it was + * @param DateTime $now The cut-off + * + * @return bool True when the holder's access ended + */ + private function copyEnded(string $copyId, ?ShareTarget $target, DateTime $now): bool { + try { + $this->secretMapper->findById($copyId); + } catch (DoesNotExistException) { + return true; + } + + if ($target === null) { + return false; + } + + try { + $current = $this->targetMapper->findById($target->getId()); + } catch (DoesNotExistException) { + return true; + } + + $this->restrictions->resolveTarget(target: $current); + $effective = $this->restrictions->effectiveFor(target: $current); + if ($effective->expiresAt === null || $effective->expiresAt > $now) { + return false; + } + + try { + return $this->remover->revokeTarget(target: $current); + } catch (Throwable) { + return false; + } + }//end copyEnded() + + /** + * Tell the holder their access ended, and the owner that it did, with a + * rotation hint when the holder could see the value. + * + * @param Secret $copy The copy as it was + * @param ShareTarget|null $target Its share row, as it was + * + * @return void + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-people-are-told-before-and-when-access-ends + */ + private function notifyEnded(Secret $copy, ?ShareTarget $target): void { + $this->notifications->notify( + subject: 'share_access_ended', + recipientId: $copy->getOwnerId(), + params: ['secret_name' => $copy->getName()], + ); + + if ($target === null) { + return; + } + + try { + $source = $this->secretMapper->findById($target->getSourceSecretId()); + } catch (DoesNotExistException) { + return; + } + + $this->notifications->notify( + subject: 'share_access_ended_owner', + recipientId: $source->getOwnerId(), + params: [ + 'secret_id' => $source->getId(), + 'secret_name' => $source->getName(), + 'recipient' => $copy->getOwnerId(), + 'use_only' => ($copy->getUseOnly() === true), + ], + objectType: 'secret', + objectId: $source->getId(), + ); + }//end notifyEnded() + + /** + * The share row of a copy, or null when it has none. + * + * @param string $copyId The copy + * + * @return ShareTarget|null + */ + private function targetOf(string $copyId): ?ShareTarget { + try { + return $this->targetMapper->findByRecipientSecret(recipientSecretId: $copyId); + } catch (DoesNotExistException) { + return null; + } + }//end targetOf() +}//end class diff --git a/lib/Service/ShareRequestService.php b/lib/Service/ShareRequestService.php index 381019ac2..f0a1777bc 100644 --- a/lib/Service/ShareRequestService.php +++ b/lib/Service/ShareRequestService.php @@ -135,19 +135,19 @@ public function submitShareRequest( }//end submitShareRequest() /** - * Approve a share request — returns the parameters the calling - * controller hands to ShareService::createShare. The actual share - * creation is the controller's responsibility because the browser - * has to produce the recipient's RSA-encrypted Secret copy first. + * Approve a share request. The browser produces the recipient's + * RSA-encrypted copy and registers it first, then calls this to confirm: + * once the share exists the requester is told it was approved. * * @param array $params The notification parameters {sourceSecretId, requesterId, targetUserId} * @param string $ownerId The approver (must be the secret owner) * - * @return array{sourceSecretId:string,requesterId:string,targetUserId:string} + * @return array{sourceSecretId:string,requesterId:string,targetUserId:string,shared:bool} * * @throws InvalidArgumentException When the approver is not the owner * * @spec openspec/changes/implement-user-sharing/tasks.md#5.2 + * @spec openspec/specs/user-sharing/spec.md#requirement-share-request-recipient-initiated */ public function approveShareRequest(array $params, string $ownerId): array { $sourceSecretId = (string)($params['sourceSecretId'] ?? ''); @@ -165,10 +165,39 @@ public function approveShareRequest(array $params, string $ownerId): array { ); } + // The browser encrypts and registers the copy first, then confirms + // here. The requester hears "approved" only once the share exists, so + // a fan-out that failed half way never reads as approved. + $shared = true; + try { + $this->shareTargetMapper->findBySourceSecretAndTargetUser( + sourceSecretId: $sourceSecretId, + targetUserId: $targetUserId + ); + } catch (DoesNotExistException) { + $shared = false; + } + + if ($shared === true) { + $this->notificationService->notify( + subject: 'share_request_result', + recipientId: $requesterId, + params: [ + 'sourceSecretId' => $sourceSecretId, + 'secretName' => $secret->getName(), + 'targetUserId' => $targetUserId, + 'result' => 'approved', + ], + objectType: 'secret', + objectId: $sourceSecretId, + ); + } + return [ 'sourceSecretId' => $sourceSecretId, 'requesterId' => $requesterId, 'targetUserId' => $targetUserId, + 'shared' => $shared, ]; }//end approveShareRequest() diff --git a/lib/Service/ShareRestriction.php b/lib/Service/ShareRestriction.php new file mode 100644 index 000000000..cd9502f4d --- /dev/null +++ b/lib/Service/ShareRestriction.php @@ -0,0 +1,75 @@ + + * @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\Service; + +use DateTime; +use DateTimeInterface; + +/** + * The two restrictions a grant can carry: use-only and an end date + * (sharing-use-only-and-expiring-shares D1). Immutable. + */ +final class ShareRestriction { + + /** + * Constructor for ShareRestriction. + * + * @param bool $useOnly Whether the recipient may only use the value + * @param DateTime|null $expiresAt When the access ends (null = no end) + * + * @return void + * + * @spec exclude Value object constructor; the combination rule carries the spec anchor. + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $useOnly is one of the two + * values this object carries, not a mode switch. + */ + public function __construct( + public readonly bool $useOnly = false, + public readonly ?DateTime $expiresAt = null, + ) { + }//end __construct() + + /** + * Whether this restriction blocks onward sharing (D4): a use-only copy + * or a copy with an end date. + * + * @return bool + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-an-expiring-copy-cannot-be-shared-onward + */ + public function isRestricted(): bool { + return ($this->useOnly === true || $this->expiresAt !== null); + }//end isRestricted() + + /** + * Whether two restrictions are the same (compared to the second). + * + * @param self $other The other restriction + * + * @return bool + * + * @spec exclude Comparison helper so the resolver writes only when something changed. + */ + public function equals(self $other): bool { + return $this->useOnly === $other->useOnly + && $this->expiresAt?->format(DateTimeInterface::ATOM) === $other->expiresAt?->format(DateTimeInterface::ATOM); + }//end equals() +}//end class diff --git a/lib/Service/ShareRestrictionResolver.php b/lib/Service/ShareRestrictionResolver.php new file mode 100644 index 000000000..063297f88 --- /dev/null +++ b/lib/Service/ShareRestrictionResolver.php @@ -0,0 +1,187 @@ + + * @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\Service; + +use OCA\Keepiq\Db\GroupShareMapper; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTarget; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IGroupManager; + +/** + * Materialises the effective use-only flag and access end date of every + * grant that reaches a recipient copy onto that copy, as `secrets.use_only` + * and `secrets.access_expires_at` (sharing-use-only-and-expiring-shares D1), + * so every read path and client sees them without a join. + * + * The grants reaching one recipient for one source secret are: the direct + * share row (when the row is direct), every group share of the source to a + * group the recipient is in, and every team-folder membership along the + * source's folder chain that covers the recipient. ShareRestrictionRules::combine() + * decides: the most generous grant wins. + */ +class ShareRestrictionResolver { + + /** + * Constructor for ShareRestrictionResolver. + * + * @param SecretMapper $secretMapper The secret mapper (sources and copies) + * @param ShareTargetMapper $shareTargetMapper The share-target mapper + * @param GroupShareMapper $groupShareMapper The group-share mapper + * @param TeamFolderQueryService $teamFolders The team-folder read side (covering memberships) + * @param IGroupManager $groupManager The Nextcloud group manager + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $shareTargetMapper, + private GroupShareMapper $groupShareMapper, + private TeamFolderQueryService $teamFolders, + private IGroupManager $groupManager, + ) { + }//end __construct() + + /** + * The effective restriction of one share row's recipient copy. + * + * @param ShareTarget $target The share row + * + * @return ShareRestriction + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.2 + */ + public function effectiveFor(ShareTarget $target): ShareRestriction { + return (new ShareRestrictionRules())->combine(grants: $this->grantsFor(target: $target)); + }//end effectiveFor() + + /** + * Recompute one share row's recipient copy and write it when it changed. + * + * @param ShareTarget $target The share row + * + * @return bool Whether the copy was written + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.2 + */ + public function resolveTarget(ShareTarget $target): bool { + try { + $copy = $this->secretMapper->findById($target->getSecretId()); + } catch (DoesNotExistException) { + return false; + } + + $effective = $this->effectiveFor(target: $target); + $current = new ShareRestriction( + useOnly: ($copy->getUseOnly() === true), + expiresAt: $copy->getAccessExpiresAt() + ); + if ($effective->equals(other: $current) === true) { + return false; + } + + $copy->setUseOnly($effective->useOnly); + $copy->setAccessExpiresAt($effective->expiresAt); + $this->secretMapper->update($copy); + + return true; + }//end resolveTarget() + + /** + * Recompute every recipient copy of one source secret. + * + * @param string $sourceSecretId The source secret + * + * @return int The number of copies written + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.2 + */ + public function resolveSource(string $sourceSecretId): int { + return $this->resolveTargets(targets: $this->shareTargetMapper->findBySourceSecret($sourceSecretId)); + }//end resolveSource() + + /** + * Recompute a list of share rows. + * + * @param array $targets The share rows + * + * @return int The number of copies written + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.2 + */ + public function resolveTargets(array $targets): int { + $written = 0; + foreach ($targets as $target) { + if ($this->resolveTarget(target: $target) === true) { + ++$written; + } + } + + return $written; + }//end resolveTargets() + + /** + * Every grant reaching a share row's recipient for its source secret. + * + * @param ShareTarget $target The share row + * + * @return array + */ + private function grantsFor(ShareTarget $target): array { + $grants = []; + if ($target->getGroupShareId() === null && $target->getTeamFolderId() === null) { + $grants[] = new ShareRestriction( + useOnly: ($target->getUseOnly() === true), + expiresAt: $target->getExpiresAt() + ); + } + + $recipient = $target->getTargetUserId(); + foreach ($this->groupShareMapper->findBySecret($target->getSourceSecretId()) as $groupShare) { + $linked = ($groupShare->getId() === $target->getGroupShareId()); + if ($linked === true || $this->groupManager->isInGroup($recipient, $groupShare->getGroupId()) === true) { + $grants[] = new ShareRestriction( + useOnly: ($groupShare->getUseOnly() === true), + expiresAt: $groupShare->getExpiresAt() + ); + } + } + + try { + $source = $this->secretMapper->findById($target->getSourceSecretId()); + } catch (DoesNotExistException) { + return $grants; + } + + foreach ($this->teamFolders->coveringMemberships(secret: $source, userId: $recipient) as $membership) { + $grants[] = new ShareRestriction( + // Use-only is offered on read memberships only; a write row + // that somehow carries the flag still lifts it. + useOnly: ($membership->getUseOnly() === true && $membership->effectiveGrade() === 'read'), + expiresAt: $membership->getExpiresAt() + ); + } + + return $grants; + }//end grantsFor() +}//end class diff --git a/lib/Service/ShareRestrictionRules.php b/lib/Service/ShareRestrictionRules.php new file mode 100644 index 000000000..4fa67d0ce --- /dev/null +++ b/lib/Service/ShareRestrictionRules.php @@ -0,0 +1,113 @@ + + * @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\Service; + +use DateTime; +use DateTimeZone; +use Exception; +use InvalidArgumentException; + +/** + * The rules for use-only and end dates (sharing-use-only-and-expiring-shares + * D1, D2): reading them from a request, and combining every grant that + * reaches one copy. Stateless. + */ +class ShareRestrictionRules { + + /** + * Read the two options from an untrusted request body, refusing an end + * date that is not in the future. + * + * @param mixed $useOnly The raw `useOnly` value (bool, "true", 1, null) + * @param mixed $expiresAt The raw `expiresAt` value (ISO 8601 string or null) + * @param DateTime $now The current time + * + * @return ShareRestriction + * + * @throws InvalidArgumentException When the date cannot be read or is not in the future + * + * @spec openspec/specs/expiring-shares/spec.md#requirement-shares-and-memberships-can-carry-an-end-date + */ + public function fromRequest(mixed $useOnly, mixed $expiresAt, DateTime $now): ShareRestriction { + $flag = ($useOnly === true || $useOnly === 1 || $useOnly === '1' || $useOnly === 'true'); + + if ($expiresAt === null || $expiresAt === '') { + return new ShareRestriction(useOnly: $flag, expiresAt: null); + } + + if (is_string($expiresAt) === false) { + throw new InvalidArgumentException(message: 'expiresAt must be a date'); + } + + try { + $end = new DateTime($expiresAt); + } catch (Exception) { + throw new InvalidArgumentException(message: 'expiresAt must be a date'); + } + + $end->setTimezone(new DateTimeZone('UTC')); + if ($end <= $now) { + throw new InvalidArgumentException(message: 'The end date must be in the future'); + } + + return new ShareRestriction(useOnly: $flag, expiresAt: $end); + }//end fromRequest() + + /** + * Combine every grant that reaches one copy: the copy is use-only only + * when every grant is, and access ends at the latest end date, with a + * grant without an end date winning (the most generous grant wins). + * No grants at all gives an unrestricted result. + * + * @param array $grants The grants reaching the copy + * + * @return ShareRestriction + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-owners-can-share-a-secret-as-use-only + * @spec openspec/specs/expiring-shares/spec.md#requirement-shares-and-memberships-can-carry-an-end-date + */ + public function combine(array $grants): ShareRestriction { + if ($grants === []) { + return new ShareRestriction(); + } + + $useOnly = true; + $latest = null; + $noEnd = false; + foreach ($grants as $grant) { + $useOnly = ($useOnly && $grant->useOnly); + if ($grant->expiresAt === null) { + $noEnd = true; + continue; + } + + if ($latest === null || $grant->expiresAt > $latest) { + $latest = $grant->expiresAt; + } + } + + if ($noEnd === true) { + $latest = null; + } + + return new ShareRestriction(useOnly: $useOnly, expiresAt: $latest); + }//end combine() + +}//end class diff --git a/lib/Service/ShareRevocationService.php b/lib/Service/ShareRevocationService.php index c1b6c7793..34dbaf27c 100644 --- a/lib/Service/ShareRevocationService.php +++ b/lib/Service/ShareRevocationService.php @@ -138,15 +138,51 @@ public function revokeShare(string $shareId, string $userId): void { }//end revokeShare() /** - * Cascade-delete all share targets for a secret (called on secret delete). + * End every share of a secret (called when the secret is trashed or + * deleted): each recipient's copy goes with its share row, exactly as a + * single revokeShare() does (keepiq#83). Deleting only the link rows left + * the copies behind as ordinary-looking live secrets nobody tracked. * * @param string $sourceSecretId The source secret ID * * @return void * + * @throws Throwable When a step fails; the transaction is rolled back + * * @spec openspec/specs/user-sharing/spec.md#requirement-revoke-share */ public function deleteAllForSecret(string $sourceSecretId): void { - $this->mapper->deleteBySourceSecret($sourceSecretId); + $this->db->beginTransaction(); + try { + foreach ($this->mapper->findBySourceSecret($sourceSecretId) as $share) { + $this->deleteRecipientCopy(copyId: $share->getSecretId()); + } + + $this->mapper->deleteBySourceSecret($sourceSecretId); + $this->db->commit(); + } catch (Throwable $exception) { + $this->db->rollBack(); + throw $exception; + } }//end deleteAllForSecret() + + /** + * Delete one recipient copy and the attachment grants it holds. A copy + * that is already gone is fine. + * + * @param string $copyId The recipient copy's Secret ID + * + * @return void + * + * @spec openspec/specs/user-sharing/spec.md#requirement-revoke-share + */ + private function deleteRecipientCopy(string $copyId): void { + try { + $this->secretMapper->delete($this->secretMapper->findById($copyId)); + } catch (DoesNotExistException) { + // Already gone; continue. + } + + $this->attachmentService?->deleteGrantsForSecretCopy($copyId); + }//end deleteRecipientCopy() }//end class diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 6f09644ae..031023722 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -34,6 +34,7 @@ use InvalidArgumentException; use OCA\Keepiq\Db\ShareTarget; use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Db\TeamFolderMember; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IDBConnection; use Ramsey\Uuid\Uuid; @@ -58,6 +59,9 @@ * whose entry points this class re-exports so ShareController and * SecretService keep one seam. Retiring the tag means repointing those two * callers, which is outside this change. + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One public method per share + * operation; the use-only change added the restriction update. */ class ShareService { @@ -79,6 +83,7 @@ class ShareService { * @param ShareSyncService $syncService The multi-recipient sync service * @param ShareRevocationService $revocationService The revocation + cascade service * @param ShareAuditTrail|null $auditTrail The share audit trail + * @param ShareRestrictionResolver|null $restrictions Materialises use-only and end dates onto copies * * @return void */ @@ -91,6 +96,7 @@ public function __construct( private ShareSyncService $syncService, private ShareRevocationService $revocationService, ?ShareAuditTrail $auditTrail = null, + private ?ShareRestrictionResolver $restrictions = null, ) { $this->auditTrail = ($auditTrail ?? new ShareAuditTrail()); }//end __construct() @@ -137,12 +143,14 @@ public function recipientCertificates(array $targetUserIds): array { * @param string $recipientSecretId The recipient's encrypted Secret copy ID * @param string|null $groupShareId Optional group-share linkage * @param string $userId The Nextcloud user ID creating the share + * @param ShareRestriction|null $restriction Use-only and end date of a direct share * * @return ShareTarget * * @throws InvalidArgumentException When validation fails * * @spec openspec/changes/implement-user-sharing/tasks.md#3.2 + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 */ public function createShare( string $sourceSecretId, @@ -150,6 +158,7 @@ public function createShare( string $recipientSecretId, ?string $groupShareId, string $userId, + ?ShareRestriction $restriction = null, ): ShareTarget { if ($sourceSecretId === '') { throw new InvalidArgumentException(message: 'sourceSecretId is required'); @@ -174,6 +183,7 @@ public function createShare( } $this->auth->assertOwnerOrDelegate(secret: $source, userId: $userId); + $source->assertOnwardShareable(); $this->auth->assertRecipientHasActiveSuite(targetUserId: $targetUserId); // Enforce one-share-per-(source,recipient) invariant. @@ -195,8 +205,13 @@ public function createShare( $entity->setGroupShareId($groupShareId); $entity->setCreatedBy($userId); $entity->setCreatedAt(new DateTime()); + if ($restriction !== null && $groupShareId === null) { + $entity->setUseOnly($restriction->useOnly); + $entity->setExpiresAt($restriction->expiresAt); + } $persisted = $this->mapper->insert($entity); + $this->restrictions?->resolveTarget(target: $persisted); // Fire-and-forget notification to the recipient. The user // preference + opt-out check happens inside NotificationService. @@ -278,6 +293,42 @@ public function createBatchShares( return $created; }//end createBatchShares() + /** + * Change the use-only flag and end date of a direct share (owner or + * delegate only), then recompute the recipient's copy. A share that + * came from a group share or a team folder is changed there instead. + * + * @param string $shareId The share-target row ID + * @param ShareRestriction $restriction The new use-only flag and end date + * @param string $userId The requesting user + * + * @return ShareTarget + * + * @throws InvalidArgumentException When not found, not authorized or not a direct share + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.1 + */ + public function updateRestriction(string $shareId, ShareRestriction $restriction, string $userId): ShareTarget { + try { + $entity = $this->mapper->findById($shareId); + } catch (DoesNotExistException) { + throw new InvalidArgumentException(message: 'Share not found'); + } + + $source = $this->auth->loadSecret(secretId: $entity->getSourceSecretId()); + $this->auth->assertOwnerOrDelegate(secret: $source, userId: $userId); + if ($entity->getGroupShareId() !== null || $entity->getTeamFolderId() !== null) { + throw new InvalidArgumentException(message: 'Change this share on its group share or team folder'); + } + + $entity->setUseOnly($restriction->useOnly); + $entity->setExpiresAt($restriction->expiresAt); + $updated = $this->mapper->update($entity); + $this->restrictions?->resolveTarget(target: $updated); + + return $updated; + }//end updateRestriction() + /** * List all share targets for a given source secret. * @@ -309,7 +360,7 @@ public function listSharesForSecret(string $sourceSecretId, string $userId = '') // A write-grade team member needs the recipient list (+ // certificates) to run the re-encrypt fan-out // (folder-permission-grades §2.3); read grades see nothing. - if ($this->auth->resolveGrade(secret: $source, userId: $userId) !== 'write') { + if (in_array($this->auth->resolveGrade(secret: $source, userId: $userId), TeamFolderMember::WRITE_GRADES, true) === false) { return []; } } diff --git a/lib/Service/ShareSyncService.php b/lib/Service/ShareSyncService.php index c87ed9754..967de958d 100644 --- a/lib/Service/ShareSyncService.php +++ b/lib/Service/ShareSyncService.php @@ -35,6 +35,7 @@ use OCA\Keepiq\Db\Secret; use OCA\Keepiq\Db\SecretMapper; use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Db\TeamFolderMember; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IDBConnection; use Throwable; @@ -98,6 +99,7 @@ public function __construct( * @throws InvalidArgumentException When validation or optimistic-lock check fails * * @spec openspec/specs/user-sharing/spec.md#requirement-sync-on-update + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce */ public function syncUpdate( string $secretId, @@ -106,6 +108,14 @@ public function syncUpdate( string $userId, ): int { $source = $this->auth->loadSecret(secretId: $secretId); + // A copy from another organisation is never written by its holder + // (sharing-federated-recipients task 3.4). + $source->assertNotReadOnly(); + if ($source->getUseOnly() === true) { + // A use-only copy is never written by its holder (D4). + throw new InvalidArgumentException(message: 'A use-only copy cannot be changed'); + } + $isWriter = $this->resolveSyncWriter(source: $source, userId: $userId); $this->assertSyncSourceUnchanged(source: $source, expectedUpdatedAt: $expectedUpdatedAt); @@ -171,7 +181,7 @@ public function writeContext(string $secretId, string $userId): array { } $ownerCertificate = null; - if ($grade === 'write' || $grade === 'owner') { + if ($grade === 'owner' || in_array($grade, TeamFolderMember::WRITE_GRADES, true) === true) { try { $ownerCertificate = $this->suiteMapper ->findActiveByOwner(ownerType: $source->getOwnerType(), ownerId: $source->getOwnerId()) @@ -208,7 +218,7 @@ private function resolveSyncWriter(Secret $source, string $userId): bool { } $grade = $this->auth->resolveGrade(secret: $source, userId: $userId); - if ($grade !== 'write') { + if (in_array($grade, TeamFolderMember::WRITE_GRADES, true) === false) { $this->auth->assertOwnerOrDelegate(secret: $source, userId: $userId); } diff --git a/lib/Service/Siem/CefFormatter.php b/lib/Service/Siem/CefFormatter.php new file mode 100644 index 000000000..be2851564 --- /dev/null +++ b/lib/Service/Siem/CefFormatter.php @@ -0,0 +1,125 @@ + + * @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\Service\Siem; + +/** + * ArcSight Common Event Format, for QRadar, ArcSight and Sentinel's CEF + * connector, sent as the MSG of the RFC 5424 frame. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-cef-formatting-over-syslog + */ +final class CefFormatter { + + /** + * Severity per event category (0-10). A category not listed is 3. + * + * @var array + */ + public const SEVERITY = [ + 'honey' => 10, + 'suite' => 8, + 'emergency' => 7, + 'share' => 5, + ]; + + /** + * Severity for any other category. + * + * @var int + */ + public const DEFAULT_SEVERITY = 3; + + /** + * Build the CEF line. + * + * @param array $payload The buildPayload() array + * @param string $appVersion The Keepiq version for the header + * + * @return string + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-cef-formatting-over-syslog + */ + public function format(array $payload, string $appVersion): string { + $view = new PayloadView(payload: $payload); + $eventType = $view->get(field: 'eventType'); + $category = $view->get(field: 'category'); + $header = [ + 'CEF:0', + 'Conduction', + 'Keepiq', + self::header(value: $appVersion), + self::header(value: $eventType), + self::header(value: str_replace(['.', '_'], ' ', $eventType)), + (string)(self::SEVERITY[$category] ?? self::DEFAULT_SEVERITY), + ]; + + $extensions = [ + 'rt' => (string)(int)($view->epoch() * 1000), + 'cat' => $category, + 'act' => $eventType, + ]; + if ($view->get(field: 'actorType') === 'user') { + $extensions['suser'] = $view->get(field: 'actorId'); + } + + $extensions += [ + 'cs1Label' => 'actorType', + 'cs1' => $view->get(field: 'actorType'), + 'cs2Label' => 'objectType', + 'cs2' => $view->get(field: 'objectType'), + 'cs3Label' => 'objectId', + 'cs3' => $view->get(field: 'objectId'), + 'msg' => (string)json_encode((object)$view->metadata()), + ]; + + $pairs = []; + foreach ($extensions as $key => $value) { + $pairs[] = $key . '=' . self::extension(value: $value); + } + + return implode('|', $header) . '|' . implode(' ', $pairs); + }//end format() + + /** + * Escape a header field: backslash and pipe. + * + * @param string $value The raw value + * + * @return string + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-cef-formatting-over-syslog + */ + public static function header(string $value): string { + return str_replace(['\\', '|', "\r", "\n"], ['\\\\', '\\|', ' ', ' '], $value); + }//end header() + + /** + * Escape an extension value: backslash, equals sign and line breaks. + * + * @param string $value The raw value + * + * @return string + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-cef-formatting-over-syslog + */ + public static function extension(string $value): string { + return str_replace(['\\', '=', "\r\n", "\n", "\r"], ['\\\\', '\\=', '\\n', '\\n', '\\r'], $value); + }//end extension() +}//end class diff --git a/lib/Service/Siem/JsonFormatter.php b/lib/Service/Siem/JsonFormatter.php new file mode 100644 index 000000000..1b07071d1 --- /dev/null +++ b/lib/Service/Siem/JsonFormatter.php @@ -0,0 +1,41 @@ + + * @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\Service\Siem; + +/** + * The generic JSON body syslog and webhook sinks have always carried. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ +final class JsonFormatter { + + /** + * Encode the sanitized payload. + * + * @param array $payload The buildPayload() array + * + * @return string + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ + public function format(array $payload): string { + return (string)json_encode((new PayloadView(payload: $payload))->sanitized()); + }//end format() +}//end class diff --git a/lib/Service/Siem/PayloadView.php b/lib/Service/Siem/PayloadView.php new file mode 100644 index 000000000..d0b761fed --- /dev/null +++ b/lib/Service/Siem/PayloadView.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service\Siem; + +use OCA\Keepiq\Event\Audit\AuditEventTypes; + +/** + * Reads the fields of a SiemService::buildPayload() array for the + * formatters. It is the only way a formatter sees the payload: it returns + * the eight known fields and nothing else, and it drops every forbidden + * metadata key again, so no formatter can forward secret material even if a + * payload carried it. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ +final class PayloadView { + + /** + * Wrap one payload. + * + * @param array $payload The buildPayload() array + * + * @return void + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ + public function __construct(private array $payload) { + }//end __construct() + + /** + * A top-level string field, '' when absent. + * + * @param string $field One of eventType, category, actorType, actorId, objectType, objectId, occurredAt + * + * @return string + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ + public function get(string $field): string { + $value = $this->payload[$field] ?? ''; + if (is_scalar($value) === false) { + return ''; + } + + return (string)$value; + }//end get() + + /** + * The whitelisted metadata minus every forbidden key. + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ + public function metadata(): array { + $metadata = $this->payload['metadata'] ?? []; + if (is_array($metadata) === false) { + return []; + } + + foreach (AuditEventTypes::FORBIDDEN_KEYS as $forbidden) { + unset($metadata[$forbidden]); + } + + return $metadata; + }//end metadata() + + /** + * The event time as a Unix timestamp with milliseconds, now when absent. + * + * @return float + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ + public function epoch(): float { + $time = strtotime($this->get(field: 'occurredAt')); + if ($time === false) { + return (float)time(); + } + + return (float)$time; + }//end epoch() + + /** + * The payload rebuilt from the known fields only. + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-output-carries-no-secret-material + */ + public function sanitized(): array { + return [ + 'eventType' => $this->get(field: 'eventType'), + 'category' => $this->get(field: 'category'), + 'actorType' => $this->get(field: 'actorType'), + 'actorId' => $this->get(field: 'actorId'), + 'objectType' => $this->get(field: 'objectType'), + 'objectId' => $this->get(field: 'objectId'), + 'occurredAt' => $this->get(field: 'occurredAt'), + 'metadata' => $this->metadata(), + ]; + }//end sanitized() +}//end class diff --git a/lib/Service/Siem/SentinelRowFormatter.php b/lib/Service/Siem/SentinelRowFormatter.php new file mode 100644 index 000000000..72add3d45 --- /dev/null +++ b/lib/Service/Siem/SentinelRowFormatter.php @@ -0,0 +1,60 @@ + + * @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\Service\Siem; + +/** + * One row for the KeepiqAudit_CL table through the Logs Ingestion API. The + * column list is the one the template integrations/siem/sentinel/keepiq-dcr.json + * declares; a test keeps the two equal. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-microsoft-sentinel-delivery-through-the-logs-ingestion-api + */ +final class SentinelRowFormatter { + + /** + * The row columns, in order. + * + * @var string[] + */ + public const COLUMNS = ['TimeGenerated', 'EventType', 'Category', 'ActorType', 'ActorId', 'ObjectType', 'ObjectId', 'Metadata']; + + /** + * Build the row. + * + * @param array $payload The buildPayload() array + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-microsoft-sentinel-delivery-through-the-logs-ingestion-api + */ + public function format(array $payload): array { + $view = new PayloadView(payload: $payload); + return [ + 'TimeGenerated' => gmdate('Y-m-d\TH:i:s\Z', (int)$view->epoch()), + 'EventType' => $view->get(field: 'eventType'), + 'Category' => $view->get(field: 'category'), + 'ActorType' => $view->get(field: 'actorType'), + 'ActorId' => $view->get(field: 'actorId'), + 'ObjectType' => $view->get(field: 'objectType'), + 'ObjectId' => $view->get(field: 'objectId'), + 'Metadata' => (object)$view->metadata(), + ]; + }//end format() +}//end class diff --git a/lib/Service/Siem/SiemSinkRequest.php b/lib/Service/Siem/SiemSinkRequest.php new file mode 100644 index 000000000..958f476ab --- /dev/null +++ b/lib/Service/Siem/SiemSinkRequest.php @@ -0,0 +1,177 @@ + + * @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\Service\Siem; + +use OCP\IRequest; + +/** + * Reads the sink fields of a create or update request into the params array + * SiemSinkService takes. One value object instead of eleven router-bound + * arguments: the HTTP contract (field names and defaults) is unchanged. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ +final class SiemSinkRequest { + + /** + * Constructor. + * + * @param IRequest $request The request + * + * @return void + */ + public function __construct(private IRequest $request) { + }//end __construct() + + /** + * The params of a create, with the create defaults. + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ + public function forCreate(): array { + $format = $this->string(name: 'format'); + if ($format === '') { + $format = 'json'; + } + + return [ + 'name' => $this->string(name: 'name'), + 'type' => $this->string(name: 'type'), + 'endpoint' => $this->string(name: 'endpoint'), + 'tls' => ($this->bool(name: 'tls') ?? true), + 'hmacSecret' => $this->string(name: 'hmacSecret'), + 'categoryFilter' => ($this->list(name: 'categoryFilter') ?? []), + 'queueCap' => ($this->int(name: 'queueCap') ?? 1000), + 'enabled' => ($this->bool(name: 'enabled') ?? true), + 'format' => $format, + 'credential' => $this->string(name: 'credential'), + 'connectorOptions' => ($this->list(name: 'connectorOptions') ?? []), + ]; + }//end forCreate() + + /** + * The fields an update actually supplies. A blank string or an absent + * field means "leave unchanged"; the two write-only secrets are always + * passed, because the service reads '' as "keep the stored one". + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-connector-credentials-are-write-only-and-encrypted-at-rest + */ + public function forUpdate(): array { + $params = [ + 'hmacSecret' => $this->string(name: 'hmacSecret'), + 'credential' => $this->string(name: 'credential'), + ]; + $candidates = [ + 'name' => $this->blankToNull(value: $this->string(name: 'name')), + 'endpoint' => $this->blankToNull(value: $this->string(name: 'endpoint')), + 'format' => $this->blankToNull(value: $this->string(name: 'format')), + 'tls' => $this->bool(name: 'tls'), + 'enabled' => $this->bool(name: 'enabled'), + 'queueCap' => $this->int(name: 'queueCap'), + 'categoryFilter' => $this->list(name: 'categoryFilter'), + 'connectorOptions' => $this->list(name: 'connectorOptions'), + ]; + + return $params + array_filter($candidates, static fn ($value): bool => $value !== null); + }//end forUpdate() + + /** + * A string field, '' when absent or not scalar. + * + * @param string $name The field + * + * @return string + */ + private function string(string $name): string { + $value = $this->request->getParam($name); + if (is_scalar($value) === false) { + return ''; + } + + return (string)$value; + }//end string() + + /** + * A boolean field, null when absent. + * + * @param string $name The field + * + * @return bool|null + */ + private function bool(string $name): ?bool { + $value = $this->request->getParam($name); + if ($value === null || $value === '') { + return null; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + }//end bool() + + /** + * An integer field, null when absent or not numeric. + * + * @param string $name The field + * + * @return int|null + */ + private function int(string $name): ?int { + $value = $this->request->getParam($name); + if (is_numeric($value) === false) { + return null; + } + + return (int)$value; + }//end int() + + /** + * An array field (list or object), null when absent or not an array. + * + * @param string $name The field + * + * @return array|null + */ + private function list(string $name): ?array { + $value = $this->request->getParam($name); + if (is_array($value) === false) { + return null; + } + + return $value; + }//end list() + + /** + * Null for an empty string. + * + * @param string $value The value + * + * @return string|null + */ + private function blankToNull(string $value): ?string { + if ($value === '') { + return null; + } + + return $value; + }//end blankToNull() +}//end class diff --git a/lib/Service/Siem/SinkConnectorSettings.php b/lib/Service/Siem/SinkConnectorSettings.php new file mode 100644 index 000000000..8a7c77486 --- /dev/null +++ b/lib/Service/Siem/SinkConnectorSettings.php @@ -0,0 +1,241 @@ + + * @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\Service\Siem; + +use InvalidArgumentException; +use OCA\Keepiq\Db\SiemSink; + +/** + * Validates what a sink type may carry: the type itself, an https endpoint + * for every HTTP connector, json or (on syslog only) cef, the connector's own + * options with the Sentinel ones required and defaulted, and a credential for + * Splunk and Sentinel. Pure: no storage, no crypto. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ +final class SinkConnectorSettings { + + /** + * Sink types: the generic syslog and webhook transports and the named + * Splunk HEC and Microsoft Sentinel connectors. + * + * @var string[] + */ + public const TYPES = ['syslog', 'webhook', 'splunk_hec', 'sentinel']; + + /** + * The non-secret options each connector accepts. + * + * @var array + */ + public const CONNECTOR_OPTIONS = [ + 'splunk_hec' => ['index', 'sourcetype'], + 'sentinel' => ['tenantId', 'clientId', 'dataCollectionEndpoint', 'dcrImmutableId', 'streamName', 'authorityHost'], + ]; + + /** + * Connectors that need a credential (HEC token, client secret). + * + * @var string[] + */ + private const NEED_CREDENTIAL = ['splunk_hec', 'sentinel']; + + /** + * Validate the connector fields of a create and return them normalised. + * + * @param array $params The create params + * + * @return array{type: string, endpoint: string, format: string, connectorOptions: string|null} + * + * @throws InvalidArgumentException On any invalid field + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ + public function forCreate(array $params): array { + $type = (string)($params['type'] ?? ''); + if (in_array($type, self::TYPES, true) === false) { + throw new InvalidArgumentException('type must be one of: ' . implode(', ', self::TYPES)); + } + + $endpoint = (string)($params['endpoint'] ?? ''); + if ($endpoint === '') { + throw new InvalidArgumentException('endpoint is required'); + } + + $this->assertEndpoint(type: $type, endpoint: $endpoint); + $credential = ($params['credential'] ?? ''); + if (in_array($type, self::NEED_CREDENTIAL, true) === true && (is_string($credential) === false || $credential === '')) { + throw new InvalidArgumentException($type . ' needs a credential (the HEC token or the client secret)'); + } + + return [ + 'type' => $type, + 'endpoint' => $endpoint, + 'format' => $this->format(type: $type, format: (string)($params['format'] ?? 'json')), + 'connectorOptions' => $this->encode(options: $this->options(type: $type, options: ($params['connectorOptions'] ?? []))), + ]; + }//end forCreate() + + /** + * Apply an update's endpoint, format and connector options to a sink, + * validated against its type. Absent or blank fields stay as they are. + * + * @param SiemSink $sink The sink + * @param array $params The update params + * + * @return void + * + * @throws InvalidArgumentException On any invalid field + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-named-siem-connectors-on-a-sink + */ + public function applyUpdate(SiemSink $sink, array $params): void { + $type = $sink->getType(); + $endpoint = (string)($params['endpoint'] ?? ''); + if ($endpoint !== '') { + $this->assertEndpoint(type: $type, endpoint: $endpoint); + $sink->setEndpoint($endpoint); + } + + $format = (string)($params['format'] ?? ''); + if ($format !== '') { + $sink->setFormat($this->format(type: $type, format: $format)); + } + + if (($params['connectorOptions'] ?? null) !== null) { + $sink->setConnectorOptions($this->encode(options: $this->options(type: $type, options: $params['connectorOptions']))); + } + }//end applyUpdate() + + /** + * Every HTTP transport (webhook, Splunk HEC, Sentinel) must be https://. + * + * @param string $type The sink type + * @param string $endpoint The endpoint + * + * @return void + * + * @throws InvalidArgumentException On a non-https HTTP endpoint + */ + private function assertEndpoint(string $type, string $endpoint): void { + if ($type !== 'syslog' && str_starts_with($endpoint, 'https://') === false) { + throw new InvalidArgumentException($type . ' endpoints must be https://'); + } + }//end assertEndpoint() + + /** + * json everywhere, cef on syslog only. + * + * @param string $type The sink type + * @param string $format The requested format + * + * @return string + * + * @throws InvalidArgumentException On an unknown format or cef off syslog + */ + private function format(string $type, string $format): string { + if (in_array($format, ['json', 'cef'], true) === false) { + throw new InvalidArgumentException('format must be json or cef'); + } + + if ($format === 'cef' && $type !== 'syslog') { + throw new InvalidArgumentException('format cef is only for syslog sinks'); + } + + return $format; + }//end format() + + /** + * Only the keys the connector knows, blanks dropped; for Sentinel the + * required settings and the defaults too. + * + * @param string $type The sink type + * @param mixed $options The requested options + * + * @return array + * + * @throws InvalidArgumentException On unknown, missing or invalid options + */ + private function options(string $type, mixed $options): array { + if (is_array($options) === false) { + throw new InvalidArgumentException('connectorOptions must be an object'); + } + + $allowed = (self::CONNECTOR_OPTIONS[$type] ?? []); + $clean = []; + foreach ($options as $key => $value) { + if (in_array((string)$key, $allowed, true) === false) { + throw new InvalidArgumentException('connectorOptions.' . $key . ' is not an option of ' . $type); + } + + if ((string)$value !== '') { + $clean[(string)$key] = (string)$value; + } + } + + if ($type === 'sentinel') { + return $this->sentinel(options: $clean); + } + + return $clean; + }//end options() + + /** + * The Sentinel settings: tenant, client, endpoint and rule required; the + * stream and authority host defaulted; both URLs https. + * + * @param array $options The cleaned options + * + * @return array + * + * @throws InvalidArgumentException On a missing setting or a non-https URL + */ + private function sentinel(array $options): array { + foreach (['tenantId', 'clientId', 'dataCollectionEndpoint', 'dcrImmutableId'] as $required) { + if (isset($options[$required]) === false) { + throw new InvalidArgumentException('sentinel needs connectorOptions.' . $required); + } + } + + $options += ['streamName' => 'Custom-KeepiqAudit', 'authorityHost' => 'https://login.microsoftonline.com']; + foreach (['dataCollectionEndpoint', 'authorityHost'] as $url) { + if (str_starts_with($options[$url], 'https://') === false) { + throw new InvalidArgumentException('connectorOptions.' . $url . ' must be https://'); + } + } + + return $options; + }//end sentinel() + + /** + * Options as stored: JSON, or null when there are none. + * + * @param array $options The options + * + * @return string|null + */ + private function encode(array $options): ?string { + if ($options === []) { + return null; + } + + return (string)json_encode($options); + }//end encode() +}//end class diff --git a/lib/Service/Siem/SplunkHecFormatter.php b/lib/Service/Siem/SplunkHecFormatter.php new file mode 100644 index 000000000..881b0c6ff --- /dev/null +++ b/lib/Service/Siem/SplunkHecFormatter.php @@ -0,0 +1,69 @@ + + * @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\Service\Siem; + +/** + * The Splunk HEC event envelope: time, host, source, sourcetype, an optional + * index, and the sanitized payload as the event. + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-splunk-http-event-collector-delivery + */ +final class SplunkHecFormatter { + + /** + * The default sourcetype, matching integrations/siem/splunk/props.conf. + * + * @var string + */ + public const SOURCETYPE = 'keepiq:audit'; + + /** + * Build the envelope. + * + * @param array $payload The buildPayload() array + * @param string $host The Nextcloud host name + * @param array $options The sink's connector options (index, sourcetype) + * + * @return array + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-splunk-http-event-collector-delivery + */ + public function format(array $payload, string $host, array $options = []): array { + $view = new PayloadView(payload: $payload); + $envelope = [ + 'time' => $view->epoch(), + 'host' => $host, + 'source' => 'keepiq', + 'sourcetype' => self::SOURCETYPE, + ]; + if (($options['sourcetype'] ?? '') !== '') { + $envelope['sourcetype'] = $options['sourcetype']; + } + + if (($options['index'] ?? '') !== '') { + $envelope['index'] = $options['index']; + } + + $event = $view->sanitized(); + $event['metadata'] = (object)$event['metadata']; + $envelope['event'] = $event; + return $envelope; + }//end format() +}//end class diff --git a/lib/Service/SiemAuditTrail.php b/lib/Service/SiemAuditTrail.php index 0f0ceb374..e0b2a62fd 100644 --- a/lib/Service/SiemAuditTrail.php +++ b/lib/Service/SiemAuditTrail.php @@ -76,13 +76,20 @@ public function recordSinkCreated(string $actorId, string $sinkId, string $type) * * @param string $actorId The admin actor * @param string $sinkId The sink id + * @param string $type The sink's connector type * * @return void * * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ - public function recordSinkUpdated(string $actorId, string $sinkId): void { - $this->dispatch(actorId: $actorId, eventType: AuditEventTypes::SIEM_SINK_UPDATED, sinkId: $sinkId); + public function recordSinkUpdated(string $actorId, string $sinkId, string $type = ''): void { + $extra = []; + if ($type !== '') { + // The connector type is an identifier; the credential never is. + $extra['type'] = $type; + } + + $this->dispatch(actorId: $actorId, eventType: AuditEventTypes::SIEM_SINK_UPDATED, sinkId: $sinkId, extra: $extra); }//end recordSinkUpdated() /** diff --git a/lib/Service/SiemFailureOutcome.php b/lib/Service/SiemFailureOutcome.php new file mode 100644 index 000000000..0a8408d68 --- /dev/null +++ b/lib/Service/SiemFailureOutcome.php @@ -0,0 +1,134 @@ + + * @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 + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service; + +use OCA\Keepiq\Db\SiemSink; +use OCA\Keepiq\Exception\SiemDeliveryException; +use Throwable; + +/** + * Builds a credential-free description of a SIEM failure. + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ +class SiemFailureOutcome { + /** + * Describe a failed delivery to a sink. + * + * @param Throwable $exception The failure + * @param SiemSink|null $sink The sink, when one was involved + * + * @return string e.g. "ServerException (HTTP 500) at siem.example.org" + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + public function describe(Throwable $exception, ?SiemSink $sink = null): string { + $status = $this->statusOf(exception: $exception); + $outcome = '(no answer)'; + if ($status !== null) { + $outcome = '(HTTP '.$status.')'; + } + + $description = $this->classOf(exception: $exception).' '.$outcome; + $host = $this->hostOf(sink: $sink); + if ($host !== '') { + $description .= ' at '.$host; + } + + return $description; + }//end describe() + + /** + * The exception's short class name, for a failure with no sink. + * + * @param Throwable $exception The failure + * + * @return string + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + public function classOf(Throwable $exception): string { + $class = get_class($exception); + $slash = strrpos($class, '\\'); + if ($slash === false) { + return $class; + } + + return substr($class, $slash + 1); + }//end classOf() + + /** + * The HTTP status the sink answered with, read from the exception's + * answer and never from its message; null when nothing answered. + * + * @param Throwable $exception The failure + * + * @return int|null + */ + private function statusOf(Throwable $exception): ?int { + if ($exception instanceof SiemDeliveryException) { + return $exception->getHttpStatus(); + } + + if (method_exists($exception, 'getResponse') === true) { + $response = $exception->getResponse(); + if (is_object($response) === true && method_exists($response, 'getStatusCode') === true) { + return (int)$response->getStatusCode(); + } + } + + return null; + }//end statusOf() + + /** + * The sink's host only: no scheme, userinfo, port, path or query. + * + * @param SiemSink|null $sink The sink + * + * @return string The host, or '' when there is none + */ + private function hostOf(?SiemSink $sink): string { + if ($sink === null) { + return ''; + } + + $endpoint = $sink->getEndpoint(); + if (str_contains($endpoint, '://') === false) { + // A syslog endpoint is host:port. + $endpoint = 'tcp://'.$endpoint; + } + + $host = parse_url($endpoint, PHP_URL_HOST); + if (is_string($host) === false) { + return ''; + } + + return $host; + }//end hostOf() +}//end class diff --git a/lib/Service/SiemService.php b/lib/Service/SiemService.php index 07b3f210c..ae5014c6e 100644 --- a/lib/Service/SiemService.php +++ b/lib/Service/SiemService.php @@ -84,7 +84,7 @@ class SiemService { * * @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 + * @spec openspec/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, @@ -106,6 +106,8 @@ public function __construct( * @param AuditEvent $event The dispatched audit event * * @return array|null + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-forwarded-payload-carries-no-secret-material */ public function buildPayload(AuditEvent $event): ?array { $eventType = $event->getEventType(); @@ -148,6 +150,8 @@ public function buildPayload(AuditEvent $event): ?array { * @param array $payload The forwarding payload * * @return int Rows enqueued + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-backpressure-and-observability */ public function enqueue(array $payload): int { $enqueued = 0; @@ -192,7 +196,7 @@ public function enqueue(array $payload): int { * * @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 + * @spec openspec/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; @@ -250,8 +254,11 @@ public function deliverOne(SiemSink $sink, SiemQueueItem $item): bool { return true; } catch (Throwable $exception) { + // Never the message: an HTTP client names the full sink URL in it, + // token and all. Class, status and host only (keepiq#728). + $failure = $this->transport->describeFailure(exception: $exception, sink: $sink); $item->setAttempts($item->getAttempts() + 1); - $item->setLastError(substr($exception->getMessage(), 0, 500)); + $item->setLastError($failure); if ($item->getAttempts() >= self::MAX_ATTEMPTS) { $item->setStatus('dead'); } @@ -268,7 +275,7 @@ public function deliverOne(SiemSink $sink, SiemQueueItem $item): bool { $sink->setLastDeliveryStatus('dead'); } - $sink->setLastError(substr($exception->getMessage(), 0, 500)); + $sink->setLastError($failure); $sink->setConsecutiveFailures($sink->getConsecutiveFailures() + 1); $this->sinkMapper->update($sink); @@ -302,6 +309,7 @@ public function createSink(string $adminUid, array $params): SiemSink { * @return SiemSink * * @throws \OCP\AppFramework\Db\DoesNotExistException When the sink is missing + * @throws \InvalidArgumentException On an invalid format, endpoint or connector option * * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ @@ -381,7 +389,7 @@ private function notifyDeadLetter(SiemSink $sink): void { ); } catch (Throwable $exception) { $this->logger->warning( - 'Keepiq: SIEM dead-letter notification failed: ' . $exception->getMessage(), + 'Keepiq: SIEM dead-letter notification failed: '.(new SiemFailureOutcome())->classOf(exception: $exception), ['app' => 'keepiq'] ); } diff --git a/lib/Service/SiemSinkService.php b/lib/Service/SiemSinkService.php index 6658ff307..ed1ef0388 100644 --- a/lib/Service/SiemSinkService.php +++ b/lib/Service/SiemSinkService.php @@ -29,11 +29,11 @@ namespace OCA\Keepiq\Service; use DateTime; -use InvalidArgumentException; use OCA\Keepiq\Db\SiemQueueItemMapper; use OCA\Keepiq\Db\SiemSink; use OCA\Keepiq\Db\SiemSinkMapper; use OCA\Keepiq\Service\Connection\ConnectionReporter; +use OCA\Keepiq\Service\Siem\SinkConnectorSettings; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Security\ICrypto; use Ramsey\Uuid\Uuid; @@ -44,6 +44,7 @@ */ class SiemSinkService { + /** * The sink audit trail. * @@ -84,33 +85,24 @@ public function __construct( * * @return SiemSink * - * @throws InvalidArgumentException On invalid parameters + * Invalid parameters raise SinkConnectorSettings's InvalidArgumentException. * * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ public function createSink(string $adminUid, array $params): SiemSink { - $type = (string)($params['type'] ?? ''); - if (in_array($type, ['syslog', 'webhook'], true) === false) { - throw new InvalidArgumentException('type must be syslog or webhook'); - } - - $endpoint = (string)($params['endpoint'] ?? ''); - if ($endpoint === '') { - throw new InvalidArgumentException('endpoint is required'); - } - - if ($type === 'webhook' && str_starts_with($endpoint, 'https://') === false) { - throw new InvalidArgumentException('webhook endpoints must be https://'); - } + $connector = (new SinkConnectorSettings())->forCreate(params: $params); + $type = $connector['type']; $sink = new SiemSink(); $sink->setId(Uuid::uuid4()->toString()); $sink->setName((string)($params['name'] ?? $type)); $sink->setType($type); $sink->setEnabled((bool)($params['enabled'] ?? true)); - $sink->setEndpoint($endpoint); + $sink->setEndpoint($connector['endpoint']); $sink->setTls((bool)($params['tls'] ?? true)); $sink->setQueueCap(max(10, (int)($params['queueCap'] ?? 1000))); + $sink->setFormat($connector['format']); + $sink->setConnectorOptions($connector['connectorOptions']); $sink->setCreatedBy($adminUid); $sink->setCreatedAt(new DateTime()); $this->applySecretAndFilter(sink: $sink, params: $params); @@ -132,36 +124,20 @@ public function createSink(string $adminUid, array $params): SiemSink { * @return SiemSink * * @throws DoesNotExistException When the sink is missing + * An invalid format, endpoint or connector option raises SinkConnectorSettings's InvalidArgumentException. * * @spec openspec/specs/siem-audit-export/spec.md#requirement-admin-configured-syslog-and-webhook-sinks */ public function updateSink(string $adminUid, string $sinkId, array $params): SiemSink { $sink = $this->sinkMapper->findById($sinkId); - if (isset($params['name']) === true) { - $sink->setName((string)$params['name']); - } - - if (isset($params['enabled']) === true) { - $sink->setEnabled((bool)$params['enabled']); - } - - if (isset($params['endpoint']) === true && (string)$params['endpoint'] !== '') { - $sink->setEndpoint((string)$params['endpoint']); - } - - if (isset($params['tls']) === true) { - $sink->setTls((bool)$params['tls']); - } - - if (isset($params['queueCap']) === true) { - $sink->setQueueCap(max(10, (int)$params['queueCap'])); - } + $this->applyGeneralChanges(sink: $sink, params: $params); + (new SinkConnectorSettings())->applyUpdate(sink: $sink, params: $params); $this->applySecretAndFilter(sink: $sink, params: $params); $sink->setUpdatedAt(new DateTime()); $sink = $this->sinkMapper->update($sink); - $this->auditTrail->recordSinkUpdated(actorId: $adminUid, sinkId: $sinkId); + $this->auditTrail->recordSinkUpdated(actorId: $adminUid, sinkId: $sinkId, type: $sink->getType()); $this->reportSinksChanged(); return $sink; @@ -221,7 +197,9 @@ public function testSink(string $adminUid, string $sinkId): array { $this->transport->deliver(sink: $sink, payloadJson: $payload); } catch (Throwable $exception) { $outcome = 'failed'; - $error = $exception->getMessage(); + // The admin sees class, status and host, never the message, which + // names the full sink URL with any token in it (keepiq#728). + $error = $this->transport->describeFailure(exception: $exception, sink: $sink); } $this->auditTrail->recordSinkTested(actorId: $adminUid, sinkId: $sinkId, outcome: $outcome); @@ -258,6 +236,13 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void { $sink->setHmacSecretEnc($this->crypto->encrypt($secret)); } + // The connector credential follows the HMAC secret's rule: write-only, + // encrypted at rest, and blank keeps the stored one. + $credential = $params['credential'] ?? null; + if (is_string($credential) === true && $credential !== '') { + $sink->setCredentialEnc($this->crypto->encrypt($credential)); + } + if (array_key_exists('categoryFilter', $params) === true) { $filter = $params['categoryFilter']; $encoded = null; @@ -269,6 +254,33 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void { } }//end applySecretAndFilter() + /** + * Apply the generic fields an update supplies: name, enabled, tls and the + * queue cap. Absent fields stay as they are. + * + * @param SiemSink $sink The sink to mutate + * @param array $params The request params + * + * @return void + */ + private function applyGeneralChanges(SiemSink $sink, array $params): void { + if (isset($params['name']) === true) { + $sink->setName((string)$params['name']); + } + + if (isset($params['enabled']) === true) { + $sink->setEnabled((bool)$params['enabled']); + } + + if (isset($params['tls']) === true) { + $sink->setTls((bool)$params['tls']); + } + + if (isset($params['queueCap']) === true) { + $sink->setQueueCap(max(10, (int)$params['queueCap'])); + } + }//end applyGeneralChanges() + /** * Ask integriq to resolve SIEM export again after a sink change. * @@ -277,7 +289,7 @@ private function applySecretAndFilter(SiemSink $sink, array $params): void { * * @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 + * @spec openspec/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( diff --git a/lib/Service/SiemTransport.php b/lib/Service/SiemTransport.php index 1bd7851d5..39b98d09e 100644 --- a/lib/Service/SiemTransport.php +++ b/lib/Service/SiemTransport.php @@ -30,10 +30,16 @@ use DateTime; use OCA\Keepiq\Db\SiemSink; +use OCA\Keepiq\AppInfo\Application as KeepiqApp; +use OCA\Keepiq\Exception\SiemDeliveryException; +use OCA\Keepiq\Service\Siem\CefFormatter; +use OCA\Keepiq\Service\Siem\SentinelRowFormatter; +use OCA\Keepiq\Service\Siem\SplunkHecFormatter; use OCA\Keepiq\Support\SuppressesDiagnostics; +use OCP\App\IAppManager; use OCP\Http\Client\IClientService; use OCP\Security\ICrypto; -use RuntimeException; +use Throwable; /** * Sends one SIEM payload over a sink's configured transport. @@ -52,7 +58,8 @@ class SiemTransport { * Constructor for SiemTransport. * * @param ICrypto $crypto NC crypto (HMAC secret at rest) - * @param IClientService $clientService The HTTP client factory (webhooks) + * @param IClientService $clientService The HTTP client factory (webhooks, Splunk, Sentinel) + * @param IAppManager|null $appManager The app manager (Keepiq version in CEF) * * @return void * @@ -61,9 +68,19 @@ class SiemTransport { public function __construct( private ICrypto $crypto, private IClientService $clientService, + private ?IAppManager $appManager = null, ) { }//end __construct() + /** + * Sentinel access tokens, per tenant and client, for this process only: + * one drain run. A bearer token is a credential and is never written to a + * cache or table. + * + * @var array + */ + private array $sentinelTokens = []; + /** * Send one payload over the sink's configured transport. The single * place the syslog/webhook choice is made, shared by the delivery @@ -74,19 +91,246 @@ public function __construct( * * @return void * - * @throws \RuntimeException On transport failure + * @throws SiemDeliveryException On transport failure * * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery */ public function deliver(SiemSink $sink, string $payloadJson): void { - if ($sink->getType() === 'syslog') { - $this->deliverSyslog(sink: $sink, payloadJson: $payloadJson); + $type = $sink->getType(); + if ($type === 'syslog') { + // A json sink sends the stored payload exactly as before; a cef + // sink sends the CEF line in the same RFC 5424 frame. + $message = $payloadJson; + if ($sink->getFormat() === 'cef') { + $message = (new CefFormatter())->format(payload: $this->decode(payloadJson: $payloadJson), appVersion: $this->appVersion()); + } + + $this->deliverSyslog(sink: $sink, payloadJson: $message); + return; + } + + if ($type === 'splunk_hec') { + $this->deliverSplunk(sink: $sink, payload: $this->decode(payloadJson: $payloadJson)); + return; + } + + if ($type === 'sentinel') { + $this->deliverSentinel(sink: $sink, payload: $this->decode(payloadJson: $payloadJson)); return; } $this->deliverWebhook(sink: $sink, payloadJson: $payloadJson); }//end deliver() + /** + * Decode a queued payload. + * + * @param string $payloadJson The JSON payload + * + * @return array + * + * @throws SiemDeliveryException On a payload that is not a JSON object + */ + private function decode(string $payloadJson): array { + $payload = json_decode($payloadJson, true); + if (is_array($payload) === false) { + throw new SiemDeliveryException(message: 'queued payload is not a JSON object'); + } + + return $payload; + }//end decode() + + /** + * The Keepiq version for the CEF header. + * + * @return string + */ + private function appVersion(): string { + $version = $this->appManager?->getAppVersion(KeepiqApp::APP_ID); + if ($version === null || $version === '') { + return 'unknown'; + } + + return $version; + }//end appVersion() + + /** + * The connector credential, decrypted in memory for one request. + * + * @param SiemSink $sink The sink + * + * @return string + * + * @throws SiemDeliveryException When no credential is stored + */ + private function credential(SiemSink $sink): string { + $enc = $sink->getCredentialEnc(); + if ($enc === null || $enc === '') { + throw new SiemDeliveryException(message: 'connector has no credential'); + } + + return $this->crypto->decrypt($enc); + }//end credential() + + /** + * Splunk HTTP Event Collector delivery: one event per request, + * `Authorization: Splunk `, success only on HTTP 200 with a + * response code of 0. + * + * @param SiemSink $sink The sink (HEC URL) + * @param array $payload The payload + * + * @return void + * + * @throws SiemDeliveryException On any other answer + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-splunk-http-event-collector-delivery + */ + private function deliverSplunk(SiemSink $sink, array $payload): void { + $host = (string)gethostname(); + $body = (new SplunkHecFormatter())->format(payload: $payload, host: $host, options: $sink->connectorOptionsArray()); + $response = $this->clientService->newClient()->post( + $sink->getEndpoint(), + [ + 'body' => (string)json_encode($body), + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Splunk ' . $this->credential(sink: $sink), + ], + 'timeout' => self::DELIVERY_TIMEOUT, + 'http_errors' => false, + ] + ); + $status = $response->getStatusCode(); + $answer = json_decode((string)$response->getBody(), true); + $code = null; + if (is_array($answer) === true && array_key_exists('code', $answer) === true) { + $code = (int)$answer['code']; + } + + if ($status !== 200 || $code !== 0) { + throw new SiemDeliveryException(message: 'splunk did not accept the event', httpStatus: $status); + } + }//end deliverSplunk() + + /** + * Microsoft Sentinel delivery through the Logs Ingestion API: a + * client-credentials token held for this run, one row per post, 204 as + * success, and one fresh token and retry after a 401. + * + * @param SiemSink $sink The sink (data collection endpoint) + * @param array $payload The payload + * + * @return void + * + * @throws SiemDeliveryException On any other answer + * + * @spec openspec/specs/siem-vendor-connectors/spec.md#requirement-microsoft-sentinel-delivery-through-the-logs-ingestion-api + */ + private function deliverSentinel(SiemSink $sink, array $payload): void { + $options = $sink->connectorOptionsArray(); + $endpoint = rtrim(($options['dataCollectionEndpoint'] ?? $sink->getEndpoint()), '/'); + $url = $endpoint . '/dataCollectionRules/' . rawurlencode(($options['dcrImmutableId'] ?? '')) + . '/streams/' . rawurlencode(($options['streamName'] ?? 'Custom-KeepiqAudit')) . '?api-version=2023-01-01'; + $body = (string)json_encode([(new SentinelRowFormatter())->format(payload: $payload)]); + + for ($attempt = 0; $attempt < 2; $attempt++) { + $token = $this->sentinelToken(sink: $sink, options: $options); + $response = $this->clientService->newClient()->post( + $url, + [ + 'body' => $body, + 'headers' => ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token], + 'timeout' => self::DELIVERY_TIMEOUT, + 'http_errors' => false, + ] + ); + $status = $response->getStatusCode(); + if ($status === 204) { + return; + } + + if ($status !== 401) { + break; + } + + // An expired or revoked token: forget it and try once more. + unset($this->sentinelTokens[$this->tokenKey(options: $options)]); + } + + throw new SiemDeliveryException(message: 'sentinel did not accept the row', httpStatus: $status); + }//end deliverSentinel() + + /** + * The cache key of a Sentinel token. + * + * @param array $options The connector options + * + * @return string + */ + private function tokenKey(array $options): string { + return ($options['authorityHost'] ?? '') . '|' . ($options['tenantId'] ?? '') . '|' . ($options['clientId'] ?? ''); + }//end tokenKey() + + /** + * A Sentinel access token: the one this run already holds, or a new one + * from Entra ID with the client-credentials grant. + * + * @param SiemSink $sink The sink (client secret) + * @param array $options The connector options + * + * @return string + * + * @throws SiemDeliveryException When Entra ID refuses + */ + private function sentinelToken(SiemSink $sink, array $options): string { + $key = $this->tokenKey(options: $options); + if (isset($this->sentinelTokens[$key]) === true) { + return $this->sentinelTokens[$key]; + } + + $authority = rtrim(($options['authorityHost'] ?? 'https://login.microsoftonline.com'), '/'); + $response = $this->clientService->newClient()->post( + $authority . '/' . rawurlencode(($options['tenantId'] ?? '')) . '/oauth2/v2.0/token', + [ + 'body' => http_build_query( + [ + 'grant_type' => 'client_credentials', + 'client_id' => ($options['clientId'] ?? ''), + 'client_secret' => $this->credential(sink: $sink), + 'scope' => 'https://monitor.azure.com//.default', + ] + ), + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'timeout' => self::DELIVERY_TIMEOUT, + 'http_errors' => false, + ] + ); + $status = $response->getStatusCode(); + $answer = json_decode((string)$response->getBody(), true); + if ($status !== 200 || is_array($answer) === false || is_string(($answer['access_token'] ?? null)) === false) { + throw new SiemDeliveryException(message: 'entra id refused the token request', httpStatus: $status); + } + + $this->sentinelTokens[$key] = $answer['access_token']; + return $answer['access_token']; + }//end sentinelToken() + + /** + * Describe a failed delivery to this sink without its message: class, + * HTTP status and host only (keepiq#728). + * + * @param Throwable $exception The failure + * @param SiemSink $sink The sink + * + * @return string + * + * @spec openspec/specs/siem-audit-export/spec.md#requirement-reliable-background-delivery + */ + public function describeFailure(Throwable $exception, SiemSink $sink): string { + return (new SiemFailureOutcome())->describe(exception: $exception, sink: $sink); + }//end describeFailure() + /** * RFC 5424 syslog delivery over TCP (TLS when configured, §3.1). * @@ -95,7 +339,7 @@ public function deliver(SiemSink $sink, string $payloadJson): void { * * @return void * - * @throws \RuntimeException On transport failure + * @throws SiemDeliveryException On transport failure */ private function deliverSyslog(SiemSink $sink, string $payloadJson): void { $endpoint = $sink->getEndpoint(); @@ -105,8 +349,8 @@ private function deliverSyslog(SiemSink $sink, string $payloadJson): void { } // The stream_socket_client() call warns on an unreachable endpoint and - // returns false; the detail is already captured in $errstr/$errno, - // which the exception below re-reports. + // returns false. Its detail is not re-reported: a failure is described + // by class, status and host only (keepiq#728). $errno = 0; $errstr = ''; $socket = $this->withoutDiagnostics( @@ -120,7 +364,7 @@ private function deliverSyslog(SiemSink $sink, string $payloadJson): void { } ); if ($socket === false) { - throw new RuntimeException('syslog connect failed: ' . $errstr . ' (' . $errno . ')'); + throw new SiemDeliveryException(message: 'syslog connect failed'); } try { @@ -131,7 +375,7 @@ private function deliverSyslog(SiemSink $sink, string $payloadJson): void { $frame = strlen($message) . ' ' . $message; $written = fwrite($socket, $frame); if ($written === false || $written < strlen($frame)) { - throw new RuntimeException('syslog write failed'); + throw new SiemDeliveryException(message: 'syslog write failed'); } } finally { fclose($socket); @@ -147,7 +391,7 @@ private function deliverSyslog(SiemSink $sink, string $payloadJson): void { * * @return void * - * @throws \RuntimeException On transport failure / non-2xx + * @throws SiemDeliveryException On transport failure / non-2xx */ private function deliverWebhook(SiemSink $sink, string $payloadJson): void { $headers = ['Content-Type' => 'application/json']; @@ -168,7 +412,7 @@ private function deliverWebhook(SiemSink $sink, string $payloadJson): void { ); $status = $response->getStatusCode(); if ($status < 200 || $status > 299) { - throw new RuntimeException('webhook responded ' . $status); + throw new SiemDeliveryException(message: 'webhook did not accept the payload', httpStatus: $status); } }//end deliverWebhook() }//end class diff --git a/lib/Service/SuiteReinstateGuard.php b/lib/Service/SuiteReinstateGuard.php new file mode 100644 index 000000000..2487c8015 --- /dev/null +++ b/lib/Service/SuiteReinstateGuard.php @@ -0,0 +1,106 @@ + + * @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\Service; + +use OCA\Keepiq\Db\AuditEntryMapper; +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\ReinstateRefusedException; + +/** + * Refuses a reinstate that would undo a containment or duplicate an identity. + * + * A suite revoked as compromised stays revoked (keepiq#865). When the audit + * trail cannot show how the suite was revoked, the reinstate is refused too: + * without a trail this guard fails closed. + */ +class SuiteReinstateGuard { + /** + * Constructor for SuiteReinstateGuard. + * + * @param EncryptionSuiteMapper $mapper The encryption suite mapper + * @param AuditEntryMapper|null $auditEntries The audit trail (reads how a suite was revoked) + * + * @return void + */ + public function __construct( + private EncryptionSuiteMapper $mapper, + private ?AuditEntryMapper $auditEntries = null, + ) { + }//end __construct() + + /** + * Refuse a reinstate that would undo a containment or duplicate an identity. + * + * @param EncryptionSuite $suite The revoked suite + * + * @return void + * + * @throws ReinstateRefusedException + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated + */ + public function assertReinstatable(EncryptionSuite $suite): void { + $revokedAsCompromise = $this->wasRevokedAsCompromise(suiteId: (string)$suite->getId()); + if ($revokedAsCompromise !== false) { + throw new ReinstateRefusedException( + error: 'revoked_as_compromised', + message: 'This suite was revoked as compromised, or its revocation cannot be confirmed. ' + . 'Its owner has to set up a new vault instead.' + ); + } + + if ($this->mapper->countActiveByOwner($suite->getOwnerType(), $suite->getOwnerId()) > 0) { + throw new ReinstateRefusedException( + error: 'owner_has_active_suite', + message: 'The owner already has an active suite. Reinstating this one would give them two.' + ); + } + }//end assertReinstatable() + + /** + * Whether the suite's last revocation was marked as a compromise. + * + * @param string $suiteId The suite + * + * @return bool|null True or false from the last SUITE_REVOKED entry, null when none is found + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-suite-revoked-as-compromised-cannot-be-reinstated + */ + private function wasRevokedAsCompromise(string $suiteId): ?bool { + if ($this->auditEntries === null) { + return null; + } + + foreach ($this->auditEntries->findByObject(objectType: 'suite', objectId: $suiteId) as $entry) { + if ($entry->getEventType() !== AuditEventTypes::SUITE_REVOKED) { + continue; + } + + $metadata = json_decode((string)$entry->getMetadata(), true); + return is_array($metadata) === true && ($metadata['markCompromised'] ?? false) === true; + } + + return null; + }//end wasRevokedAsCompromise() +}//end class diff --git a/lib/Service/SuiteSetupGuard.php b/lib/Service/SuiteSetupGuard.php new file mode 100644 index 000000000..2f513951b --- /dev/null +++ b/lib/Service/SuiteSetupGuard.php @@ -0,0 +1,98 @@ + + * @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 + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-plain-create-refuses-to-mint-a-second-active-suite + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Service; + +use OCA\Keepiq\Db\EncryptionSuite; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Exception\ConflictException; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; + +/** + * The single-active-suite rule, made atomic per owner. + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-plain-create-refuses-to-mint-a-second-active-suite + */ +class SuiteSetupGuard { + /** + * Constructor for SuiteSetupGuard. + * + * @param EncryptionSuiteMapper $mapper The encryption suite mapper + * @param ILockingProvider|null $locking Nextcloud's locking provider + * + * @return void + * + * @spec exclude Constructor wiring only; no behaviour. + */ + public function __construct( + private EncryptionSuiteMapper $mapper, + private ?ILockingProvider $locking = null, + ) { + }//end __construct() + + /** + * Refuse when the owner already has an active suite, else persist one, + * both while holding the owner's setup lock. A setup that finds the lock + * held by another request for the same owner is refused too. + * + * @param string $ownerType 'user' or 'application' + * @param string $ownerId Nextcloud user ID or Application ID + * @param callable():EncryptionSuite $persist Inserts the suite + * + * @return EncryptionSuite + * + * @throws ConflictException When a suite exists or a setup is in progress + * + * @spec openspec/specs/encryption-suites/spec.md#requirement-a-plain-create-refuses-to-mint-a-second-active-suite + */ + public function createExclusive(string $ownerType, string $ownerId, callable $persist): EncryptionSuite { + $lockKey = 'keepiq/suite/'.$ownerType.'/'.$ownerId; + try { + $this->locking?->acquireLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException) { + throw new ConflictException(message: 'A vault setup for this owner is already in progress.'); + } + + try { + // Reported as #289: without this check any session could mint a + // second active suite, and resolution picks the NEWEST, so new + // secrets were sealed to a key the owner was not unlocking with. + if ($this->mapper->countActiveByOwner(ownerType: $ownerType, ownerId: $ownerId) > 0) { + throw new ConflictException( + message: 'An active EncryptionSuite already exists for this owner. ' + .'Change the master password or start a compromise recovery instead of creating a second suite.' + ); + } + + return $persist(); + } finally { + $this->locking?->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE); + } + }//end createExclusive() +}//end class diff --git a/lib/Service/TeamFolderAuditor.php b/lib/Service/TeamFolderAuditor.php index 049d5e47b..e86d52a20 100644 --- a/lib/Service/TeamFolderAuditor.php +++ b/lib/Service/TeamFolderAuditor.php @@ -234,10 +234,13 @@ public function gradeChanged( * @param string $successorUserId The successor taking ownership * @param int $revoked The number of derived shares revoked * @param int $transferred The number of secrets transferred + * @param int $membershipsRemoved The number of direct team-folder memberships removed + * @param string[] $coveringGroupIds The groups whose membership rows still cover the leaver * * @return void * * @spec openspec/changes/team-folder-sharing/tasks.md#2.5 + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-offboarding-removes-the-leavers-direct-team-folder-memberships */ public function offboarded( string $adminId, @@ -245,6 +248,8 @@ public function offboarded( string $successorUserId, int $revoked, int $transferred, + int $membershipsRemoved=0, + array $coveringGroupIds=[], ): void { $this->dispatch( event: $this->auditEvents->forUser( @@ -258,8 +263,38 @@ public function offboarded( 'successorUserId' => $successorUserId, 'revokedCount' => $revoked, 'transferredCount' => $transferred, + 'membershipsRemovedCount' => $membershipsRemoved, + 'coveringGroupIds' => $coveringGroupIds, ], ) ); }//end offboarded() + + /** + * Record an automatic confirmation run: the confirmer is the actor. + * + * @param string $actorId The confirmer (owner or write-grade member) + * @param string $teamFolderId The team folder + * @param int $confirmedCount The number of copies created + * @param int $memberCount The number of members who received copies + * + * @return void + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + public function membersConfirmed(string $actorId, string $teamFolderId, int $confirmedCount, int $memberCount): void { + $this->dispatch( + event: $this->auditEvents->forUser( + actorId: $actorId, + eventType: AuditEventTypes::TEAM_FOLDER_MEMBERS_CONFIRMED, + objectType: self::OBJECT_TYPE, + objectId: $teamFolderId, + objectName: '', + metadata: [ + 'confirmedCount' => $confirmedCount, + 'memberCount' => $memberCount, + ], + ) + ); + }//end membersConfirmed() }//end class diff --git a/lib/Service/TeamFolderConfirmationService.php b/lib/Service/TeamFolderConfirmationService.php new file mode 100644 index 000000000..2ea4b4fb1 --- /dev/null +++ b/lib/Service/TeamFolderConfirmationService.php @@ -0,0 +1,394 @@ + + * @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\Service; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Db\TeamFolder; +use OCA\Keepiq\Db\TeamFolderMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; + +/** + * Serves pending confirmations and accepts a confirmer's rows. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The confirmation rules join + * the team folder, membership, grade, share and secret sides in one place. + */ +class TeamFolderConfirmationService { + /** + * The admin policy switch (admin-auto-confirm-members D1). + */ + public const SWITCH_KEY = 'team_folder_auto_confirm'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig The app config (policy switch) + * @param TeamFolderMapper $mapper The team folder mapper + * @param TeamFolderService $teamFolders The owner fan-out path + * @param TeamFolderMembershipResolver $memberships Coverage, recipients, subtree + * @param TeamFolderShareService $shares Missing pairs and row registration + * @param ConfirmerCopyResolver $copies The confirmer's own current write copy + * @param NotificationService $notificationService The owner notice + * @param TeamFolderAuditor $audit The confirmation audit event + * @param TeamFolderQueryService $queries Reads the caller's grade on the folder + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IAppConfig $appConfig, + private TeamFolderMapper $mapper, + private TeamFolderService $teamFolders, + private TeamFolderMembershipResolver $memberships, + private TeamFolderShareService $shares, + private ConfirmerCopyResolver $copies, + private NotificationService $notificationService, + private TeamFolderAuditor $audit, + private TeamFolderQueryService $queries, + ) { + }//end __construct() + + /** + * Whether the administrator switched automatic confirmation on. + * + * @return bool + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-pending-confirmations-are-served-to-authorised-confirmers-only + */ + public function isEnabled(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::SWITCH_KEY, false); + }//end isEnabled() + + /** + * The team folders where the user may confirm and members still wait. + * + * Owned folders list every missing pair. For a folder the user is a member + * of, only pairs whose source the user holds with `write` grade and a + * current copy of are listed, each with the id of that copy. Empty when + * the switch is off. + * + * @param string $userId The session user + * + * @return array>,recipients:array}> + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-pending-confirmations-are-served-to-authorised-confirmers-only + */ + public function pendingConfirmations(string $userId): array { + if ($this->isEnabled() === false) { + return []; + } + + $pending = []; + foreach ($this->candidateFolders(userId: $userId) as $teamFolder) { + $entry = $this->pendingForFolder(teamFolder: $teamFolder, userId: $userId); + if ($entry !== null) { + $pending[] = $entry; + } + } + + return $pending; + }//end pendingConfirmations() + + /** + * Register fan-out rows from the owner, a manager or a `write`-grade confirmer. + * + * The owner and a manager (effective grade `manage`) take the plain + * fan-out, which checks the manage grade and the subtree itself + * (sharing-team-folder-manager-role D2). Anyone else needs the switch on, + * and every row is checked before anything is stored; rows that fail a + * check are skipped, so the pair stays pending. + * + * @param string $teamFolderId The team folder + * @param array> $rows The browser-encrypted rows + * @param string $userId The caller + * + * @return array{created:int,rows:array} + * + * @throws InvalidArgumentException When the folder is missing or the caller may not confirm + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-managers-keep-the-membership-current + */ + public function registerShares(string $teamFolderId, array $rows, string $userId): array { + $teamFolder = $this->loadTeamFolder(teamFolderId: $teamFolderId); + if ($teamFolder->getOwnerId() === $userId + || $this->queries->gradeOnTeamFolder(teamFolder: $teamFolder, userId: $userId) === 'manage' + ) { + return $this->teamFolders->registerFanOutShares(teamFolderId: $teamFolderId, shares: $rows, userId: $userId); + } + + if ($this->isEnabled() === false) { + throw new InvalidArgumentException(message: 'Not authorized to manage this team folder'); + } + + $subtreeIds = $this->subtreeIds(teamFolder: $teamFolder); + $eligible = $this->eligibleTargets(teamFolder: $teamFolder, confirmerId: $userId); + + $accepted = []; + foreach ($rows as $row) { + if ($this->rowIsSafe(row: $row, subtreeIds: $subtreeIds, eligible: $eligible, confirmerId: $userId) === true) { + $accepted[] = $row; + } + } + + if ($accepted === []) { + return ['created' => 0, 'rows' => []]; + } + + $result = $this->shares->registerFanOutShares( + teamFolder: $teamFolder, + shares: $accepted, + subtreeSecretIds: $subtreeIds, + userId: $userId + ); + + if ($result['created'] > 0) { + $this->announce(teamFolder: $teamFolder, confirmerId: $userId, rows: $result['rows']); + } + + return $result; + }//end registerShares() + + /** + * Team folders the user owns or is covered by a membership row of. + * + * @param string $userId The session user + * + * @return array Keyed by team folder id + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-pending-confirmations-are-served-to-authorised-confirmers-only + */ + private function candidateFolders(string $userId): array { + $folders = []; + foreach ($this->mapper->findByOwner(ownerId: $userId) as $owned) { + $folders[$owned->getId()] = $owned; + } + + foreach ($this->memberships->membershipRowsForUser(userId: $userId) as $row) { + $teamFolderId = (string)$row->getTeamFolderId(); + if (isset($folders[$teamFolderId]) === true) { + continue; + } + + try { + $folders[$teamFolderId] = $this->mapper->findById(id: $teamFolderId); + } catch (DoesNotExistException) { + continue; + } + } + + return $folders; + }//end candidateFolders() + + /** + * The pending entry of one folder for one confirmer, or null. + * + * @param TeamFolder $teamFolder The team folder + * @param string $userId The confirmer + * + * @return array{teamFolderId:string,role:string,missing:array>,recipients:array}|null + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-pending-confirmations-are-served-to-authorised-confirmers-only + */ + private function pendingForFolder(TeamFolder $teamFolder, string $userId): ?array { + $isOwner = $teamFolder->getOwnerId() === $userId; + $secrets = $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder); + $recipients = $this->memberships->eligibleRecipients( + userIds: array_values( + array_diff( + $this->memberships->effectiveUsers(teamFolderId: $teamFolder->getId()), + [$teamFolder->getOwnerId(), $userId] + ) + ) + ); + + $missing = []; + $ownCopies = []; + foreach ($this->shares->missingPairs(secrets: $secrets, recipients: $recipients) as $pair) { + if ($isOwner === true) { + $missing[] = $pair; + continue; + } + + $sourceId = $pair['secretId']; + if (array_key_exists($sourceId, $ownCopies) === false) { + $ownCopies[$sourceId] = $this->copies->currentWriteCopy(sourceId: $sourceId, confirmerId: $userId); + } + + if ($ownCopies[$sourceId] !== null) { + $missing[] = $pair + ['ownCopyId' => $ownCopies[$sourceId]]; + } + } + + if ($missing === []) { + return null; + } + + $needed = array_flip(array_column($missing, 'userId')); + $role = 'member'; + if ($isOwner === true) { + $role = 'owner'; + } + + return [ + 'teamFolderId' => $teamFolder->getId(), + 'role' => $role, + 'missing' => $missing, + 'recipients' => array_values( + array_filter($recipients, static fn (array $recipient): bool => isset($needed[$recipient['userId']])) + ), + ]; + }//end pendingForFolder() + + /** + * Whether one row from a non-owner confirmer may be stored. + * + * @param array $row The row + * @param array $subtreeIds The folder's subtree secret ids + * @param array $eligible The covered, enabled targets with a suite + * @param string $confirmerId The confirmer + * + * @return bool + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function rowIsSafe(array $row, array $subtreeIds, array $eligible, string $confirmerId): bool { + $sourceId = (string)($row['sourceSecretId'] ?? ''); + $targetId = (string)($row['targetUserId'] ?? ''); + + if (isset($subtreeIds[$sourceId]) === false || isset($eligible[$targetId]) === false) { + return false; + } + + return $this->copies->currentWriteCopy(sourceId: $sourceId, confirmerId: $confirmerId) !== null; + }//end rowIsSafe() + + /** + * The users a confirmer may hand a copy to: covered by a membership row, + * enabled, with an active suite, never the owner or the confirmer. + * + * @param TeamFolder $teamFolder The team folder + * @param string $confirmerId The confirmer + * + * @return array Keyed by user id + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function eligibleTargets(TeamFolder $teamFolder, string $confirmerId): array { + $targets = []; + $candidates = array_values( + array_diff( + $this->memberships->effectiveUsers(teamFolderId: $teamFolder->getId()), + [$teamFolder->getOwnerId(), $confirmerId] + ) + ); + foreach ($this->memberships->eligibleRecipients(userIds: $candidates) as $recipient) { + $targets[$recipient['userId']] = true; + } + + return $targets; + }//end eligibleTargets() + + /** + * The subtree secret ids of a team folder. + * + * @param TeamFolder $teamFolder The team folder + * + * @return array + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function subtreeIds(TeamFolder $teamFolder): array { + $ids = []; + foreach ($this->memberships->subtreeSecretRefs(teamFolder: $teamFolder) as $ref) { + $ids[$ref['id']] = true; + } + + return $ids; + }//end subtreeIds() + + /** + * Tell the owner who confirmed whom, and audit with the confirmer as actor. + * + * @param TeamFolder $teamFolder The team folder + * @param string $confirmerId The confirmer + * @param array $rows The created rows + * + * @return void + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function announce(TeamFolder $teamFolder, string $confirmerId, array $rows): void { + $memberIds = array_values(array_unique(array_column($rows, 'targetUserId'))); + + $this->notificationService->notify( + subject: 'team_folder_member_confirmed', + recipientId: $teamFolder->getOwnerId(), + params: [ + 'teamFolderId' => $teamFolder->getId(), + 'confirmedBy' => $confirmerId, + 'memberIds' => $memberIds, + ], + objectType: 'team_folder', + objectId: $teamFolder->getId(), + ); + + $this->audit->membersConfirmed( + actorId: $confirmerId, + teamFolderId: $teamFolder->getId(), + confirmedCount: count($rows), + memberCount: count($memberIds), + ); + }//end announce() + + /** + * Load a team folder by id. + * + * @param string $teamFolderId The team folder id + * + * @return TeamFolder + * + * @throws InvalidArgumentException When it does not exist + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-the-server-accepts-a-confirmers-row-only-when-it-is-safe + */ + private function loadTeamFolder(string $teamFolderId): TeamFolder { + try { + return $this->mapper->findById(id: $teamFolderId); + } catch (DoesNotExistException) { + throw new InvalidArgumentException(message: 'Team folder not found'); + } + }//end loadTeamFolder() +}//end class diff --git a/lib/Service/TeamFolderContributionService.php b/lib/Service/TeamFolderContributionService.php new file mode 100644 index 000000000..222e94bb2 --- /dev/null +++ b/lib/Service/TeamFolderContributionService.php @@ -0,0 +1,382 @@ + + * @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\Service; + +use DateTime; +use InvalidArgumentException; +use OCA\Keepiq\Db\EncryptionSuiteMapper; +use OCA\Keepiq\Db\FolderMapper; +use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\TeamFolder; +use OCA\Keepiq\Db\TeamFolderMapper; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\ForbiddenException; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use Ramsey\Uuid\Uuid; +use Throwable; + +/** + * Stores a write-grade member's new secret in a team folder. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The contribution joins the + * team folder, grade, suite, secret and share sides in one write. + */ +class TeamFolderContributionService { + /** + * Constructor. + * + * @param TeamFolderMapper $teamFolderMapper The team folder mapper + * @param FolderMapper $folderMapper The folder subtree + * @param TeamFolderQueryService $queries Effective grades + * @param TeamFolderMembershipResolver $memberships Covered, eligible members + * @param TeamFolderShareService $shares Registers the member copies + * @param SecretMapper $secretMapper Stores the owner row + * @param EncryptionSuiteMapper $suiteMapper The owner's active suite + * @param SecretTypeService $typeService Resolves the type for the owner + * @param IEventDispatcher|null $eventDispatcher The audit dispatcher + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private TeamFolderMapper $teamFolderMapper, + private FolderMapper $folderMapper, + private TeamFolderQueryService $queries, + private TeamFolderMembershipResolver $memberships, + private TeamFolderShareService $shares, + private SecretMapper $secretMapper, + private EncryptionSuiteMapper $suiteMapper, + private SecretTypeService $typeService, + private ?IEventDispatcher $eventDispatcher = null, + ) { + }//end __construct() + + /** + * Store a member's new secret in a team folder. + * + * @param string $teamFolderId The team folder + * @param array $data name, url, typeId, folderId, key, login, + * additionalFields (owner ciphertext) and + * copies (rows per member) + * @param string $userId The contributing member + * + * @return array{secret:Secret,copies:int} + * + * @throws NotFoundException When the team folder or target folder is unknown + * @throws ForbiddenException When the caller holds no write grade there + * @throws InvalidArgumentException On a missing name or owner ciphertext + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + public function contribute(string $teamFolderId, array $data, string $userId): array { + $teamFolder = $this->loadTeamFolder(teamFolderId: $teamFolderId); + $folderId = $this->targetFolder(teamFolder: $teamFolder, requested: $data['folderId'] ?? null); + + // Authorise on the grade of the target folder, before anything is read + // or written. The owner uses the ordinary secret create. + if ($this->mayContribute(teamFolder: $teamFolder, folderId: $folderId, userId: $userId) === false) { + throw new ForbiddenException(message: 'Only a member with write access can add a secret to this team folder'); + } + + $name = trim((string)($data['name'] ?? '')); + $key = (string)($data['key'] ?? ''); + if ($name === '' || $key === '') { + throw new InvalidArgumentException('A secret requires a name and a key'); + } + + $ownerId = $teamFolder->getOwnerId(); + try { + $suite = $this->suiteMapper->findActiveByOwner(ownerType: 'user', ownerId: $ownerId); + } catch (DoesNotExistException) { + throw new InvalidArgumentException('The team folder owner has no active vault'); + } + + $typeId = $this->typeService->resolveTypeForSecret($data['typeId'] ?? null, $ownerId); + $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($folderId); + $secret->setKey($key); + $secret->setLogin($this->nullableString(value: $data['login'] ?? null)); + $secret->setAdditionalFields($this->nullableString(value: $data['additionalFields'] ?? null)); + $secret->setEncryptionSuiteId($suite->getId()); + $secret->setOwnerType('user'); + $secret->setOwnerId($ownerId); + $secret->setCreatedAt($now); + $secret->setUpdatedAt($now); + $secret->setKeyUpdatedAt($now); + $this->secretMapper->insert($secret); + + try { + $result = $this->shares->registerFanOutShares( + teamFolder: $teamFolder, + shares: $this->memberCopies(teamFolder: $teamFolder, secretId: $secret->getId(), copies: (array)($data['copies'] ?? [])), + subtreeSecretIds: [$secret->getId() => true], + userId: $userId + ); + } catch (Throwable $exception) { + // No owner row without its copies: undo, then report. + $this->secretMapper->delete($secret); + throw $exception; + } + + $this->eventDispatcher?->dispatchTyped( + (new AuditEventFactory())->forUser( + actorId: $userId, + eventType: AuditEventTypes::SECRET_CREATED, + objectType: 'secret', + objectId: $secret->getId(), + objectName: $secret->getName(), + metadata: ['typeId' => $typeId, 'folderId' => $folderId], + ) + ); + + return ['secret' => $secret, 'copies' => $result['created']]; + }//end contribute() + + /** + * The team folders the user may contribute to: covered by a membership + * row, not owned, with an effective `write` grade on the folder itself. + * + * @param string $userId The session user + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + public function contributable(string $userId): array { + $found = []; + foreach ($this->memberships->membershipRowsForUser(userId: $userId) as $row) { + $teamFolderId = (string)$row->getTeamFolderId(); + if (isset($found[$teamFolderId]) === true) { + continue; + } + + try { + $teamFolder = $this->teamFolderMapper->findById(id: $teamFolderId); + } catch (DoesNotExistException) { + continue; + } + + if ($this->mayContribute(teamFolder: $teamFolder, folderId: $teamFolder->getFolderId(), userId: $userId) === false) { + continue; + } + + $folderName = ''; + try { + $folderName = $this->folderMapper->findById($teamFolder->getFolderId())->getName(); + } catch (DoesNotExistException) { + // Folder vanished: keep the entry with an empty name. + } + + $found[$teamFolderId] = [ + 'teamFolderId' => $teamFolderId, + 'folderId' => $teamFolder->getFolderId(), + 'folderName' => $folderName, + ]; + }//end foreach + + return array_values($found); + }//end contributable() + + /** + * What a write-grade member's browser needs to encrypt a contribution: + * the owner's certificate and every eligible member's certificate. + * Public key material only. + * + * @param string $teamFolderId The team folder + * @param string $userId The contributing member + * + * @return array{teamFolderId:string,folderId:string,ownerCertificate:string,recipients:array} + * + * @throws NotFoundException When the team folder is unknown + * @throws ForbiddenException When the caller holds no write grade there + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + public function context(string $teamFolderId, string $userId): array { + $teamFolder = $this->loadTeamFolder(teamFolderId: $teamFolderId); + if ($this->mayContribute(teamFolder: $teamFolder, folderId: $teamFolder->getFolderId(), userId: $userId) === false) { + throw new ForbiddenException(message: 'Only a member with write access can add a secret to this team folder'); + } + + try { + $ownerCertificate = (string)$this->suiteMapper + ->findActiveByOwner(ownerType: 'user', ownerId: $teamFolder->getOwnerId()) + ->getCertificate(); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'The team folder owner has no active vault'); + } + + return [ + 'teamFolderId' => $teamFolder->getId(), + 'folderId' => $teamFolder->getFolderId(), + 'ownerCertificate' => $ownerCertificate, + 'recipients' => $this->memberships->eligibleRecipients( + userIds: array_values( + array_diff( + $this->memberships->effectiveUsers(teamFolderId: $teamFolder->getId()), + [$teamFolder->getOwnerId()] + ) + ) + ), + ]; + }//end context() + + /** + * Whether the user may contribute to a folder of a team folder: not the + * owner, and an effective `write` grade on that folder. + * + * @param TeamFolder $teamFolder The team folder + * @param string $folderId The target folder + * @param string $userId The user + * + * @return bool + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + private function mayContribute(TeamFolder $teamFolder, string $folderId, string $userId): bool { + if ($teamFolder->getOwnerId() === $userId) { + return false; + } + + $probe = new Secret(); + $probe->setFolderId($folderId); + + return $this->queries->resolveGrade(secret: $probe, userId: $userId) === 'write'; + }//end mayContribute() + + /** + * The copy rows the server accepts: one per covered, enabled member with + * an active suite, never for the owner, for this new secret only. + * + * @param TeamFolder $teamFolder The team folder + * @param string $secretId The new owner row + * @param array $copies The submitted rows + * + * @return array> + * + * @spec openspec/specs/vault-policies/spec.md#requirement-write-grade-members-save-new-secrets-into-a-team-folder + */ + private function memberCopies(TeamFolder $teamFolder, string $secretId, array $copies): array { + $eligible = []; + $candidates = array_values( + array_diff( + $this->memberships->effectiveUsers(teamFolderId: $teamFolder->getId()), + [$teamFolder->getOwnerId()] + ) + ); + foreach ($this->memberships->eligibleRecipients(userIds: $candidates) as $recipient) { + $eligible[$recipient['userId']] = true; + } + + $rows = []; + foreach ($copies as $copy) { + if (is_array($copy) === false || isset($eligible[(string)($copy['targetUserId'] ?? '')]) === false) { + continue; + } + + $rows[] = [ + 'sourceSecretId' => $secretId, + 'targetUserId' => (string)$copy['targetUserId'], + 'encryptedKey' => (string)($copy['encryptedKey'] ?? ''), + 'encryptedLogin' => ($copy['encryptedLogin'] ?? null), + 'encryptedAdditionalFields' => ($copy['encryptedAdditionalFields'] ?? null), + ]; + } + + return $rows; + }//end memberCopies() + + /** + * The target folder: the team folder itself, or a folder in its subtree. + * + * @param TeamFolder $teamFolder The team folder + * @param mixed $requested The requested folder id + * + * @return string + * + * @throws NotFoundException When the folder is outside the team folder + */ + private function targetFolder(TeamFolder $teamFolder, mixed $requested): string { + if ($requested === null || $requested === '') { + return $teamFolder->getFolderId(); + } + + $subtree = array_map('strval', $this->folderMapper->getSubtreeIds(folderId: $teamFolder->getFolderId())); + if (in_array((string)$requested, $subtree, true) === false) { + throw new NotFoundException(message: 'Folder not found in this team folder'); + } + + return (string)$requested; + }//end targetFolder() + + /** + * Load a team folder. + * + * @param string $teamFolderId The team folder id + * + * @return TeamFolder + * + * @throws NotFoundException When it does not exist + */ + private function loadTeamFolder(string $teamFolderId): TeamFolder { + try { + return $this->teamFolderMapper->findById(id: $teamFolderId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Team folder not found'); + } + }//end loadTeamFolder() + + /** + * An empty value as null. + * + * @param mixed $value The value + * + * @return string|null + */ + private function nullableString(mixed $value): ?string { + if ($value === null || $value === '') { + return null; + } + + return (string)$value; + }//end nullableString() +}//end class diff --git a/lib/Service/TeamFolderMembershipResolver.php b/lib/Service/TeamFolderMembershipResolver.php index ee56ab8b5..330e7377b 100644 --- a/lib/Service/TeamFolderMembershipResolver.php +++ b/lib/Service/TeamFolderMembershipResolver.php @@ -165,15 +165,25 @@ public function effectiveUsers(string $teamFolderId): array { * their public certificates for browser-side encryption. Users * without a suite are skipped silently (§2.2). * + * A user whose Nextcloud account is disabled is skipped too. Disabling + * the account is the standard offboarding step, and a leaver still in a + * member group keeps an active suite, so without this the next + * reconcile would hand them a fresh copy (admin-member-overview D3). + * * @param string[] $userIds The candidate user IDs * * @return array * * @spec openspec/changes/team-folder-sharing/tasks.md#2.2 + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-the-fan-out-never-re-shares-to-a-disabled-account */ public function eligibleRecipients(array $userIds): array { $recipients = []; foreach ($userIds as $candidateId) { + if ($this->isDisabledAccount(userId: $candidateId) === true) { + continue; + } + try { $suite = $this->suiteMapper->findActiveByOwner(ownerType: 'user', ownerId: $candidateId); } catch (DoesNotExistException) { @@ -189,6 +199,24 @@ public function eligibleRecipients(array $userIds): array { return $recipients; }//end eligibleRecipients() + /** + * Whether a user's Nextcloud account exists and is disabled. + * + * An unknown user id is not "disabled": it simply has no suite and is + * skipped by the suite lookup, as before. + * + * @param string $userId The candidate user ID + * + * @return bool + * + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-the-fan-out-never-re-shares-to-a-disabled-account + */ + private function isDisabledAccount(string $userId): bool { + $user = $this->userManager->get($userId); + + return $user !== null && $user->isEnabled() === false; + }//end isDisabledAccount() + /** * All membership rows that cover a user: direct user rows plus group * rows of every group the user belongs to. @@ -229,6 +257,7 @@ public function membershipRowsForUser(string $userId): array { * @return array * * @spec openspec/changes/team-folder-sharing/tasks.md#2.3 + * @spec openspec/specs/use-only-shares/spec.md#requirement-the-server-refuses-what-it-can-enforce */ public function subtreeSecretRefs(TeamFolder $teamFolder): array { $refs = []; @@ -239,6 +268,11 @@ public function subtreeSecretRefs(TeamFolder $teamFolder): array { ownerId: $teamFolder->getOwnerId(), folderId: (string)$folderId ) as $secret) { + // A use-only or expiring copy is never fanned out (D4). + if ($secret->isRestrictedCopy() === true) { + continue; + } + $refs[] = [ 'id' => $secret->getId(), 'name' => $secret->getName(), diff --git a/lib/Service/TeamFolderOffboardingService.php b/lib/Service/TeamFolderOffboardingService.php index 133671669..6d3653fd8 100644 --- a/lib/Service/TeamFolderOffboardingService.php +++ b/lib/Service/TeamFolderOffboardingService.php @@ -9,8 +9,8 @@ * successor holds no recipient copy yet are reported as skipped — the admin * re-runs the offboarding after adding the successor to the folder. * - * The action is restricted to Nextcloud instance admins and members of the - * `vault_admin` group, mirroring DelegationService. + * The action is restricted to Nextcloud instance admins and holders of the + * People and offboarding admin area, mirroring DelegationService. * * @category Service * @package OCA\Keepiq\Service @@ -29,29 +29,24 @@ namespace OCA\Keepiq\Service; use InvalidArgumentException; -use OCP\IGroupManager; +use OCA\Keepiq\Db\TeamFolderMemberMapper; +use OCA\Keepiq\Settings\PeopleAdminSettings; use Psr\Log\LoggerInterface; /** * Runs the admin offboarding of a departing user's team-folder access. */ class TeamFolderOffboardingService { - /** - * The Nextcloud group whose members may run the offboarding action - * (in addition to instance admins). Mirrors DelegationService. - * - * @var string - */ - private const VAULT_ADMIN_GROUP = 'vault_admin'; - /** * Constructor for TeamFolderOffboardingService. * * @param TeamFolderShareService $shares The derived-share service (revocation) * @param TeamSecretTransferService $transfers The team-secret transfer service - * @param IGroupManager $groupManager The Nextcloud group manager + * @param AdminAreaAuthorizer $areas The People and offboarding area check * @param LoggerInterface $logger The logger * @param TeamFolderAuditor $audit The team-folder auditor + * @param TeamFolderMemberMapper $memberMapper The team-folder member rows + * @param TeamFolderMembershipResolver $memberships Resolves the group rows that cover a user * * @return void * @@ -60,25 +55,30 @@ class TeamFolderOffboardingService { public function __construct( private TeamFolderShareService $shares, private TeamSecretTransferService $transfers, - private IGroupManager $groupManager, + private AdminAreaAuthorizer $areas, private LoggerInterface $logger, private TeamFolderAuditor $audit, + private TeamFolderMemberMapper $memberMapper, + private TeamFolderMembershipResolver $memberships, ) { }//end __construct() /** - * Revoke every team-folder-derived share held by the leaving user, then - * transfer each team secret the leaver OWNS to the successor. + * Revoke every team-folder-derived share held by the leaving user, + * transfer each team secret the leaver OWNS to the successor, then + * remove the leaver's direct team-folder memberships and report the + * group memberships that still cover them. * * @param string $leavingUserId The user being offboarded * @param string $successorUserId The user taking over owned team secrets - * @param string $adminId The caller (instance admin or vault_admin) + * @param string $adminId The caller (instance admin or People area holder) * - * @return array{revoked:int,transferred:int,skipped:array} + * @return array{revoked:int,transferred:int,skipped:array,membershipsRemoved:int,stillCoveredByGroups:array} * * @throws InvalidArgumentException On invalid input / not authorized * * @spec openspec/changes/team-folder-sharing/tasks.md#2.5 + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-offboarding-removes-the-leavers-direct-team-folder-memberships */ public function offboard(string $leavingUserId, string $successorUserId, string $adminId): array { $this->assertOffboardingAdmin(userId: $adminId); @@ -103,9 +103,20 @@ public function offboard(string $leavingUserId, string $successorUserId, string $transferred = $transfer['transferred']; $skipped = $transfer['skipped']; + // Step 3 — remove the leaver's direct member rows. Revoking the shares + // alone left the leaver a member, so the owner's next "Share now" for + // pending members handed the secrets straight back (#747). It runs + // after the transfer, so a failed transfer leaves the rows in place for + // a re-run (admin-member-overview-and-offboarding D1). A membership + // through a group covers colleagues too: it stays, and is reported. + $membershipsRemoved = $this->removeDirectMemberships(userId: $leavingUserId); + $stillCoveredByGroups = $this->coveringGroups(userId: $leavingUserId); + $this->logger->info( 'Offboarded ' . $leavingUserId . ': revoked ' . $revoked . ' team shares, transferred ' - . $transferred . ' secrets to ' . $successorUserId, + . $transferred . ' secrets to ' . $successorUserId . ', removed ' + . $membershipsRemoved . ' team-folder memberships, ' + . count($stillCoveredByGroups) . ' group memberships still cover the user', ['app' => 'keepiq'] ); @@ -115,18 +126,72 @@ public function offboard(string $leavingUserId, string $successorUserId, string successorUserId: $successorUserId, revoked: $revoked, transferred: $transferred, + membershipsRemoved: $membershipsRemoved, + coveringGroupIds: array_values( + array_unique(array_column($stillCoveredByGroups, 'groupId')) + ), ); return [ 'revoked' => $revoked, 'transferred' => $transferred, 'skipped' => $skipped, + 'membershipsRemoved' => $membershipsRemoved, + 'stillCoveredByGroups' => $stillCoveredByGroups, ]; }//end offboard() /** - * Assert the caller may run the offboarding action: a Nextcloud - * instance admin or a member of the vault_admin group. + * The group membership rows that still cover a user after offboarding. + * + * A group row is never deleted: it covers every member of the group. The + * administrator gets the list instead, to remove the leaver from the + * Nextcloud group or disable the account (design D2). + * + * @param string $userId The user being offboarded + * + * @return array + * + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-offboarding-removes-the-leavers-direct-team-folder-memberships + */ + private function coveringGroups(string $userId): array { + $covering = []; + foreach ($this->memberships->membershipRowsForUser(userId: $userId) as $row) { + if ($row->getMemberType() !== 'group') { + continue; + } + + $covering[] = [ + 'teamFolderId' => (string)$row->getTeamFolderId(), + 'groupId' => (string)$row->getMemberId(), + ]; + } + + return $covering; + }//end coveringGroups() + + /** + * Delete every direct user-type team-folder membership of a user. + * + * @param string $userId The user being offboarded + * + * @return int The number of member rows removed + * + * @spec openspec/specs/team-folder-sharing/spec.md#requirement-offboarding-removes-the-leavers-direct-team-folder-memberships + */ + private function removeDirectMemberships(string $userId): int { + $removed = 0; + foreach ($this->memberMapper->findUserMemberships(userId: $userId) as $membership) { + $this->memberMapper->delete(entity: $membership); + $removed++; + } + + return $removed; + }//end removeDirectMemberships() + + /** + * Assert the caller may run the offboarding action: an instance admin or + * a holder of the People and offboarding area (admin-scoped-roles D5). * * @param string $userId The candidate admin * @@ -134,19 +199,15 @@ public function offboard(string $leavingUserId, string $successorUserId, string * * @throws InvalidArgumentException When unauthorized * - * @spec openspec/changes/team-folder-sharing/tasks.md#2.5 + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#2.4 */ private function assertOffboardingAdmin(string $userId): void { - if ($this->groupManager->isAdmin($userId) === true) { - return; - } - - if ($this->groupManager->isInGroup($userId, self::VAULT_ADMIN_GROUP) === true) { + if ($this->areas->holds(userId: $userId, areaClass: PeopleAdminSettings::class) === true) { return; } throw new InvalidArgumentException( - message: 'Offboarding requires instance admin or vault_admin membership' + message: 'Offboarding requires the People and offboarding admin area' ); }//end assertOffboardingAdmin() }//end class diff --git a/lib/Service/TeamFolderQueryService.php b/lib/Service/TeamFolderQueryService.php index 0c6f91f56..027081324 100644 --- a/lib/Service/TeamFolderQueryService.php +++ b/lib/Service/TeamFolderQueryService.php @@ -38,11 +38,20 @@ use OCA\Keepiq\Db\TeamFolderMapper; use OCA\Keepiq\Db\TeamFolderMember; use OCA\Keepiq\Db\TeamFolderMemberMapper; +use OCA\Keepiq\Exception\ManagerOnlyException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IGroupManager; /** * Read-side lookups and ancestor-chain resolution for team folders. + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The read side of team folders, + * including the ancestor walks that grades and restrictions both need. + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One public lookup per caller need. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The read side joins folders, + * team folders, memberships, groups and secrets, and the manage check + * refuses with its own exception so a viewer's grade change reads as + * forbidden (folder-permission-grades). */ class TeamFolderQueryService { /** @@ -170,8 +179,12 @@ public function listForUser(string $userId): array { } // Recipients see the folder identity, never the member list - // (share-visibility rule, user-sharing spec). - $memberOf[] = $this->describe(teamFolder: $teamFolder, includeMembers: false); + // (share-visibility rule, user-sharing spec); a manager sees the + // list it manages (sharing-team-folder-manager-role D5). + $grade = $this->gradeOnTeamFolder(teamFolder: $teamFolder, userId: $userId); + $entry = $this->describe(teamFolder: $teamFolder, includeMembers: $grade === 'manage'); + $entry['grade'] = $grade; + $memberOf[] = $entry; } return [ @@ -197,7 +210,9 @@ public function listMembers(string $teamFolderId, string $userId): array { return []; } - if ($teamFolder->getOwnerId() !== $userId) { + if ($teamFolder->getOwnerId() !== $userId + && $this->gradeOnTeamFolder(teamFolder: $teamFolder, userId: $userId) !== 'manage' + ) { return []; } @@ -266,11 +281,11 @@ public function resolveGrade(Secret $secret, string $userId): ?string { continue; } - if ($membership->effectiveGrade() === 'write') { - return 'write'; + $best = $this->higherGrade(current: $best, candidate: $membership->effectiveGrade()); + if ($best === 'manage') { + // Nothing ranks higher. + return $best; } - - $best = 'read'; } } catch (DoesNotExistException) { // Not a team folder — keep climbing. @@ -286,6 +301,109 @@ public function resolveGrade(Secret $secret, string $userId): ?string { return $best; }//end resolveGrade() + /** + * The higher of two grades (`read` < `write` < `manage`). + * + * @param string|null $current The best grade so far + * @param string $candidate Another grade + * + * @return string + */ + private function higherGrade(?string $current, string $candidate): string { + $ranks = array_flip(TeamFolderMember::GRADES); + if ($current === null || ($ranks[$candidate] ?? -1) > ($ranks[$current] ?? -1)) { + return $candidate; + } + + return $current; + }//end higherGrade() + + /** + * The caller's effective grade on a team folder itself: the highest + * grade any membership of it or of an ancestor team folder gives them. + * Null when nothing covers them. + * + * @param TeamFolder $teamFolder The team folder + * @param string $userId The caller + * + * @return string|null + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-effective-grade-is-the-highest-grade-along-the-ancestor-folder-chain + */ + public function gradeOnTeamFolder(TeamFolder $teamFolder, string $userId): ?string { + $best = null; + foreach ($this->ancestorTeamFolders(folderId: $teamFolder->getFolderId()) as $ancestor) { + foreach ($this->memberMapper->findByTeamFolder(teamFolderId: $ancestor->getId()) as $membership) { + if ($this->membershipCovers(membership: $membership, userId: $userId) === true) { + $best = $this->higherGrade(current: $best, candidate: $membership->effectiveGrade()); + } + } + } + + return $best; + }//end gradeOnTeamFolder() + + /** + * Load a team folder the caller may manage: its owner, or a member whose + * effective grade on it is `manage` (sharing-team-folder-manager-role D2). + * + * @param string $teamFolderId The TeamFolder UUID + * @param string $userId The caller + * + * @return TeamFolder + * + * @throws InvalidArgumentException When missing or the caller may not manage it + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-managers-keep-the-membership-current + */ + public function loadManageableTeamFolder(string $teamFolderId, string $userId): TeamFolder { + try { + $teamFolder = $this->mapper->findById(id: $teamFolderId); + } catch (DoesNotExistException) { + throw new InvalidArgumentException(message: 'Team folder not found'); + } + + if ($teamFolder->getOwnerId() !== $userId + && $this->gradeOnTeamFolder(teamFolder: $teamFolder, userId: $userId) !== 'manage' + ) { + // A viewer or an editor: forbidden, not a bad request + // (folder-permission-grades, "Non-owner cannot change a grade"). + throw new ManagerOnlyException(message: 'Not authorized to manage this team folder'); + } + + return $teamFolder; + }//end loadManageableTeamFolder() + + /** + * Every team-folder membership along a secret's folder ancestor chain + * that covers a user, directly or through a group. These are the + * team-folder grants ShareRestrictionResolver combines. + * + * @param Secret $secret The SOURCE secret + * @param string $userId The candidate user + * + * @return array + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-owners-can-share-a-secret-as-use-only + */ + public function coveringMemberships(Secret $secret, string $userId): array { + $folderId = $secret->getFolderId(); + if ($folderId === null || $folderId === '') { + return []; + } + + $covering = []; + foreach ($this->ancestorTeamFolders(folderId: $folderId) as $teamFolder) { + foreach ($this->memberMapper->findByTeamFolder(teamFolderId: $teamFolder->getId()) as $membership) { + if ($this->membershipCovers(membership: $membership, userId: $userId) === true) { + $covering[] = $membership; + } + } + } + + return $covering; + }//end coveringMemberships() + /** * Describe a team folder for the API (folder name resolved; members * included for the owner view only). @@ -329,8 +447,9 @@ private function describe(TeamFolder $teamFolder, bool $includeMembers): array { * @return array * * @spec openspec/changes/team-folder-sharing/tasks.md#2.3 + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders */ - private function ancestorTeamFolders(string $folderId): array { + public function ancestorTeamFolders(string $folderId): array { $found = []; $current = $folderId; $guard = 0; diff --git a/lib/Service/TeamFolderService.php b/lib/Service/TeamFolderService.php index 43295ccb7..e1cfd9e6d 100644 --- a/lib/Service/TeamFolderService.php +++ b/lib/Service/TeamFolderService.php @@ -41,10 +41,12 @@ use DateTime; use InvalidArgumentException; use OCA\Keepiq\Db\Secret; +use OCA\Keepiq\Db\ShareTargetMapper; use OCA\Keepiq\Db\TeamFolder; use OCA\Keepiq\Db\TeamFolderMapper; use OCA\Keepiq\Db\TeamFolderMember; use OCA\Keepiq\Db\TeamFolderMemberMapper; +use OCA\Keepiq\Exception\OwnerOnlyException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IDBConnection; use Ramsey\Uuid\Uuid; @@ -59,6 +61,9 @@ * one place. * @SuppressWarnings(PHPMD.TooManyPublicMethods) One public method per * API operation of the team-folder lifecycle. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The lifecycle carries the + * owner and manager rules (sharing-team-folder-manager-role) and the + * membership restrictions (sharing-use-only-and-expiring-shares) in one place. */ class TeamFolderService { /** @@ -73,10 +78,15 @@ class TeamFolderService { * @param TeamFolderAuditor $audit The team-folder auditor * @param NotificationService $notificationService The notification dispatcher * @param IDBConnection $db The database connection + * @param ShareRestrictionResolver|null $restrictions Materialises use-only and end dates onto copies + * @param ShareTargetMapper|null $shareTargets The share-target mapper (copies to recompute) * * @return void * * @spec exclude Constructor wiring only. + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) Constructor DI list; the two + * optional collaborators recompute copies after a membership change. */ public function __construct( private TeamFolderMapper $mapper, @@ -88,6 +98,8 @@ public function __construct( private TeamFolderAuditor $audit, private NotificationService $notificationService, private IDBConnection $db, + private ?ShareRestrictionResolver $restrictions = null, + private ?ShareTargetMapper $shareTargets = null, ) { }//end __construct() @@ -210,16 +222,24 @@ public function listMembers(string $teamFolderId, string $userId): array { * @param string $teamFolderId The TeamFolder UUID * @param string $memberType The member type (`user`|`group`) * @param string $memberId The Nextcloud user or group ID - * @param string $userId The caller (must be the owner) + * @param string $userId The caller (the owner or a manager) + * @param ShareRestriction|null $restriction Use-only (read grade only) and end date of the membership * * @return array{member:TeamFolderMember,recipients:array,secrets:array} * * @throws InvalidArgumentException On invalid input / not authorized * * @spec openspec/changes/team-folder-sharing/tasks.md#2.2 + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.2 */ - public function addMember(string $teamFolderId, string $memberType, string $memberId, string $userId): array { - $teamFolder = $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $userId); + public function addMember( + string $teamFolderId, + string $memberType, + string $memberId, + string $userId, + ?ShareRestriction $restriction = null, + ): array { + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $userId); $this->memberships->assertMemberAddable( teamFolder: $teamFolder, memberType: $memberType, @@ -233,6 +253,9 @@ public function addMember(string $teamFolderId, string $memberType, string $memb memberId: $memberId, userId: $userId ); + if ($restriction !== null) { + $membership = $this->applyRestriction(membership: $membership, restriction: $restriction); + } $newUsers = array_values( array_diff( @@ -245,7 +268,11 @@ public function addMember(string $teamFolderId, string $memberType, string $memb return [ 'member' => $membership, 'recipients' => $this->memberships->eligibleRecipients(userIds: $newUsers), - 'secrets' => $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder), + 'secrets' => $this->withCallerCopies( + refs: $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder), + teamFolder: $teamFolder, + userId: $userId + ), ]; }//end addMember() @@ -302,7 +329,7 @@ private function findOrCreateMembership( * * @param string $teamFolderId The TeamFolder UUID * @param string $membershipId The membership row UUID - * @param string $userId The caller (must be the owner) + * @param string $userId The caller (the owner or a manager) * * @return int Number of derived shares revoked * @@ -311,7 +338,7 @@ private function findOrCreateMembership( * @spec openspec/changes/team-folder-sharing/tasks.md#2.2 */ public function removeMember(string $teamFolderId, string $membershipId, string $userId): int { - $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $userId); + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $userId); try { $membership = $this->memberMapper->findById(id: $membershipId); @@ -323,6 +350,7 @@ public function removeMember(string $teamFolderId, string $membershipId, string throw new InvalidArgumentException(message: 'Membership does not belong to this team folder'); } + $this->assertManagerMayTouch(teamFolder: $teamFolder, membership: $membership, userId: $userId, leaving: true); $this->memberMapper->delete($membership); $coveredAfter = $this->memberships->effectiveUsers(teamFolderId: $teamFolderId); @@ -342,6 +370,10 @@ public function removeMember(string $teamFolderId, string $membershipId, string ); } + // Users still covered by another grant may have lost the one that + // lifted use-only or extended their access. + $this->resolveCopiesOf(membership: $membership); + $this->audit->memberRemoved( actorId: $userId, teamFolderId: $teamFolderId, @@ -375,18 +407,23 @@ public function resolveRecipients(string $secretId): array { * them. Idempotent server writes make a partial fan-out self-heal. * * @param string $teamFolderId The TeamFolder UUID - * @param string $userId The caller (must be the owner) + * @param string $userId The caller (the owner or a manager) * - * @return array{secrets:array,recipients:array,missing:array} + * @return array{secrets:array,recipients:array,missing:array,confirmedBy:object} * * @throws InvalidArgumentException On not found / not authorized * * @spec openspec/changes/team-folder-sharing/tasks.md#2.4 + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-an-unlocked-confirmers-browser-confirms-without-a-click */ public function reconcile(string $teamFolderId, string $userId): array { - $teamFolder = $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $userId); + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $userId); - $secrets = $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder); + $secrets = $this->withCallerCopies( + refs: $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder), + teamFolder: $teamFolder, + userId: $userId + ); $recipients = $this->memberships->eligibleRecipients( userIds: array_values( array_diff( @@ -400,6 +437,8 @@ public function reconcile(string $teamFolderId, string $userId): array { 'secrets' => $secrets, 'recipients' => $recipients, 'missing' => $this->shares->missingPairs(secrets: $secrets, recipients: $recipients), + // Members a write-grade colleague confirmed (admin-auto-confirm-members §3.3). + 'confirmedBy' => (object)$this->shares->confirmers(teamFolder: $teamFolder), ]; }//end reconcile() @@ -412,7 +451,7 @@ public function reconcile(string $teamFolderId, string $userId): array { * @param array> $shares Rows of sourceSecretId, targetUserId, * encryptedKey, encryptedLogin, * encryptedAdditionalFields - * @param string $userId The caller (must be the owner) + * @param string $userId The caller (the owner or a manager) * * @return array{created: int, rows: array} * @@ -421,7 +460,7 @@ public function reconcile(string $teamFolderId, string $userId): array { * @spec openspec/changes/team-folder-sharing/tasks.md#2.4 */ public function registerFanOutShares(string $teamFolderId, array $shares, string $userId): array { - $teamFolder = $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $userId); + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $userId); $subtreeSecretIds = []; foreach ($this->memberships->subtreeSecretRefs(teamFolder: $teamFolder) as $secretRef) { @@ -485,7 +524,7 @@ public function handleGroupMemberJoin(string $userId, string $groupId): int { * * @param string $teamFolderId The TeamFolder UUID * @param string $newMemberId The approved user's Nextcloud user ID - * @param string $userId The approver (must be the owner) + * @param string $userId The approver (the owner or a manager) * * @return array{recipients:array,secrets:array} * @@ -494,7 +533,7 @@ public function handleGroupMemberJoin(string $userId, string $groupId): int { * @spec openspec/changes/team-folder-sharing/tasks.md#3.1 */ public function approveJoin(string $teamFolderId, string $newMemberId, string $userId): array { - $teamFolder = $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $userId); + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $userId); $covered = $this->memberships->effectiveUsers(teamFolderId: $teamFolderId); if (in_array($newMemberId, $covered, true) === false) { @@ -503,7 +542,11 @@ public function approveJoin(string $teamFolderId, string $newMemberId, string $u return [ 'recipients' => $this->memberships->eligibleRecipients(userIds: [$newMemberId]), - 'secrets' => $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder), + 'secrets' => $this->withCallerCopies( + refs: $this->memberships->subtreeSecretRefs(teamFolder: $teamFolder), + teamFolder: $teamFolder, + userId: $userId + ), ]; }//end approveJoin() @@ -542,7 +585,7 @@ public function handleGroupMemberLeave(string $userId, string $groupId): int { * * @param string $leavingUserId The user being offboarded * @param string $successorUserId The user taking over owned team secrets - * @param string $adminId The caller (instance admin or vault_admin) + * @param string $adminId The caller (instance admin or People area holder) * * @return array{revoked:int,transferred:int,skipped:array} * @@ -565,21 +608,29 @@ public function offboard(string $leavingUserId, string $successorUserId, string * @param string $teamFolderId The team folder UUID * @param string $memberId The membership row UUID * @param string $grade The grade (`read`|`write`) - * @param string $ownerId The calling user (must own the folder) + * @param string $ownerId The calling user (the owner or a manager) + * @param ShareRestriction|null $restriction New use-only flag and end date (null = leave them) * * @return TeamFolderMember * * @throws InvalidArgumentException On non-owner, unknown member, or invalid grade * - * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-or-write-grade + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-team-folder-membership-carries-a-read-write-or-manage-grade * @spec openspec/specs/folder-permission-grades/spec.md#requirement-grade-changes-and-non-owner-writes-are-audited + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.2 */ - public function setMemberGrade(string $teamFolderId, string $memberId, string $grade, string $ownerId): TeamFolderMember { - if (in_array($grade, ['read', 'write'], true) === false) { - throw new InvalidArgumentException(message: 'grade must be read or write'); + public function setMemberGrade( + string $teamFolderId, + string $memberId, + string $grade, + string $ownerId, + ?ShareRestriction $restriction = null, + ): TeamFolderMember { + if (in_array($grade, TeamFolderMember::GRADES, true) === false) { + throw new InvalidArgumentException(message: 'grade must be read, write or manage'); } - $this->queries->loadOwnedTeamFolder(teamFolderId: $teamFolderId, userId: $ownerId); + $teamFolder = $this->queries->loadManageableTeamFolder(teamFolderId: $teamFolderId, userId: $ownerId); try { $member = $this->memberMapper->findById($memberId); @@ -591,8 +642,25 @@ public function setMemberGrade(string $teamFolderId, string $memberId, string $g throw new InvalidArgumentException(message: 'Membership not found'); } + $this->assertManagerMayTouch(teamFolder: $teamFolder, membership: $member, userId: $ownerId, leaving: false); + if ($grade === 'manage' && $teamFolder->getOwnerId() !== $ownerId) { + throw new OwnerOnlyException(message: 'Only the owner can make a member a manager'); + } + $member->setGrade($grade); + if ($restriction === null && $grade !== 'read') { + // Use-only is a read-grade option; an editor sees the value. + $restriction = new ShareRestriction(useOnly: false, expiresAt: $member->getExpiresAt()); + } + + if ($restriction !== null) { + $this->assertRestrictionFitsGrade(grade: $member->effectiveGrade(), restriction: $restriction); + $member->setUseOnly($restriction->useOnly); + $member->setExpiresAt($restriction->expiresAt); + } + $member = $this->memberMapper->update($member); + $this->resolveCopiesOf(membership: $member); $this->audit->gradeChanged( actorId: $ownerId, @@ -605,6 +673,144 @@ public function setMemberGrade(string $teamFolderId, string $memberId, string $g return $member; }//end setMemberGrade() + /** + * Tell a manager's browser which of its own recipient copies to decrypt + * for each folder secret (sharing-team-folder-manager-role D3): each ref + * gains `copyId`, the caller's copy, or null when the caller holds none + * (that secret is then skipped and stays missing for the owner). The + * owner's refs carry their own id, since the owner decrypts the source. + * + * @param array $refs The subtree secret refs + * @param TeamFolder $teamFolder The team folder + * @param string $userId The caller + * + * @return array + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-managers-keep-the-membership-current + */ + private function withCallerCopies(array $refs, TeamFolder $teamFolder, string $userId): array { + $isOwner = ($teamFolder->getOwnerId() === $userId); + foreach ($refs as $index => $ref) { + $copyId = null; + if ($isOwner === true) { + $copyId = $ref['id']; + } elseif ($this->shareTargets !== null) { + try { + $copyId = $this->shareTargets + ->findBySourceSecretAndTargetUser(sourceSecretId: $ref['id'], targetUserId: $userId) + ->getSecretId(); + } catch (DoesNotExistException) { + $copyId = null; + } + } + + $refs[$index]['copyId'] = $copyId; + } + + return $refs; + }//end withCallerCopies() + + /** + * Keep a manager below the owner (sharing-team-folder-manager-role D2): + * a manager who is not the owner may not change or remove a manager, + * except that a manager may remove their own membership (leave). + * + * @param TeamFolder $teamFolder The team folder + * @param TeamFolderMember $membership The membership being changed or removed + * @param string $userId The caller + * @param bool $leaving Whether this is a removal (own removal allowed) + * + * @return void + * + * @throws InvalidArgumentException When a manager reaches above their role + * + * @spec openspec/specs/folder-permission-grades/spec.md#requirement-only-the-owner-governs-managers-and-the-folder-itself + */ + private function assertManagerMayTouch( + TeamFolder $teamFolder, + TeamFolderMember $membership, + string $userId, + bool $leaving, + ): void { + if ($teamFolder->getOwnerId() === $userId) { + return; + } + + $ownMembership = ($membership->getMemberType() === 'user' && $membership->getMemberId() === $userId); + if ($leaving === true && $ownMembership === true) { + return; + } + + if ($membership->effectiveGrade() === 'manage') { + throw new OwnerOnlyException(message: 'Only the owner can change or remove a manager'); + } + }//end assertManagerMayTouch() + + /** + * Set a membership's use-only flag and end date, refusing use-only on a + * grade other than `read`, and recompute the covered copies. + * + * @param TeamFolderMember $membership The membership row + * @param ShareRestriction $restriction The new flag and end date + * + * @return TeamFolderMember + * + * @throws InvalidArgumentException When use-only is asked for a write grade + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-2.2 + */ + private function applyRestriction(TeamFolderMember $membership, ShareRestriction $restriction): TeamFolderMember { + $this->assertRestrictionFitsGrade(grade: $membership->effectiveGrade(), restriction: $restriction); + $membership->setUseOnly($restriction->useOnly); + $membership->setExpiresAt($restriction->expiresAt); + $membership = $this->memberMapper->update($membership); + $this->resolveCopiesOf(membership: $membership); + + return $membership; + }//end applyRestriction() + + /** + * Refuse use-only on any grade but `read` (D2: editing a value you + * cannot see is not offered). + * + * @param string $grade The membership's effective grade + * @param ShareRestriction $restriction The requested flag and end date + * + * @return void + * + * @throws InvalidArgumentException When use-only is asked for a write grade + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-owners-can-share-a-secret-as-use-only + */ + private function assertRestrictionFitsGrade(string $grade, ShareRestriction $restriction): void { + if ($restriction->useOnly === true && $grade !== 'read') { + throw new InvalidArgumentException(message: 'Use only is available for the read grade only'); + } + }//end assertRestrictionFitsGrade() + + /** + * Recompute the recipient copies of every user a membership covers. + * + * @param TeamFolderMember $membership The membership row + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-sharing-use-only-and-expiring-shares/tasks.md#task-1.2 + */ + private function resolveCopiesOf(TeamFolderMember $membership): void { + if ($this->restrictions === null || $this->shareTargets === null) { + return; + } + + $users = $this->memberships->expandMember( + memberType: $membership->getMemberType(), + memberId: $membership->getMemberId() + ); + foreach ($users as $coveredUserId) { + $this->restrictions->resolveTargets(targets: $this->shareTargets->findByTargetUser($coveredUserId)); + } + }//end resolveCopiesOf() + /** * The MAX grade any team-folder membership along a secret's folder * ancestor chain grants a user (`write` outranks `read`), or null diff --git a/lib/Service/TeamFolderShareService.php b/lib/Service/TeamFolderShareService.php index b5ad314d3..800dcb9c4 100644 --- a/lib/Service/TeamFolderShareService.php +++ b/lib/Service/TeamFolderShareService.php @@ -41,6 +41,9 @@ /** * Registers and revokes the derived shares of a team folder. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) The fan-out writes the copy, the + * share row and its restriction; each collaborator is one of those. */ class TeamFolderShareService { /** @@ -51,6 +54,7 @@ class TeamFolderShareService { * @param RecipientSecretCopyService $copies The recipient-copy service * @param NotificationService $notificationService The notification dispatcher * @param IDBConnection $db The database connection + * @param ShareRestrictionResolver|null $restrictions Materialises use-only and end dates onto copies * * @return void * @@ -62,6 +66,7 @@ public function __construct( private RecipientSecretCopyService $copies, private NotificationService $notificationService, private IDBConnection $db, + private ?ShareRestrictionResolver $restrictions = null, ) { }//end __construct() @@ -175,6 +180,31 @@ public function missingPairs(array $secrets, array $recipients): array { return $missing; }//end missingPairs() + /** + * Who handed each member their copies, when that was not the owner: + * the automatic confirmations of admin-auto-confirm-members, for the + * team folder dialog. User ids only, from the derived share rows. + * + * @param TeamFolder $teamFolder The team folder + * + * @return array Confirmer user id keyed by member user id + * + * @spec openspec/specs/team-folder-auto-confirm/spec.md#requirement-an-unlocked-confirmers-browser-confirms-without-a-click + */ + public function confirmers(TeamFolder $teamFolder): array { + $confirmers = []; + foreach ($this->bulkGrantMapper->findByTeamFolder(teamFolderId: $teamFolder->getId()) as $row) { + $createdBy = $row->getCreatedBy(); + if ($createdBy === '' || $createdBy === $teamFolder->getOwnerId()) { + continue; + } + + $confirmers[$row->getTargetUserId()] = $createdBy; + } + + return $confirmers; + }//end confirmers() + /** * Revoke every derived ShareTarget of a team folder (and the * recipient Secret copies they point at). @@ -312,7 +342,8 @@ private function createFanOutShare( $entity->setTeamFolderId($teamFolder->getId()); $entity->setCreatedBy($userId); $entity->setCreatedAt(new DateTime()); - $this->shareTargetMapper->insert($entity); + $persisted = $this->shareTargetMapper->insert($entity); + $this->restrictions?->resolveTarget(target: $persisted); return [ 'sourceSecretId' => $sourceSecretId, diff --git a/lib/Service/TwoFactorGate.php b/lib/Service/TwoFactorGate.php new file mode 100644 index 000000000..546412c8d --- /dev/null +++ b/lib/Service/TwoFactorGate.php @@ -0,0 +1,162 @@ + + * @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\Service; + +use OCP\Authentication\TwoFactorAuth\IRegistry; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; + +/** + * Decides whether the two-factor policy blocks a user's unlock. + */ +class TwoFactorGate { + /** + * The code every refusal and withheld suite carries. + */ + public const CODE = 'two_factor_required'; + + /** + * Backup codes are a fallback, not a second factor of their own. + */ + private const IGNORED_PROVIDERS = ['backup_codes']; + + /** + * Constructor. + * + * @param VaultPolicyService $policies The vault policies + * @param IRegistry $registry Nextcloud's two-factor provider registry + * @param IUserManager $userManager Resolves the user + * @param IGroupManager|null $groupManager Group members, for the admin count + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private VaultPolicyService $policies, + private IRegistry $registry, + private IUserManager $userManager, + private ?IGroupManager $groupManager = null, + ) { + }//end __construct() + + /** + * Whether the policy applies and the user has no real second factor. + * + * Fails closed: an in-scope user id Nextcloud cannot resolve is blocked. + * + * @param string $userId The user + * + * @return bool + * + * @spec openspec/specs/vault-policies/spec.md#requirement-vault-unlock-requires-nextcloud-two-factor-login + */ + public function blocks(string $userId): bool { + if ($this->policies->appliesTo(policy: VaultPolicyService::REQUIRE_TWO_FACTOR, userId: $userId) === false) { + return false; + } + + $user = $this->userManager->get($userId); + if ($user === null) { + return true; + } + + return $this->hasSecondFactor(user: $user) === false; + }//end blocks() + + /** + * How many users a two-factor policy scoped to these groups would cover, + * and how many of them have no second factor and would lose vault access + * at once. The admin section shows it before saving (design risk D3). + * + * @param string[] $groupIds The scope; empty means every user who logged in + * + * @return array{inScope:int,withoutTwoFactor:int} + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function gapReport(array $groupIds): array { + $users = $this->usersInScope(groupIds: $groupIds); + + $without = 0; + foreach ($users as $user) { + if ($this->hasSecondFactor(user: $user) === false) { + $without++; + } + } + + return ['inScope' => count($users), 'withoutTwoFactor' => $without]; + }//end gapReport() + + /** + * The users a scope covers, keyed by user id: the groups' members, or + * every user who has logged in when no group is named. + * + * @param string[] $groupIds The scope + * + * @return array + */ + private function usersInScope(array $groupIds): array { + $users = []; + if ($groupIds === []) { + $this->userManager->callForSeenUsers( + static function (IUser $user) use (&$users): bool { + $users[$user->getUID()] = $user; + return true; + } + ); + + return $users; + } + + foreach ($groupIds as $groupId) { + foreach ($this->groupManager?->get($groupId)?->getUsers() ?? [] as $user) { + $users[$user->getUID()] = $user; + } + } + + return $users; + }//end usersInScope() + + /** + * Whether a user has an enabled provider other than backup codes. + * + * @param IUser $user The user + * + * @return bool + */ + private function hasSecondFactor(IUser $user): bool { + foreach ($this->registry->getProviderStates($user) as $providerId => $enabled) { + if ($enabled === true && in_array($providerId, self::IGNORED_PROVIDERS, true) === false) { + return true; + } + } + + return false; + }//end hasSecondFactor() +}//end class diff --git a/lib/Service/UseOnlyUseRecorder.php b/lib/Service/UseOnlyUseRecorder.php new file mode 100644 index 000000000..fb0f8e3b0 --- /dev/null +++ b/lib/Service/UseOnlyUseRecorder.php @@ -0,0 +1,112 @@ + + * @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\Service; + +use DateTime; +use OCA\Keepiq\Db\SecretMapper; +use OCA\Keepiq\Db\ShareTargetMapper; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCA\Keepiq\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; + +/** + * Records each fill of a use-only copy the extension reports, as a + * `secret.used` audit event on the SOURCE secret, so the owner sees in its + * activity tab who used the login and when + * (sharing-use-only-and-expiring-shares D3, task 3.3). + */ +class UseOnlyUseRecorder { + + /** + * Constructor for UseOnlyUseRecorder. + * + * @param SecretMapper $secretMapper The secret mapper + * @param ShareTargetMapper $shareTargetMapper The share-target mapper (copy to source) + * @param IEventDispatcher $eventDispatcher The audit event dispatcher + * @param AuditEventFactory $auditEvents The audit-event factory + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private SecretMapper $secretMapper, + private ShareTargetMapper $shareTargetMapper, + private IEventDispatcher $eventDispatcher, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * Record one use of a use-only copy by its holder. + * + * Refused, with the answer an unknown id gets, for a secret the caller + * does not hold, for a copy that is not use-only, and for a copy whose + * access ended. + * + * @param string $copyId The recipient copy the extension filled + * @param string $userId The caller + * + * @return void + * + * @throws NotFoundException When the caller holds no such use-only copy + * + * @spec openspec/specs/use-only-shares/spec.md#requirement-each-use-is-recorded + */ + public function recordUse(string $copyId, string $userId): void { + try { + $copy = $this->secretMapper->findById($copyId); + $target = $this->shareTargetMapper->findByRecipientSecret(recipientSecretId: $copyId); + } catch (DoesNotExistException) { + throw new NotFoundException(message: 'Secret not found'); + } + + $accessEnds = $copy->getAccessExpiresAt(); + if ($copy->getOwnerType() !== 'user' + || $copy->getOwnerId() !== $userId + || $copy->getUseOnly() !== true + || ($accessEnds !== null && $accessEnds <= new DateTime()) + ) { + throw new NotFoundException(message: 'Secret not found'); + } + + $this->secretMapper->markUsed($copyId, $userId, new DateTime()); + + $sourceName = $copy->getName(); + try { + $sourceName = $this->secretMapper->findById($target->getSourceSecretId())->getName(); + } catch (DoesNotExistException) { + // Source gone; the copy's own name is the same plaintext metadata. + } + + $this->eventDispatcher->dispatchTyped( + $this->auditEvents->forUser( + actorId: $userId, + eventType: AuditEventTypes::SECRET_USED, + objectType: 'secret', + objectId: $target->getSourceSecretId(), + objectName: $sourceName, + metadata: ['copyId' => $copyId], + ) + ); + }//end recordUse() +}//end class diff --git a/lib/Service/VaultKeyProofExemption.php b/lib/Service/VaultKeyProofExemption.php new file mode 100644 index 000000000..c43fb361e --- /dev/null +++ b/lib/Service/VaultKeyProofExemption.php @@ -0,0 +1,48 @@ + + * @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 OCP\IRequest; + +/** + * Decides whether one request may skip its vault-key proof. + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ +interface VaultKeyProofExemption { + /** + * Whether this request may go ahead without a proof. Must answer false + * whenever it is unsure: a wrong true is a missing guard. + * + * @param IRequest $request The incoming request + * @param string $userId The acting user + * + * @return bool + * + * @spec openspec/specs/user-sharing/spec.md#requirement-sharing-with-a-new-party-requires-a-verified-key-proof + */ + public function exempts(IRequest $request, string $userId): bool; +}//end interface diff --git a/lib/Service/VaultKeyProofService.php b/lib/Service/VaultKeyProofService.php index 3d20b83cb..19ea8a59a 100644 --- a/lib/Service/VaultKeyProofService.php +++ b/lib/Service/VaultKeyProofService.php @@ -12,15 +12,13 @@ * caller, the purpose, and an expiry, so it can be verified without any * server-side store. * - * A proof is also SINGLE-USE: a successful verify() consumes its nonce in the - * distributed cache for the rest of the challenge's lifetime, and a second use - * is refused. The signature commits to the operation's parameters, but that is - * not enough on an upsert route: a captured designate proof, replayed after the - * owner revoked the contact, would recreate it (#804 review). This follows - * JwtAuthService's jti replay protection. The limit is the cache: without a - * memcache Nextcloud hands out a null cache, which forgets everything, so reuse - * is then not detected. The guarded flows keep working; only the replay - * protection degrades. With APCu only, reuse is detected per server. + * A proof is also SINGLE-USE: a successful verify() records a hash of its + * nonce in the database (keepiq_used_proofs, unique on the hash) for the rest + * of the challenge's lifetime, and a second use is refused. The signature + * commits to the operation's parameters, but that is not enough on an upsert + * route: a captured designate proof, replayed after the owner revoked the + * contact, would recreate it (#804 review). The database makes this hold on + * every install and across cluster nodes; a memcache did not (keepiq#868). * * The proof is a SIGNATURE, never a decryption. The browser's session key is * non-extractable and decrypt-only, so a decrypt challenge would be satisfiable @@ -43,14 +41,14 @@ namespace OCA\Keepiq\Service; +use OCA\Keepiq\Db\UsedProofNonceMapper; 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; +use Throwable; /** * Stateless issuance and verification of vault-key proofs. @@ -61,11 +59,6 @@ class VaultKeyProofService { */ 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 @@ -79,6 +72,16 @@ class VaultKeyProofService { 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'; + public const PURPOSE_ABORT_MIGRATION = 'abort-migration'; + // Keepiq#818: a session alone must not subscribe a new party to future + // values of a secret. A share to a new recipient, a batch registration and + // every delegation need a proof; a share to a known recipient does not. + public const PURPOSE_SHARE_NEW_RECIPIENT = 'share-new-recipient'; + public const PURPOSE_SHARE_REGISTER_BATCH = 'share-register-batch'; + public const PURPOSE_DELEGATION_CREATE = 'delegation-create'; + public const PURPOSE_DELEGATION_HANDOVER = 'delegation-handover'; + public const PURPOSE_APPROVE_DEVICE = 'approve-device'; + public const PURPOSE_APPROVE_ACCOUNT_RECOVERY = 'approve-account-recovery'; /** * The purposes a challenge may be issued for. @@ -92,23 +95,23 @@ class VaultKeyProofService { self::PURPOSE_EMERGENCY_DESIGNATE, self::PURPOSE_EMERGENCY_RE_ENVELOPE, self::PURPOSE_DELETE_ACCOUNT_DATA, + self::PURPOSE_ABORT_MIGRATION, + self::PURPOSE_SHARE_NEW_RECIPIENT, + self::PURPOSE_SHARE_REGISTER_BATCH, + self::PURPOSE_DELEGATION_CREATE, + self::PURPOSE_DELEGATION_HANDOVER, + self::PURPOSE_APPROVE_DEVICE, + self::PURPOSE_APPROVE_ACCOUNT_RECOVERY, ]; - /** - * 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 + * @param UsedProofNonceMapper $usedNonces Holds consumed nonces, so each proof is single-use + * @param LoggerInterface $logger Records a store failure that refused a proof * * @return void * @@ -118,7 +121,7 @@ public function __construct( private IConfig $config, private ISecureRandom $secureRandom, private ITimeFactory $timeFactory, - private ICacheFactory $cacheFactory, + private UsedProofNonceMapper $usedNonces, private LoggerInterface $logger, ) { }//end __construct() @@ -131,7 +134,7 @@ public function __construct( * * @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 + * @spec openspec/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); @@ -165,7 +168,7 @@ public function issueChallenge(string $userId, string $purpose): array { * * @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 + * @spec openspec/specs/vault-key-proof/spec.md#requirement-irreversible-operations-require-a-verified-key-proof */ public function verify( string $nonce, @@ -214,66 +217,42 @@ public function 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. + * The database is the authority (keepiq#868): a unique index on the + * nonce's hash makes the first claim win on every install, with or + * without a memcache, and across every node of a cluster. The cache this + * replaced was a no-op without a memcache and per node with APCu alone, + * so "single-use" silently weakened to "within five minutes, per node". + * + * Fails closed: if the claim cannot be recorded, the proof is refused. + * Expired claims are swept first; they protect nothing, since an expired + * challenge is refused on its own. * * @param string $nonce The verified challenge * @param int $expiresAt When the challenge expires * * @return void * - * @throws KeyProofRequiredException When the nonce was already used + * @throws KeyProofRequiredException When the nonce was already used, or the claim could not be recorded * - * @spec openspec/changes/harden-vault-key-material-guards/specs/vault-key-proof/spec.md#requirement-challenges-are-stateless-and-expiring + * @spec openspec/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; + try { + $this->usedNonces->deleteExpired($this->timeFactory->getTime()); + $claimed = $this->usedNonces->claim(nonceHash: hash('sha256', $nonce), expiresAt: $expiresAt); + } catch (Throwable $e) { + $this->logger->error( + 'Keepiq: could not record a used vault-key proof; refusing it', + ['app' => 'keepiq', 'exception' => $e] + ); + throw new KeyProofRequiredException(message: 'Proof could not be recorded as used'); } - if ($cache->hasKey($key) === true) { + if ($claimed === false) { 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 @@ -285,7 +264,7 @@ private function warnReuseUndetected(): void { * * @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 + * @spec openspec/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]; diff --git a/lib/Service/VaultPolicyService.php b/lib/Service/VaultPolicyService.php new file mode 100644 index 000000000..0415248fd --- /dev/null +++ b/lib/Service/VaultPolicyService.php @@ -0,0 +1,354 @@ + + * @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\Service; + +use InvalidArgumentException; +use OCA\Keepiq\AppInfo\Application; +use OCA\Keepiq\Event\Audit\AuditEventFactory; +use OCA\Keepiq\Event\Audit\AuditEventTypes; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUserSession; + +/** + * Reads, writes and evaluates the vault policies. + */ +class VaultPolicyService { + /** + * Policy: block personal vault export. + */ + public const EXPORT_DISABLED = 'vault_export_disabled'; + + /** + * Policy: require Nextcloud two-factor login before the vault unlocks. + */ + public const REQUIRE_TWO_FACTOR = 'vault_require_two_factor'; + + /** + * Policy: keep work logins in team folders. + */ + public const ORG_OWNERSHIP = 'vault_org_ownership'; + + /** + * The secret types the ownership policy covers. + */ + public const ORG_OWNERSHIP_TYPES = 'vault_org_ownership_types'; + + /** + * The three policies; each has a `_groups` scope key. + * + * @var string[] + */ + public const POLICIES = [self::EXPORT_DISABLED, self::REQUIRE_TWO_FACTOR, self::ORG_OWNERSHIP]; + + /** + * Default ownership types: the credential types a tender means by work logins. + * + * @var string[] + */ + public const DEFAULT_ORG_OWNERSHIP_TYPES = ['login', 'api_key', 'database']; + + /** + * A type name or group id may hold only these characters. + */ + private const NAME_PATTERN = '/^[A-Za-z0-9 _.@-]{1,64}$/'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig The app config + * @param IGroupManager $groupManager Group existence and membership + * @param IUserSession $userSession The audit actor + * @param IEventDispatcher|null $eventDispatcher The audit dispatcher + * @param AuditEventFactory $auditEvents The audit event factory + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + private IAppConfig $appConfig, + private IGroupManager $groupManager, + private IUserSession $userSession, + private ?IEventDispatcher $eventDispatcher = null, + private AuditEventFactory $auditEvents = new AuditEventFactory(), + ) { + }//end __construct() + + /** + * Every policy key with its stored or default value, for the admin page. + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function read(): array { + $settings = []; + foreach (self::POLICIES as $policy) { + $settings[$policy] = $this->appConfig->getValueBool(Application::APP_ID, $policy, false); + $settings[$policy . '_groups'] = $this->readList(key: $policy . '_groups', default: []); + } + + $settings[self::ORG_OWNERSHIP_TYPES] = $this->ownershipTypes(); + + return $settings; + }//end read() + + /** + * Validate and store the policy keys present in an admin save, then + * audit one before and after snapshot of the touched keys. + * + * Validation runs over every touched key before anything is written, so + * a bad value leaves all policies as they were. + * + * @param array $data The admin-settings input + * + * @return void + * + * @throws InvalidArgumentException On an unknown group or a malformed list + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function update(array $data): void { + $keys = array_keys($this->read()); + $touched = array_values(array_intersect($keys, array_keys($data))); + if ($touched === []) { + return; + } + + $writes = []; + foreach ($touched as $key) { + $writes[$key] = $this->validate(key: $key, value: $data[$key]); + } + + $before = array_intersect_key($this->read(), array_flip($touched)); + foreach ($writes as $key => $value) { + if (is_bool($value) === true) { + $this->appConfig->setValueBool(Application::APP_ID, $key, $value); + continue; + } + + $this->appConfig->setValueString(Application::APP_ID, $key, (string)json_encode($value)); + } + + $after = array_intersect_key($this->read(), array_flip($touched)); + + $this->eventDispatcher?->dispatchTyped( + $this->auditEvents->forUser( + actorId: ($this->userSession->getUser()?->getUID() ?? 'system'), + eventType: AuditEventTypes::VAULT_POLICY_UPDATED, + objectType: 'settings', + objectId: 'vault_policy', + objectName: '', + metadata: [ + 'before' => $before, + 'after' => $after, + ], + ) + ); + }//end update() + + /** + * Whether a policy is on and its group scope covers the user. + * + * @param string $policy One of POLICIES + * @param string $userId The user + * + * @return bool + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function appliesTo(string $policy, string $userId): bool { + if (in_array($policy, self::POLICIES, true) === false + || $this->appConfig->getValueBool(Application::APP_ID, $policy, false) === false + ) { + return false; + } + + $groups = $this->readList(key: $policy . '_groups', default: []); + if ($groups === []) { + return true; + } + + foreach ($groups as $groupId) { + if ($this->groupManager->isInGroup($userId, $groupId) === true) { + return true; + } + } + + return false; + }//end appliesTo() + + /** + * The effective policies for one user, as the browser may see them: + * whether each applies, and the covered types. Never the group lists. + * + * @param string $userId The session user + * + * @return array + * + * @spec openspec/specs/vault-policies/spec.md#requirement-administrator-configures-vault-policies-per-group + */ + public function effectiveFor(string $userId): array { + $effective = []; + foreach (self::POLICIES as $policy) { + $effective[$policy] = $this->appliesTo(policy: $policy, userId: $userId); + } + + $effective[self::ORG_OWNERSHIP_TYPES] = $this->ownershipTypes(); + + return $effective; + }//end effectiveFor() + + /** + * The secret type names the ownership policy covers. + * + * @return string[] + * + * @spec openspec/specs/vault-policies/spec.md#requirement-work-logins-are-kept-in-team-folders + */ + public function ownershipTypes(): array { + return $this->readList(key: self::ORG_OWNERSHIP_TYPES, default: self::DEFAULT_ORG_OWNERSHIP_TYPES); + }//end ownershipTypes() + + /** + * Validate one touched key: a policy switch, the type list, or a group list. + * + * @param string $key The key + * @param mixed $value The submitted value + * + * @return bool|string[] The value to store + * + * @throws InvalidArgumentException On a bad value + */ + private function validate(string $key, mixed $value): bool|array { + if (in_array($key, self::POLICIES, true) === true) { + return $this->validSwitch(key: $key, value: $value); + } + + $names = $this->validNames(key: $key, value: $value); + if ($key === self::ORG_OWNERSHIP_TYPES) { + if ($names === []) { + throw new InvalidArgumentException($key . ' must name at least one type'); + } + + return $names; + } + + return $this->existingGroups(key: $key, groupIds: $names); + }//end validate() + + /** + * A policy switch. + * + * @param string $key The key + * @param mixed $value The submitted value + * + * @return bool + * + * @throws InvalidArgumentException When it is not a boolean + */ + private function validSwitch(string $key, mixed $value): bool { + $bool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($bool === null) { + throw new InvalidArgumentException($key . ' must be true or false'); + } + + return $bool; + }//end validSwitch() + + /** + * A list of distinct, well-formed names. + * + * @param string $key The key + * @param mixed $value The submitted value + * + * @return string[] + * + * @throws InvalidArgumentException When it is not a list of valid names + */ + private function validNames(string $key, mixed $value): array { + if (is_array($value) === false) { + throw new InvalidArgumentException($key . ' must be a list'); + } + + $names = []; + foreach ($value as $name) { + if (is_string($name) === false || preg_match(self::NAME_PATTERN, $name) !== 1) { + throw new InvalidArgumentException($key . ' holds an invalid name'); + } + + $names[$name] = true; + } + + return array_keys($names); + }//end validNames() + + /** + * Group ids that exist in Nextcloud. + * + * @param string $key The key + * @param string[] $groupIds The group ids + * + * @return string[] + * + * @throws InvalidArgumentException When a group does not exist + */ + private function existingGroups(string $key, array $groupIds): array { + foreach ($groupIds as $groupId) { + if ($this->groupManager->groupExists($groupId) === false) { + throw new InvalidArgumentException($key . ' names an unknown group: ' . $groupId); + } + } + + return $groupIds; + }//end existingGroups() + + /** + * Read a JSON list key. + * + * @param string $key The key + * @param string[] $default The default list + * + * @return string[] + */ + private function readList(string $key, array $default): array { + $decoded = json_decode( + $this->appConfig->getValueString(Application::APP_ID, $key, (string)json_encode($default)), + true + ); + if (is_array($decoded) === false) { + return $default; + } + + return array_values(array_filter($decoded, 'is_string')); + }//end readList() +}//end class diff --git a/lib/Service/X509CertificateAssembler.php b/lib/Service/X509CertificateAssembler.php index 2aa7c8824..c36684d0e 100644 --- a/lib/Service/X509CertificateAssembler.php +++ b/lib/Service/X509CertificateAssembler.php @@ -30,10 +30,10 @@ use OCA\Keepiq\AppInfo\Application; use OCA\Keepiq\Support\PublicKeyLoaderAdapter; -use phpseclib3\Crypt\RSA; -use phpseclib3\Crypt\RSA\PrivateKey; -use phpseclib3\Crypt\RSA\PublicKey; -use phpseclib3\File\X509; +use phpseclib4\Crypt\RSA; +use phpseclib4\Crypt\RSA\PrivateKey; +use phpseclib4\Crypt\RSA\PublicKey; +use phpseclib4\File\X509; use Psr\Log\LoggerInterface; use RuntimeException; use Throwable; @@ -90,30 +90,23 @@ public function issueForPublicKey( throw new RuntimeException('Intermediate private key could not be loaded for issuance'); } - $issuer = new X509(); - $issuer->loadX509($intermediateCertPem); - $issuer->setPrivateKey($issuerPrivate->withPadding(RSA::SIGNATURE_PKCS1)); - - $subject = new X509(); // PKCS1 padding on the subject key so the SPKI carries the plain - // rsaEncryption OID — phpseclib's PSS default would emit an + // rsaEncryption OID: phpseclib's PSS default would emit an // id-RSASSA-PSS SPKI that WebCrypto/openssl consumers reject. - $subject->setPublicKey($subjectPublic->withPadding(RSA::SIGNATURE_PKCS1)); + $certificate = new X509($subjectPublic->withPadding(RSA::SIGNATURE_PKCS1)); foreach ($subjectDn as $dnProp => $dnValue) { - $subject->setDNProp($dnProp, $dnValue); + $certificate->addSubjectDNProp($dnProp, $dnValue); } - $signer = new X509(); - $signer->setSerialNumber((string)random_int(1, PHP_INT_MAX), 10); - $signer->setEndDate('+365 days'); - $issued = $signer->sign($issuer, $subject); - if ($issued === false) { - throw new RuntimeException('phpseclib certificate issuance failed'); - } + $certificate->setSerialNumber((string)random_int(1, PHP_INT_MAX), 10); + $certificate->setEndDate('+365 days'); + $this->signWithIntermediate( + certificate: $certificate, + intermediateCertPem: $intermediateCertPem, + issuerPrivate: $issuerPrivate, + ); - // The saveX509() helper is declared `: string`, so the only failure - // shape left to guard is an empty export. - $pem = $signer->saveX509($issued); + $pem = $certificate->toString(); if ($pem === '') { throw new RuntimeException('phpseclib certificate export failed'); } @@ -128,6 +121,8 @@ public function issueForPublicKey( * @param string $oldCert The current PEM certificate to re-sign * @param string $intermediateCert The signing intermediate certificate (PEM) * @param string $intermediateKeyPem The decrypted intermediate private key (PEM) + * @param string|null $fallbackCn CommonName to add when the old subject has none; + * an existing commonName is always kept * * @return string|null The new PEM certificate, or null when signing failed. * @@ -137,31 +132,38 @@ public function resignPreservingSubject( string $oldCert, string $intermediateCert, string $intermediateKeyPem, + ?string $fallbackCn = null, ): ?string { try { - $old = new X509(); - if ($old->loadX509($oldCert) === false) { + $old = $this->keyLoader->loadCertificate($oldCert); + $oldPublic = $old->getPublicKey(); + if ($oldPublic instanceof PublicKey === false) { return null; } - $issuer = new X509(); - $issuer->loadX509($intermediateCert); - $issuer->setPrivateKey($this->keyLoader->loadPrivateKey($intermediateKeyPem)); - - $subject = new X509(); - $subject->setPublicKey($old->getPublicKey()); - $subject->setDN($old->getDN()); - - $signer = new X509(); - $signer->setStartDate('-1 day'); - $signer->setEndDate('+365 days'); - $signer->setSerialNumber((string)random_int(1, PHP_INT_MAX), 10); - $signed = $signer->sign($issuer, $subject); - if ($signed === false) { + $issuerPrivate = $this->keyLoader->loadPrivateKey($intermediateKeyPem); + if ($issuerPrivate instanceof PrivateKey === false) { return null; } - return $signer->saveX509($signed); + // Same PKCS1 pin as issuance: a key read back from a certificate + // carries phpseclib's PSS default, which would rewrite the SPKI. + $certificate = new X509($oldPublic->withPadding(RSA::SIGNATURE_PKCS1)); + $certificate->setSubjectDN($old->getSubjectDN(X509::DN_ARRAY)); + if ($fallbackCn !== null && $fallbackCn !== '' && $old->hasSubjectDNProp('id-at-commonName') === false) { + $certificate->addSubjectDNProp('id-at-commonName', $fallbackCn); + } + + $certificate->setStartDate('-1 day'); + $certificate->setEndDate('+365 days'); + $certificate->setSerialNumber((string)random_int(1, PHP_INT_MAX), 10); + $this->signWithIntermediate( + certificate: $certificate, + intermediateCertPem: $intermediateCert, + issuerPrivate: $issuerPrivate, + ); + + return $certificate->toString(); } catch (Throwable $exception) { $this->logger->warning( 'Keepiq: phpseclib re-sign failed: ' . $exception->getMessage(), @@ -171,4 +173,26 @@ public function resignPreservingSubject( return null; }//end try }//end resignPreservingSubject() + + /** + * Sign a certificate with the intermediate: copy the intermediate's + * subject DN and key identifier into the issuer fields, then sign with + * PKCS#1 v1.5 (sha256WithRSAEncryption), for issuance and renewal alike. + * + * @param X509 $certificate The certificate to sign, signed in place + * @param string $intermediateCertPem The signing intermediate certificate (PEM) + * @param PrivateKey $issuerPrivate The intermediate's private key + * + * @return void + * + * @spec openspec/specs/certificate-lifecycle/spec.md + */ + private function signWithIntermediate( + X509 $certificate, + string $intermediateCertPem, + PrivateKey $issuerPrivate, + ): void { + $certificate->copySigningX509Attributes($this->keyLoader->loadCertificate($intermediateCertPem)); + $issuerPrivate->withPadding(RSA::SIGNATURE_PKCS1)->withHash('sha256')->sign($certificate); + }//end signWithIntermediate() }//end class diff --git a/lib/Settings/AdminAreaSettings.php b/lib/Settings/AdminAreaSettings.php new file mode 100644 index 000000000..b4da07470 --- /dev/null +++ b/lib/Settings/AdminAreaSettings.php @@ -0,0 +1,118 @@ +::class)]` and + * `IManager::getAllowedAdminSettings()` compare that stored name with + * `get_class()` of the registered instance, so each area MUST be a concrete + * Keepiq class registered under its own name (see DomainOverrideRegistrar). + * + * @category Settings + * @package OCA\Keepiq\Settings + * + * @author Conduction Development Team + * @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\Settings; + +use OCA\Keepiq\AppInfo\Application; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\IL10N; +use OCP\Settings\IDelegatedSettings; + +/** + * Shared shape of the five Keepiq admin areas. + */ +abstract class AdminAreaSettings implements IDelegatedSettings { + /** + * The Keepiq admin section every area belongs to. + * + * @var string + */ + public const SECTION = Application::APP_ID; + + /** + * Constructor. + * + * @param IL10N $l10n Translates the area name + * @param IInitialState $initialState Tells the admin bundle which area mounts here + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + protected readonly IL10N $l10n, + protected readonly IInitialState $initialState, + ) { + }//end __construct() + + /** + * The area key: `general`, `policies`, `applications`, `people` or `audit`. + * + * @return string + */ + abstract public function getArea(): string; + + /** + * The admin form: one mount element and one initial-state key per area, + * so five areas on one page never overwrite each other's state + * (admin-scoped-roles D3). + * + * @return TemplateResponse + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getForm(): TemplateResponse { + $this->initialState->provideInitialState('area-' . $this->getArea(), true); + $this->provideAreaState(); + + return new TemplateResponse(Application::APP_ID, 'settings/admin', ['area' => $this->getArea()]); + }//end getForm() + + /** + * Extra initial state an area needs; none by default. + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + protected function provideAreaState(): void { + }//end provideAreaState() + + /** + * The Keepiq admin section. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getSection(): string { + return self::SECTION; + }//end getSection() + + /** + * Delegated admins manage no app config through Nextcloud's own API; + * Keepiq's area routes do the writing. + * + * @return array + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getAuthorizedAppConfig(): array { + return []; + }//end getAuthorizedAppConfig() +}//end class diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 33e05a328..e45c4609a 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -1,16 +1,15 @@ `, and - * `#[AuthorizedAdminSetting(AdminSettings::class)]` on the SettingsController - * mutating methods targets this exact class — so it must physically exist in - * the Keepiq namespace. All behaviour lives in the engine-owned - * {@see \OCA\OpenRegister\AppHost\Settings\GenericAdminSettings}, which - * Bootstrap::register() binds to this class via a factory closure. This subclass - * carries no logic. + * The first of the five delegable Keepiq admin areas (admin-scoped-roles D1). + * It keeps its historical class name, so a delegation an administrator made + * before the split still means something: it now grants the General area. + * Nextcloud loads it from info.xml ``, and + * DomainOverrideRegistrar binds this concrete class over the AppHost generic, + * so `get_class()` of the registered instance is this class and a delegation + * of it satisfies `#[AuthorizedAdminSetting(AdminSettings::class)]`. * * @category Settings * @package OCA\Keepiq\Settings @@ -28,12 +27,88 @@ namespace OCA\Keepiq\Settings; -use OCA\OpenRegister\AppHost\Settings\GenericAdminSettings; +use OCA\Keepiq\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\AppFramework\Services\IInitialState; +use OCP\IAppConfig; +use OCP\IL10N; /** - * Keepiq admin-settings panel — engine-backed stub (AppHost, ADR-040). + * The General area: version, CA, attachments, offline cache, breach check, + * secret types and vault backups. * - * @psalm-suppress UnusedClass + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . */ -class AdminSettings extends GenericAdminSettings { +class AdminSettings extends AdminAreaSettings { + /** + * Constructor. + * + * @param IL10N $l10n Translates the area name + * @param IInitialState $initialState The admin bundle's initial state + * @param IAppManager $appManager The running app version + * @param IAppConfig $appConfig The version the configuration was imported for + * + * @return void + * + * @spec exclude Constructor wiring only. + */ + public function __construct( + IL10N $l10n, + IInitialState $initialState, + private readonly IAppManager $appManager, + private readonly IAppConfig $appConfig, + ) { + parent::__construct(l10n: $l10n, initialState: $initialState); + }//end __construct() + + /** + * The area key. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getArea(): string { + return 'general'; + }//end getArea() + + /** + * The area name on Nextcloud's "Administration privileges" page. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getName(): string { + return $this->l10n->t('General'); + }//end getName() + + /** + * First in the section. + * + * @return int + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getPriority(): int { + return 10; + }//end getPriority() + + /** + * The version card state the admin settings shell reads (formerly + * provided by the AppHost generic). + * + * @return void + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#3.3 + */ + protected function provideAreaState(): void { + $version = $this->appManager->getAppVersion(Application::APP_ID); + // Gate-59: unclosable-gate exclude 'config_version' is written by OpenRegister's AppHost configuration + // import, not by this app; it is only displayed here and guards no setup. + $configuredVersion = $this->appConfig->getValueString(Application::APP_ID, 'config_version', ''); + $this->initialState->provideInitialState('version', $version); + $this->initialState->provideInitialState('configuredVersion', $configuredVersion); + $this->initialState->provideInitialState('isUpToDate', ($configuredVersion !== '' && $configuredVersion === $version)); + }//end provideAreaState() }//end class diff --git a/lib/Settings/ApplicationAdminSettings.php b/lib/Settings/ApplicationAdminSettings.php new file mode 100644 index 000000000..d4b16fed5 --- /dev/null +++ b/lib/Settings/ApplicationAdminSettings.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 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Settings; + +/** + * The applications and machine access area: the application queue, application requests and machine leases. + * + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . + */ +class ApplicationAdminSettings extends AdminAreaSettings { + /** + * The area key. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getArea(): string { + return 'applications'; + }//end getArea() + + /** + * The area name on Nextcloud's "Administration privileges" page. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getName(): string { + return $this->l10n->t('Applications and machine access'); + }//end getName() + + /** + * Position in the section, after the areas listed before it. + * + * @return int + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getPriority(): int { + return 12; + }//end getPriority() +}//end class diff --git a/lib/Settings/AuditAdminSettings.php b/lib/Settings/AuditAdminSettings.php new file mode 100644 index 000000000..741187417 --- /dev/null +++ b/lib/Settings/AuditAdminSettings.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 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Settings; + +/** + * The audit and compliance area: the audit log, compliance reports, SIEM sinks and honey alerts. + * + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . + */ +class AuditAdminSettings extends AdminAreaSettings { + /** + * The area key. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getArea(): string { + return 'audit'; + }//end getArea() + + /** + * The area name on Nextcloud's "Administration privileges" page. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getName(): string { + return $this->l10n->t('Audit and compliance'); + }//end getName() + + /** + * Position in the section, after the areas listed before it. + * + * @return int + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getPriority(): int { + return 14; + }//end getPriority() +}//end class diff --git a/lib/Settings/PeopleAdminSettings.php b/lib/Settings/PeopleAdminSettings.php new file mode 100644 index 000000000..794aa461a --- /dev/null +++ b/lib/Settings/PeopleAdminSettings.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 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Settings; + +/** + * The people and offboarding area: members, team offboarding, encryption suites and admin handover. + * + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . + */ +class PeopleAdminSettings extends AdminAreaSettings { + /** + * The area key. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getArea(): string { + return 'people'; + }//end getArea() + + /** + * The area name on Nextcloud's "Administration privileges" page. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getName(): string { + return $this->l10n->t('People and offboarding'); + }//end getName() + + /** + * Position in the section, after the areas listed before it. + * + * @return int + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getPriority(): int { + return 13; + }//end getPriority() +}//end class diff --git a/lib/Settings/PolicyAdminSettings.php b/lib/Settings/PolicyAdminSettings.php new file mode 100644 index 000000000..8bcfbdb3a --- /dev/null +++ b/lib/Settings/PolicyAdminSettings.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 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Keepiq\Settings; + +/** + * The policies area: master password, organisation password, rotation and expiry, session timeout, vault policies, version and trash retention. + * + * @psalm-suppress UnusedClass Loaded by Nextcloud from appinfo/info.xml . + */ +class PolicyAdminSettings extends AdminAreaSettings { + /** + * The area key. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getArea(): string { + return 'policies'; + }//end getArea() + + /** + * The area name on Nextcloud's "Administration privileges" page. + * + * @return string + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getName(): string { + return $this->l10n->t('Policies'); + }//end getName() + + /** + * Position in the section, after the areas listed before it. + * + * @return int + * + * @spec openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md#1.1 + */ + public function getPriority(): int { + return 11; + }//end getPriority() +}//end class diff --git a/lib/Support/PublicKeyLoaderAdapter.php b/lib/Support/PublicKeyLoaderAdapter.php index c63f1c195..617adb24a 100644 --- a/lib/Support/PublicKeyLoaderAdapter.php +++ b/lib/Support/PublicKeyLoaderAdapter.php @@ -3,11 +3,12 @@ /** * Keepiq Public Key Loader Adapter * - * A thin injectable seam over phpseclib3's PublicKeyLoader, whose key-parsing + * A thin injectable seam over phpseclib4's PublicKeyLoader, whose key-parsing * entry points are static factory methods with no instance API * (vendor/phpseclib/phpseclib/phpseclib/Crypt/PublicKeyLoader.php declares * `public static function load()` and `loadPrivateKey()` and the class has no - * constructor at all). + * constructor at all). Since phpseclib 4 a certificate is parsed the same + * way, through the static `File\X509::load()`, so that lives here too. * * Wrapping it keeps CertificateAuthorityService free of a hard-wired static * call and gives the certificate-issuance paths — some of the hardest code in @@ -29,16 +30,18 @@ namespace OCA\Keepiq\Support; -use phpseclib3\Crypt\Common\AsymmetricKey; -use phpseclib3\Crypt\Common\PrivateKey; -use phpseclib3\Crypt\PublicKeyLoader; +use phpseclib4\Crypt\Common\AsymmetricKey; +use phpseclib4\Crypt\Common\PrivateKey; +use phpseclib4\Crypt\PublicKeyLoader; +use phpseclib4\File\X509; /** - * Loads phpseclib3 keys through instance methods. + * Loads phpseclib4 keys through instance methods. * - * @SuppressWarnings(PHPMD.StaticAccess) The two delegations below are the ONE - * place in the app that reaches phpseclib3\Crypt\PublicKeyLoader. The library - * exposes key parsing exclusively as static factory methods — there is no + * @SuppressWarnings(PHPMD.StaticAccess) The delegations below are the ONE + * place in the app that reaches phpseclib4\Crypt\PublicKeyLoader and + * phpseclib4\File\X509::load(). The library exposes key and certificate + * parsing exclusively as static factory methods — there is no * instance API to call and nothing to construct — so the static access cannot * be removed, only confined to a documented, injectable adapter. */ @@ -74,4 +77,17 @@ public function loadPrivateKey(string $key, string $password = ''): PrivateKey { return PublicKeyLoader::loadPrivateKey($key, $password); }//end loadPrivateKey() + + /** + * Parse an X.509 certificate from its PEM/DER encoding. + * + * @param string $certificate The encoded certificate + * + * @return X509 + * + * @spec openspec/specs/certificate-lifecycle/spec.md + */ + public function loadCertificate(string $certificate): X509 { + return X509::load($certificate); + }//end loadCertificate() }//end class diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 000000000..25a47c27e --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,13 @@ +.gradle/ +build/ +local.properties +.kotlin/ +*.iml +.idea/ +ios/**/xcuserdata/ +ios/**/DerivedData/ +# The e2e workflow writes the test server certificate here. +android/app/src/e2e/res/raw/ +# Generated by xcodegen from ios/project.yml. +ios/Keepiq.xcodeproj/ +ios/Keepiq/Info.plist diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 000000000..245bc1b06 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,116 @@ +# Keepiq mobile apps + +Keepiq for Android and Keepiq for iOS, built from one Kotlin Multiplatform core. The design is in `openspec/changes/clients-mobile-apps/` (read `design.md` first). The user guide is `docs/mobile/using.md`, the privacy policy `docs/mobile/privacy.md`. + +## Layout + +| Path | What it holds | +|---|---| +| `shared/` | The Kotlin Multiplatform core: crypto, pairing, the vault store (SQLCipher), sync, Send, the generator, one-time codes, autofill matching and passkeys. Targets: Android, JVM (tests and the live server test) and iOS (an XCFramework). | +| `android/app/` | The Android app (Jetpack Compose), with the autofill service and the passkey provider. | +| `android/otherapp/` | A second app, used only by the autofill e2e test. It is never released. | +| `ios/` | The iOS app as an XcodeGen spec (`project.yml`): `Keepiq/` (the app), `KeepiqAutofill/` (the AutoFill extension), `Shared/` (code both use), `KeepiqUITests/`. `KeepiqApp/` is a Swift package that checks the framework links. | +| `e2e/` | The end-to-end harness: `server.mjs` (test server front, seeding, recording and replay), `android-run.sh`, `ios-run.sh` and the recorded `fixtures/`. | +| `fastlane/metadata/android/` | The store texts and screenshots, in the layout F-Droid and Google Play read. | +| `gradle/` | The version catalogue (`libs.versions.toml`), dependency verification (`verification-metadata.xml`) and the wrapper. | + +## Requirements + +- JDK 21. CI uses Temurin 21. +- For Android: the Android SDK, with `ANDROID_HOME` set or `sdk.dir` in `local.properties`. Without it, Gradle builds only `shared` on its JVM target. +- For iOS: macOS with Xcode and `xcodegen` (`brew install xcodegen`). iOS targets are skipped on Linux. + +The Gradle wrapper pins Gradle with a checksum. Run every command below from `mobile/`. + +## The shared core + +``` +./gradlew :shared:allTests # JVM and Android unit tests +./gradlew :shared:jvmTest # the JVM tests only, the quickest round +./gradlew :shared:iosSimulatorArm64Test # on a Mac +``` + +### Crypto vectors + +The vectors in `tests/vectors/` are the contract between the core and the web app. The `shared` build compiles them into its tests, so `:shared:allTests` checks that the core opens what the web app wrote. The core writes its own output to `shared/build/vectors/kotlin-output.json`. Then check that the web app opens it, from the repository root: + +``` +npm ci --ignore-scripts +KEEPIQ_KOTLIN_VECTORS=mobile/shared/build/vectors/kotlin-output.json \ + npx vitest run tests/vitest/crypto-vectors.spec.js tests/vitest/crypto-vectors-kotlin.spec.js \ + tests/vitest/generator-vectors.spec.js tests/vitest/autofill-vectors.spec.js +``` + +`.github/workflows/mobile.yml` runs both directions on every change to either side. + +## Android + +``` +./gradlew :android:app:assembleFdroidDebug # a debug APK +./gradlew :android:app:testFdroidDebugUnitTest # screen tests on the JVM (Robolectric) +./gradlew :android:app:assembleFdroidRelease # the R8-shrunk release APK +``` + +The release build is signed only when these four variables are set: `KEEPIQ_SIGNING_STORE_FILE`, `KEEPIQ_SIGNING_STORE_PASSWORD`, `KEEPIQ_SIGNING_KEY_ALIAS` and `KEEPIQ_SIGNING_KEY_PASSWORD`. Without them it is unsigned. No key lives in the repository. Add `-Pkeepiq.abiSplits=true` for one APK per processor type next to the universal one. + +### Flavours + +There are two flavours, `fdroid` and `play`. They differ only in the update check, and today neither has one: F-Droid and Google Play each update the app themselves. An in-app update prompt for Play would need Play Core, which is not free software. Design D8 allows no proprietary library in any flavour, so it stays out of both. If Play ever gets an update prompt, it must be built without one. + +The preview releases on GitHub (`.github/workflows/mobile-preview.yml`, tags `mobile-v-preview.`) and the e2e smoke check use the `fdroid` flavour. + +### F-Droid rules + +The build keeps to what F-Droid needs to build and publish the app: + +- Every dependency is free software from Maven Central or Google's Maven repository. Every version is pinned in `gradle/libs.versions.toml`, and `gradle/verification-metadata.xml` holds the checksum of every artifact. There are no binary jars in the tree. +- No Google Play Services, Firebase, Play Core or other non-free artifact, in either flavour. `.github/workflows/mobile-fdroid.yml` checks the release runtime classpath of both and names any such artifact it finds. +- The build is reproducible. The same workflow builds the unsigned `fdroid` release APK twice, from two clean checkouts at different paths, and compares them byte for byte. On a mismatch it uploads a diffoscope report. To make that hold, the build tools are pinned, the APK carries no dependency metadata blob (`dependenciesInfo`) and no git commit id (`vcsInfo.include = false`), and nothing in the build writes a timestamp. +- Store texts and screenshots live in `fastlane/metadata/android//`, in English (`en-US`) and Dutch (`nl-NL`). + +A new dependency therefore needs three things: a free licence, a pinned version in the catalogue, and its checksums in the verification metadata. Regenerate the metadata with: + +``` +./gradlew --write-verification-metadata sha256 -Pkeepiq.android=true resolveAllDependencies +``` + +Then add the macOS Kotlin/Native toolchain by hand, as the comment in `gradle/verification-metadata.xml` explains. + +## iOS + +On a Mac: + +``` +./gradlew :shared:assembleKeepiqSharedDebugXCFramework +cd ios +xcodegen generate +xcodebuild build -project Keepiq.xcodeproj -scheme Keepiq \ + -destination "generic/platform=iOS Simulator" CODE_SIGNING_ALLOWED=NO +``` + +No `.xcodeproj` is kept in the tree; `xcodegen` makes it from `project.yml`. + +## End-to-end tests + +`.github/workflows/mobile-e2e.yml` runs them on every pull request that touches `mobile/`, with screenshots and videos as artifacts. + +- **Android** runs on an emulator (API 34 and API 28) against a real Nextcloud with Keepiq, from `browser-extension/capture/compose.yaml`. `e2e/server.mjs proxy` puts an https front on it, and the `e2e` build type trusts that run's certificate. `e2e/android-run.sh` installs the app and its tests and runs them. On API 34 it also starts the R8 release build and checks that it stays up. +- **iOS** runs on a simulator. The macOS runners have no Docker, so `e2e/server.mjs replay` answers from the recordings in `e2e/fixtures/server.json`. `e2e/ios-run.sh` runs the UI tests. + +To refresh the iOS recordings, start the test server of `browser-extension/capture/compose.yaml`, then seed and record against it from the repository root: + +``` +node mobile/e2e/server.mjs seed --upstream http://localhost:8188 +node mobile/e2e/server.mjs record --upstream http://localhost:8188 --container -nc-1 +``` + +The header of `e2e/server.mjs` lists every option. + +## Continuous integration + +| Workflow | What it checks | +|---|---| +| `mobile.yml` | Shared core tests, the Android debug build and screen tests, the crypto vectors in both directions, the iOS framework and app compile. | +| `mobile-e2e.yml` | The end-to-end tests on Android and iOS. | +| `mobile-fdroid.yml` | No non-free artifact, and two identical builds of the unsigned `fdroid` release APK. | +| `mobile-preview.yml` | The signed preview APKs, and the GitHub pre-release on a `mobile-v*-preview.*` tag. | diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts new file mode 100644 index 000000000..bbcef9164 --- /dev/null +++ b/mobile/android/app/build.gradle.kts @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +// The app: pairing and unlock (group 2) and the vault, Send and +// generator screens (group 3) over :shared. Autofill and the passkey +// providers come in later groups of clients-mobile-apps. +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "nl.conduction.keepiq.android" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + applicationId = "nl.conduction.keepiq" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + // The build tools are pinned so a rebuild from the same commit gives the + // same APK (task 6.2); the JDK and Gradle are pinned in the workflows and + // the wrapper. + buildToolsVersion = libs.versions.android.buildTools.get() + // Two distributions (task 6.1, design D8). They differ only in the update + // check, and today neither has one: Google Play and the F-Droid client + // each update the app themselves. An in-app update prompt for Play would + // need Play Core, which is not free software, so it is left out of both + // flavours; mobile-fdroid.yml fails the build on any such artifact. + // F-Droid builds `fdroid`, the Play bundle is built from `play`. + flavorDimensions += "distribution" + productFlavors { + create("fdroid") { dimension = "distribution" } + create("play") { dimension = "distribution" } + } + // Release signing comes from the environment only, so no key or + // password lives in the repository. mobile-preview.yml sets these four + // variables from GitHub secrets (or from a throwaway key in its pull + // request dry run). Without them the release build stays unsigned. + val releaseStoreFile = providers.environmentVariable("KEEPIQ_SIGNING_STORE_FILE").orNull + signingConfigs { + if (releaseStoreFile != null) { + create("release") { + storeFile = file(releaseStoreFile) + storePassword = providers.environmentVariable("KEEPIQ_SIGNING_STORE_PASSWORD").get() + keyAlias = providers.environmentVariable("KEEPIQ_SIGNING_KEY_ALIAS").get() + keyPassword = providers.environmentVariable("KEEPIQ_SIGNING_KEY_PASSWORD").get() + } + } + } + buildTypes { + getByName("release") { + signingConfig = signingConfigs.findByName("release") + // R8: drop unused code and resources (keep rules in + // proguard-rules.pro). Only the release build; debug and e2e stay + // as they are, so the emulator tests run unshrunk code. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + // No git commit id in the APK (META-INF/version-control-info): + // F-Droid rebuilds from a source tarball as well as from git. + vcsInfo.include = false + } + // The end-to-end build (.github/workflows/mobile-e2e.yml). It is the + // debug build plus one thing: it trusts the self-signed certificate + // of the test server, which the workflow writes to + // src/e2e/res/raw/keepiq_e2e_ca.pem before it builds. Release and + // debug never trust it. + create("e2e") { + initWith(getByName("debug")) + matchingFallbacks += "debug" + } + } + testBuildType = "e2e" + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { compose = true } + // Compose screen tests on the JVM with Robolectric (free software); the + // emulator tests come with the e2e harness of task group 2. + testOptions { + unitTests.isIncludeAndroidResources = true + } + // Lint on release builds resolves lint artifacts that are not in + // gradle/verification-metadata.xml, so the release build would fail + // dependency verification. The release workflow builds without it. + lint { + checkReleaseBuilds = false + } + // One APK per processor type next to the universal one, when asked for + // (-Pkeepiq.abiSplits=true, mobile-preview.yml): the SQLCipher native + // library is most of the APK, and a phone needs only its own. Off by + // default, so debug and e2e builds keep their single APK. + if (providers.gradleProperty("keepiq.abiSplits").orNull?.toBoolean() == true) { + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + isUniversalApk = true + } + } + } + // F-Droid: no Google dependency metadata blob in the APK. + dependenciesInfo { + includeInApk = false + includeInBundle = false + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +// The e2e build without the test server's certificate would build an app +// that trusts nothing extra and fails every request; say so instead. +val e2eCa = layout.projectDirectory.file("src/e2e/res/raw/keepiq_e2e_ca.pem") +val checkE2eCa by tasks.registering { + val caFile = e2eCa.asFile + doLast { + if (!caFile.isFile) { + throw GradleException("The e2e build needs the test server's certificate in ${caFile.path}; see .github/workflows/mobile-e2e.yml.") + } + } +} +// One pre-build task per flavour: preFdroidE2eBuild, prePlayE2eBuild. +tasks.matching { it.name.startsWith("pre") && it.name.endsWith("E2eBuild") }.configureEach { dependsOn(checkE2eCa) } + +dependencies { + implementation(project(":shared")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.fragment) + implementation(libs.androidx.biometric) + implementation(libs.androidx.autofill) + implementation(libs.androidx.credentials) + implementation(libs.androidx.browser) + implementation(libs.androidx.lifecycle.process) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.material3) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlinx.serialization.json) + androidTestImplementation(libs.junit) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.serialization.json) + androidTestImplementation(libs.kotlinx.serialization.json) + testImplementation(libs.robolectric) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.junit4) + debugImplementation(platform(libs.compose.bom)) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro new file mode 100644 index 000000000..706340fa2 --- /dev/null +++ b/mobile/android/app/proguard-rules.pro @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# R8 for the release build (build.gradle.kts). Debug and e2e builds are not +# shrunk. Most libraries ship their own rules in their jar or AAR, and R8 +# reads those: kotlinx.serialization and kotlinx.coroutines +# (META-INF/com.android.tools), Ktor (META-INF/proguard/ktor.pro), SQLCipher +# (proguard.txt: its JNI classes and native methods), OkHttp, AndroidX and +# Compose. androidx.credentials, for the passkey work, ships its own too. +# What is left is below. + +# Readable stack traces in bug reports from a free-software app: shrink and +# optimise, but keep the names. It also means no mapping file to keep per +# release, and the APK stays easy to compare with a build from source. +-dontobfuscate + +# SQLCipher's JNI code calls back into these by name; its own rules keep the +# native methods, this keeps the classes the native side looks up. +-keep class net.zetetic.database.** { *; } + +# The offline store's queries, generated by SQLDelight, are plain code; its +# Android driver needs nothing extra. + +# kotlinx.serialization: the companion serializer of every @Serializable +# class in the app and the shared core (the library's rules cover the +# lookup; this keeps the classes Keepiq stores and sends intact). +-keepclassmembers @kotlinx.serialization.Serializable class nl.conduction.keepiq.** { + *** Companion; + *** INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +# BouncyCastle is only used for Argon2 (Primitives.jvmShared.kt), through +# its lightweight API with direct references, so R8 keeps what is called. +# The provider classes refer to JDK parts Android does not have. +-dontwarn javax.naming.** +-dontwarn org.bouncycastle.jsse.** + +# Ktor and coroutines refer to JVM-only classes in code paths Android never +# takes; OkHttp to TLS providers Keepiq does not ship. +-dontwarn java.lang.management.** +-dontwarn org.slf4j.** +-dontwarn org.conscrypt.** +-dontwarn org.openjsse.** +-dontwarn reactor.blockhound.** diff --git a/mobile/android/app/src/androidTest/AndroidManifest.xml b/mobile/android/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..693be31e8 --- /dev/null +++ b/mobile/android/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/AutofillFormActivity.java b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/AutofillFormActivity.java new file mode 100644 index 000000000..c38e5b55b --- /dev/null +++ b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/AutofillFormActivity.java @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofilltest; + +import android.app.Activity; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.view.View; +import android.view.autofill.AutofillManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import nl.conduction.keepiq.android.test.R; + +/** + * The "other app" of SystemAutofillTest: a two-step login form (user name + * and password, then a one-time code) or a sign-up form, in plain views + * with Android autofill hints. The status line shows what was filled in, + * so the test can read it: the user name, the password's length and the + * code. + * + * Java, not Kotlin: this runs in the test APK's own process, which carries + * no Kotlin runtime (the app under test does). + */ +public class AutofillFormActivity extends Activity { + private EditText username; + private EditText password; + private EditText otp; + private TextView status; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final boolean signUp = "signup".equals(getIntent().getStringExtra("mode")); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(48, 96, 48, 48); + status = new TextView(this); + status.setId(R.id.status); + status.setTextSize(18f); + // The button sits above the fields, so the autofill dropdown never covers it. + Button submit = new Button(this); + submit.setId(R.id.submit); + submit.setText(signUp ? "Create account" : "Next"); + username = new EditText(this); + username.setId(R.id.username); + username.setHint(signUp ? "Email" : "User name"); + username.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + username.setAutofillHints(signUp ? "newUsername" : View.AUTOFILL_HINT_USERNAME); + password = new EditText(this); + password.setId(R.id.password); + password.setHint(signUp ? "New password" : "Password"); + password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + password.setAutofillHints(signUp ? "newPassword" : View.AUTOFILL_HINT_PASSWORD); + otp = new EditText(this); + otp.setId(R.id.otp); + otp.setHint("Code from your authenticator"); + otp.setInputType(InputType.TYPE_CLASS_NUMBER); + otp.setAutofillHints("2faAppOTPCode"); + otp.setVisibility(View.GONE); + root.addView(status); + root.addView(submit); + root.addView(username); + root.addView(password); + root.addView(otp); + setContentView(root); + TextWatcher watcher = new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } + @Override public void afterTextChanged(Editable s) { show(); } + }; + username.addTextChangedListener(watcher); + password.addTextChangedListener(watcher); + otp.addTextChangedListener(watcher); + show(); + final AutofillManager autofill = getSystemService(AutofillManager.class); + submit.setOnClickListener(v -> { + if (signUp) { + autofill.commit(); + finish(); + } else { + username.setVisibility(View.GONE); + password.setVisibility(View.GONE); + otp.setVisibility(View.VISIBLE); + otp.requestFocus(); + otp.post(() -> autofill.requestAutofill(otp)); + } + }); + username.requestFocus(); + username.postDelayed(() -> autofill.requestAutofill(username), 500); + } + + private void show() { + status.setText("user=" + username.getText() + " password=" + password.getText().length() + " code=" + otp.getText()); + } +} diff --git a/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/PasskeyClientActivity.java b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/PasskeyClientActivity.java new file mode 100644 index 000000000..ff4a6c723 --- /dev/null +++ b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/PasskeyClientActivity.java @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofilltest; + +import android.app.Activity; +import android.credentials.CreateCredentialException; +import android.credentials.CreateCredentialRequest; +import android.credentials.CreateCredentialResponse; +import android.credentials.CredentialManager; +import android.credentials.CredentialOption; +import android.credentials.GetCredentialException; +import android.credentials.GetCredentialRequest; +import android.credentials.GetCredentialResponse; +import android.graphics.drawable.Icon; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.OutcomeReceiver; +import android.widget.LinearLayout; +import android.widget.TextView; +import nl.conduction.keepiq.android.test.R; +import org.json.JSONObject; + +/** + * The "other app" of PasskeyProviderTest: an app that asks Android's + * Credential Manager to create a passkey or to sign in with one, as any app + * with a WebAuthn login does. The status line shows the answer, so the test + * reads it: the run id the test passed, then "created:" or "got:" with the + * response JSON, or "error:" with the exception type. The run id keeps the + * test from reading the previous call's answer while this one starts. + * + * Java and the framework API (android.credentials, Android 14), not the + * androidx library: this runs in the test APK's own process, which carries + * neither the Kotlin runtime nor the app's libraries. The bundles are the + * ones androidx.credentials 1.3.0 writes (CreatePublicKeyCredentialRequest, + * GetPublicKeyCredentialOption), which the provider reads back with androidx. + */ +public class PasskeyClientActivity extends Activity { + private static final String TYPE = "androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL"; + private static final String SUBTYPE = "androidx.credentials.BUNDLE_KEY_SUBTYPE"; + private static final String REQUEST_JSON = "androidx.credentials.BUNDLE_KEY_REQUEST_JSON"; + private static final String CLIENT_DATA_HASH = "androidx.credentials.BUNDLE_KEY_CLIENT_DATA_HASH"; + private static final String AUTO_SELECT = "androidx.credentials.BUNDLE_KEY_IS_AUTO_SELECT_ALLOWED"; + private static final String PREFER_IMMEDIATELY = "androidx.credentials.BUNDLE_KEY_PREFER_IMMEDIATELY_AVAILABLE_CREDENTIALS"; + private static final String DISPLAY_INFO = "androidx.credentials.BUNDLE_KEY_REQUEST_DISPLAY_INFO"; + private static final String USER_ID = "androidx.credentials.BUNDLE_KEY_USER_ID"; + private static final String USER_DISPLAY_NAME = "androidx.credentials.BUNDLE_KEY_USER_DISPLAY_NAME"; + private static final String TYPE_ICON = "androidx.credentials.BUNDLE_KEY_CREDENTIAL_TYPE_ICON"; + private static final String REGISTRATION_JSON = "androidx.credentials.BUNDLE_KEY_REGISTRATION_RESPONSE_JSON"; + private static final String AUTHENTICATION_JSON = "androidx.credentials.BUNDLE_KEY_AUTHENTICATION_RESPONSE_JSON"; + + private TextView status; + private String run = ""; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(48, 96, 48, 48); + TextView title = new TextView(this); + title.setTextSize(20f); + title.setText("Passkey test app"); + status = new TextView(this); + status.setId(R.id.status); + status.setTextSize(12f); + root.addView(title); + root.addView(status); + setContentView(root); + + run = String.valueOf(getIntent().getStringExtra("run")); + status.setText(run + "|waiting"); + String mode = getIntent().getStringExtra("mode"); + String json = getIntent().getStringExtra("request"); + try { + if ("create".equals(mode)) { + create(json); + } else { + get(json); + } + } catch (Exception e) { + status.setText(run + "|error:" + e.getClass().getSimpleName() + ":" + e.getMessage()); + } + } + + private Bundle requestBundle(String subtype, String json) { + Bundle bundle = new Bundle(); + bundle.putString(SUBTYPE, subtype); + bundle.putString(REQUEST_JSON, json); + bundle.putByteArray(CLIENT_DATA_HASH, null); + bundle.putBoolean(AUTO_SELECT, false); + return bundle; + } + + private void create(String json) throws Exception { + JSONObject user = new JSONObject(json).getJSONObject("user"); + Bundle credentialData = requestBundle("androidx.credentials.BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST", json); + credentialData.putBoolean(PREFER_IMMEDIATELY, false); + Bundle displayInfo = new Bundle(); + displayInfo.putCharSequence(USER_ID, user.getString("name")); + displayInfo.putCharSequence(USER_DISPLAY_NAME, user.optString("displayName", "")); + displayInfo.putParcelable(TYPE_ICON, Icon.createWithResource(this, android.R.drawable.ic_lock_lock)); + credentialData.putBundle(DISPLAY_INFO, displayInfo); + Bundle candidateQueryData = requestBundle("androidx.credentials.BUNDLE_VALUE_SUBTYPE_CREATE_PUBLIC_KEY_CREDENTIAL_REQUEST", json); + CreateCredentialRequest request = new CreateCredentialRequest.Builder(TYPE, credentialData, candidateQueryData) + .setAlwaysSendAppInfoToProvider(true) + .build(); + CredentialManager manager = getSystemService(CredentialManager.class); + manager.createCredential(this, request, new CancellationSignal(), getMainExecutor(), + new OutcomeReceiver() { + @Override + public void onResult(CreateCredentialResponse response) { + status.setText(run + "|created:" + response.getData().getString(REGISTRATION_JSON)); + } + + @Override + public void onError(CreateCredentialException e) { + status.setText(run + "|error:" + e.getType() + ":" + e.getMessage()); + } + }); + } + + private void get(String json) { + Bundle data = new Bundle(); + data.putBoolean(PREFER_IMMEDIATELY, false); + Bundle option = requestBundle("androidx.credentials.BUNDLE_VALUE_SUBTYPE_GET_PUBLIC_KEY_CREDENTIAL_OPTION", json); + GetCredentialRequest request = new GetCredentialRequest.Builder(data) + .addCredentialOption(new CredentialOption.Builder(TYPE, option, option).build()) + .build(); + CredentialManager manager = getSystemService(CredentialManager.class); + manager.getCredential(this, request, new CancellationSignal(), getMainExecutor(), + new OutcomeReceiver() { + @Override + public void onResult(GetCredentialResponse response) { + status.setText(run + "|got:" + response.getCredential().getData().getString(AUTHENTICATION_JSON)); + } + + @Override + public void onError(GetCredentialException e) { + status.setText(run + "|error:" + e.getType() + ":" + e.getMessage()); + } + }); + } +} diff --git a/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/WebFormActivity.java b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/WebFormActivity.java new file mode 100644 index 000000000..90eedba33 --- /dev/null +++ b/mobile/android/app/src/androidTest/java/nl/conduction/keepiq/android/autofilltest/WebFormActivity.java @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofilltest; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.net.http.SslError; +import android.os.Bundle; +import android.webkit.SslErrorHandler; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import nl.conduction.keepiq.android.test.R; + +/** + * The web-domain path of SystemAutofillTest: the test server's login page + * in a WebView, which reports the page's domain to the autofill service. + * The e2e build of Keepiq trusts this test app as a browser + * (src/e2e/AndroidManifest.xml); no other build does. Java, for the same + * reason as AutofillFormActivity. + */ +public class WebFormActivity extends Activity { + @SuppressLint({"SetJavaScriptEnabled", "WebViewClientOnReceivedSslError"}) + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + WebView web = new WebView(this); + web.setId(R.id.web); + web.getSettings().setJavaScriptEnabled(true); + web.setWebViewClient(new WebViewClient() { + // The test server's certificate is made per run; this test app only. + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + handler.proceed(); + } + }); + setContentView(web); + String url = getIntent().getStringExtra("url"); + web.loadUrl(url != null ? url : "about:blank"); + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/E2e.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/E2e.kt new file mode 100644 index 000000000..370bd6c42 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/E2e.kt @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.graphics.Bitmap +import android.os.ParcelFileDescriptor +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +/** + * What the end-to-end tests share (.github/workflows/mobile-e2e.yml): the + * test server's address and the demo account from the instrumentation + * arguments, the screenshots, and the test helper on the server. + */ +object E2e { + private val args = InstrumentationRegistry.getArguments() + + /** The test server as the emulator reaches it, https://10.0.2.2:8443. */ + val server: String get() = args.getString("keepiqServer") ?: "" + val user: String get() = args.getString("keepiqUser") ?: "admin" + val masterPassword: String get() = args.getString("keepiqMasterPassword") ?: "" + val appPassword: String get() = args.getString("keepiqAppPassword") ?: "" + + /** Fails when the workflow did not pass a server: these tests only make sense against one. */ + fun requireServer() { + check(server.startsWith("https://")) { "keepiqServer was not passed; run through mobile/e2e/android-run.sh" } + } + + val app: KeepiqApp get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as KeepiqApp + + /** Saves a screenshot under files/e2e-shots; android-run.sh pulls them with run-as. */ + fun shot(name: String) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + Thread.sleep(400) + val bitmap = instrumentation.uiAutomation.takeScreenshot() ?: return + val dir = File(instrumentation.targetContext.filesDir, "e2e-shots").apply { mkdirs() } + File(dir, "$name.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + + /** Runs a shell command as the shell user and returns its output. */ + fun shell(command: String): String { + val fd = InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(command) + return ParcelFileDescriptor.AutoCloseInputStream(fd).use { it.readBytes().toString(Charsets.UTF_8) } + } + + /** Calls the e2e helper of mobile/e2e/server.mjs, which stands in for the user's browser and for occ. */ + fun helper(path: String, body: String? = null): String { + val connection = URL("$server/__e2e$path").openConnection() as HttpsURLConnection + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + if (body != null) { + connection.requestMethod = "POST" + connection.doOutput = true + connection.setRequestProperty("Content-Type", "application/json") + connection.outputStream.use { it.write(body.toByteArray()) } + } + val status = connection.responseCode + val text = (if (status in 200..299) connection.inputStream else connection.errorStream)?.use { it.readBytes().toString(Charsets.UTF_8) } ?: "" + check(status in 200..299) { "e2e helper $path answered $status: $text" } + return text + } +} + +/** Waits until a node with [tag] exists. */ +fun ComposeTestRule.waitForTag(tag: String, timeoutMillis: Long = 30_000) { + waitUntil(timeoutMillis) { onAllNodesWithTag(tag, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() } +} + +/** Waits until some node shows [text]. */ +fun ComposeTestRule.waitForText(text: String, timeoutMillis: Long = 30_000) { + waitUntil(timeoutMillis) { + (this as SemanticsNodeInteractionsProvider).onAllNodesWithText(text, substring = true, useUnmergedTree = true) + .fetchSemanticsNodes().isNotEmpty() + } +} + +/** Waits until no node shows [text] any more. */ +fun ComposeTestRule.waitUntilGone(text: String, timeoutMillis: Long = 30_000) { + waitUntil(timeoutMillis) { + (this as SemanticsNodeInteractionsProvider).onAllNodesWithText(text, substring = true, useUnmergedTree = true) + .fetchSemanticsNodes().isEmpty() + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/ManualPairingAndBlockTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/ManualPairingAndBlockTest.kt new file mode 100644 index 000000000..95f346839 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/ManualPairingAndBlockTest.kt @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The fallback pairing with an app password (2.1), and the two-factor block + * (2.2). android-run.sh turns on the organisation's two-factor policy + * before this class runs; the demo user has no second factor, so the server + * withholds the key and the app must say why and offer no unlock field. + */ +@RunWith(AndroidJUnit4::class) +class ManualPairingAndBlockTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Before + fun setUp() { + E2e.requireServer() + check(E2e.appPassword.isNotEmpty()) { "keepiqAppPassword was not passed" } + } + + @Test + fun pairWithAnAppPasswordAndMeetTheTwoFactorBlock() { + compose.waitForTag("server") + compose.onNodeWithTag("server").performTextInput(E2e.server) + compose.onNodeWithTag("useAppPassword").performClick() + compose.waitForTag("appPassword") + compose.onNodeWithTag("loginName").performTextInput(E2e.user) + compose.onNodeWithTag("appPassword").performTextInput(E2e.appPassword) + E2e.shot("20-app-password") + compose.onNodeWithTag("connect").performClick() + + compose.waitForTag("blocked", 60_000) + compose.waitForText("two-factor authentication") + compose.onNodeWithTag("masterPassword").assertDoesNotExist() + compose.onNodeWithTag("pin").assertDoesNotExist() + assertEquals(1, E2e.app.state.client.accounts.accounts().size) + E2e.shot("21-two-factor-required") + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PackageVisibilityTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PackageVisibilityTest.kt new file mode 100644 index 000000000..2a93c9ab4 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PackageVisibilityTest.kt @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.autofill.AppLink +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemParts +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Whether Keepiq can fill an ordinary app (task 4.1) with the package + * visibility it declares, on this Android version. + * + * Keepiq names an app by its package and signing certificate, read from + * the package manager. From Android 11 the package manager hides other + * apps unless the manifest asks for them. SystemAutofillTest cannot answer + * this: its forms live in the test APK, which Android always shows to the + * app it instruments. So the form here is a separate APK, + * :android:otherapp, installed by mobile/e2e/android-run.sh. + * + * The control: when Keepiq declares no QUERY_ALL_PACKAGES, the other app + * must be hidden from Keepiq before the fill (Android 11+). Without that + * the fill would prove nothing about visibility. + */ +@RunWith(AndroidJUnit4::class) +class PackageVisibilityTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val device = UiDevice.getInstance(instrumentation) + private val service = "nl.conduction.keepiq/nl.conduction.keepiq.android.autofill.KeepiqAutofillService" + + @Before + fun setUp() { + E2e.requireServer() + check(E2e.appPassword.isNotEmpty()) { "keepiqAppPassword was not passed" } + E2e.shell("settings put secure autofill_service $service") + assertTrue("Keepiq is the autofill service", E2e.shell("settings get secure autofill_service").trim() == service) + assertTrue("$OTHER is installed (android-run.sh)", E2e.shell("pm path $OTHER").contains("package:")) + } + + @After + fun tearDown() { + E2e.shell("settings delete secure autofill_service") + E2e.shell("am force-stop $OTHER") + } + + @Test + fun fillsAnAppThatIsNotInstrumented() { + val context = instrumentation.targetContext + val requested = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) + .requestedPermissions.orEmpty() + val queriesAll = "android.permission.QUERY_ALL_PACKAGES" in requested + val filtered = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !queriesAll + Log.i(TAG, "API ${Build.VERSION.SDK_INT}, QUERY_ALL_PACKAGES declared: $queriesAll") + val core = E2e.app.autofill + if (filtered) { + assertEquals("the control: $OTHER is hidden from Keepiq before any fill", null, core.identities.identity(OTHER)) + } + + val state = E2e.app.state + val account = runBlocking { state.client.pairManually(E2e.server, E2e.user, E2e.appPassword) } + instrumentation.runOnMainSync { state.switchAccount(account.id) } + instrumentation.runOnMainSync { state.unlockWithMasterPassword(account.id, E2e.masterPassword) } + waitUntil(180_000) { state.openVault() != null } + val vault = state.openVault()!! + val keys = RsaVaultKeys.fromPem(vault.privateKeyPem, vault.certificate!!, vault.suiteId, vault.unlockKeyEpoch) + val api = state.client.api(account) + + // The other app is signed with the same debug key as the e2e build + // (Gradle's debug keystore on the runner), so Keepiq's own + // certificate is its certificate too. A wrong guess fails the fill, + // it never passes it. + val print = core.identities.identity(context.packageName)!!.certFingerprints.first() + val typeId = runBlocking { api.listTypes() }.first { (it["name"] as? JsonPrimitive)?.contentOrNull == "login" }["id"]!! + .let { (it as JsonPrimitive).content } + val id = runBlocking { + val body = ItemCodec.createBody(ItemParts("Other app", AppLink(OTHER, setOf(print)).toUrl(), null, "otheruser", "other-secret-1", null), typeId, keys) + (api.createSecret(body)!!["id"] as JsonPrimitive).content + } + try { + runBlocking { VaultSession(account, api, keys, null, null).repository.refresh(SyncTrigger.MANUAL) } + assertTrue(core.indexOf(account.id).entries.any { it.id == id }) + + // Started by the shell, not by Keepiq, so the start grants Keepiq nothing. + E2e.shell("am start -W -n $OTHER/nl.conduction.keepiq.otherapp.LoginActivity") + waitFor(By.res(OTHER, "status")) + val entry = device.wait(Until.findObject(By.text("otheruser")), 30_000) + E2e.shot("visibility-01-other-app-${Build.VERSION.SDK_INT}") + assertTrue( + "Keepiq offered the other app's login (API ${Build.VERSION.SDK_INT}, QUERY_ALL_PACKAGES declared: $queriesAll)", + entry != null, + ) + entry!!.click() + waitFor(By.textContains("user=otheruser password=14")) + E2e.shot("visibility-02-other-app-filled-${Build.VERSION.SDK_INT}") + } finally { + runBlocking { api.trashSecret(id) } + instrumentation.runOnMainSync { state.lock() } + state.client.wipe(account.id) + } + } + + private fun waitFor(selector: BySelector, timeout: Long = 30_000): UiObject2 = + device.wait(Until.findObject(selector), timeout) ?: error("not on screen: $selector") + + private fun waitUntil(timeout: Long, condition: () -> Boolean) { + val end = System.currentTimeMillis() + timeout + while (!condition()) { + check(System.currentTimeMillis() < end) { "timed out" } + Thread.sleep(250) + } + } + + companion object { + private const val TAG = "KeepiqE2e" + const val OTHER = "nl.conduction.keepiq.e2e.otherapp" + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PairUnlockUnpairTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PairUnlockUnpairTest.kt new file mode 100644 index 000000000..d13ebd057 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PairUnlockUnpairTest.kt @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.app.Activity +import android.app.Instrumentation.ActivityResult +import android.content.Intent +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApiException +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The whole of task group 2 on the emulator against the test server, in the + * order a user meets it: pair through Login Flow v2 (2.1), see the phone in + * the device list (2.4), a wrong and a right master password (2.2), lock, + * a PIN with a wrong try (2.3), the device lock (2.5), and unpair, after + * which the old app password gets 401 (2.6). + * + * The system browser is replaced by the e2e helper: the test catches the + * Custom Tab intent, and the helper signs in on the very login page the + * intent carried, and grants access, as the user would. + */ +@RunWith(AndroidJUnit4::class) +class PairUnlockUnpairTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Before + fun setUp() { + E2e.requireServer() + Intents.init() + intending(hasAction(Intent.ACTION_VIEW)).respondWith(ActivityResult(Activity.RESULT_OK, null)) + } + + @After + fun tearDown() { + Intents.release() + } + + @Test + fun pairUnlockLockPinDeviceLockAndUnpair() { + // 2.1: the address form, then the browser sign-in. + compose.waitForTag("server") + E2e.shot("01-connect") + compose.onNodeWithTag("server").performTextInput(E2e.server) + compose.onNodeWithTag("signIn").performClick() + compose.waitForTag("waiting") + E2e.shot("02-waiting-for-browser") + val loginUrl = Intents.getIntents().last { it.action == Intent.ACTION_VIEW }.data.toString() + assertTrue("the login page is on the test server: $loginUrl", loginUrl.startsWith(E2e.server + "/")) + E2e.helper("/grant", """{"loginUrl":${jsonString(loginUrl)},"user":${jsonString(E2e.user)}}""") + + // Paired: the unlock screen. + compose.waitForTag("masterPassword", 60_000) + val account = E2e.app.state.client.accounts.accounts().single() + assertEquals(E2e.user, account.loginName) + E2e.shot("03-unlock") + + // 2.4: Nextcloud names the app password after the app. + val tokens = E2e.helper("/tokens") + assertTrue("device list: $tokens", tokens.contains("Keepiq for Android")) + + // 2.2: a wrong master password unlocks nothing. + compose.onNodeWithTag("masterPassword").performTextInput("not the master password") + compose.onNodeWithTag("unlock").performClick() + compose.waitForText("This master password is not correct.", 60_000) + E2e.shot("04-wrong-master-password") + + // 2.2: the right one opens the vault. + compose.onNodeWithTag("masterPassword").performTextInput(E2e.masterPassword) + compose.onNodeWithTag("unlock").performClick() + compose.waitForTag("unlocked", 60_000) + E2e.shot("05-unlocked") + + // 2.3: a PIN. + compose.onNodeWithTag("settings").performClick() + compose.waitForTag("newPin") + compose.onNodeWithTag("newPin").performTextInput("246810") + compose.onNodeWithTag("setPin").performClick() + compose.waitForTag("removePin", 60_000) + E2e.shot("06-settings-pin-set") + compose.onNodeWithTag("back").performScrollTo().performClick() + + // Lock, a wrong PIN, then the right one. + compose.waitForTag("lock") + compose.onNodeWithTag("lock").performClick() + compose.waitForTag("pin", 30_000) + E2e.shot("07-locked-pin") + compose.onNodeWithTag("pin").performTextInput("000000") + compose.onNodeWithTag("unlockPin").performClick() + compose.waitForText("Wrong PIN. 4 tries left.", 60_000) + E2e.shot("08-wrong-pin") + compose.onNodeWithTag("pin").performTextInput("246810") + compose.onNodeWithTag("unlockPin").performClick() + compose.waitForTag("unlocked", 60_000) + + // 2.5: the phone locks, so the vault locks. + E2e.shell("input keyevent KEYCODE_SLEEP") + Thread.sleep(1_500) + E2e.shell("input keyevent KEYCODE_WAKEUP") + E2e.shell("wm dismiss-keyguard") + compose.waitForTag("pin", 30_000) + E2e.shot("09-locked-after-screen-off") + compose.onNodeWithTag("pin").performTextInput("246810") + compose.onNodeWithTag("unlockPin").performClick() + compose.waitForTag("unlocked", 60_000) + + // 2.6: unpair. + compose.onNodeWithTag("settings").performClick() + compose.waitForTag("unpair") + compose.onNodeWithTag("unpair").performScrollTo().performClick() + compose.waitForTag("confirmUnpair") + E2e.shot("10-unpair-confirm") + compose.onNodeWithTag("confirmUnpair").performClick() + compose.waitForText("The app password was deleted in Nextcloud.", 60_000) + E2e.shot("11-unpaired") + assertTrue(E2e.app.state.client.accounts.accounts().isEmpty()) + assertTrue(!E2e.app.state.client.pins.has(account.id)) + + // The old app password no longer works on the server. + runBlocking { + try { + E2e.app.state.client.api(Account("", account.server, account.loginName, account.appPassword)).pair() + fail("the old app password still pairs") + } catch (e: KeepiqApiException) { + assertEquals(401, e.status) + } + } + val after = E2e.helper("/tokens") + assertTrue("device list after unpair: $after", !after.contains("Keepiq for Android")) + } + + private fun jsonString(value: String): String = + "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PasskeyProviderTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PasskeyProviderTest.kt new file mode 100644 index 000000000..26a8132db --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/PasskeyProviderTest.kt @@ -0,0 +1,304 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.crypto.WebAuthn +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemParts +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.security.MessageDigest + +/** + * Task 5.1 on the Android 14 emulator against the test server: Keepiq as + * the Credential Manager provider of another app (the test APK's + * PasskeyClientActivity), which asks for passkeys for the rpId 10.0.2.2. + * The test server lists that app in its assetlinks.json (Digital Asset + * Links), as a real site lists its app. + * + * In the order a user meets it: a site that accepts only RS256 gets nothing + * from Keepiq; a passkey is created, checked byte by byte and found in the + * vault on the server; signing in with it verifies with its public key; a + * stored counter of 4 signs as 5 and the item stores 5; a locked vault + * shows "Unlock Keepiq", then the passkey; and an rpId whose site does not + * list the app gets nothing. + * + * Manual: Chrome and other browsers, which ask as privileged apps with a + * web origin (the allowlist path), and choosing Keepiq in the system + * settings, which this test does with `settings put`. + */ +@RunWith(AndroidJUnit4::class) +class PasskeyProviderTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val device = UiDevice.getInstance(instrumentation) + private val testPackage = instrumentation.context.packageName + private val provider = "nl.conduction.keepiq/nl.conduction.keepiq.android.passkey.KeepiqCredentialProviderService" + private val rpId = "10.0.2.2" + private val userName = "passkey.demo@example.test" + private val userId = Encoding.utf8("e2e-user-1") + + @Before + fun setUp() { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { "passkeys need Android 14; run this class on API 34" } + E2e.requireServer() + check(E2e.appPassword.isNotEmpty()) { "keepiqAppPassword was not passed" } + E2e.shell("settings put secure credential_service $provider") + E2e.shell("settings put secure credential_service_primary $provider") + assertEquals(provider, E2e.shell("settings get secure credential_service").trim()) + Log.i(TAG, E2e.shell("dumpsys credential").take(4000)) + } + + @After + fun tearDown() { + E2e.shell("settings delete secure credential_service_primary") + E2e.shell("settings delete secure credential_service") + } + + @Test + fun createSignInCounterLockedAndRefused() { + val state = E2e.app.state + val account = runBlocking { state.client.pairManually(E2e.server, E2e.user, E2e.appPassword) } + main { state.switchAccount(account.id) } + unlock(account.id) + val vault = state.openVault()!! + val keys = RsaVaultKeys.fromPem(vault.privateKeyPem, vault.certificate!!, vault.suiteId, vault.unlockKeyEpoch) + val api = state.client.api(account) + val session = VaultSession(account, api, keys, null, null) + runBlocking { session.repository.refresh(SyncTrigger.MANUAL) } + + // The site lists the test app, by package and certificate. + val identity = E2e.app.autofill.identities.identity(testPackage)!! + val print = identity.certFingerprints.first() + E2e.helper( + "/assetlinks", + """[{"relation":["delegate_permission/common.get_login_creds"],"target":{"namespace":"android_app","package_name":"$testPackage","sha256_cert_fingerprints":["$print"]}}]""", + ) + val origin = "android:apk-key-hash:" + Encoding.toBase64Url(print.split(':').map { it.toInt(16).toByte() }.toByteArray()) + + // A site that accepts only RS256: Keepiq declines, and with no other provider the app hears so. + client("create", creation(challenge(1), listOf(-257))) + val refused = waitForAnswer("passkey-00-rs256", listOf("Create", "Continue", "Save")) + assertTrue("RS256 only: $refused", refused.startsWith("error:")) + assertTrue("nothing was saved", passkeyRows(api).isEmpty()) + + // Create. + val createChallenge = challenge(2) + client("create", creation(createChallenge, listOf(-7, -257))) + val accountLabel = "${account.loginName} · ${account.server.removePrefix("https://").trimEnd('/')}" + val created = waitForAnswer("passkey-01-create", listOf("Create", "Continue", "Save", accountLabel, "Keepiq")) + assertTrue("created: $created", created.startsWith("created:")) + val registration = Json.parseToJsonElement(created.removePrefix("created:")).jsonObject + val credentialId = registration.text("id") + val response = registration["response"]!!.jsonObject + val clientData = Json.parseToJsonElement(Encoding.fromUtf8(Encoding.fromBase64Url(response.text("clientDataJSON")))).jsonObject + assertEquals("webauthn.create", clientData.text("type")) + assertEquals(Encoding.toBase64Url(createChallenge), clientData.text("challenge")) + assertEquals(origin, clientData.text("origin")) + val attestation = Encoding.fromBase64Url(response.text("attestationObject")) + val authData = attestation.copyOfRange(30, attestation.size) + assertArrayEquals(sha256(Encoding.utf8(rpId)), authData.copyOfRange(0, 32)) + assertEquals("flags UP, UV and AT", 0x45, authData[32].toInt() and 0xFF) + assertArrayEquals("all-zero AAGUID", ByteArray(16), authData.copyOfRange(37, 53)) + assertArrayEquals(Encoding.fromBase64Url(credentialId), authData.copyOfRange(55, 71)) + val spki = spkiOf(authData.copyOfRange(71, authData.size)) + E2e.shot("passkey-02-created") + + // The vault on the server holds it, as a passkey item for the rpId. + val stored = waitForPasskey(api, keys) { it.credentialId == credentialId } + assertEquals(rpId, stored.second.rpId) + assertEquals(userName, stored.second.userName) + assertEquals(Encoding.toBase64Url(userId), stored.second.userHandle) + + // Sign in with it: the signature verifies with the public key from the attestation. + signIn(credentialId, spki, origin, expectCounter = 0, name = "passkey-03-sign-in") + + // A counter of 4 signs as 5, and the item stores 5. + val (itemId, record) = stored + runBlocking { + api.updateSecret(itemId, ItemCodec.updateBody(ItemParts(rpId, rpId, null, "", record.copy(counter = 4).toJson(), null), setOf("key"), keys)) + session.repository.refresh(SyncTrigger.MANUAL) + } + signIn(credentialId, spki, origin, expectCounter = 5, name = "passkey-04-counter") + waitForPasskey(api, keys) { it.credentialId == credentialId && it.counter == 5L } + + // Locked: "Unlock Keepiq", the app's unlock screen, then the passkey. + main { state.lock() } + assertEquals(null, state.openVault()) + signIn(credentialId, spki, origin, expectCounter = 6, name = "passkey-05-locked") { unlock(account.id) } + + // An rpId whose site does not list the app: Keepiq offers nothing. + client("get", request(challenge(9), "localhost", emptyList())) + val other = waitForAnswer("passkey-06-unlisted", listOf(userName, "Unlock Keepiq")) + assertTrue("unlisted rpId: $other", other.startsWith("error:")) + + runBlocking { api.trashSecret(itemId) } + main { state.lock() } + state.client.wipe(account.id) + } + + private fun signIn(credentialId: String, spki: ByteArray, origin: String, expectCounter: Long, name: String, onKeepiq: () -> Unit = {}) { + val challenge = challenge(expectCounter.toInt() + 20) + client("get", request(challenge, rpId, listOf(credentialId))) + val answer = waitForAnswer(name, listOf(userName, "Unlock Keepiq", "Continue", "Sign in", "Use passkey"), onKeepiq) + assertTrue("$name: $answer", answer.startsWith("got:")) + val json = Json.parseToJsonElement(answer.removePrefix("got:")).jsonObject + assertEquals(credentialId, json.text("id")) + val response = json["response"]!!.jsonObject + val clientDataJson = Encoding.fromBase64Url(response.text("clientDataJSON")) + val clientData = Json.parseToJsonElement(Encoding.fromUtf8(clientDataJson)).jsonObject + assertEquals("webauthn.get", clientData.text("type")) + assertEquals(Encoding.toBase64Url(challenge), clientData.text("challenge")) + assertEquals(origin, clientData.text("origin")) + val authData = Encoding.fromBase64Url(response.text("authenticatorData")) + assertArrayEquals(sha256(Encoding.utf8(rpId)), authData.copyOfRange(0, 32)) + assertEquals("flags UP and UV", 0x05, authData[32].toInt() and 0xFF) + val counter = authData.copyOfRange(33, 37).fold(0L) { acc, b -> (acc shl 8) or (b.toLong() and 0xFF) } + assertEquals(expectCounter, counter) + assertArrayEquals(userId, Encoding.fromBase64Url(response.text("userHandle"))) + assertTrue("$name: the signature verifies", WebAuthn.verify(spki, authData, clientDataJson, Encoding.fromBase64Url(response.text("signature")))) + E2e.shot("$name-done") + } + + private fun creation(challenge: ByteArray, algorithms: List): String = + """{"challenge":"${Encoding.toBase64Url(challenge)}","rp":{"id":"$rpId","name":"Keepiq e2e"},""" + + """"user":{"id":"${Encoding.toBase64Url(userId)}","name":"$userName","displayName":"Passkey Demo"},""" + + """"pubKeyCredParams":[${algorithms.joinToString(",") { """{"type":"public-key","alg":$it}""" }}],""" + + """"timeout":60000,"attestation":"none","authenticatorSelection":{"residentKey":"required","userVerification":"preferred"}}""" + + private fun request(challenge: ByteArray, rp: String, allow: List): String = + """{"challenge":"${Encoding.toBase64Url(challenge)}","rpId":"$rp","timeout":60000,"userVerification":"preferred",""" + + """"allowCredentials":[${allow.joinToString(",") { """{"type":"public-key","id":"$it"}""" }}]}""" + + private fun challenge(seed: Int) = ByteArray(32) { (it * 7 + seed).toByte() } + + /** The id of the current client call: its answer starts with it, so an earlier answer is never read. */ + private var run = 0 + + private fun client(mode: String, json: String) { + run += 1 + val intent = Intent().setClassName(testPackage, "nl.conduction.keepiq.android.autofilltest.PasskeyClientActivity") + .putExtra("mode", mode) + .putExtra("request", json) + .putExtra("run", run.toString()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + instrumentation.targetContext.startActivity(intent) + } + + private fun status(): String? = runCatching { device.findObject(By.res(testPackage, "status"))?.text }.getOrNull() + + /** + * Answers the system sheet until the test app shows an answer: taps the + * first of [labels] on screen, and runs [onKeepiq] once when Keepiq's + * own screen comes up (the unlock). Saves a screenshot of each new + * screen, and the window tree when no answer comes. + */ + private fun waitForAnswer(name: String, labels: List, onKeepiq: () -> Unit = {}): String { + val end = System.currentTimeMillis() + 120_000 + var shots = 0 + var keepiqSeen = false + while (System.currentTimeMillis() < end) { + val text = status()?.takeIf { it.startsWith("$run|") }?.substringAfter('|') + if (text != null && (text.startsWith("created:") || text.startsWith("got:") || text.startsWith("error:"))) { + E2e.shot("$name-answer") + return text + } + if (!keepiqSeen && device.hasObject(By.pkg("nl.conduction.keepiq"))) { + keepiqSeen = true + E2e.shot("$name-keepiq") + onKeepiq() + continue + } + val target = labels.firstNotNullOfOrNull { label -> device.findObject(By.text(label)) } + if (target != null) { + if (shots < 4) E2e.shot("$name-sheet-${shots++}") + runCatching { target.click() } + Thread.sleep(1_500) + } else { + Thread.sleep(500) + } + } + val dir = File(instrumentation.targetContext.filesDir, "e2e-shots").apply { mkdirs() } + File(dir, "$name-windows.xml").outputStream().use { device.dumpWindowHierarchy(it) } + E2e.shot("$name-timeout") + error("$name: no answer from Credential Manager, status ${status()}") + } + + private fun passkeyRows(api: KeepiqApi): List = runBlocking { + val typeId = api.listTypes().first { (it["name"] as? JsonPrimitive)?.contentOrNull == PasskeyCredential.TYPE_NAME }.text("id") + api.listSecrets().filter { (it["typeId"] as? JsonPrimitive)?.contentOrNull == typeId && (it["url"] as? JsonPrimitive)?.contentOrNull == rpId } + } + + /** The passkey item on the server whose decrypted record satisfies [match]: its id and record. */ + private fun waitForPasskey(api: KeepiqApi, keys: RsaVaultKeys, match: (PasskeyCredential) -> Boolean): Pair { + val end = System.currentTimeMillis() + 30_000 + while (true) { + for (row in passkeyRows(api)) { + val record = runCatching { PasskeyCredential.parse(keys.decryptField(row.text("key"))) { "" } }.getOrNull() ?: continue + if (match(record)) return row.text("id") to record + } + check(System.currentTimeMillis() < end) { "no passkey item on the server matches" } + Thread.sleep(500) + } + } + + /** DER SubjectPublicKeyInfo of the COSE EC2 key in the attested credential data. */ + private fun spkiOf(cose: ByteArray): ByteArray { + val head = byteArrayOf(0xA5.toByte(), 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20) + assertArrayEquals("COSE key {1: 2, 3: -7, -1: 1, -2: x, -3: y}", head, cose.copyOfRange(0, 10)) + val x = cose.copyOfRange(10, 42) + val y = cose.copyOfRange(45, 77) + val prefix = byteArrayOf( + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01, + 0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, 0x04, + ) + return prefix + x + y + } + + private fun sha256(bytes: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(bytes) + + private fun JsonObject.text(name: String): String = (this[name] as? JsonPrimitive)?.contentOrNull ?: error("no $name") + + private fun main(block: () -> Unit) = instrumentation.runOnMainSync(block) + + private fun unlock(accountId: String) { + main { E2e.app.state.unlockWithMasterPassword(accountId, E2e.masterPassword) } + val end = System.currentTimeMillis() + 180_000 + while (E2e.app.state.openVault() == null) { + check(System.currentTimeMillis() < end) { + "the vault did not open: screen ${E2e.app.state.screen.value::class.simpleName}, message ${E2e.app.state.message.value}" + } + Thread.sleep(250) + } + } + + private companion object { + const val TAG = "PasskeyProviderTest" + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/SystemAutofillTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/SystemAutofillTest.kt new file mode 100644 index 000000000..f1746709b --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/SystemAutofillTest.kt @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.Intent +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.autofill.AppLink +import nl.conduction.keepiq.shared.crypto.Totp +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemParts +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Task group 4 on the emulator against the test server: Keepiq as the + * autofill service of another app (the test APK's forms) and of a WebView + * page, in the order a user meets it. + * + * 4.1 the locked "Unlock Keepiq" entry without names, the unlock, the pick + * between the app's own login and the one Digital Asset Links verified, a + * look-alike login (same package, another certificate) never offered, + * then the unlocked dropdown, and the web domain of the WebView page. + * 4.3 the one-time code on the step after the login. 4.2 saving a sign-up, + * "Never" and no offer after it. 4.6 a login trashed on the server is gone + * after the next sync, and unpairing clears the index. + * + * Inline suggestions need a keyboard that shows them; the emulator's + * keyboard does not, so this test sees the dropdown (manual check in the + * PR covers Gboard). + */ +@RunWith(AndroidJUnit4::class) +class SystemAutofillTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val device = UiDevice.getInstance(instrumentation) + private val testPackage = instrumentation.context.packageName + private val service = "nl.conduction.keepiq/nl.conduction.keepiq.android.autofill.KeepiqAutofillService" + private val seed = "otpauth://totp/Keepiq:e2e?secret=JBSWY3DPEHPK3PXP&issuer=Keepiq" + + @Before + fun setUp() { + E2e.requireServer() + check(E2e.appPassword.isNotEmpty()) { "keepiqAppPassword was not passed" } + E2e.shell("settings put secure autofill_service $service") + assertTrue("Keepiq is the autofill service", E2e.shell("settings get secure autofill_service").trim() == service) + } + + @After + fun tearDown() { + E2e.shell("settings delete secure autofill_service") + } + + @Test + fun fillSaveCodesAndTheIndex() { + val state = E2e.app.state + val core = E2e.app.autofill + val account = runBlocking { state.client.pairManually(E2e.server, E2e.user, E2e.appPassword) } + main { state.switchAccount(account.id) } + unlock(account.id) + val vault = state.openVault()!! + val keys = RsaVaultKeys.fromPem(vault.privateKeyPem, vault.certificate!!, vault.suiteId, vault.unlockKeyEpoch) + val api = state.client.api(account) + + // The test app as Keepiq sees it, and the website that lists it back. + val identity = core.identities.identity(testPackage)!! + val print = identity.certFingerprints.first() + E2e.helper( + "/assetlinks", + """[{"relation":["delegate_permission/common.get_login_creds"],"target":{"namespace":"android_app","package_name":"$testPackage","sha256_cert_fingerprints":["$print"]}}]""", + ) + val appUrl = AppLink(testPackage, setOf(print)).toUrl() + val lookAlikeUrl = AppLink(testPackage, setOf("00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF")).toUrl() + val webId = create(api, keys, "login", "Test server", "https://10.0.2.2:8443", "webuser", "web-secret-1") + val appId = create(api, keys, "login", "Test app", appUrl, "appuser", "app-secret-1") + val lookAlikeId = create(api, keys, "login", "Look-alike", lookAlikeUrl, "wrongcert", "look-alike-1") + val codeId = create(api, keys, "totp", "Test code", appUrl, "", seed) + + // 4.6: a sync builds the index (VaultSession reports every refresh). + val session = VaultSession(account, api, keys, null, null) + runBlocking { session.repository.refresh(SyncTrigger.MANUAL) } + val indexed = core.indexOf(account.id).entries.map { it.id } + assertTrue("indexed: $indexed", indexed.containsAll(listOf(webId, appId, lookAlikeId, codeId))) + + // 4.1 locked: one entry, no account names. + main { state.lock() } + assertEquals(null, state.openVault()) + form(login = true) + val unlockEntry = waitFor(By.text("Unlock Keepiq")) + assertFalse(device.hasObject(By.textContains("appuser"))) + assertFalse(device.hasObject(By.textContains("Test app"))) + E2e.shot("autofill-01-locked-entry") + unlockEntry.click() + waitFor(By.pkg("nl.conduction.keepiq")) + E2e.shot("autofill-02-unlock") + unlock(account.id) + + // Two logins for the app: its own, and the website's through Digital Asset Links. Never the look-alike. + waitFor(By.text("Choose a login"), 60_000) + assertNotNull(device.findObject(By.text("appuser"))) + assertNotNull(device.findObject(By.text("webuser"))) + assertFalse(device.hasObject(By.text("wrongcert"))) + E2e.shot("autofill-03-choose") + device.findObject(By.text("appuser")).click() + waitFor(By.textContains("user=appuser password=12")) + E2e.shot("autofill-04-filled-after-unlock") + + // 4.3: the code on the next step. + device.findObject(By.res(testPackage, "submit")).click() + waitFor(By.textStartsWith("Code from Test code")).click() + val statusText = waitForStatus { it.substringAfter("code=").length == 6 } + val code = statusText.substringAfter("code=") + val now = System.currentTimeMillis() + val valid = (-1..1).map { Totp.generate(Totp.parse(seed), now + it * 30_000L) } + assertTrue("code $code is one of $valid", code in valid) + E2e.shot("autofill-05-one-time-code") + + // 4.1 unlocked: the dropdown names the logins; the look-alike stays out. + form(login = true) + waitFor(By.text("appuser")) + assertNotNull(device.findObject(By.text("webuser"))) + assertFalse(device.hasObject(By.text("wrongcert"))) + E2e.shot("autofill-06-unlocked-dropdown") + device.findObject(By.text("webuser")).click() + waitFor(By.textContains("user=webuser password=12")) + + // The web domain of a page: the site's login, never the app's. + web() + waitFor(By.clazz("android.widget.EditText")).click() + waitFor(By.text("webuser")) + assertFalse(device.hasObject(By.text("appuser"))) + E2e.shot("autofill-07-webview") + device.findObject(By.text("webuser")).click() + waitFor(By.textContains("filled: webuser / 12")) + E2e.shot("autofill-08-webview-filled") + + // 4.2: saving a sign-up. + form(login = false) + type("username", "newuser@example.test") + type("password", "Brand-new-pass-1") + device.findObject(By.res(testPackage, "submit")).click() + val save = waitFor(By.res("android", "autofill_save_yes")) + E2e.shot("autofill-09-save-offer") + save.click() + val saved = waitForServer(api, keys) { it == "newuser@example.test" } + assertTrue("the saved login belongs to the test app", saved.startsWith("androidapp://$testPackage#sha256_cert_fingerprints=")) + + // "Never" (Android 11+): the app goes on the never-save list, and is not asked again. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + form(login = false) + type("username", "other@example.test") + type("password", "Other-pass-2") + device.findObject(By.res(testPackage, "submit")).click() + waitFor(By.res("android", "autofill_save_no")).click() + waitUntil { core.neverSave.contains("androidapp://$testPackage") } + } else { + // Android 9 and 10 have no "Never" button; the list is honoured all the same. + core.neverSave.set("androidapp://$testPackage", true) + } + form(login = false) + type("username", "third@example.test") + type("password", "Third-pass-3") + device.findObject(By.res(testPackage, "submit")).click() + assertFalse("no save offer after Never", device.wait(Until.hasObject(By.res("android", "autofill_save_yes")), 5_000)) + E2e.shot("autofill-10-never") + + // 4.6: a login trashed on the server is not offered after the next sync. + runBlocking { + api.trashSecret(webId) + session.repository.refresh(SyncTrigger.MANUAL) + } + assertFalse(core.indexOf(account.id).entries.any { it.id == webId }) + web() + waitFor(By.clazz("android.widget.EditText")).click() + assertFalse("a trashed login is not offered", device.wait(Until.hasObject(By.text("webuser")), 5_000)) + E2e.shot("autofill-11-trashed-not-offered") + + // Unpair (here the local wipe, so the shared app password stays): the index goes. + runBlocking { listOf(appId, lookAlikeId, codeId).forEach { api.trashSecret(it) } } + main { state.lock() } + state.client.wipe(account.id) + assertTrue(core.indexOf(account.id).entries.isEmpty()) + core.neverSave.set("androidapp://$testPackage", false) + } + + private fun main(block: () -> Unit) = instrumentation.runOnMainSync(block) + + private fun unlock(accountId: String) { + main { E2e.app.state.unlockWithMasterPassword(accountId, E2e.masterPassword) } + // The unlock opens the encrypted store and syncs before the vault + // counts as open; on the API 28 emulator that is slow. + try { + waitUntil(180_000) { E2e.app.state.openVault() != null } + } catch (e: IllegalStateException) { + error("the vault did not open: screen ${E2e.app.state.screen.value::class.simpleName}, message ${E2e.app.state.message.value}") + } + } + + private fun create(api: KeepiqApi, keys: RsaVaultKeys, type: String, name: String, url: String, login: String, secret: String): String = runBlocking { + val typeId = api.listTypes().first { (it["name"] as? JsonPrimitive)?.contentOrNull == type }["id"]!!.let { (it as JsonPrimitive).content } + val saved = api.createSecret(ItemCodec.createBody(ItemParts(name, url, null, login, secret, null), typeId, keys)) + (saved!!["id"] as JsonPrimitive).content + } + + private fun form(login: Boolean) { + val intent = Intent().setClassName(testPackage, "nl.conduction.keepiq.android.autofilltest.AutofillFormActivity") + .putExtra("mode", if (login) "login" else "signup") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + instrumentation.targetContext.startActivity(intent) + waitFor(By.res(testPackage, "status")) + } + + private fun web() { + val intent = Intent().setClassName(testPackage, "nl.conduction.keepiq.android.autofilltest.WebFormActivity") + .putExtra("url", E2e.server + "/__e2e/login.html") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + instrumentation.targetContext.startActivity(intent) + waitFor(By.textContains("filled:"), 60_000) + } + + private fun type(field: String, text: String) { + waitFor(By.res(testPackage, field)).text = text + } + + private fun waitFor(selector: BySelector, timeout: Long = 30_000): UiObject2 = + device.wait(Until.findObject(selector), timeout) ?: error("not on screen: $selector") + + private fun waitForStatus(done: (String) -> Boolean): String { + var text = "" + waitUntil { text = device.findObject(By.res(testPackage, "status"))?.text ?: ""; done(text) } + return text + } + + private fun waitUntil(timeout: Long = 30_000, condition: () -> Boolean) { + val end = System.currentTimeMillis() + timeout + while (!condition()) { + check(System.currentTimeMillis() < end) { "timed out" } + Thread.sleep(250) + } + } + + /** The url of the login on the server whose decrypted login name satisfies [match]. */ + private fun waitForServer(api: KeepiqApi, keys: RsaVaultKeys, match: (String) -> Boolean): String { + var url: String? = null + waitUntil { + url = runBlocking { api.listSecrets() }.firstOrNull { row -> + val login = (row["login"] as? JsonPrimitive)?.contentOrNull + login != null && runCatching { match(keys.decryptField(login)) }.getOrDefault(false) + }?.let { (it["url"] as? JsonPrimitive)?.contentOrNull } + url != null + } + return url!! + } +} diff --git a/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/VaultFlowsTest.kt b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/VaultFlowsTest.kt new file mode 100644 index 000000000..4a73e59f0 --- /dev/null +++ b/mobile/android/app/src/androidTest/kotlin/nl/conduction/keepiq/android/VaultFlowsTest.kt @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.ClipboardManager +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onLast +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import kotlin.concurrent.thread +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.vault.VaultLockedException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Task group 3 on the emulator against the test server, over the demo vault + * of `mobile/e2e/server.mjs seed`: after unlock the vault list, search, an + * item with its password revealed and copied (marked sensitive), the + * authenticator code with its countdown, a new login with a generated + * password, its edit and its trash, the generator, a Send with its link, + * the settings route, and the lock, after which the session's key is gone. + */ +@RunWith(AndroidJUnit4::class) +class VaultFlowsTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Before + fun setUp() { + E2e.requireServer() + check(E2e.appPassword.isNotEmpty()) { "keepiqAppPassword was not passed" } + } + + @Test + fun vaultSearchRevealCopyTotpCreateEditTrashGeneratorSendAndLock() { + // Pair with an app password and unlock: the vault list is what opens. + compose.waitForTag("server") + compose.onNodeWithTag("server").performTextInput(E2e.server) + compose.onNodeWithTag("useAppPassword").performClick() + compose.waitForTag("appPassword") + compose.onNodeWithTag("loginName").performTextInput(E2e.user) + compose.onNodeWithTag("appPassword").performTextInput(E2e.appPassword) + compose.onNodeWithTag("connect").performClick() + compose.waitForTag("masterPassword", 60_000) + compose.onNodeWithTag("masterPassword").performTextInput(E2e.masterPassword) + compose.onNodeWithTag("unlock").performClick() + compose.waitForTag("unlocked", 60_000) + compose.waitForText("Webmail (demo)", 60_000) + compose.waitForText("Authenticator (demo)") + compose.waitForText("Personal") + E2e.shot("30-vault-list") + val unlocked = E2e.app.state.screen.value as Screen.Unlocked + + // Search runs on the device, over every folder. + compose.onNodeWithTag("search").performTextInput("bank") + compose.waitForText("Bank (demo)") + compose.waitUntilGone("Webmail (demo)") + E2e.shot("31-search") + compose.onNodeWithTag("search").performTextClearance() + compose.waitForText("Webmail (demo)") + + // A folder holds its own items. + compose.onNodeWithContentDescription("Open folder Personal").performClick() + compose.waitForText("Bank (demo)") + E2e.shot("31-folder") + compose.onNodeWithText("Back").performClick() + compose.waitForText("Webmail (demo)") + + // An item: the password hidden until shown, then copied as sensitive. + compose.onNodeWithText("Webmail (demo)").performClick() + compose.waitForText("anna.demo@example.com", 60_000) + assertTrue(compose.onAllNodesWithText("Lantern-Orbit-42!").fetchSemanticsNodes().isEmpty()) + compose.onNodeWithContentDescription("Show Password").performClick() + compose.waitForText("Lantern-Orbit-42!") + E2e.shot("32-item-revealed") + compose.onNodeWithContentDescription("Copy Password").performClick() + compose.waitForText("Copied.") + val (copied, sensitive) = clipboard() + assertEquals("Lantern-Orbit-42!", copied) + assertTrue("the copied password is marked sensitive", sensitive) + E2e.shot("33-copied") + compose.onNodeWithText("Back").performClick() + + // The authenticator code and its seconds left. + compose.waitForText("Authenticator (demo)") + compose.onNodeWithText("Authenticator (demo)").performClick() + compose.waitUntil(60_000) { compose.nodes(totpCode).isNotEmpty() } + compose.waitUntil(10_000) { compose.nodes(hasContentDescription("seconds left", substring = true)).isNotEmpty() } + E2e.shot("34-totp") + compose.onNodeWithText("Back").performClick() + + // A new login with a generated password. The copy's snackbar covers + // the add button while it shows. + compose.waitUntilGone("Copied.", 30_000) + compose.onNodeWithContentDescription("Add an item").performClick() + compose.waitUntil(30_000) { compose.nodes(field("Name")).isNotEmpty() } + compose.onNode(field("Name")).performTextInput("Shop (demo)") + compose.onNode(field("Website address")).performTextInput("https://shop.example.com") + compose.onNode(field("User name")).performScrollTo().performTextInput("anna.demo@example.com") + hideKeyboard() + compose.onNodeWithText("Generate").performScrollTo().performClick() + compose.waitForTag("generated") + E2e.shot("35-generate-in-form") + // The value as the generator shows it: the password field's semantics + // carry only the masked text. + val generated = generatedValue() + assertTrue("a generated password: $generated", generated.length >= 12) + compose.onNodeWithText("Use this").performClick() + hideKeyboard() + compose.onNodeWithText("Save").performScrollTo().performClick() + // Saved: the form gives way to the new item. + compose.waitUntilGone("New item", 60_000) + compose.waitForText("Shop (demo)", 60_000) + compose.waitForText("anna.demo@example.com") + compose.onNodeWithContentDescription("Show Password").performClick() + compose.waitForText(generated) + E2e.shot("36-created") + // On the server: the login as the web app's API lists it, its password encrypted to the suite. + val stored = onOwnThread { unlocked.session.api.listSecrets() } + .single { it["name"]?.jsonPrimitive?.content == "Shop (demo)" } + assertEquals("https://shop.example.com", stored["url"]?.jsonPrimitive?.content) + val storedKey = stored["key"]!!.jsonPrimitive.content + assertFalse("the server holds no plaintext", storedKey.contains(generated)) + assertEquals(generated, unlocked.session.keys.decryptField(storedKey)) + + // Edit it. + compose.onNodeWithText("Edit").performScrollTo().performClick() + compose.waitUntil(30_000) { compose.nodes(field("Name")).isNotEmpty() } + compose.onNode(field("Name")).performTextClearance() + compose.onNode(field("Name")).performTextInput("Shop account (demo)") + hideKeyboard() + compose.onNodeWithText("Save").performScrollTo().performClick() + compose.waitUntilGone("Edit item", 60_000) + compose.waitForText("Shop account (demo)", 60_000) + E2e.shot("37-edited") + + // Trash it: gone from the list. + compose.onNodeWithText("Move to trash").performScrollTo().performClick() + compose.waitForTag("confirmTrash") + compose.onNodeWithTag("confirmTrash").performClick() + compose.waitForText("Webmail (demo)", 60_000) + compose.waitUntilGone("Shop account (demo)", 60_000) + E2e.shot("38-trashed") + + // The generator. + compose.onNodeWithText("Generator").performClick() + compose.waitForTag("generated") + val first = generatedValue() + compose.onNodeWithContentDescription("Generate a new value").performClick() + compose.waitUntil(10_000) { generatedValue() != first } + assertNotEquals(first, generatedValue()) + E2e.shot("39-generator") + + // A Send and its link. + compose.onNodeWithText("Send").performClick() + compose.onNodeWithContentDescription("New Send").performClick() + compose.waitUntil(30_000) { compose.nodes(field("Text to send")).isNotEmpty() } + compose.onNode(field("Text to send")).performTextInput("The demo door code is 2468.") + hideKeyboard() + compose.onNodeWithText("Create link").performScrollTo().performClick() + compose.waitForText("Your link is ready", 60_000) + val link = compose.nodes(hasText("/public/", substring = true)).single().config[SemanticsProperties.Text].joinToString("") + assertTrue("a Send link with its key in the fragment: $link", link.startsWith(E2e.server) && link.contains("#")) + E2e.shot("40-send-link") + compose.onNodeWithText("Close").performScrollTo().performClick() + compose.waitForText("Text", 30_000) + E2e.shot("41-send-list") + + // Open the link in the app, as its recipient would: the text, and its one view used. + compose.onNodeWithText("Open a Send link").performClick() + compose.waitUntil(30_000) { compose.nodes(field("Send link")).isNotEmpty() } + compose.onNode(field("Send link")).performTextInput(link) + compose.waitForText("Opening shows the text", 60_000) + hideKeyboard() + compose.onNodeWithText("Open").performClick() + compose.waitForText("The demo door code is 2468.", 60_000) + E2e.shot("42-send-opened") + compose.onAllNodesWithText("Close").onLast().performClick() + + // The settings route from inside the vault, and back. + compose.onNodeWithTag("settings").performClick() + compose.waitForTag("newPin") + E2e.shot("43-settings") + compose.onNodeWithTag("back").performScrollTo().performClick() + compose.waitForTag("unlocked") + + // Lock: the unlock screen, and the session's key no longer decrypts. + compose.onNodeWithTag("lock").performClick() + compose.waitForTag("masterPassword", 30_000) + E2e.shot("44-locked") + assertTrue(unlocked.vault.isLocked) + assertFalse(E2e.app.state.screen.value is Screen.Unlocked) + try { + unlocked.session.keys.decryptField("AAAA") + fail("the locked session still decrypts") + } catch (e: VaultLockedException) { + // The key is gone. + } + } + + /** A six-digit code shown as two groups of three. */ + private val totpCode = SemanticsMatcher("a TOTP code") { node -> + node.config.getOrElse(SemanticsProperties.Text) { emptyList() }.any { Regex("^\\d{3} \\d{3}$").matches(it.text) } + } + + /** The keyboard covers the lower half; a touch there would land in the keyboard's window. */ + private fun hideKeyboard() { + Espresso.closeSoftKeyboard() + compose.waitForIdle() + } + + /** + * Runs a suspend call to completion on a thread of its own. A + * runBlocking on the test thread lets a Compose frame run inside its + * event loop, off the main thread. + */ + private fun onOwnThread(block: suspend () -> T): T { + var result: Result? = null + thread { result = runCatching { runBlocking { block() } } }.join() + return result!!.getOrThrow() + } + + /** An input labelled [label]. */ + private fun field(label: String): SemanticsMatcher = hasSetTextAction() and hasText(label) + + private fun ComposeTestRule.nodes(matcher: SemanticsMatcher) = + onAllNodes(matcher, useUnmergedTree = false).fetchSemanticsNodes() + + private fun generatedValue(): String = + compose.onNodeWithTag("generated", useUnmergedTree = true).fetchSemanticsNode() + .config.getOrElse(SemanticsProperties.Text) { emptyList() }.joinToString("") + + /** The clipboard's text and whether it is marked sensitive, read on the main thread while the app has focus. */ + private fun clipboard(): Pair { + var result = "" to false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val clip = E2e.app.getSystemService(ClipboardManager::class.java).primaryClip + val text = clip?.getItemAt(0)?.text?.toString() ?: "" + val sensitive = clip?.description?.extras?.getBoolean("android.content.extra.IS_SENSITIVE") == true + result = text to sensitive + } + return result + } +} diff --git a/mobile/android/app/src/androidTest/res/values/autofilltest.xml b/mobile/android/app/src/androidTest/res/values/autofilltest.xml new file mode 100644 index 000000000..c085a4375 --- /dev/null +++ b/mobile/android/app/src/androidTest/res/values/autofilltest.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + [{\"include\": \"https://10.0.2.2:8443/.well-known/assetlinks.json\"}] + diff --git a/mobile/android/app/src/e2e/AndroidManifest.xml b/mobile/android/app/src/e2e/AndroidManifest.xml new file mode 100644 index 000000000..93f3761ab --- /dev/null +++ b/mobile/android/app/src/e2e/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/mobile/android/app/src/e2e/res/xml/network_security_config.xml b/mobile/android/app/src/e2e/res/xml/network_security_config.xml new file mode 100644 index 000000000..4de799799 --- /dev/null +++ b/mobile/android/app/src/e2e/res/xml/network_security_config.xml @@ -0,0 +1,19 @@ + + + + + + + + 10.0.2.2 + + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..42e4da841 --- /dev/null +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/AppState.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/AppState.kt new file mode 100644 index 000000000..61ad34ebb --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/AppState.kt @@ -0,0 +1,394 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import androidx.fragment.app.FragmentActivity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.security.BiometricCancelledException +import nl.conduction.keepiq.android.security.BiometricUnlock +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.KeepiqClient +import nl.conduction.keepiq.shared.UnlockGate +import nl.conduction.keepiq.shared.account.AccountSettings +import nl.conduction.keepiq.shared.account.IdlePolicy +import nl.conduction.keepiq.shared.account.IdleTimer +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.pairing.LoginFlowStart +import nl.conduction.keepiq.shared.pairing.LoginFlowStoppedException +import nl.conduction.keepiq.shared.sync.LockReason +import nl.conduction.keepiq.shared.sync.SyncListener +import nl.conduction.keepiq.shared.unlock.StaleUnlockKeyException +import nl.conduction.keepiq.shared.unlock.UnlockedVault + +/** Where the app is. */ +sealed interface Screen { + /** No account, or the user adds one. */ + data object Pair : Screen + + /** A paired account, locked. */ + data class Unlock(val accountId: String) : Screen + + /** The vault is open; the vault screens read and write through [session]. */ + data class Unlocked(val vault: UnlockedVault, val session: VaultSession) : Screen + + /** Unlock options, auto-lock and unpair for the open vault. */ + data class Settings(val vault: UnlockedVault) : Screen +} + +/** The browser sign-in. */ +sealed interface LoginState { + data object Idle : LoginState + + /** The login page is open in the browser; the app polls. */ + data class Waiting(val start: LoginFlowStart) : LoginState +} + +/** + * The app's state and the actions the screens call (tasks 2.1 to 2.6). One + * instance per process, in [KeepiqApp]. + */ +class AppState( + val client: KeepiqClient, + val biometric: BiometricUnlock, + private val openSession: (Account, UnlockedVault, SyncListener) -> VaultSession, + private val onAccountWiped: (String) -> Unit = {}, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val scope = MainScope() + private var loginJob: Job? = null + private var idleJob: Job? = null + + private val _screen = MutableStateFlow(initialScreen()) + val screen: StateFlow = _screen.asStateFlow() + + private val _login = MutableStateFlow(LoginState.Idle) + val login: StateFlow = _login.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val _message = MutableStateFlow(null) + + /** The last problem, in words for the user; the screen shows it. */ + val message: StateFlow = _message.asStateFlow() + + /** The unlock screen's offer for the account, read from the server. */ + private val _gate = MutableStateFlow(null) + val gate: StateFlow = _gate.asStateFlow() + + /** The organisation's idle maximum, read at unlock. */ + private val _maxIdle = MutableStateFlow(null) + val maxIdle: StateFlow = _maxIdle.asStateFlow() + + private val idle = IdleTimer(clock, IdlePolicy.DEFAULT_MINUTES) + + /** The open vault's session, closed on lock. */ + private var session: VaultSession? = null + + init { + client.onWipe = { accountId -> + biometric.disable(accountId) + onAccountWiped(accountId) + } + } + + private fun initialScreen(): Screen = client.accounts.activeId()?.let { Screen.Unlock(it) } ?: Screen.Pair + + fun clearMessage() { + _message.value = null + } + + fun reportProblem(text: String) { + _message.value = text + } + + // Pairing (tasks 2.1 and 2.4) + + /** Starts Login Flow v2 and hands the login page to [openBrowser]. */ + fun startLogin(server: String, openBrowser: (String) -> Unit) = action { + val start = client.startLogin(server) + android.util.Log.i("Keepiq", "login flow started at ${start.server}") + _login.value = LoginState.Waiting(start) + openBrowser(start.loginUrl) + loginJob = scope.launch { + try { + val account = client.finishLogin(start) + _login.value = LoginState.Idle + showUnlock(account.id) + } catch (e: CancellationException) { + throw e + } catch (e: LoginFlowStoppedException) { + _login.value = LoginState.Idle + if (e.timedOut) _message.value = e.message + } catch (e: Exception) { + _login.value = LoginState.Idle + _message.value = e.message + } + } + } + + /** + * The user came back from the browser without the flow finishing: one + * last poll, then the address form again with nothing stored (spec: + * "The user abandons the login"). + */ + fun browserClosed() { + val waiting = _login.value as? LoginState.Waiting ?: return + android.util.Log.i("Keepiq", "browser closed while waiting: one last poll") + loginJob?.cancel() + client.cancelLogin() + action { + val account = client.finishLoginNow(waiting.start) + _login.value = LoginState.Idle + if (account != null) showUnlock(account.id) + } + } + + fun cancelLogin() { + loginJob?.cancel() + client.cancelLogin() + _login.value = LoginState.Idle + } + + fun pairManually(server: String, loginName: String, appPassword: String) = action { + val account = client.pairManually(server, loginName, appPassword) + showUnlock(account.id) + } + + fun addAccount() { + lock() + _message.value = null + _screen.value = Screen.Pair + } + + fun switchAccount(accountId: String) { + lock() + client.accounts.setActive(accountId) + showUnlock(accountId) + } + + // Unlock (tasks 2.2 and 2.3) + + private fun showUnlock(accountId: String) { + _gate.value = null + _message.value = null + _screen.value = Screen.Unlock(accountId) + refreshGate(accountId) + } + + /** Reads what the unlock screen may offer: the master password, or the reason it is blocked. */ + fun refreshGate(accountId: String, keepMessage: Boolean = false) = action(clearMessage = !keepMessage) { + _gate.value = client.unlockGate(accountId) + } + + fun unlockWithMasterPassword(accountId: String, password: String) = withReadySuite(accountId) { suite -> + opened(client.unlockWithMasterPassword(accountId, suite, password)) + } + + fun unlockWithPin(accountId: String, pin: String) = withReadySuite(accountId) { suite -> + opened(client.unlockWithPin(accountId, suite, pin)) + } + + fun unlockWithBiometric(activity: FragmentActivity, accountId: String) = withReadySuite(accountId) { suite -> + try { + opened(client.unlockWithKey(accountId, suite, biometric.unlockKey(activity, accountId))) + } catch (e: BiometricCancelledException) { + // The master password field is right there. + } catch (e: StaleUnlockKeyException) { + biometric.disable(accountId) + throw e + } + } + + private fun withReadySuite(accountId: String, block: suspend (nl.conduction.keepiq.shared.api.Suite) -> Unit) = action { + val gate = _gate.value ?: client.unlockGate(accountId).also { _gate.value = it } + when (gate) { + is UnlockGate.Blocked -> _message.value = gate.message + is UnlockGate.Ready -> block(gate.suite) + } + } + + private suspend fun opened(vault: UnlockedVault) { + _message.value = null + val opened = try { + val account = client.accounts.account(vault.accountId) ?: throw IllegalStateException("This account is gone.") + openSession(account, vault, syncListener(vault.accountId)) + } catch (e: Exception) { + vault.lock() + throw e + } + session = opened + _screen.value = Screen.Unlocked(vault, opened) + _maxIdle.value = client.maxIdleMinutes(vault.accountId) + applyIdle(vault.accountId) + idle.touch() + startIdleWatch() + } + + // Unlock options (task 2.3) + + fun enableBiometric(activity: FragmentActivity, vault: UnlockedVault) = action { + try { + biometric.enable(activity, vault.accountId, vault.unlockKey()) + client.accounts.updateSettings(vault.accountId, client.accounts.settings(vault.accountId).copy(biometric = true)) + } catch (e: BiometricCancelledException) { + // Nothing changed. + } + } + + fun disableBiometric(vault: UnlockedVault) { + biometric.disable(vault.accountId) + client.accounts.updateSettings(vault.accountId, client.accounts.settings(vault.accountId).copy(biometric = false)) + } + + fun setPin(vault: UnlockedVault, pin: String, onDone: () -> Unit) = action { + client.setPin(vault, pin) + onDone() + } + + fun removePin(vault: UnlockedVault) { + client.pins.remove(vault.accountId) + } + + // Locking (task 2.5) + + fun setIdleMinutes(vault: UnlockedVault, minutes: Int) { + client.accounts.updateSettings(vault.accountId, client.accounts.settings(vault.accountId).copy(idleMinutes = minutes)) + applyIdle(vault.accountId) + } + + fun effectiveIdleMinutes(accountId: String): Int = + IdlePolicy.effectiveMinutes(client.accounts.settings(accountId).idleMinutes, _maxIdle.value) + + private fun applyIdle(accountId: String) { + idle.minutes = effectiveIdleMinutes(accountId) + } + + /** Every touch of the screen restarts the idle time (MainActivity.onUserInteraction). */ + fun touch() = idle.touch() + + fun onForeground() { + if (idle.expired()) lock() + if (_screen.value is Screen.Unlocked || _screen.value is Screen.Settings) startIdleWatch() + } + + fun onBackground() { + idleJob?.cancel() + } + + private fun startIdleWatch() { + idleJob?.cancel() + idleJob = scope.launch { + while (true) { + if (idle.expired()) { + lock() + return@launch + } + delay(minOf(idle.remainingMillis(), 10_000L).coerceAtLeast(500L)) + } + } + } + + /** + * Locks now: closes the session, so the private key and the store's + * sealing key leave memory, overwrites the unlock key, and shows the + * unlock screen. [reason] is shown there, for a lock the user did not ask for. + */ + fun lock(reason: String? = null) { + val vault = when (val s = _screen.value) { + is Screen.Unlocked -> s.vault + is Screen.Settings -> s.vault + else -> return + } + idleJob?.cancel() + closeSession() + vault.lock() + _gate.value = null + _screen.value = Screen.Unlock(vault.accountId) + refreshGate(vault.accountId, keepMessage = reason != null) + if (reason != null) _message.value = reason + } + + private fun closeSession() { + session?.close() + session = null + } + + /** + * What a sync does when it finds the keys changed elsewhere: the screens + * lock (VaultApp's onLocked); a changed master password or suite also + * makes the biometric and PIN wraps useless, so they go (design D4). + */ + private fun syncListener(accountId: String) = object : SyncListener { + override fun lock(reason: LockReason) = Unit + + override fun deleteUnlockWraps() { + scope.launch { + biometric.disable(accountId) + client.pins.remove(accountId) + client.accounts.updateSettings(accountId, client.accounts.settings(accountId).copy(biometric = false)) + } + } + } + + /** The open vault, or null while locked; the autofill service fills from it (task 4.1). */ + fun openVault(): UnlockedVault? = when (val s = _screen.value) { + is Screen.Unlocked -> s.vault + is Screen.Settings -> s.vault + else -> null + } + + fun openSettings(vault: UnlockedVault) { + _screen.value = Screen.Settings(vault) + } + + fun closeSettings(vault: UnlockedVault) { + val open = session ?: return lock() + _screen.value = Screen.Unlocked(vault, open) + } + + fun settings(accountId: String): AccountSettings = client.accounts.settings(accountId) + + // Unpair (task 2.6) + + fun unpair(accountId: String) = action { + closeSession() + (_screen.value as? Screen.Unlocked)?.vault?.lock() + (_screen.value as? Screen.Settings)?.vault?.lock() + idleJob?.cancel() + val revoked = client.unpair(accountId) + _gate.value = null + _screen.value = initialScreen() + _message.value = if (revoked) { + "Disconnected. The app password was deleted in Nextcloud." + } else { + "Disconnected on this phone. Nextcloud could not be reached, so delete the app password there under Security." + } + } + + /** Runs [block] with the busy flag set, and turns a failure into the message the screen shows. */ + private fun action(clearMessage: Boolean = true, block: suspend () -> Unit) { + scope.launch { + _busy.value = true + if (clearMessage) _message.value = null + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w("Keepiq", "action failed", e) + _message.value = e.message ?: e.toString() + } finally { + _busy.value = false + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/KeepiqApp.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/KeepiqApp.kt new file mode 100644 index 000000000..a0b093675 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/KeepiqApp.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import nl.conduction.keepiq.android.autofill.AutofillCore +import nl.conduction.keepiq.android.security.BiometricUnlock +import nl.conduction.keepiq.android.security.KeystoreStorage +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.KeepiqClient +import nl.conduction.keepiq.shared.store.deleteEncryptedStore + +/** + * Holds the one [AppState] of the process. The vault is unlocked in memory + * only, so a killed process is a locked vault. + * + * Locks (design D4, "Locking"): when the screen turns off, which is how a + * phone locks, and when the idle time ran out while the app was away. + */ +class KeepiqApp : Application() { + lateinit var state: AppState + private set + + /** System autofill (task group 4): the index, app identities and the never-save list. */ + lateinit var autofill: AutofillCore + private set + + override fun onCreate() { + super.onCreate() + val storage = KeystoreStorage(this) + val client = KeepiqClient(storage, clientName()) + // System autofill first: unpair wipes its index (task 4.6). It reads + // the state lazily, so it may exist before the state. + autofill = AutofillCore(this, storage) { state } + state = AppState( + client, + BiometricUnlock(this, storage), + openSession = { account, vault, listener -> VaultSession.open(this, client.api(account), account, vault, listener) }, + // Unpair removes everything stored for the account: the offline copy and the autofill index. + onAccountWiped = { accountId -> + deleteEncryptedStore(this, accountId) + autofill.forget(accountId) + }, + ) + + val screenOff = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_SCREEN_OFF) state.lock() + } + } + ContextCompat.registerReceiver(this, screenOff, IntentFilter(Intent.ACTION_SCREEN_OFF), ContextCompat.RECEIVER_NOT_EXPORTED) + + ProcessLifecycleOwner.get().lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + state.onForeground() + // Autofill turned off in the system settings: no index on disk. + autofill.index.clearFilesIfNotTheService() + } + + override fun onStop(owner: LifecycleOwner) = state.onBackground() + }, + ) + } + + /** + * What the Nextcloud device list shows for the app password: Nextcloud + * names a Login Flow v2 app password after the User-Agent (task 2.4). + */ + private fun clientName(): String = "Keepiq for Android (${Build.MANUFACTURER} ${Build.MODEL})" +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/MainActivity.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/MainActivity.kt new file mode 100644 index 000000000..900a984cf --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/MainActivity.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.browser.customtabs.CustomTabsIntent +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.ui.KeepiqRoot + +/** + * The one activity. A FragmentActivity, because BiometricPrompt needs one. + * The login page opens in a Custom Tab, in the user's own browser (design + * D3); when the user comes back without finishing, the app polls once more + * and then shows the address form again. + */ +class MainActivity : FragmentActivity() { + private val state: AppState get() = (application as KeepiqApp).state + private var browserOpen = false + + // True once the browser covered the app (onStop). Only a return from + // there means the user left the sign-in page. + private var browserCovered = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { KeepiqRoot(state, this, ::openBrowser) } + // Back in front when the browser sign-in paired an account. + lifecycleScope.launch { + state.screen.collect { screen -> + if (browserOpen && screen is Screen.Unlock) { + browserOpen = false + startActivity( + Intent(this@MainActivity, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + } + } + } + } + + override fun onStop() { + super.onStop() + if (browserOpen) browserCovered = true + } + + override fun onResume() { + super.onResume() + if (browserOpen && browserCovered) { + Log.i(TAG, "back from the browser before the sign-in finished") + browserOpen = false + browserCovered = false + state.browserClosed() + } + } + + override fun onUserInteraction() { + super.onUserInteraction() + state.touch() + } + + private fun openBrowser(url: String) { + try { + browserOpen = true + browserCovered = false + CustomTabsIntent.Builder().setShowTitle(true).build().launchUrl(this, Uri.parse(url)) + Log.i(TAG, "opened the sign-in page") + } catch (e: ActivityNotFoundException) { + browserOpen = false + state.cancelLogin() + state.reportProblem("No browser is installed. Use an app password instead.") + } + } + + private companion object { + const val TAG = "Keepiq" + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/OpenSendActivity.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/OpenSendActivity.kt new file mode 100644 index 000000000..f82ce5b0d --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/OpenSendActivity.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import nl.conduction.keepiq.android.ui.OpenSendScreen +import nl.conduction.keepiq.android.vault.AndroidClipboard +import nl.conduction.keepiq.android.vault.HandlerScheduler +import nl.conduction.keepiq.android.vault.VaultPreferences +import nl.conduction.keepiq.shared.vault.ClearDelay +import nl.conduction.keepiq.shared.vault.SensitiveClipboard + +/** + * Opens a Keepiq Send link in the app (task 3.6): a tapped link the user + * chose to open with Keepiq, or a link shared to it. Needs no paired + * account and no unlock: a Send is opened with the key in its link or its + * password, as the public page does. The public page keeps working for + * anyone without the app. + */ +class OpenSendActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val link = when (intent?.action) { + Intent.ACTION_VIEW -> intent.dataString + Intent.ACTION_SEND -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let { text -> + Regex("https://\\S+").find(text)?.value + } + else -> null + } ?: "" + setContent { + MaterialTheme { + Surface(Modifier.fillMaxSize()) { + val prefs = remember { VaultPreferences(this) } + val clipboard = remember { + SensitiveClipboard(AndroidClipboard(this), HandlerScheduler(), ClearDelay { prefs.clipboardClearSeconds }) + } + OpenSendScreen( + initialLink = link, + modifier = Modifier.safeDrawingPadding().padding(8.dp), + onCopy = { clipboard.write(it) }, + ) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AndroidAutofillIndex.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AndroidAutofillIndex.kt new file mode 100644 index 000000000..d281a2c1f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AndroidAutofillIndex.kt @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.view.autofill.AutofillManager +import nl.conduction.keepiq.android.passkey.PasskeyProvider +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.autofill.AutofillIndexSink +import nl.conduction.keepiq.shared.vault.VaultKeys +import java.io.File +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * The autofill index on Android (design D5, task 4.6): one file per + * account in the no-backup directory, sealed with AES-256-GCM under an + * AndroidKeyStore key that needs no user authentication. The service reads + * it while the vault is locked, to tell that a form has a match; it holds + * names, addresses and the item ciphertext the server sent, never a + * plaintext secret. + * + * Kept in memory only when the organisation keeps no offline copy, and when + * Keepiq is neither the autofill service nor the passkey provider: then + * nothing is written to disk. + */ +class AndroidAutofillIndex(private val context: Context) : AutofillIndexSink { + private val memory = HashMap() + + @Synchronized + override fun replace(accountId: String, index: AutofillIndex, persist: Boolean, keys: VaultKeys) { + memory[accountId] = index + if (persist && keepsFiles()) { + write(accountId, index) + } else { + fileOf(accountId).delete() + } + } + + @Synchronized + override fun clear(accountId: String) { + memory.remove(accountId) + fileOf(accountId).delete() + } + + /** The index of an account: memory first, else the sealed file. */ + @Synchronized + fun index(accountId: String): AutofillIndex { + memory[accountId]?.let { return it } + val file = fileOf(accountId) + if (!file.isFile) return AutofillIndex.EMPTY + val index = try { + AutofillIndex.fromJson(String(open(file.readBytes()), Charsets.UTF_8)) + } catch (e: Exception) { + // Sealed under a key that is gone: worthless, rebuilt at the next sync. + file.delete() + AutofillIndex.EMPTY + } + memory[accountId] = index + return index + } + + /** The user turned autofill off, or chose another service: no index on disk (mobile-system-autofill). */ + @Synchronized + fun clearFilesIfNotTheService() { + if (keepsFiles()) return + dir().listFiles()?.forEach { it.delete() } + } + + @Synchronized + fun clearAll() { + memory.clear() + dir().listFiles()?.forEach { it.delete() } + } + + /** + * The index is on disk while Keepiq fills: as the autofill service, or + * as the passkey and password provider on Android 14 and later (task + * 5.1), which reads it while the vault is locked. + */ + private fun keepsFiles(): Boolean = isAutofillService() || PasskeyProvider.isEnabled(context) + + fun isAutofillService(): Boolean = runCatching { + context.getSystemService(AutofillManager::class.java)?.hasEnabledAutofillServices() == true + }.getOrDefault(false) + + private fun write(accountId: String, index: AutofillIndex) { + val file = fileOf(accountId) + val tmp = File(file.path + ".tmp") + tmp.writeBytes(seal(index.toJson().toByteArray(Charsets.UTF_8))) + tmp.renameTo(file) + } + + private fun dir(): File = File(context.noBackupFilesDir, "autofill").apply { mkdirs() } + + private fun fileOf(accountId: String) = File(dir(), "index-" + accountId.toByteArray(Charsets.UTF_8).joinToString("") { "%02x".format(it) } + ".bin") + + private fun seal(plain: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key()) + return cipher.iv + cipher.doFinal(plain) + } + + private fun open(blob: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, blob, 0, 12)) + return cipher.doFinal(blob, 12, blob.size - 12) + } + + private fun key(): SecretKey { + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + private companion object { + const val ALIAS = "keepiq-autofill-index" + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AppIdentities.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AppIdentities.kt new file mode 100644 index 000000000..d19e17ecb --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AppIdentities.kt @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.util.Log +import nl.conduction.keepiq.shared.account.SecureStorage +import nl.conduction.keepiq.shared.autofill.AppIdentity +import nl.conduction.keepiq.shared.autofill.AssetLinks +import nl.conduction.keepiq.shared.autofill.AutofillTarget +import nl.conduction.keepiq.shared.autofill.SiteMatch +import java.net.HttpURLConnection +import java.net.URL +import java.security.MessageDigest + +/** + * Who asks for autofill (task 4.1). An app is its package name and the + * SHA-256 of its signing certificates, read from the package manager, never + * from the form. A website is the domain a browser reports, trusted only + * from a browser: an app that shows a page in its own WebView could report + * any domain, so for it Keepiq uses the app's own identity instead. + * + * A browser is one of the [BROWSERS] package names. Its certificate is + * recorded the first time it asks, and a later request from that package + * with another certificate is treated as an app, not as a browser. + */ +class AppIdentities(private val context: Context, private val storage: SecureStorage) { + private val prefs = context.getSharedPreferences("keepiq.assetlinks", Context.MODE_PRIVATE) + + fun identity(packageName: String): AppIdentity? = try { + val pm = context.packageManager + val info = pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES) + val signing = info.signingInfo ?: return null + val signatures: Array = if (signing.hasMultipleSigners()) signing.apkContentsSigners else signing.signingCertificateHistory + AppIdentity(packageName, signatures.map { fingerprint(it.toByteArray()) }.toSet()) + } catch (e: PackageManager.NameNotFoundException) { + null + } + + fun label(packageName: String): String? = runCatching { + val pm = context.packageManager + pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString() + }.getOrNull() + + /** + * The target of a form: the reported website when a trusted browser + * asks, else the app, with the websites Digital Asset Links verified for + * it. Runs network requests; call it off the main thread. + */ + fun target(form: ParsedForm): AutofillTarget? { + val app = identity(form.packageName) ?: return null + val domain = form.webDomain + if (domain != null && isTrustedBrowser(app)) return AutofillTarget.Web(domain) + return AutofillTarget.App(app, verifiedHosts(app)) + } + + fun isTrustedBrowser(app: AppIdentity): Boolean { + if (app.packageName !in BROWSERS && app.packageName !in e2eBrowsers()) return false + val key = "autofill:browser:${app.packageName}" + val known = storage.read(key)?.split(',')?.toSet() + if (known == null) { + storage.write(key, app.certFingerprints.sorted().joinToString(",")) + return true + } + if (app.certFingerprints.any { it in known }) return true + Log.w(TAG, "${app.packageName} is signed with another certificate than before; not trusted as a browser") + return false + } + + /** The hosts of the websites the app names in asset_statements that list the app back. */ + fun verifiedHosts(app: AppIdentity): List = + declaredSites(app.packageName).filter { site -> siteAllows(site, app) }.mapNotNull { site -> + SiteMatch.hostOf(site).takeIf { it.isNotEmpty() } + } + + private fun declaredSites(packageName: String): List = try { + val pm = context.packageManager + val info = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + val res = info.metaData?.getInt("asset_statements", 0) ?: 0 + if (res == 0) { + emptyList() + } else { + AssetLinks.declaredSites(pm.getResourcesForApplication(info).getString(res)) + } + } catch (e: Exception) { + emptyList() + } + + /** + * Whether [site] lists [app] in its assetlinks.json with the login + * relation, cached for a day. The passkey provider asks this for the + * rpId an app names (task 5.1). + */ + fun siteAllows(site: String, app: AppIdentity): Boolean { + val cacheKey = site + "|" + app.packageName + "|" + app.certFingerprints.sorted().joinToString(",") + val cached = prefs.getString(cacheKey, null) + val now = System.currentTimeMillis() + if (cached != null) { + val (at, ok) = cached.split(':').let { it[0].toLong() to (it[1] == "1") } + if (now - at < CACHE_MILLIS) return ok + } + val ok = try { + AssetLinks.allowsLogins(fetch(AssetLinks.statementUrl(site)), app) + } catch (e: Exception) { + Log.i(TAG, "assetlinks of $site could not be read: ${e.javaClass.simpleName}") + // Unreachable: use a recent answer, else nothing. + return cached?.endsWith(":1") == true && now - cached.substringBefore(':').toLong() < STALE_MILLIS + } + prefs.edit().putString(cacheKey, "$now:${if (ok) 1 else 0}").apply() + return ok + } + + /** The statement file: https only, no redirects, at most 256 kB. */ + private fun fetch(url: String): String { + val connection = URL(url).openConnection() as HttpURLConnection + connection.instanceFollowRedirects = false + connection.connectTimeout = 2_500 + connection.readTimeout = 2_500 + try { + if (connection.responseCode != 200) throw IllegalStateException("status ${connection.responseCode}") + val bytes = connection.inputStream.use { it.readNBytesCompat(256 * 1024) } + return String(bytes, Charsets.UTF_8) + } finally { + connection.disconnect() + } + } + + private fun java.io.InputStream.readNBytesCompat(limit: Int): ByteArray { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (out.size() < limit) { + val n = read(buffer) + if (n < 0) break + out.write(buffer, 0, minOf(n, limit - out.size())) + } + return out.toByteArray() + } + + /** Extra browsers of the e2e build only (src/e2e/AndroidManifest.xml); none in debug or release. */ + private fun e2eBrowsers(): Set = runCatching { + context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + .metaData?.getString(E2E_BROWSERS)?.split(',')?.map { it.trim() }?.toSet() + }.getOrNull() ?: emptySet() + + companion object { + private const val TAG = "KeepiqAutofill" + private const val CACHE_MILLIS = 24L * 3600_000L + private const val STALE_MILLIS = 7L * 24 * 3600_000L + const val E2E_BROWSERS = "nl.conduction.keepiq.e2e.BROWSERS" + + /** Browsers that report the web domain to an autofill service (design D6), and their common forks. */ + val BROWSERS = setOf( + "com.android.chrome", "com.chrome.beta", "com.chrome.dev", "com.chrome.canary", + "org.chromium.chrome", "org.mozilla.firefox", "org.mozilla.firefox_beta", "org.mozilla.fenix", + "org.mozilla.fennec_fdroid", "us.spotco.fennec_dos", "org.ironfoxoss.ironfox", "com.brave.browser", + "com.microsoft.emmx", "com.sec.android.app.sbrowser", "com.vivaldi.browser", "com.duckduckgo.mobile.android", + "org.torproject.torbrowser", "com.kiwibrowser.browser", "org.bromite.bromite", "org.cromite.cromite", + ) + + fun fingerprint(der: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(der).joinToString(":") { "%02X".format(it) } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillCore.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillCore.kt new file mode 100644 index 000000000..4ce656464 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillCore.kt @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import nl.conduction.keepiq.android.AppState +import nl.conduction.keepiq.shared.account.SecureStorage +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.autofill.AutofillIndexHub +import nl.conduction.keepiq.shared.autofill.AutofillTarget +import nl.conduction.keepiq.shared.autofill.NeverSaveList +import nl.conduction.keepiq.shared.crypto.RsaFields +import nl.conduction.keepiq.shared.crypto.RsaPrivateKey +import nl.conduction.keepiq.shared.unlock.UnlockedVault +import nl.conduction.keepiq.shared.vault.VaultKeys +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap + +/** A login typed into a form while the vault was locked, held until the user unlocks or for five minutes. */ +class PendingSave( + val accountId: String, + val target: AutofillTarget, + val login: String, + val password: String, + val appLabel: String?, + val webScheme: String?, + val expiresAt: Long, +) { + override fun toString(): String = "PendingSave(target=$target)" +} + +/** + * What the autofill service and its unlock screen share in the app process + * (design D4: on Android the services run in the app process, so they see + * the app's unlocked vault): the index, app identities, the never-save list + * and logins waiting for an unlock. + */ +class AutofillCore(context: Context, storage: SecureStorage, private val state: () -> AppState) { + val index = AndroidAutofillIndex(context) + val identities = AppIdentities(context, storage) + val neverSave = NeverSaveList(storage) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val pending = ConcurrentHashMap() + private var keysFor: Pair? = null + + init { + AutofillIndexHub.sink = index + } + + /** The active account, which autofill serves. */ + fun accountId(): String? = state().client.accounts.activeId() + + fun indexOf(accountId: String): AutofillIndex = index.index(accountId) + + /** The keys of the open vault of [accountId], or null while it is locked. */ + @Synchronized + fun keys(accountId: String): VaultKeys? { + val vault = state().openVault()?.takeIf { it.accountId == accountId } ?: return null + keysFor?.let { (v, k) -> if (v === vault) return k } + // The vault's own keys, forgotten when it locks; without a certificate, read only. + val keys = try { + if (vault.certificate != null) vault.keys() else DecryptOnlyKeys(RsaPrivateKey.fromPem(vault.privateKeyPem), vault.suiteId, vault.unlockKeyEpoch) + } catch (e: Exception) { + return null + } + keysFor = vault to keys + return keys + } + + fun api(accountId: String): KeepiqApi? = state().client.accounts.account(accountId)?.let { state().client.api(it) } + + fun hold(save: PendingSave): String { + val now = System.currentTimeMillis() + pending.entries.removeIf { it.value.expiresAt < now } + val token = ByteArray(16).also { SecureRandom().nextBytes(it) }.joinToString("") { "%02x".format(it) } + pending[token] = save + return token + } + + /** The held login for [token], once: it is forgotten as it is taken. */ + fun take(token: String?): PendingSave? = + token?.let { pending.remove(it) }?.takeIf { it.expiresAt >= System.currentTimeMillis() } + + /** Unpair: the account's index goes with it. */ + fun forget(accountId: String) { + AutofillIndexHub.clear(accountId) + } + + /** Without a certificate the vault can still be read; saving needs one. */ + private class DecryptOnlyKeys(private val key: RsaPrivateKey, override val suiteId: String, override val unlockKeyEpoch: Long?) : VaultKeys { + override fun decryptField(ciphertext: String?): String = if (ciphertext.isNullOrEmpty()) "" else RsaFields.decrypt(ciphertext, key) + + override fun encryptField(plaintext: String): String = throw IllegalStateException("This vault has no certificate to encrypt to") + } + + companion object { + const val PENDING_MILLIS = 5 * 60_000L + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillSettingsActivity.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillSettingsActivity.kt new file mode 100644 index 000000000..4578d806e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillSettingsActivity.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.autofill.AutofillManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import nl.conduction.keepiq.android.KeepiqApp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.passkey.PasskeyProvider +import nl.conduction.keepiq.android.passkey.PasskeySettings +import nl.conduction.keepiq.android.ui.ScreenColumn + +/** + * Keepiq's autofill settings, which Android links from its autofill + * service settings: whether Keepiq fills, the button to choose it, the + * never-save list with a way to take a site off it, and turning autofill + * off, which deletes the index on the phone. + */ +class AutofillSettingsActivity : ComponentActivity() { + private val core: AutofillCore get() = (application as KeepiqApp).autofill + private var enabled by mutableStateOf(false) + private var never by mutableStateOf(emptyList()) + private var passkeys by mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + ScreenColumn { + Text(stringResource(R.string.autofill_settings_title), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + Text(stringResource(if (enabled) R.string.autofill_status_on else R.string.autofill_status_off), modifier = Modifier.testTag("autofillStatus")) + if (enabled) { + OutlinedButton(onClick = ::turnOff, modifier = Modifier.fillMaxWidth().testTag("autofillOff")) { Text(stringResource(R.string.autofill_turn_off)) } + } else { + Button(onClick = ::chooseService, modifier = Modifier.fillMaxWidth().testTag("autofillChoose")) { Text(stringResource(R.string.autofill_choose_service)) } + } + PasskeySettings(enabled = passkeys) + Text(stringResource(R.string.autofill_never_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + if (never.isEmpty()) Text(stringResource(R.string.autofill_never_empty)) + for (site in never) { + ListItem( + headlineContent = { Text(site) }, + trailingContent = { + TextButton(onClick = { never = core.neverSave.set(site, false) }, modifier = Modifier.testTag("neverRemove")) { + Text(stringResource(R.string.autofill_never_remove, site)) + } + }, + ) + } + } + } + } + } + } + + override fun onResume() { + super.onResume() + enabled = core.index.isAutofillService() + never = core.neverSave.sites() + passkeys = PasskeyProvider.isEnabled(this) + core.index.clearFilesIfNotTheService() + } + + private fun chooseService() = chooseKeepiq(this) + + private fun turnOff() { + getSystemService(AutofillManager::class.java)?.disableAutofillServices() + core.index.clearAll() + enabled = core.index.isAutofillService() + } + + companion object { + /** Whether Keepiq is the autofill service now. */ + fun isKeepiq(context: Context): Boolean = runCatching { + context.getSystemService(AutofillManager::class.java)?.hasEnabledAutofillServices() == true + }.getOrDefault(false) + + /** + * Android's own "use Keepiq for autofill?" question. A phone without + * it (autofill turned off by the maker) gets its settings instead. + */ + fun chooseKeepiq(context: Context) { + val ask = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).setData(Uri.parse("package:${context.packageName}")) + try { + context.startActivity(ask) + } catch (e: ActivityNotFoundException) { + context.startActivity(Intent(Settings.ACTION_SETTINGS)) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillUnlockActivity.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillUnlockActivity.kt new file mode 100644 index 000000000..419be3da3 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/AutofillUnlockActivity.kt @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.app.Activity +import android.app.PendingIntent +import android.app.assist.AssistStructure +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.view.autofill.AutofillManager +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import nl.conduction.keepiq.android.KeepiqApp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.Screen +import nl.conduction.keepiq.android.ui.ScreenColumn +import nl.conduction.keepiq.android.ui.UnlockScreen +import nl.conduction.keepiq.shared.autofill.AutofillChoices +import nl.conduction.keepiq.shared.autofill.AutofillIndexHub +import nl.conduction.keepiq.shared.autofill.AutofillSaver +import nl.conduction.keepiq.shared.autofill.AutofillTarget +import nl.conduction.keepiq.shared.autofill.LoginChoice +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultRepository + +/** + * The "Unlock Keepiq" entry (tasks 4.1 to 4.3): the app's own unlock + * screen, then the fill. With one matching login it returns that login's + * dataset, which the system fills at once; with several the user picks one + * here. In save mode it saves the login that waited for the unlock. + */ +class AutofillUnlockActivity : FragmentActivity() { + private val app: KeepiqApp get() = application as KeepiqApp + private val core: AutofillCore get() = app.autofill + private val phase = mutableStateOf(Phase.Unlock) + private var started = false + + private sealed interface Phase { + data object Unlock : Phase + data object Working : Phase + data class Choose(val choices: List, val form: ParsedForm) : Phase + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setResult(Activity.RESULT_CANCELED) + val accountId = core.accountId() ?: return finish() + val state = app.state + if (state.gate.value == null) state.refreshGate(accountId) + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + when (val p = phase.value) { + Phase.Unlock -> UnlockScreen(state, this, accountId) + Phase.Working -> ScreenColumn { CircularProgressIndicator(modifier = Modifier.testTag("autofillBusy")) } + is Phase.Choose -> Choices(p) + } + } + } + } + lifecycleScope.launch { + state.screen.collect { screen -> + if (!started && (screen is Screen.Unlocked || screen is Screen.Settings)) { + started = true + phase.value = Phase.Working + unlocked(accountId) + } + } + } + } + + private suspend fun unlocked(accountId: String) { + val keys = core.keys(accountId) ?: return finish() + withContext(Dispatchers.IO) { ensureIndex(accountId, keys) } + if (intent.getStringExtra(EXTRA_MODE) == MODE_SAVE) { + save(keys) + } else { + fill(accountId, keys) + } + } + + /** No sync has built the index yet in this process: read the vault once, which builds it. */ + private suspend fun ensureIndex(accountId: String, keys: VaultKeys) { + if (core.indexOf(accountId).entries.isNotEmpty()) return + val api = core.api(accountId) ?: return + runCatching { + VaultRepository(api, keys, null, null, { System.currentTimeMillis() }, { AutofillIndexHub.refreshed(accountId, it, keys) }) + .refresh(SyncTrigger.MANUAL) + } + } + + private suspend fun fill(accountId: String, keys: VaultKeys) { + val structure = assistStructure() ?: return finish() + val form = FormParser.parse(structure) + val (target, logins, codes) = withContext(Dispatchers.IO) { + val target = core.identities.target(form) + val index = core.indexOf(accountId) + Triple( + target, + target?.let { AutofillChoices.logins(index, it, keys) } ?: emptyList(), + target?.let { AutofillChoices.codes(index, it, keys, System.currentTimeMillis()) } ?: emptyList(), + ) + } + if (target == null) return finish() + val datasets = Datasets(this, null) + val hasLoginFields = form.username != null || form.passwords.isNotEmpty() + when { + hasLoginFields && logins.size == 1 -> deliver(dataset(datasets, form, logins[0])) + hasLoginFields && logins.size > 1 -> phase.value = Phase.Choose(logins, form) + form.oneTimeCode != null && codes.isNotEmpty() -> + deliver(datasets.filled(getString(R.string.autofill_code_from, codes[0].name), null, listOf(form.oneTimeCode!!.id to codes[0].code))) + else -> { + android.widget.Toast.makeText(this, R.string.autofill_nothing, android.widget.Toast.LENGTH_SHORT).show() + finish() + } + } + } + + private fun dataset(datasets: Datasets, form: ParsedForm, choice: LoginChoice): Dataset? = + datasets.filled(choice.login.ifEmpty { choice.name }, choice.name, KeepiqAutofillService.loginValues(form, choice.login, choice.password)) + + private fun deliver(dataset: Dataset?) { + if (dataset != null) setResult(Activity.RESULT_OK, Intent().putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset)) + finish() + } + + private suspend fun save(keys: VaultKeys) { + val pending = core.take(intent.getStringExtra(EXTRA_TOKEN)) ?: return finish() + val result = withContext(Dispatchers.IO) { + runCatching { + val api = core.api(pending.accountId) ?: return@runCatching null + AutofillSaver(api, keys).save(pending.target, core.indexOf(pending.accountId), pending.login, pending.password, pending.appLabel, pending.webScheme) + }.getOrNull() + } + KeepiqAutofillService.toast(this, result) + finish() + } + + @Suppress("DEPRECATION") + private fun assistStructure(): AssistStructure? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE, AssistStructure::class.java) + } else { + intent.getParcelableExtra(AutofillManager.EXTRA_ASSIST_STRUCTURE) + } + + @Composable + private fun Choices(p: Phase.Choose) { + ScreenColumn { + Text(stringResource(R.string.autofill_choose), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + val datasets = Datasets(this@AutofillUnlockActivity, null) + for (choice in p.choices) { + ListItem( + headlineContent = { Text(choice.login.ifEmpty { choice.name }) }, + supportingContent = { Text(choice.name) }, + modifier = Modifier.testTag("autofillChoice").clickable { deliver(dataset(datasets, p.form, choice)) }, + ) + } + } + } + + companion object { + private const val EXTRA_MODE = "nl.conduction.keepiq.autofill.MODE" + private const val EXTRA_TOKEN = "nl.conduction.keepiq.autofill.TOKEN" + private const val MODE_FILL = "fill" + private const val MODE_SAVE = "save" + private var requestCode = 0 + + /** The locked entry's authentication: the system adds the form's structure to the intent. */ + fun fillIntent(context: Context): IntentSender { + val intent = Intent(context, AutofillUnlockActivity::class.java).putExtra(EXTRA_MODE, MODE_FILL) + return PendingIntent.getActivity( + context, synchronized(this) { ++requestCode }, intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_MUTABLE, + ).intentSender + } + + /** The save offer while locked: unlock, then save the held login. */ + fun saveIntent(context: Context, token: String): IntentSender { + val intent = Intent(context, AutofillUnlockActivity::class.java).putExtra(EXTRA_MODE, MODE_SAVE).putExtra(EXTRA_TOKEN, token) + return PendingIntent.getActivity( + context, synchronized(this) { ++requestCode }, intent, + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ).intentSender + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/Datasets.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/Datasets.kt new file mode 100644 index 000000000..5f3a090a9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/Datasets.kt @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:Suppress("DEPRECATION") + +package nl.conduction.keepiq.android.autofill + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.slice.Slice +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.InlinePresentation +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.RemoteViews +import androidx.autofill.inline.v1.InlineSuggestionUi +import nl.conduction.keepiq.android.R + +/** + * The entries Keepiq shows in a form: the dropdown under the field, and on + * Android 11+ the inline suggestions in the keyboard when the keyboard + * supports them (task 4.1). + */ +class Datasets(private val context: Context, private val inline: InlineSuggestionsRequest?) { + private var inlineUsed = 0 + + /** A dropdown row: a title and an optional second line. */ + fun dropdown(title: String, subtitle: String?): RemoteViews = + RemoteViews(context.packageName, R.layout.autofill_item).apply { + setTextViewText(R.id.autofill_title, title) + if (subtitle.isNullOrEmpty()) { + setViewVisibility(R.id.autofill_subtitle, android.view.View.GONE) + } else { + setTextViewText(R.id.autofill_subtitle, subtitle) + } + } + + /** The inline chip for the next suggestion, while the keyboard has room for one. */ + @SuppressLint("NewApi") + fun inline(title: String, subtitle: String?, attribution: PendingIntent): InlinePresentation? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + val request = inline ?: return null + if (inlineUsed >= request.maxSuggestionCount) return null + val specs = request.inlinePresentationSpecs + if (specs.isEmpty()) return null + val spec = specs[minOf(inlineUsed, specs.size - 1)] + inlineUsed++ + val content = InlineSuggestionUi.newContentBuilder(attribution) + .setTitle(title) + .setStartIcon(Icon.createWithResource(context, R.drawable.ic_autofill_key)) + .setContentDescription(listOfNotNull(title, subtitle).joinToString(", ")) + if (!subtitle.isNullOrEmpty()) content.setSubtitle(subtitle) + val slice: Slice = content.build().slice + return InlinePresentation(slice, spec, false) + } + + /** What a long press on an inline chip opens: Keepiq's autofill settings. */ + fun attribution(): PendingIntent = PendingIntent.getActivity( + context, 0, Intent(context, AutofillSettingsActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + /** A dataset that fills [values] at once. */ + fun filled(title: String, subtitle: String?, values: List>): Dataset? { + if (values.isEmpty()) return null + val presentation = dropdown(title, subtitle) + val inlineChip = inline(title, subtitle, attribution()) + val builder = Dataset.Builder(presentation) + for ((id, value) in values) { + if (inlineChip != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setValue(id, AutofillValue.forText(value), presentation, inlineChip) + } else { + builder.setValue(id, AutofillValue.forText(value), presentation) + } + } + return builder.build() + } + + /** A dataset that opens [authentication] first: the locked "Unlock Keepiq" entry, without account names. */ + fun locked(ids: List, authentication: IntentSender): Dataset? { + if (ids.isEmpty()) return null + val title = context.getString(R.string.autofill_unlock) + val presentation = dropdown(title, context.getString(R.string.autofill_unlock_hint)) + val inlineChip = inline(title, null, attribution()) + val builder = Dataset.Builder(presentation) + for (id in ids) { + if (inlineChip != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + builder.setValue(id, null, presentation, inlineChip) + } else { + builder.setValue(id, null, presentation) + } + } + builder.setAuthentication(authentication) + return builder.build() + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/FormParser.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/FormParser.kt new file mode 100644 index 000000000..5a1c15559 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/FormParser.kt @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.app.assist.AssistStructure +import android.text.InputType +import android.view.View +import android.view.autofill.AutofillId +import nl.conduction.keepiq.shared.autofill.DetectedFields +import nl.conduction.keepiq.shared.autofill.FieldDetector +import nl.conduction.keepiq.shared.autofill.FieldFacts + +/** One input field of a form, with what the user typed in it (for a save request). */ +class FormField(val id: AutofillId, val facts: FieldFacts, val text: String?, val webDomain: String?, val webScheme: String?) + +/** A form as the system hands it to the service: who asks, its fields and which are which. */ +class ParsedForm( + val packageName: String, + val fields: List, + val detected: DetectedFields, +) { + /** The domain a browser or WebView reported for the form, the one nearest the login fields. */ + val webDomain: String? get() = loginFields().firstNotNullOfOrNull { it.webDomain } ?: fields.firstNotNullOfOrNull { it.webDomain } + + val webScheme: String? get() = fields.firstNotNullOfOrNull { it.webScheme } + + val username: FormField? get() = detected.username?.let { fields[it] } + val passwords: List get() = (detected.passwords + detected.newPasswords).sorted().map { fields[it] } + val oneTimeCode: FormField? get() = detected.oneTimeCode?.let { fields[it] } + + /** The password the user typed: a new password before the current one, as on a change form. */ + val typedPassword: String? + get() = (detected.newPasswords + detected.passwords).map { fields[it].text }.firstOrNull { !it.isNullOrEmpty() } + + private fun loginFields() = listOfNotNull(username, oneTimeCode) + passwords +} + +/** + * Reads an [AssistStructure] (task 4.1): every editable view, with its + * autofill hints, the HTML attributes a browser or WebView reports in + * htmlInfo, its view id, hint text and input type, and the web domain on + * the way down. + */ +object FormParser { + fun parse(structure: AssistStructure): ParsedForm { + val fields = ArrayList() + for (i in 0 until structure.windowNodeCount) { + walk(structure.getWindowNodeAt(i).rootViewNode, null, null, fields) + } + return ParsedForm(structure.activityComponent.packageName, fields, FieldDetector.detect(fields.map { it.facts })) + } + + private fun walk(node: AssistStructure.ViewNode, domain: String?, scheme: String?, out: MutableList) { + val webDomain = node.webDomain?.takeIf { it.isNotEmpty() } ?: domain + val webScheme = node.webScheme?.takeIf { it.isNotEmpty() } ?: scheme + val id = node.autofillId + val html = node.htmlInfo + val isInput = html?.tag?.equals("input", ignoreCase = true) == true + if (id != null && (node.autofillType == View.AUTOFILL_TYPE_TEXT || isInput)) { + val attributes = html?.attributes?.associate { it.first.lowercase() to (it.second ?: "") } ?: emptyMap() + val inputType = node.inputType + val variation = inputType and InputType.TYPE_MASK_VARIATION + val klass = inputType and InputType.TYPE_MASK_CLASS + val password = (klass == InputType.TYPE_CLASS_TEXT && variation in PASSWORD_TEXT_VARIATIONS) || + (klass == InputType.TYPE_CLASS_NUMBER && variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD) + val facts = FieldFacts( + autofillHints = node.autofillHints?.toList() ?: emptyList(), + htmlTag = html?.tag, + htmlAttributes = attributes, + idEntry = node.idEntry, + hint = node.hint, + passwordInput = password, + numberInput = klass == InputType.TYPE_CLASS_NUMBER || klass == InputType.TYPE_CLASS_PHONE, + editable = node.isEnabled && (node.autofillType == View.AUTOFILL_TYPE_TEXT), + visible = node.visibility == View.VISIBLE, + ) + val text = node.autofillValue?.takeIf { it.isText }?.textValue?.toString() + out += FormField(id, facts, text, webDomain, webScheme) + } + for (i in 0 until node.childCount) walk(node.getChildAt(i), webDomain, webScheme, out) + } + + private val PASSWORD_TEXT_VARIATIONS = setOf( + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + ) +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/KeepiqAutofillService.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/KeepiqAutofillService.kt new file mode 100644 index 000000000..253d0aebd --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/KeepiqAutofillService.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveInfo +import android.service.autofill.SaveRequest +import android.util.Log +import android.widget.Toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import nl.conduction.keepiq.android.KeepiqApp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.shared.autofill.AutofillChoices +import nl.conduction.keepiq.shared.autofill.AutofillSaver +import nl.conduction.keepiq.shared.autofill.AutofillTarget +import nl.conduction.keepiq.shared.autofill.SaveResult + +/** + * Keepiq as the Android autofill service (mobile-system-autofill, tasks + * 4.1 to 4.3): + * + * - finds user name, password and one-time-code fields ([FormParser]); + * - matches the website a trusted browser reports, or the app by package + * and signing certificate plus its Digital Asset Links websites; + * - unlocked: one entry per matched login, or per current code; locked: + * one "Unlock Keepiq" entry without account names, which opens + * [AutofillUnlockActivity] and fills after the unlock; + * - offers to save what the user typed, unless the site or app is on the + * never-save list, with "Never" adding it there. + */ +class KeepiqAutofillService : AutofillService() { + private val core: AutofillCore get() = (application as KeepiqApp).autofill + + override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) { + val structure = request.fillContexts.lastOrNull()?.structure ?: return callback.onSuccess(null) + val form = FormParser.parse(structure) + if (form.packageName == packageName || form.detected.isEmpty) return callback.onSuccess(null) + val inline = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) request.inlineSuggestionsRequest else null + val job = core.scope.launch { + val response = try { + respond(form, Datasets(this@KeepiqAutofillService, inline)) + } catch (e: Exception) { + Log.w(TAG, "fill request failed", e) + null + } + withContext(Dispatchers.Main) { + runCatching { callback.onSuccess(response) } + } + } + cancellationSignal.setOnCancelListener { job.cancel() } + } + + private fun respond(form: ParsedForm, datasets: Datasets): FillResponse? { + val accountId = core.accountId() ?: return null + val target = core.identities.target(form) ?: return null + val index = core.indexOf(accountId) + val loginIds = listOfNotNull(form.username?.id) + form.passwords.map { it.id } + val code = form.oneTimeCode + val keys = core.keys(accountId) + val builder = FillResponse.Builder() + var any = false + + if (keys == null) { + // Locked: one entry, no names, only when something matches. + val hasLogin = loginIds.isNotEmpty() && index.candidates(target).isNotEmpty() + val hasCode = code != null && index.candidates(target, totp = true).isNotEmpty() + if (hasLogin || hasCode) { + val ids = (if (hasLogin) loginIds else emptyList()) + listOfNotNull(code?.id?.takeIf { hasCode }) + datasets.locked(ids, AutofillUnlockActivity.fillIntent(this))?.let { + builder.addDataset(it) + any = true + } + } + } else { + if (loginIds.isNotEmpty()) { + for (choice in AutofillChoices.logins(index, target, keys)) { + datasets.filled(choice.login.ifEmpty { choice.name }, choice.name, loginValues(form, choice.login, choice.password)) + ?.let { builder.addDataset(it); any = true } + } + } + if (code != null) { + for (choice in AutofillChoices.codes(index, target, keys, System.currentTimeMillis())) { + datasets.filled(getString(R.string.autofill_code_from, choice.name), null, listOf(code.id to choice.code)) + ?.let { builder.addDataset(it); any = true } + } + } + } + + val saveInfo = saveInfo(form, target, accountId) + if (saveInfo != null) { + builder.setSaveInfo(saveInfo) + any = true + } + return if (any) builder.build() else null + } + + /** The save offer: for a form with a password field, unless the site or app is on the never-save list. */ + private fun saveInfo(form: ParsedForm, target: AutofillTarget, accountId: String): SaveInfo? { + val passwords = form.passwords.map { it.id } + if (passwords.isEmpty() || core.neverSave.contains(target.saveKey)) return null + if (core.indexOf(accountId).blocksSave(target)) return null + val type = SaveInfo.SAVE_DATA_TYPE_PASSWORD or (if (form.username != null) SaveInfo.SAVE_DATA_TYPE_USERNAME else 0) + val builder = SaveInfo.Builder(type, passwords.toTypedArray()) + .apply { form.username?.let { setOptionalIds(arrayOf(it.id)) } } + .setFlags(SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE) + // The "Never" button exists from Android 11. Before that the offer + // has the system's own "Not now", and the list is kept in Keepiq's + // autofill settings only. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val never = PendingIntent.getBroadcast( + this, target.saveKey.hashCode(), + Intent(this, NeverSaveReceiver::class.java).putExtra(NeverSaveReceiver.EXTRA_SITE, target.saveKey), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + builder.setNegativeAction(SaveInfo.NEGATIVE_BUTTON_STYLE_NEVER, never.intentSender) + } + return builder.build() + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + val structure = request.fillContexts.lastOrNull()?.structure ?: return callback.onSuccess() + val form = FormParser.parse(structure) + val password = form.typedPassword + val accountId = core.accountId() + if (password.isNullOrEmpty() || accountId == null) return callback.onSuccess() + val login = form.username?.text ?: "" + val keys = core.keys(accountId) + if (keys == null) { + // Locked: hold the login in memory and ask for the unlock first. + core.scope.launch { + val target = core.identities.target(form) + withContext(Dispatchers.Main) { + if (target == null || core.neverSave.contains(target.saveKey)) return@withContext callback.onSuccess() + val token = core.hold( + PendingSave(accountId, target, login, password, core.identities.label(form.packageName), form.webScheme, System.currentTimeMillis() + AutofillCore.PENDING_MILLIS), + ) + callback.onSuccess(AutofillUnlockActivity.saveIntent(this@KeepiqAutofillService, token)) + } + } + return + } + callback.onSuccess() + core.scope.launch { + val result = try { + val target = core.identities.target(form) ?: return@launch + if (core.neverSave.contains(target.saveKey)) return@launch + val api = core.api(accountId) ?: return@launch + AutofillSaver(api, keys).save(target, core.indexOf(accountId), login, password, core.identities.label(form.packageName), form.webScheme) + } catch (e: Exception) { + Log.w(TAG, "save failed", e) + null + } + withContext(Dispatchers.Main) { toast(this@KeepiqAutofillService, result) } + } + } + + companion object { + private const val TAG = "KeepiqAutofill" + + /** What saving did, in words. */ + fun toast(context: android.content.Context, result: SaveResult?) { + val text = when (result) { + SaveResult.SAVED -> R.string.autofill_saved + SaveResult.UPDATED -> R.string.autofill_updated + SaveResult.UNCHANGED, SaveResult.REFUSED -> return + null -> R.string.autofill_save_failed + } + Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + + /** The login and password values for the fields a form has. */ + fun loginValues(form: ParsedForm, login: String, password: String): List> = + listOfNotNull(form.username?.id?.takeIf { login.isNotEmpty() }?.let { it to login }) + form.passwords.map { it.id to password } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/NeverSaveReceiver.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/NeverSaveReceiver.kt new file mode 100644 index 000000000..8524bc7f7 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/autofill/NeverSaveReceiver.kt @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.autofill + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import nl.conduction.keepiq.android.KeepiqApp + +/** "Never" in the system's save offer: the site or app goes on the never-save list (task 4.2). */ +class NeverSaveReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val site = intent.getStringExtra(EXTRA_SITE) ?: return + (context.applicationContext as KeepiqApp).autofill.neverSave.set(site, true) + } + + companion object { + const val EXTRA_SITE = "nl.conduction.keepiq.autofill.SITE" + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialCaller.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialCaller.kt new file mode 100644 index 000000000..02724a224 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialCaller.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.passkey + +import android.content.Context +import android.content.pm.Signature +import android.util.Base64 +import android.util.Log +import androidx.credentials.provider.CallingAppInfo +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.autofill.AppIdentities +import nl.conduction.keepiq.shared.autofill.AppIdentity +import nl.conduction.keepiq.shared.autofill.AutofillTarget +import nl.conduction.keepiq.shared.autofill.SiteMatch +import nl.conduction.keepiq.shared.passkey.Passkeys +import java.security.MessageDigest + +/** + * Who asks Credential Manager for a credential, from what the system + * verified (design D7, "The relying party comes from the operating + * system"): the calling package and its signing certificates, and for a + * browser the web origin it set, which counts only when the browser is on + * the privileged allowlist with its certificate. + */ +sealed class CredentialCaller { + abstract val identity: AppIdentity + + /** The origin the clientDataJSON names. */ + abstract val origin: String + + /** A browser on the allowlist, asking for [origin], a web origin. */ + data class Browser(override val identity: AppIdentity, override val origin: String) : CredentialCaller() + + /** An app, asking for itself: origin `android:apk-key-hash:`. */ + data class App(override val identity: AppIdentity, override val origin: String) : CredentialCaller() + + companion object { + private const val TAG = "KeepiqPasskeys" + + /** The allowlist of browsers whose origin Keepiq believes (res/raw/privileged_browsers.json). */ + fun allowlist(context: Context): String = + context.resources.openRawResource(R.raw.privileged_browsers).use { it.readBytes().toString(Charsets.UTF_8) } + + /** + * The caller, or null when it cannot be trusted: a package that sets + * an origin without being an allowlisted browser is refused outright. + */ + fun of(context: Context, info: CallingAppInfo): CredentialCaller? { + val signing = info.signingInfo + val signatures: Array = if (signing.hasMultipleSigners()) signing.apkContentsSigners else signing.signingCertificateHistory + if (signatures.isEmpty()) return null + val identity = AppIdentity(info.packageName, signatures.map { AppIdentities.fingerprint(it.toByteArray()) }.toSet()) + if (info.isOriginPopulated()) { + val origin = try { + info.getOrigin(allowlist(context)) + } catch (e: IllegalStateException) { + Log.w(TAG, "${info.packageName} set an origin but is not an allowlisted browser") + return null + } ?: return null + return Browser(identity, origin.trimEnd('/')) + } + // The current certificate: the last of a rotation history, the first of several signers. + val current = if (signing.hasMultipleSigners()) signatures.first() else signatures.last() + val hash = MessageDigest.getInstance("SHA-256").digest(current.toByteArray()) + return App(identity, "android:apk-key-hash:" + Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)) + } + } + + /** + * Whether this caller may use passkeys of [rpId]. A browser: the rpId + * is its origin's host or a parent of it (the extension's rp.js rule). + * An app: the site at [rpId] lists the app in its assetlinks.json + * (Digital Asset Links), which costs a network request; call it off the + * main thread. + */ + fun mayUse(rpId: String, identities: AppIdentities): Boolean = when (this) { + is Browser -> Passkeys.rpIdAllowed(rpId, origin) + is App -> rpId.isNotEmpty() && identities.siteAllows("https://" + rpId.lowercase(), identity) + } + + /** Where this caller's passwords come from: the site of a browser's origin, or the app with its verified sites. */ + fun passwordTarget(identities: AppIdentities): AutofillTarget = when (this) { + is Browser -> AutofillTarget.Web(SiteMatch.hostOf(origin)) + is App -> AutofillTarget.App(identity, identities.verifiedHosts(identity)) + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialEntries.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialEntries.kt new file mode 100644 index 000000000..1b84e7d95 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialEntries.kt @@ -0,0 +1,159 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.passkey + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Icon +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.credentials.provider.AuthenticationAction +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePasswordCredentialRequest +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.PasswordCredentialEntry +import androidx.credentials.provider.PublicKeyCredentialEntry +import nl.conduction.keepiq.android.KeepiqApp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.autofill.AutofillCore +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.autofill.SiteMatch +import nl.conduction.keepiq.shared.passkey.Passkeys +import nl.conduction.keepiq.shared.passkey.WebAuthnJson +import nl.conduction.keepiq.shared.vault.VaultKeys + +/** + * What Keepiq offers in Android's Credential Manager sheet (task 5.1), + * built for the service's Begin calls and again for the unlock screen. + * + * - Unlocked: one entry per matching passkey and password, by user name. + * A pick opens [CredentialProviderActivity], which signs or fills. + * - Locked: one "Unlock Keepiq" action, and no names, when the index on + * the phone has something for the request or nothing at all yet. + * - A passkey request whose rpId the caller may not use, or that asks for + * another algorithm, gets nothing, so the system offers another provider. + * + * Network: the rpId of an app is checked with Digital Asset Links. Call + * these off the main thread. + */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CredentialEntries(private val context: Context, private val app: KeepiqApp) { + private val core: AutofillCore get() = app.autofill + + fun beginGet(request: BeginGetCredentialRequest): BeginGetCredentialResponse { + val caller = request.callingAppInfo?.let { CredentialCaller.of(context, it) } ?: return EMPTY_GET + val accountId = core.accountId() ?: return EMPTY_GET + val keys = core.keys(accountId) + val index = core.indexOf(accountId) + if (keys == null) { + val known = index.entries.isEmpty() || request.beginGetCredentialOptions.any { option -> mayMatch(option, caller, index) } + return if (known) BeginGetCredentialResponse(authenticationActions = listOf(unlockAction())) else EMPTY_GET + } + return BeginGetCredentialResponse(credentialEntries = entries(request, caller, index, keys)) + } + + /** The entries for an unlocked vault. */ + fun entries(request: BeginGetCredentialRequest, caller: CredentialCaller, index: AutofillIndex, keys: VaultKeys): List { + val out = mutableListOf() + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + val get = WebAuthnJson.get(option.requestJson) ?: continue + val rpId = rpIdOf(get.rpId, caller) ?: continue + if (!caller.mayUse(rpId, core.identities)) continue + for (choice in Passkeys.candidates(index, rpId, keys, get.allowCredentialIds)) { + val intent = CredentialProviderActivity.intent(context, CredentialProviderActivity.MODE_PASSKEY, choice.itemId) + out += PublicKeyCredentialEntry.Builder(context, choice.label, pending(intent), option) + .setDisplayName(choice.name) + .setIcon(icon()) + .build() + } + } + is BeginGetPasswordOption -> { + val target = caller.passwordTarget(core.identities) + for (entry in index.candidates(target)) { + val user = runCatching { keys.decryptField(entry.login) }.getOrNull() ?: continue + val intent = CredentialProviderActivity.intent(context, CredentialProviderActivity.MODE_PASSWORD, entry.id) + out += PasswordCredentialEntry.Builder(context, user.ifEmpty { entry.name }, pending(intent), option) + .setDisplayName(entry.name) + .setIcon(icon()) + .build() + } + } + else -> Unit + } + } + return out + } + + fun beginCreate(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse { + val caller = request.callingAppInfo?.let { CredentialCaller.of(context, it) } ?: return EMPTY_CREATE + val accountId = core.accountId() ?: return EMPTY_CREATE + val account = app.state.client.accounts.account(accountId) + ?.let { "${it.loginName} · ${it.server.removePrefix("https://").trimEnd('/')}" } ?: return EMPTY_CREATE + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + val create = WebAuthnJson.creation(request.requestJson) ?: return EMPTY_CREATE + if (!nl.conduction.keepiq.shared.crypto.WebAuthn.supports(create.algorithms)) return EMPTY_CREATE + if (!caller.mayUse(create.rpId, core.identities)) return EMPTY_CREATE + } + is BeginCreatePasswordCredentialRequest -> Unit + else -> return EMPTY_CREATE + } + val intent = CredentialProviderActivity.intent(context, CredentialProviderActivity.MODE_CREATE, null) + val entry = CreateEntry.Builder(account, pending(intent)) + .setDescription(context.getString(R.string.passkey_create_description)) + .setIcon(icon()) + .build() + return BeginCreateCredentialResponse(createEntries = listOf(entry)) + } + + /** + * The rpId of a get request: the one it names, else the host of a + * browser's origin. An app must name one. + */ + private fun rpIdOf(named: String?, caller: CredentialCaller): String? = + named?.takeIf { it.isNotEmpty() } ?: (caller as? CredentialCaller.Browser)?.let { SiteMatch.hostOf(it.origin) }?.takeIf { it.isNotEmpty() } + + /** Whether the locked index holds something for [option], without decrypting or asking the network. */ + private fun mayMatch(option: androidx.credentials.provider.BeginGetCredentialOption, caller: CredentialCaller, index: AutofillIndex): Boolean = when (option) { + is BeginGetPublicKeyCredentialOption -> { + val rpId = rpIdOf(WebAuthnJson.get(option.requestJson)?.rpId, caller) + rpId != null && index.entries.any { it.isPasskey && SiteMatch.registrableDomain(it.url ?: "") == SiteMatch.registrableDomain(rpId) } + } + is BeginGetPasswordOption -> index.candidates(caller.passwordTarget(core.identities)).isNotEmpty() + else -> false + } + + private fun unlockAction(): AuthenticationAction { + val intent = CredentialProviderActivity.intent(context, CredentialProviderActivity.MODE_UNLOCK, null) + return AuthenticationAction(context.getString(R.string.autofill_unlock), pending(intent)) + } + + private fun pending(intent: Intent): PendingIntent = PendingIntent.getActivity( + context, + nextRequestCode(), + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + private fun icon(): Icon = Icon.createWithResource(context, R.drawable.ic_autofill_key) + + companion object { + private val EMPTY_GET = BeginGetCredentialResponse() + private val EMPTY_CREATE = BeginCreateCredentialResponse() + private var requestCode = 0 + + @Synchronized + private fun nextRequestCode(): Int = ++requestCode + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialProviderActivity.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialProviderActivity.kt new file mode 100644 index 000000000..ac0f4720d --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/CredentialProviderActivity.kt @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.passkey + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.credentials.CreatePasswordRequest +import androidx.credentials.CreatePasswordResponse +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.exceptions.NoCredentialException +import androidx.credentials.exceptions.domerrors.InvalidStateError +import androidx.credentials.exceptions.domerrors.NotAllowedError +import androidx.credentials.exceptions.domerrors.NotSupportedError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.PendingIntentHandler +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import nl.conduction.keepiq.android.KeepiqApp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.Screen +import nl.conduction.keepiq.android.autofill.AutofillCore +import nl.conduction.keepiq.android.ui.ScreenColumn +import nl.conduction.keepiq.android.ui.UnlockScreen +import nl.conduction.keepiq.shared.autofill.AutofillChoices +import nl.conduction.keepiq.shared.autofill.AutofillIndexHub +import nl.conduction.keepiq.shared.autofill.AutofillSaver +import nl.conduction.keepiq.shared.autofill.SaveResult +import nl.conduction.keepiq.shared.autofill.SiteMatch +import nl.conduction.keepiq.shared.crypto.ClientData +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.WebAuthn +import nl.conduction.keepiq.shared.passkey.ExcludedCredentialException +import nl.conduction.keepiq.shared.passkey.Passkeys +import nl.conduction.keepiq.shared.passkey.WebAuthnJson +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultRepository + +/** + * What happens after a pick in Android's Credential Manager sheet (task + * 5.1): the app's own unlock screen when the vault is locked, then + * + * - [MODE_UNLOCK]: the entries for the request, now with names; + * - [MODE_PASSKEY]: the assertion with the picked passkey, its counter + * written back as the extension does; + * - [MODE_PASSWORD]: the picked login; + * - [MODE_CREATE]: a new passkey or password saved in the vault. + * + * The caller and its rpId are checked again here from what the system + * hands over, so nothing rests on the earlier Begin call. + */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CredentialProviderActivity : FragmentActivity() { + private val app: KeepiqApp get() = application as KeepiqApp + private val core: AutofillCore get() = app.autofill + private val unlocking = mutableStateOf(true) + private var started = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setResult(Activity.RESULT_CANCELED) + val accountId = core.accountId() ?: return finish() + val state = app.state + if (state.gate.value == null) state.refreshGate(accountId) + setContent { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + if (unlocking.value) { + UnlockScreen(state, this, accountId) + } else { + ScreenColumn { + Text(stringResource(R.string.passkey_working), style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + CircularProgressIndicator(modifier = Modifier.testTag("passkeyBusy")) + } + } + } + } + } + lifecycleScope.launch { + state.screen.collect { screen -> + if (!started && (screen is Screen.Unlocked || screen is Screen.Settings)) { + started = true + unlocking.value = false + val result = withContext(Dispatchers.IO) { answer(accountId) } + if (result != null) setResult(Activity.RESULT_OK, result) + finish() + } + } + } + } + + /** The result intent for the system, or null to cancel. */ + private suspend fun answer(accountId: String): Intent? { + val keys = core.keys(accountId) ?: return null + val mode = intent.getStringExtra(EXTRA_MODE) + return try { + when (mode) { + MODE_UNLOCK -> unlocked(accountId, keys) + MODE_PASSKEY -> passkey(accountId, keys) + MODE_PASSWORD -> password(accountId, keys) + MODE_CREATE -> create(accountId, keys) + else -> null + } + } catch (e: Exception) { + Log.w(TAG, "$mode failed: ${e.javaClass.simpleName}") + Intent().also { out -> + if (mode == MODE_CREATE) { + PendingIntentHandler.setCreateCredentialException(out, createError(e)) + } else { + PendingIntentHandler.setGetCredentialException(out, GetCredentialUnknownException(e.javaClass.simpleName)) + } + } + } + } + + private suspend fun unlocked(accountId: String, keys: VaultKeys): Intent? { + val request = PendingIntentHandler.retrieveBeginGetCredentialRequest(intent) ?: return null + val caller = request.callingAppInfo?.let { CredentialCaller.of(this, it) } ?: return null + refreshIndex(accountId, keys, always = false) + val list = CredentialEntries(this, app).entries(request, caller, core.indexOf(accountId), keys) + return Intent().also { PendingIntentHandler.setBeginGetCredentialResponse(it, BeginGetCredentialResponse(credentialEntries = list)) } + } + + private suspend fun passkey(accountId: String, keys: VaultKeys): Intent { + val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) ?: return getError(GetCredentialUnknownException("no request")) + val caller = CredentialCaller.of(this, request.callingAppInfo) ?: return getError(NoCredentialException()) + val option = request.credentialOptions.filterIsInstance().firstOrNull() ?: return getError(NoCredentialException()) + val get = WebAuthnJson.get(option.requestJson) ?: return getError(NoCredentialException()) + val rpId = get.rpId?.takeIf { it.isNotEmpty() } ?: (caller as? CredentialCaller.Browser)?.let { SiteMatch.hostOf(it.origin) } ?: "" + if (!caller.mayUse(rpId, core.identities)) return getError(NoCredentialException()) + refreshIndex(accountId, keys, always = false) + val itemId = intent.getStringExtra(EXTRA_ITEM) + val choice = Passkeys.candidates(core.indexOf(accountId), rpId, keys, get.allowCredentialIds).firstOrNull { it.itemId == itemId } + ?: return getError(NoCredentialException()) + val clientData = option.clientDataHash?.let { ClientData.hashed(it) } ?: ClientData.build(ClientData.GET, get.challenge, caller.origin) + val api = core.api(accountId) ?: return getError(GetCredentialUnknownException("no account")) + val assertion = Passkeys.sign(api, keys, choice, clientData, rpId) + // The index holds the item as it was: read it again, so the next sign starts from the new counter. + if (assertion.counter > 0) refreshIndex(accountId, keys, always = true) + return Intent().also { + PendingIntentHandler.setGetCredentialResponse(it, GetCredentialResponse(PublicKeyCredential(WebAuthnJson.assertionResponse(assertion)))) + } + } + + private suspend fun password(accountId: String, keys: VaultKeys): Intent { + val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) ?: return getError(GetCredentialUnknownException("no request")) + if (request.credentialOptions.none { it is GetPasswordOption }) return getError(NoCredentialException()) + val caller = CredentialCaller.of(this, request.callingAppInfo) ?: return getError(NoCredentialException()) + refreshIndex(accountId, keys, always = false) + val itemId = intent.getStringExtra(EXTRA_ITEM) + val choice = AutofillChoices.logins(core.indexOf(accountId), caller.passwordTarget(core.identities), keys).firstOrNull { it.id == itemId } + ?: return getError(NoCredentialException()) + return Intent().also { PendingIntentHandler.setGetCredentialResponse(it, GetCredentialResponse(PasswordCredential(choice.login, choice.password))) } + } + + private suspend fun create(accountId: String, keys: VaultKeys): Intent { + val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + ?: return createErrorIntent(CreateCredentialUnknownException("no request")) + val caller = CredentialCaller.of(this, request.callingAppInfo) + ?: return createErrorIntent(CreatePublicKeyCredentialDomException(NotAllowedError(), "caller not verified")) + val api = core.api(accountId) ?: return createErrorIntent(CreateCredentialUnknownException("no account")) + refreshIndex(accountId, keys, always = false) + val index = core.indexOf(accountId) + return when (val call = request.callingRequest) { + is CreatePublicKeyCredentialRequest -> { + val create = WebAuthnJson.creation(call.requestJson) + ?: return createErrorIntent(CreatePublicKeyCredentialDomException(NotSupportedError(), "request not readable")) + if (!caller.mayUse(create.rpId, core.identities)) { + return createErrorIntent(CreatePublicKeyCredentialDomException(NotAllowedError(), "rpId not verified for this caller")) + } + val clientData = call.clientDataHash?.let { ClientData.hashed(it) } + ?: ClientData.build(ClientData.CREATE, WebAuthnJson.challenge(call.requestJson) ?: ByteArray(0), caller.origin) + val registration = Passkeys.create(api, keys, index, create, clientData, System.currentTimeMillis()) + refreshIndex(accountId, keys, always = true) + Intent().also { + PendingIntentHandler.setCreateCredentialResponse(it, CreatePublicKeyCredentialResponse(WebAuthnJson.registrationResponse(registration))) + } + } + is CreatePasswordRequest -> { + val label = core.identities.label(caller.identity.packageName) + val saved = AutofillSaver(api, keys).save(caller.passwordTarget(core.identities), index, call.id, call.password, label, null) + if (saved == SaveResult.REFUSED) return createErrorIntent(CreateCredentialUnknownException("refused")) + refreshIndex(accountId, keys, always = true) + Intent().also { PendingIntentHandler.setCreateCredentialResponse(it, CreatePasswordResponse()) } + } + else -> createErrorIntent(CreateCredentialUnknownException("unsupported type")) + } + } + + /** + * Reads the vault again so the index has what the server has. With + * [always] false only when no sync built the index in this process yet. + */ + private suspend fun refreshIndex(accountId: String, keys: VaultKeys, always: Boolean) { + if (!always && core.indexOf(accountId).entries.isNotEmpty()) return + val api = core.api(accountId) ?: return + runCatching { + VaultRepository(api, keys, null, null, { System.currentTimeMillis() }, { AutofillIndexHub.refreshed(accountId, it, keys) }) + .refresh(SyncTrigger.MANUAL) + } + } + + private fun getError(e: GetCredentialException): Intent = Intent().also { PendingIntentHandler.setGetCredentialException(it, e) } + + private fun createErrorIntent(e: CreateCredentialException): Intent = Intent().also { PendingIntentHandler.setCreateCredentialException(it, e) } + + private fun createError(e: Exception): CreateCredentialException = when { + e is ExcludedCredentialException -> CreatePublicKeyCredentialDomException(InvalidStateError(), e.message) + e is KeepiqCryptoException && e.message == WebAuthn.UNSUPPORTED_ALGORITHM -> CreatePublicKeyCredentialDomException(NotSupportedError(), e.message) + else -> CreateCredentialUnknownException(e.javaClass.simpleName) + } + + companion object { + private const val TAG = "KeepiqPasskeys" + private const val EXTRA_MODE = "nl.conduction.keepiq.passkey.MODE" + private const val EXTRA_ITEM = "nl.conduction.keepiq.passkey.ITEM" + const val MODE_UNLOCK = "unlock" + const val MODE_PASSKEY = "passkey" + const val MODE_PASSWORD = "password" + const val MODE_CREATE = "create" + + fun intent(context: Context, mode: String, itemId: String?): Intent = + Intent(context, CredentialProviderActivity::class.java) + .putExtra(EXTRA_MODE, mode) + .apply { if (itemId != null) putExtra(EXTRA_ITEM, itemId) } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/KeepiqCredentialProviderService.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/KeepiqCredentialProviderService.kt new file mode 100644 index 000000000..72f9dd2b1 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/KeepiqCredentialProviderService.kt @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.passkey + +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.KeepiqApp + +/** + * Keepiq in Android's Credential Manager (task 5.1, Android 14 and later): + * passkeys and passwords, from androidx.credentials alone, without + * credentials-play-services-auth. The manifest declares it for every + * version, but Android starts a credential provider on 14 and later only. + * + * The Begin calls run off the main thread (an app's rpId is checked with + * Digital Asset Links) and answer with entries or an unlock action; the + * pick runs in [CredentialProviderActivity]. + */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class KeepiqCredentialProviderService : CredentialProviderService() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val entries by lazy { CredentialEntries(this, application as KeepiqApp) } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + scope.launch { + val response = try { + entries.beginGet(request) + } catch (e: Exception) { + Log.w(TAG, "get request failed: ${e.javaClass.simpleName}") + callback.onError(GetCredentialUnknownException(e.javaClass.simpleName)) + return@launch + } + if (!cancellationSignal.isCanceled) callback.onResult(response) + } + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + scope.launch { + val response = try { + entries.beginCreate(request) + } catch (e: Exception) { + Log.w(TAG, "create request failed: ${e.javaClass.simpleName}") + callback.onError(CreateCredentialUnknownException(e.javaClass.simpleName)) + return@launch + } + if (!cancellationSignal.isCanceled) callback.onResult(response) + } + } + + /** Keepiq keeps no sign-in state for a caller, so there is nothing to clear. */ + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + callback.onResult(null) + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private companion object { + const val TAG = "KeepiqPasskeys" + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/PasskeySettings.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/PasskeySettings.kt new file mode 100644 index 000000000..d91365f39 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/passkey/PasskeySettings.kt @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.passkey + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import nl.conduction.keepiq.android.R + +/** Whether Keepiq is an enabled Credential Manager provider (Android 14 and later). */ +object PasskeyProvider { + fun isEnabled(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return false + return runCatching { + context.getSystemService(android.credentials.CredentialManager::class.java) + ?.isEnabledCredentialProviderService(ComponentName(context, KeepiqCredentialProviderService::class.java)) == true + }.getOrDefault(false) + } + + /** Android's own screen for choosing password and passkey providers. */ + fun openSettings(context: Context) { + try { + context.startActivity(Intent("android.settings.CREDENTIAL_PROVIDER").setData(Uri.parse("package:${context.packageName}"))) + } catch (e: ActivityNotFoundException) { + context.startActivity(Intent(android.provider.Settings.ACTION_SETTINGS)) + } + } +} + +/** + * The passkeys part of Keepiq's autofill settings (mobile-passkey-provider, + * "Android before version 14"): on 14 and later whether Keepiq is the + * provider and the way to choose it; before 14 that passkeys need Android + * 14 while passwords and codes still fill. + */ +@Composable +fun PasskeySettings(sdk: Int = Build.VERSION.SDK_INT, enabled: Boolean) { + val context = LocalContext.current + Text(stringResource(R.string.passkeys_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + when { + sdk < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> + Text(stringResource(R.string.passkeys_needs_android_14), modifier = Modifier.testTag("passkeyStatus")) + enabled -> Text(stringResource(R.string.passkeys_status_on), modifier = Modifier.testTag("passkeyStatus")) + else -> { + Text(stringResource(R.string.passkeys_status_off), modifier = Modifier.testTag("passkeyStatus")) + Button(onClick = { PasskeyProvider.openSettings(context) }, modifier = Modifier.fillMaxWidth().testTag("passkeyChoose")) { + Text(stringResource(R.string.passkeys_choose)) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/BiometricUnlock.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/BiometricUnlock.kt new file mode 100644 index 000000000..575600157 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/BiometricUnlock.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.security + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import java.security.KeyStore +import java.security.UnrecoverableKeyException +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** Biometric unlock cannot be used now; the message says why. */ +class BiometricUnavailableException(message: String) : Exception(message) + +/** A new fingerprint or face was enrolled, so the wrap is gone (design D4). */ +class BiometricInvalidatedException : Exception( + "A fingerprint or face was added to this phone. Unlock with your master password, then turn biometric unlock on again.", +) + +/** The user chose the master password in the prompt, or cancelled it. */ +class BiometricCancelledException : Exception("Biometric unlock cancelled.") + +/** + * Biometric unlock on Android (design D4): the 32-byte unlock key is + * encrypted with an AES key in the AndroidKeyStore that + * + * - needs a strong biometric for every use (`setUserAuthenticationRequired`), + * opened through [BiometricPrompt] with a [BiometricPrompt.CryptoObject]; + * - is invalidated when a biometric is enrolled + * (`setInvalidatedByBiometricEnrollment`); + * - lives in StrongBox when the phone has it. + * + * The encrypted unlock key is kept in [KeystoreStorage], so it is sealed + * twice and never readable without the biometric. + */ +class BiometricUnlock(private val context: Context, private val storage: KeystoreStorage) { + fun canUse(): Boolean = BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS + + fun isEnabled(accountId: String): Boolean = storage.read(blobKey(accountId)) != null + + /** Wraps [unlockKey] after a biometric prompt. */ + suspend fun enable(activity: FragmentActivity, accountId: String, unlockKey: ByteArray) { + if (!canUse()) throw BiometricUnavailableException("Set up a fingerprint or face unlock in the phone settings first.") + disable(accountId) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, createKey(accountId)) + val authenticated = prompt(activity, cipher, "Turn on biometric unlock") + val sealed = authenticated.iv + authenticated.doFinal(unlockKey) + storage.write(blobKey(accountId), Base64.encodeToString(sealed, Base64.NO_WRAP)) + } + + /** The unlock key, after a biometric prompt. */ + suspend fun unlockKey(activity: FragmentActivity, accountId: String): ByteArray { + val sealed = storage.read(blobKey(accountId))?.let { Base64.decode(it, Base64.NO_WRAP) } + ?: throw BiometricUnavailableException("Biometric unlock is off for this account.") + val cipher = Cipher.getInstance(TRANSFORMATION) + try { + val key = keyStore().getKey(alias(accountId), null) as? SecretKey ?: throw KeyPermanentlyInvalidatedException() + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, IV_LENGTH)) + } catch (e: KeyPermanentlyInvalidatedException) { + disable(accountId) + throw BiometricInvalidatedException() + } catch (e: UnrecoverableKeyException) { + disable(accountId) + throw BiometricInvalidatedException() + } + val authenticated = prompt(activity, cipher, "Unlock Keepiq") + return authenticated.doFinal(sealed, IV_LENGTH, sealed.size - IV_LENGTH) + } + + /** Deletes the wrap and its key, on unpair, on an epoch change and when the user turns it off. */ + fun disable(accountId: String) { + storage.delete(blobKey(accountId)) + runCatching { keyStore().deleteEntry(alias(accountId)) } + } + + private suspend fun prompt(activity: FragmentActivity, cipher: Cipher, title: String): Cipher = + suspendCancellableCoroutine { continuation -> + val prompt = BiometricPrompt( + activity, + ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val c = result.cryptoObject?.cipher + if (c == null) { + continuation.resumeWithException(BiometricUnavailableException("The biometric prompt returned no key.")) + } else { + continuation.resume(c) + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (!continuation.isActive) return + val cancelled = errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_CANCELED + continuation.resumeWithException( + if (cancelled) BiometricCancelledException() else BiometricUnavailableException(errString.toString()), + ) + } + }, + ) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setNegativeButtonText("Use master password") + .setAllowedAuthenticators(BIOMETRIC_STRONG) + .build() + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + continuation.invokeOnCancellation { prompt.cancelAuthentication() } + } + + private fun createKey(accountId: String): SecretKey { + fun spec(strongBox: Boolean) = KeyGenParameterSpec.Builder( + alias(accountId), + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) + } + if (strongBox) setIsStrongBoxBacked(true) + } + .build() + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KeystoreStorage.ANDROID_KEYSTORE) + val hasStrongBox = context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + if (hasStrongBox) { + try { + generator.init(spec(strongBox = true)) + return generator.generateKey() + } catch (e: StrongBoxUnavailableException) { + // Fall through to the TEE. + } + } + generator.init(spec(strongBox = false)) + return generator.generateKey() + } + + private fun keyStore(): KeyStore = KeyStore.getInstance(KeystoreStorage.ANDROID_KEYSTORE).apply { load(null) } + + private fun alias(accountId: String) = "keepiq.biometric.$accountId" + + private fun blobKey(accountId: String) = "biometric:$accountId" + + companion object { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/KeystoreStorage.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/KeystoreStorage.kt new file mode 100644 index 000000000..d62c1826b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/security/KeystoreStorage.kt @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.security + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import nl.conduction.keepiq.shared.account.SecureStorage +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * [SecureStorage] on Android (design D4): SharedPreferences whose values are + * sealed with AES-256-GCM under a key in the AndroidKeyStore. The key needs + * no user authentication, so the app can read its accounts before an + * unlock, but it never leaves the device: a copied preferences file is + * unreadable elsewhere. The app has `allowBackup="false"`. + */ +class KeystoreStorage(context: Context) : SecureStorage { + private val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + override fun read(key: String): String? { + val sealed = prefs.getString(key, null) ?: return null + return try { + val raw = Base64.decode(sealed, Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, raw, 0, IV_LENGTH)) + String(cipher.doFinal(raw, IV_LENGTH, raw.size - IV_LENGTH), Charsets.UTF_8) + } catch (e: Exception) { + // A value sealed under a key that is gone (app data restored + // onto another device) is worthless: drop it. + prefs.edit().remove(key).apply() + null + } + } + + override fun write(key: String, value: String) { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val sealed = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + prefs.edit().putString(key, Base64.encodeToString(sealed, Base64.NO_WRAP)).commit() + } + + override fun delete(key: String) { + prefs.edit().remove(key).commit() + } + + private fun secretKey(): SecretKey { + val store = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + generator.init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val PREFS = "keepiq.secure" + private const val ALIAS = "keepiq.storage" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_LENGTH = 12 + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/FolderDialogs.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/FolderDialogs.kt new file mode 100644 index 000000000..e998a8b73 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/FolderDialogs.kt @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.vault.FolderDeleteKind +import nl.conduction.keepiq.shared.vault.WriteProblem +import nl.conduction.keepiq.shared.vault.WriteResult + +sealed interface FolderDialog { + data class Create(val parentId: String?) : FolderDialog + data class Rename(val id: String, val name: String) : FolderDialog + data class Delete(val id: String, val name: String) : FolderDialog +} + +/** folderNameProblem (browser-extension/src/lib/folder-rules.js). */ +private fun folderNameProblem(name: String): Int? { + val clean = name.trim() + return when { + clean.isEmpty() -> R.string.folder_name_missing + clean.contains('/') -> R.string.folder_name_slash + clean.length > 255 -> R.string.problem_too_long + else -> null + } +} + +/** Create, rename and delete a folder (task 3.2). [onDone] says whether the vault changed and whether the folder is gone. */ +@Composable +fun FolderDialogs(dialog: FolderDialog, session: VaultSession, onDone: (changed: Boolean, gone: Boolean) -> Unit) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + var problem by remember { mutableStateOf(null) } + + fun run(gone: Boolean, block: suspend () -> WriteResult) { + busy = true + scope.launch { + when (val result = io { block() }) { + is WriteResult.Saved -> onDone(true, gone) + is WriteResult.Refused -> problem = result.problem + } + busy = false + } + } + + when (dialog) { + is FolderDialog.Create, is FolderDialog.Rename -> { + var name by remember { mutableStateOf((dialog as? FolderDialog.Rename)?.name ?: "") } + val nameProblem = folderNameProblem(name) + AlertDialog( + onDismissRequest = { onDone(false, false) }, + title = { Text(stringResource(if (dialog is FolderDialog.Create) R.string.folder_new else R.string.folder_rename)) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.folder_name)) }, + singleLine = true, + isError = nameProblem != null && name.isNotEmpty(), + supportingText = nameProblem?.takeIf { name.isNotEmpty() }?.let { { Text(stringResource(it)) } }, + ) + problem?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton(enabled = !busy && nameProblem == null, onClick = { + run(gone = false) { + when (dialog) { + is FolderDialog.Create -> session.repository.createFolder(name, dialog.parentId) + is FolderDialog.Rename -> session.repository.renameFolder(dialog.id, name) + else -> error("unreachable") + } + } + }) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { TextButton(onClick = { onDone(false, false) }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } + is FolderDialog.Delete -> { + var kind by remember { mutableStateOf(null) } + var loaded by remember { mutableStateOf(false) } + LaunchedEffect(dialog.id) { + kind = io { session.repository.folderDeleteKind(dialog.id) } + loaded = true + } + AlertDialog( + onDismissRequest = { onDone(false, false) }, + title = { Text(stringResource(R.string.folder_delete)) }, + text = { + Column { + when { + !loaded -> Text(stringResource(R.string.vault_loading)) + kind == null -> Text(stringResource(R.string.write_unreachable)) + kind == FolderDeleteKind.EMPTY -> Text(stringResource(R.string.folder_delete_empty, dialog.name)) + kind == FolderDeleteKind.ITEMS -> Text(stringResource(R.string.folder_delete_items, dialog.name)) + else -> Text(stringResource(R.string.folder_delete_subfolders)) + } + problem?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error) } + if (kind == FolderDeleteKind.ITEMS) { + TextButton(enabled = !busy, onClick = { + run(gone = true) { session.repository.deleteFolder(dialog.id, FolderDeleteKind.ITEMS, deleteItems = true) } + }) { Text(stringResource(R.string.folder_delete_with_items)) } + } + } + }, + confirmButton = { + when (kind) { + FolderDeleteKind.EMPTY -> TextButton(enabled = !busy, onClick = { + run(gone = true) { session.repository.deleteFolder(dialog.id, FolderDeleteKind.EMPTY, deleteItems = false) } + }) { Text(stringResource(R.string.action_delete)) } + FolderDeleteKind.ITEMS -> TextButton(enabled = !busy, onClick = { + run(gone = true) { session.repository.deleteFolder(dialog.id, FolderDeleteKind.ITEMS, deleteItems = false) } + }) { Text(stringResource(R.string.folder_delete_move)) } + else -> TextButton(onClick = { onDone(false, false) }) { Text(stringResource(R.string.action_close)) } + } + }, + dismissButton = { TextButton(onClick = { onDone(false, false) }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/GeneratorScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/GeneratorScreen.kt new file mode 100644 index 000000000..777a230c9 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/GeneratorScreen.kt @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:OptIn(ExperimentalMaterial3Api::class) + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.shared.generator.Generator +import nl.conduction.keepiq.shared.generator.GeneratorException +import nl.conduction.keepiq.shared.generator.GeneratorMode +import nl.conduction.keepiq.shared.generator.GeneratorPolicy +import nl.conduction.keepiq.shared.generator.GeneratorSettings + +/** + * The generator (task 3.5), on its own tab and inside the item editor. + * Values are made on the device. The organisation's policy sets the shortest + * length that can be picked and switches on the kinds of character it + * requires; those switches show that the policy decides them. + */ +@Composable +fun GeneratorScreen( + policy: GeneratorPolicy?, + settings: GeneratorSettings, + onSettings: (GeneratorSettings) -> Unit, + onCopy: ((String) -> Unit)?, + modifier: Modifier, + onValue: (String) -> Unit = {}, +) { + val safe = settings.sanitized(policy) + var round by remember { mutableIntStateOf(0) } + val outcome = remember(safe, policy, round) { runCatching { safe.generate(policy) } } + LaunchedEffect(outcome) { onValue(outcome.getOrNull() ?: "") } + val passphraseAllowed = policy?.allowPassphrase != false + + Column(modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton( + selected = safe.mode == GeneratorMode.PASSWORD, + onClick = { onSettings(safe.copy(mode = GeneratorMode.PASSWORD)) }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text(stringResource(R.string.gen_password)) } + SegmentedButton( + selected = safe.mode == GeneratorMode.PASSPHRASE, + enabled = passphraseAllowed, + onClick = { onSettings(safe.copy(mode = GeneratorMode.PASSPHRASE)) }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text(stringResource(R.string.gen_passphrase)) } + } + if (!passphraseAllowed) Text(stringResource(R.string.gen_passphrase_off), style = MaterialTheme.typography.bodySmall) + + Card(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { + val value = outcome.getOrNull() + val error = outcome.exceptionOrNull() as? GeneratorException + Text( + value ?: error?.let { generatorProblemText(it) } ?: "", + fontFamily = if (value != null) FontFamily.Monospace else null, + color = if (value == null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f).testTag("generated").semantics { liveRegion = LiveRegionMode.Polite }, + ) + IconButton(onClick = { round++ }) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.cd_regenerate)) + } + if (onCopy != null && value != null) { + TextButton(onClick = { onCopy(value) }) { Text(stringResource(R.string.action_copy)) } + } + } + } + policy?.let { Text(stringResource(R.string.gen_policy, it.minLength), style = MaterialTheme.typography.bodySmall) } + + if (safe.mode == GeneratorMode.PASSWORD) { + val p = safe.password + val min = settings.minimumLength(policy) + Text(stringResource(R.string.gen_length, p.length)) + Slider( + value = p.length.toFloat(), + onValueChange = { onSettings(safe.copy(password = p.copy(length = it.toInt()))) }, + valueRange = min.toFloat()..Generator.MAX_LENGTH.toFloat(), + ) + Toggle(stringResource(R.string.gen_upper), p.includeUppercase, locked = policy?.requireUpper == true) { + onSettings(safe.copy(password = p.copy(includeUppercase = it))) + } + Toggle(stringResource(R.string.gen_lower), p.includeLowercase, locked = policy?.requireLower == true) { + onSettings(safe.copy(password = p.copy(includeLowercase = it))) + } + Toggle(stringResource(R.string.gen_digits), p.includeDigits, locked = policy?.requireDigit == true) { + onSettings(safe.copy(password = p.copy(includeDigits = it))) + } + Toggle(stringResource(R.string.gen_symbols), p.includeSpecialCharacters, locked = policy?.requireSymbol == true) { + onSettings(safe.copy(password = p.copy(includeSpecialCharacters = it))) + } + if (p.includeDigits) { + Text(stringResource(R.string.gen_min_digits, p.minDigits)) + Slider(value = p.minDigits.toFloat(), onValueChange = { onSettings(safe.copy(password = p.copy(minDigits = it.toInt()))) }, valueRange = 0f..9f, steps = 8) + } + if (p.includeSpecialCharacters) { + Text(stringResource(R.string.gen_min_symbols, p.minSpecial)) + Slider(value = p.minSpecial.toFloat(), onValueChange = { onSettings(safe.copy(password = p.copy(minSpecial = it.toInt()))) }, valueRange = 0f..9f, steps = 8) + } + Toggle(stringResource(R.string.gen_avoid_ambiguous), p.avoidAmbiguous, locked = false) { + onSettings(safe.copy(password = p.copy(avoidAmbiguous = it))) + } + } else { + val w = safe.passphrase + Text(stringResource(R.string.gen_words, w.words)) + Slider( + value = w.words.toFloat(), + onValueChange = { onSettings(safe.copy(passphrase = w.copy(words = it.toInt()))) }, + valueRange = Generator.MIN_WORDS.toFloat()..Generator.MAX_WORDS.toFloat(), + steps = Generator.MAX_WORDS - Generator.MIN_WORDS - 1, + ) + OutlinedTextField( + value = w.separator, + onValueChange = { onSettings(safe.copy(passphrase = w.copy(separator = it.take(3)))) }, + label = { Text(stringResource(R.string.gen_separator)) }, + singleLine = true, + ) + Toggle(stringResource(R.string.gen_capitalise), w.capitalise, locked = policy?.requireUpper == true) { + onSettings(safe.copy(passphrase = w.copy(capitalise = it))) + } + Toggle(stringResource(R.string.gen_number), w.includeNumber, locked = policy?.requireDigit == true) { + onSettings(safe.copy(passphrase = w.copy(includeNumber = it))) + } + } + } +} + +/** A switch row; a switch the policy decides is on, locked, and says so. */ +@Composable +private fun Toggle(label: String, checked: Boolean, locked: Boolean, onChange: (Boolean) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .toggleable(value = checked || locked, enabled = !locked, role = Role.Switch, onValueChange = onChange) + .padding(vertical = 4.dp), + ) { + Column(Modifier.weight(1f)) { + Text(label) + if (locked) Text(stringResource(R.string.gen_policy_required), style = MaterialTheme.typography.bodySmall) + } + Switch(checked = checked || locked, onCheckedChange = null, enabled = !locked) + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemDetailScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemDetailScreen.kt new file mode 100644 index 000000000..05f570783 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemDetailScreen.kt @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.crypto.Totp +import nl.conduction.keepiq.shared.vault.Composite +import nl.conduction.keepiq.shared.vault.DecryptedItem +import nl.conduction.keepiq.shared.vault.FormKind +import nl.conduction.keepiq.shared.vault.OpenResult +import nl.conduction.keepiq.shared.vault.VaultIndex +import nl.conduction.keepiq.shared.vault.WriteProblem +import nl.conduction.keepiq.shared.vault.WriteResult + +private const val MASK = "••••••••" + +/** + * One item (task 3.1): secret values hidden until revealed, copy through + * the sensitive clipboard, the TOTP code with its seconds left. A use-only + * copy shows no value and offers no reveal or copy of it. + */ +@Composable +fun ItemDetailScreen( + session: VaultSession, + id: String, + modifier: Modifier, + onCopy: (String) -> Unit, + onEdit: () -> Unit, + onGone: () -> Unit, + onSendLogin: (String, String) -> Unit, +) { + var result by remember(id) { mutableStateOf(null) } + var action by remember { mutableStateOf(null) } + var problem by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + LaunchedEffect(id) { result = io { session.repository.open(id) } } + + when (val r = result) { + null -> Message(stringResource(R.string.vault_loading)) + OpenResult.Missing -> Message(stringResource(R.string.detail_missing)) + is OpenResult.Failed -> Message(writeProblemText(r.problem)) + is OpenResult.Opened -> ItemDetail( + item = r.item, + folderPath = VaultIndex.folderPath(session.repository.state.folders, r.item.row.folderId) + ?: stringResource(R.string.vault_no_folder), + offline = session.repository.state.offline, + problem = problem, + modifier = modifier, + onCopy = onCopy, + onEdit = onEdit, + onMove = { action = DetailAction.Move }, + onTrash = { action = DetailAction.Trash }, + onSendLogin = { onSendLogin(r.item.login, r.item.secret) }, + ) + } + + val item = (result as? OpenResult.Opened)?.item ?: return + fun write(block: suspend () -> WriteResult, after: () -> Unit) { + action = null + scope.launch { + when (val w = io { block() }) { + is WriteResult.Saved -> after() + is WriteResult.Refused -> problem = w.problem + } + } + } + when (action) { + DetailAction.Trash -> AlertDialog( + onDismissRequest = { action = null }, + text = { Text(stringResource(R.string.detail_trash_confirm, item.row.name)) }, + confirmButton = { + TextButton(onClick = { write({ session.repository.trash(item.row.id) }, onGone) }, modifier = Modifier.testTag("confirmTrash")) { + Text(stringResource(R.string.action_trash)) + } + }, + dismissButton = { TextButton(onClick = { action = null }) { Text(stringResource(R.string.action_cancel)) } }, + ) + DetailAction.Move -> { + val folders = VaultIndex.folderTree(session.repository.state.folders) + AlertDialog( + onDismissRequest = { action = null }, + title = { Text(stringResource(R.string.move_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + val choices = listOf>(null to stringResource(R.string.vault_no_folder)) + + folders.map { it.id to " ".repeat(it.depth) + it.name } + for ((folderId, label) in choices) { + ListItem( + headlineContent = { Text(label) }, + leadingContent = { RadioButton(selected = folderId == item.row.folderId, onClick = null) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = label.trim() } + .clickable { + write({ session.repository.move(item.row.id, folderId) }) { + scope.launch { result = io { session.repository.open(id) } } + } + }, + ) + } + } + }, + confirmButton = { TextButton(onClick = { action = null }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } + null -> Unit + } +} + +private enum class DetailAction { Move, Trash } + +@Composable +internal fun ItemDetail( + item: DecryptedItem, + folderPath: String, + offline: Boolean, + problem: WriteProblem?, + modifier: Modifier, + onCopy: (String) -> Unit, + onEdit: () -> Unit, + onMove: () -> Unit, + onTrash: () -> Unit, + onSendLogin: () -> Unit, +) { + val row = item.row + Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(row.name, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + Text("${item.type?.label ?: item.typeName} · $folderPath", style = MaterialTheme.typography.bodyMedium) + if (item.fromCache) Note(stringResource(R.string.detail_from_cache)) + if (row.useOnly) Note(stringResource(R.string.detail_use_only)) + if (row.readOnly && !row.useOnly) Note(stringResource(R.string.detail_read_only)) + problem?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error) } + if (row.blocked) { + Note(row.blockedReason ?: stringResource(R.string.detail_blocked)) + return@Column + } + + val passwordLabel = stringResource(if (item.kind == FormKind.LOGIN) R.string.detail_password else R.string.detail_value) + when (item.kind) { + FormKind.LOGIN, FormKind.GENERIC -> { + if (item.login.isNotEmpty()) FieldRow(stringResource(R.string.detail_username), item.login, onCopy = onCopy) + if (!row.useOnly) FieldRow(passwordLabel, item.secret, masked = true, onCopy = onCopy) + } + FormKind.TOTP -> TotpRow(item, onCopy) + FormKind.NOTE -> if (!row.useOnly) FieldRow(stringResource(R.string.detail_notes), item.secret, onCopy = onCopy) + FormKind.CARD, FormKind.IDENTITY -> if (!row.useOnly) { + val data = item.composite + if (data == null) { + Text(stringResource(R.string.detail_fields_error), color = MaterialTheme.colorScheme.error) + } else { + if (item.kind == FormKind.CARD) { + Composite.last4(data["number"] ?: "").takeIf { it.isNotEmpty() }?.let { + Text(stringResource(R.string.detail_card_ending, it)) + } + } + for (field in Composite.fieldsOf(item.kind)) { + val value = data[field] ?: "" + if (value.isNotEmpty()) FieldRow(compositeLabel(field), value, masked = field in Composite.MASKED, onCopy = onCopy) + } + } + } + FormKind.PASSKEY -> item.passkey?.let { pk -> + FieldRow(stringResource(R.string.detail_passkey_site), pk.rpName?.let { "$it (${pk.rpId})" } ?: pk.rpId, copy = false, onCopy = onCopy) + pk.userName?.let { FieldRow(stringResource(R.string.detail_passkey_account), it, copy = false, onCopy = onCopy) } + pk.createdAt?.let { FieldRow(stringResource(R.string.detail_passkey_created), it, copy = false, onCopy = onCopy) } + Text(stringResource(R.string.detail_passkey_note), style = MaterialTheme.typography.bodySmall) + } + } + row.url?.takeIf { it.isNotEmpty() }?.let { FieldRow(stringResource(R.string.detail_address), it, onCopy = onCopy) } + if (!row.useOnly) { + for ((field, value) in item.typedValues) { + if (value.isNotEmpty()) FieldRow(field.label, value, masked = field.hidden, onCopy = onCopy) + } + if (item.kind != FormKind.NOTE && item.notes.isNotEmpty()) FieldRow(stringResource(R.string.detail_notes), item.notes, onCopy = onCopy) + if (item.additionalFieldsError) Text(stringResource(R.string.detail_fields_error), color = MaterialTheme.colorScheme.error) + if (item.extraFields.isNotEmpty()) { + Text(stringResource(R.string.detail_extra_fields), style = MaterialTheme.typography.titleSmall, modifier = Modifier.semantics { heading() }) + for ((name, value) in item.extraFields) FieldRow(name, value, onCopy = onCopy) + } + } + + HorizontalDivider() + if (!row.useOnly) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onEdit, enabled = !offline) { Text(stringResource(R.string.action_edit)) } + OutlinedButton(onClick = onMove, enabled = !offline) { Text(stringResource(R.string.action_move)) } + } + if (item.kind == FormKind.LOGIN) { + OutlinedButton(onClick = onSendLogin, enabled = !offline) { Text(stringResource(R.string.cd_new_send)) } + } + } + OutlinedButton(onClick = onTrash, enabled = !offline) { Text(stringResource(R.string.action_trash)) } + if (offline) Text(stringResource(R.string.write_offline), style = MaterialTheme.typography.bodySmall) + } +} + +@Composable +private fun compositeLabel(field: String): String = stringResource( + when (field) { + "number" -> R.string.composite_number + "expiry" -> R.string.composite_expiry + "cvv" -> R.string.composite_cvv + "pin" -> R.string.composite_pin + "cardholder" -> R.string.composite_cardholder + "firstName" -> R.string.composite_first_name + "lastName" -> R.string.composite_last_name + "address" -> R.string.composite_address + "phone" -> R.string.composite_phone + "email" -> R.string.composite_email + else -> R.string.composite_bsn + }, +) + +@Composable +private fun Note(text: String) { + Card(Modifier.fillMaxWidth()) { Text(text, modifier = Modifier.padding(12.dp)) } +} + +/** A labelled value with Show and Copy. Buttons are 48 dp targets and name the field for TalkBack. */ +@Composable +fun FieldRow(label: String, value: String, masked: Boolean = false, copy: Boolean = true, onCopy: (String) -> Unit) { + var shown by remember(value) { mutableStateOf(!masked) } + Column(Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + if (shown) value.ifEmpty { "-" } else MASK, + fontFamily = if (masked) FontFamily.Monospace else null, + modifier = Modifier.weight(1f).let { if (!shown) it.clearAndSetSemantics { contentDescription = label } else it }, + ) + if (masked) { + val cd = stringResource(if (shown) R.string.cd_hide_field else R.string.cd_show_field, label) + TextButton(onClick = { shown = !shown }, modifier = Modifier.semantics { contentDescription = cd }) { + Text(stringResource(if (shown) R.string.action_hide else R.string.action_show)) + } + } + if (copy && value.isNotEmpty()) { + val cd = stringResource(R.string.cd_copy_field, label) + TextButton(onClick = { onCopy(value) }, modifier = Modifier.semantics { contentDescription = cd }) { + Text(stringResource(R.string.action_copy)) + } + } + } + } +} + +/** The current code and its seconds left, refreshed every second. */ +@Composable +private fun TotpRow(item: DecryptedItem, onCopy: (String) -> Unit) { + val params = item.totp + if (params == null) { + Text(stringResource(R.string.detail_code_invalid), color = MaterialTheme.colorScheme.error) + return + } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(params) { + while (true) { + now = System.currentTimeMillis() + delay(1_000L - now % 1_000L) + } + } + val code = remember(params, now / 1_000L / params.period) { Totp.generate(params, now) } + val left = Totp.secondsRemaining(params.period, now) + val label = stringResource(R.string.detail_code) + val leftText = stringResource(R.string.detail_code_seconds, left) + Column { + Text(label, style = MaterialTheme.typography.labelMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val half = (code.length + 1) / 2 + Text( + code.substring(0, half) + " " + code.substring(half), + style = MaterialTheme.typography.headlineMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + CircularProgressIndicator( + progress = { left.toFloat() / params.period }, + modifier = Modifier.semantics { contentDescription = leftText }, + ) + val copyCd = stringResource(R.string.cd_copy_field, label) + TextButton(onClick = { onCopy(code) }, modifier = Modifier.semantics { contentDescription = copyCd }) { + Text(stringResource(R.string.action_copy)) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemEditScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemEditScreen.kt new file mode 100644 index 000000000..85ad9bd64 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/ItemEditScreen.kt @@ -0,0 +1,336 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:OptIn(ExperimentalMaterial3Api::class) + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.generator.GeneratorPolicy +import nl.conduction.keepiq.shared.generator.GeneratorSettings +import nl.conduction.keepiq.shared.vault.Composite +import nl.conduction.keepiq.shared.vault.DecryptedItem +import nl.conduction.keepiq.shared.vault.DraftProblem +import nl.conduction.keepiq.shared.vault.FormKind +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemDraft +import nl.conduction.keepiq.shared.vault.OpenResult +import nl.conduction.keepiq.shared.vault.SecretType +import nl.conduction.keepiq.shared.vault.VaultIndex +import nl.conduction.keepiq.shared.vault.WriteProblem +import nl.conduction.keepiq.shared.vault.WriteResult + +/** + * Create or edit an item (task 3.2). The type's fields come from + * `/api/v1/secret-types`; the values are encrypted on the device to the + * suite's key before they are sent. A refusal is shown and the form keeps + * what the user typed; nothing is shown as saved unless the server took it. + */ +@Composable +fun ItemEditScreen( + session: VaultSession, + id: String?, + folderId: String?, + policy: GeneratorPolicy?, + generatorSettings: GeneratorSettings, + modifier: Modifier, + onSaved: (String?) -> Unit, + onCancel: () -> Unit, +) { + val repository = session.repository + val types = repository.state.types.filter { it.name != "passkey" } + var item by remember(id) { mutableStateOf(null) } + var loaded by remember(id) { mutableStateOf(id == null) } + var type by remember(id) { mutableStateOf(types.firstOrNull { it.name == "login" } ?: types.firstOrNull()) } + var draft by remember(id) { mutableStateOf(ItemCodec.draft(null, type).copy(folderId = folderId)) } + var problem by remember { mutableStateOf(null) } + var showErrors by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + var generateFor by remember { mutableStateOf<((String) -> Unit)?>(null) } + val scope = rememberCoroutineScope() + // The keyboard closes on save, so it does not cover the item that opens next. + val focus = LocalFocusManager.current + + LaunchedEffect(id) { + if (id != null) { + val opened = io { repository.open(id) } + if (opened is OpenResult.Opened) { + item = opened.item + type = opened.item.type + draft = ItemCodec.draft(opened.item, opened.item.type) + } else if (opened is OpenResult.Failed) { + problem = opened.problem + } + loaded = true + } + } + if (!loaded) { + Message(stringResource(R.string.vault_loading)) + return + } + val errors = ItemCodec.validate(draft, type) + fun error(key: String): DraftProblem? = errors[key].takeIf { showErrors } + + Column( + modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(if (id == null) R.string.edit_new_title else R.string.edit_title), style = MaterialTheme.typography.headlineSmall) + if (id == null && types.isNotEmpty()) { + Picker( + label = stringResource(R.string.edit_type), + selected = type?.label ?: type?.name ?: "", + options = types.map { (it.label ?: it.name) to it }, + onPick = { picked -> + type = picked + val kept = draft + draft = ItemCodec.draft(null, picked).copy(name = kept.name, url = kept.url, folderId = kept.folderId, notes = kept.notes) + }, + ) + } + if (draft.kind == FormKind.PASSKEY) Text(stringResource(R.string.edit_passkey_note), style = MaterialTheme.typography.bodySmall) + TextInput(stringResource(R.string.edit_name), draft.name, error("name")) { draft = draft.copy(name = it) } + TextInput(stringResource(R.string.edit_url), draft.url, error("url"), keyboard = KeyboardType.Uri) { draft = draft.copy(url = it) } + val folders = VaultIndex.folderTree(repository.state.folders) + Picker( + label = stringResource(R.string.detail_folder), + selected = VaultIndex.folderPath(repository.state.folders, draft.folderId) ?: stringResource(R.string.vault_no_folder), + options = listOf>(stringResource(R.string.vault_no_folder) to null) + + folders.map { (" ".repeat(it.depth) + it.name) to it.id }, + onPick = { draft = draft.copy(folderId = it) }, + ) + when (draft.kind) { + FormKind.LOGIN, FormKind.GENERIC -> { + TextInput(stringResource(R.string.detail_username), draft.login, error("login")) { draft = draft.copy(login = it) } + SecretInput( + label = stringResource(if (draft.kind == FormKind.LOGIN) R.string.detail_password else R.string.detail_value), + value = draft.secret, + problem = error("secret"), + onGenerate = { generateFor = { v -> draft = draft.copy(secret = v) } }, + ) { draft = draft.copy(secret = it) } + } + FormKind.TOTP -> TextInput(stringResource(R.string.edit_totp_secret), draft.secret, error("secret")) { draft = draft.copy(secret = it) } + FormKind.CARD, FormKind.IDENTITY -> for (field in Composite.fieldsOf(draft.kind)) { + val value = draft.composite[field] ?: "" + val label = compositeFieldLabel(field) + if (field in Composite.MASKED) { + SecretInput(label, value, null, onGenerate = null) { draft = draft.copy(composite = draft.composite + (field to it)) } + } else { + TextInput(label, value, null) { draft = draft.copy(composite = draft.composite + (field to it)) } + } + } + FormKind.NOTE, FormKind.PASSKEY -> Unit + } + for (field in type?.fields ?: emptyList()) { + val value = draft.typed[field.key] ?: "" + val label = if (field.required) "${field.label} (${stringResource(R.string.edit_required)})" else field.label + val update: (String) -> Unit = { draft = draft.copy(typed = draft.typed + (field.key to it)) } + if (field.hidden) { + SecretInput(label, value, error("typed-${field.key}"), onGenerate = { generateFor = update }, onChange = update) + } else { + val keyboard = when (field.kind) { + "url" -> KeyboardType.Uri + "email" -> KeyboardType.Email + else -> KeyboardType.Text + } + TextInput(label, value, error("typed-${field.key}"), keyboard = keyboard, onChange = update) + } + } + TextInput(stringResource(R.string.detail_notes), draft.notes, null, singleLine = false) { draft = draft.copy(notes = it) } + if (draft.kind != FormKind.PASSKEY) ExtraFields(draft, ::error) { draft = it } + + problem?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error) } + error("fields")?.let { Text(draftProblemText(it), color = MaterialTheme.colorScheme.error) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = !busy && !repository.state.offline, + onClick = { + focus.clearFocus() + showErrors = true + if (errors.isNotEmpty()) return@Button + busy = true + problem = null + scope.launch { + val current = item + val result = io { if (current == null) repository.create(draft, type) else repository.update(current, draft) } + busy = false + when (result) { + is WriteResult.Saved -> onSaved(result.id) + is WriteResult.Refused -> problem = result.problem + } + } + }, + ) { Text(stringResource(R.string.action_save)) } + OutlinedButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) } + } + if (repository.state.offline) Text(stringResource(R.string.write_offline), style = MaterialTheme.typography.bodySmall) + } + + generateFor?.let { apply -> + var value by remember { mutableStateOf("") } + var settings by remember { mutableStateOf(generatorSettings) } + AlertDialog( + onDismissRequest = { generateFor = null }, + text = { + GeneratorScreen(policy = policy, settings = settings, onSettings = { settings = it }, onCopy = null, onValue = { value = it }, modifier = Modifier) + }, + confirmButton = { + TextButton(enabled = value.isNotEmpty(), onClick = { + apply(value) + generateFor = null + }) { Text(stringResource(R.string.action_use)) } + }, + dismissButton = { TextButton(onClick = { generateFor = null }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } +} + +@Composable +private fun ExtraFields(draft: ItemDraft, error: (String) -> DraftProblem?, onChange: (ItemDraft) -> Unit) { + Text(stringResource(R.string.detail_extra_fields), style = MaterialTheme.typography.titleSmall) + draft.fields.forEachIndexed { i, (name, value) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + TextInput(stringResource(R.string.edit_field_name), name, error("field-$i")) { n -> + onChange(draft.copy(fields = draft.fields.toMutableList().also { it[i] = n to value })) + } + TextInput(stringResource(R.string.edit_field_value), value, null) { v -> + onChange(draft.copy(fields = draft.fields.toMutableList().also { it[i] = name to v })) + } + } + IconButton(onClick = { onChange(draft.copy(fields = draft.fields.filterIndexed { j, _ -> j != i })) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.cd_remove_field, name)) + } + } + } + TextButton(onClick = { onChange(draft.copy(fields = draft.fields + ("" to ""))) }) { Text(stringResource(R.string.edit_add_field)) } +} + +@Composable +private fun compositeFieldLabel(field: String): String = stringResource( + when (field) { + "number" -> R.string.composite_number + "expiry" -> R.string.composite_expiry + "cvv" -> R.string.composite_cvv + "pin" -> R.string.composite_pin + "cardholder" -> R.string.composite_cardholder + "firstName" -> R.string.composite_first_name + "lastName" -> R.string.composite_last_name + "address" -> R.string.composite_address + "phone" -> R.string.composite_phone + "email" -> R.string.composite_email + else -> R.string.composite_bsn + }, +) + +@Composable +fun TextInput( + label: String, + value: String, + problem: DraftProblem?, + keyboard: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true, + onChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + singleLine = singleLine, + isError = problem != null, + supportingText = problem?.let { { Text(draftProblemText(it)) } }, + keyboardOptions = KeyboardOptions(keyboardType = keyboard), + modifier = Modifier.fillMaxWidth(), + ) +} + +/** A hidden input with Show and, where it fits, Generate. The keyboard is told not to learn it. */ +@Composable +private fun SecretInput(label: String, value: String, problem: DraftProblem?, onGenerate: (() -> Unit)?, onChange: (String) -> Unit) { + var shown by remember { mutableStateOf(false) } + Column { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + singleLine = true, + isError = problem != null, + supportingText = problem?.let { { Text(draftProblemText(it)) } }, + visualTransformation = if (shown) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrectEnabled = false), + modifier = Modifier.fillMaxWidth(), + ) + Row { + TextButton(onClick = { shown = !shown }) { + Text(stringResource(if (shown) R.string.cd_hide_field else R.string.cd_show_field, label)) + } + if (onGenerate != null) TextButton(onClick = onGenerate) { Text(stringResource(R.string.action_generate)) } + } + } +} + +/** A read-only field that opens a menu of [options]. */ +@Composable +fun Picker(label: String, selected: String, options: List>, onPick: (T) -> Unit) { + var open by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = open, onExpandedChange = { open = it }) { + OutlinedTextField( + value = selected, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = open) }, + modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = open, onDismissRequest = { open = false }) { + for ((text, value) in options) { + DropdownMenuItem(text = { Text(text) }, onClick = { + open = false + onPick(value) + }) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Messages.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Messages.kt new file mode 100644 index 000000000..a276b6be0 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Messages.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import android.text.format.DateUtils +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.shared.generator.GeneratorErrorCode +import nl.conduction.keepiq.shared.generator.GeneratorException +import nl.conduction.keepiq.shared.send.SendFormProblem +import nl.conduction.keepiq.shared.vault.DraftProblem +import nl.conduction.keepiq.shared.vault.VaultLockedException +import nl.conduction.keepiq.shared.vault.WriteProblem +import nl.conduction.keepiq.shared.vault.WriteProblemKind + +/** + * Runs shared work (network, store, RSA, Argon2id) off the main thread. Work + * the lock overtook ends as a cancellation: the screen that asked is gone. + */ +suspend fun io(block: suspend () -> T): T = withContext(Dispatchers.IO) { + try { + block() + } catch (e: VaultLockedException) { + throw CancellationException("The vault was locked", e) + } +} + +@Composable +fun writeProblemText(problem: WriteProblem): String = when (problem.kind) { + WriteProblemKind.OFFLINE -> stringResource(R.string.write_offline) + WriteProblemKind.KEY_MIGRATION -> stringResource(R.string.write_key_migration) + WriteProblemKind.SUITE_BLOCKED -> stringResource(R.string.write_suite_blocked) + WriteProblemKind.SERVER_MESSAGE -> stringResource(R.string.write_server, problem.serverMessage ?: "") + WriteProblemKind.REFUSED -> stringResource(R.string.write_refused) + WriteProblemKind.UNREACHABLE -> stringResource(R.string.write_unreachable) + WriteProblemKind.FAILED -> stringResource(R.string.write_failed) +} + +@Composable +fun draftProblemText(problem: DraftProblem): String = stringResource( + when (problem) { + DraftProblem.NAME_MISSING -> R.string.problem_name_missing + DraftProblem.TOO_LONG -> R.string.problem_too_long + DraftProblem.FIELD_NAME_MISSING -> R.string.problem_field_name_missing + DraftProblem.FIELD_NAME_RESERVED -> R.string.problem_field_name_reserved + DraftProblem.FIELD_NAME_TAKEN -> R.string.problem_field_name_taken + DraftProblem.NOT_AN_AUTHENTICATOR_SECRET -> R.string.problem_not_totp + DraftProblem.REQUIRED -> R.string.problem_required + }, +) + +@Composable +fun sendProblemText(problem: SendFormProblem): String = stringResource( + when (problem) { + SendFormProblem.NOTHING_TO_SEND -> R.string.send_nothing + SendFormProblem.CUSTOM_HOURS -> R.string.send_hours_range + SendFormProblem.CUSTOM_HOURS_TOO_MANY -> R.string.send_hours_max + SendFormProblem.VIEWS_OUT_OF_RANGE -> R.string.send_views_range + SendFormProblem.PASSWORD_NOT_AVAILABLE -> R.string.send_password_unavailable + }, +) + +@Composable +fun generatorProblemText(error: GeneratorException): String = when (error.code) { + GeneratorErrorCode.PASSPHRASE_OFF -> stringResource(R.string.gen_passphrase_off) + GeneratorErrorCode.NO_KIND_CHOSEN -> stringResource(R.string.gen_error, stringResource(R.string.gen_err_no_kind)) + GeneratorErrorCode.CHARSET_EMPTY, GeneratorErrorCode.CHARSET_TOO_SMALL -> + stringResource(R.string.gen_error, stringResource(R.string.gen_err_charset)) + GeneratorErrorCode.LENGTH_TOO_SHORT, GeneratorErrorCode.LENGTH_TOO_LONG, GeneratorErrorCode.PASSPHRASE_WORDS -> + stringResource(R.string.gen_error, stringResource(R.string.gen_err_length)) + else -> stringResource(R.string.gen_error, stringResource(R.string.gen_err_other)) +} + +/** "5 minutes ago", in the device's language. */ +fun relativeTime(millis: Long): String = + DateUtils.getRelativeTimeSpanString(millis, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/PairScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/PairScreen.kt new file mode 100644 index 000000000..6ca13ab0b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/PairScreen.kt @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import nl.conduction.keepiq.android.AppState +import nl.conduction.keepiq.android.LoginState + +/** + * Connect an account (task 2.1): the server address, then the server's own + * login page in the browser. "Use an app password instead" is the fallback + * for servers whose login page does not open in a browser tab. + */ +@Composable +fun PairScreen(state: AppState, openBrowser: (String) -> Unit) { + val login by state.login.collectAsState() + val busy by state.busy.collectAsState() + val message by state.message.collectAsState() + var server by rememberSaveable { mutableStateOf("") } + var manual by rememberSaveable { mutableStateOf(false) } + var loginName by rememberSaveable { mutableStateOf("") } + var appPassword by rememberSaveable { mutableStateOf("") } + val hasAccounts = state.client.accounts.accounts().isNotEmpty() + + ScreenColumn { + Text("Connect to your Nextcloud", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + + val waiting = login as? LoginState.Waiting + if (waiting != null) { + Text("Sign in on the page that opened in your browser, then come back here. Keepiq waits up to 20 minutes.") + CircularProgressIndicator(modifier = Modifier.testTag("waiting")) + OutlinedButton(onClick = { openBrowser(waiting.start.loginUrl) }, modifier = Modifier.fillMaxWidth()) { + Text("Open the sign-in page again") + } + TextButton(onClick = { state.cancelLogin() }, modifier = Modifier.testTag("cancelLogin")) { Text("Cancel") } + Problem(message) + return@ScreenColumn + } + + OutlinedTextField( + value = server, + onValueChange = { server = it }, + label = { Text("Server address") }, + placeholder = { Text("cloud.example.com") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth().testTag("server"), + ) + + if (!manual) { + Button( + onClick = { state.startLogin(server, openBrowser) }, + enabled = !busy && server.isNotBlank(), + modifier = Modifier.fillMaxWidth().testTag("signIn"), + ) { Text("Sign in with your browser") } + TextButton(onClick = { manual = true }, modifier = Modifier.testTag("useAppPassword")) { + Text("Use an app password instead") + } + } else { + Text("Create an app password in Nextcloud under Personal settings, Security, and enter it here.") + OutlinedTextField( + value = loginName, + onValueChange = { loginName = it }, + label = { Text("User name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("loginName"), + ) + OutlinedTextField( + value = appPassword, + onValueChange = { appPassword = it }, + label = { Text("App password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().testTag("appPassword"), + ) + Button( + onClick = { state.pairManually(server, loginName, appPassword) }, + enabled = !busy && server.isNotBlank() && loginName.isNotBlank() && appPassword.isNotBlank(), + modifier = Modifier.fillMaxWidth().testTag("connect"), + ) { Text("Connect") } + TextButton(onClick = { manual = false }) { Text("Sign in with your browser instead") } + } + + if (busy) CircularProgressIndicator() + Problem(message) + + if (hasAccounts) { + val active = state.client.accounts.activeId() + TextButton(onClick = { active?.let { state.switchAccount(it) } }) { Text("Back to your accounts") } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Root.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Root.kt new file mode 100644 index 000000000..04d72f1f8 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/Root.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import nl.conduction.keepiq.android.AppState +import nl.conduction.keepiq.android.Screen + +/** The app: one screen at a time, chosen by [AppState.screen]. */ +@Composable +fun KeepiqRoot(state: AppState, activity: FragmentActivity, openBrowser: (String) -> Unit) { + val screen by state.screen.collectAsState() + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + when (val s = screen) { + Screen.Pair -> PairScreen(state, openBrowser) + is Screen.Unlock -> UnlockScreen(state, activity, s.accountId) + is Screen.Unlocked -> VaultApp( + session = s.session, + accounts = state.client.accounts.accounts(), + onSwitchAccount = { state.switchAccount(it.id) }, + onAddAccount = { state.addAccount() }, + onLock = { state.lock() }, + onSettings = { state.openSettings(s.vault) }, + onLocked = { reason -> state.lock(reason) }, + ) + is Screen.Settings -> SettingsScreen(state, activity, s.vault) + } + } + } +} + +/** A scrolling column with the app's margins, clear of the system bars and the keyboard. */ +@Composable +internal fun ScreenColumn(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = content, + ) +} + +/** The last problem, read out by TalkBack when it appears. */ +@Composable +internal fun Problem(message: String?) { + if (message == null) return + Text( + message, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .testTag("message") + .semantics { liveRegion = LiveRegionMode.Polite }, + ) +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SendScreens.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SendScreens.kt new file mode 100644 index 000000000..e1267d9c3 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SendScreens.kt @@ -0,0 +1,345 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:OptIn(ExperimentalMaterial3Api::class) + +package nl.conduction.keepiq.android.ui + +import android.content.Intent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.send.CreatedSend +import nl.conduction.keepiq.shared.send.IsoTime +import nl.conduction.keepiq.shared.send.OpenSendClient +import nl.conduction.keepiq.shared.send.OpenSendResult +import nl.conduction.keepiq.shared.send.SendExpiry +import nl.conduction.keepiq.shared.send.SendForm +import nl.conduction.keepiq.shared.send.SendLink +import nl.conduction.keepiq.shared.send.SendPayloadType +import nl.conduction.keepiq.shared.send.SendResult +import nl.conduction.keepiq.shared.send.SendSummary +import nl.conduction.keepiq.shared.vault.WriteProblem +import java.text.DateFormat +import java.util.Date + +/** The account's sends (task 3.6): metadata only, with delete, and a field to open a Send link. */ +@Composable +fun SendListScreen(session: VaultSession, modifier: Modifier, onNew: () -> Unit, onCopy: (String) -> Unit) { + var sends by remember(session) { mutableStateOf?>(null) } + var problem by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + var opening by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + fun load() { + scope.launch { + when (val r = io { session.sends.list() }) { + is SendResult.Done -> { sends = r.value; problem = null } + is SendResult.Problem -> problem = r.write + } + } + } + LaunchedEffect(session) { load() } + + Box(modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + TextButton(onClick = { opening = true }, modifier = Modifier.padding(horizontal = 8.dp)) { + Text(stringResource(R.string.open_send_paste)) + } + problem?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) } + val list = sends + when { + list == null && problem == null -> Message(stringResource(R.string.vault_loading)) + list != null && list.isEmpty() -> Message(stringResource(R.string.send_empty)) + list != null -> LazyColumn { + items(list, key = { it.id }) { send -> + SendRow(send) { deleting = send } + HorizontalDivider() + } + } + } + } + FloatingActionButton(onClick = onNew, modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_new_send)) + } + } + + deleting?.let { send -> + AlertDialog( + onDismissRequest = { deleting = null }, + text = { Text(stringResource(R.string.send_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + deleting = null + scope.launch { + when (val r = io { session.sends.delete(send.id) }) { + is SendResult.Done -> load() + is SendResult.Problem -> problem = r.write + } + } + }) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { TextButton(onClick = { deleting = null }) { Text(stringResource(R.string.action_cancel)) } }, + ) + } + if (opening) { + AlertDialog( + onDismissRequest = { opening = false }, + text = { OpenSendScreen(initialLink = "", modifier = Modifier, onCopy = onCopy) }, + confirmButton = { TextButton(onClick = { opening = false }) { Text(stringResource(R.string.action_close)) } }, + ) + } +} + +@Composable +private fun SendRow(send: SendSummary, onDelete: () -> Unit) { + val kind = stringResource(if (send.payloadType == "credential") R.string.send_credential else R.string.send_text) + val created = IsoTime.parseMillis(send.createdAt)?.let { DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it)) } + val minutes = SendForm.minutesLeft(IsoTime.parseMillis(send.expiresAt), System.currentTimeMillis()) + val expiry = when { + minutes == null -> null + minutes <= 0 -> stringResource(R.string.send_expired) + minutes < 60 -> stringResource(R.string.send_expires_minutes, minutes.toInt()) + minutes < 48 * 60 -> stringResource(R.string.send_expires_hours, ((minutes + 30) / 60).toInt()) + else -> stringResource(R.string.send_expires_days, ((minutes + 720) / 1440).toInt()) + } + val details = listOfNotNull( + expiry, + stringResource(R.string.send_views, send.viewCount.toInt(), send.maxViews.toInt()), + if (send.hasPassword) stringResource(R.string.send_password_badge) else null, + ).joinToString(" · ") + ListItem( + headlineContent = { Text(if (created != null) stringResource(R.string.send_row, kind, created) else kind) }, + supportingContent = { Text(details) }, + trailingContent = { TextButton(onClick = onDelete) { Text(stringResource(R.string.action_delete)) } }, + ) +} + +/** + * Create a Send (task 3.6): text or a login, a view limit, an expiry and + * an optional password. The link goes to the system share sheet. + */ +@Composable +fun NewSendScreen( + session: VaultSession, + initial: Route.NewSend, + modifier: Modifier, + onCopy: (String) -> Unit, + onDone: () -> Unit, +) { + val context = LocalContext.current + var type by remember { mutableStateOf(if (initial.password.isNotEmpty()) SendPayloadType.CREDENTIAL else SendPayloadType.TEXT) } + var text by remember { mutableStateOf(initial.text) } + var username by remember { mutableStateOf(initial.username) } + var password by remember { mutableStateOf(initial.password) } + var views by remember { mutableStateOf("1") } + var expiry by remember { mutableStateOf(SendExpiry.SEVEN_DAYS) } + var hours by remember { mutableStateOf("") } + var sendPassword by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var problem by remember { mutableStateOf(null) } + var created by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val focus = LocalFocusManager.current + + Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.send_new_title), style = MaterialTheme.typography.headlineSmall) + val done = created + if (done != null) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(if (done.hasPassword) R.string.send_created_password else R.string.send_created)) + Text(done.link, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + val share = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, done.link) + context.startActivity(Intent.createChooser(share, null)) + }) { Text(stringResource(R.string.action_share)) } + OutlinedButton(onClick = { onCopy(done.link) }) { Text(stringResource(R.string.send_copy_link)) } + } + TextButton(onClick = onDone) { Text(stringResource(R.string.action_close)) } + } + } + return@Column + } + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { + SegmentedButton(selected = type == SendPayloadType.TEXT, onClick = { type = SendPayloadType.TEXT }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { + Text(stringResource(R.string.send_kind_text)) + } + SegmentedButton(selected = type == SendPayloadType.CREDENTIAL, onClick = { type = SendPayloadType.CREDENTIAL }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { + Text(stringResource(R.string.send_kind_login)) + } + } + if (type == SendPayloadType.TEXT) { + OutlinedTextField(text, { text = it }, label = { Text(stringResource(R.string.send_text_label)) }, minLines = 3, modifier = Modifier.fillMaxWidth()) + } else { + OutlinedTextField(username, { username = it }, label = { Text(stringResource(R.string.detail_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + password, { password = it }, label = { Text(stringResource(R.string.detail_password)) }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + ) + } + OutlinedTextField( + views, { views = it.filter(Char::isDigit).take(3) }, label = { Text(stringResource(R.string.send_views_label)) }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), + ) + Picker( + label = stringResource(R.string.send_expiry_label), + selected = expiryLabel(expiry), + options = SendExpiry.entries.map { expiryLabel(it) to it }, + onPick = { expiry = it }, + ) + if (expiry == SendExpiry.CUSTOM) { + OutlinedTextField( + hours, { hours = it.filter(Char::isDigit).take(4) }, label = { Text(stringResource(R.string.send_hours_label)) }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth(), + ) + } + OutlinedTextField( + sendPassword, { sendPassword = it }, label = { Text(stringResource(R.string.send_password_label)) }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + ) + problem?.form?.let { Text(sendProblemText(it), color = MaterialTheme.colorScheme.error) } + problem?.write?.let { Text(writeProblemText(it), color = MaterialTheme.colorScheme.error) } + Button(enabled = !busy, onClick = { + focus.clearFocus() + busy = true + problem = null + scope.launch { + val plaintext = if (type == SendPayloadType.CREDENTIAL) SendForm.credentialPayload(username, password) else text + val result = io { + session.sends.create(type, plaintext, views, expiry, hours, sendPassword, passwordAvailable = true) + } + busy = false + when (result) { + is SendResult.Done -> created = result.value + is SendResult.Problem -> problem = result + } + } + }) { Text(stringResource(R.string.send_create)) } + } +} + +@Composable +private fun expiryLabel(expiry: SendExpiry): String = stringResource( + when (expiry) { + SendExpiry.HOUR -> R.string.expiry_1h + SendExpiry.DAY -> R.string.expiry_1d + SendExpiry.TWO_DAYS -> R.string.expiry_2d + SendExpiry.THREE_DAYS -> R.string.expiry_3d + SendExpiry.SEVEN_DAYS -> R.string.expiry_7d + SendExpiry.THIRTY_DAYS -> R.string.expiry_30d + SendExpiry.CUSTOM -> R.string.expiry_custom + }, +) + +/** + * Opens a Send link on this phone (task 3.6), decrypting on the device as + * the public page does. Needs no account: the recipient may have none on + * that server. Opening uses a view, so the app asks first. + */ +@Composable +fun OpenSendScreen(initialLink: String, modifier: Modifier, onCopy: ((String) -> Unit)? = null) { + val client = remember { OpenSendClient.platform() } + var link by remember { mutableStateOf(initialLink) } + var state by remember { mutableStateOf(null) } + var password by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val parsed = SendLink.parse(link) + LaunchedEffect(parsed) { + state = null + if (parsed != null) state = io { client.peek(parsed) } + } + + Column(modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.open_send_title), style = MaterialTheme.typography.titleLarge) + val current = state + if (current !is OpenSendResult.Opened) { + OutlinedTextField( + link, { link = it.trim() }, label = { Text(stringResource(R.string.open_send_link)) }, singleLine = true, + isError = link.isNotEmpty() && parsed == null, + supportingText = if (link.isNotEmpty() && parsed == null) ({ Text(stringResource(R.string.open_send_invalid)) }) else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), + ) + } + val needsPassword = current is OpenSendResult.NeedsPassword || (current is OpenSendResult.WrongPassword && !current.burned) + when (current) { + is OpenSendResult.Ready -> Text(stringResource(R.string.open_send_ready)) + is OpenSendResult.NeedsPassword -> Text(stringResource(R.string.open_send_password)) + is OpenSendResult.WrongPassword -> Text( + if (current.burned) stringResource(R.string.open_send_burned) else stringResource(R.string.open_send_wrong, current.attemptsLeft.toInt()), + color = MaterialTheme.colorScheme.error, + ) + OpenSendResult.Gone -> Text(stringResource(R.string.open_send_gone)) + OpenSendResult.NoKey -> Text(stringResource(R.string.open_send_no_key)) + is OpenSendResult.Failed -> Text(stringResource(R.string.open_send_failed), color = MaterialTheme.colorScheme.error) + is OpenSendResult.Opened -> { + Card(Modifier.fillMaxWidth()) { Text(current.payload, modifier = Modifier.padding(12.dp)) } + if (current.burned) Text(stringResource(R.string.open_send_last_view), style = MaterialTheme.typography.bodySmall) + if (onCopy != null) OutlinedButton(onClick = { onCopy(current.payload) }) { Text(stringResource(R.string.action_copy)) } + } + null -> Unit + } + if (needsPassword) { + OutlinedTextField( + password, { password = it }, label = { Text(stringResource(R.string.detail_password)) }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth(), + ) + } + if (parsed != null && (current is OpenSendResult.Ready || needsPassword)) { + Button(enabled = !busy && (!needsPassword || password.isNotEmpty()), onClick = { + busy = true + scope.launch { + state = io { client.open(parsed, password) } + busy = false + } + }) { Text(stringResource(R.string.action_open)) } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SettingsScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SettingsScreen.kt new file mode 100644 index 000000000..c67b89b9d --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/SettingsScreen.kt @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import android.content.Intent +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import nl.conduction.keepiq.android.AppState +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.autofill.AutofillSettingsActivity +import nl.conduction.keepiq.shared.account.IdlePolicy +import nl.conduction.keepiq.shared.unlock.PinUnlock +import nl.conduction.keepiq.shared.unlock.UnlockedVault + +/** + * Autofill from inside the app: whether Keepiq is the autofill service, a + * button to choose it, and the way to the never-save list. Without this the + * autofill settings are only reachable from Android's own settings. The + * status is read again whenever the screen comes back from Android's picker. + */ +@Composable +fun AutofillEntry() { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + var enabled by remember { mutableStateOf(AutofillSettingsActivity.isKeepiq(context)) } + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) enabled = AutofillSettingsActivity.isKeepiq(context) + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + AutofillEntry( + enabled = enabled, + onChoose = { AutofillSettingsActivity.chooseKeepiq(context) }, + onNeverList = { context.startActivity(Intent(context, AutofillSettingsActivity::class.java)) }, + ) +} + +/** The autofill entry of the settings screen, without the system calls. */ +@Composable +fun AutofillEntry(enabled: Boolean, onChoose: () -> Unit, onNeverList: () -> Unit) { + Text(stringResource(R.string.autofill_settings_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + Text(stringResource(if (enabled) R.string.autofill_status_on else R.string.autofill_status_off), modifier = Modifier.testTag("settingsAutofillStatus")) + if (!enabled) { + Button(onClick = onChoose, modifier = Modifier.fillMaxWidth().testTag("settingsAutofillChoose")) { + Text(stringResource(R.string.autofill_choose_service)) + } + } + OutlinedButton(onClick = onNeverList, modifier = Modifier.fillMaxWidth().testTag("settingsAutofillNever")) { + Text(stringResource(R.string.autofill_open_never_list)) + } +} + +/** Unlock options (2.3), auto-lock (2.5), autofill (group 4) and disconnect (2.6) for the open vault. */ +@Composable +fun SettingsScreen(state: AppState, activity: FragmentActivity, vault: UnlockedVault) { + val busy by state.busy.collectAsState() + val message by state.message.collectAsState() + val maxIdle by state.maxIdle.collectAsState() + // Re-read after every change; the store is the truth. + var revision by remember { mutableIntStateOf(0) } + val settings = remember(revision, busy) { state.settings(vault.accountId) } + val biometricOn = remember(revision, busy) { state.biometric.isEnabled(vault.accountId) } + val pinSet = remember(revision, busy) { state.client.pins.has(vault.accountId) } + var newPin by remember { mutableStateOf("") } + var confirmUnpair by remember { mutableStateOf(false) } + val account = state.client.accounts.account(vault.accountId) + + ScreenColumn { + Text("Unlock and account", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + + Text("Unlock", style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text("Fingerprint or face", modifier = Modifier.weight(1f)) + Switch( + checked = biometricOn, + enabled = !busy && state.biometric.canUse(), + onCheckedChange = { on -> + if (on) state.enableBiometric(activity, vault) else state.disableBiometric(vault) + revision++ + }, + modifier = Modifier.testTag("biometricSwitch"), + ) + } + if (!state.biometric.canUse()) { + Text("Set up a fingerprint or face unlock in the phone settings to use it here.", style = MaterialTheme.typography.bodySmall) + } + + if (!state.client.pins.available) { + Text("PIN unlock is not available on this phone.") + } else if (pinSet) { + Text("A PIN is set. Five wrong PINs delete it.") + OutlinedButton(onClick = { state.removePin(vault); revision++ }, modifier = Modifier.testTag("removePin")) { Text("Remove the PIN") } + } else { + OutlinedTextField( + value = newPin, + onValueChange = { newPin = it }, + label = { Text("New PIN, 6 to 64 characters") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + supportingText = { PinUnlock.problem(newPin)?.takeIf { newPin.isNotEmpty() }?.let { Text(it) } }, + modifier = Modifier.fillMaxWidth().testTag("newPin"), + ) + Button( + onClick = { state.setPin(vault, newPin) { newPin = ""; revision++ } }, + enabled = !busy && PinUnlock.problem(newPin) == null, + modifier = Modifier.testTag("setPin"), + ) { Text("Set PIN") } + } + + HorizontalDivider() + Text("Lock after", style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + if (maxIdle != null) Text("Your organisation allows at most $maxIdle minutes.", style = MaterialTheme.typography.bodySmall) + Column(Modifier.selectableGroup()) { + for (minutes in IdlePolicy.offeredChoices(maxIdle)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = settings.idleMinutes == minutes, + role = Role.RadioButton, + onClick = { state.setIdleMinutes(vault, minutes); revision++ }, + ) + .testTag("idle$minutes"), + ) { + RadioButton(selected = settings.idleMinutes == minutes, onClick = null) + Text(if (minutes == 1) "1 minute" else "$minutes minutes") + } + } + } + + HorizontalDivider() + AutofillEntry() + + HorizontalDivider() + Text("Account", style = MaterialTheme.typography.titleMedium, modifier = Modifier.semantics { heading() }) + if (account != null) Text("${account.loginName} on ${account.server.removePrefix("https://")}") + OutlinedButton(onClick = { confirmUnpair = true }, enabled = !busy, modifier = Modifier.fillMaxWidth().testTag("unpair")) { + Text("Disconnect this account") + } + Problem(message) + TextButton(onClick = { state.closeSettings(vault) }, modifier = Modifier.testTag("back")) { Text("Back") } + } + + if (confirmUnpair) { + AlertDialog( + onDismissRequest = { confirmUnpair = false }, + title = { Text("Disconnect this account?") }, + text = { + Text("Keepiq deletes its app password in Nextcloud and removes everything it stored for this account on this phone.") + }, + confirmButton = { + TextButton(onClick = { confirmUnpair = false; state.unpair(vault.accountId) }, modifier = Modifier.testTag("confirmUnpair")) { + Text("Disconnect") + } + }, + dismissButton = { TextButton(onClick = { confirmUnpair = false }) { Text("Cancel") } }, + ) + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/UnlockScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/UnlockScreen.kt new file mode 100644 index 000000000..c38313dc5 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/UnlockScreen.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.fragment.app.FragmentActivity +import nl.conduction.keepiq.android.AppState +import nl.conduction.keepiq.shared.UnlockGate +import nl.conduction.keepiq.shared.account.AccountStore + +/** + * Unlock (tasks 2.2 and 2.3): the master password, or the PIN or the + * biometric prompt when the user turned them on. When the server reports + * `unlockBlocked`, the screen says why and shows no unlock field. + */ +@Composable +fun UnlockScreen(state: AppState, activity: FragmentActivity, accountId: String) { + val gate by state.gate.collectAsState() + val busy by state.busy.collectAsState() + val message by state.message.collectAsState() + val account = state.client.accounts.account(accountId) + val accounts = state.client.accounts.accounts() + var password by remember(accountId) { mutableStateOf("") } + var pin by remember(accountId) { mutableStateOf("") } + var usePassword by remember(accountId) { mutableStateOf(false) } + val pinSet = state.client.pins.has(accountId) + val biometricOn = state.biometric.isEnabled(accountId) && state.biometric.canUse() + + LaunchedEffect(accountId) { if (gate == null) state.refreshGate(accountId) } + LaunchedEffect(accountId, gate is UnlockGate.Ready, biometricOn) { + if (gate is UnlockGate.Ready && biometricOn) state.unlockWithBiometric(activity, accountId) + } + + ScreenColumn { + Text("Unlock Keepiq", style = MaterialTheme.typography.headlineSmall, modifier = Modifier.semantics { heading() }) + if (account != null) Text("${account.loginName} on ${account.server.removePrefix("https://")}", modifier = Modifier.testTag("account")) + + when (val g = gate) { + null -> CircularProgressIndicator() + is UnlockGate.Blocked -> { + Text(g.message, modifier = Modifier.testTag("blocked")) + OutlinedButton(onClick = { state.refreshGate(accountId) }, enabled = !busy) { Text("Check again") } + } + is UnlockGate.Ready -> { + if (g.offline) Text("You are offline. Keepiq unlocks with what this phone stored at the last unlock.") + if (biometricOn) { + OutlinedButton( + onClick = { state.unlockWithBiometric(activity, accountId) }, + enabled = !busy, + modifier = Modifier.fillMaxWidth().testTag("biometric"), + ) { Text("Unlock with fingerprint or face") } + } + if (pinSet && !usePassword) { + OutlinedTextField( + value = pin, + onValueChange = { pin = it }, + label = { Text("PIN") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().testTag("pin"), + ) + Button( + onClick = { state.unlockWithPin(accountId, pin); pin = "" }, + enabled = !busy && pin.isNotEmpty(), + modifier = Modifier.fillMaxWidth().testTag("unlockPin"), + ) { Text("Unlock with PIN") } + TextButton(onClick = { usePassword = true }) { Text("Use your master password") } + } else { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Master password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), + modifier = Modifier.fillMaxWidth().testTag("masterPassword"), + ) + Button( + onClick = { state.unlockWithMasterPassword(accountId, password); password = "" }, + enabled = !busy && password.isNotEmpty(), + modifier = Modifier.fillMaxWidth().testTag("unlock"), + ) { Text("Unlock") } + } + } + } + if (busy) CircularProgressIndicator(modifier = Modifier.testTag("busy")) + Problem(message) + + HorizontalDivider() + for (other in accounts.filter { it.id != accountId }) { + TextButton(onClick = { state.switchAccount(other.id) }) { + Text("Switch to ${other.loginName} on ${other.server.removePrefix("https://")}") + } + } + if (accounts.size < AccountStore.MAX_ACCOUNTS) { + TextButton(onClick = { state.addAccount() }, modifier = Modifier.testTag("addAccount")) { Text("Connect another account") } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultApp.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultApp.kt new file mode 100644 index 000000000..d28b1bd69 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultApp.kt @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:OptIn(ExperimentalMaterial3Api::class) + +package nl.conduction.keepiq.android.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Send +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.AndroidClipboard +import nl.conduction.keepiq.android.vault.HandlerScheduler +import nl.conduction.keepiq.android.vault.VaultPreferences +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.generator.GeneratorPolicy +import nl.conduction.keepiq.shared.generator.GeneratorSettings +import nl.conduction.keepiq.shared.vault.ClearDelay +import nl.conduction.keepiq.shared.vault.SensitiveClipboard + +/** Where the user is inside a tab. */ +sealed interface Route { + data class Folder(val id: String?) : Route + data class Detail(val id: String) : Route + data class Edit(val id: String?) : Route + data object Sends : Route + data class NewSend(val text: String = "", val username: String = "", val password: String = "") : Route + data object Generator : Route +} + +enum class Tab { VAULT, GENERATOR, SEND } + +/** + * The vault, Send and generator screens of one unlocked account (task + * group 3). The unlock flow hands in the [session]; [accounts] and the + * switch and add callbacks drive the account switcher. [onLock] and + * [onSettings] are the top bar's lock and settings; [onLocked] is called + * with the reason when a sync found the keys changed elsewhere. + */ +@Composable +fun VaultApp( + session: VaultSession, + accounts: List, + onSwitchAccount: (Account) -> Unit, + onAddAccount: (() -> Unit)?, + onLock: () -> Unit = {}, + onSettings: () -> Unit = {}, + onLocked: (String) -> Unit = {}, +) { + val context = LocalContext.current + val prefs = remember { VaultPreferences(context) } + val clipboard = remember { + SensitiveClipboard(AndroidClipboard(context), HandlerScheduler(), ClearDelay { prefs.clipboardClearSeconds }) + } + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var tab by remember(session) { mutableStateOf(Tab.VAULT) } + var stacks by remember(session) { + mutableStateOf( + mapOf( + Tab.VAULT to listOf(Route.Folder(null)), + Tab.GENERATOR to listOf(Route.Generator), + Tab.SEND to listOf(Route.Sends), + ), + ) + } + var showAccounts by remember { mutableStateOf(false) } + var policy by remember(session) { mutableStateOf(null) } + var generatorSettings by remember(session) { mutableStateOf(GeneratorSettings()) } + + LaunchedEffect(session) { + policy = io { GeneratorPolicy.fetch(session.api) } + } + + val stack = stacks.getValue(tab) + fun push(route: Route) { + stacks = stacks + (tab to stack + route) + } + fun pop() { + if (stack.size > 1) stacks = stacks + (tab to stack.dropLast(1)) + } + fun replaceTop(route: Route) { + stacks = stacks + (tab to stack.dropLast(1) + route) + } + BackHandler(enabled = stack.size > 1) { pop() } + + val copiedText = stringResource(R.string.copied) + val copiedKept = stringResource(R.string.copied_kept) + val copy: (String) -> Unit = { value -> + val seconds = clipboard.write(value) + scope.launch { snackbar.showSnackbar(if (seconds > 0) copiedText.format(seconds) else copiedKept) } + } + + Scaffold( + modifier = Modifier.testTag("unlocked"), + topBar = { + TopAppBar( + title = { Text(stringResource(titleOf(tab))) }, + navigationIcon = { + if (stack.size > 1) { + TextButton(onClick = { pop() }) { Text(stringResource(R.string.action_back)) } + } + }, + actions = { + IconButton(onClick = { showAccounts = true }) { + Icon(Icons.Filled.AccountCircle, contentDescription = stringResource(R.string.cd_switch_account, session.label)) + } + IconButton(onClick = onSettings, modifier = Modifier.testTag("settings")) { + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.cd_settings)) + } + IconButton(onClick = onLock, modifier = Modifier.testTag("lock")) { + Icon(Icons.Filled.Lock, contentDescription = stringResource(R.string.cd_lock)) + } + }, + ) + }, + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = tab == Tab.VAULT, + onClick = { tab = Tab.VAULT }, + icon = { Icon(Icons.Filled.Home, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_vault)) }, + ) + NavigationBarItem( + selected = tab == Tab.GENERATOR, + onClick = { tab = Tab.GENERATOR }, + icon = { Icon(Icons.Filled.Build, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_generator)) }, + ) + NavigationBarItem( + selected = tab == Tab.SEND, + onClick = { tab = Tab.SEND }, + icon = { Icon(Icons.Filled.Send, contentDescription = null) }, + label = { Text(stringResource(R.string.tab_send)) }, + ) + } + }, + // Above the list's add button, which lives in the content and not in + // the Scaffold's button slot, so the snackbar never covers it. + snackbarHost = { SnackbarHost(snackbar, modifier = Modifier.padding(bottom = 72.dp)) }, + ) { padding -> + val modifier = Modifier.padding(padding) + when (val route = stack.last()) { + is Route.Folder -> VaultListScreen( + session = session, + folderId = route.id, + modifier = modifier, + onOpenFolder = { push(Route.Folder(it)) }, + onOpenItem = { push(Route.Detail(it)) }, + onAddItem = { push(Route.Edit(null)) }, + onFolderGone = { pop() }, + onLocked = onLocked, + ) + is Route.Detail -> ItemDetailScreen( + session = session, + id = route.id, + modifier = modifier, + onCopy = copy, + onEdit = { push(Route.Edit(route.id)) }, + onGone = { pop() }, + onSendLogin = { username, password -> + tab = Tab.SEND + stacks = stacks + (Tab.SEND to listOf(Route.Sends, Route.NewSend(username = username, password = password))) + }, + ) + is Route.Edit -> ItemEditScreen( + session = session, + id = route.id, + folderId = stack.filterIsInstance().lastOrNull()?.id, + policy = policy, + generatorSettings = generatorSettings, + modifier = modifier, + onSaved = { savedId -> + if (route.id == null && savedId != null) replaceTop(Route.Detail(savedId)) else pop() + }, + onCancel = { pop() }, + ) + Route.Generator -> GeneratorScreen( + policy = policy, + settings = generatorSettings, + onSettings = { generatorSettings = it }, + onCopy = copy, + modifier = modifier, + ) + Route.Sends -> SendListScreen( + session = session, + modifier = modifier, + onNew = { push(Route.NewSend()) }, + onCopy = copy, + ) + is Route.NewSend -> NewSendScreen( + session = session, + initial = route, + modifier = modifier, + onCopy = copy, + onDone = { pop() }, + ) + } + } + + if (showAccounts) { + AccountsDialog( + session = session, + accounts = accounts, + prefs = prefs, + onSwitch = { + showAccounts = false + onSwitchAccount(it) + }, + onAdd = onAddAccount?.let { add -> { showAccounts = false; add() } }, + onDismiss = { showAccounts = false }, + ) + } +} + +private fun titleOf(tab: Tab): Int = when (tab) { + Tab.VAULT -> R.string.tab_vault + Tab.GENERATOR -> R.string.tab_generator + Tab.SEND -> R.string.tab_send +} + +/** The account switcher, with the clipboard delay that applies to every account. */ +@Composable +private fun AccountsDialog( + session: VaultSession, + accounts: List, + prefs: VaultPreferences, + onSwitch: (Account) -> Unit, + onAdd: (() -> Unit)?, + onDismiss: () -> Unit, +) { + var clearSeconds by remember { mutableStateOf(prefs.clipboardClearSeconds) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.accounts_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Column(Modifier.selectableGroup()) { + for (account in accounts) { + val label = "${account.loginName} · ${account.server.removePrefix("https://").trimEnd('/')}" + ListItem( + headlineContent = { Text(label) }, + leadingContent = { RadioButton(selected = account.id == session.account.id, onClick = null) }, + modifier = Modifier + .fillMaxWidth() + .selectable(selected = account.id == session.account.id, role = Role.RadioButton) { onSwitch(account) }, + ) + } + } + if (onAdd != null && accounts.size < 5) { + TextButton(onClick = onAdd) { Text(stringResource(R.string.accounts_add)) } + } else if (onAdd != null) { + Text(stringResource(R.string.accounts_limit), style = MaterialTheme.typography.bodySmall) + } + Text( + stringResource(R.string.settings_clipboard), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + ) + Column(Modifier.selectableGroup()) { + for (seconds in SensitiveClipboard.CLEAR_CHOICES) { + val label = when { + seconds == 0 -> stringResource(R.string.settings_clipboard_never) + seconds >= 120 -> stringResource(R.string.settings_clipboard_minutes, seconds / 60) + else -> stringResource(R.string.settings_clipboard_seconds, seconds) + } + ListItem( + headlineContent = { Text(label) }, + leadingContent = { RadioButton(selected = clearSeconds == seconds, onClick = null) }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = label } + .selectable(selected = clearSeconds == seconds, role = Role.RadioButton) { + clearSeconds = seconds + prefs.clipboardClearSeconds = seconds + }, + ) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_close)) } }, + ) +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultListScreen.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultListScreen.kt new file mode 100644 index 000000000..eb2d86080 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/ui/VaultListScreen.kt @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +@file:OptIn(ExperimentalMaterial3Api::class) + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nl.conduction.keepiq.android.R +import nl.conduction.keepiq.android.vault.VaultSession +import nl.conduction.keepiq.shared.sync.LockReason +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.sync.VaultSync +import nl.conduction.keepiq.shared.vault.IndexEntry +import nl.conduction.keepiq.shared.vault.ListState +import nl.conduction.keepiq.shared.vault.VaultIndex +import nl.conduction.keepiq.shared.vault.VaultState + +/** + * The vault list (task 3.1): one folder's subfolders and items, or, while + * searching, every match. Names and addresses only: nothing is decrypted + * here. Search runs on the device. + */ +@Composable +fun VaultListScreen( + session: VaultSession, + folderId: String?, + modifier: Modifier, + onOpenFolder: (String) -> Unit, + onOpenItem: (String) -> Unit, + onAddItem: () -> Unit, + onFolderGone: () -> Unit, + onLocked: (String) -> Unit = {}, +) { + val repository = session.repository + var state by remember(session) { mutableStateOf(repository.state.takeIf { it != VaultState.EMPTY }) } + var query by remember { mutableStateOf("") } + var folderMenu by remember { mutableStateOf(false) } + var folderDialog by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun sync(trigger: SyncTrigger) { + scope.launch { state = io { repository.refresh(trigger) } } + } + + LaunchedEffect(session) { + if (state == null) state = io { repository.refresh(SyncTrigger.START) } + while (true) { + delay(VaultSync.SYNC_INTERVAL_MILLIS) + state = io { repository.refresh(SyncTrigger.TIMER) } + } + } + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle, session) { + var started = false + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + if (started) sync(SyncTrigger.FOREGROUND) + started = true + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + + val current = state + val lockedText = current?.locked?.let { stringResource(lockText(it)) } + LaunchedEffect(lockedText) { if (lockedText != null) onLocked(lockedText) } + Box(modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 16.dp)) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text(stringResource(R.string.search_label)) }, + placeholder = { Text(stringResource(R.string.search_hint)) }, + singleLine = true, + modifier = Modifier.weight(1f).testTag("search"), + ) + IconButton(onClick = { sync(SyncTrigger.MANUAL) }) { + Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.action_refresh)) + } + IconButton(onClick = { folderMenu = true }) { + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.cd_folder_actions)) + } + DropdownMenu(expanded = folderMenu, onDismissRequest = { folderMenu = false }) { + DropdownMenuItem(text = { Text(stringResource(R.string.folder_new)) }, onClick = { + folderMenu = false + folderDialog = FolderDialog.Create(folderId) + }) + if (folderId != null) { + val name = current?.folders?.firstOrNull { it.id == folderId }?.name ?: "" + DropdownMenuItem(text = { Text(stringResource(R.string.folder_rename)) }, onClick = { + folderMenu = false + folderDialog = FolderDialog.Rename(folderId, name) + }) + DropdownMenuItem(text = { Text(stringResource(R.string.folder_delete)) }, onClick = { + folderMenu = false + folderDialog = FolderDialog.Delete(folderId, name) + }) + } + } + } + if (current != null) SyncNote(current) + when { + current == null -> Message(stringResource(R.string.vault_loading)) + current.locked != null -> Message(stringResource(lockText(current.locked!!))) + current.needsConnection -> Message(stringResource(R.string.vault_needs_connection)) + else -> VaultEntries(current, folderId, query, onOpenFolder, onOpenItem) + } + } + if (current != null && !current.offline && current.locked == null) { + FloatingActionButton( + onClick = onAddItem, + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), + ) { Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_add_item)) } + } + } + + folderDialog?.let { dialog -> + FolderDialogs( + dialog = dialog, + session = session, + onDone = { changed, gone -> + folderDialog = null + if (changed) state = repository.state + if (gone) onFolderGone() + }, + ) + } +} + +@Composable +private fun SyncNote(state: VaultState) { + val text = when { + state.offline && state.syncedAtMillis != null -> stringResource(R.string.vault_offline, relativeTime(state.syncedAtMillis!!)) + state.offline -> stringResource(R.string.vault_offline_never) + state.onlineOnly -> stringResource(R.string.vault_online_only) + state.syncedAtMillis != null -> stringResource(R.string.vault_synced, relativeTime(state.syncedAtMillis!!)) + else -> return + } + val colors = if (state.offline) { + CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) + } else { + CardDefaults.cardColors() + } + Card(colors = colors, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(12.dp)) + } + state.problem?.let { + Text(writeProblemText(it), color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 16.dp)) + } +} + +private fun lockText(reason: LockReason): Int = when (reason) { + LockReason.SUITE_CHANGED -> R.string.vault_locked_suite + LockReason.MASTER_PASSWORD_CHANGED -> R.string.vault_locked_password + LockReason.UNLOCK_BLOCKED -> R.string.vault_locked_two_factor +} + +@Composable +private fun VaultEntries( + state: VaultState, + folderId: String?, + query: String, + onOpenFolder: (String) -> Unit, + onOpenItem: (String) -> Unit, +) { + val index = state.index + val searching = query.isNotBlank() + val folderIds = state.folders.map { it.id }.toSet() + val items = when { + searching -> VaultIndex.filter(index, query) + folderId != null -> VaultIndex.filter(index, folderId = folderId) + // The top level also holds items whose folder is gone, so none is ever out of reach. + else -> index.filter { it.folderId == null || it.folderId !in folderIds } + } + val folders = if (searching) emptyList() else VaultIndex.subfolders(state.folders, folderId) + val listState = VaultIndex.listState(index, if (searching) items else index) + when { + listState == ListState.EMPTY -> Message(stringResource(R.string.vault_empty)) + listState == ListState.ALL_BLOCKED && !searching -> Message(stringResource(R.string.vault_all_blocked)) + searching && items.isEmpty() -> Message(stringResource(R.string.vault_no_match)) + !searching && items.isEmpty() && folders.isEmpty() -> Message(stringResource(R.string.vault_folder_empty)) + else -> LazyColumn(Modifier.fillMaxSize()) { + items(folders, key = { "f-" + it.id }) { folder -> + val description = stringResource(R.string.cd_open_folder, folder.name) + ListItem( + headlineContent = { Text(folder.name) }, + leadingContent = { Icon(Icons.Filled.List, contentDescription = null) }, + modifier = Modifier + .semantics { contentDescription = description } + .clickable { onOpenFolder(folder.id) }, + ) + HorizontalDivider() + } + items(items, key = { "s-" + it.id }) { entry -> + EntryRow(entry, showFolder = searching, onOpenItem) + HorizontalDivider() + } + } + } +} + +@Composable +private fun EntryRow(entry: IndexEntry, showFolder: Boolean, onOpenItem: (String) -> Unit) { + val badges = buildList { + if (entry.useOnly) add(stringResource(R.string.badge_use_only)) + if (entry.readOnly && !entry.useOnly) add(stringResource(R.string.badge_read_only)) + if (entry.blocked) add(stringResource(R.string.badge_blocked)) + } + val host = entry.url.removePrefix("https://").removePrefix("http://").substringBefore('/') + val supporting = listOfNotNull( + host.ifEmpty { null }, + entry.folderName.takeIf { showFolder && it.isNotEmpty() }, + badges.joinToString(", ").ifEmpty { null }, + ).joinToString(" · ") + ListItem( + headlineContent = { Text(entry.name) }, + supportingContent = if (supporting.isNotEmpty()) ({ Text(supporting) }) else null, + modifier = Modifier.clickable(onClickLabel = stringResource(R.string.cd_open_item, entry.name)) { onOpenItem(entry.id) }, + ) +} + +@Composable +fun Message(text: String) { + Text(text, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(24.dp)) +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/AndroidClipboard.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/AndroidClipboard.kt new file mode 100644 index 000000000..680d6f129 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/AndroidClipboard.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.vault + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PersistableBundle +import nl.conduction.keepiq.shared.vault.PendingClear +import nl.conduction.keepiq.shared.vault.ClipboardPort +import nl.conduction.keepiq.shared.vault.ScheduledAction +import nl.conduction.keepiq.shared.vault.ClearScheduler +import nl.conduction.keepiq.shared.vault.SensitiveClipboard + +/** + * The system clipboard for [SensitiveClipboard] (task 3.3). Every clip is + * marked sensitive with ClipDescription.EXTRA_IS_SENSITIVE (and its + * pre-Android 13 key), so the system and keyboards hide the preview. + * + * Android 10 and later only lets the focused app read the clipboard, so + * "is it still ours" is answered by the last token this app wrote and by + * the change listener while the app is in front. + */ +class AndroidClipboard(context: Context) : ClipboardPort { + private val manager = context.getSystemService(ClipboardManager::class.java) + private var ourToken: String? = null + + init { + manager.addPrimaryClipChangedListener { + val label = manager.primaryClipDescription?.label?.toString() + if (label != LABEL) ourToken = null + } + } + + override fun writeSensitive(text: String, expiresInSeconds: Int) { + val clip = ClipData.newPlainText(LABEL, text) + clip.description.extras = PersistableBundle().apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } else { + putBoolean("android.content.extra.IS_SENSITIVE", true) + } + } + manager.setPrimaryClip(clip) + ourToken = SensitiveClipboard.tokenOf(text) + } + + override fun clearIfOurs(token: String) { + if (ourToken != token) return + manager.clearPrimaryClip() + ourToken = null + } + + companion object { + const val LABEL = "Keepiq" + } +} + +/** Main-thread timers for [SensitiveClipboard]. */ +class HandlerScheduler : ClearScheduler { + private val handler = Handler(Looper.getMainLooper()) + + override fun schedule(delayMillis: Long, action: ScheduledAction): PendingClear { + val runnable = Runnable { action.run() } + handler.postDelayed(runnable, delayMillis) + return PendingClear { handler.removeCallbacks(runnable) } + } +} + +/** The app's own settings for these screens, per device. */ +class VaultPreferences(context: Context) { + private val prefs = context.getSharedPreferences("keepiq-vault", Context.MODE_PRIVATE) + + var clipboardClearSeconds: Int + get() = prefs.getInt(KEY_CLIPBOARD, SensitiveClipboard.DEFAULT_CLEAR_SECONDS) + set(value) = prefs.edit().putInt(KEY_CLIPBOARD, value).apply() + + private companion object { + const val KEY_CLIPBOARD = "clipboard-clear-seconds" + } +} diff --git a/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/VaultSession.kt b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/VaultSession.kt new file mode 100644 index 000000000..69a114ba6 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/nl/conduction/keepiq/android/vault/VaultSession.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.vault + +import android.content.Context +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.autofill.AutofillIndexHub +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.send.SendService +import nl.conduction.keepiq.shared.store.UnlockKeySealer +import nl.conduction.keepiq.shared.store.VaultStore +import nl.conduction.keepiq.shared.store.openEncryptedDriver +import nl.conduction.keepiq.shared.sync.SyncListener +import nl.conduction.keepiq.shared.sync.VaultSync +import nl.conduction.keepiq.shared.unlock.UnlockedVault +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultRepository + +/** + * One unlocked account, as the vault, Send and generator screens use it. + * The unlock flow (task group 2) builds it from the paired [account], the + * [keys] it opened, and the account's store and sync ([store] and [sync] + * are null when the organisation keeps no offline copy). + */ +class VaultSession( + val account: Account, + val api: KeepiqApi, + val keys: VaultKeys, + private val store: VaultStore?, + sync: VaultSync?, +) { + val repository = VaultRepository( + api, keys, store, sync, + clock = { System.currentTimeMillis() }, + onRefreshed = { AutofillIndexHub.refreshed(account.id, it, keys) }, + ) + val sends = SendService(api) + + /** "alice on cloud.example.nl", for the account switcher. */ + val label: String get() = "${account.loginName} · ${account.server.removePrefix("https://").trimEnd('/')}" + + /** + * On lock: the private key and the store's sealing key leave memory and + * the store closes. Nothing decrypts with this session again. + */ + fun close() { + keys.forget() + runCatching { store?.close() } + } + + companion object { + /** + * The session of a vault the unlock flow just opened: the SQLCipher + * store of the account, sealed with the unlock key (design D5), and + * its sync. [listener] hears when a sync finds the keys changed. + */ + fun open(context: Context, api: KeepiqApi, account: Account, vault: UnlockedVault, listener: SyncListener): VaultSession { + val unlockKey = vault.unlockKey() + val sealer = try { + UnlockKeySealer(unlockKey) + } finally { + unlockKey.fill(0) + } + val store = VaultStore(openEncryptedDriver(context, account.id), sealer) + val sync = VaultSync(api, store, listener, clock = { System.currentTimeMillis() }) + return VaultSession(account, api, vault.keys(), store, sync) + } + } +} diff --git a/mobile/android/app/src/main/res/drawable/ic_autofill_key.xml b/mobile/android/app/src/main/res/drawable/ic_autofill_key.xml new file mode 100644 index 000000000..098e0e191 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_autofill_key.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/layout/autofill_item.xml b/mobile/android/app/src/main/res/layout/autofill_item.xml new file mode 100644 index 000000000..dddd31c0a --- /dev/null +++ b/mobile/android/app/src/main/res/layout/autofill_item.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/raw/privileged_browsers.json b/mobile/android/app/src/main/res/raw/privileged_browsers.json new file mode 100644 index 000000000..b91b5a4a4 --- /dev/null +++ b/mobile/android/app/src/main/res/raw/privileged_browsers.json @@ -0,0 +1,328 @@ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.android.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fenix", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "50:04:77:90:88:E7:F9:88:D5:BC:5C:C5:F8:79:8F:EB:F4:F8:CD:08:4A:1B:2A:46:EF:D4:C8:EE:4A:EA:F2:11" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.klar", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "app.vanadium.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BB:7B:B3:1C:57:3C:46:A1:DA:7F:C5:C5:28:A6:AC:F4:32:10:84:56:FE:EC:50:81:0C:7F:33:69:4E:B3:D2:D4" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "net.waterfox.android.release", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "29:39:99:7A:2D:8F:07:30:3C:EB:37:AD:68:10:AF:EF:0B:DA:71:0B:E2:11:64:76:E3:52:5A:73:79:EC:2E:1A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "5D:6A:FB:F8:7F:65:2A:F0:46:47:AD:A0:DF:63:4C:F2:23:70:90:0B:16:4B:09:D5:0B:D2:3A:A2:CB:52:85:B8" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.opera.browser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "5D:6A:FB:F8:7F:65:2A:F0:46:47:AD:A0:DF:63:4C:F2:23:70:90:0B:16:4B:09:D5:0B:D2:3A:A2:CB:52:85:B8" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/mobile/android/app/src/main/res/values-nl/passkey_strings.xml b/mobile/android/app/src/main/res/values-nl/passkey_strings.xml new file mode 100644 index 000000000..ca0cd9d39 --- /dev/null +++ b/mobile/android/app/src/main/res/values-nl/passkey_strings.xml @@ -0,0 +1,13 @@ + + + + + + Je aanmelding wordt klaargezet + Versleuteld bewaard in je Keepiq-kluis. + Passkeys + Keepiq bewaart je passkeys en meldt je ermee aan in apps en browsers. + Kies Keepiq bij wachtwoorden en passkeys om passkeys op te slaan en te gebruiken. + Kies Keepiq voor passkeys + Passkeys hebben Android 14 nodig. Keepiq vult je wachtwoorden en codes nog steeds in. + diff --git a/mobile/android/app/src/main/res/values-nl/strings.xml b/mobile/android/app/src/main/res/values-nl/strings.xml new file mode 100644 index 000000000..42766d741 --- /dev/null +++ b/mobile/android/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,229 @@ + + + + + Kluis + Generator + Send + Terug + Annuleren + Opslaan + Verwijderen + Sluiten + Kopiëren + Tonen + Verbergen + Bewerken + Verplaatsen + Naar de prullenbak + Nu synchroniseren + Opnieuw proberen + Openen + Delen + Toevoegen + Weghalen + Genereren + Gebruik deze + %1$s kopiëren + %1$s tonen + %1$s verbergen + Map %1$s openen + %1$s openen + Ander account, nu %1$s + Item toevoegen + Nieuwe Send + Mapacties + Veld %1$s weghalen + Nieuwe waarde genereren + Gekopieerd. Wordt over %1$d seconden gewist. + Gekopieerd. + Accounts + Account toevoegen + Je kunt tot 5 accounts koppelen. + Klembord wissen na + Nooit + %1$d seconden + %1$d minuten + Zoek in de kluis + Naam of adres + Geen map + Mappen + Items + Je kluis wordt geladen + Je kluis is leeg. Voeg een item toe om te beginnen. + Niets komt overeen met je zoekopdracht. + Elk item is hier geblokkeerd. De sleutel werkt niet op dit apparaat. + Deze map is leeg. + Laatst gesynchroniseerd %1$s + Je bent offline. Je ziet de kopie van %1$s. Bewerken kan alleen met verbinding. + Je bent offline. Bewerken kan alleen met verbinding. + Je organisatie bewaart geen kopie op dit apparaat. Maak verbinding om je kluis te openen. + Je organisatie bewaart geen kopie op dit apparaat. + Je versleutelingssleutels zijn gewijzigd. Ontgrendel opnieuw. + Je hoofdwachtwoord is gewijzigd. Ontgrendel opnieuw. + Meld je eerst in de webapp aan met tweestapsverificatie. + Alleen gebruiken + Alleen lezen + Geblokkeerd + Gebruikersnaam + Wachtwoord + Waarde + Adres + Notities + Map + Verificatiecode + Nog %1$d seconden + Dit is geen geldige verificatiesleutel. + Kaart eindigend op %1$s + Site + Account + Gemaakt + De website die deze passkey gebruikt, maakt en wijzigt hem. + Extra velden + De extra velden zijn niet te lezen. + Gedeeld om alleen te gebruiken. Je kunt het invullen, maar de waarde niet zien of kopiëren. + Je kunt dit item alleen lezen. De server weigert wijzigingen. + Dit item kan hier niet worden geopend. + Je bent offline. Dit is de kopie van je laatste synchronisatie. + Dit item bestaat niet meer. + %1$s naar de prullenbak verplaatsen? Je kunt het herstellen in de webapp. + Naar map verplaatsen + Nieuw item + Item bewerken + Soort + Naam + Websiteadres + Verificatiesleutel of otpauth-link + Veld toevoegen + Veldnaam + Veldwaarde + Van een passkey kun je alleen de naam, het adres, de map en de notities wijzigen. + Verplicht + Opgeslagen. + Geef het item een naam. + Dit is te lang om op te slaan. + Geef het veld een naam. + Deze naam is gereserveerd. + Een ander veld heeft deze naam. + Dit is geen geldige verificatiesleutel. + Vul dit veld in. + Bewerken kan alleen met verbinding. Er is niets gewijzigd. + Je kluis krijgt nieuwe sleutels. Probeer het later opnieuw. + Je versleutelingssuite is geblokkeerd. Open Keepiq op het web om dit op te lossen. + De server weigerde: %1$s + De server weigerde deze wijziging. + De server is niet bereikbaar. + Opslaan is mislukt. + Nieuwe map + Map hernoemen + Map verwijderen + Mapnaam + Geef de map een naam. + Een mapnaam mag geen schuine streep bevatten. + De lege map %1$s verwijderen? + %1$s bevat items. Wat gebeurt ermee? + Items eruit halen + Items ook verwijderen + Deze map heeft submappen. Verwijder hem in de webapp, waar je per submap kiest. + Wachtwoord + Wachtzin + Lengte: %1$d + A tot Z + a tot z + 0 tot 9 + Symbolen + Minstens %1$d cijfers + Minstens %1$d symbolen + Geen tekens die op elkaar lijken + Woorden: %1$d + Scheidingsteken + Woorden met hoofdletter + Cijfer toevoegen + Je organisatie vraagt minstens %1$d tekens. + Je organisatie vereist dit. + Je organisatie heeft wachtzinnen uitgezet. + Dit kan niet worden gegenereerd: %1$s + kies minstens één soort teken. + er blijven te weinig tekens over na de uitsluitingen. + de lengte valt buiten het bereik. + de opties passen niet bij elkaar. + Je sends + Je hebt geen actieve sends. + Tekst-send + Inlog-send + %1$s, %2$s + %1$d van %2$d keer bekeken + Wachtwoord + Verlopen + Verloopt over %1$d minuten + Verloopt over %1$d uur + Verloopt over %1$d dagen + Deze send beëindigen? De link werkt dan niet meer. + Nieuwe Send + Tekst + Inlog + Tekst om te versturen + Aantal keer bekijken + Verloopt na + Uren + Wachtwoord (optioneel) + Dit apparaat kan een send nog niet met een wachtwoord beveiligen. + Link maken + Je link is klaar. Iedereen met de link kan de send openen. + Je link is klaar. Deel het wachtwoord op een andere manier. + Link kopiëren + Er is niets om te versturen. + Kies 1 tot 100 keer. + Vul een heel aantal uren in, minstens 1. + Hoogstens 720 uur (30 dagen). + 1 uur + 1 dag + 2 dagen + 3 dagen + 7 dagen + 30 dagen + Zelf kiezen + Send openen + Send-link + Dit is geen Keepiq Send-link. + Openen toont de tekst en telt als één keer bekijken. + Deze send is beveiligd met een wachtwoord. + Verkeerd wachtwoord. Nog %1$d pogingen voordat de send wordt vernietigd. + Te vaak een verkeerd wachtwoord. De send is vernietigd. + Deze send bestaat niet meer. Hij is verlopen of al geopend. + De link mist de sleutel, dus de send kan niet open. + De send kon niet worden geopend. + Dit was de laatste keer. De send is nu weg. + Send-link openen + Kaartnummer + Vervaldatum (MM/JJ) + CVC + Pincode + Kaarthouder + Voornaam + Achternaam + Adres + Telefoon + E-mail + BSN + Kluis vergrendelen + Ontgrendelen en accountinstellingen + + Keepiq ontgrendelen + Ontgrendel om je login in te vullen + Code van %1$s + Kies een login + Keepiq heeft geen login voor deze app of site. + Login opgeslagen in Keepiq. + Wachtwoord bijgewerkt in Keepiq. + Keepiq kon deze login niet opslaan. Voeg hem toe in de app. + Automatisch invullen + Keepiq vult je logins in, in apps en browsers. + Keepiq vult nog geen logins in. Kies Keepiq als dienst voor automatisch invullen. + Kies Keepiq voor automatisch invullen + Automatisch invullen uitzetten + Nooit opslaan op + Keepiq biedt op elke site en in elke app aan je login op te slaan. + Weer opslaan op %1$s + Toon waar Keepiq nooit opslaat + diff --git a/mobile/android/app/src/main/res/values/autofill_theme.xml b/mobile/android/app/src/main/res/values/autofill_theme.xml new file mode 100644 index 000000000..593a0f025 --- /dev/null +++ b/mobile/android/app/src/main/res/values/autofill_theme.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/mobile/android/app/src/main/res/values/passkey_strings.xml b/mobile/android/app/src/main/res/values/passkey_strings.xml new file mode 100644 index 000000000..cea884a0e --- /dev/null +++ b/mobile/android/app/src/main/res/values/passkey_strings.xml @@ -0,0 +1,13 @@ + + + + + + Getting your sign-in ready + Saved encrypted in your Keepiq vault. + Passkeys + Keepiq saves your passkeys and signs you in with them in apps and browsers. + Choose Keepiq under passwords and passkeys to save and use passkeys. + Choose Keepiq for passkeys + Passkeys need Android 14. Keepiq still fills in your passwords and codes. + diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..1e6c13bcf --- /dev/null +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -0,0 +1,231 @@ + + + + + Keepiq + + Vault + Generator + Send + Back + Cancel + Save + Delete + Close + Copy + Show + Hide + Edit + Move + Move to trash + Sync now + Try again + Open + Share + Add + Remove + Generate + Use this + Copy %1$s + Show %1$s + Hide %1$s + Open folder %1$s + Open %1$s + Switch account, now %1$s + Add an item + New Send + Folder actions + Remove field %1$s + Generate a new value + Copied. Cleared in %1$d seconds. + Copied. + Accounts + Add an account + You can pair up to 5 accounts. + Clear the clipboard after + Never + %1$d seconds + %1$d minutes + Search the vault + Name or address + No folder + Folders + Items + Loading your vault + Your vault is empty. Add an item to start. + Nothing matches your search. + Every item is blocked here. Its key cannot be used on this device. + This folder is empty. + Last synced %1$s + You are offline. You see the copy from %1$s. Edits need a connection. + You are offline. Edits need a connection. + Your organisation keeps no copy on this device. Connect to open your vault. + Your organisation keeps no copy on this device. + Your encryption keys changed. Unlock again. + Your master password changed. Unlock again. + Sign in with two-step verification in the web app first. + Use only + Read only + Blocked + User name + Password + Value + Address + Notes + Folder + Authenticator code + %1$d seconds left + This is not a valid authenticator secret. + Card ending in %1$s + Site + Account + Created + The website that uses this passkey creates and updates it. + Additional fields + Could not read the additional fields. + Shared as use only. You can fill it, but not view or copy the value. + You can only read this item. The server refuses changes. + This item cannot be opened here. + You are offline. This is the copy from your last sync. + This item no longer exists. + Move %1$s to the trash? You can restore it in the web app. + Move to folder + New item + Edit item + Type + Name + Website address + Authenticator secret or otpauth link + Add a field + Field name + Field value + Only the name, address, folder and notes of a passkey can change. + Required + Saved. + Give the item a name. + This is too long to save. + Give the field a name. + This name is reserved. + Another field has this name. + This is not a valid authenticator secret. + Fill in this field. + Edits need a connection. Nothing was changed. + Your vault is being moved to new keys. Try again later. + Your encryption suite is blocked. Open Keepiq on the web to resolve it. + The server refused: %1$s + The server refused this change. + Could not reach the server. + Saving failed. + New folder + Rename folder + Delete folder + Folder name + Give the folder a name. + A folder name cannot contain a slash. + Delete the empty folder %1$s? + %1$s holds items. What happens to them? + Move the items out + Delete the items too + This folder has subfolders. Delete it in the web app, where you choose what happens to each one. + Password + Passphrase + Length: %1$d + A to Z + a to z + 0 to 9 + Symbols + At least %1$d digits + At least %1$d symbols + Avoid look-alike characters + Words: %1$d + Separator + Capitalise words + Add a number + Your organisation asks for at least %1$d characters. + Your organisation requires this. + Your organisation switched passphrases off. + This cannot be generated: %1$s + choose at least one kind of character. + too few characters are left after the exclusions. + the length is out of range. + the options do not fit together. + Your sends + You have no active sends. + Text send + Login send + %1$s, %2$s + %1$d of %2$d views used + Password + Expired + Expires in %1$d minutes + Expires in %1$d hours + Expires in %1$d days + End this send? The link stops working. + New Send + Text + Login + Text to send + Views + Expires after + Hours + Password (optional) + This device cannot protect a send with a password yet. + Create link + Your link is ready. Anyone with it can open the send. + Your link is ready. Share the password another way. + Copy link + There is nothing to send. + Choose 1 to 100 views. + Enter a whole number of hours, at least 1. + At most 720 hours (30 days). + 1 hour + 1 day + 2 days + 3 days + 7 days + 30 days + Custom + Open a Send + Send link + This is not a Keepiq Send link. + Opening shows the text and uses one view. + This send is protected with a password. + Wrong password. %1$d tries left before the send is destroyed. + Too many wrong passwords. The send is destroyed. + This send does not exist anymore. It expired or was opened already. + The link misses its key, so the send cannot be opened. + Could not open the send. + This was the last view. The send is gone now. + Open a Send link + Card number + Expiry (MM/YY) + CVV + PIN + Cardholder + First name + Last name + Address + Phone + Email + BSN + Lock the vault + Unlock and account settings + + Unlock Keepiq + Unlock to fill in your login + Code from %1$s + Choose a login + Keepiq has no login for this app or site. + Login saved in Keepiq. + Password updated in Keepiq. + Keepiq could not save this login. Add it in the app. + Autofill + Keepiq fills in your logins in apps and browsers. + Keepiq does not fill in logins yet. Choose Keepiq as your autofill service. + Choose Keepiq for autofill + Turn off autofill + Never save on + Keepiq offers to save your logins on every site and in every app. + Save on %1$s again + Show where Keepiq never saves + diff --git a/mobile/android/app/src/main/res/xml/autofill_service.xml b/mobile/android/app/src/main/res/xml/autofill_service.xml new file mode 100644 index 000000000..06feaa0f2 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/autofill_service.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/mobile/android/app/src/main/res/xml/credential_provider.xml b/mobile/android/app/src/main/res/xml/credential_provider.xml new file mode 100644 index 000000000..52d6095e3 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/credential_provider.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..dc0812427 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/PasskeySettingsTest.kt b/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/PasskeySettingsTest.kt new file mode 100644 index 000000000..f417d2f2d --- /dev/null +++ b/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/PasskeySettingsTest.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import nl.conduction.keepiq.android.passkey.PasskeySettings +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The passkeys part of the autofill settings (mobile-passkey-provider, + * "Android before version 14"): Android 13 hears that passkeys need + * Android 14 while passwords and codes still fill; Android 14 gets the way + * to choose Keepiq, until it is chosen. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PasskeySettingsTest { + @get:Rule + val compose = createComposeRule() + + private fun show(sdk: Int, enabled: Boolean) = compose.setContent { + MaterialTheme { Column { PasskeySettings(sdk = sdk, enabled = enabled) } } + } + + @Test + fun androidThirteenExplainsThatPasskeysNeedAndroidFourteen() { + show(33, enabled = false) + compose.onNodeWithText("Passkeys need Android 14. Keepiq still fills in your passwords and codes.").assertIsDisplayed() + compose.onNodeWithTag("passkeyChoose").assertDoesNotExist() + } + + @Test + fun androidFourteenOffersToChooseKeepiqUntilItIsChosen() { + show(34, enabled = false) + compose.onNodeWithText("Choose Keepiq for passkeys").assertIsDisplayed() + } + + @Test + fun aChosenProviderSaysWhatItDoes() { + show(34, enabled = true) + compose.onNodeWithText("Keepiq saves your passkeys and signs you in with them in apps and browsers.").assertIsDisplayed() + compose.onNodeWithTag("passkeyChoose").assertDoesNotExist() + } +} diff --git a/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/ScreensTest.kt b/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/ScreensTest.kt new file mode 100644 index 000000000..11dc8c9eb --- /dev/null +++ b/mobile/android/app/src/test/kotlin/nl/conduction/keepiq/android/ui/ScreensTest.kt @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import nl.conduction.keepiq.shared.generator.GeneratorPolicy +import nl.conduction.keepiq.shared.generator.GeneratorSettings +import nl.conduction.keepiq.shared.vault.DecryptedItem +import nl.conduction.keepiq.shared.vault.SecretType +import nl.conduction.keepiq.shared.vault.TypeField +import nl.conduction.keepiq.shared.vault.VaultRow +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The item detail and generator screens on the JVM (tasks 3.1 and 3.5), + * through Compose's semantics tree, which is also what TalkBack reads: + * a password is hidden until shown, every Show and Copy button names its + * field, a use-only copy offers neither, and the policy locks the kinds of + * character it requires. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ScreensTest { + @get:Rule + val compose = createComposeRule() + + private val login = SecretType("1", "login", "Login", listOf(TypeField("pin", "Pincode", "hidden", false))) + + private fun row(useOnly: Boolean) = VaultRow( + id = "s1", name = "Huisbank", url = "https://mijn.huisbank.example", typeId = "1", folderId = null, + key = "ct", login = "ct", additionalFields = null, updatedAt = null, + useOnly = useOnly, readOnly = useOnly, blocked = false, blockedReason = null, + ) + + private fun item(useOnly: Boolean) = DecryptedItem( + row = row(useOnly), typeName = "login", type = login, login = "alice", + secret = if (useOnly) "" else "geheim-1", + additionalFields = if (useOnly) null else Json.parseToJsonElement("""{"Pincode":"4321"}""").jsonObject, + additionalFieldsError = false, passkey = null, fromCache = false, + ) + + private fun show(item: DecryptedItem, copied: MutableList) = compose.setContent { + MaterialTheme { + ItemDetail( + item = item, folderPath = "Geen map", offline = false, problem = null, modifier = Modifier, + onCopy = { copied += it }, onEdit = {}, onMove = {}, onTrash = {}, onSendLogin = {}, + ) + } + } + + @Test + fun aPasswordIsHiddenUntilShownAndCopiesThroughTheCallback() { + val copied = mutableListOf() + show(item(useOnly = false), copied) + compose.onNodeWithText("geheim-1").assertDoesNotExist() + compose.onNodeWithContentDescription("Show Password").performClick() + compose.onNodeWithText("geheim-1").assertIsDisplayed() + compose.onNodeWithContentDescription("Copy Password").performClick() + compose.onNodeWithContentDescription("Copy User name").performClick() + assertEquals(listOf("geheim-1", "alice"), copied) + // A hidden typed field is masked too, and names itself. + compose.onNodeWithText("4321").assertDoesNotExist() + compose.onNodeWithContentDescription("Show Pincode").assertExists() + } + + @Test + fun aUseOnlyCopyOffersNoRevealAndNoCopyOfItsValue() { + show(item(useOnly = true), mutableListOf()) + compose.onNodeWithContentDescription("Show Password").assertDoesNotExist() + compose.onNodeWithContentDescription("Copy Password").assertDoesNotExist() + compose.onNodeWithText("Shared as use only. You can fill it, but not view or copy the value.").assertExists() + compose.onNodeWithText("Edit").assertDoesNotExist() + } + + @Test + fun thePolicyLocksTheDigitSwitchAndRaisesTheLength() { + val policy = GeneratorPolicy.from( + Json.parseToJsonElement("""{"policy_enabled":true,"generator_min_length":20,"generator_require_digit":true}""").jsonObject, + ) + var value = "" + compose.setContent { + MaterialTheme { + GeneratorScreen( + policy = policy, + settings = GeneratorSettings(password = GeneratorSettings.DEFAULT_PASSWORD.copy(includeDigits = false)), + onSettings = {}, + onCopy = null, + modifier = Modifier, + onValue = { value = it }, + ) + } + } + compose.onNodeWithText("Length: 20").assertExists() + compose.onNodeWithText("Your organisation asks for at least 20 characters.").assertExists() + compose.onNodeWithText("0 to 9", useUnmergedTree = true).assertExists() + compose.waitForIdle() + assertEquals(20, value.length) + assert(value.any { it.isDigit() }) { value } + } + + @Test + fun theSettingsAutofillEntryOffersTheChoiceOnlyWhenKeepiqIsNotTheService() { + var enabled by mutableStateOf(false) + val calls = mutableListOf() + compose.setContent { + MaterialTheme { + Column { AutofillEntry(enabled = enabled, onChoose = { calls += "choose" }, onNeverList = { calls += "never" }) } + } + } + compose.onNodeWithText("Keepiq does not fill in logins yet. Choose Keepiq as your autofill service.").assertExists() + compose.onNodeWithText("Choose Keepiq for autofill").performClick() + compose.onNodeWithText("Show where Keepiq never saves").performClick() + assertEquals(listOf("choose", "never"), calls) + + enabled = true + compose.onNodeWithText("Keepiq fills in your logins in apps and browsers.").assertExists() + compose.onNodeWithText("Choose Keepiq for autofill").assertDoesNotExist() + compose.onNodeWithText("Show where Keepiq never saves").assertExists() + } +} diff --git a/mobile/android/otherapp/build.gradle.kts b/mobile/android/otherapp/build.gradle.kts new file mode 100644 index 000000000..152c78cbc --- /dev/null +++ b/mobile/android/otherapp/build.gradle.kts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +// A stand-alone login form for the end-to-end tests, and nothing else +// (PackageVisibilityTest in :android:app). It is a separate APK on purpose: +// Android makes an instrumentation APK and the app it tests visible to each +// other, so the test APK's own forms cannot show whether Keepiq can read the +// identity of an ordinary app that asks for autofill. Never released. +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "nl.conduction.keepiq.otherapp" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + applicationId = "nl.conduction.keepiq.e2e.otherapp" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependenciesInfo { + includeInApk = false + includeInBundle = false + } +} diff --git a/mobile/android/otherapp/src/main/AndroidManifest.xml b/mobile/android/otherapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5fe798e5f --- /dev/null +++ b/mobile/android/otherapp/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/mobile/android/otherapp/src/main/java/nl/conduction/keepiq/otherapp/LoginActivity.java b/mobile/android/otherapp/src/main/java/nl/conduction/keepiq/otherapp/LoginActivity.java new file mode 100644 index 000000000..2853ceb5b --- /dev/null +++ b/mobile/android/otherapp/src/main/java/nl/conduction/keepiq/otherapp/LoginActivity.java @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.otherapp; + +import android.app.Activity; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.view.View; +import android.view.autofill.AutofillManager; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * A user name and password form with Android autofill hints. The status + * line shows what was filled in (the user name and the password's length), + * so PackageVisibilityTest can read it. + */ +public class LoginActivity extends Activity { + private EditText username; + private EditText password; + private TextView status; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(48, 96, 48, 48); + status = new TextView(this); + status.setId(R.id.status); + status.setTextSize(18f); + username = new EditText(this); + username.setId(R.id.username); + username.setHint("User name"); + username.setInputType(InputType.TYPE_CLASS_TEXT); + username.setAutofillHints(View.AUTOFILL_HINT_USERNAME); + password = new EditText(this); + password.setId(R.id.password); + password.setHint("Password"); + password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + password.setAutofillHints(View.AUTOFILL_HINT_PASSWORD); + root.addView(status); + root.addView(username); + root.addView(password); + setContentView(root); + TextWatcher watcher = new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } + @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } + @Override public void afterTextChanged(Editable s) { show(); } + }; + username.addTextChangedListener(watcher); + password.addTextChangedListener(watcher); + show(); + final AutofillManager autofill = getSystemService(AutofillManager.class); + username.requestFocus(); + username.postDelayed(() -> autofill.requestAutofill(username), 500); + } + + private void show() { + status.setText("user=" + username.getText() + " password=" + password.getText().length()); + } +} diff --git a/mobile/android/otherapp/src/main/res/values/ids.xml b/mobile/android/otherapp/src/main/res/values/ids.xml new file mode 100644 index 000000000..233584263 --- /dev/null +++ b/mobile/android/otherapp/src/main/res/values/ids.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts new file mode 100644 index 000000000..e3d9ab1c4 --- /dev/null +++ b/mobile/build.gradle.kts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +plugins { + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.sqldelight) apply false +} + +// Resolves every configuration of every project, so the dependency +// verification metadata covers the Android and iOS artifacts too, also on a +// machine without an Android SDK or macOS. Regenerate the metadata with: +// ./gradlew --write-verification-metadata sha256 -Pkeepiq.android=true resolveAllDependencies +// then add the macOS Kotlin/Native toolchain by hand (see the comment in +// gradle/verification-metadata.xml). +fun touch(configuration: Configuration) = + configuration.resolvedConfiguration.lenientConfiguration.allModuleDependencies + .flatMap { it.moduleArtifacts } + .forEach { it.file } + +tasks.register("resolveAllDependencies") { + notCompatibleWithConfigurationCache("walks every configuration of every project") + doLast { + allprojects.forEach { project -> + project.configurations.filter { it.isCanBeResolved }.forEach { configuration -> + // Project dependencies are left out: their own configurations + // are resolved in turn, and Android variants of a sibling + // project are ambiguous without the variant attributes. + val resolved = runCatching { touch(configuration) }.recoverCatching { + touch(configuration.copyRecursive { it !is ProjectDependency }) + } + if (resolved.isFailure) logger.lifecycle("skipped ${project.path}:${configuration.name}") + } + } + // AGP fetches aapt2 for the build host through a configuration of its + // own; CI builds on Linux. + val aapt2 = libs.aapt2.linux.get() + touch(configurations.detachedConfiguration(dependencies.create("${aapt2.module}:${aapt2.version}:linux"))) + } +} diff --git a/mobile/e2e/android-run.sh b/mobile/e2e/android-run.sh new file mode 100755 index 000000000..84a451a1c --- /dev/null +++ b/mobile/e2e/android-run.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# Runs the Android end-to-end tests on a booted emulator, against the test +# server behind mobile/e2e/server.mjs proxy (https://10.0.2.2:8443 from the +# emulator). Called by .github/workflows/mobile-e2e.yml inside +# reactivecircus/android-emulator-runner. +# +# bash mobile/e2e/android-run.sh +# +# Writes screenshots (PNG), a video per test class (each under 3 minutes) and +# the instrumentation output to . Fails when a test class did not +# report exactly its one test as passed: a class that did not run is a +# failure, not a pass. The vault test needs the demo vault of +# `server.mjs seed`. +set -euo pipefail + +OUT="$(mkdir -p "${1:?out dir}" && cd "$1" && pwd)" +PROJECT="${2:?compose project}" +APP_PASSWORD="$(tr -d '[:space:]' < "${3:?app password file}")" +HERE="$(cd "$(dirname "$0")" && pwd)" +APK_DIR="$HERE/../android/app/build/outputs/apk" +PKG=nl.conduction.keepiq +SERVER=https://10.0.2.2:8443 + +occ() { docker exec -u www-data "${PROJECT}-nc-1" php occ "$@"; } + +# The web server can keep an app setting cached for a while after occ changes +# it. Wait until the suites answer no longer reports the two-factor block, so +# the next test class does not start against a vault that is still withheld. +wait_until_unblocked() { + local answer + for _ in $(seq 1 60); do + answer="$(curl -sk -u "admin:$APP_PASSWORD" -H 'OCS-APIRequest: true' \ + https://localhost:8443/index.php/apps/keepiq/api/v1/suites || true)" + case "$answer" in + *unlockBlocked*) sleep 2 ;; + '') sleep 2 ;; + *) return 0 ;; + esac + done + echo "::error::the two-factor block was still reported 120 s after the setting was removed" + return 1 +} + +adb wait-for-device +adb install -r -t "$APK_DIR/fdroid/e2e/app-fdroid-e2e.apk" +adb install -r -t "$APK_DIR/androidTest/fdroid/e2e/app-fdroid-e2e-androidTest.apk" +# An ordinary app that does not instrument Keepiq (PackageVisibilityTest). +adb install -r -t "$HERE/../android/otherapp/build/outputs/apk/debug/otherapp-debug.apk" +adb shell input keyevent KEYCODE_WAKEUP +adb shell wm dismiss-keyguard || true +adb shell settings put system screen_off_timeout 1800000 || true + +pull_shots() { + adb exec-out run-as "$PKG" sh -c 'cd files 2>/dev/null && tar -cf - e2e-shots 2>/dev/null' | tar -xf - -C "$OUT" 2>/dev/null || true +} + +# The video is recorded by the emulator itself, on this host, under 3 minutes. +video_start() { + adb emu screenrecord start --time-limit 175 "$OUT/$1.webm" || echo "::warning::the emulator did not start a recording" +} + +video_stop() { + adb emu screenrecord stop || true + sleep 3 + if [ -f "$OUT/$1.webm" ] && command -v ffmpeg >/dev/null; then + ffmpeg -loglevel error -y -i "$OUT/$1.webm" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$OUT/$1.mp4" \ + && rm "$OUT/$1.webm" + fi +} + +run_class() { + local class="$1"; shift + local log="$OUT/$class.txt" + adb logcat -c || true + adb shell am instrument -w \ + -e class "$PKG.android.$class" \ + -e keepiqServer "$SERVER" \ + -e keepiqUser admin \ + -e keepiqMasterPassword Oj \ + "$@" \ + "$PKG.test/androidx.test.runner.AndroidJUnitRunner" | tee "$log" + pull_shots + adb logcat -d > "$OUT/logcat-$class.txt" || true + grep -q '^OK (1 test)' "$log" +} + +status=0 +video_start keepiq-android-pairing +run_class PairUnlockUnpairTest || status=1 +video_stop keepiq-android-pairing + +# The vault flows, over the seeded demo vault, with an app password of their own. +adb shell pm clear "$PKG" >/dev/null +VAULT_PASSWORD="$(docker exec -u www-data -e NC_PASS=admin "${PROJECT}-nc-1" \ + php occ user:auth-tokens:add --password-from-env --name 'Keepiq e2e vault' admin | tail -n 1 | tr -d '[:space:]')" +video_start keepiq-android-vault +run_class VaultFlowsTest -e keepiqAppPassword "$VAULT_PASSWORD" || status=1 +video_stop keepiq-android-vault + +# The two-factor block: the organisation requires two-factor, and admin has +# none, so the server withholds the key. +adb shell pm clear "$PKG" >/dev/null +occ config:app:set keepiq vault_require_two_factor --value=true --type=boolean +run_class ManualPairingAndBlockTest -e keepiqAppPassword "$APP_PASSWORD" || status=1 +occ config:app:delete keepiq vault_require_two_factor +wait_until_unblocked + +# System autofill (task group 4): the test APK's forms and a WebView page +# on the test server. +adb shell pm clear "$PKG" >/dev/null +video_start keepiq-android-autofill +run_class SystemAutofillTest -e keepiqAppPassword "$APP_PASSWORD" || status=1 +video_stop keepiq-android-autofill + +# Package visibility: Keepiq fills an app that the test APK does not stand in for. +adb shell pm clear "$PKG" >/dev/null +run_class PackageVisibilityTest -e keepiqAppPassword "$APP_PASSWORD" || status=1 + +# Passkeys in Credential Manager (task group 5): Android 14 and later only. +# The test app asks for the rpId 10.0.2.2, whose assetlinks.json the https +# front serves on port 443 (mobile-e2e.yml redirects it to 8443). +if [ "$(adb shell getprop ro.build.version.sdk | tr -d '\r')" -ge 34 ]; then + adb shell pm clear "$PKG" >/dev/null + video_start keepiq-android-passkeys + run_class PasskeyProviderTest -e keepiqAppPassword "$APP_PASSWORD" || status=1 + video_stop keepiq-android-passkeys + adb shell dumpsys credential > "$OUT/dumpsys-credential.txt" 2>&1 || true +fi + +# The R8 release build (API 34 job only): it starts, shows its first screen +# and stays up while another app asks it for autofill. R8 removes code that only +# reflection or JNI reaches; the e2e build above is not shrunk, so this is +# the one run of shrunk code. +RELEASE_APK="$APK_DIR/fdroid/release/app-fdroid-release.apk" +if [ -f "$RELEASE_APK" ]; then + adb uninstall "$PKG" >/dev/null || true + adb install -r "$RELEASE_APK" + adb logcat -c || true + adb shell am start -W -n "$PKG/$PKG.android.MainActivity" + sleep 8 + adb shell settings put secure autofill_service "$PKG/$PKG.android.autofill.KeepiqAutofillService" + adb shell am start -W -n nl.conduction.keepiq.e2e.otherapp/nl.conduction.keepiq.otherapp.LoginActivity + sleep 5 + adb shell am start -W -n "$PKG/$PKG.android.MainActivity" + sleep 3 + adb exec-out screencap -p > "$OUT/release-smoke.png" || true + adb logcat -d > "$OUT/logcat-release-smoke.txt" || true + adb shell settings delete secure autofill_service || true + if grep -A20 "FATAL EXCEPTION" "$OUT/logcat-release-smoke.txt" | grep -q "$PKG"; then + echo "::error::the R8 release build crashed; see logcat-release-smoke.txt" + status=1 + elif ! adb shell pidof "$PKG" >/dev/null; then + echo "::error::the R8 release build is not running after the smoke check" + status=1 + else + echo "The R8 release build started and stayed up through an autofill request." + fi +fi + +ls -la "$OUT" "$OUT/e2e-shots" 2>/dev/null || true +exit "$status" diff --git a/mobile/e2e/fixtures/server.json b/mobile/e2e/fixtures/server.json new file mode 100644 index 000000000..f7024833e --- /dev/null +++ b/mobile/e2e/fixtures/server.json @@ -0,0 +1,489 @@ +{ + "recordedAt": "2026-10-04", + "origin": "{origin}", + "loginInit": { + "poll": { + "token": "recorded-poll-token", + "endpoint": "{origin}/login/v2/poll" + }, + "login": "{origin}/login/v2/flow/recorded-login-token" + }, + "poll": { + "server": "{origin}", + "loginName": "admin", + "appPassword": "stub-app-password" + }, + "pair": { + "ok": true, + "user": "admin", + "apiVersion": 1, + "serverVersion": "0.3.4-unstable.20261004130000", + "capabilities": [ + "match", + "autofill", + "passkey-provider", + "totp" + ] + }, + "suites": [ + { + "id": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIFxTCCA62gAwIBAgIIWd4Vtb0ZtTAwDQYJKoZIhvcNAQELBQAwgYAxCzAJBgNV\nBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5kMRIwEAYDVQQHDAlBbXN0ZXJk\nYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNVBAsMBktlZXBpcTEfMB0GA1UE\nAwwWS2VlcGlxIEludGVybWVkaWF0ZSBDQTAeFw0yNjEwMDQyMjIzMjBaFw0yNzEw\nMDQyMjIzMjBaMG8xCzAJBgNVBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5k\nMRIwEAYDVQQHDAlBbXN0ZXJkYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNV\nBAsMBktlZXBpcTEOMAwGA1UEAwwFYWRtaW4wggIiMA0GCSqGSIb3DQEBAQUAA4IC\nDwAwggIKAoICAQCin/HxRgFLfDJJ9ACZD4UcP/35wZGCIWpFQwFjzfWQHW0yMu3d\n9jcZ8aBtc9lePiTDSs/G+FWcFCpNejKr/YxO2AMaNf+cdsZRWZTKfmARKsfsfBne\nAYTu4EGZulpa+X0KwyfPEYTYNAJYpOqyEWn1j9+kGcHzUifox9igx9h8a8HbxqDy\nYlXyopjBvd/5UqaRu90yb4NMRKGit++qO7G+RS/ecVjFwUqHUsCfdftWjFgI84Gc\n9aHdb6XxIfVfs19S74DalpEaWKo0Q/ZQz7NMBdeApade+bTFz5v6m2129f0LFsFa\nZllNuKexojl0TLSfTWGGiyhx+co0yhzhrdNLEVZOzOmOZXrQt7vJA1wc55YRFZBB\ngyx85Ewhv9ILU70NKW6KspfMv81JEEUBaYpWW/QYZdTNv3lqW3UHLJLnCZrz3MtA\np0SA08iXaa0eAIza2GldZyKwtUfXs84i8D8ZBm41kT5NzN3qQatbzsztZC5HaM/Z\nHJLWpT1DpuhC59b37vEJ22KCigNRHi4cxHqQS7/PgsDyPniBR2+dBDtCfimb7gTX\n5KYpXXFVYpVtrAmOcbA1Ob1s7fWjF/4DDd1vV7qGZIdxzEOOLeeb+3r56lez5aK6\n18zduhgyjgEr9jq8o7nWgav5tXSsa8uGPIh7AxdtWfT7iI9DliRdqzt58QIDAQAB\no1MwUTAdBgNVHQ4EFgQUf53Vzxspv2zvAPJwi3UWKaW+wkwwHwYDVR0jBBgwFoAU\nvMxh0TmD32F9dtAuvOS5xytZ+PcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\nAQsFAAOCAgEAmdsqLluPpR4s83L+weo3PBEwRlRrepHpfOPajuD2QHkwj/KUzr/s\nTumu2LQD/MxL/4YR7Z7MWp13WT1PiKe63X5pDPyA/X2ei1vmkKg1TXOVC6qRoC96\nNY1zm7Cx/09F4j33rQRI64Xb8vAwsCuBzMNhrvDPdDj6/Dn4MCoSP3VcUYkuaArL\nWNBNuoeJbidh76HrUuT7dk5op8d0ESxIgoEe+iZMdPw2VekyogYTzStM2+ye3qvK\nJL5tM0TcZn8iZ7c35kcEVH39EyoZmm7szS4qE4Hw8RR602ZU3rnHE6gtd8x9BL+9\nMQWyK/5PuVq14eGC9w3d8D82NNMbZ0+YmQ6Q93hpzPSWFHHC3ynMS61psfY1Dmnd\ncGS+onbM+sC9DTZbc0dw3Jo4wGfvw1CgUHi1sPmLJEnHYionBkHOdHZUGcipk3dB\nyvqnEXPVipuRLFZFzCxwl3Q9wV9C2MA2ib4AX1FJrfNFBabGEDC+nl0ei9jijoKP\nTvWblHbQTs5WTNlubgOvx+StNk2OsDlomDqkRsIGnRyg/ps40yuT8EtbCZ5SDDwf\nzTaczUMiXvlKsmGRyrXNyJPh9BuaKJQ1Ra08zxp64UuFpvkU4CGw/ZEXZCrQXgN7\nn2PjHCgfwjqlTFKL7EkoP/xIbSJL3KecVXiS6o8e+mWrKD6djlWvxdg=\n-----END CERTIFICATE-----\n", + "privateKey": "AAAAATn+gc/2VJakds1UyJ3/M6xxBHOIC989RYXud2+V8BfQHjedTjW/vOSXggNNDsruFeaBwD/Wyspeac4AgQQg991d7+uoI8OyW+jrsV3THtkvLP1AHQF0E1WUBHxLgiBsEnuokZgnH9kaC8LdbNuKn0VMvce4/7vIyxEIMk10eJ/yCMRiEASz7PEnwYv3De/y/0yZECZtjR4LtVKCJOShq+ieuW8734MeYP+vunlG9DANXUj84z3OQ29oa2mygE2YAthY/+UIa7GdgZoVXo57GkCzNouNKXquaCBqlE7QatnWHwXjFtoGjOThtMX3hh5hbznxTPJv0DTdm3gWgBhXz3LHmvkZHJ6FKxtpTToe6adlr7+t76SqSg8C3FyxwjKsAjMCjE1Dh1u3wZXgy48U/LoKkTsp5i+d0HAFIs7/RxN9ZHS45GlViDT71RO/U4P2bX5jUUEaCXD2s1WL2qoTbvuAUPe7QOfc4WNhlFOoNZ1DbWiiR7TY9h2j6YnNyLfsVYwezQayeAa3NLKmFaP3g0lUrU/6uOUnftFLRvyk5zjjulqNSyNv5uFuik4+EC2t7RM9cNht9rMlnMe3mDmqrVwXIR+9jF5p2f8RSvfkT3HQr4qUOdDmF8OL92WnU/D2eHg/ZksxJLbje84yGjClzB5aM5nVyKTqAAByrwKWVWQeAXwBDwdFj5jSt1fYKE7koIVEPnkTh+LKq1/HiHVwTO2k0B7lCSJOdRHq8sZb8LM+9V1geF3GFGeKGgfcbK1UQXuoq/c98/jc3C/RZIeOIO8gCtJY7Zg7owlAQfB1WK51zsmqcHraP4b07je13WmhgoGWQHeLmOM9xB91Tl0z8T7j4jRHSN9Ssx/vE9egyJlnFBi3vGEUU65+WQsNME4yDzFIjREr9ZCRWxPFXILhB+HnpB4ST7zS/V8OUESj+8ooIxZDRc7pglebjvCRoSDCRYRxMFlxlcCq+lKH590YMwO/5ofdisFUxK3hif7KcqzBMlc5wnO+lz6/NCq6atUTx+SYaXgJWJuYQP0VD9RoieOtfQTl36/TjCWfwPiwOB2Si66B8dmAb30GCu5OzeY0h5wbnWTdu9ms0RobRGJx9S4JKlx3XE1YWK4AyqHVGphcPGe2mF8CL7Djn3lmmJgkBqEgGfEER2A5fsMttYjEJhdUY6aaGP96HodJfmgQq1GGcOlJj4WVL5tcX5k9+4miBKBbb9UmSsK9MEBtdK1f4PWU4xmEUkcZX6kYfm2wtY878mf9B45P15U4ShQWMPy+Zwd1lmccFjbkhTSMwTnvEtUrdZsY0viPKDb/Q5xrzUy/WNmh6oSLYP/0wI2aD67v2dnp96nAiw2/ozFYEk24wCy2STEia688xIvut+Crh8EWvdjDkXbb5b4GCFbLXybvEixIMtFN9qwNdi5a3uval1lIxwXkdKB5FJGlrGvR7if+8gR0UN8ZAJ8OTmg/eyaIsrB6/9S3oN2wpSJiZHAM/xSdtjgkklrbkvCMEDBolUUl+Bodob4wZRJRdKdDEozseZ8S06YLWLI1li9MVPgx9Yy2BuacgNIWEi+8cavMa01xJfi4w33U1zRTrhobeIyXjjXOrH6Hhc7HwQFhZDaZ0WdD9z3C9SsBmMM53xyf22QWCOu82+5zszkMBk+S9v3k3Ls/TRX6tgz+dRm3sgvHqzSbyC2PMkUI+nQguosiyr0oxh3+DZAZhj3LcavEpU7zEL7sAe8nmBYFnPxFOfGbN9QsDBZJhCcxO1HrwiUy6bMrEhxI8JgtS5j9WbI08FzmJy0NnvYD+2XS2O4H3Ytdwbe5b+fU9jUaU9mP/FzNUgXM3Reu6mLOKjzWlMPcea7YVHMZv1UNUOCtOCCc0lrToCr/SdFHs8GSuSCvmvjBYpBFjANFpYiJC0stdLo8t5KTIAZimBsgVPL/h7gn762FuiutZgMa3xpRw6D2d+otTMTAWvLNGA4mVWhSqLP9jDYw16OFoD8gqFwCQN3UMbrHXUz3/UUD1RC7ut9Rhpzh7S8wL90x9jP1GNVUEhBz5ymG/DJIjqCxk6SdGTf7Y/36+9VbooY8jucmxf+55iW7HWRrHnwuDpps3mgvc41uBPvcCIhFsb1XDugP2AMeB3kw+/YWiO/RC6pE4sHnHuwu+Iw+Nso7BpuF2I1szSbVD3LxBpZKxsr8eKT1i1xAxTH4Vw7Qn7F5k8R1TxriV3ZvKHT805PbpA7FmgHEqYLsIHsappNwTxNue+Pi8aGlsfzSTQ4Gbpd67JJij1fLECIlzDcVQpo4FueAhgpTaqhBjiAOcOPz+tkkrHJAfCoT7ZJcIHHTScb3VHdF/a8aqI8h6imR+X8gI5SLdrMVVM4tySlRZsjvAEHjU7SJ0iSk3TrPEQDC32OlxK3sR1r85mzGOEWMbZP0R3g7zCk8wWVyyAzrS2XfD4RzHG1YcPhFUbOZnr9CksHNtkXa/KRuEBce7rmf9uQKAo0h210kSupRlqY33wkgqIAgAsWh4up00KDuxPbGAbvancI7v4BmmvWRp/gaXD32MDGTc1LuRGOgY6ee4Usn93sBBb9KNUhGRktkyEIQgJe1tRHmjZy04PmBQZcpwM9cQAGipEptD0LYKwcpQTPftw4rHH4Ac+SbzaxYPa9a5QQaOWG5k5fTHakI6ApvlRJBC6XxaEc/zcxGrMtGXk38MscNfQuBH2dGdrVNzb9SDbgVNy3UZJz2c6AHAq5b/6U25OF1T9BuGFuYhkn4boXug4qBItSTohtTpHrI9h3fk1sZCmyeX/wU9vXvJEbVGWt+ekICaCBT3Wu+U05vQX4g8ikPPXOuMJNXxIWFmRbwcFtJABhxmVXFdE+hAsCjhOqnR/3VA/G7k+/1Rq5UvgM2kMKFx+0zONgNO6379IXTue1cAwFdpPh3THae8ASERmctCZzWdoMxCpww/K1D1NvkgvfgKaawRXRSiZXGusHKbtcbmt87VoLal0nAfDpwrN/YaiHbWwXvwBZS4O5cK7uCXZPZtLco8F98O+N2t/bU9r6Hppq7PhXV56Kn40//CXX4eBMR78s8O5A7jdo+kCwo8tkng0kFYiTWpIfVf22PR/P318Y94EEYlYcMHy6QRy4qFBiX3UEiOXTUbBvpkvhd7yrs2vOXhICLQdkyr7+Ktx62YIxW0CNeW5zIi7+bnX7/0UmDo48oTVGYwelnlOApyamssR2TY1pnnQMBNSI/26ONhn5Cg+5dOLDTWK+4LvzJ6MkVY5TJ+FF+YjALMASmHWUO+kQUHcIP4n1o01iU/GXncB18OGecBOfVDDskZAz1QrwR2QtO09p+xrLsqUcqHCjjpogxr403ILAOvJ0gPeRmV8tjz7De8lo/CDz75g1Trl4mwe57l1MSnqi1c1/TQo+CysmHPFwkc9+lfw618zHexNtqHa6kB4zdFm+ykkucXCxQnOGygnvCfWI+4iTN1+dvCq2zKsAAvMBzMhtb0QJL6deEGqa3ubbizfUMCkSAEoCpV4yapdRR83PlrrbHwZ9oQWLF5KHiYkmZvlfrBnQgyPy3t28cXWX79+ZEpAsHEeKxEyIAKPVuia7ROQ3WvgN7ITORuD1UOF6Eg2St08+ozLVXc9Qu77QYv0WcrMKT5gASKNbo4+ijoM/mvvMpm1XmZqJOlnvHVzkqWjuhFVq/JArRVBPnrh1Hsz3SBZ0q++S0ZdEkKcAIP118MFtm+1ROFsVJ1AY0nMMr9FPoWXtuRMa5YS6iXOSh1nzQFTDRPKxWvwCTFSOhTqnrY9UF9Os53OUP1zGDuE4e4XRWMcVGImc8A69Hb59JVjtcIt4WNXJcbtaOSpdbktImlBcgMPgslZugvT35wtBVUGXNV2GB8lnKI2hFEhVNB2gppRJvlMBr8pWs03v8oMNcmzyGV4fwguqWLWZcggOXu+hIuxhCpakepwkHsRyTgMF/RbYINKIkVTwj6IzZcidpQQVtKfOqcyZwRDTlZrlGhZdyeOsC2fnM+ed141HVIG4ljcK1np/noOt50DJ5gynkHcvTJ20s2Tqe+ReO9cUAFH0Ny3VNDyYdif9khjflgJpxwU8NsRiBa5ZVirlJYgI5rNCHsB6sygZGnMrFTg4gYanaEa72+fRholcUdL9a6o7aDEjM9nWplGPybIJDkG02PP0yFSWsJvNZDvCHwUcToJDOHZVVOeWnuKh9B5pqItwP3v2FNUEUs6dULsh+7AkFGUvc1SRinWBXu80q/y8OoXce4CUMvCzzyedVyfxQvZBMZyNbW6DFRaT9biFf82H87Dlfkyx7Zd79qnauaYcODMFk2lkULufj5Cu+M4vPPrKwJWW9lCCLm1lhS2LZDwVZfTCRH+Y6bG1WaApv1zNh7aDQTZCZox+mqyX6bxhOjVVfcg7MH0APVcWy6m0qafUZHFp/uPA=", + "status": "active", + "revokedAt": null, + "revokedReason": null, + "revokedBy": null, + "reinstatedAt": null, + "reinstatedBy": null, + "createdAt": "2026-10-04T22:23:20+00:00", + "unlockKeyEpoch": 1 + } + ], + "suitesTwoFactorRequired": [ + { + "id": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIFxTCCA62gAwIBAgIIWd4Vtb0ZtTAwDQYJKoZIhvcNAQELBQAwgYAxCzAJBgNV\nBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5kMRIwEAYDVQQHDAlBbXN0ZXJk\nYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNVBAsMBktlZXBpcTEfMB0GA1UE\nAwwWS2VlcGlxIEludGVybWVkaWF0ZSBDQTAeFw0yNjEwMDQyMjIzMjBaFw0yNzEw\nMDQyMjIzMjBaMG8xCzAJBgNVBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5k\nMRIwEAYDVQQHDAlBbXN0ZXJkYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNV\nBAsMBktlZXBpcTEOMAwGA1UEAwwFYWRtaW4wggIiMA0GCSqGSIb3DQEBAQUAA4IC\nDwAwggIKAoICAQCin/HxRgFLfDJJ9ACZD4UcP/35wZGCIWpFQwFjzfWQHW0yMu3d\n9jcZ8aBtc9lePiTDSs/G+FWcFCpNejKr/YxO2AMaNf+cdsZRWZTKfmARKsfsfBne\nAYTu4EGZulpa+X0KwyfPEYTYNAJYpOqyEWn1j9+kGcHzUifox9igx9h8a8HbxqDy\nYlXyopjBvd/5UqaRu90yb4NMRKGit++qO7G+RS/ecVjFwUqHUsCfdftWjFgI84Gc\n9aHdb6XxIfVfs19S74DalpEaWKo0Q/ZQz7NMBdeApade+bTFz5v6m2129f0LFsFa\nZllNuKexojl0TLSfTWGGiyhx+co0yhzhrdNLEVZOzOmOZXrQt7vJA1wc55YRFZBB\ngyx85Ewhv9ILU70NKW6KspfMv81JEEUBaYpWW/QYZdTNv3lqW3UHLJLnCZrz3MtA\np0SA08iXaa0eAIza2GldZyKwtUfXs84i8D8ZBm41kT5NzN3qQatbzsztZC5HaM/Z\nHJLWpT1DpuhC59b37vEJ22KCigNRHi4cxHqQS7/PgsDyPniBR2+dBDtCfimb7gTX\n5KYpXXFVYpVtrAmOcbA1Ob1s7fWjF/4DDd1vV7qGZIdxzEOOLeeb+3r56lez5aK6\n18zduhgyjgEr9jq8o7nWgav5tXSsa8uGPIh7AxdtWfT7iI9DliRdqzt58QIDAQAB\no1MwUTAdBgNVHQ4EFgQUf53Vzxspv2zvAPJwi3UWKaW+wkwwHwYDVR0jBBgwFoAU\nvMxh0TmD32F9dtAuvOS5xytZ+PcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\nAQsFAAOCAgEAmdsqLluPpR4s83L+weo3PBEwRlRrepHpfOPajuD2QHkwj/KUzr/s\nTumu2LQD/MxL/4YR7Z7MWp13WT1PiKe63X5pDPyA/X2ei1vmkKg1TXOVC6qRoC96\nNY1zm7Cx/09F4j33rQRI64Xb8vAwsCuBzMNhrvDPdDj6/Dn4MCoSP3VcUYkuaArL\nWNBNuoeJbidh76HrUuT7dk5op8d0ESxIgoEe+iZMdPw2VekyogYTzStM2+ye3qvK\nJL5tM0TcZn8iZ7c35kcEVH39EyoZmm7szS4qE4Hw8RR602ZU3rnHE6gtd8x9BL+9\nMQWyK/5PuVq14eGC9w3d8D82NNMbZ0+YmQ6Q93hpzPSWFHHC3ynMS61psfY1Dmnd\ncGS+onbM+sC9DTZbc0dw3Jo4wGfvw1CgUHi1sPmLJEnHYionBkHOdHZUGcipk3dB\nyvqnEXPVipuRLFZFzCxwl3Q9wV9C2MA2ib4AX1FJrfNFBabGEDC+nl0ei9jijoKP\nTvWblHbQTs5WTNlubgOvx+StNk2OsDlomDqkRsIGnRyg/ps40yuT8EtbCZ5SDDwf\nzTaczUMiXvlKsmGRyrXNyJPh9BuaKJQ1Ra08zxp64UuFpvkU4CGw/ZEXZCrQXgN7\nn2PjHCgfwjqlTFKL7EkoP/xIbSJL3KecVXiS6o8e+mWrKD6djlWvxdg=\n-----END CERTIFICATE-----\n", + "status": "active", + "revokedAt": null, + "revokedReason": null, + "revokedBy": null, + "reinstatedAt": null, + "reinstatedBy": null, + "createdAt": "2026-10-04T22:23:20+00:00", + "unlockKeyEpoch": 1, + "unlockBlocked": "two_factor_required" + } + ], + "policy": { + "maxIdleMinutes": 240 + }, + "manifest": { + "suite": { + "id": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIFxTCCA62gAwIBAgIIWd4Vtb0ZtTAwDQYJKoZIhvcNAQELBQAwgYAxCzAJBgNV\nBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5kMRIwEAYDVQQHDAlBbXN0ZXJk\nYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNVBAsMBktlZXBpcTEfMB0GA1UE\nAwwWS2VlcGlxIEludGVybWVkaWF0ZSBDQTAeFw0yNjEwMDQyMjIzMjBaFw0yNzEw\nMDQyMjIzMjBaMG8xCzAJBgNVBAYTAk5MMRYwFAYDVQQIDA1Ob29yZC1Ib2xsYW5k\nMRIwEAYDVQQHDAlBbXN0ZXJkYW0xEzARBgNVBAoMCkNvbmR1Y3Rpb24xDzANBgNV\nBAsMBktlZXBpcTEOMAwGA1UEAwwFYWRtaW4wggIiMA0GCSqGSIb3DQEBAQUAA4IC\nDwAwggIKAoICAQCin/HxRgFLfDJJ9ACZD4UcP/35wZGCIWpFQwFjzfWQHW0yMu3d\n9jcZ8aBtc9lePiTDSs/G+FWcFCpNejKr/YxO2AMaNf+cdsZRWZTKfmARKsfsfBne\nAYTu4EGZulpa+X0KwyfPEYTYNAJYpOqyEWn1j9+kGcHzUifox9igx9h8a8HbxqDy\nYlXyopjBvd/5UqaRu90yb4NMRKGit++qO7G+RS/ecVjFwUqHUsCfdftWjFgI84Gc\n9aHdb6XxIfVfs19S74DalpEaWKo0Q/ZQz7NMBdeApade+bTFz5v6m2129f0LFsFa\nZllNuKexojl0TLSfTWGGiyhx+co0yhzhrdNLEVZOzOmOZXrQt7vJA1wc55YRFZBB\ngyx85Ewhv9ILU70NKW6KspfMv81JEEUBaYpWW/QYZdTNv3lqW3UHLJLnCZrz3MtA\np0SA08iXaa0eAIza2GldZyKwtUfXs84i8D8ZBm41kT5NzN3qQatbzsztZC5HaM/Z\nHJLWpT1DpuhC59b37vEJ22KCigNRHi4cxHqQS7/PgsDyPniBR2+dBDtCfimb7gTX\n5KYpXXFVYpVtrAmOcbA1Ob1s7fWjF/4DDd1vV7qGZIdxzEOOLeeb+3r56lez5aK6\n18zduhgyjgEr9jq8o7nWgav5tXSsa8uGPIh7AxdtWfT7iI9DliRdqzt58QIDAQAB\no1MwUTAdBgNVHQ4EFgQUf53Vzxspv2zvAPJwi3UWKaW+wkwwHwYDVR0jBBgwFoAU\nvMxh0TmD32F9dtAuvOS5xytZ+PcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B\nAQsFAAOCAgEAmdsqLluPpR4s83L+weo3PBEwRlRrepHpfOPajuD2QHkwj/KUzr/s\nTumu2LQD/MxL/4YR7Z7MWp13WT1PiKe63X5pDPyA/X2ei1vmkKg1TXOVC6qRoC96\nNY1zm7Cx/09F4j33rQRI64Xb8vAwsCuBzMNhrvDPdDj6/Dn4MCoSP3VcUYkuaArL\nWNBNuoeJbidh76HrUuT7dk5op8d0ESxIgoEe+iZMdPw2VekyogYTzStM2+ye3qvK\nJL5tM0TcZn8iZ7c35kcEVH39EyoZmm7szS4qE4Hw8RR602ZU3rnHE6gtd8x9BL+9\nMQWyK/5PuVq14eGC9w3d8D82NNMbZ0+YmQ6Q93hpzPSWFHHC3ynMS61psfY1Dmnd\ncGS+onbM+sC9DTZbc0dw3Jo4wGfvw1CgUHi1sPmLJEnHYionBkHOdHZUGcipk3dB\nyvqnEXPVipuRLFZFzCxwl3Q9wV9C2MA2ib4AX1FJrfNFBabGEDC+nl0ei9jijoKP\nTvWblHbQTs5WTNlubgOvx+StNk2OsDlomDqkRsIGnRyg/ps40yuT8EtbCZ5SDDwf\nzTaczUMiXvlKsmGRyrXNyJPh9BuaKJQ1Ra08zxp64UuFpvkU4CGw/ZEXZCrQXgN7\nn2PjHCgfwjqlTFKL7EkoP/xIbSJL3KecVXiS6o8e+mWrKD6djlWvxdg=\n-----END CERTIFICATE-----\n", + "privateKey": "AAAAATn+gc/2VJakds1UyJ3/M6xxBHOIC989RYXud2+V8BfQHjedTjW/vOSXggNNDsruFeaBwD/Wyspeac4AgQQg991d7+uoI8OyW+jrsV3THtkvLP1AHQF0E1WUBHxLgiBsEnuokZgnH9kaC8LdbNuKn0VMvce4/7vIyxEIMk10eJ/yCMRiEASz7PEnwYv3De/y/0yZECZtjR4LtVKCJOShq+ieuW8734MeYP+vunlG9DANXUj84z3OQ29oa2mygE2YAthY/+UIa7GdgZoVXo57GkCzNouNKXquaCBqlE7QatnWHwXjFtoGjOThtMX3hh5hbznxTPJv0DTdm3gWgBhXz3LHmvkZHJ6FKxtpTToe6adlr7+t76SqSg8C3FyxwjKsAjMCjE1Dh1u3wZXgy48U/LoKkTsp5i+d0HAFIs7/RxN9ZHS45GlViDT71RO/U4P2bX5jUUEaCXD2s1WL2qoTbvuAUPe7QOfc4WNhlFOoNZ1DbWiiR7TY9h2j6YnNyLfsVYwezQayeAa3NLKmFaP3g0lUrU/6uOUnftFLRvyk5zjjulqNSyNv5uFuik4+EC2t7RM9cNht9rMlnMe3mDmqrVwXIR+9jF5p2f8RSvfkT3HQr4qUOdDmF8OL92WnU/D2eHg/ZksxJLbje84yGjClzB5aM5nVyKTqAAByrwKWVWQeAXwBDwdFj5jSt1fYKE7koIVEPnkTh+LKq1/HiHVwTO2k0B7lCSJOdRHq8sZb8LM+9V1geF3GFGeKGgfcbK1UQXuoq/c98/jc3C/RZIeOIO8gCtJY7Zg7owlAQfB1WK51zsmqcHraP4b07je13WmhgoGWQHeLmOM9xB91Tl0z8T7j4jRHSN9Ssx/vE9egyJlnFBi3vGEUU65+WQsNME4yDzFIjREr9ZCRWxPFXILhB+HnpB4ST7zS/V8OUESj+8ooIxZDRc7pglebjvCRoSDCRYRxMFlxlcCq+lKH590YMwO/5ofdisFUxK3hif7KcqzBMlc5wnO+lz6/NCq6atUTx+SYaXgJWJuYQP0VD9RoieOtfQTl36/TjCWfwPiwOB2Si66B8dmAb30GCu5OzeY0h5wbnWTdu9ms0RobRGJx9S4JKlx3XE1YWK4AyqHVGphcPGe2mF8CL7Djn3lmmJgkBqEgGfEER2A5fsMttYjEJhdUY6aaGP96HodJfmgQq1GGcOlJj4WVL5tcX5k9+4miBKBbb9UmSsK9MEBtdK1f4PWU4xmEUkcZX6kYfm2wtY878mf9B45P15U4ShQWMPy+Zwd1lmccFjbkhTSMwTnvEtUrdZsY0viPKDb/Q5xrzUy/WNmh6oSLYP/0wI2aD67v2dnp96nAiw2/ozFYEk24wCy2STEia688xIvut+Crh8EWvdjDkXbb5b4GCFbLXybvEixIMtFN9qwNdi5a3uval1lIxwXkdKB5FJGlrGvR7if+8gR0UN8ZAJ8OTmg/eyaIsrB6/9S3oN2wpSJiZHAM/xSdtjgkklrbkvCMEDBolUUl+Bodob4wZRJRdKdDEozseZ8S06YLWLI1li9MVPgx9Yy2BuacgNIWEi+8cavMa01xJfi4w33U1zRTrhobeIyXjjXOrH6Hhc7HwQFhZDaZ0WdD9z3C9SsBmMM53xyf22QWCOu82+5zszkMBk+S9v3k3Ls/TRX6tgz+dRm3sgvHqzSbyC2PMkUI+nQguosiyr0oxh3+DZAZhj3LcavEpU7zEL7sAe8nmBYFnPxFOfGbN9QsDBZJhCcxO1HrwiUy6bMrEhxI8JgtS5j9WbI08FzmJy0NnvYD+2XS2O4H3Ytdwbe5b+fU9jUaU9mP/FzNUgXM3Reu6mLOKjzWlMPcea7YVHMZv1UNUOCtOCCc0lrToCr/SdFHs8GSuSCvmvjBYpBFjANFpYiJC0stdLo8t5KTIAZimBsgVPL/h7gn762FuiutZgMa3xpRw6D2d+otTMTAWvLNGA4mVWhSqLP9jDYw16OFoD8gqFwCQN3UMbrHXUz3/UUD1RC7ut9Rhpzh7S8wL90x9jP1GNVUEhBz5ymG/DJIjqCxk6SdGTf7Y/36+9VbooY8jucmxf+55iW7HWRrHnwuDpps3mgvc41uBPvcCIhFsb1XDugP2AMeB3kw+/YWiO/RC6pE4sHnHuwu+Iw+Nso7BpuF2I1szSbVD3LxBpZKxsr8eKT1i1xAxTH4Vw7Qn7F5k8R1TxriV3ZvKHT805PbpA7FmgHEqYLsIHsappNwTxNue+Pi8aGlsfzSTQ4Gbpd67JJij1fLECIlzDcVQpo4FueAhgpTaqhBjiAOcOPz+tkkrHJAfCoT7ZJcIHHTScb3VHdF/a8aqI8h6imR+X8gI5SLdrMVVM4tySlRZsjvAEHjU7SJ0iSk3TrPEQDC32OlxK3sR1r85mzGOEWMbZP0R3g7zCk8wWVyyAzrS2XfD4RzHG1YcPhFUbOZnr9CksHNtkXa/KRuEBce7rmf9uQKAo0h210kSupRlqY33wkgqIAgAsWh4up00KDuxPbGAbvancI7v4BmmvWRp/gaXD32MDGTc1LuRGOgY6ee4Usn93sBBb9KNUhGRktkyEIQgJe1tRHmjZy04PmBQZcpwM9cQAGipEptD0LYKwcpQTPftw4rHH4Ac+SbzaxYPa9a5QQaOWG5k5fTHakI6ApvlRJBC6XxaEc/zcxGrMtGXk38MscNfQuBH2dGdrVNzb9SDbgVNy3UZJz2c6AHAq5b/6U25OF1T9BuGFuYhkn4boXug4qBItSTohtTpHrI9h3fk1sZCmyeX/wU9vXvJEbVGWt+ekICaCBT3Wu+U05vQX4g8ikPPXOuMJNXxIWFmRbwcFtJABhxmVXFdE+hAsCjhOqnR/3VA/G7k+/1Rq5UvgM2kMKFx+0zONgNO6379IXTue1cAwFdpPh3THae8ASERmctCZzWdoMxCpww/K1D1NvkgvfgKaawRXRSiZXGusHKbtcbmt87VoLal0nAfDpwrN/YaiHbWwXvwBZS4O5cK7uCXZPZtLco8F98O+N2t/bU9r6Hppq7PhXV56Kn40//CXX4eBMR78s8O5A7jdo+kCwo8tkng0kFYiTWpIfVf22PR/P318Y94EEYlYcMHy6QRy4qFBiX3UEiOXTUbBvpkvhd7yrs2vOXhICLQdkyr7+Ktx62YIxW0CNeW5zIi7+bnX7/0UmDo48oTVGYwelnlOApyamssR2TY1pnnQMBNSI/26ONhn5Cg+5dOLDTWK+4LvzJ6MkVY5TJ+FF+YjALMASmHWUO+kQUHcIP4n1o01iU/GXncB18OGecBOfVDDskZAz1QrwR2QtO09p+xrLsqUcqHCjjpogxr403ILAOvJ0gPeRmV8tjz7De8lo/CDz75g1Trl4mwe57l1MSnqi1c1/TQo+CysmHPFwkc9+lfw618zHexNtqHa6kB4zdFm+ykkucXCxQnOGygnvCfWI+4iTN1+dvCq2zKsAAvMBzMhtb0QJL6deEGqa3ubbizfUMCkSAEoCpV4yapdRR83PlrrbHwZ9oQWLF5KHiYkmZvlfrBnQgyPy3t28cXWX79+ZEpAsHEeKxEyIAKPVuia7ROQ3WvgN7ITORuD1UOF6Eg2St08+ozLVXc9Qu77QYv0WcrMKT5gASKNbo4+ijoM/mvvMpm1XmZqJOlnvHVzkqWjuhFVq/JArRVBPnrh1Hsz3SBZ0q++S0ZdEkKcAIP118MFtm+1ROFsVJ1AY0nMMr9FPoWXtuRMa5YS6iXOSh1nzQFTDRPKxWvwCTFSOhTqnrY9UF9Os53OUP1zGDuE4e4XRWMcVGImc8A69Hb59JVjtcIt4WNXJcbtaOSpdbktImlBcgMPgslZugvT35wtBVUGXNV2GB8lnKI2hFEhVNB2gppRJvlMBr8pWs03v8oMNcmzyGV4fwguqWLWZcggOXu+hIuxhCpakepwkHsRyTgMF/RbYINKIkVTwj6IzZcidpQQVtKfOqcyZwRDTlZrlGhZdyeOsC2fnM+ed141HVIG4ljcK1np/noOt50DJ5gynkHcvTJ20s2Tqe+ReO9cUAFH0Ny3VNDyYdif9khjflgJpxwU8NsRiBa5ZVirlJYgI5rNCHsB6sygZGnMrFTg4gYanaEa72+fRholcUdL9a6o7aDEjM9nWplGPybIJDkG02PP0yFSWsJvNZDvCHwUcToJDOHZVVOeWnuKh9B5pqItwP3v2FNUEUs6dULsh+7AkFGUvc1SRinWBXu80q/y8OoXce4CUMvCzzyedVyfxQvZBMZyNbW6DFRaT9biFf82H87Dlfkyx7Zd79qnauaYcODMFk2lkULufj5Cu+M4vPPrKwJWW9lCCLm1lhS2LZDwVZfTCRH+Y6bG1WaApv1zNh7aDQTZCZox+mqyX6bxhOjVVfcg7MH0APVcWy6m0qafUZHFp/uPA=", + "status": "active", + "revokedAt": null, + "revokedReason": null, + "revokedBy": null, + "reinstatedAt": null, + "reinstatedBy": null, + "createdAt": "2026-10-04T22:23:20+00:00", + "unlockKeyEpoch": 1 + }, + "secrets": [ + { + "id": "b82d163b-9ae8-4447-b71b-e698eae25347", + "name": "Authenticator (demo)", + "url": "https://webmail.example.com", + "typeId": "ad4d6696-3cca-56c5-9112-fc594139c284", + "folderId": null, + "key": "AAAAAVJ0dRSUalQ8lMRdNKXgXtcg5D/7HjUYIchL4uut4M9RznXBD8LPNjAEkYVBfmcRFTqJ3gVUvZel8QKboksUyjOsZvp7pEIrxaJMS0whU77uroOaf+f0i8ORlJmafBjWuwv3jdJBDUUJztXGdrrPSIx12j2zOEx/iqKWuarE2RMLnDY5H3rVPY+/6n5PpmsQENp85WKazn6O1w/Jfgnes1Nnv150nbpLF70GhvuRdbBGqRwuZBLonOUZXqET7NnDXxq/sZI5QonlmYwXvSuSXU1qjzneO0fSwB4a1UyfLxyFakZVbwSVxRP+eh92xx9wN39rDX6aiVx3zDq97fECOP57VToXmYAo/gTr238E7q9rjAEE3HktvHAl6arF6hQgjyuME3KxSgBaaQY1ZpBHYwNgqjkMr6kKQ9C9/ZNUPzr7Ez1t69ZqWJuSmRmMLIWl1GWI4DyusXYvh5Ig1L6NHvB2EWmoj3MEuN1BiZ3Vmi5Q9OfikTOzwXIUjsg6uH638kJIpdrpKHU0YpwyQQT7RZud3dCAXXDgqX0sxuQ8jaSWvdGZAnguvBLff9eCdlQ7amu0ixYa0ViLk8AeUCAHGS7zNDWUJf8P5jOkLqfMLk+M/qPdKwlj+U0t9CPKCbyRRssmb/FrrS/NI3j8mrXYO3vJQBDHhRNTWvA5J0LLs/tP", + "login": null, + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:53+00:00", + "updatedAt": "2026-10-04T22:28:53+00:00", + "keyUpdatedAt": "2026-10-04T22:28:53+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": "2026-10-04T22:30:13+00:00", + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "be0d6862-d202-4e61-81fd-7962dace4f13", + "name": "Bank (demo)", + "url": "https://bank.example.net", + "typeId": "307f9df3-b31f-519e-bb55-748490bb9b11", + "folderId": "a8046380-7105-4f7d-bb5c-4811854fbd02", + "key": "AAAAAZ5Wobh2ZbxCd8mleXI/4bKgUGY9K8A5I7dNxBiymzM6HvwZMqdQL8oHPAvWiviHGN0YHuG8XadH9S57T3jZsQGqDftgbp03KnIKLx4pQ8fE5N0yc/oUAIP6zQoFZcoENEWoW2TN3KDP6vpHln8Y02mP5KEkm8QgloZvfZcuUTWRnaUZSVslxxoxRY0rJbIi0Sw7/jpwRq15MCCOuQfb+X6xFaR9skB9pk3v6zmDrFkxsAkFeS0SNWZ6BrdGGT87G32OXOPEOnP6gzdwWM8d09WA6FngkYmfgvdc7OfdFnOqL/GB0XxgwCXY6r8W4izteu+VWnU7Q2nFXoVeXpWzdlYO+SaMXtnCnknNJVglmY0+gxNsxjVr5jMbHVJmVeW3UZTIwxgDtJVOGsnkJmYO/kdDB9DJ962xPhOMD2xHEG2HfcHQI7OWjyFDwQjlTTPltWcj1ExkygsSDRG/QPbeD2ZTjRW8qP43072Re/hyD2FwB8HTeR4DW/wXXDMrZHzK+cNNzAXWh4TJgY2S1r64O6c/oXI7AdiRiqW7JyYiUxQqW2SFcaxdptAQf+GlEUr65s5RaHeeZxuQ4jPWK5DXmaGJ/bit1uDXG7c41CHkpGOdFRp4/1ryJF5XrMtCoWKar6oT6TNaIz6EhdiRG8ad/n9nJJTE76VC7bvPfxHfKvDy", + "login": "AAAAASsFwSZ0IzrTbhXXGz07o2leOMT6V9lksOsLRllOTSiTbgChadjf4ttmKKeM/ZxnPRGHCVwxlsKhzzJlf2JHfjquzjXw+Kdk/L8Cu6vGxGayTSPsb06SMWr7t60tf8uuv5iQiqVO2dhQ2Gm2jdOqB2Z08QN5ppo/kpusSbw4yff7hrZHFNcRY0hhVP0TM7xM1z1Ld/Pe4ZzK6/b/x2aRVCoZNSY4CJr+xJX3A/YBm2BddEu6TPI12K6NWHcO/QzPsR64qt6jJYW0PedVwrTus6t8wkHZMwkjfD4oZqCHeOfeMkW1TJRO7EqSgf8RVkPLzUyt1GugHn/Ft7krb+KYOWfWatcmhk225Zt3CmXpw5bFSOtgbaYqXNqxU2YTpD3GWDasEGsEWlrAcuiBgmzl2h0W4ODuKa0oiSFgd+nwxiFMIwdixPSOUqWnB7qZfukadyr+QyhyDDko7o8ZvXTKQljs/+pgx5w5P4KnEhb4UKmS0muO0NQOBOAYJOHhd298rG08NSiKxmiyebwHcclb888a+AumGU9EgQ7gXaZ3r3A7mNTO6MUwmtt0AsJUYiFsnE9IKwRXDqvzP7/5Bni58eivgyrxYtMV3M5oFMXSCfSc4pPtf12ohOjPjU5RkmIxcY0yDK5KEEHwv4z+a6vttA6eOilaFxwd4qewAiVialm2", + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:56+00:00", + "updatedAt": "2026-10-04T22:28:56+00:00", + "keyUpdatedAt": "2026-10-04T22:28:56+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "63cb6a26-4079-4b8d-afe4-463102fa55dc", + "name": "Intranet (demo)", + "url": "https://intranet.example.org", + "typeId": "307f9df3-b31f-519e-bb55-748490bb9b11", + "folderId": "bd79863a-00d3-490a-aabc-db5737c17d8d", + "key": "AAAAAWQfJ5/YA0k5sRDE26XzgroaShDzN2nO04NEZ0oUBVJcPmFQiJ8JQnftfbPrG/u8jcLdx2EqKNt7CpfA9cAhatmsv343vl4iA/rt2yLft6W+thGmbaqdCquCH0N4eQ3lCc57CQlIXVY+hT21wcjIkZZupoeYtQ1UyG4B1xWx/Tul17n8rECOVPIjRNBPAeDWUTvZ2aeGr3hgtmqzzq3eIUHEVrBHrLjMgI+jmhZwZiRCnHSgUFwCo17iDWVcSwvjHr3S0fWlUCV1/6AAywOVr9ZjYg5gcBHy2jrQDXzxWytdM2QkZ/aK7mJ+KkWKw9F1sgyjcuuQ9BZHDyA1mV6eNB9SleBa8tMoGSfLPszzzu2mkqZS5EYYUetG3IDl8LtF3B4GyEK98IxyqBtv/GqQebRzP5MczcmPplplUwEqIhJrb8gvOGVXVPEXIriUpxD8OzzuvIu1WsGM01S/hjNkpB7ZveMZhQllUGw0jIv66IOdMUADTZPtlep+spVpJLyHxFR6T/HxNrikRw/9ZcYCR0zZN2q8yBEVg6V5NYpvA0R92fH/IwkiBJ322mNfz6iI5NOiw5lxi2XBhYw4AzyS8hluK/mHbXwp3NHjO8vyuS0SKGEMPosynJLLkkFPja+ZPrpEqisHwDOhbLUF4sxI60y80W4boV3DOHwGT2WP7J1w", + "login": "AAAAAVnpiXa1cD/2457/dI9Y2kht38M5iRdBDfeak7st27sgRMNBYuiOBLVDcTGIHNg6wo6QFMLdOE+LVw6cRobaf+PEjy1QIuFQPW+hdtGdC2gb2/WzLwNVwkWOD6Fdj/JqbZmHOUDgCMLSjEiCvaXfSovR8+/TqNdvHc9HEe7KXd10C5aY6Gw1Up/kgOfa//KVlMeOuHbcnhfRzzO3gjtBN3Hae+CgS1dc0AseYW+umM7W3miYx+gR4ZsInBf6iNybqHd097MrKTCA+bh5DDf0h+j3W3MRTNYIecjnzxAd/2eiYTuTE9OIEDlyneoGTHs9UfybxnmZXiewaNgXR9EgJtAvryN8R+ZGtePJ/GhhYbliHe0OBC9Xm+RLKPQe4YkKOm6a2uj0axLPk1B21QHc9p/rH7hfVkOrR0eBOsE2Iy+gYgDGp8WBFzvJU0zUM1fPLS6NCL1PbERE+Jmwap05q16iX1LdkI3WknLMBIFR6YoweyfJOsRdh6YSRpIEdu8wvtcMYrbqCcI+sR3xkEAktUsRzLq4AsstA8RCa+HqcxkF/k9cwOFzzi/pogTuH5RUOVmxXr1ECl3xauq2SgjodVvlSgmx3QHkqz/CphgXI1fNvhIfweOtLrO3hIu+ykH3Lm20jHRdTQFze7tDfLnK6VyhxmPAHlrJi6l7M0LiBTv6", + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:57+00:00", + "updatedAt": "2026-10-04T22:28:57+00:00", + "keyUpdatedAt": "2026-10-04T22:28:57+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "53acfbed-3bb6-4dba-9766-28922517d7c9", + "name": "Project board (demo)", + "url": "https://board.example.org", + "typeId": "307f9df3-b31f-519e-bb55-748490bb9b11", + "folderId": "bd79863a-00d3-490a-aabc-db5737c17d8d", + "key": "AAAAAS4xNyI2pui+lUXuz0bmXk9L4XzggEUlB3q14ze4Q+u38pvyquUe+NAmXeZEUHYD5zQ0prLkLHoMimT7HVmoQzQPFd51HyLIUiR/906FTcSkaXiVev7tM2Mnu8hYN8IKOBwIykbJljobssx/DJfkr/Uupten2ntCDYNXYc601vZuKwFFw5gYPsMPDfeGEX06MbZbtk/m3g2z/o6zY3IbXbGYotijq9OPy93GmPOkwRo0n48IXzokiheDsVrJfsDArJoYk8iyQF5QTbYz7+vc+rX5NJ98XVlluRWzvpT48G7yhX3+lFTcZ2Jx0v5Tw2YlEZ+P6J6ijV8/Enf+gzuQxLcNRDaYbXrWaKbS+PGgW1lRC8wozFQtEhPKGtbufrOd4hDytC4GQ1gikLdI8t0V/HaE/swn71UN7omIvi51seTO8rjpZuPehtF22VJYwrhpWxb62LfEHf+HsBjSln05INA0eRcnSbpI+xOMCiOGaX9LPZwvQ0UuUorye9dlNVeTNXch3wknHSfOAEbtSW2gjb+6UTcDzLMOba54QIMTyxWcHquJVywltbXgK4g1f+uYdrGkZzRIlswmmeNp3vUC/anCgWiOB0xkLf4tS9x93Oql4On2247ul5eNd64KjcDs894U1QM7Wv3fEaDfLCTbL9f0sGVApDVXJznmOskvabA/", + "login": "AAAAARimNFeEqhUbT5Ol3u+w4F7YK7xTzv1YNQnGIdc/7ZSC5czVoE95YDROXATUIV0+zECSwVdurwfgUjH3OSqCDt9H8lpUjJAd+dAFUCO7uJcuTVVgvaQeKIZOsT87iBqwb1WcZVQbZUmu0TbvambdEtwCK+kcRnAH/cqw9PZobRnC8aB2PAcEdsObzTq0+DrppY8RLZcqyTl1MwhYuaPkbpL36K2FTeWND8C3uqtonQzR4sQ5EbcwMWRDk6AqoeJQ9sE/XkZ/squOGRNj4bW5Qm8ITLzjaegyrIUexVBoEgelDrqjq4E7tansGu7THV0hu9Hzz9myaBJ9zMsX8NjqUT/uC7ygav641IA/WjP/+sD9qn4K2dpjXITnkybHmMTD2HjLsYAnx/1pe0cepWGmUGm2DWKHvm2vN58bds0uOkg4uDXXbSyaEfkFRT/hBBvlo9AF8/mxXAQ5GeAHfA3GdN3gCQNE+UyWPRUOrj8opQO3THmBQFLJPgfTvQ5xjkBRZda2I49A0cTc+9PxL0ItyI6X/Gh/taCneUeQlyiEmj50abDmRduAwC9G3bXJb2HVFjl0W2Nkw5M4vd+jDbhaDPUHkUzyjqDrznGb2z3dg4Y6huSQOhJTFZSuUcSVZgEzpV2WCH0SuCi4EumT9IuOqIwwT2RX4IGbkAjblfkICwLk", + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:58+00:00", + "updatedAt": "2026-10-04T22:28:58+00:00", + "keyUpdatedAt": "2026-10-04T22:28:58+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "6be0c6b6-9036-4f0b-a488-7d086b153c60", + "name": "Router admin (demo)", + "url": "https://router.example", + "typeId": "307f9df3-b31f-519e-bb55-748490bb9b11", + "folderId": null, + "key": "AAAAAVMYx/9oBsaTxzoW7/+UTpMeqhLI0pjysps9V2ia2wElv7b77tU6/f+zXvOVmgf5lLEuoCyyH/e+naZOVmCQ4wVeatqvWLz9vwGoVZoOylMPNqVSeKryLemjZiUx6VeJAv2rJoaPIrpQo2W5fUIRCiuQMi+NrEembJ03LzuTM/I2f7K8U4il79mTQMrZyoXsLhkznZOgeHMteld+mJ6lhKoMkrbtmGI7qT39E7JYYe4lLgKxRl7uR9vQ33kQD1oV4c60FlZaXJZPRqmZmoPbLtchiy6+JnRxZjmY+TN4uWH+e9o/5E6FXk1r7lIRqFjx2YZxawSjrLNbd7fTHuNIcNYZR2O8cGdkTmtIZB688/o5oZoaOgS6ZHqxn1dS+ZUJbwhOWprXvPRo8gzaONjaCQE3KXxrm4qyYmFld/7AQK0yVi7L5mb/8+GzF9niWTdwdSFuFkAa0PHaRanCN5O7FmCMYLj9DXZ/GpNWGPtrMsDNks7wMRUGwi6eP3nCfVZcYHspyKD3tpX1S8gctZCfXj8LOr1F4VQI/oorlYuu/b+696/rP43JHDGk6ARdbXjIc7EMInHYIEF+3PvKs4MDkXT+egWdIDpjPmjUYyF9Xn5dtZMbsPmHNDLWG7GvowVpDkFbDVtXvV+VEIV+uXpT9bJCteTyVb6BICsFcie+zXH9", + "login": "AAAAAUzwyYza154y6TO9T7rmPciuhyued9b96NyJ0Loeta0Ysiq4Z1CFfOYH9uHlrncJbLDDN59UoD5NK4fmN1eLLktS8hOuXTYZOnzEecjmV54OuBPBfBDFDVKDW/y56Oshaf5jRmDqeiAPra9x+uuJiVWJo9Bm8+XiKx9LLjLGoglbqmxDXK4IbF7QLChcZhFp++nLYTc6Ix5NpDJQdmb/mvJl4G6LhPpiPGtdflRiJ7RjKKl4KsIybJQB2WYkm5LlBr8vMN+OewbRQ4oMKsKadMTBJhG4LPkd2GzVBtpO9+Na9se/6de2KxDOOYN/kBl1Yfp1/5q5m8fmWJOcDhRBfizod1Q1soHFFbdrKAE9+SKMdKG6rqYNDt/uEtP3lV9Vd2XaiaAAtU93uKNPdgfHNVr27C3LCLu5rWKtulbNVo1XH+o+csi+HmrPC78O4R6NwaZ36mtXdtBONGOpBz8TZGgwiZ8M4cFS2K9+4ACRm+0UOAFhd6oQlxXInV0wEfuXAX94BMVsNJIz6J1FRsqo8DcXPfKw+/ZY/DORkx/XAsAZ1kkDTc8y/ree4c6yW3Z/GEk+ZHkPQM1rI6+QWyjmmMG6DVWcF1X73gGz3m7TUTwe3rfcAOV8rEh7KnbvS3mYNn1fsxWAxlYqYYfCeQ5fyNaAaLcx+XZSAZIKGF6UEWCZ", + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:52+00:00", + "updatedAt": "2026-10-04T22:28:52+00:00", + "keyUpdatedAt": "2026-10-04T22:28:52+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "320c2fee-67dc-434c-a6cc-9af1bb0a0f2c", + "name": "Webmail (demo)", + "url": "https://webmail.example.com", + "typeId": "307f9df3-b31f-519e-bb55-748490bb9b11", + "folderId": null, + "key": "AAAAASEVb97P/tq03OrZpsgq9WiwmXpISt22qVLAVHxiRRwC6F0BAvvQWhKk9k4dITNUf5WUW6OVs5hWJEZ/SH2htEez7aFWRnGX1n+FM5tIC8hudlIeT96etlrZ2PK0a0kBgSUmKhVB/GwD1F+8spzbRijr0bCSy/ZijvKT2emvTNGZIk0OAQyx2777nxCKrmTI6zPLmUBTghTtUpeuCaNsre1r70Rs41gv9vsqwKMm8gwjYXv8O0Ry4y5u90cU64rvkvEd+rBwlm5iAIJMXtH3WSfPHvj4iRp77dTl2c5J0EQkRqDeAWuL6Y1dHTqUIKmLGFkZR+1xuugtJcApzQj22w0oMWxjFShjvUURMLNAbFxb62dU5rqVHnl3zCNAgR0JPkmlhmnMjRdC9cUvIlmE15/UtMJxszbFu9JNUjq3V3K7TYkJZDlBIGoDNny4DocfMFhWa6qRdAlzj90BK9ZgNV8UjTJI1Xd+EAQqCQ9NgQByBQwD+s8uhzsMoa6k+8FQohsw8sI/uiVu4Hg46q71FydUSAhyis5yw0xJgFdQqIWji3FLy5PKDnOUqUETS6clcNIDwW6Hp2o5spPtvGR0aeGc+wO7dZOldYKDhayUkaiyXSLxvjxZX6a5F2JWOyCrA0UpZxtdzJ8UydFVsYYzLM+/9Y+AvjlnUgwATnjoq5Vp", + "login": "AAAAAQT0ma+BKKdcNf+s0AeH/SXbEK/AHdueoK2Z5/mq9m04n3RaSd2dqSw3ox6uSauoJdvR/6yrUGfs2DjYUYxUij04AsqL8cjBzZn6AAFqCyG4UioPbsDjDKfhSQeIPpXBOd0OluqEOlVuahXtyJquTX5/+WfDH4X2FP2Ilyd678L02JNnVOtLH2RHhbmjQWOv7MDswahlj5OPIrqUX8AhFRS5zhdyQWJFinluumYdLfncFaxf2poRRrcPeFT9z1RXe54rtyS/a6f/ePi2Rh1meadRKUo6X+DXATQi0yWxRq46O+6OazoKDxpy8PjryR3NdMCFQqKSZh4SjtP2UDhecDq38YDUZNZds+7d15vIrs4dgIWDKxlXVJmnGcPf3/sm/b4bK4I61EZ01Bv6UoR65to7w+2PsXo++6C6wtaeNQTcIuS5dNtDYExL3cKWvxCXJHh2WuyLKEOZXStGFKW21ji22ZHI2wYL3kwnmmYkVhbMaw+qiEsW5zYPn9la3sC0k9wSfyLGL27NmXn+MzqeUTTx5AXdr2G+/DI8PddS77yEtF7XNyn0G8RsTd0tysAHFzMy2U3IDtsnD86nf9M5dhtYRoAHhrKCsKjTAG2RI66e4hOun5Mr6VNV2jEBHzKUiCnfhLf1JFQANQZXiDsmbSZEsiq267RPYYmLd6D+fDUJ", + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:51+00:00", + "updatedAt": "2026-10-04T22:28:51+00:00", + "keyUpdatedAt": "2026-10-04T22:28:51+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + }, + { + "id": "5b80b4d5-989d-4271-9d23-d2110f599eda", + "name": "Wi-Fi at home (demo)", + "url": null, + "typeId": "cc565d72-e4eb-5103-a636-b954ecb94bdd", + "folderId": null, + "key": "AAAAAUONLQrDctSLdRdkGeR4c7RdXMIWxqb3UI/Y8MPIp/TvbYzvk/wEHv/BvikVuBDrPlFObCppOHSO5STuTntja3j3dBoFT3BttE3vnkiiK7snwKQzxK1udrbIQtaNV4fTfa97TxK9sVaLxpPzw2Gqx1NW89npEIvJjswCiefqPlqfzZJtdYFOVag1qhsDY4XTHDzsmZmLSl8i1sk1KPfAV1UZjM4AQ+JCrMHpLQqAvcTQJPyYcZ0DU67EZdN9BFhFUUpkq075PZbOfNlapTQybPl2r2oRg7z6kkYy8+NQY3WblbL1lqHJ4MI13xY1VlKH5AI2o7HNCxJY+HBOnASYDA1fqpc8GnU+TAy2DQdXi2NW7Pbc5g/hARSx8XTMeazrVfuphwpxPabmEhrJcOhTXz/Z6NSaX9lLu8+TGIXYGvhVqTBdZQeF7MmY8S+jSucsPHzRGAF3bBLNE3twEjXXw6fDb866SKJZ4x/vuAvxmpBEUDz/An65NSxkfNq37f1uk5VN3QT9L5ooWvSc5qV5HgaUs8GLjdM+xkyxULtMAZynA1ReUApWegN2fNoUUZHtFuNy2EdWw2bL3osaVSi4KE09bppxQ/e+ucASaQiMicN/9XjnDUCv67t0JDzUPvfnfJycMQ/U2ot5xow23bdXqwZHr1oqhc0ePFkfLZwQbn58", + "login": null, + "additionalFields": null, + "encryptionSuiteId": "ea15bc00-77c5-496f-b7aa-c53abb0c3261", + "ownerType": "user", + "ownerId": "admin", + "blocked": false, + "createdAt": "2026-10-04T22:28:54+00:00", + "updatedAt": "2026-10-04T22:28:54+00:00", + "keyUpdatedAt": "2026-10-04T22:28:54+00:00", + "expiresAt": null, + "possiblyCompromisedAt": null, + "tombstonedAt": null, + "tombstoneReason": null, + "trashedAt": null, + "archivedAt": null, + "favourite": false, + "lastUsedAt": null, + "useOnly": false, + "accessExpiresAt": null, + "readOnly": false, + "federatedSource": null, + "pendingAdditionalFields": [] + } + ], + "folders": [ + { + "id": "a8046380-7105-4f7d-bb5c-4811854fbd02", + "name": "Personal", + "parentId": null, + "ownerType": "user", + "ownerId": "admin", + "createdAt": "2026-10-04T22:23:20+00:00", + "updatedAt": "2026-10-04T22:23:20+00:00", + "customIcon": null, + "customColor": null + }, + { + "id": "bd79863a-00d3-490a-aabc-db5737c17d8d", + "name": "Work", + "parentId": null, + "ownerType": "user", + "ownerId": "admin", + "createdAt": "2026-10-04T22:23:20+00:00", + "updatedAt": "2026-10-04T22:23:20+00:00", + "customIcon": null, + "customColor": null + } + ], + "types": [ + { + "id": "398f4274-0cda-5c40-8cf2-34124657b1d5", + "name": "api_key", + "label": "API Key", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "ad4d6696-3cca-56c5-9112-fc594139c284", + "name": "totp", + "label": "Authenticator (TOTP)", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "a9b57f96-eaa8-5009-b1f1-93a82bfbc687", + "name": "certificate", + "label": "Certificate", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "9c491896-3d6b-5a1a-9b0e-e89dfeb6617c", + "name": "database", + "label": "Database", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "47ea989b-1d36-5f3e-b23a-95729843d95d", + "name": "identity", + "label": "Identity", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "307f9df3-b31f-519e-bb55-748490bb9b11", + "name": "login", + "label": "Login", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "9e35c16b-db87-5c62-b041-6b282f4246a0", + "name": "passkey", + "label": "Passkey", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "a67c7ff9-30a2-5144-9225-5ef402b155c7", + "name": "card", + "label": "Payment Card", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "fd2e8ebb-4603-52d0-b83b-504e01a9d81a", + "name": "ssh_key", + "label": "SSH Key", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + }, + { + "id": "cc565d72-e4eb-5103-a636-b954ecb94bdd", + "name": "note", + "label": "Secure Note", + "scope": "system", + "ownerId": null, + "fields": [], + "createdAt": "2026-10-04T22:23:18+00:00" + } + ], + "syncedAt": "2026-10-04T22:30:43+00:00", + "unlockBlocked": null, + "offlineEditsEnabled": false + }, + "secretTags": [], + "generatorPolicy": { + "master_password_min_length": 12, + "master_password_min_score": 3, + "policy_enabled": false, + "generator_min_length": 12, + "generator_require_upper": false, + "generator_require_lower": false, + "generator_require_digit": false, + "generator_require_symbol": false, + "generator_allow_passphrase": true, + "min_zxcvbn_score": 0, + "block_on_hibp_hit": false, + "policy_exempt_types": [ + "note", + "ssh_key", + "certificate", + "passkey", + "card", + "identity" + ], + "team_folder_auto_confirm": false, + "vault_export_disabled": false, + "vault_require_two_factor": false, + "vault_org_ownership": false, + "vault_org_ownership_types": [ + "login", + "api_key", + "database" + ] + }, + "sendCreated": { + "id": "9245e98d-47b7-408c-9928-a7b32d5e0734", + "token": "9769654a686816387048c95c6e4fb5176e948778bcab609fe3a323d7fd58d127", + "payloadType": "text", + "hasPassword": false, + "maxViews": 1, + "viewCount": 0, + "remainingViews": 1, + "expiresAt": "2026-10-04T23:30:44+00:00", + "createdAt": "2026-10-04T22:30:44+00:00" + }, + "sendListed": { + "id": "9245e98d-47b7-408c-9928-a7b32d5e0734", + "token": "9769654a686816387048c95c6e4fb5176e948778bcab609fe3a323d7fd58d127", + "payloadType": "text", + "hasPassword": false, + "maxViews": 1, + "viewCount": 0, + "remainingViews": 1, + "expiresAt": "2026-10-04T23:30:44+00:00", + "createdAt": "2026-10-04T22:30:44+00:00" + }, + "unpair": { + "ok": true, + "note": "Revoke the app-password in Nextcloud security settings to fully unpair." + }, + "revoke": { + "status": 200, + "body": { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [] + } + } + }, + "afterRevoke": { + "status": 401, + "body": { + "message": "" + } + } +} diff --git a/mobile/e2e/ios-run.sh b/mobile/e2e/ios-run.sh new file mode 100755 index 000000000..0a980b348 --- /dev/null +++ b/mobile/e2e/ios-run.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# Runs the iOS UI tests on a simulator against mobile/e2e/server.mjs replay, +# which answers as the recorded test server (there is no Docker on the macOS +# runners). Called by .github/workflows/mobile-e2e.yml after the shared +# framework is built and `xcodegen generate` ran in mobile/ios. +# +# bash mobile/e2e/ios-run.sh +# +# Writes screenshots, a video per test class (each under 3 minutes) and an +# xcresult bundle per class. +set -euo pipefail + +OUT="$(mkdir -p "${1:?out dir}" && cd "$1" && pwd)" +HERE="$(cd "$(dirname "$0")" && pwd)" +IOS="$HERE/../ios" +CERTS="$(mktemp -d)" +mkdir -p "$OUT/shots" + +# A certificate for localhost, trusted by this simulator only. +openssl req -x509 -newkey rsa:2048 -nodes -days 2 -subj "/CN=Keepiq e2e" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "extendedKeyUsage=serverAuth" \ + -keyout "$CERTS/key.pem" -out "$CERTS/cert.pem" 2>/dev/null + +UDID="$(xcrun simctl list devices available -j | python3 -c ' +import json, sys +devices = json.load(sys.stdin)["devices"] +best = None +for runtime, items in devices.items(): + if "iOS" not in runtime: + continue + version = tuple(int(p) for p in runtime.rsplit("iOS-", 1)[-1].split("-") if p.isdigit()) + for d in items: + if d["name"].startswith("iPhone") and (best is None or version > best[0]): + best = (version, d["udid"], d["name"]) +print(best[1]) +')" +echo "simulator $UDID" +xcrun simctl boot "$UDID" || true +xcrun simctl bootstatus "$UDID" -b +xcrun simctl keychain "$UDID" add-root-cert "$CERTS/cert.pem" + +node "$HERE/server.mjs" replay --port 8443 --cert "$CERTS/cert.pem" --key "$CERTS/key.pem" > "$OUT/replay.log" 2>&1 & +REPLAY=$! +trap 'kill "$REPLAY" 2>/dev/null || true' EXIT + +xcodebuild build-for-testing -project "$IOS/Keepiq.xcodeproj" -scheme Keepiq \ + -destination "id=$UDID" -derivedDataPath "$OUT/DerivedData" -quiet + +# One test class at a time, each with its own video under 3 minutes. +status=0 +run_class() { + local class="$1" video="$2" + xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$OUT/$video.mp4" & + local recorder=$! + TEST_RUNNER_KEEPIQ_SERVER=https://localhost:8443 TEST_RUNNER_KEEPIQ_SHOTS_DIR="$OUT/shots" \ + xcodebuild test-without-building -project "$IOS/Keepiq.xcodeproj" -scheme Keepiq \ + -destination "id=$UDID" -derivedDataPath "$OUT/DerivedData" \ + -only-testing:"KeepiqUITests/$class" \ + -resultBundlePath "$OUT/$class.xcresult" || status=1 + kill -INT "$recorder" 2>/dev/null || true + wait "$recorder" 2>/dev/null || true + fit_video "$OUT/$video.mp4" +} + +# A video longer than 3 minutes is played faster until it fits, so the whole +# run stays visible. The simulator itself has no time limit to record with. +fit_video() { + local file="$1" seconds + [ -f "$file" ] || return 0 + command -v ffmpeg >/dev/null || brew install --quiet ffmpeg >/dev/null 2>&1 || return 0 + seconds="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$file" | cut -d. -f1)" + [ "${seconds:-0}" -gt 175 ] || return 0 + ffmpeg -loglevel error -y -i "$file" -an -vf "setpts=PTS*170/${seconds}" -c:v libx264 -pix_fmt yuv420p \ + -movflags +faststart "${file%.mp4}-fit.mp4" && mv "${file%.mp4}-fit.mp4" "$file" +} +run_class PairUnlockUITests keepiq-ios-pairing +run_class VaultFlowsUITests keepiq-ios-vault +# The AutoFill extension's screens inside the app (task group 4), then its +# passkey screens and calls (task 5.2), both against the replay. +run_class AutofillUITests keepiq-ios-autofill +run_class PasskeyUITests keepiq-ios-passkeys +rm -rf "$OUT/DerivedData" +ls -la "$OUT" "$OUT/shots" +exit "$status" diff --git a/mobile/e2e/server.mjs b/mobile/e2e/server.mjs new file mode 100755 index 000000000..35fa8ec04 --- /dev/null +++ b/mobile/e2e/server.mjs @@ -0,0 +1,590 @@ +#!/usr/bin/env node +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +/** + * The test server side of the mobile end-to-end tests + * (.github/workflows/mobile-e2e.yml). Node only, no packages. + * + * proxy An https front for the test Nextcloud of + * browser-extension/capture/compose.yaml, so the apps pair with an + * https address as a real user would. Every request goes to the + * Nextcloud with its Host kept. Under /__e2e/ it also stands in for + * what a phone test cannot do itself: + * POST /__e2e/grant {loginUrl, user} signs in on that Login + * Flow v2 page and grants access, as the user would in the + * browser; + * GET /__e2e/tokens the user's app password names (the + * Nextcloud device list), through occ; + * POST /__e2e/assetlinks the statements this front then serves + * at /.well-known/assetlinks.json (Digital Asset Links for + * the autofill test app, whose signing key is made per run); + * GET /__e2e/login.html a login form, for the autofill test's + * WebView (the web-domain path). + * seed Fills admin's vault with the demo items the vault tests use: + * logins on example.com, example.net, example.org and .example + * names, an authenticator (TOTP) item and a note, all clearly + * fake. Every value is encrypted to admin's suite as the apps do + * it. Run it again and it adds only what is missing. + * record Talks to the seeded test Nextcloud and writes the answers the + * iOS tests replay to mobile/e2e/fixtures/server.json. + * replay An https stub that answers from those recordings, for the iOS + * simulator job, where no Docker runs. It keeps the vault and the + * Sends in memory, so an item the test creates, edits or trashes + * behaves as on the real server. + * + * node mobile/e2e/server.mjs proxy --port 8443 --cert c.pem --key k.pem --upstream http://localhost:8188 --container kq-e2e-nc-1 + * node mobile/e2e/server.mjs seed --upstream http://localhost:8188 + * node mobile/e2e/server.mjs record --upstream http://localhost:8188 --container kq-e2e-nc-1 + * node mobile/e2e/server.mjs replay --port 8443 --cert c.pem --key k.pem + * + * The demo account is admin with the password admin and the master password + * Oj, the development vault of browser-extension/capture/setup.sh. + */ +import { execFileSync } from 'node:child_process' +import { constants as cryptoConstants, publicEncrypt, randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { request as httpRequest } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const FIXTURES = join(HERE, 'fixtures', 'server.json') +const USER_AGENT = 'Keepiq for iOS (recorded)' + +function options(argv) { + const out = { _: argv[0] } + for (let i = 1; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1] + return out +} + +/** One request to the Nextcloud, with the Host the caller chose. */ +function nc(upstream, method, path, { host, jar, form, headers = {}, body } = {}) { + const target = new URL(upstream) + const payload = form ? new URLSearchParams(form).toString() : body + const all = { host: host || target.host, ...headers } + if (form) all['content-type'] = 'application/x-www-form-urlencoded' + if (payload !== undefined) all['content-length'] = Buffer.byteLength(payload) + if (jar && jar.size) all.cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ') + return new Promise((resolve, reject) => { + const req = httpRequest({ hostname: target.hostname, port: target.port, method, path, headers: all }, (res) => { + const chunks = [] + res.on('data', (c) => chunks.push(c)) + res.on('end', () => { + for (const line of res.headers['set-cookie'] || []) { + const [pair] = line.split(';') + const at = pair.indexOf('=') + const name = pair.slice(0, at).trim() + const value = pair.slice(at + 1).trim() + if (!jar) continue + if (value === 'deleted' || /max-age=0|expires=thu, 01 jan 1970/i.test(line)) jar.delete(name) + else jar.set(name, value) + } + resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }) + }) + }) + req.on('error', reject) + if (payload !== undefined) req.write(payload) + req.end() + }) +} + +function pathOf(url) { + const u = new URL(url) + return u.pathname + u.search +} + +function initialState(html, app, key) { + const m = html.match(new RegExp(`id="initial-state-${app}-${key}"\\s+value="([^"]+)"`)) + if (!m) throw new Error(`no initial state ${app}-${key} on the page`) + return JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')) +} + +function requestToken(html) { + const m = html.match(/data-requesttoken="([^"]+)"/) + if (!m) throw new Error('no request token on the page') + return m[1] +} + +/** + * What the user does in the browser after the app opened the login page: + * open it, log in, and press "Grant access". Every step is the Nextcloud + * page's own form (core/Controller/ClientFlowLoginV2Controller). + */ +export async function grant(upstream, loginUrl, user, password) { + const host = new URL(loginUrl).host + const jar = new Map() + const step = async (method, path, opts = {}) => { + // A browser's Accept, so Nextcloud answers pages and login redirects. + // and the Origin a browser sends with a form, which the login form checks. + const headers = { accept: 'text/html,application/xhtml+xml', 'user-agent': 'Mozilla/5.0 (e2e browser stand-in)' } + if (method === 'POST') headers.origin = new URL(loginUrl).origin + const res = await nc(upstream, method, path, { host, jar, headers, ...opts }) + if (res.status >= 400) throw new Error(`${method} ${path} answered ${res.status}`) + return res + } + let res = await step('GET', pathOf(loginUrl)) + while (res.status >= 300 && res.status < 400) res = await step('GET', pathOf(new URL(res.headers.location, loginUrl).href)) + const auth = initialState(res.text, 'core', 'loginFlowAuth') + + // The grant page sends a visitor without a session to the login form. + res = await step('GET', pathOf(auth.loginRedirectUrl)) + if (res.status >= 300 && res.status < 400) { + const loginPage = new URL(res.headers.location, loginUrl) + const form = await step('GET', pathOf(loginPage.href)) + res = await step('POST', '/index.php/login', { + form: { + user, + password, + requesttoken: requestToken(form.text), + redirect_url: loginPage.searchParams.get('redirect_url') || '', + timezone: 'UTC', + timezone_offset: '0', + }, + }) + if (!(res.status >= 300 && res.status < 400) || /\/login(\?|$)/.test(res.headers.location || '')) { + throw new Error(`logging in failed (${res.status} ${res.headers.location || ''})`) + } + res = await step('GET', pathOf(new URL(res.headers.location, loginUrl).href)) + } + const grantState = initialState(res.text, 'core', 'loginFlowGrant') + res = await step('POST', pathOf(grantState.actionUrl), { + form: { stateToken: grantState.stateToken, requesttoken: requestToken(res.text) }, + }) + return { client: grantState.client } +} + +function occ(container, ...args) { + return execFileSync('docker', ['exec', '-u', 'www-data', container, 'php', 'occ', ...args], { encoding: 'utf8' }) +} + +/** The names in the user's device list (app passwords and sessions). */ +function tokenNames(container, user) { + const out = occ(container, 'user:auth-tokens:list', user, '--output=json') + return JSON.parse(out).map((t) => t.name) +} + +function readJson(req) { + return new Promise((resolve) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) + } catch { + resolve({}) + } + }) + }) +} + +function send(res, status, body, type = 'application/json') { + res.writeHead(status, { 'content-type': type }) + res.end(typeof body === 'string' ? body : JSON.stringify(body)) +} + +// What /.well-known/assetlinks.json answers; the autofill test posts it. +let assetLinks = '[]' + +// The WebView page of the autofill test: a login form that shows what was +// filled in, so the test can read it. +const LOGIN_PAGE = ` + +Sign in + +
+ + + + +
+

empty

+` + +function proxy(o) { + const upstream = o.upstream + const server = createHttpsServer({ cert: readFileSync(o.cert), key: readFileSync(o.key) }, async (req, res) => { + try { + if (req.url === '/__e2e/grant' && req.method === 'POST') { + const body = await readJson(req) + const done = await grant(upstream, body.loginUrl, body.user || 'admin', o.password || 'admin') + console.log(`[e2e] granted access to "${done.client}"`) + return send(res, 200, { ok: true, client: done.client }) + } + if (req.url === '/__e2e/tokens') { + return send(res, 200, { names: tokenNames(o.container, o.user || 'admin') }) + } + if (req.url === '/__e2e/assetlinks' && req.method === 'POST') { + assetLinks = JSON.stringify(await readJson(req)) + return send(res, 200, { ok: true }) + } + if (req.url === '/.well-known/assetlinks.json') { + return send(res, 200, assetLinks) + } + if (req.url.startsWith('/__e2e/login.html')) { + return send(res, 200, LOGIN_PAGE, 'text/html; charset=utf-8') + } + } catch (e) { + console.error('[e2e]', e.message) + return send(res, 500, { error: e.message }) + } + // Forward, keeping the Host the phone used (a trusted domain). + const target = new URL(upstream) + const forward = httpRequest( + { hostname: target.hostname, port: target.port, method: req.method, path: req.url, headers: req.headers }, + (answer) => { + res.writeHead(answer.statusCode || 502, answer.headers) + answer.pipe(res) + }, + ) + forward.on('error', () => send(res, 502, { error: 'upstream' })) + req.pipe(forward) + }) + server.listen(Number(o.port || 8443), '0.0.0.0', () => console.log(`[e2e] proxy on https://0.0.0.0:${o.port || 8443} -> ${upstream}`)) +} + +/** The demo vault (seed). Names say "(demo)"; every address is a reserved example name (RFC 2606). */ +export const DEMO_FOLDERS = ['Personal', 'Work'] +export const DEMO_ITEMS = [ + { name: 'Webmail (demo)', type: 'login', url: 'https://webmail.example.com', login: 'anna.demo@example.com', key: 'Lantern-Orbit-42!', folder: null }, + { name: 'Router admin (demo)', type: 'login', url: 'https://router.example', login: 'admin', key: 'Quiet-Meadow-19$', folder: null }, + { + name: 'Authenticator (demo)', + type: 'totp', + url: 'https://webmail.example.com', + login: '', + key: 'otpauth://totp/Webmail%20demo:anna.demo%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Webmail%20demo', + folder: null, + }, + { name: 'Wi-Fi at home (demo)', type: 'note', url: '', login: '', key: 'Network: demo-home\nThis is not a real network.', folder: null }, + { name: 'Bank (demo)', type: 'login', url: 'https://bank.example.net', login: '40817265', key: 'Copper-Harbor-7#', folder: 'Personal' }, + { name: 'Intranet (demo)', type: 'login', url: 'https://intranet.example.org', login: 'anna.demo', key: 'Silver-Comet-58%', folder: 'Work' }, + { name: 'Project board (demo)', type: 'login', url: 'https://board.example.org', login: 'anna.demo@example.com', key: 'Amber-Valley-23&', folder: 'Work' }, +] + +/** + * A field as the apps encrypt it (src/crypto/rsa.js rsaEncrypt, the shared + * core's RsaFields): a 4-byte big-endian chunk count, then one 512-byte + * RSA-OAEP-SHA256 block per 446 bytes of UTF-8, base64. + */ +export function encryptField(text, certificatePem) { + const data = Buffer.from(text, 'utf8') + const chunks = [] + for (let i = 0; i < Math.max(data.length, 1); i += 446) chunks.push(data.subarray(i, i + 446)) + const head = Buffer.alloc(4) + head.writeUInt32BE(chunks.length) + const blocks = chunks.map((c) => publicEncrypt({ key: certificatePem, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, c)) + return Buffer.concat([head, ...blocks]).toString('base64') +} + +/** Keepiq's API as admin, for seed and record. */ +function keepiqApi(upstream, password) { + const headers = { + authorization: 'Basic ' + Buffer.from(`admin:${password}`).toString('base64'), + 'ocs-apirequest': 'true', + accept: 'application/json', + } + return async (method, path, body) => { + const opts = { headers: { ...headers } } + if (body !== undefined) { + opts.headers['content-type'] = 'application/json' + opts.body = JSON.stringify(body) + } + const r = await nc(upstream, method, '/index.php/apps/keepiq' + path, opts) + if (r.status < 200 || r.status > 299) throw new Error(`${method} ${path} answered ${r.status}: ${r.text.slice(0, 200)}`) + return r.text ? JSON.parse(r.text) : null + } +} + +const listOf = (data) => (Array.isArray(data) ? data : data?.items || []) + +async function seed(o) { + const call = keepiqApi(o.upstream, o.password || 'admin') + const suite = listOf(await call('GET', '/api/v1/suites')).find((s) => s.status === 'active') + if (!suite?.certificate) throw new Error('admin has no active suite with a certificate') + const types = listOf(await call('GET', '/api/v1/secret-types')) + const folders = listOf(await call('GET', '/api/v1/folders')) + const folderId = {} + for (const name of DEMO_FOLDERS) { + const found = folders.find((f) => f.name === name && !f.parentId) + folderId[name] = found ? found.id : (await call('POST', '/api/v1/folders', { name, parentId: null })).id + } + const manifest = await call('GET', '/api/v1/offline/manifest') + const have = new Set((manifest.secrets || []).map((s) => s.name)) + let added = 0 + for (const item of DEMO_ITEMS) { + if (have.has(item.name)) continue + const type = types.find((t) => t.name === item.type) + if (!type) throw new Error(`no secret type ${item.type}`) + const body = { + name: item.name, + url: item.url || null, + typeId: type.id, + folderId: item.folder ? folderId[item.folder] : null, + key: encryptField(item.key, suite.certificate), + } + if (item.login) body.login = encryptField(item.login, suite.certificate) + await call('POST', '/api/v1/secrets', body) + added++ + } + console.log(`[e2e] seeded ${added} demo items (${DEMO_ITEMS.length - added} were there)`) +} + +/** + * Records what the iOS replay needs. App passwords are replaced by fixed + * fake ones, and the recorded host by a placeholder the replay fills in. + */ +async function record(o) { + const upstream = o.upstream + const host = new URL(upstream).host + const origin = `https://${host}` + const keepiq = '/index.php/apps/keepiq' + const json = (r) => JSON.parse(r.text) + const basic = (pw) => ({ authorization: 'Basic ' + Buffer.from(`admin:${pw}`).toString('base64'), 'ocs-apirequest': 'true', accept: 'application/json' }) + const out = { recordedAt: new Date().toISOString().slice(0, 10), origin: '{origin}' } + + const init = await nc(upstream, 'POST', '/index.php/login/v2', { headers: { 'user-agent': USER_AGENT } }) + if (init.status !== 200) throw new Error(`login/v2 answered ${init.status}`) + const flow = json(init) + await grant(upstream, flow.login.replace(/^http:/, 'https:'), 'admin', o.password || 'admin') + const poll = await nc(upstream, 'POST', '/index.php/login/v2/poll', { form: { token: flow.poll.token } }) + if (poll.status !== 200) throw new Error(`poll answered ${poll.status}`) + const creds = json(poll) + const appPassword = creds.appPassword + + const call = async (method, path) => { + const r = await nc(upstream, method, keepiq + path, { headers: basic(appPassword) }) + if (r.status !== 200) throw new Error(`${method} ${path} answered ${r.status}: ${r.text.slice(0, 200)}`) + return json(r) + } + const callJson = async (method, path, body) => { + const r = await nc(upstream, method, keepiq + path, { + headers: { ...basic(appPassword), 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + if (r.status < 200 || r.status > 299) throw new Error(`${method} ${path} answered ${r.status}: ${r.text.slice(0, 200)}`) + return json(r) + } + const pair = await call('POST', '/api/v1/extension/pair') + const suites = await call('GET', '/api/v1/suites') + const policy = await call('GET', '/api/v1/extension/policy') + + // The vault (seed first): the manifest, each item as GET answers it, the + // generator policy, and one Send made and ended again for its shapes. + const manifest = await call('GET', '/api/v1/offline/manifest') + if (!(manifest.secrets || []).some((s) => s.name === DEMO_ITEMS[0].name)) throw new Error('the vault is not seeded: run `server.mjs seed` first') + const detail = await call('GET', `/api/v1/secrets/${encodeURIComponent(manifest.secrets[0].id)}`) + const generatorPolicy = await call('GET', '/api/settings/policy') + const send = await callJson('POST', '/api/v1/sends', { + encryptedPayload: 'recorded-payload-not-a-secret', + payloadType: 'text', + maxViews: 1, + ttlSeconds: 3600, + hasPassword: false, + }) + const sends = await call('GET', '/api/v1/sends') + await call('DELETE', `/api/v1/sends/${encodeURIComponent(send.id)}`) + occ(o.container, 'config:app:set', 'keepiq', 'vault_require_two_factor', '--value=true', '--type=boolean') + // The web server may read the setting from its cache for a moment. + let blocked + try { + for (let i = 0; i < 30; i++) { + blocked = await call('GET', '/api/v1/suites') + if (listOf(blocked).some((s) => s.unlockBlocked)) break + await new Promise((r) => setTimeout(r, 2000)) + } + if (!listOf(blocked).some((s) => s.unlockBlocked)) throw new Error('the two-factor block never showed in /suites') + } finally { + occ(o.container, 'config:app:delete', 'keepiq', 'vault_require_two_factor') + } + for (let i = 0; i < 30 && listOf(await call('GET', '/api/v1/suites')).some((s) => s.unlockBlocked); i++) { + await new Promise((r) => setTimeout(r, 2000)) + } + const unpair = await call('POST', '/api/v1/extension/unpair') + const revoke = await nc(upstream, 'DELETE', '/ocs/v2.php/core/apppassword', { headers: basic(appPassword) }) + const after = await nc(upstream, 'POST', keepiq + '/api/v1/extension/pair', { headers: basic(appPassword) }) + + const placeholder = (text) => text.split(origin).join('{origin}').split(`http://${host}`).join('{origin}') + out.loginInit = JSON.parse(placeholder(JSON.stringify(flow))) + out.loginInit.poll.token = 'recorded-poll-token' + out.loginInit.login = out.loginInit.login.replace(/flow\/[^/?#]+/, 'flow/recorded-login-token') + out.poll = { ...creds, server: '{origin}', appPassword: 'stub-app-password' } + out.pair = pair + out.suites = suites + out.suitesTwoFactorRequired = blocked + out.policy = policy + out.manifest = manifest + // GET of one item answers the manifest row plus its tags. + out.secretTags = detail.tags ?? [] + out.generatorPolicy = generatorPolicy + out.sendCreated = send + out.sendListed = listOf(sends).find((s) => s.id === send.id) || listOf(sends)[0] || null + out.unpair = unpair + out.revoke = { status: revoke.status, body: JSON.parse(revoke.text) } + out.afterRevoke = { status: after.status, body: JSON.parse(after.text || '{}') } + mkdirSync(dirname(FIXTURES), { recursive: true }) + const text = JSON.stringify(out, null, '\t') + '\n' + if (text.includes(appPassword)) throw new Error('the real app password is still in the recording') + writeFileSync(FIXTURES, text) + console.log(`[e2e] wrote ${FIXTURES}`) +} + +/** + * Answers as the recorded server. Accepted app passwords: stub-app-password + * (from the login flow) and manual-app-password (typed by hand), for admin; + * the user "blocked" gets the suite with the two-factor block. A revoked + * password answers 401 afterwards, as the real server does. + */ +function replay(o) { + const f = JSON.parse(readFileSync(FIXTURES, 'utf8')) + const valid = new Set(['admin:stub-app-password', 'admin:manual-app-password', 'blocked:manual-app-password']) + const polls = new Map() + const fill = (value, origin) => JSON.parse(JSON.stringify(value).split('{origin}').join(origin)) + const vault = new Map((f.manifest?.secrets || []).map((row) => [row.id, { ...row }])) + const sends = new Map() + const server = createHttpsServer({ cert: readFileSync(o.cert), key: readFileSync(o.key) }, async (req, res) => { + const origin = `https://${req.headers.host}` + // Nextcloud links its Login Flow routes with and without /index.php + // (the recording has /login/v2/poll), so the replay accepts both. + const path = req.url.split('?')[0].replace(/^\/index\.php(?=\/login\/)/, '') + const body = await new Promise((resolve) => { + const chunks = [] + req.on('data', (c) => chunks.push(c)) + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) + }) + console.log(`[replay] ${req.method} ${path}`) + if (req.method === 'POST' && path === '/login/v2') return send(res, 200, fill(f.loginInit, origin)) + if (req.method === 'POST' && path === '/login/v2/poll') { + // Pending twice, as while the user signs in, then granted. + const token = new URLSearchParams(body).get('token') || '' + const n = (polls.get(token) || 0) + 1 + polls.set(token, n) + return n < 3 ? send(res, 404, '[]') : send(res, 200, fill(f.poll, origin)) + } + if (path.startsWith('/login/v2/flow')) { + return send(res, 200, 'Nextcloud

Log in to Nextcloud (test stub)

', 'text/html') + } + const auth = Buffer.from((req.headers.authorization || '').replace(/^Basic /, ''), 'base64').toString('utf8') + if (!valid.has(auth)) return send(res, 401, { error: 'unauthorized' }) + const user = auth.split(':')[0] + if (req.method === 'DELETE' && path === '/ocs/v2.php/core/apppassword') { + valid.delete(auth) + return send(res, f.revoke.status, f.revoke.body) + } + const keepiq = '/index.php/apps/keepiq/api/v1' + if (req.method === 'POST' && path === `${keepiq}/extension/pair`) return send(res, 200, { ...f.pair, user }) + if (req.method === 'POST' && path === `${keepiq}/extension/unpair`) return send(res, 200, f.unpair) + if (req.method === 'GET' && path === `${keepiq}/extension/policy`) return send(res, 200, f.policy) + if (req.method === 'GET' && path === `${keepiq}/suites`) return send(res, 200, user === 'blocked' ? f.suitesTwoFactorRequired : f.suites) + const answer = user === 'admin' ? vaultAnswer(f, vault, sends, req.method, path, req.url, body) : null + if (answer) return send(res, answer[0], answer[1]) + return send(res, 404, { error: 'not recorded' }) + }) + server.listen(Number(o.port || 8443), '0.0.0.0', () => console.log(`[replay] on https://0.0.0.0:${o.port || 8443}`)) +} + +const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00') + +/** + * The vault and Send endpoints of the replay: the recorded manifest, kept in + * memory and changed by the writes the tests make. Field values arrive + * encrypted by the app and are kept as they are; the replay never sees a + * plaintext. Answers [status, body], or null for a path it does not know. + */ +function vaultAnswer(f, vault, sends, method, path, url, body) { + const api = '/index.php/apps/keepiq/api/v1' + const json = () => { + try { + return JSON.parse(body || '{}') + } catch { + return {} + } + } + const rows = () => [...vault.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + if (method === 'GET' && path === '/index.php/apps/keepiq/api/settings/policy') return [200, f.generatorPolicy] + if (method === 'GET' && path === `${api}/offline/manifest`) return [200, { ...f.manifest, secrets: rows(), syncedAt: now() }] + if (method === 'GET' && path === `${api}/folders`) return [200, f.manifest.folders] + if (method === 'GET' && path === `${api}/secret-types`) return [200, f.manifest.types] + if (method === 'GET' && path === `${api}/secrets`) { + const limit = Number(new URL(url, 'https://replay').searchParams.get('limit') || 100) + return [200, { items: rows().slice(0, limit), total: vault.size }] + } + if (method === 'POST' && path === `${api}/secrets`) { + const b = json() + const template = f.manifest.secrets[0] + const at = now() + const row = { + ...template, + id: randomUUID(), + name: b.name, + url: b.url ?? null, + typeId: b.typeId ?? null, + folderId: b.folderId ?? null, + key: b.key, + login: b.login ?? null, + additionalFields: b.additionalFields ?? null, + createdAt: at, + updatedAt: at, + keyUpdatedAt: at, + } + vault.set(row.id, row) + return [201, row] + } + const secret = path.match(new RegExp(`^${api}/secrets/([^/]+)$`)) + if (secret) { + const id = decodeURIComponent(secret[1]) + const row = vault.get(id) + if (!row) return [404, { error: 'not found' }] + if (method === 'GET') return [200, { ...row, tags: f.secretTags || [] }] + if (method === 'PUT') { + const b = json() + for (const field of ['name', 'url', 'folderId', 'key', 'login', 'additionalFields']) if (field in b) row[field] = b[field] + row.updatedAt = now() + if ('key' in b) row.keyUpdatedAt = row.updatedAt + return [200, row] + } + if (method === 'DELETE') { + vault.delete(id) + return [200, { ...row, trashedAt: now() }] + } + } + if (method === 'GET' && path === `${api}/sends`) return [200, [...sends.values()]] + if (method === 'POST' && path === `${api}/sends`) { + const b = json() + const id = randomUUID() + const at = new Date() + const { token: _recordedToken, ...shape } = f.sendListed || {} + const listed = { + ...shape, + id, + payloadType: b.payloadType || 'text', + maxViews: b.maxViews ?? 1, + viewCount: 0, + hasPassword: b.hasPassword === true, + createdAt: now(), + expiresAt: new Date(at.getTime() + (b.ttlSeconds || 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, '+00:00'), + } + sends.set(id, listed) + return [201, { ...f.sendCreated, ...listed, token: randomUUID().replace(/-/g, '') }] + } + const oneSend = path.match(new RegExp(`^${api}/sends/([^/]+)$`)) + if (oneSend && method === 'DELETE') { + sends.delete(decodeURIComponent(oneSend[1])) + return [200, { success: true }] + } + return null +} + +const o = options(process.argv.slice(2)) +if (o._ === 'proxy') proxy(o) +else if (o._ === 'seed') await seed(o) +else if (o._ === 'record') await record(o) +else if (o._ === 'replay') replay(o) +else { + console.error('usage: server.mjs proxy|seed|record|replay [--options]') + process.exit(2) +} diff --git a/mobile/fastlane/metadata/android/en-US/changelogs/1.txt b/mobile/fastlane/metadata/android/en-US/changelogs/1.txt new file mode 100644 index 000000000..7908f0521 --- /dev/null +++ b/mobile/fastlane/metadata/android/en-US/changelogs/1.txt @@ -0,0 +1 @@ +The first release of Keepiq for Android. Open your vault, fill in logins and passkeys, make passwords and share secrets with a Send link. diff --git a/mobile/fastlane/metadata/android/en-US/full_description.txt b/mobile/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 000000000..187e77061 --- /dev/null +++ b/mobile/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,33 @@ +Keepiq puts your passwords on your phone. They come from your own Nextcloud, not from someone else's cloud. + +Your organisation runs Keepiq on its Nextcloud. Connect your phone once and your vault is there. Every login, one-time code and passkey you saved on the web is on your phone too. + +You open your vault with your master password. Keepiq decrypts it on the phone, so your Nextcloud only ever stores it encrypted. A PIN or your fingerprint opens it faster. + +What you can do + +- Find a login by name or web address and copy the password. Keepiq clears the clipboard after a minute. +- See the current one-time code of a login with two-step verification. +- Fill in logins in other apps and in your browser, without copying anything. +- Save and use passkeys on Android 14 and later. +- Make a strong password or passphrase that fits your organisation's rules. +- Share a secret with a Send link that expires after a set time or number of views. +- Open, search and copy without a connection. + +Keepiq only gives a login to the app it belongs to. A copy of that app from another publisher gets nothing. + +Your data + +- Keepiq talks to your own Nextcloud. It sends nothing to us. +- There are no analytics, no crash reports and no ads. +- When you open a Send link, Keepiq fetches it from the server in that link. +- When another app asks for a login, Keepiq checks that the app's website lists the app. + +Read the full privacy policy at https://keepiq.conduction.nl/docs/mobile/privacy + +What you need + +- A Nextcloud with the Keepiq app. +- Android 9 or later. + +Keepiq is free software under the EUPL. The source code is on GitHub. diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 000000000..235c9e06d Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 000000000..7b5798ab6 Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 000000000..b04b0fbd1 Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 000000000..6f5b8c335 Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 000000000..b0210a88e Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png new file mode 100644 index 000000000..45308808b Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png new file mode 100644 index 000000000..3662639ce Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png differ diff --git a/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png new file mode 100644 index 000000000..42e358edf Binary files /dev/null and b/mobile/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png differ diff --git a/mobile/fastlane/metadata/android/en-US/short_description.txt b/mobile/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 000000000..0dd053aac --- /dev/null +++ b/mobile/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Your passwords and passkeys from your own Nextcloud, safe on your phone. diff --git a/mobile/fastlane/metadata/android/en-US/title.txt b/mobile/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 000000000..81e1106b6 --- /dev/null +++ b/mobile/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Keepiq diff --git a/mobile/fastlane/metadata/android/nl-NL/changelogs/1.txt b/mobile/fastlane/metadata/android/nl-NL/changelogs/1.txt new file mode 100644 index 000000000..83aa0e7fe --- /dev/null +++ b/mobile/fastlane/metadata/android/nl-NL/changelogs/1.txt @@ -0,0 +1 @@ +De eerste versie van Keepiq voor Android. Open je kluis, vul logins en passkeys in, maak wachtwoorden en deel geheimen met een Send-link. diff --git a/mobile/fastlane/metadata/android/nl-NL/full_description.txt b/mobile/fastlane/metadata/android/nl-NL/full_description.txt new file mode 100644 index 000000000..3416cddc0 --- /dev/null +++ b/mobile/fastlane/metadata/android/nl-NL/full_description.txt @@ -0,0 +1,33 @@ +Keepiq zet je wachtwoorden op je telefoon. Ze komen uit je eigen Nextcloud, niet uit de cloud van een ander. + +Je organisatie draait Keepiq op haar Nextcloud. Koppel je telefoon één keer en je kluis staat erop. Elke login, eenmalige code en passkey die je op het web bewaarde, heb je dan ook op je telefoon. + +Je opent je kluis met je hoofdwachtwoord. Keepiq ontsleutelt hem op de telefoon, dus je Nextcloud bewaart hem alleen versleuteld. Met een pincode of je vingerafdruk gaat hij sneller open. + +Wat je ermee doet + +- Zoek een login op naam of webadres en kopieer het wachtwoord. Keepiq wist het klembord na een minuut. +- Zie de actuele eenmalige code van een login met tweestapsverificatie. +- Vul logins in andere apps en in je browser in, zonder iets te kopiëren. +- Bewaar en gebruik passkeys vanaf Android 14. +- Maak een sterk wachtwoord of een wachtzin die past bij de regels van je organisatie. +- Deel een geheim met een Send-link die verloopt na een vaste tijd of een aantal keer openen. +- Open, zoek en kopieer ook zonder verbinding. + +Keepiq geeft een login alleen aan de app waar hij bij hoort. Een kopie van die app van een andere uitgever krijgt niets. + +Je gegevens + +- Keepiq praat met je eigen Nextcloud. Naar ons stuurt de app niets. +- Er zijn geen analytics, geen crashrapporten en geen advertenties. +- Open je een Send-link, dan haalt Keepiq die op bij de server in die link. +- Vraagt een andere app om een login, dan controleert Keepiq of de website van die app de app noemt. + +Lees het volledige privacybeleid (in het Engels) op https://keepiq.conduction.nl/docs/mobile/privacy + +Wat je nodig hebt + +- Een Nextcloud met de app Keepiq. +- Android 9 of nieuwer. + +Keepiq is vrije software onder de EUPL. De broncode staat op GitHub. diff --git a/mobile/fastlane/metadata/android/nl-NL/short_description.txt b/mobile/fastlane/metadata/android/nl-NL/short_description.txt new file mode 100644 index 000000000..ceda66745 --- /dev/null +++ b/mobile/fastlane/metadata/android/nl-NL/short_description.txt @@ -0,0 +1 @@ +Je wachtwoorden en passkeys uit je eigen Nextcloud, veilig op je telefoon. diff --git a/mobile/fastlane/metadata/android/nl-NL/title.txt b/mobile/fastlane/metadata/android/nl-NL/title.txt new file mode 100644 index 000000000..81e1106b6 --- /dev/null +++ b/mobile/fastlane/metadata/android/nl-NL/title.txt @@ -0,0 +1 @@ +Keepiq diff --git a/mobile/gradle.properties b/mobile/gradle.properties new file mode 100644 index 000000000..74833995c --- /dev/null +++ b/mobile/gradle.properties @@ -0,0 +1,8 @@ +org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 +org.gradle.caching=false +kotlin.code.style=official +kotlin.mpp.enableCInteropCommonization=true +android.useAndroidX=true +android.nonTransitiveRClass=true +# iOS targets compile only on macOS. On Linux they are skipped without a warning. +kotlin.native.ignoreDisabledTargets=true diff --git a/mobile/gradle/libs.versions.toml b/mobile/gradle/libs.versions.toml new file mode 100644 index 000000000..b6e7913d3 --- /dev/null +++ b/mobile/gradle/libs.versions.toml @@ -0,0 +1,87 @@ +# Every version is pinned. Every artifact is free software from Maven Central +# or Google's Maven repository, so F-Droid can build from source +# (clients-mobile-apps design D1 and D8). gradle/verification-metadata.xml +# holds the checksum of every artifact the build resolves. +[versions] +kotlin = "2.2.21" +agp = "8.13.0" +# The aapt2 build AGP 8.13.0 downloads (aapt2_version.properties in the AGP jar). +aapt2 = "8.13.0-13719691" +kotlinx-serialization = "1.9.0" +kotlinx-coroutines = "1.10.2" +cryptography-kotlin = "0.5.0" +bouncycastle = "1.81" +ktor = "3.3.1" +sqldelight = "2.1.0" +sqlcipher-android = "4.10.0" +androidx-sqlite = "2.4.0" +androidx-activity = "1.10.1" +compose-bom = "2025.06.01" +androidx-biometric = "1.1.0" +androidx-autofill = "1.1.0" +# The Credential Manager provider API (passkeys, task 5.1). Never with +# credentials-play-services-auth: a provider does not need it, and it is not free software. +androidx-credentials = "1.3.0" +androidx-uiautomator = "2.3.0" +androidx-browser = "1.8.0" +androidx-fragment = "1.8.9" +androidx-lifecycle = "2.9.4" +androidx-test-runner = "1.6.2" +androidx-test-rules = "1.6.1" +androidx-test-junit = "1.2.1" +androidx-espresso = "3.6.1" +robolectric = "4.16" +junit = "4.13.2" +android-compileSdk = "36" +android-minSdk = "28" +android-targetSdk = "36" +# The Android build tools (aapt2 aside, which AGP pins above), pinned so a +# rebuild gives the same APK (task 6.2). +android-buildTools = "35.0.0" + +[libraries] +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography-kotlin" } +cryptography-provider-apple = { module = "dev.whyoleg.cryptography:cryptography-provider-apple", version.ref = "cryptography-kotlin" } +cryptography-provider-cryptokit = { module = "dev.whyoleg.cryptography:cryptography-provider-cryptokit", version.ref = "cryptography-kotlin" } +bouncycastle-prov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "androidx-biometric" } +androidx-autofill = { module = "androidx.autofill:autofill", version.ref = "androidx-autofill" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } +androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" } +androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } +compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } +androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "androidx-espresso" } +compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +junit = { module = "junit:junit", version.ref = "junit" } +aapt2-linux = { module = "com.android.tools.build:aapt2", version.ref = "aapt2" } + +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqlcipher-android = { module = "net.zetetic:sqlcipher-android", version.ref = "sqlcipher-android" } +androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "androidx-sqlite" } + +[plugins] +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "agp" } +android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/mobile/gradle/verification-metadata.xml b/mobile/gradle/verification-metadata.xml new file mode 100644 index 000000000..607a0086d --- /dev/null +++ b/mobile/gradle/verification-metadata.xml @@ -0,0 +1,6277 @@ + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/gradle/wrapper/gradle-wrapper.jar b/mobile/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..1b33c55ba Binary files /dev/null and b/mobile/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/gradle/wrapper/gradle-wrapper.properties b/mobile/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..10b1471a7 --- /dev/null +++ b/mobile/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/gradlew b/mobile/gradlew new file mode 100755 index 000000000..0f14772e0 --- /dev/null +++ b/mobile/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mobile/gradlew.bat b/mobile/gradlew.bat new file mode 100644 index 000000000..8de1053a1 --- /dev/null +++ b/mobile/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/ios/Keepiq/AppModel.swift b/mobile/ios/Keepiq/AppModel.swift new file mode 100644 index 000000000..5ade21eed --- /dev/null +++ b/mobile/ios/Keepiq/AppModel.swift @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import AuthenticationServices +import Foundation +import KeepiqShared +import UIKit + +/// Where the app is. +enum AppScreen { + case pair + case unlock(String) + /// The vault is open; the vault screens read and write through the session. + case unlocked(UnlockedVault, MobileSession) + case settings(UnlockedVault) +} + +/// The app's state and the actions the views call (tasks 2.1 to 2.6). All of +/// it runs on the main actor, which is also where Kotlin's suspend functions +/// are called from. +@MainActor +final class AppModel: ObservableObject { + @Published private(set) var screen: AppScreen = .pair + @Published var message: String? + @Published private(set) var busy = false + @Published private(set) var gate: UnlockGate? + @Published private(set) var waiting: LoginFlowStart? + @Published private(set) var maxIdle: Int? + /// Bumped after a settings change, so views re-read the stores. + @Published private(set) var revision = 0 + + let client: KeepiqClient + let biometric = BiometricKeychain() + private let browser = BrowserLogin() + private var loginTask: Task? + private var idleTask: Task? + private var lastActivity = Date() + /// The open vault's session, closed on lock. + private var session: MobileSession? + /// UI tests replace the browser: the replayed server grants on its own. + private let noBrowser: Bool + + init() { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("-keepiq-reset") { KeychainStorage.deleteAll() } + noBrowser = ProcessInfo.processInfo.environment["KEEPIQ_UITEST_NO_BROWSER"] == "1" + #else + noBrowser = false + #endif + client = KeepiqClientKt.doNewKeepiqClient(storage: KeychainStorage(), clientName: "Keepiq for iOS (\(UIDevice.current.model))") + let biometric = self.biometric + // Unpair wipes the account's autofill index too (task 4.6); every + // vault refresh rebuilds it through the hub. + AutofillIndexHub.shared.sink = IOSAutofillIndex.shared + client.onWipe = { accountId in + biometric.delete(accountId) + AutofillIndexHub.shared.clear(accountId: accountId) + } + if let active = client.accounts.activeId() { screen = .unlock(active) } + NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataWillBecomeUnavailableNotification, object: nil, queue: .main + ) { [weak self] _ in + // The phone locked (design D4): lock the vault with it. + MainActor.assumeIsolated { self?.lock() } + } + } + + var accounts: [Account] { client.accounts.accounts() } + + func account(_ id: String) -> Account? { client.accounts.account(id: id) } + + // MARK: Pairing (2.1, 2.4) + + func startLogin(server: String) { + perform { + let start = try await self.client.startLogin(serverInput: server) + self.waiting = start + if !self.noBrowser, let url = URL(string: start.loginUrl) { + self.browser.open(url) { [weak self] in self?.browserClosed() } + } + self.loginTask = Task { [weak self] in + guard let self else { return } + do { + let account = try await self.client.finishLogin(start: start) + self.loginDone(account) + } catch { + self.waiting = nil + self.browser.close() + if !Self.isCancellation(error) { self.message = Self.text(error) } + } + } + } + } + + /// The user closed the browser sheet: one last poll, then the form again. + private func browserClosed() { + guard let start = waiting else { return } + loginTask?.cancel() + client.cancelLogin() + perform { + let account = try await self.client.finishLoginNow(start: start) + self.waiting = nil + if let account { self.loginDone(account) } + } + } + + func cancelLogin() { + loginTask?.cancel() + client.cancelLogin() + waiting = nil + browser.close() + } + + private func loginDone(_ account: Account) { + waiting = nil + browser.close() + showUnlock(account.accountId) + } + + func pairManually(server: String, loginName: String, appPassword: String) { + perform { + let account = try await self.client.pairManually(serverInput: server, loginName: loginName, appPassword: appPassword) + self.showUnlock(account.accountId) + } + } + + func addAccount() { + lock() + message = nil + screen = .pair + } + + func switchAccount(_ id: String) { + lock() + try? client.accounts.setActive(id: id) + showUnlock(id) + } + + // MARK: Unlock (2.2, 2.3) + + private func showUnlock(_ accountId: String) { + gate = nil + message = nil + screen = .unlock(accountId) + refreshGate(accountId) + } + + func refreshGate(_ accountId: String) { + perform { self.gate = try await self.client.unlockGate(accountId: accountId) } + } + + func unlock(_ accountId: String, masterPassword: String) { + withSuite(accountId) { suite in + try await self.client.unlockWithMasterPassword(accountId: accountId, suite: suite, masterPassword: masterPassword) + } + } + + func unlock(_ accountId: String, pin: String) { + withSuite(accountId) { suite in + try await self.client.unlockWithPin(accountId: accountId, suite: suite, pin: pin) + } + } + + func unlockWithBiometric(_ accountId: String) { + withSuite(accountId) { suite in + let key = try await self.biometric.read(accountId) + do { + return try await self.client.unlockWithKeyBase64(accountId: accountId, suite: suite, unlockKey: key) + } catch { + if (error as NSError).userInfo["KotlinException"] is StaleUnlockKeyException { self.biometric.delete(accountId) } + throw error + } + } + } + + private func withSuite(_ accountId: String, _ open: @escaping (Suite) async throws -> UnlockedVault) { + perform { + let current: UnlockGate + if let gate = self.gate { + current = gate + } else { + current = try await self.client.unlockGate(accountId: accountId) + } + self.gate = current + if let blocked = current as? UnlockGate.Blocked { + self.message = blocked.message + return + } + guard let ready = current as? UnlockGate.Ready else { return } + let vault = try await open(ready.suite) + let session: MobileSession + do { + guard let account = self.account(accountId) else { throw AppError.accountGone } + session = try MobileSession.companion.forVault(account: account, vault: vault) + } catch { + vault.lock() + throw error + } + self.session = session + self.message = nil + self.screen = .unlocked(vault, session) + self.refreshAutofill(vault) + if let max = try? await self.client.maxIdleMinutes(accountId: accountId) { + self.maxIdle = max.intValue + } else { + self.maxIdle = nil + } + self.touch() + self.startIdleWatch() + } + } + + /// Reads the vault once after an unlock, which rebuilds the AutoFill + /// index (task 4.6), while Keepiq is the AutoFill provider. Without it, + /// the index files are deleted. + func refreshAutofill(_ vault: UnlockedVault) { + guard let account = account(vault.accountId) else { return } + Task { + guard await AutofillState.isEnabled() else { + AutofillFiles.shared.clearAll() + return + } + do { + let keys = try AutofillSites.shared.keysOf(vault: vault) + let session = try MobileSession.companion.online(account: account, keys: keys) + _ = try await session.repository.refresh(trigger: SyncTrigger.manual) + } catch { + // The next unlock tries again. + } + } + } + + // MARK: Unlock options (2.3) + + func enableBiometric(_ vault: UnlockedVault) { + perform { + try self.biometric.save(vault.accountId, unlockKey: vault.unlockKeyBase64()) + self.revision += 1 + } + } + + func disableBiometric(_ vault: UnlockedVault) { + biometric.delete(vault.accountId) + revision += 1 + } + + func setPin(_ vault: UnlockedVault, pin: String) { + perform { + try await self.client.setPin(vault: vault, pin: pin) + self.revision += 1 + } + } + + func removePin(_ vault: UnlockedVault) { + client.pins.remove(accountId: vault.accountId) + revision += 1 + } + + // MARK: Locking (2.5) + + func idleChoice(_ accountId: String) -> Int { Int(client.accounts.settings(id: accountId).idleMinutes) } + + func setIdle(_ vault: UnlockedVault, minutes: Int) { + let current = client.accounts.settings(id: vault.accountId) + try? client.accounts.updateSettings(id: vault.accountId, settings: current.doCopy(idleMinutes: Int32(minutes), biometric: current.biometric)) + revision += 1 + } + + func effectiveIdle(_ accountId: String) -> Int { + let max = maxIdle.map { KotlinInt(int: Int32($0)) } + return Int(IdlePolicy.shared.effectiveMinutes(choice: Int32(idleChoice(accountId)), organisationMax: max)) + } + + func offeredIdleChoices() -> [Int] { + let max = maxIdle.map { KotlinInt(int: Int32($0)) } + return IdlePolicy.shared.offeredChoices(organisationMax: max).map { $0.intValue } + } + + /// Any interaction restarts the idle time. + func touch() { lastActivity = Date() } + + func cameToForeground() { + if let accountId = unlockedAccountId, Date().timeIntervalSince(lastActivity) >= Double(effectiveIdle(accountId) * 60) { + lock() + } else if unlockedAccountId != nil { + startIdleWatch() + } + } + + private var unlockedAccountId: String? { + switch screen { + case .unlocked(let vault, _), .settings(let vault): return vault.accountId + default: return nil + } + } + + private func startIdleWatch() { + idleTask?.cancel() + idleTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, let accountId = self.unlockedAccountId else { return } + if Date().timeIntervalSince(self.lastActivity) >= Double(self.effectiveIdle(accountId) * 60) { + self.lock() + return + } + } + } + } + + /// Locks now: the session forgets the private key, the unlock key is + /// overwritten, and the unlock screen shows. `reason` is shown there, for + /// a lock the user did not ask for. + func lock(reason: String? = nil) { + let vault: UnlockedVault + switch screen { + case .unlocked(let v, _), .settings(let v): vault = v + default: return + } + idleTask?.cancel() + closeSession() + vault.lock() + showUnlock(vault.accountId) + if let reason { message = reason } + } + + private func closeSession() { + session?.close() + session = nil + } + + func openSettings(_ vault: UnlockedVault) { touch(); screen = .settings(vault) } + + func closeSettings(_ vault: UnlockedVault) { + touch() + guard let session else { return lock() } + screen = .unlocked(vault, session) + } + + // MARK: Unpair (2.6) + + func unpair(_ accountId: String) { + closeSession() + if case .unlocked(let v, _) = screen { v.lock() } + if case .settings(let v) = screen { v.lock() } + idleTask?.cancel() + perform { + let revoked = try await self.client.unpair(accountId: accountId).boolValue + self.gate = nil + if let active = self.client.accounts.activeId() { self.screen = .unlock(active); self.refreshGate(active) } else { self.screen = .pair } + self.message = revoked + ? "Disconnected. The app password was deleted in Nextcloud." + : "Disconnected on this phone. Nextcloud could not be reached, so delete the app password there under Security." + } + } + + // MARK: Helpers + + /// Every action restarts the idle time. A tap gesture over the whole app + /// would do it for plain taps too, but it swallows the taps of Form + /// buttons on iOS 18. + private func perform(_ work: @escaping () async throws -> Void) { + touch() + busy = true + message = nil + Task { + defer { self.busy = false } + do { + try await work() + } catch { + if !Self.isCancellation(error) { self.message = Self.text(error) } + } + } + } + + /// The Kotlin message of a core error, or the Swift one. + static func text(_ error: Error) -> String { + if let kotlin = (error as NSError).userInfo["KotlinException"] as? KotlinThrowable, let message = kotlin.message { + return message + } + return error.localizedDescription + } + + static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + if case BiometricError.cancelled = error { return true } + if let stopped = (error as NSError).userInfo["KotlinException"] as? LoginFlowStoppedException { return !stopped.timedOut } + return false + } +} + +enum AppError: LocalizedError { + case accountGone + + var errorDescription: String? { "This account is gone from this phone." } +} + +/// The login page in the system browser sheet (design D3). Nextcloud's +/// grant page does not call back, so the app closes the sheet itself when +/// polling found the app password. +final class BrowserLogin: NSObject, ASWebAuthenticationPresentationContextProviding { + private var session: ASWebAuthenticationSession? + private var closedByApp = false + + func open(_ url: URL, onClosed: @escaping @MainActor () -> Void) { + closedByApp = false + let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "keepiq") { [weak self] _, _ in + guard let self, !self.closedByApp else { return } + Task { @MainActor in onClosed() } + } + session.presentationContextProvider = self + session.prefersEphemeralWebBrowserSession = false + self.session = session + session.start() + } + + func close() { + closedByApp = true + session?.cancel() + session = nil + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} diff --git a/mobile/ios/Keepiq/BundleModule.swift b/mobile/ios/Keepiq/BundleModule.swift new file mode 100644 index 000000000..178b7b7f1 --- /dev/null +++ b/mobile/ios/Keepiq/BundleModule.swift @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import Foundation + +/// The vault, Send and generator screens are the KeepiqApp Swift package, +/// which reads its strings from `Bundle.module`. The app target compiles +/// those sources itself (project.yml), and their Localizable.strings land in +/// the main bundle. +extension Bundle { + static var module: Bundle { .main } +} diff --git a/mobile/ios/Keepiq/Keepiq.entitlements b/mobile/ios/Keepiq/Keepiq.entitlements new file mode 100644 index 000000000..c106ab73e --- /dev/null +++ b/mobile/ios/Keepiq/Keepiq.entitlements @@ -0,0 +1,14 @@ + + + + + + + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.security.application-groups + + group.nl.conduction.keepiq + + + diff --git a/mobile/ios/Keepiq/KeepiqApp.swift b/mobile/ios/Keepiq/KeepiqApp.swift new file mode 100644 index 000000000..8fb44c2e8 --- /dev/null +++ b/mobile/ios/Keepiq/KeepiqApp.swift @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import CryptoKit +import KeepiqShared +import SwiftUI + +@main +struct KeepiqApp: App { + @StateObject private var model = AppModel() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(model) + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { model.cameToForeground() } + } + } +} + +/// One screen at a time (tasks 2.1 to 2.6). +struct RootView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + screens + #if DEBUG + .overlay(alignment: .bottom) { AutofillPreviewButton() } + #endif + } + + @ViewBuilder private var screens: some View { + switch model.screen { + case .unlocked(let vault, let session): + // The vault brings its own tabs and navigation stacks. + VaultAppView( + session: session, + accounts: model.accounts, + onSwitchAccount: { model.switchAccount($0.accountId) }, + onAddAccount: { model.addAccount() }, + onLock: { model.lock() }, + onSettings: { model.openSettings(vault) }, + onLocked: { model.lock(reason: $0) } + ) + .id(ObjectIdentifier(session)) + default: + NavigationStack { + switch model.screen { + case .pair: PairView() + case .unlock(let accountId): UnlockView(accountId: accountId) + case .settings(let vault): SettingsView(vault: vault) + case .unlocked: EmptyView() + } + } + } + } +} + +#if DEBUG +/// UI tests only (-keepiq-autofill-preview): the AutoFill extension's screens +/// inside the app, since a simulator test cannot pick Keepiq as the +/// provider in Settings. The model, files and Keychain are the extension's. +struct AutofillPreviewButton: View { + @EnvironmentObject private var model: AppModel + @ObservedObject private var index = IOSAutofillIndex.shared + @State private var preview: AutofillModel? + @State private var filled: String? + + var body: some View { + if ProcessInfo.processInfo.arguments.contains("-keepiq-autofill-preview"), case .unlocked(let vault, _) = model.screen { + VStack { + if let filled { Text(filled).accessibilityIdentifier("autofillFilled") } + Text(indexLine(vault.accountId)) + .accessibilityIdentifier("autofillIndex") + Button("AutoFill preview") { + preview = AutofillModel(serviceIdentifiers: [site]) { login in + filled = "filled: \(login.user) / \(login.password.count)" + preview = nil + self.model.refreshAutofill(vault) + } + } + .accessibilityIdentifier("autofillPreview") + if let rp = passkeyRp { passkeyButtons(rp, vault) } + } + .padding() + .sheet(item: Binding(get: { preview.map(PreviewBox.init) }, set: { if $0 == nil { preview = nil } })) { box in + AutofillRootView(model: box.model, onCancel: { preview = nil }) + } + } + } + + private var site: String { ProcessInfo.processInfo.environment["KEEPIQ_AUTOFILL_SITE"] ?? "example.com" } + + // MARK: Passkeys (task 5.2): the extension's passkey screens and calls, inside the app + + @State private var passkeyLine = "no passkey yet" + @State private var passkeyKey: P256.Signing.PublicKey? + private var passkeyRp: String? { ProcessInfo.processInfo.environment["KEEPIQ_PASSKEY_RP"] } + + /// A clientDataJSON as Safari would build it, and its hash, which is all the extension gets. + private func clientDataHash(_ type: String, _ rp: String) -> Data { + let json = #"{"type":"\#(type)","challenge":"\#(UUID().uuidString)","origin":"https://\#(rp)","crossOrigin":false}"# + return Data(SHA256.hash(data: Data(json.utf8))) + } + + @ViewBuilder private func passkeyButtons(_ rp: String, _ vault: UnlockedVault) -> some View { + Text(passkeyLine).accessibilityIdentifier("passkeyResult") + Text(passkeyIndexLine(vault.accountId, rp)).accessibilityIdentifier("passkeyIndex") + HStack { + Button("Create passkey") { + let request = PasskeyRequest.registration( + rpId: rp, + userName: "passkey.demo@example.test", + userHandle: Data("e2e-user-1".utf8), + clientDataHash: clientDataHash("webauthn.create", rp), + algorithms: [-7, -257] + ) + preview = AutofillModel(serviceIdentifiers: [rp], passkey: request, onPasskey: { result in + passkeyLine = describe(result) + preview = nil + self.model.refreshAutofill(vault) + }, onFill: { _ in }) + } + .accessibilityIdentifier("passkeyCreatePreview") + Button("Sign in with passkey") { + let request = PasskeyRequest.assertion(rpId: rp, clientDataHash: clientDataHash("webauthn.get", rp), credentialId: nil, allowed: []) + preview = AutofillModel(serviceIdentifiers: [rp], passkey: request, onPasskey: { result in + passkeyLine = describe(result) + preview = nil + self.model.refreshAutofill(vault) + }, onFill: { _ in }) + } + .accessibilityIdentifier("passkeySignInPreview") + } + } + + /// What iOS would get back, checked as a relying party would: the + /// attestation's flags and key, then the signature with that key. + private func describe(_ result: PasskeyResult) -> String { + switch result { + case .registration(let made, let rp, _, _): + guard let att = Data(base64Encoded: made.attestationObject), att.count >= 30 + 55 + 16 + 77 else { return "registration unreadable" } + let auth = [UInt8](att.dropFirst(30)) + let rpHash = [UInt8](SHA256.hash(data: Data(rp.utf8))) + let idLength = Int(auth[53]) << 8 | Int(auth[54]) + let cose = Array(auth[(55 + idLength)...]) + guard cose.count >= 77, let key = try? P256.Signing.PublicKey(rawRepresentation: Data(cose[10..<42] + cose[45..<77])) else { return "registration: no key" } + passkeyKey = key + let ok = Array(auth[0..<32]) == rpHash && auth[32] == 0x45 && auth[37..<53].allSatisfy { $0 == 0 } && idLength == 16 + return "registered: rp \(Array(auth[0..<32]) == rpHash), flags \(String(format: "0x%02x", auth[32])), id \(idLength) bytes, checks \(ok)" + case .assertion(let signed, let rp, let hash): + guard let key = passkeyKey, + let auth = Data(base64Encoded: signed.authenticatorData), + let der = Data(base64Encoded: signed.signature), + let signature = try? P256.Signing.ECDSASignature(derRepresentation: der) else { return "signed in: cannot verify" } + let verified = key.isValidSignature(signature, for: auth + hash) + let bytes = [UInt8](auth) + let counter = bytes[33..<37].reduce(0) { $0 << 8 | Int($1) } + let rpOk = Array(bytes[0..<32]) == [UInt8](SHA256.hash(data: Data(rp.utf8))) + return "signed in: verified \(verified), rp \(rpOk), flags \(String(format: "0x%02x", bytes[32])), counter \(counter), user \(Data(base64Encoded: signed.userHandle).map { String(decoding: $0, as: UTF8.self) } ?? "")" + } + } + + /// The passkeys in the site file on disk, and in the identity store's last rebuild. + private func passkeyIndexLine(_ accountId: String, _ rp: String) -> String { + let key = ApplePasskeys.shared.siteKey(rpId: rp) + let count = AutofillFiles.shared.read(accountId: accountId, site: key) + .map { AutofillIndex.companion.fromJson(text: $0).entries.filter { $0.isPasskey }.count } ?? 0 + return "\(key): \(count) passkeys on disk, passkey identities \(index.lastPasskeyCount)" + } + + /// What is on disk for the asked site, read back from its file, not counted on the way in. + private func indexLine(_ accountId: String) -> String { + let key = AutofillSites.shared.siteKey(serviceIdentifier: site) + let logins = AutofillFiles.shared.read(accountId: accountId, site: key) + .map { AutofillIndex.companion.fromJson(text: $0).entries.count } ?? 0 + let where_ = AutofillFiles.shared.isShared ? "shared" : "app only" + return "\(key): \(logins) logins on disk (\(where_)), sites \(index.lastSiteCount), identities \(index.lastIdentityCount)" + } + + private struct PreviewBox: Identifiable { + let model: AutofillModel + var id: ObjectIdentifier { ObjectIdentifier(model) } + } +} +#endif + +/// The last problem, read out by VoiceOver when it appears. +struct ProblemText: View { + let message: String? + + var body: some View { + if let message { + Text(message) + .foregroundStyle(.red) + .accessibilityIdentifier("message") + .accessibilityAddTraits(.updatesFrequently) + } + } +} + +extension String { + var withoutScheme: String { replacingOccurrences(of: "https://", with: "") } +} diff --git a/mobile/ios/Keepiq/Views.swift b/mobile/ios/Keepiq/Views.swift new file mode 100644 index 000000000..281a6693b --- /dev/null +++ b/mobile/ios/Keepiq/Views.swift @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// Connect an account (2.1): the server address, then the server's own login +/// page in the browser sheet; an app password as the fallback. +struct PairView: View { + @EnvironmentObject private var model: AppModel + @State private var server = "" + @State private var manual = false + @State private var loginName = "" + @State private var appPassword = "" + + var body: some View { + Form { + if model.waiting != nil { + Section { + Text("Sign in on the page that opened, then come back here. Keepiq waits up to 20 minutes.") + ProgressView().accessibilityIdentifier("waiting") + Button("Cancel", role: .cancel) { model.cancelLogin() }.accessibilityIdentifier("cancelLogin") + } + } else { + Section { + LabeledField("Server address") { + TextField("Server address", text: $server, prompt: Text("cloud.example.com")) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("server") + } + } + if !manual { + Section { + Button("Sign in with your browser") { model.startLogin(server: server) } + .disabled(model.busy || server.isEmpty) + .accessibilityIdentifier("signIn") + Button("Use an app password instead") { manual = true } + .accessibilityIdentifier("useAppPassword") + } + } else { + Section(footer: Text("Create an app password in Nextcloud under Personal settings, Security.")) { + LabeledField("User name") { + TextField("User name", text: $loginName, prompt: Text("Your Nextcloud user name")) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityIdentifier("loginName") + } + LabeledField("App password") { + SecureField("App password", text: $appPassword, prompt: Text("Paste the app password")) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("appPassword") + } + Button("Connect") { model.pairManually(server: server, loginName: loginName, appPassword: appPassword) } + .disabled(model.busy || server.isEmpty || loginName.isEmpty || appPassword.isEmpty) + .accessibilityIdentifier("connect") + Button("Sign in with your browser instead") { manual = false } + } + } + } + if model.busy { ProgressView() } + ProblemText(message: model.message) + if let active = model.client.accounts.activeId() { + Button("Back to your accounts") { model.switchAccount(active) } + } + } + .navigationTitle("Connect to Nextcloud") + } +} + +/// Unlock (2.2, 2.3): the master password, a PIN or Face ID. A blocked +/// unlock says why and shows no field. +struct UnlockView: View { + @EnvironmentObject private var model: AppModel + let accountId: String + @State private var password = "" + @State private var pin = "" + @State private var usePassword = false + + var body: some View { + let account = model.account(accountId) + let pinSet = model.client.pins.has(accountId: accountId) + let biometricOn = model.biometric.isEnabled(accountId) && model.biometric.canUse() + Form { + if let account { + Text("\(account.loginName) on \(account.server.withoutScheme)").accessibilityIdentifier("account") + } + if let blocked = model.gate as? UnlockGate.Blocked { + Section { + Text(blocked.message).accessibilityIdentifier("blocked") + Button("Check again") { model.refreshGate(accountId) }.disabled(model.busy) + } + } else if let ready = model.gate as? UnlockGate.Ready { + if ready.offline { + Text("You are offline. Keepiq unlocks with what this phone stored at the last unlock.") + } + if biometricOn { + Button("Unlock with Face ID or Touch ID") { model.unlockWithBiometric(accountId) } + .accessibilityIdentifier("biometric") + } + if pinSet && !usePassword { + Section { + LabeledField("PIN") { + SecureField("PIN", text: $pin, prompt: Text("Your PIN")) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .accessibilityIdentifier("pin") + } + Button("Unlock with PIN") { model.unlock(accountId, pin: pin); pin = "" } + .disabled(model.busy || pin.isEmpty) + .accessibilityIdentifier("unlockPin") + Button("Use your master password") { usePassword = true } + } + } else { + Section { + // Keepiq's own secrets are typed as one-time codes, not passwords, so iOS + // never offers to save them in another password manager. + LabeledField("Master password") { + SecureField("Master password", text: $password, prompt: Text("Your master password")) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("masterPassword") + .onSubmit { model.unlock(accountId, masterPassword: password); password = "" } + } + Button("Unlock") { model.unlock(accountId, masterPassword: password); password = "" } + .disabled(model.busy || password.isEmpty) + .accessibilityIdentifier("unlock") + } + } + } else { + ProgressView() + } + if model.busy { ProgressView().accessibilityIdentifier("busy") } + ProblemText(message: model.message) + Section { + ForEach(model.accounts.filter { $0.accountId != accountId }, id: \.accountId) { other in + Button("Switch to \(other.loginName) on \(other.server.withoutScheme)") { model.switchAccount(other.accountId) } + } + if model.accounts.count < 5 { + Button("Connect another account") { model.addAccount() }.accessibilityIdentifier("addAccount") + } + } + } + .navigationTitle("Unlock Keepiq") + .task(id: accountId) { + if model.gate == nil { model.refreshGate(accountId) } + } + } +} + +/// Unlock options (2.3), auto-lock (2.5) and disconnect (2.6). +struct SettingsView: View { + @EnvironmentObject private var model: AppModel + let vault: UnlockedVault + @State private var newPin = "" + @State private var confirmUnpair = false + + var body: some View { + let _ = model.revision + let biometricOn = model.biometric.isEnabled(vault.accountId) + let pinSet = model.client.pins.has(accountId: vault.accountId) + Form { + Section("Unlock") { + Toggle("Face ID or Touch ID", isOn: Binding( + get: { biometricOn }, + set: { on in on ? model.enableBiometric(vault) : model.disableBiometric(vault) } + )) + .disabled(!model.biometric.canUse()) + .accessibilityIdentifier("biometricSwitch") + if !model.client.pins.available { + Text("PIN unlock is not available on this phone.") + } else if pinSet { + Text("A PIN is set. Five wrong PINs delete it.") + Button("Remove the PIN") { model.removePin(vault) }.accessibilityIdentifier("removePin") + } else { + LabeledField("New PIN") { + SecureField("New PIN", text: $newPin, prompt: Text("6 to 64 characters")) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .accessibilityIdentifier("newPin") + } + Button("Set PIN") { model.setPin(vault, pin: newPin); newPin = "" } + .disabled(model.busy || newPin.count < 6 || newPin.count > 64) + .accessibilityIdentifier("setPin") + } + } + Section("Account") { + if let account = model.account(vault.accountId) { + Text("\(account.loginName) on \(account.server.withoutScheme)") + } + Button("Disconnect this account", role: .destructive) { confirmUnpair = true } + .accessibilityIdentifier("unpair") + } + Section("Lock after") { + if let max = model.maxIdle { + Text("Your organisation allows at most \(max) minutes.").font(.footnote) + } + Picker("Lock after", selection: Binding( + get: { model.idleChoice(vault.accountId) }, + set: { model.setIdle(vault, minutes: $0) } + )) { + ForEach(model.offeredIdleChoices(), id: \.self) { minutes in + Text(minutes == 1 ? "1 minute" : "\(minutes) minutes").tag(minutes) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + ProblemText(message: model.message) + } + .navigationTitle("Unlock and account") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Back") { model.closeSettings(vault) }.accessibilityIdentifier("back") + } + } + .alert("Disconnect this account?", isPresented: $confirmUnpair) { + Button("Disconnect", role: .destructive) { model.unpair(vault.accountId) } + Button("Cancel", role: .cancel) {} + } message: { + Text("Keepiq deletes its app password in Nextcloud and removes everything it stored for this account on this phone.") + } + } +} diff --git a/mobile/ios/KeepiqApp/Package.swift b/mobile/ios/KeepiqApp/Package.swift new file mode 100644 index 000000000..50a40f5ae --- /dev/null +++ b/mobile/ios/KeepiqApp/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:5.9 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 +// +// Placeholder Swift package that links the shared Kotlin core. Build the +// framework first: `cd mobile && ./gradlew :shared:assembleKeepiqSharedXCFramework`. +// The vault, Send and generator screens live in this package (group 3); the +// app target, its AutoFill extension and the Xcode project come in later +// groups of clients-mobile-apps. +import PackageDescription + +let package = Package( + name: "KeepiqApp", + defaultLocalization: "en", + platforms: [.iOS(.v17)], + products: [ + .library(name: "KeepiqApp", targets: ["KeepiqApp"]), + ], + targets: [ + .binaryTarget( + name: "KeepiqShared", + path: "../../shared/build/XCFrameworks/release/KeepiqShared.xcframework" + ), + .target( + name: "KeepiqApp", + dependencies: ["KeepiqShared"], + resources: [.process("Resources")] + ), + ] +) diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/ContentView.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/ContentView.swift new file mode 100644 index 000000000..930ca36e4 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/ContentView.swift @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// Placeholder screen: proves the app links the shared core. +public struct ContentView: View { + public init() {} + + public var body: some View { + Text(KeepiqShared.shared.APP_NAME) + .font(.largeTitle) + } +} diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/GeneratorAndSendViews.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/GeneratorAndSendViews.swift new file mode 100644 index 000000000..d9067fa31 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/GeneratorAndSendViews.swift @@ -0,0 +1,455 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// The generator (task 3.5), on its own tab and from the item editor. Values +/// are made on the device. The organisation's policy sets the shortest length +/// that can be picked and switches on the kinds of character it requires. +struct GeneratorView: View { + @ObservedObject var model: VaultModel + let onUse: ((String) -> Void)? + + init(model: VaultModel, onUse: ((String) -> Void)?) { + self.model = model + self.onUse = onUse + } + + @State private var passphrase = false + @State private var length = 14.0 + @State private var upper = true + @State private var lower = true + @State private var digits = true + @State private var symbols = false + @State private var minDigits = 1 + @State private var minSymbols = 1 + @State private var avoidAmbiguous = true + @State private var words = 5.0 + @State private var separator = "-" + @State private var capitalise = false + @State private var number = false + @State private var round = 0 + + private var settings: GeneratorSettings { + GeneratorSettings( + mode: passphrase ? .passphrase : .password, + password: PasswordOptions( + length: Int32(length), + includeUppercase: upper, + includeLowercase: lower, + includeDigits: digits, + includeSpecialCharacters: symbols, + minDigits: Int32(minDigits), + minSpecial: Int32(minSymbols), + excludedCharacters: "", + avoidAmbiguous: avoidAmbiguous, + regex: nil + ), + passphrase: PassphraseOptions(words: Int32(words), separator: separator, capitalise: capitalise, includeNumber: number) + ).sanitized(policy: model.policy) + } + + var body: some View { + let policy = model.policy + let safe = settings + let outcome = safe.tryGenerate(policy: policy) + let _ = round + Form { + Section { + Picker(L("gen_password"), selection: $passphrase) { + Text(L("gen_password")).tag(false) + Text(L("gen_passphrase")).tag(true) + } + .pickerStyle(.segmented) + .disabled(policy?.allowPassphrase == false) + if policy?.allowPassphrase == false { Text(L("gen_passphrase_off")).font(.footnote) } + HStack { + if let value = outcome.value { + Text(value).font(.body.monospaced()).textSelection(.disabled).accessibilityIdentifier("generated") + } else { + Text(generatorProblem(outcome.error)).foregroundStyle(.red) + } + Spacer() + Button { round += 1 } label: { Image(systemName: "arrow.clockwise") } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(L("cd_regenerate")) + if let value = outcome.value { + if let onUse { + Button(L("action_use")) { onUse(value) }.frame(minWidth: 44, minHeight: 44) + } else { + Button(L("action_copy")) { model.copy(value) }.frame(minWidth: 44, minHeight: 44) + } + } + } + .buttonStyle(.borderless) + if let policy { Text(L("gen_policy", Int(policy.minLength))).font(.footnote) } + } + if !passphrase { + Section { + let minimum = Double(safe.minimumLength(policy: policy)) + Text(L("gen_length", Int(safe.password.length))) + Slider(value: $length, in: minimum...Double(Generator.shared.MAX_LENGTH), step: 1) + .accessibilityLabel(L("gen_length", Int(safe.password.length))) + PolicyToggle(label: L("gen_upper"), isOn: $upper, locked: policy?.requireUpper == true) + PolicyToggle(label: L("gen_lower"), isOn: $lower, locked: policy?.requireLower == true) + PolicyToggle(label: L("gen_digits"), isOn: $digits, locked: policy?.requireDigit == true) + PolicyToggle(label: L("gen_symbols"), isOn: $symbols, locked: policy?.requireSymbol == true) + if safe.password.includeDigits { Stepper(L("gen_min_digits", minDigits), value: $minDigits, in: 0...9) } + if safe.password.includeSpecialCharacters { Stepper(L("gen_min_symbols", minSymbols), value: $minSymbols, in: 0...9) } + Toggle(L("gen_avoid_ambiguous"), isOn: $avoidAmbiguous) + } + } else { + Section { + Text(L("gen_words", Int(words))) + Slider(value: $words, in: Double(Generator.shared.MIN_WORDS)...Double(Generator.shared.MAX_WORDS), step: 1) + .accessibilityLabel(L("gen_words", Int(words))) + LabeledField(L("gen_separator")) { + TextField(L("gen_separator"), text: $separator) + .onChange(of: separator) { _, value in if value.count > 3 { separator = String(value.prefix(3)) } } + } + PolicyToggle(label: L("gen_capitalise"), isOn: $capitalise, locked: policy?.requireUpper == true) + PolicyToggle(label: L("gen_number"), isOn: $number, locked: policy?.requireDigit == true) + } + } + } + .onChange(of: policy) { _, newPolicy in + if let newPolicy { length = max(length, Double(newPolicy.minLength)) } + } + } + + private func generatorProblem(_ code: GeneratorErrorCode?) -> String { + guard let code else { return "" } + if code == .passphraseOff { return L("gen_passphrase_off") } + if code == .noKindChosen { return L("gen_error", L("gen_err_no_kind")) } + if code == .charsetEmpty || code == .charsetTooSmall { return L("gen_error", L("gen_err_charset")) } + if code == .lengthTooShort || code == .lengthTooLong || code == .passphraseWords { return L("gen_error", L("gen_err_length")) } + return L("gen_error", L("gen_err_other")) + } +} + +/// A switch the policy may decide: then it is on, locked, and says so. +private struct PolicyToggle: View { + let label: String + @Binding var isOn: Bool + let locked: Bool + + var body: some View { + Toggle(isOn: Binding(get: { isOn || locked }, set: { isOn = $0 })) { + VStack(alignment: .leading) { + Text(label) + if locked { Text(L("gen_policy_required")).font(.footnote) } + } + } + .disabled(locked) + } +} + +/// The account's sends (task 3.6): metadata only, with delete, a new Send +/// and a field to open a Send link. +struct SendListView: View { + @ObservedObject var model: VaultModel + + init(model: VaultModel) { + self.model = model + } + + @State private var sends: [SendSummary]? + @State private var problem: WriteProblem? + @State private var deleting: SendSummary? + @State private var creating = false + @State private var opening = false + + var body: some View { + List { + Button(L("open_send_paste")) { opening = true } + if let problem { Text(writeProblemText(problem)).foregroundStyle(.red) } + if let sends { + if sends.isEmpty { Text(L("send_empty")) } + ForEach(sends, id: \.id) { send in + SendRow(send: send) + .swipeActions { + Button(L("action_delete"), role: .destructive) { deleting = send } + } + .accessibilityAction(named: L("action_delete")) { deleting = send } + } + } else if problem == nil { + ProgressView(L("vault_loading")) + } + } + .navigationTitle(L("send_title")) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { creating = true } label: { Image(systemName: "plus") } + .accessibilityLabel(L("cd_new_send")) + } + } + .task { await load() } + .refreshable { await load() } + .confirmationDialog(L("send_delete_confirm"), isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { + Button(L("action_delete"), role: .destructive) { + guard let send = deleting else { return } + deleting = nil + Task { @MainActor in + let result = try? await model.session.sends.delete(id: send.id) + if let failed = result?.problemOrNull { problem = failed.write } else { await load() } + } + } + } + .navigationDestination(isPresented: $creating) { + NewSendView(model: model) { creating = false; Task { await load() } } + } + .sheet(isPresented: $opening) { + NavigationStack { + OpenSendView(initialLink: "", onCopy: model.copy) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L("action_close")) { opening = false } } } + } + } + } + + private func load() async { + let result = try? await model.session.sends.list() + if let list = result?.valueOrNull as? [SendSummary] { + sends = list + problem = nil + } else { + problem = result?.problemOrNull?.write ?? WriteProblem(kind: .unreachable, serverMessage: nil, status: 0) + } + } +} + +private struct SendRow: View { + let send: SendSummary + + var body: some View { + let kind = send.payloadType == "credential" ? L("send_credential") : L("send_text") + let created = IsoTime.shared.parseMillis(text: send.createdAt).map { + Date(timeIntervalSince1970: TimeInterval($0.int64Value) / 1000).formatted(date: .abbreviated, time: .shortened) + } + let nowMillis = Int64(Date().timeIntervalSince1970 * 1000) + let minutes = SendForm.shared.minutesLeft(expiresAtMillis: IsoTime.shared.parseMillis(text: send.expiresAt), nowMillis: nowMillis)?.int64Value + let expiry: String? = minutes.map(expiryText) + VStack(alignment: .leading, spacing: 2) { + Text(created.map { L("send_row", kind, $0) } ?? kind) + Text(detailText(expiry: expiry)) + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(minHeight: 44) + } + + private func expiryText(_ m: Int64) -> String { + if m <= 0 { return L("send_expired") } + if m < 60 { return L("send_expires_minutes", Int(m)) } + if m < 48 * 60 { return L("send_expires_hours", Int((m + 30) / 60)) } + return L("send_expires_days", Int((m + 720) / 1440)) + } + + private func detailText(expiry: String?) -> String { + var parts: [String] = [] + if let expiry { parts.append(expiry) } + parts.append(L("send_views", Int(send.viewCount), Int(send.maxViews))) + if send.hasPassword { parts.append(L("send_password_badge")) } + return parts.joined(separator: " · ") + } +} + +/// Create a Send (task 3.6). A password needs Argon2id, which this device +/// gets with task 1.3.1; until then the password field says so. +struct NewSendView: View { + @ObservedObject var model: VaultModel + let onDone: () -> Void + + init(model: VaultModel, onDone: @escaping () -> Void) { + self.model = model + self.onDone = onDone + } + + @State private var credential = false + @State private var text = "" + @State private var username = "" + @State private var password = "" + @State private var views = "1" + @State private var expiry = 4 + @State private var hours = "" + @State private var sendPassword = "" + @State private var busy = false + @State private var problem: String? + @State private var created: CreatedSend? + + /// Argon2id is not on iOS yet (task 1.3.1), so a password send cannot be made here. + private let passwordAvailable = false + + private let expiries: [(SendExpiry, String)] = [ + (.hour, "expiry_1h"), (.day, "expiry_1d"), (.twoDays, "expiry_2d"), (.threeDays, "expiry_3d"), + (.sevenDays, "expiry_7d"), (.thirtyDays, "expiry_30d"), (.custom, "expiry_custom"), + ] + + var body: some View { + Form { + if let created { + Section { + Text(created.hasPassword ? L("send_created_password") : L("send_created")) + Text(created.link).font(.footnote).textSelection(.enabled).accessibilityIdentifier("sendLink") + if let url = URL(string: created.link) { + ShareLink(item: url) { Label(L("action_share"), systemImage: "square.and.arrow.up") } + } + Button(L("send_copy_link")) { model.copy(created.link) } + Button(L("action_close"), action: onDone) + } + } else { + Section { + Picker(L("send_kind_text"), selection: $credential) { + Text(L("send_kind_text")).tag(false) + Text(L("send_kind_login")).tag(true) + } + .pickerStyle(.segmented) + if credential { + LabeledField(L("detail_username")) { + TextField(L("detail_username"), text: $username).textInputAutocapitalization(.never) + } + LabeledField(L("detail_password")) { + // Keepiq's own secrets are typed as one-time codes, not passwords, so iOS + // never offers to save them in another password manager. + SecureField(L("detail_password"), text: $password).textContentType(.oneTimeCode) + } + } else { + LabeledField(L("send_text_label")) { + TextField(L("send_text_label"), text: $text, axis: .vertical).lineLimit(3...8) + } + } + } + Section { + LabeledField(L("send_views_label")) { + TextField(L("send_views_label"), text: $views).keyboardType(.numberPad) + } + Picker(L("send_expiry_label"), selection: $expiry) { + ForEach(expiries.indices, id: \.self) { i in Text(L(expiries[i].1)).tag(i) } + } + if expiries[expiry].0 == .custom { + LabeledField(L("send_hours_label")) { + TextField(L("send_hours_label"), text: $hours).keyboardType(.numberPad) + } + } + LabeledField(L("send_password_label")) { + SecureField(L("send_password_label"), text: $sendPassword).textContentType(.oneTimeCode).disabled(!passwordAvailable) + } + if !passwordAvailable { Text(L("send_password_unavailable")).font(.footnote) } + } + Section { + if let problem { Text(problem).foregroundStyle(.red) } + Button(L("send_create")) { create() }.disabled(busy) + } + } + } + .navigationTitle(L("send_new_title")) + } + + private func create() { + dismissKeyboard() + busy = true + problem = nil + let plaintext = credential ? SendForm.shared.credentialPayload(username: username, password: password) : text + Task { @MainActor in + let result = try? await model.session.sends.create( + payloadType: credential ? .credential : .text, + plaintext: plaintext, + maxViews: views, + expiry: expiries[expiry].0, + customHours: hours, + password: sendPassword, + passwordAvailable: passwordAvailable + ) + busy = false + if let done = result?.valueOrNull as? CreatedSend { + created = done + } else if let failed = result?.problemOrNull { + if let form = failed.form { + problem = sendProblemText(form) + } else if let write = failed.write { + problem = writeProblemText(write) + } + } else { + problem = L("write_failed") + } + } + } + + private func sendProblemText(_ problem: SendFormProblem) -> String { + if problem == .nothingToSend { return L("send_nothing") } + if problem == .customHours { return L("send_hours_range") } + if problem == .customHoursTooMany { return L("send_hours_max") } + if problem == .viewsOutOfRange { return L("send_views_range") } + return L("send_password_unavailable") + } +} + +/// Opens a Send link on this phone (task 3.6), decrypting on the device as +/// the public page does. Needs no account. Opening uses a view, so the app +/// asks first. Hand a tapped link to `initialLink`. +public struct OpenSendView: View { + private let client = OpenSendClient.companion.platform() + private let onCopy: ((String) -> Void)? + @State private var link: String + @State private var state: OpenSendResult? + @State private var password = "" + @State private var busy = false + + public init(initialLink: String, onCopy: ((String) -> Void)?) { + _link = State(initialValue: initialLink) + self.onCopy = onCopy + } + + public var body: some View { + let parsed = SendLink.companion.parse(link: link) + let needsPassword = state is OpenSendResult.NeedsPassword || ((state as? OpenSendResult.WrongPassword)?.burned == false) + Form { + if !(state is OpenSendResult.Opened) { + LabeledField(L("open_send_link")) { + TextField(L("open_send_link"), text: $link) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + if !link.isEmpty && parsed == nil { Text(L("open_send_invalid")).foregroundStyle(.red) } + } + if state is OpenSendResult.Ready { + Text(L("open_send_ready")) + } else if state is OpenSendResult.NeedsPassword { + Text(L("open_send_password")) + } else if let wrong = state as? OpenSendResult.WrongPassword { + Text(wrong.burned ? L("open_send_burned") : L("open_send_wrong", Int(wrong.attemptsLeft))).foregroundStyle(.red) + } else if state is OpenSendResult.Gone { + Text(L("open_send_gone")) + } else if state is OpenSendResult.NoKey { + Text(L("open_send_no_key")) + } else if state is OpenSendResult.Failed { + Text(L("open_send_failed")).foregroundStyle(.red) + } else if let opened = state as? OpenSendResult.Opened { + Text(opened.payload).textSelection(.enabled) + if opened.burned { Text(L("open_send_last_view")).font(.footnote) } + if let onCopy { Button(L("action_copy")) { onCopy(opened.payload) } } + } + if needsPassword { + LabeledField(L("detail_password")) { + SecureField(L("detail_password"), text: $password).textContentType(.oneTimeCode) + } + } + if let parsed, state is OpenSendResult.Ready || needsPassword { + Button(L("action_open")) { + busy = true + Task { @MainActor in + state = try? await client.open(link: parsed, password: password) + busy = false + } + } + .disabled(busy || (needsPassword && password.isEmpty)) + } + } + .navigationTitle(L("open_send_title")) + .task(id: link) { + state = nil + if let parsed { state = try? await client.peek(link: parsed) } + } + } +} diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/ItemViews.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/ItemViews.swift new file mode 100644 index 000000000..baafc4ec8 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/ItemViews.swift @@ -0,0 +1,512 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// One item (task 3.1): secret values hidden until revealed, copy through +/// the sensitive clipboard, the TOTP code with its seconds left. A use-only +/// copy shows no value and offers no reveal or copy of it. +struct ItemDetailView: View { + @ObservedObject var model: VaultModel + let itemId: String + + init(model: VaultModel, itemId: String) { + self.model = model + self.itemId = itemId + } + + @State private var result: OpenResult? + @State private var problem: WriteProblem? + @State private var confirmTrash = false + @State private var moving = false + @State private var editing = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + Group { + if let opened = result as? OpenResult.Opened { + detail(opened.item) + } else if result is OpenResult.Missing { + Text(L("detail_missing")) + } else if let failed = result as? OpenResult.Failed { + Text(writeProblemText(failed.problem)) + } else { + ProgressView(L("vault_loading")) + } + } + .task(id: itemId) { await load() } + } + + private func load() async { + result = try? await model.repository.open(id: itemId) + } + + @ViewBuilder + private func detail(_ item: DecryptedItem) -> some View { + let row = item.row + let offline = model.repository.state.offline + List { + Section { + Text("\(item.type?.label ?? item.typeName) · \(VaultIndex.shared.folderPath(folders: model.repository.state.folders, folderId: row.folderId) ?? L("vault_no_folder"))") + .font(.footnote) + if item.fromCache { Text(L("detail_from_cache")) } + if row.useOnly { Text(L("detail_use_only")) } + if row.readOnly && !row.useOnly { Text(L("detail_read_only")) } + if let problem { Text(writeProblemText(problem)).foregroundStyle(.red) } + } + if row.blocked { + Text(row.blockedReason ?? L("detail_blocked")) + } else { + Section { fields(item) } + if !row.useOnly { + Section { + Button(L("action_edit")) { editing = true }.disabled(offline) + Button(L("action_move")) { moving = true }.disabled(offline) + } + } + Section { + Button(L("action_trash"), role: .destructive) { confirmTrash = true }.disabled(offline) + if offline { Text(L("write_offline")).font(.footnote) } + } + } + } + .navigationTitle(row.name) + .confirmationDialog(L("detail_trash_confirm", row.name), isPresented: $confirmTrash, titleVisibility: .visible) { + Button(L("action_trash"), role: .destructive) { + write({ try await model.repository.trash(id: row.id) }) { dismiss() } + } + } + .sheet(isPresented: $moving) { + MoveSheet(model: model, current: row.folderId) { folderId in + moving = false + write({ try await model.repository.move(id: row.id, folderId: folderId) }) { + Task { await load() } + } + } + } + .navigationDestination(isPresented: $editing) { + ItemEditView(model: model, itemId: row.id, folderId: row.folderId) { _ in + editing = false + Task { await load() } + } + } + } + + @ViewBuilder + private func fields(_ item: DecryptedItem) -> some View { + let row = item.row + let kind = item.kind + if kind == .login || kind == .generic { + if !item.login.isEmpty { FieldRow(label: L("detail_username"), value: item.login, onCopy: model.copy) } + if !row.useOnly { + FieldRow(label: kind == .login ? L("detail_password") : L("detail_value"), value: item.secret, masked: true, onCopy: model.copy) + } + } else if kind == .totp { + TotpRow(item: item, onCopy: model.copy) + } else if kind == .note { + if !row.useOnly { FieldRow(label: L("detail_notes"), value: item.secret, onCopy: model.copy) } + } else if kind == .card || kind == .identity { + if !row.useOnly { + if let data = item.composite { + ForEach(Composite.shared.fieldsOf(kind: kind), id: \.self) { field in + if let value = data[field], !value.isEmpty { + FieldRow(label: compositeLabel(field), value: value, masked: Composite.shared.MASKED.contains(field), onCopy: model.copy) + } + } + } else { + Text(L("detail_fields_error")).foregroundStyle(.red) + } + } + } else if let passkey = item.passkey { + FieldRow(label: L("detail_passkey_site"), value: passkey.rpName.map { "\($0) (\(passkey.rpId))" } ?? passkey.rpId, copy: false, onCopy: model.copy) + if let user = passkey.userName { FieldRow(label: L("detail_passkey_account"), value: user, copy: false, onCopy: model.copy) } + Text(L("detail_passkey_note")).font(.footnote) + } + if let url = row.url, !url.isEmpty { + FieldRow(label: L("detail_address"), value: url, onCopy: model.copy) + } + if !row.useOnly { + ForEach(Array(item.typedValues.enumerated()), id: \.offset) { _, pair in + if let field = pair.first as? TypeField, let value = pair.second as? String, !value.isEmpty { + FieldRow(label: field.label, value: value, masked: field.hidden, onCopy: model.copy) + } + } + if kind != .note && !item.notes.isEmpty { + FieldRow(label: L("detail_notes"), value: item.notes, onCopy: model.copy) + } + if item.additionalFieldsError { Text(L("detail_fields_error")).foregroundStyle(.red) } + ForEach(Array(item.extraFields.enumerated()), id: \.offset) { _, pair in + FieldRow(label: (pair.first as? String) ?? "", value: (pair.second as? String) ?? "", onCopy: model.copy) + } + } + } + + private func write(_ block: @escaping () async throws -> WriteResult, after: @escaping () -> Void) { + Task { @MainActor in + let outcome = try? await block() + if outcome is WriteResult.Saved { + after() + } else if let refused = outcome as? WriteResult.Refused { + problem = refused.problem + } + } + } +} + +/// The current code and its seconds left, refreshed every second. +private struct TotpRow: View { + let item: DecryptedItem + let onCopy: (String) -> Void + + var body: some View { + if let params = item.totp { + TimelineView(.periodic(from: .now, by: 1)) { context in + let millis = Int64(context.date.timeIntervalSince1970 * 1000) + let code = Totp.shared.generate(params: params, epochMillis: millis) + let left = Totp.shared.secondsRemaining(period: params.period, epochMillis: millis) + VStack(alignment: .leading) { + Text(L("detail_code")).font(.caption).foregroundStyle(.secondary) + HStack { + Text(code).font(.title.monospaced()).accessibilityIdentifier("totpCode") + // A circular ProgressView spins on iOS whatever its value, so + // the seconds left are a gauge with the number in it. + Gauge(value: Double(left), in: 0...Double(params.period)) { + EmptyView() + } currentValueLabel: { + Text("\(Int(left))") + } + .gaugeStyle(.accessoryCircularCapacity) + .scaleEffect(0.7) + .frame(width: 44, height: 44) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("detail_code_seconds", Int(left))) + Spacer() + Button(L("action_copy")) { onCopy(code) } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(L("cd_copy_field", L("detail_code"))) + } + } + } + } else { + Text(L("detail_code_invalid")).foregroundStyle(.red) + } + } +} + +/// Pick a folder to move an item to. +private struct MoveSheet: View { + @ObservedObject var model: VaultModel + let current: String? + let onPick: (String?) -> Void + + var body: some View { + NavigationStack { + List { + Button(L("vault_no_folder")) { onPick(nil) } + .accessibilityAddTraits(current == nil ? .isSelected : []) + ForEach(VaultIndex.shared.folderTree(folders: model.repository.state.folders), id: \.id) { node in + Button(String(repeating: " ", count: Int(node.depth)) + node.name) { onPick(node.id) } + .accessibilityLabel(node.name) + .accessibilityAddTraits(current == node.id ? .isSelected : []) + } + } + .navigationTitle(L("move_title")) + } + } +} + +/// Create or edit an item (task 3.2). The type's fields come from +/// `/api/v1/secret-types`; values are encrypted on the device to the suite's +/// key before they are sent. A refusal is shown and the form keeps what the +/// user typed. +struct ItemEditView: View { + @ObservedObject var model: VaultModel + let itemId: String? + let folderId: String? + let onSaved: (String?) -> Void + + init(model: VaultModel, itemId: String?, folderId: String?, onSaved: @escaping (String?) -> Void) { + self.model = model + self.itemId = itemId + self.folderId = folderId + self.onSaved = onSaved + } + + @State private var item: DecryptedItem? + @State private var type: SecretType? + @State private var base: ItemDraft? + @State private var name = "" + @State private var url = "" + @State private var folder: String? + @State private var login = "" + @State private var secret = "" + @State private var notes = "" + @State private var composite: [String: String] = [:] + @State private var typed: [String: String] = [:] + @State private var fieldNames: [String] = [] + @State private var fieldValues: [String] = [] + @State private var problem: WriteProblem? + @State private var showErrors = false + @State private var busy = false + @State private var generating = false + @State private var generateTarget: String? + + private var types: [SecretType] { model.repository.state.types.filter { $0.name != "passkey" } } + + var body: some View { + Group { + if let base { + form(base) + } else if let problem { + Text(writeProblemText(problem)) + } else { + ProgressView(L("vault_loading")) + } + } + .navigationTitle(itemId == nil ? L("edit_new_title") : L("edit_title")) + .task { await load() } + .sheet(isPresented: $generating) { + NavigationStack { + GeneratorView(model: model) { value in + if let target = generateTarget { typed[target] = value } else { secret = value } + generating = false + } + } + } + } + + private func load() async { + // Load once: coming back to the form keeps what the user typed. + guard base == nil else { return } + if let itemId { + let opened = try? await model.repository.open(id: itemId) + if let opened = opened as? OpenResult.Opened { + item = opened.item + type = opened.item.type + apply(ItemCodec.shared.draft(item: opened.item, type: opened.item.type)) + } else if let failed = opened as? OpenResult.Failed { + problem = failed.problem + } + } else { + let initial = types.first { $0.name == "login" } ?? types.first + pickType(initial) + folder = folderId + } + } + + private func pickType(_ picked: SecretType?) { + type = picked + let keptName = name + let keptUrl = url + let keptFolder = folder + let keptNotes = notes + apply(ItemCodec.shared.draft(item: nil, type: picked)) + name = keptName + url = keptUrl + folder = keptFolder + notes = keptNotes + } + + private func apply(_ draft: ItemDraft) { + base = draft + name = draft.name + url = draft.url + folder = draft.folderId + login = draft.login + secret = draft.secret + notes = draft.notes + composite = draft.composite + typed = draft.typed + fieldNames = draft.fieldNames + fieldValues = draft.fieldValues + } + + private func current(_ base: ItemDraft) -> ItemDraft { + base.edited( + name: name, url: url, folderId: folder, login: login, secret: secret, notes: notes, + composite: composite, typed: typed, fieldNames: fieldNames, fieldValues: fieldValues + ) + } + + @ViewBuilder + private func form(_ base: ItemDraft) -> some View { + let draft = current(base) + let errors = ItemCodec.shared.validate(draft: draft, type: type) + let error: (String) -> String? = { key in showErrors ? errors[key].map(draftProblemText) : nil } + let kind = draft.kind + Form { + Section { + if itemId == nil && !types.isEmpty { + Picker(L("edit_type"), selection: Binding(get: { type?.id ?? "" }, set: { id in pickType(types.first { $0.id == id }) })) { + ForEach(types, id: \.id) { t in Text(t.label ?? t.name).tag(t.id) } + } + } + if kind == .passkey { Text(L("edit_passkey_note")).font(.footnote) } + LabeledInput(label: L("edit_name"), text: $name, problem: error("name")) + LabeledInput(label: L("edit_url"), text: $url, problem: error("url"), keyboard: .URL) + Picker(L("detail_folder"), selection: $folder) { + Text(L("vault_no_folder")).tag(String?.none) + ForEach(VaultIndex.shared.folderTree(folders: model.repository.state.folders), id: \.id) { node in + Text(String(repeating: " ", count: Int(node.depth)) + node.name).tag(String?.some(node.id)) + } + } + } + Section { + if kind == .login || kind == .generic { + LabeledInput(label: L("detail_username"), text: $login, problem: error("login")) + SecretInput(label: kind == .login ? L("detail_password") : L("detail_value"), text: $secret, problem: error("secret")) { + generateTarget = nil + generating = true + } + } else if kind == .totp { + LabeledInput(label: L("edit_totp_secret"), text: $secret, problem: error("secret")) + } else if kind == .card || kind == .identity { + ForEach(Composite.shared.fieldsOf(kind: kind), id: \.self) { field in + let binding = Binding(get: { composite[field] ?? "" }, set: { composite[field] = $0 }) + if Composite.shared.MASKED.contains(field) { + SecretInput(label: compositeLabel(field), text: binding, problem: nil, onGenerate: nil) + } else { + LabeledInput(label: compositeLabel(field), text: binding, problem: nil) + } + } + } + ForEach(type?.fields ?? [], id: \.key) { field in + let binding = Binding(get: { typed[field.key] ?? "" }, set: { typed[field.key] = $0 }) + let label = field.required ? "\(field.label) (\(L("edit_required")))" : field.label + if field.hidden { + SecretInput(label: label, text: binding, problem: error("typed-\(field.key)")) { + generateTarget = field.key + generating = true + } + } else { + LabeledInput(label: label, text: binding, problem: error("typed-\(field.key)"), keyboard: keyboardFor(field)) + } + } + VStack(alignment: .leading) { + Text(L("detail_notes")).font(.caption) + TextEditor(text: $notes).frame(minHeight: 80).accessibilityLabel(L("detail_notes")) + } + } + if kind != .passkey { + Section(L("detail_extra_fields")) { + ForEach(fieldNames.indices, id: \.self) { i in + VStack { + LabeledInput(label: L("edit_field_name"), text: $fieldNames[i], problem: error("field-\(i)")) + LabeledInput(label: L("edit_field_value"), text: $fieldValues[i], problem: nil) + Button(L("action_remove"), role: .destructive) { + fieldNames.remove(at: i) + fieldValues.remove(at: i) + } + .accessibilityLabel(L("cd_remove_field", fieldNames[i])) + } + } + Button(L("edit_add_field")) { + fieldNames.append("") + fieldValues.append("") + } + } + } + Section { + if let problem { Text(writeProblemText(problem)).foregroundStyle(.red) } + if let fieldsProblem = error("fields") { Text(fieldsProblem).foregroundStyle(.red) } + Button(L("action_save")) { save(draft, valid: errors.isEmpty) } + .disabled(busy || model.repository.state.offline) + if model.repository.state.offline { Text(L("write_offline")).font(.footnote) } + } + } + } + + private func keyboardFor(_ field: TypeField) -> UIKeyboardType { + switch field.kind { + case "url": return .URL + case "email": return .emailAddress + default: return .default + } + } + + private func save(_ draft: ItemDraft, valid: Bool) { + dismissKeyboard() + showErrors = true + guard valid else { return } + busy = true + problem = nil + Task { @MainActor in + let outcome: WriteResult? + if let item { + outcome = try? await model.repository.update(item: item, draft: draft) + } else { + outcome = try? await model.repository.create(draft: draft, type: type) + } + busy = false + if let saved = outcome as? WriteResult.Saved { + onSaved(saved.id) + } else if let refused = outcome as? WriteResult.Refused { + problem = refused.problem + } else { + problem = WriteProblem(kind: .failed, serverMessage: nil, status: 0) + } + } + } +} + +struct LabeledInput: View { + let label: String + @Binding var text: String + let problem: String? + var keyboard: UIKeyboardType = .default + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + LabeledField(label) { + TextField(label, text: $text) + .keyboardType(keyboard) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + if let problem { Text(problem).font(.footnote).foregroundStyle(.red) } + } + } +} + +/// A hidden input with Show and, where it fits, Generate. +struct SecretInput: View { + let label: String + @Binding var text: String + let problem: String? + let onGenerate: (() -> Void)? + + init(label: String, text: Binding, problem: String?, onGenerate: (() -> Void)?) { + self.label = label + _text = text + self.problem = problem + self.onGenerate = onGenerate + } + + @State private var shown = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.footnote).accessibilityHidden(true) + HStack { + // Keepiq's own secrets are typed as one-time codes, not passwords, so iOS + // never offers to save them in another password manager (or in Keepiq). + if shown { + TextField(label, text: $text).textInputAutocapitalization(.never).autocorrectionDisabled() + .textContentType(.oneTimeCode) + .accessibilityLabel(label) + } else { + SecureField(label, text: $text).textContentType(.oneTimeCode).accessibilityLabel(label) + } + Button(shown ? L("action_hide") : L("action_show")) { shown.toggle() } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(shown ? L("cd_hide_field", label) : L("cd_show_field", label)) + if let onGenerate { + Button(L("action_generate"), action: onGenerate).frame(minWidth: 44, minHeight: 44) + } + } + .buttonStyle(.borderless) + if let problem { Text(problem).font(.footnote).foregroundStyle(.red) } + } + } +} diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/en.lproj/Localizable.strings b/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000..df39fbd8d --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/en.lproj/Localizable.strings @@ -0,0 +1,210 @@ +/* SPDX-FileCopyrightText: 2026 Conduction B.V. */ +/* SPDX-License-Identifier: EUPL-1.2 */ +/* Generated from the Android strings (mobile/android/app/src/main/res); keep the keys the same. */ + +"tab_vault" = "Vault"; +"tab_generator" = "Generator"; +"tab_send" = "Send"; +"action_back" = "Back"; +"action_cancel" = "Cancel"; +"action_save" = "Save"; +"action_delete" = "Delete"; +"action_close" = "Close"; +"action_copy" = "Copy"; +"action_show" = "Show"; +"action_hide" = "Hide"; +"action_edit" = "Edit"; +"action_move" = "Move"; +"action_trash" = "Move to trash"; +"action_refresh" = "Sync now"; +"action_retry" = "Try again"; +"action_open" = "Open"; +"action_share" = "Share"; +"action_add" = "Add"; +"action_remove" = "Remove"; +"action_generate" = "Generate"; +"action_use" = "Use this"; +"cd_copy_field" = "Copy %1$@"; +"cd_show_field" = "Show %1$@"; +"cd_hide_field" = "Hide %1$@"; +"cd_open_folder" = "Open folder %1$@"; +"cd_open_item" = "Open %1$@"; +"cd_switch_account" = "Switch account, now %1$@"; +"cd_add_item" = "Add an item"; +"cd_new_send" = "New Send"; +"cd_folder_actions" = "Folder actions"; +"cd_remove_field" = "Remove field %1$@"; +"cd_regenerate" = "Generate a new value"; +"copied" = "Copied. Cleared in %1$d seconds."; +"copied_kept" = "Copied."; +"accounts_title" = "Accounts"; +"accounts_add" = "Add an account"; +"accounts_limit" = "You can pair up to 5 accounts."; +"settings_clipboard" = "Clear the clipboard after"; +"settings_clipboard_never" = "Never"; +"settings_clipboard_seconds" = "%1$d seconds"; +"settings_clipboard_minutes" = "%1$d minutes"; +"search_label" = "Search the vault"; +"search_hint" = "Name or address"; +"vault_no_folder" = "No folder"; +"vault_folders" = "Folders"; +"vault_items" = "Items"; +"vault_loading" = "Loading your vault"; +"vault_empty" = "Your vault is empty. Add an item to start."; +"vault_no_match" = "Nothing matches your search."; +"vault_all_blocked" = "Every item is blocked here. Its key cannot be used on this device."; +"vault_folder_empty" = "This folder is empty."; +"vault_synced" = "Last synced %1$@"; +"vault_offline" = "You are offline. You see the copy from %1$@. Edits need a connection."; +"vault_offline_never" = "You are offline. Edits need a connection."; +"vault_needs_connection" = "Your organisation keeps no copy on this device. Connect to open your vault."; +"vault_online_only" = "Your organisation keeps no copy on this device."; +"vault_locked_suite" = "Your encryption keys changed. Unlock again."; +"vault_locked_password" = "Your master password changed. Unlock again."; +"vault_locked_two_factor" = "Sign in with two-step verification in the web app first."; +"badge_use_only" = "Use only"; +"badge_read_only" = "Read only"; +"badge_blocked" = "Blocked"; +"detail_username" = "User name"; +"detail_password" = "Password"; +"detail_value" = "Value"; +"detail_address" = "Address"; +"detail_notes" = "Notes"; +"detail_folder" = "Folder"; +"detail_code" = "Authenticator code"; +"detail_code_seconds" = "%1$d seconds left"; +"detail_code_invalid" = "This is not a valid authenticator secret."; +"detail_card_ending" = "Card ending in %1$@"; +"detail_passkey_site" = "Site"; +"detail_passkey_account" = "Account"; +"detail_passkey_created" = "Created"; +"detail_passkey_note" = "The website that uses this passkey creates and updates it."; +"detail_extra_fields" = "Additional fields"; +"detail_fields_error" = "Could not read the additional fields."; +"detail_use_only" = "Shared as use only. You can fill it, but not view or copy the value."; +"detail_read_only" = "You can only read this item. The server refuses changes."; +"detail_blocked" = "This item cannot be opened here."; +"detail_from_cache" = "You are offline. This is the copy from your last sync."; +"detail_missing" = "This item no longer exists."; +"detail_trash_confirm" = "Move %1$@ to the trash? You can restore it in the web app."; +"move_title" = "Move to folder"; +"edit_new_title" = "New item"; +"edit_title" = "Edit item"; +"edit_type" = "Type"; +"edit_name" = "Name"; +"edit_url" = "Website address"; +"edit_totp_secret" = "Authenticator secret or otpauth link"; +"edit_add_field" = "Add a field"; +"edit_field_name" = "Field name"; +"edit_field_value" = "Field value"; +"edit_passkey_note" = "Only the name, address, folder and notes of a passkey can change."; +"edit_required" = "Required"; +"edit_saved" = "Saved."; +"problem_name_missing" = "Give the item a name."; +"problem_too_long" = "This is too long to save."; +"problem_field_name_missing" = "Give the field a name."; +"problem_field_name_reserved" = "This name is reserved."; +"problem_field_name_taken" = "Another field has this name."; +"problem_not_totp" = "This is not a valid authenticator secret."; +"problem_required" = "Fill in this field."; +"write_offline" = "Edits need a connection. Nothing was changed."; +"write_key_migration" = "Your vault is being moved to new keys. Try again later."; +"write_suite_blocked" = "Your encryption suite is blocked. Open Keepiq on the web to resolve it."; +"write_server" = "The server refused: %1$@"; +"write_refused" = "The server refused this change."; +"write_unreachable" = "Could not reach the server."; +"write_failed" = "Saving failed."; +"folder_new" = "New folder"; +"folder_rename" = "Rename folder"; +"folder_delete" = "Delete folder"; +"folder_name" = "Folder name"; +"folder_name_missing" = "Give the folder a name."; +"folder_name_slash" = "A folder name cannot contain a slash."; +"folder_delete_empty" = "Delete the empty folder %1$@?"; +"folder_delete_items" = "%1$@ holds items. What happens to them?"; +"folder_delete_move" = "Move the items out"; +"folder_delete_with_items" = "Delete the items too"; +"folder_delete_subfolders" = "This folder has subfolders. Delete it in the web app, where you choose what happens to each one."; +"gen_password" = "Password"; +"gen_passphrase" = "Passphrase"; +"gen_length" = "Length: %1$d"; +"gen_upper" = "A to Z"; +"gen_lower" = "a to z"; +"gen_digits" = "0 to 9"; +"gen_symbols" = "Symbols"; +"gen_min_digits" = "At least %1$d digits"; +"gen_min_symbols" = "At least %1$d symbols"; +"gen_avoid_ambiguous" = "Avoid look-alike characters"; +"gen_words" = "Words: %1$d"; +"gen_separator" = "Separator"; +"gen_capitalise" = "Capitalise words"; +"gen_number" = "Add a number"; +"gen_policy" = "Your organisation asks for at least %1$d characters."; +"gen_policy_required" = "Your organisation requires this."; +"gen_passphrase_off" = "Your organisation switched passphrases off."; +"gen_error" = "This cannot be generated: %1$@"; +"gen_err_no_kind" = "choose at least one kind of character."; +"gen_err_charset" = "too few characters are left after the exclusions."; +"gen_err_length" = "the length is out of range."; +"gen_err_other" = "the options do not fit together."; +"send_title" = "Your sends"; +"send_empty" = "You have no active sends."; +"send_text" = "Text send"; +"send_credential" = "Login send"; +"send_row" = "%1$@, %2$@"; +"send_views" = "%1$d of %2$d views used"; +"send_password_badge" = "Password"; +"send_expired" = "Expired"; +"send_expires_minutes" = "Expires in %1$d minutes"; +"send_expires_hours" = "Expires in %1$d hours"; +"send_expires_days" = "Expires in %1$d days"; +"send_delete_confirm" = "End this send? The link stops working."; +"send_new_title" = "New Send"; +"send_kind_text" = "Text"; +"send_kind_login" = "Login"; +"send_text_label" = "Text to send"; +"send_views_label" = "Views"; +"send_expiry_label" = "Expires after"; +"send_hours_label" = "Hours"; +"send_password_label" = "Password (optional)"; +"send_password_unavailable" = "This device cannot protect a send with a password yet."; +"send_create" = "Create link"; +"send_created" = "Your link is ready. Anyone with it can open the send."; +"send_created_password" = "Your link is ready. Share the password another way."; +"send_copy_link" = "Copy link"; +"send_nothing" = "There is nothing to send."; +"send_views_range" = "Choose 1 to 100 views."; +"send_hours_range" = "Enter a whole number of hours, at least 1."; +"send_hours_max" = "At most 720 hours (30 days)."; +"expiry_1h" = "1 hour"; +"expiry_1d" = "1 day"; +"expiry_2d" = "2 days"; +"expiry_3d" = "3 days"; +"expiry_7d" = "7 days"; +"expiry_30d" = "30 days"; +"expiry_custom" = "Custom"; +"open_send_title" = "Open a Send"; +"open_send_link" = "Send link"; +"open_send_invalid" = "This is not a Keepiq Send link."; +"open_send_ready" = "Opening shows the text and uses one view."; +"open_send_password" = "This send is protected with a password."; +"open_send_wrong" = "Wrong password. %1$d tries left before the send is destroyed."; +"open_send_burned" = "Too many wrong passwords. The send is destroyed."; +"open_send_gone" = "This send does not exist anymore. It expired or was opened already."; +"open_send_no_key" = "The link misses its key, so the send cannot be opened."; +"open_send_failed" = "Could not open the send."; +"open_send_last_view" = "This was the last view. The send is gone now."; +"open_send_paste" = "Open a Send link"; +"composite_number" = "Card number"; +"composite_expiry" = "Expiry (MM/YY)"; +"composite_cvv" = "CVV"; +"composite_pin" = "PIN"; +"composite_cardholder" = "Cardholder"; +"composite_first_name" = "First name"; +"composite_last_name" = "Last name"; +"composite_address" = "Address"; +"composite_phone" = "Phone"; +"composite_email" = "Email"; +"composite_bsn" = "BSN"; +"cd_lock" = "Lock the vault"; +"cd_settings" = "Unlock and account settings"; diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/nl.lproj/Localizable.strings b/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/nl.lproj/Localizable.strings new file mode 100644 index 000000000..00c7f65b8 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/Resources/nl.lproj/Localizable.strings @@ -0,0 +1,210 @@ +/* SPDX-FileCopyrightText: 2026 Conduction B.V. */ +/* SPDX-License-Identifier: EUPL-1.2 */ +/* Generated from the Android strings (mobile/android/app/src/main/res); keep the keys the same. */ + +"tab_vault" = "Kluis"; +"tab_generator" = "Generator"; +"tab_send" = "Send"; +"action_back" = "Terug"; +"action_cancel" = "Annuleren"; +"action_save" = "Opslaan"; +"action_delete" = "Verwijderen"; +"action_close" = "Sluiten"; +"action_copy" = "Kopiëren"; +"action_show" = "Tonen"; +"action_hide" = "Verbergen"; +"action_edit" = "Bewerken"; +"action_move" = "Verplaatsen"; +"action_trash" = "Naar de prullenbak"; +"action_refresh" = "Nu synchroniseren"; +"action_retry" = "Opnieuw proberen"; +"action_open" = "Openen"; +"action_share" = "Delen"; +"action_add" = "Toevoegen"; +"action_remove" = "Weghalen"; +"action_generate" = "Genereren"; +"action_use" = "Gebruik deze"; +"cd_copy_field" = "%1$@ kopiëren"; +"cd_show_field" = "%1$@ tonen"; +"cd_hide_field" = "%1$@ verbergen"; +"cd_open_folder" = "Map %1$@ openen"; +"cd_open_item" = "%1$@ openen"; +"cd_switch_account" = "Ander account, nu %1$@"; +"cd_add_item" = "Item toevoegen"; +"cd_new_send" = "Nieuwe Send"; +"cd_folder_actions" = "Mapacties"; +"cd_remove_field" = "Veld %1$@ weghalen"; +"cd_regenerate" = "Nieuwe waarde genereren"; +"copied" = "Gekopieerd. Wordt over %1$d seconden gewist."; +"copied_kept" = "Gekopieerd."; +"accounts_title" = "Accounts"; +"accounts_add" = "Account toevoegen"; +"accounts_limit" = "Je kunt tot 5 accounts koppelen."; +"settings_clipboard" = "Klembord wissen na"; +"settings_clipboard_never" = "Nooit"; +"settings_clipboard_seconds" = "%1$d seconden"; +"settings_clipboard_minutes" = "%1$d minuten"; +"search_label" = "Zoek in de kluis"; +"search_hint" = "Naam of adres"; +"vault_no_folder" = "Geen map"; +"vault_folders" = "Mappen"; +"vault_items" = "Items"; +"vault_loading" = "Je kluis wordt geladen"; +"vault_empty" = "Je kluis is leeg. Voeg een item toe om te beginnen."; +"vault_no_match" = "Niets komt overeen met je zoekopdracht."; +"vault_all_blocked" = "Elk item is hier geblokkeerd. De sleutel werkt niet op dit apparaat."; +"vault_folder_empty" = "Deze map is leeg."; +"vault_synced" = "Laatst gesynchroniseerd %1$@"; +"vault_offline" = "Je bent offline. Je ziet de kopie van %1$@. Bewerken kan alleen met verbinding."; +"vault_offline_never" = "Je bent offline. Bewerken kan alleen met verbinding."; +"vault_needs_connection" = "Je organisatie bewaart geen kopie op dit apparaat. Maak verbinding om je kluis te openen."; +"vault_online_only" = "Je organisatie bewaart geen kopie op dit apparaat."; +"vault_locked_suite" = "Je versleutelingssleutels zijn gewijzigd. Ontgrendel opnieuw."; +"vault_locked_password" = "Je hoofdwachtwoord is gewijzigd. Ontgrendel opnieuw."; +"vault_locked_two_factor" = "Meld je eerst in de webapp aan met tweestapsverificatie."; +"badge_use_only" = "Alleen gebruiken"; +"badge_read_only" = "Alleen lezen"; +"badge_blocked" = "Geblokkeerd"; +"detail_username" = "Gebruikersnaam"; +"detail_password" = "Wachtwoord"; +"detail_value" = "Waarde"; +"detail_address" = "Adres"; +"detail_notes" = "Notities"; +"detail_folder" = "Map"; +"detail_code" = "Verificatiecode"; +"detail_code_seconds" = "Nog %1$d seconden"; +"detail_code_invalid" = "Dit is geen geldige verificatiesleutel."; +"detail_card_ending" = "Kaart eindigend op %1$@"; +"detail_passkey_site" = "Site"; +"detail_passkey_account" = "Account"; +"detail_passkey_created" = "Gemaakt"; +"detail_passkey_note" = "De website die deze passkey gebruikt, maakt en wijzigt hem."; +"detail_extra_fields" = "Extra velden"; +"detail_fields_error" = "De extra velden zijn niet te lezen."; +"detail_use_only" = "Gedeeld om alleen te gebruiken. Je kunt het invullen, maar de waarde niet zien of kopiëren."; +"detail_read_only" = "Je kunt dit item alleen lezen. De server weigert wijzigingen."; +"detail_blocked" = "Dit item kan hier niet worden geopend."; +"detail_from_cache" = "Je bent offline. Dit is de kopie van je laatste synchronisatie."; +"detail_missing" = "Dit item bestaat niet meer."; +"detail_trash_confirm" = "%1$@ naar de prullenbak verplaatsen? Je kunt het herstellen in de webapp."; +"move_title" = "Naar map verplaatsen"; +"edit_new_title" = "Nieuw item"; +"edit_title" = "Item bewerken"; +"edit_type" = "Soort"; +"edit_name" = "Naam"; +"edit_url" = "Websiteadres"; +"edit_totp_secret" = "Verificatiesleutel of otpauth-link"; +"edit_add_field" = "Veld toevoegen"; +"edit_field_name" = "Veldnaam"; +"edit_field_value" = "Veldwaarde"; +"edit_passkey_note" = "Van een passkey kun je alleen de naam, het adres, de map en de notities wijzigen."; +"edit_required" = "Verplicht"; +"edit_saved" = "Opgeslagen."; +"problem_name_missing" = "Geef het item een naam."; +"problem_too_long" = "Dit is te lang om op te slaan."; +"problem_field_name_missing" = "Geef het veld een naam."; +"problem_field_name_reserved" = "Deze naam is gereserveerd."; +"problem_field_name_taken" = "Een ander veld heeft deze naam."; +"problem_not_totp" = "Dit is geen geldige verificatiesleutel."; +"problem_required" = "Vul dit veld in."; +"write_offline" = "Bewerken kan alleen met verbinding. Er is niets gewijzigd."; +"write_key_migration" = "Je kluis krijgt nieuwe sleutels. Probeer het later opnieuw."; +"write_suite_blocked" = "Je versleutelingssuite is geblokkeerd. Open Keepiq op het web om dit op te lossen."; +"write_server" = "De server weigerde: %1$@"; +"write_refused" = "De server weigerde deze wijziging."; +"write_unreachable" = "De server is niet bereikbaar."; +"write_failed" = "Opslaan is mislukt."; +"folder_new" = "Nieuwe map"; +"folder_rename" = "Map hernoemen"; +"folder_delete" = "Map verwijderen"; +"folder_name" = "Mapnaam"; +"folder_name_missing" = "Geef de map een naam."; +"folder_name_slash" = "Een mapnaam mag geen schuine streep bevatten."; +"folder_delete_empty" = "De lege map %1$@ verwijderen?"; +"folder_delete_items" = "%1$@ bevat items. Wat gebeurt ermee?"; +"folder_delete_move" = "Items eruit halen"; +"folder_delete_with_items" = "Items ook verwijderen"; +"folder_delete_subfolders" = "Deze map heeft submappen. Verwijder hem in de webapp, waar je per submap kiest."; +"gen_password" = "Wachtwoord"; +"gen_passphrase" = "Wachtzin"; +"gen_length" = "Lengte: %1$d"; +"gen_upper" = "A tot Z"; +"gen_lower" = "a tot z"; +"gen_digits" = "0 tot 9"; +"gen_symbols" = "Symbolen"; +"gen_min_digits" = "Minstens %1$d cijfers"; +"gen_min_symbols" = "Minstens %1$d symbolen"; +"gen_avoid_ambiguous" = "Geen tekens die op elkaar lijken"; +"gen_words" = "Woorden: %1$d"; +"gen_separator" = "Scheidingsteken"; +"gen_capitalise" = "Woorden met hoofdletter"; +"gen_number" = "Cijfer toevoegen"; +"gen_policy" = "Je organisatie vraagt minstens %1$d tekens."; +"gen_policy_required" = "Je organisatie vereist dit."; +"gen_passphrase_off" = "Je organisatie heeft wachtzinnen uitgezet."; +"gen_error" = "Dit kan niet worden gegenereerd: %1$@"; +"gen_err_no_kind" = "kies minstens één soort teken."; +"gen_err_charset" = "er blijven te weinig tekens over na de uitsluitingen."; +"gen_err_length" = "de lengte valt buiten het bereik."; +"gen_err_other" = "de opties passen niet bij elkaar."; +"send_title" = "Je sends"; +"send_empty" = "Je hebt geen actieve sends."; +"send_text" = "Tekst-send"; +"send_credential" = "Inlog-send"; +"send_row" = "%1$@, %2$@"; +"send_views" = "%1$d van %2$d keer bekeken"; +"send_password_badge" = "Wachtwoord"; +"send_expired" = "Verlopen"; +"send_expires_minutes" = "Verloopt over %1$d minuten"; +"send_expires_hours" = "Verloopt over %1$d uur"; +"send_expires_days" = "Verloopt over %1$d dagen"; +"send_delete_confirm" = "Deze send beëindigen? De link werkt dan niet meer."; +"send_new_title" = "Nieuwe Send"; +"send_kind_text" = "Tekst"; +"send_kind_login" = "Inlog"; +"send_text_label" = "Tekst om te versturen"; +"send_views_label" = "Aantal keer bekijken"; +"send_expiry_label" = "Verloopt na"; +"send_hours_label" = "Uren"; +"send_password_label" = "Wachtwoord (optioneel)"; +"send_password_unavailable" = "Dit apparaat kan een send nog niet met een wachtwoord beveiligen."; +"send_create" = "Link maken"; +"send_created" = "Je link is klaar. Iedereen met de link kan de send openen."; +"send_created_password" = "Je link is klaar. Deel het wachtwoord op een andere manier."; +"send_copy_link" = "Link kopiëren"; +"send_nothing" = "Er is niets om te versturen."; +"send_views_range" = "Kies 1 tot 100 keer."; +"send_hours_range" = "Vul een heel aantal uren in, minstens 1."; +"send_hours_max" = "Hoogstens 720 uur (30 dagen)."; +"expiry_1h" = "1 uur"; +"expiry_1d" = "1 dag"; +"expiry_2d" = "2 dagen"; +"expiry_3d" = "3 dagen"; +"expiry_7d" = "7 dagen"; +"expiry_30d" = "30 dagen"; +"expiry_custom" = "Zelf kiezen"; +"open_send_title" = "Send openen"; +"open_send_link" = "Send-link"; +"open_send_invalid" = "Dit is geen Keepiq Send-link."; +"open_send_ready" = "Openen toont de tekst en telt als één keer bekijken."; +"open_send_password" = "Deze send is beveiligd met een wachtwoord."; +"open_send_wrong" = "Verkeerd wachtwoord. Nog %1$d pogingen voordat de send wordt vernietigd."; +"open_send_burned" = "Te vaak een verkeerd wachtwoord. De send is vernietigd."; +"open_send_gone" = "Deze send bestaat niet meer. Hij is verlopen of al geopend."; +"open_send_no_key" = "De link mist de sleutel, dus de send kan niet open."; +"open_send_failed" = "De send kon niet worden geopend."; +"open_send_last_view" = "Dit was de laatste keer. De send is nu weg."; +"open_send_paste" = "Send-link openen"; +"composite_number" = "Kaartnummer"; +"composite_expiry" = "Vervaldatum (MM/JJ)"; +"composite_cvv" = "CVC"; +"composite_pin" = "Pincode"; +"composite_cardholder" = "Kaarthouder"; +"composite_first_name" = "Voornaam"; +"composite_last_name" = "Achternaam"; +"composite_address" = "Adres"; +"composite_phone" = "Telefoon"; +"composite_email" = "E-mail"; +"composite_bsn" = "BSN"; +"cd_lock" = "Kluis vergrendelen"; +"cd_settings" = "Ontgrendelen en accountinstellingen"; diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultAppView.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultAppView.swift new file mode 100644 index 000000000..147a828a2 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultAppView.swift @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// The vault, Send and generator screens of one unlocked account (task +/// group 3). The unlock flow hands in the session; `accounts` and the +/// callbacks drive the account switcher. `onLock` is the lock button, +/// `onSettings` opens the unlock and account settings, and `onLocked` is +/// called with the reason when a sync found the keys changed elsewhere. +public struct VaultAppView: View { + @StateObject private var model: VaultModel + private let accounts: [Account] + private let onSwitchAccount: (Account) -> Void + private let onAddAccount: (() -> Void)? + private let onLock: (() -> Void)? + private let onSettings: (() -> Void)? + @State private var showAccounts = false + @State private var openLink: String? + + public init( + session: MobileSession, + accounts: [Account], + onSwitchAccount: @escaping (Account) -> Void, + onAddAccount: (() -> Void)?, + onLock: (() -> Void)? = nil, + onSettings: (() -> Void)? = nil, + onLocked: ((String) -> Void)? = nil + ) { + let model = VaultModel(session: session) + model.onLocked = onLocked + _model = StateObject(wrappedValue: model) + self.accounts = accounts + self.onSwitchAccount = onSwitchAccount + self.onAddAccount = onAddAccount + self.onLock = onLock + self.onSettings = onSettings + } + + public var body: some View { + TabView { + NavigationStack { + VaultListView(model: model, folderId: nil) + .toolbar { accountButton } + } + .tabItem { Label(L("tab_vault"), systemImage: "lock.rectangle.stack") } + + NavigationStack { + GeneratorView(model: model, onUse: nil) + .navigationTitle(L("tab_generator")) + .toolbar { accountButton } + } + .tabItem { Label(L("tab_generator"), systemImage: "wand.and.stars") } + + NavigationStack { + SendListView(model: model) + .toolbar { accountButton } + } + .tabItem { Label(L("tab_send"), systemImage: "paperplane") } + } + .task { await model.loadPolicy() } + .overlay(alignment: .bottom) { + if let toast = model.toast { + Text(toast) + .padding(12) + .background(.thinMaterial, in: Capsule()) + .padding(.bottom, 64) + .accessibilityAddTraits(.updatesFrequently) + .task(id: toast) { + try? await Task.sleep(nanoseconds: 3_000_000_000) + model.toast = nil + } + } + } + // A Send link handed to the app (a universal link, once the server + // publishes an apple-app-site-association file) opens here. + .onOpenURL { url in + if SendLink.companion.parse(link: url.absoluteString) != nil { openLink = url.absoluteString } + } + .sheet(isPresented: Binding(get: { openLink != nil }, set: { if !$0 { openLink = nil } })) { + NavigationStack { + OpenSendView(initialLink: openLink ?? "", onCopy: model.copy) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button(L("action_close")) { openLink = nil } } } + } + } + .sheet(isPresented: $showAccounts) { + AccountsView( + current: model.session.account, + accounts: accounts, + onSwitch: { account in + showAccounts = false + onSwitchAccount(account) + }, + onAdd: onAddAccount.map { add in { showAccounts = false; add() } }, + onSettings: onSettings.map { open in { showAccounts = false; open() } } + ) + } + } + + private var accountButton: some ToolbarContent { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { showAccounts = true } label: { Image(systemName: "person.crop.circle") } + .accessibilityLabel(L("cd_switch_account", model.session.label)) + .accessibilityIdentifier("accounts") + if let onLock { + Button(action: onLock) { Image(systemName: "lock") } + .accessibilityLabel(L("cd_lock")) + .accessibilityIdentifier("lock") + } + } + } +} + +/// The account switcher, the unlock and account settings, and the clipboard delay. +struct AccountsView: View { + let current: Account + let accounts: [Account] + let onSwitch: (Account) -> Void + let onAdd: (() -> Void)? + let onSettings: (() -> Void)? + + init(current: Account, accounts: [Account], onSwitch: @escaping (Account) -> Void, onAdd: (() -> Void)?, onSettings: (() -> Void)? = nil) { + self.current = current + self.accounts = accounts + self.onSwitch = onSwitch + self.onAdd = onAdd + self.onSettings = onSettings + } + + @State private var clearSeconds = VaultSettings.clipboardClearSeconds + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + ForEach(accounts, id: \.id) { account in + Button { + onSwitch(account) + } label: { + HStack { + Text(MobileSession.companion.labelOf(account: account)) + Spacer() + if account.id == current.id { Image(systemName: "checkmark").accessibilityHidden(true) } + } + } + .accessibilityAddTraits(account.id == current.id ? .isSelected : []) + } + if let onAdd { + if accounts.count < 5 { + Button(L("accounts_add"), action: onAdd) + } else { + Text(L("accounts_limit")).font(.footnote) + } + } + } + if let onSettings { + Section { + Button(L("cd_settings"), action: onSettings).accessibilityIdentifier("settings") + } + } + Section(L("settings_clipboard")) { + Picker(L("settings_clipboard"), selection: $clearSeconds) { + ForEach(SensitiveClipboard.companion.CLEAR_CHOICES.map { Int(truncating: $0) }, id: \.self) { seconds in + Text(clipboardLabel(seconds)).tag(seconds) + } + } + .pickerStyle(.inline) + .labelsHidden() + .onChange(of: clearSeconds) { _, value in VaultSettings.clipboardClearSeconds = value } + } + } + .navigationTitle(L("accounts_title")) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button(L("action_close")) { dismiss() } } + } + } + } + + private func clipboardLabel(_ seconds: Int) -> String { + if seconds == 0 { return L("settings_clipboard_never") } + if seconds >= 120 { return L("settings_clipboard_minutes", seconds / 60) } + return L("settings_clipboard_seconds", seconds) + } +} diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultListView.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultListView.swift new file mode 100644 index 000000000..6733b41fd --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultListView.swift @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import KeepiqShared +import SwiftUI + +/// The vault list (task 3.1): one folder's subfolders and items, or, while +/// searching, every match. Names and addresses only: nothing is decrypted +/// here. Search runs on the device. +struct VaultListView: View { + @ObservedObject var model: VaultModel + let folderId: String? + + init(model: VaultModel, folderId: String?) { + self.model = model + self.folderId = folderId + } + + @State private var state: VaultState? + @State private var query = "" + @State private var folderSheet: FolderSheet? + @State private var addItem = false + @Environment(\.scenePhase) private var scenePhase + @Environment(\.dismiss) private var dismiss + + var body: some View { + content + .navigationTitle(folderName ?? L("tab_vault")) + .searchable(text: $query, prompt: L("search_hint")) + .refreshable { await sync(.manual) } + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Menu { + Button(L("folder_new")) { folderSheet = .create(parentId: folderId) } + if let folderId, let name = folderName { + Button(L("folder_rename")) { folderSheet = .rename(id: folderId, name: name) } + Button(L("folder_delete"), role: .destructive) { folderSheet = .delete(id: folderId, name: name) } + } + } label: { + Image(systemName: "folder.badge.gearshape") + } + .accessibilityLabel(L("cd_folder_actions")) + } + if let state, !state.offline, state.locked == nil { + ToolbarItem(placement: .primaryAction) { + Button { addItem = true } label: { Image(systemName: "plus") } + .accessibilityLabel(L("cd_add_item")) + } + } + } + .navigationDestination(isPresented: $addItem) { + ItemEditView(model: model, itemId: nil, folderId: folderId) { _ in + addItem = false + state = model.repository.state + } + } + .sheet(item: $folderSheet) { sheet in + FolderSheetView(model: model, sheet: sheet) { changed, gone in + folderSheet = nil + if changed { state = model.repository.state } + if gone { dismiss() } + } + } + .task { + if model.repository.state.rows.isEmpty && model.repository.state.syncedAtMillis == nil { + await sync(.start) + } else { + state = model.repository.state + } + // The extension's 15-minute timer while the app is open. + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(VaultSync.companion.SYNC_INTERVAL_MILLIS) * 1_000_000) + if Task.isCancelled { break } + await sync(.timer) + } + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await sync(.foreground) } } + } + } + + private var folderName: String? { + guard let folderId else { return nil } + return model.repository.state.folders.first { $0.id == folderId }?.name + } + + private func sync(_ trigger: SyncTrigger) async { + state = try? await model.repository.refresh(trigger: trigger) + if let locked = state?.locked { model.onLocked?(lockText(locked)) } + } + + @ViewBuilder + private var content: some View { + if let state { + List { + SyncNote(state: state) + if let locked = state.locked { + Text(lockText(locked)) + } else if state.needsConnection { + Text(L("vault_needs_connection")) + } else { + entries(state) + } + } + .listStyle(.plain) + } else { + ProgressView(L("vault_loading")) + } + } + + @ViewBuilder + private func entries(_ state: VaultState) -> some View { + let index = state.index + let searching = !query.trimmingCharacters(in: .whitespaces).isEmpty + let folderIds = Set(state.folders.map { $0.id }) + let items: [IndexEntry] = { + if searching { return VaultIndex.shared.filter(entries: index, query: query, folderId: nil, typeName: nil) } + if let folderId { return VaultIndex.shared.filter(entries: index, query: "", folderId: folderId, typeName: nil) } + // The top level also holds items whose folder is gone, so none is ever out of reach. + return index.filter { entry in entry.folderId == nil || !folderIds.contains(entry.folderId!) } + }() + let folders: [VaultFolder] = searching ? [] : VaultIndex.shared.subfolders(folders: state.folders, parentId: folderId) + if index.isEmpty { + Text(L("vault_empty")) + } else if !searching && index.allSatisfy({ $0.blocked }) { + Text(L("vault_all_blocked")) + } else if searching && items.isEmpty { + Text(L("vault_no_match")) + } else if !searching && items.isEmpty && folders.isEmpty { + Text(L("vault_folder_empty")) + } else { + ForEach(folders, id: \.id) { folder in + NavigationLink { + VaultListView(model: model, folderId: folder.id) + } label: { + Label(folder.name, systemImage: "folder") + } + .accessibilityLabel(L("cd_open_folder", folder.name)) + } + ForEach(items, id: \.id) { entry in + NavigationLink { + ItemDetailView(model: model, itemId: entry.id) + } label: { + EntryRow(entry: entry, showFolder: searching) + } + } + } + } + + private func lockText(_ reason: LockReason) -> String { + switch reason { + case .suiteChanged: return L("vault_locked_suite") + case .masterPasswordChanged: return L("vault_locked_password") + default: return L("vault_locked_two_factor") + } + } +} + +private struct EntryRow: View { + let entry: IndexEntry + let showFolder: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + let host = entry.url + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .split(separator: "/").first.map(String.init) ?? "" + let details = detailText(host: host) + if !details.isEmpty { + Text(details).font(.footnote).foregroundStyle(.secondary) + } + } + .frame(minHeight: 44) + } + + private func detailText(host: String) -> String { + var badges: [String] = [] + if entry.useOnly { badges.append(L("badge_use_only")) } + if entry.readOnly && !entry.useOnly { badges.append(L("badge_read_only")) } + if entry.blocked { badges.append(L("badge_blocked")) } + var parts: [String] = [] + if !host.isEmpty { parts.append(host) } + if showFolder && !entry.folderName.isEmpty { parts.append(entry.folderName) } + if !badges.isEmpty { parts.append(badges.joined(separator: ", ")) } + return parts.joined(separator: " · ") + } +} + +/// The last-synced note, or what offline means for this vault. +struct SyncNote: View { + let state: VaultState + + var body: some View { + if let text { + Text(text) + .font(.footnote) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(state.offline ? Color.yellow.opacity(0.2) : Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .listRowSeparator(.hidden) + } + if let problem = state.problem { + Text(writeProblemText(problem)).foregroundStyle(.red) + } + } + + private var text: String? { + let synced = state.syncedAtMillis?.int64Value + if state.offline, let synced { return L("vault_offline", relativeTime(millis: synced)) } + if state.offline { return L("vault_offline_never") } + if state.onlineOnly { return L("vault_online_only") } + if let synced { return L("vault_synced", relativeTime(millis: synced)) } + return nil + } +} + +enum FolderSheet: Identifiable { + case create(parentId: String?) + case rename(id: String, name: String) + case delete(id: String, name: String) + + var id: String { + switch self { + case .create(let parentId): return "create-\(parentId ?? "")" + case .rename(let id, _): return "rename-\(id)" + case .delete(let id, _): return "delete-\(id)" + } + } +} + +/// Create, rename and delete a folder (task 3.2). +struct FolderSheetView: View { + @ObservedObject var model: VaultModel + let sheet: FolderSheet + let onDone: (_ changed: Bool, _ gone: Bool) -> Void + + init(model: VaultModel, sheet: FolderSheet, onDone: @escaping (_ changed: Bool, _ gone: Bool) -> Void) { + self.model = model + self.sheet = sheet + self.onDone = onDone + } + + @State private var name = "" + @State private var problem: WriteProblem? + @State private var busy = false + @State private var deleteKind: FolderDeleteKind? + @State private var loaded = false + + var body: some View { + NavigationStack { + Form { + switch sheet { + case .create, .rename: + LabeledField(L("folder_name")) { + TextField(L("folder_name"), text: $name) + } + if let nameProblem, !name.isEmpty { Text(nameProblem).foregroundStyle(.red) } + case .delete(let id, let folderName): + if !loaded { + ProgressView().task { + deleteKind = try? await model.repository.folderDeleteKind(id: id) + loaded = true + } + } else if let deleteKind { + if deleteKind == .empty { + Text(L("folder_delete_empty", folderName)) + Button(L("action_delete"), role: .destructive) { run(gone: true) { try await model.repository.deleteFolder(id: id, kind: .empty, deleteItems: false) } } + } else if deleteKind == .items { + Text(L("folder_delete_items", folderName)) + Button(L("folder_delete_move")) { run(gone: true) { try await model.repository.deleteFolder(id: id, kind: .items, deleteItems: false) } } + Button(L("folder_delete_with_items"), role: .destructive) { run(gone: true) { try await model.repository.deleteFolder(id: id, kind: .items, deleteItems: true) } } + } else { + Text(L("folder_delete_subfolders")) + } + } else { + Text(L("write_unreachable")) + } + } + if let problem { Text(writeProblemText(problem)).foregroundStyle(.red) } + } + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button(L("action_cancel")) { onDone(false, false) } } + if !isDelete { + ToolbarItem(placement: .confirmationAction) { + Button(L("action_save")) { save() }.disabled(busy || nameProblem != nil) + } + } + } + .onAppear { + if case .rename(_, let current) = sheet { name = current } + } + } + } + + private var isDelete: Bool { + if case .delete = sheet { return true } + return false + } + + private var title: String { + switch sheet { + case .create: return L("folder_new") + case .rename: return L("folder_rename") + case .delete: return L("folder_delete") + } + } + + /// folderNameProblem (browser-extension/src/lib/folder-rules.js). + private var nameProblem: String? { + let clean = name.trimmingCharacters(in: .whitespacesAndNewlines) + if clean.isEmpty { return L("folder_name_missing") } + if clean.contains("/") { return L("folder_name_slash") } + if clean.count > 255 { return L("problem_too_long") } + return nil + } + + private func save() { + switch sheet { + case .create(let parentId): run(gone: false) { try await model.repository.createFolder(name: name, parentId: parentId) } + case .rename(let id, _): run(gone: false) { try await model.repository.renameFolder(id: id, name: name) } + case .delete: break + } + } + + private func run(gone: Bool, _ block: @escaping () async throws -> WriteResult) { + busy = true + Task { @MainActor in + let result = try? await block() + busy = false + if result is WriteResult.Saved { + onDone(true, gone) + } else if let refused = result as? WriteResult.Refused { + problem = refused.problem + } + } + } +} diff --git a/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultSupport.swift b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultSupport.swift new file mode 100644 index 000000000..e49be7519 --- /dev/null +++ b/mobile/ios/KeepiqApp/Sources/KeepiqApp/VaultSupport.swift @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import Foundation +import KeepiqShared +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +/// A localized string from this package, with positional arguments. +func L(_ key: String, _ args: CVarArg...) -> String { + let format = NSLocalizedString(key, bundle: .module, comment: "") + return args.isEmpty ? format : String(format: format, arguments: args) +} + +/// The system pasteboard for the shared SensitiveClipboard (task 3.3): local +/// only, so it never syncs to other devices, with an expiration date, so iOS +/// clears it even when the app is gone. +final class IOSClipboard: NSObject, ClipboardPort { + private var ourChangeCount: Int? + private var ourToken: String? + + func writeSensitive(text: String, expiresInSeconds: Int32) { + var options: [UIPasteboard.OptionsKey: Any] = [.localOnly: true] + if expiresInSeconds > 0 { + options[.expirationDate] = Date().addingTimeInterval(TimeInterval(expiresInSeconds)) + } + UIPasteboard.general.setItems([[UTType.plainText.identifier: text]], options: options) + ourChangeCount = UIPasteboard.general.changeCount + ourToken = SensitiveClipboard.companion.tokenOf(text: text) + } + + func clearIfOurs(token: String) { + guard token == ourToken, UIPasteboard.general.changeCount == ourChangeCount else { return } + UIPasteboard.general.setItems([], options: [:]) + ourToken = nil + ourChangeCount = nil + } +} + +/// Main-queue timers for the shared SensitiveClipboard. +final class MainQueueScheduler: NSObject, ClearScheduler { + func schedule(delayMillis: Int64, action: ScheduledAction) -> PendingClear { + let work = DispatchWorkItem { action.run() } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(delayMillis)), execute: work) + return WorkCancellable(work: work) + } +} + +private final class WorkCancellable: NSObject, PendingClear { + private let work: DispatchWorkItem + + init(work: DispatchWorkItem) { + self.work = work + } + + func cancel() { + work.cancel() + } +} + +/// Reads the stored delay at each copy. +final class StoredClearDelay: NSObject, ClearDelay { + func seconds() -> Int32 { Int32(VaultSettings.clipboardClearSeconds) } +} + +/// The clipboard delay, the same choices as the extension (default 60 seconds). +enum VaultSettings { + private static let clipboardKey = "keepiq.clipboard-clear-seconds" + + static var clipboardClearSeconds: Int { + get { + let stored = UserDefaults.standard.object(forKey: clipboardKey) as? Int + return stored ?? Int(SensitiveClipboard.companion.DEFAULT_CLEAR_SECONDS) + } + set { UserDefaults.standard.set(newValue, forKey: clipboardKey) } + } +} + +/// One unlocked account for the screens. The unlock flow (task group 2) +/// creates the shared MobileSession; this keeps the clipboard and the policy. +/// Kotlin suspend functions are called on the main thread, so the async +/// work here is main-actor isolated. +final class VaultModel: ObservableObject { + let session: MobileSession + let clipboard: SensitiveClipboard + @Published var policy: GeneratorPolicy? + @Published var toast: String? + /// Called with the reason when a sync locks the vault (the keys changed elsewhere). + var onLocked: ((String) -> Void)? + + init(session: MobileSession) { + self.session = session + self.clipboard = SensitiveClipboard( + port: IOSClipboard(), + scheduler: MainQueueScheduler(), + clearSeconds: StoredClearDelay() + ) + } + + var repository: VaultRepository { session.repository } + + @MainActor + func loadPolicy() async { + policy = try? await GeneratorPolicy.companion.fetch(api: session.api) + } + + /// Called from the screens, on the main thread. + func copy(_ value: String) { + let seconds = clipboard.write(text: value) + toast = seconds > 0 ? L("copied", Int(seconds)) : L("copied_kept") + } +} + +/// The text for a refused or failed write. +func writeProblemText(_ problem: WriteProblem) -> String { + switch problem.kind { + case .offline: return L("write_offline") + case .keyMigration: return L("write_key_migration") + case .suiteBlocked: return L("write_suite_blocked") + case .serverMessage: return L("write_server", problem.serverMessage ?? "") + case .refused: return L("write_refused") + case .unreachable: return L("write_unreachable") + default: return L("write_failed") + } +} + +func draftProblemText(_ problem: DraftProblem) -> String { + switch problem { + case .nameMissing: return L("problem_name_missing") + case .tooLong: return L("problem_too_long") + case .fieldNameMissing: return L("problem_field_name_missing") + case .fieldNameReserved: return L("problem_field_name_reserved") + case .fieldNameTaken: return L("problem_field_name_taken") + case .notAnAuthenticatorSecret: return L("problem_not_totp") + default: return L("problem_required") + } +} + +func relativeTime(millis: Int64) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter.localizedString(for: Date(timeIntervalSince1970: TimeInterval(millis) / 1000), relativeTo: Date()) +} + +func compositeLabel(_ field: String) -> String { + switch field { + case "number": return L("composite_number") + case "expiry": return L("composite_expiry") + case "cvv": return L("composite_cvv") + case "pin": return L("composite_pin") + case "cardholder": return L("composite_cardholder") + case "firstName": return L("composite_first_name") + case "lastName": return L("composite_last_name") + case "address": return L("composite_address") + case "phone": return L("composite_phone") + case "email": return L("composite_email") + default: return L("composite_bsn") + } +} + +/// Closes the keyboard, so it does not cover the screen a save or a new Send opens. +func dismissKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) +} + +/// An input with its name shown above it. A placeholder disappears as soon +/// as the user types, and a secure field then shows nothing at all, so the +/// name stays visible and is the field's VoiceOver label too. +struct LabeledField: View { + let title: String + let field: Field + + init(_ title: String, @ViewBuilder field: () -> Field) { + self.title = title + self.field = field() + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.footnote).accessibilityHidden(true) + field.accessibilityLabel(title) + } + } +} + +/// A labelled value with Show and Copy. Buttons name the field for VoiceOver +/// and keep a 44 point target. +struct FieldRow: View { + let label: String + let value: String + var masked = false + var copy = true + let onCopy: (String) -> Void + + init(label: String, value: String, masked: Bool = false, copy: Bool = true, onCopy: @escaping (String) -> Void) { + self.label = label + self.value = value + self.masked = masked + self.copy = copy + self.onCopy = onCopy + } + + @State private var shown = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.caption).foregroundStyle(.secondary) + HStack { + if masked && !shown { + Text("••••••••").accessibilityLabel(label) + } else { + Text(value.isEmpty ? "-" : value) + .font(masked ? .body.monospaced() : .body) + .textSelection(.disabled) + } + Spacer() + if masked { + Button(shown ? L("action_hide") : L("action_show")) { shown.toggle() } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(shown ? L("cd_hide_field", label) : L("cd_show_field", label)) + } + if copy && !value.isEmpty { + Button(L("action_copy")) { onCopy(value) } + .frame(minWidth: 44, minHeight: 44) + .accessibilityLabel(L("cd_copy_field", label)) + } + } + } + } +} diff --git a/mobile/ios/KeepiqAutofill/CredentialProviderViewController.swift b/mobile/ios/KeepiqAutofill/CredentialProviderViewController.swift new file mode 100644 index 000000000..610d85ae6 --- /dev/null +++ b/mobile/ios/KeepiqAutofill/CredentialProviderViewController.swift @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import AuthenticationServices +import SwiftUI +import UIKit + +/// Keepiq as the iOS AutoFill provider (mobile-system-autofill, task 4.4). +/// +/// - prepareCredentialList: the logins for the asked sites, read from their +/// per-site files only, after an unlock in the sheet. +/// - provideCredentialWithoutUserInteraction: fills at once when this +/// extension opened the vault within the idle time; otherwise iOS shows +/// prepareInterfaceToProvideCredential, which unlocks and then fills. +/// - After a password fill, the site's one-time code goes on the clipboard +/// for 60 seconds (task 4.5, iOS 17 path). +/// - Passkeys (task 5.2, iOS 17): the same unlock, then the assertion or the +/// new passkey, signed with the shared core's ES256 over the clientDataHash +/// iOS built for the rpId it verified. A request that allows no ES256 is +/// declined, so iOS can ask another provider. +final class CredentialProviderViewController: ASCredentialProviderViewController { + private var host: UIHostingController? + + override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + show(AutofillModel(serviceIdentifiers: serviceIdentifiers.map(\.identifier), onFill: complete)) + } + + override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { + show(AutofillModel(serviceIdentifiers: [credentialIdentity.serviceIdentifier.identifier], record: credentialIdentity.recordIdentifier, onFill: complete)) + } + + override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + let model = AutofillModel(serviceIdentifiers: [credentialIdentity.serviceIdentifier.identifier], onFill: { _ in }) + guard model.phase == .list, let record = credentialIdentity.recordIdentifier, let filled = model.fill(record: record) else { + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userInteractionRequired.rawValue)) + return + } + complete(filled) + } + + // MARK: iOS 17 requests: passwords and passkeys + + override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters) { + let request = PasskeyRequest.assertion( + rpId: requestParameters.relyingPartyIdentifier, + clientDataHash: requestParameters.clientDataHash, + credentialId: nil, + allowed: requestParameters.allowedCredentials + ) + show(AutofillModel(serviceIdentifiers: [requestParameters.relyingPartyIdentifier], passkey: request, onPasskey: completePasskey, onFill: complete)) + } + + override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) { + switch credentialRequest.type { + case .passkeyAssertion: + guard let request = credentialRequest as? ASPasskeyCredentialRequest, + let identity = request.credentialIdentity as? ASPasskeyCredentialIdentity else { return fail() } + let passkey = PasskeyRequest.assertion(rpId: identity.relyingPartyIdentifier, clientDataHash: request.clientDataHash, credentialId: identity.credentialID, allowed: []) + show(AutofillModel(serviceIdentifiers: [identity.relyingPartyIdentifier], passkey: passkey, onPasskey: completePasskey, onFill: complete)) + case .password: + guard let identity = credentialRequest.credentialIdentity as? ASPasswordCredentialIdentity else { return fail() } + prepareInterfaceToProvideCredential(for: identity) + default: + fail() + } + } + + override func provideCredentialWithoutUserInteraction(for credentialRequest: ASCredentialRequest) { + if credentialRequest.type == .password, let identity = credentialRequest.credentialIdentity as? ASPasswordCredentialIdentity { + provideCredentialWithoutUserInteraction(for: identity) + return + } + // A passkey signs in the sheet: it writes a counter back, which needs the network. + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userInteractionRequired.rawValue)) + } + + override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) { + guard let request = registrationRequest as? ASPasskeyCredentialRequest, + let identity = request.credentialIdentity as? ASPasskeyCredentialIdentity else { return fail() } + let algorithms = request.supportedAlgorithms.map { Int($0.rawValue) } + // ES256 (-7) only, as the extension; an empty list means the default, which includes it. + guard algorithms.isEmpty || algorithms.contains(-7) else { return fail() } + let passkey = PasskeyRequest.registration( + rpId: identity.relyingPartyIdentifier, + userName: identity.userName, + userHandle: identity.userHandle, + clientDataHash: request.clientDataHash, + algorithms: algorithms + ) + show(AutofillModel(serviceIdentifiers: [identity.relyingPartyIdentifier], passkey: passkey, onPasskey: completePasskey, onFill: { _ in })) + } + + private func completePasskey(_ result: PasskeyResult) { + switch result { + case .assertion(let signed, let rpId, let hash): + guard let signature = Data(base64Encoded: signed.signature), + let authenticatorData = Data(base64Encoded: signed.authenticatorData), + let credentialId = Data(base64Encoded: signed.credentialId) else { return fail() } + let credential = ASPasskeyAssertionCredential( + userHandle: Data(base64Encoded: signed.userHandle) ?? Data(), + relyingParty: rpId, + signature: signature, + clientDataHash: hash, + authenticatorData: authenticatorData, + credentialID: credentialId + ) + extensionContext.completeAssertionRequest(using: credential, completionHandler: nil) + case .registration(let made, let rpId, let hash, let userHandle): + guard let credentialId = Data(base64Encoded: made.credentialId), + let attestation = Data(base64Encoded: made.attestationObject) else { return fail() } + // Offer the new passkey in the QuickType bar before the app's next sync. + let identity = ASPasskeyCredentialIdentity( + relyingPartyIdentifier: rpId, + userName: made.userName, + credentialID: credentialId, + userHandle: userHandle, + recordIdentifier: nil + ) + ASCredentialIdentityStore.shared.saveCredentialIdentities([identity]) { _, _ in } + let credential = ASPasskeyRegistrationCredential(relyingParty: rpId, clientDataHash: hash, credentialID: credentialId, attestationObject: attestation) + extensionContext.completeRegistrationRequest(using: credential, completionHandler: nil) + } + } + + /// Declines, so iOS can offer another provider. + private func fail() { + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.failed.rawValue)) + } + + override func prepareInterfaceForExtensionConfiguration() { + show(AutofillModel(serviceIdentifiers: [], configuration: true, onFill: { _ in })) + } + + private func complete(_ filled: FilledLogin) { + extensionContext.completeRequest(withSelectedCredential: ASPasswordCredential(user: filled.user, password: filled.password), completionHandler: nil) + } + + private func cancel() { + extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + } + + private func show(_ model: AutofillModel) { + host?.willMove(toParent: nil) + host?.view.removeFromSuperview() + host?.removeFromParent() + let controller = UIHostingController(rootView: AutofillRootView(model: model, onCancel: { [weak self] in self?.cancel() })) + addChild(controller) + controller.view.frame = view.bounds + controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(controller.view) + controller.didMove(toParent: self) + host = controller + } +} diff --git a/mobile/ios/KeepiqAutofill/KeepiqAutofill.entitlements b/mobile/ios/KeepiqAutofill/KeepiqAutofill.entitlements new file mode 100644 index 000000000..c106ab73e --- /dev/null +++ b/mobile/ios/KeepiqAutofill/KeepiqAutofill.entitlements @@ -0,0 +1,14 @@ + + + + + + + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.security.application-groups + + group.nl.conduction.keepiq + + + diff --git a/mobile/ios/KeepiqUITests/AutofillUITests.swift b/mobile/ios/KeepiqUITests/AutofillUITests.swift new file mode 100644 index 000000000..a49967f2e --- /dev/null +++ b/mobile/ios/KeepiqUITests/AutofillUITests.swift @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import XCTest + +/// Task group 4 on the iOS simulator, as far as a simulator test reaches. +/// Picking Keepiq as the AutoFill provider happens in Settings and cannot be +/// automated, so the app shows the extension's own screens (the same model, +/// files and Keychain) under -keepiq-autofill-preview, against the replay: +/// +/// - the unlock inside the AutoFill sheet, with the master password; +/// - "Add login" for keepiq-autofill.test, saved encrypted to the suite key and +/// filled in at once; +/// - the index rebuilt after the vault refresh: one site file and one +/// identity for ASCredentialIdentityStore; +/// - the sheet again, without a new unlock, listing that login from the +/// site file and filling it. +/// +/// Manual: Keepiq in the QuickType bar of Safari and an app, Face ID in the +/// sheet, and the one-time code on the clipboard after a fill. +final class AutofillUITests: XCTestCase { + private var app: XCUIApplication! + private let env = ProcessInfo.processInfo.environment + private var server: String { env["KEEPIQ_SERVER"] ?? "https://localhost:8443" } + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-keepiq-reset", "-keepiq-autofill-preview"] + app.launchEnvironment["KEEPIQ_UITEST_NO_BROWSER"] = "1" + // A site the seeded replay vault has no login for. + app.launchEnvironment["KEEPIQ_AUTOFILL_SITE"] = "keepiq-autofill.test" + app.launch() + } + + private func shot(_ name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + if let dir = env["KEEPIQ_SHOTS_DIR"] { + try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: dir).appendingPathComponent("\(name).png")) + } + } + + /// iOS may show its own "Save Password?" prompt after a sign-in or a save. + /// It is not Keepiq's, so the test answers "Not Now" when it is up. + private func dismissSystemSavePrompt() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].exists { + owner.buttons["Not Now"].tap() + return + } + } + + /// Connecting with an app password makes iOS offer to save it a moment + /// later. Answer that first: keys typed while the prompt slides in are lost. + private func answerSavePromptAfterConnect() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].waitForExistence(timeout: 4) { + owner.buttons["Not Now"].tap() + return + } + } + + /// Makes an element tappable: answers the prompt, hides the keyboard that + /// covers the lower half of a form, or scrolls the element into view. + private func makeHittable(_ element: XCUIElement) { + dismissSystemSavePrompt() + if element.isHittable { return } + if app.keyboards.count > 0 { + // Return ends editing in a SwiftUI field and lowers the keyboard. + let keys = app.keyboards.buttons.matching( + NSPredicate(format: "label IN %@", ["Return", "return", "Done", "done"])) + if keys.firstMatch.exists { keys.firstMatch.tap() } else { app.swipeDown(velocity: .slow) } + } else { + app.swipeUp(velocity: .slow) + } + } + + /// Taps until the field has keyboard focus: a field in a sheet that is + /// still sliding in takes the tap without taking the focus. + /// Waits for an element while answering the system prompt, which can + /// appear a moment after the tap that caused it. + private func appears(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let end = Date().addingTimeInterval(timeout) + while Date() < end { + if element.waitForExistence(timeout: 1) { + if element.isHittable { return true } + makeHittable(element) + } + } + return element.exists && element.isHittable + } + + private func type(_ text: String, into element: XCUIElement) { + XCTAssertTrue(appears(element, timeout: 20), "\(element) is missing") + let focused = NSPredicate(format: "hasKeyboardFocus == true") + for _ in 0..<5 { + if element.isHittable { element.tap() } + let wait = XCTNSPredicateExpectation(predicate: focused, object: element) + if XCTWaiter().wait(for: [wait], timeout: 3) == .completed { break } + } + element.typeText(text) + } + + private func tap(_ element: XCUIElement, timeout: TimeInterval = 20) { + XCTAssertTrue(appears(element, timeout: timeout), "\(element) is missing") + element.tap() + } + + private func waitForLabel(_ element: XCUIElement, containing text: String, timeout: TimeInterval = 60) { + expectation(for: NSPredicate(format: "label CONTAINS %@", text), evaluatedWith: element) + waitForExpectations(timeout: timeout) + } + + func testAddAndFillThroughTheAutofillSheet() { + type(server, into: app.textFields["server"]) + tap(app.buttons["useAppPassword"]) + type("admin", into: app.textFields["loginName"]) + type("manual-app-password", into: app.secureTextFields["appPassword"]) + tap(app.buttons["connect"]) + answerSavePromptAfterConnect() + type("Oj", into: app.secureTextFields["masterPassword"]) + tap(app.buttons["unlock"]) + XCTAssertTrue(app.buttons["lock"].waitForExistence(timeout: 60), "the vault opens after unlock") + + // The sheet starts locked: the extension has its own unlock. + tap(app.buttons["autofillPreview"]) + type("Oj", into: app.secureTextFields["autofillMasterPassword"]) + shot("autofill-ios-01-unlock") + tap(app.buttons["autofillUnlock"]) + XCTAssertTrue(app.staticTexts["autofillEmpty"].waitForExistence(timeout: 60)) + shot("autofill-ios-02-no-login-yet") + + // Add login: saved, then filled. + tap(app.buttons["autofillAdd"]) + type("alice", into: app.textFields["autofillAddUser"]) + type("Correct-horse-1", into: app.secureTextFields["autofillAddPassword"]) + shot("autofill-ios-03-add-login") + tap(app.buttons["autofillAddSave"]) + waitForLabel(app.staticTexts["autofillFilled"], containing: "filled: alice / 15") + + // The refresh after the save rebuilt the index, and the site file is on disk. + waitForLabel(app.staticTexts["autofillIndex"], containing: "keepiq-autofill.test: 1 logins on disk") + shot("autofill-ios-04-index-rebuilt") + + // Again: no unlock within the idle time, the login from the site file. + // The first sheet may still be closing: wait until the button is back. + let preview = app.buttons["autofillPreview"] + XCTAssertTrue(preview.waitForExistence(timeout: 20)) + tap(preview) + let row = app.buttons["autofillRow"] + let shown = [row, app.secureTextFields["autofillMasterPassword"], app.staticTexts["autofillEmpty"]] + let deadline = Date().addingTimeInterval(30) + while Date() < deadline, !shown.contains(where: { $0.exists }) { + _ = row.waitForExistence(timeout: 1) + } + shot("autofill-ios-05-second-sheet") + XCTAssertTrue(row.exists, "second sheet: unlock form \(shown[1].exists), empty list \(shown[2].exists)") + XCTAssertFalse(app.secureTextFields["autofillMasterPassword"].exists) + row.tap() + waitForLabel(app.staticTexts["autofillFilled"], containing: "filled: alice / 15") + } +} diff --git a/mobile/ios/KeepiqUITests/PairUnlockUITests.swift b/mobile/ios/KeepiqUITests/PairUnlockUITests.swift new file mode 100644 index 000000000..9302d0d8b --- /dev/null +++ b/mobile/ios/KeepiqUITests/PairUnlockUITests.swift @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import XCTest + +/// Task group 2 on the iOS simulator, against mobile/e2e/server.mjs replaying +/// answers recorded from the real test server (.github/workflows/mobile-e2e.yml). +/// The replay grants a Login Flow v2 on the third poll, as a user signing in +/// would; the app skips the browser sheet in this run. +final class PairUnlockUITests: XCTestCase { + private var app: XCUIApplication! + private let env = ProcessInfo.processInfo.environment + private var server: String { env["KEEPIQ_SERVER"] ?? "https://localhost:8443" } + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-keepiq-reset"] + app.launchEnvironment["KEEPIQ_UITEST_NO_BROWSER"] = "1" + app.launch() + } + + private func shot(_ name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + if let dir = env["KEEPIQ_SHOTS_DIR"] { + try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: dir).appendingPathComponent("\(name).png")) + } + } + + /// iOS may show its own "Save Password?" prompt after a sign-in or a save. + /// It is not Keepiq's, so the test answers "Not Now" when it is up. + private func dismissSystemSavePrompt() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].exists { + owner.buttons["Not Now"].tap() + return + } + } + + /// Connecting with an app password makes iOS offer to save it a moment + /// later. Answer that first: keys typed while the prompt slides in are lost. + private func answerSavePromptAfterConnect() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].waitForExistence(timeout: 4) { + owner.buttons["Not Now"].tap() + return + } + } + + /// Makes an element tappable: answers the prompt, hides the keyboard that + /// covers the lower half of a form, or scrolls the element into view. + private func makeHittable(_ element: XCUIElement) { + dismissSystemSavePrompt() + if element.isHittable { return } + if app.keyboards.count > 0 { + // Return ends editing in a SwiftUI field and lowers the keyboard. + let keys = app.keyboards.buttons.matching( + NSPredicate(format: "label IN %@", ["Return", "return", "Done", "done"])) + if keys.firstMatch.exists { keys.firstMatch.tap() } else { app.swipeDown(velocity: .slow) } + } else { + app.swipeUp(velocity: .slow) + } + } + + /// Waits for an element while answering the system prompt, which can + /// appear a moment after the tap that caused it. + private func appears(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let end = Date().addingTimeInterval(timeout) + while Date() < end { + if element.waitForExistence(timeout: 1) { + if element.isHittable { return true } + makeHittable(element) + } + } + return element.exists && element.isHittable + } + + private func type(_ text: String, into element: XCUIElement) { + XCTAssertTrue(appears(element, timeout: 20), "\(element) is missing") + element.tap() + element.typeText(text) + } + + private func tap(_ element: XCUIElement, timeout: TimeInterval = 20) { + XCTAssertTrue(appears(element, timeout: timeout), "\(element) is missing") + element.tap() + } + + private func waitForMessage(containing text: String, timeout: TimeInterval = 60) { + let message = app.staticTexts["message"] + let predicate = NSPredicate(format: "label CONTAINS %@", text) + expectation(for: predicate, evaluatedWith: message) + waitForExpectations(timeout: timeout) + } + + /// The unlock and account settings, from the account sheet inside the vault. + private func openSettings() { + tap(app.buttons["accounts"]) + tap(app.buttons["settings"]) + } + + func testBrowserPairingUnlockPinAndUnpair() { + // 2.1: the browser sign-in. + type(server, into: app.textFields["server"]) + shot("01-connect") + tap(app.buttons["signIn"]) + XCTAssertTrue(app.activityIndicators["waiting"].waitForExistence(timeout: 20)) + shot("02-waiting-for-browser") + + // Paired: unlock. A wrong master password first (2.2). + type("not the master password", into: app.secureTextFields["masterPassword"]) + shot("03-unlock") + tap(app.buttons["unlock"]) + waitForMessage(containing: "not correct") + shot("04-wrong-master-password") + + type("Oj", into: app.secureTextFields["masterPassword"]) + tap(app.buttons["unlock"]) + XCTAssertTrue(app.buttons["lock"].waitForExistence(timeout: 60), "the vault opens after unlock") + shot("05-unlocked") + + // 2.3: a PIN (Argon2id through the reference C code). + openSettings() + type("246810", into: app.secureTextFields["newPin"]) + tap(app.buttons["setPin"]) + XCTAssertTrue(app.buttons["removePin"].waitForExistence(timeout: 60)) + shot("06-settings-pin-set") + tap(app.buttons["back"]) + + tap(app.buttons["lock"]) + type("000000", into: app.secureTextFields["pin"]) + shot("07-locked-pin") + tap(app.buttons["unlockPin"]) + waitForMessage(containing: "4 tries left") + shot("08-wrong-pin") + type("246810", into: app.secureTextFields["pin"]) + tap(app.buttons["unlockPin"]) + XCTAssertTrue(app.buttons["lock"].waitForExistence(timeout: 60), "the vault opens after unlock") + + // 2.6: unpair. + openSettings() + let unpair = app.buttons["unpair"] + XCTAssertTrue(unpair.waitForExistence(timeout: 20)) + if !unpair.isHittable { app.swipeUp() } + unpair.tap() + let confirm = app.alerts.buttons["Disconnect"] + XCTAssertTrue(confirm.waitForExistence(timeout: 10)) + shot("09-unpair-confirm") + confirm.tap() + waitForMessage(containing: "The app password was deleted in Nextcloud.") + XCTAssertTrue(app.textFields["server"].exists) + shot("10-unpaired") + } + + func testAppPasswordPairingAndTwoFactorBlock() { + type(server, into: app.textFields["server"]) + tap(app.buttons["useAppPassword"]) + type("blocked", into: app.textFields["loginName"]) + type("manual-app-password", into: app.secureTextFields["appPassword"]) + shot("20-app-password") + tap(app.buttons["connect"]) + answerSavePromptAfterConnect() + + let blocked = app.staticTexts["blocked"] + XCTAssertTrue(blocked.waitForExistence(timeout: 60)) + XCTAssertTrue(blocked.label.contains("two-factor authentication")) + XCTAssertFalse(app.secureTextFields["masterPassword"].exists) + XCTAssertFalse(app.secureTextFields["pin"].exists) + shot("21-two-factor-required") + } +} diff --git a/mobile/ios/KeepiqUITests/PasskeyUITests.swift b/mobile/ios/KeepiqUITests/PasskeyUITests.swift new file mode 100644 index 000000000..7379da5b4 --- /dev/null +++ b/mobile/ios/KeepiqUITests/PasskeyUITests.swift @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import XCTest + +/// Task 5.2 on the iOS simulator, as far as a simulator test reaches. The +/// app shows the AutoFill extension's own passkey screens and calls (the same +/// model, files, Keychain and shared core) under -keepiq-autofill-preview, +/// against the replay, with the clientDataHash Safari would hand over: +/// +/// - creating a passkey for keepiq-passkey.test after the unlock in the sheet, +/// saved as a passkey item, with the attestation checked as a relying party +/// would (rpId hash, flags UP, UV and AT, zero AAGUID, 16-byte id); +/// - the index rebuilt: the passkey in the site file and one passkey identity +/// for ASCredentialIdentityStore; +/// - signing in with it without a new unlock, the signature verified with the +/// key from the attestation, counter 0 and the user handle. +/// +/// Manual, with the signed build: Keepiq as the passkey provider in Settings, +/// and a real ceremony in Safari and in an app with an associated domain. +final class PasskeyUITests: XCTestCase { + private var app: XCUIApplication! + private let env = ProcessInfo.processInfo.environment + private var server: String { env["KEEPIQ_SERVER"] ?? "https://localhost:8443" } + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-keepiq-reset", "-keepiq-autofill-preview"] + app.launchEnvironment["KEEPIQ_UITEST_NO_BROWSER"] = "1" + app.launchEnvironment["KEEPIQ_AUTOFILL_SITE"] = "keepiq-passkey.test" + app.launchEnvironment["KEEPIQ_PASSKEY_RP"] = "keepiq-passkey.test" + app.launch() + } + + private func shot(_ name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + if let dir = env["KEEPIQ_SHOTS_DIR"] { + try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: dir).appendingPathComponent("\(name).png")) + } + } + + /// iOS may show its own "Save Password?" prompt after a sign-in or a save. + /// It is not Keepiq's, so the test answers "Not Now" when it is up. + private func dismissSystemSavePrompt() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].exists { + owner.buttons["Not Now"].tap() + return + } + } + + /// Connecting with an app password makes iOS offer to save it a moment + /// later. Answer that first: keys typed while the prompt slides in are lost. + private func answerSavePromptAfterConnect() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].waitForExistence(timeout: 4) { + owner.buttons["Not Now"].tap() + return + } + } + + /// Makes an element tappable: answers the prompt, hides the keyboard that + /// covers the lower half of a form, or scrolls the element into view. + private func makeHittable(_ element: XCUIElement) { + dismissSystemSavePrompt() + if element.isHittable { return } + if app.keyboards.count > 0 { + // Return ends editing in a SwiftUI field and lowers the keyboard. + let keys = app.keyboards.buttons.matching( + NSPredicate(format: "label IN %@", ["Return", "return", "Done", "done"])) + if keys.firstMatch.exists { keys.firstMatch.tap() } else { app.swipeDown(velocity: .slow) } + } else { + app.swipeUp(velocity: .slow) + } + } + + /// Taps until the field has keyboard focus: a field in a sheet that is + /// still sliding in takes the tap without taking the focus. + /// Waits for an element while answering the system prompt, which can + /// appear a moment after the tap that caused it. + private func appears(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let end = Date().addingTimeInterval(timeout) + while Date() < end { + if element.waitForExistence(timeout: 1) { + if element.isHittable { return true } + makeHittable(element) + } + } + return element.exists && element.isHittable + } + + private func type(_ text: String, into element: XCUIElement) { + XCTAssertTrue(appears(element, timeout: 20), "\(element) is missing") + let focused = NSPredicate(format: "hasKeyboardFocus == true") + for _ in 0..<5 { + if element.isHittable { element.tap() } + let wait = XCTNSPredicateExpectation(predicate: focused, object: element) + if XCTWaiter().wait(for: [wait], timeout: 3) == .completed { break } + } + element.typeText(text) + } + + private func tap(_ element: XCUIElement, timeout: TimeInterval = 20) { + XCTAssertTrue(appears(element, timeout: timeout), "\(element) is missing") + element.tap() + } + + private func waitForLabel(_ element: XCUIElement, containing text: String, timeout: TimeInterval = 60) { + expectation(for: NSPredicate(format: "label CONTAINS %@", text), evaluatedWith: element) + waitForExpectations(timeout: timeout) + } + + func testCreateAndSignInWithAPasskey() { + type(server, into: app.textFields["server"]) + tap(app.buttons["useAppPassword"]) + type("admin", into: app.textFields["loginName"]) + type("manual-app-password", into: app.secureTextFields["appPassword"]) + tap(app.buttons["connect"]) + answerSavePromptAfterConnect() + type("Oj", into: app.secureTextFields["masterPassword"]) + tap(app.buttons["unlock"]) + XCTAssertTrue(app.buttons["lock"].waitForExistence(timeout: 60), "the vault opens after unlock") + + // Create: the sheet unlocks on its own, then the core makes and saves the passkey. + tap(app.buttons["passkeyCreatePreview"]) + type("Oj", into: app.secureTextFields["autofillMasterPassword"]) + shot("passkey-ios-01-unlock") + tap(app.buttons["autofillUnlock"]) + waitForLabel(app.staticTexts["passkeyResult"], containing: "registered: rp true, flags 0x45, id 16 bytes, checks true") + shot("passkey-ios-02-created") + + // The refresh after the save put it in the site file and the identity store. + waitForLabel(app.staticTexts["passkeyIndex"], containing: "keepiq-passkey.test: 1 passkeys on disk, passkey identities 1") + shot("passkey-ios-03-index") + + // Sign in: no new unlock within the idle time, one passkey fits, it signs at once. + let signIn = app.buttons["passkeySignInPreview"] + XCTAssertTrue(signIn.waitForExistence(timeout: 20)) + tap(signIn) + waitForLabel(app.staticTexts["passkeyResult"], containing: "signed in: verified true, rp true, flags 0x05, counter 0, user e2e-user-1") + XCTAssertFalse(app.secureTextFields["autofillMasterPassword"].exists) + shot("passkey-ios-04-signed-in") + } +} diff --git a/mobile/ios/KeepiqUITests/VaultFlowsUITests.swift b/mobile/ios/KeepiqUITests/VaultFlowsUITests.swift new file mode 100644 index 000000000..45c8c7e67 --- /dev/null +++ b/mobile/ios/KeepiqUITests/VaultFlowsUITests.swift @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import UIKit +import XCTest + +/// Task group 3 on the iOS simulator, over the demo vault recorded from the +/// seeded test server (mobile/e2e/server.mjs seed, then record). The replay +/// keeps the vault in memory, so what the test creates, edits and trashes +/// behaves as on the real server: after unlock the vault list, search, an +/// item with its password revealed and copied, the authenticator code, a new +/// login with a generated password, its edit and trash, the generator, a Send +/// with its link, the settings route, and the lock. +final class VaultFlowsUITests: XCTestCase { + private var app: XCUIApplication! + private let env = ProcessInfo.processInfo.environment + private var server: String { env["KEEPIQ_SERVER"] ?? "https://localhost:8443" } + + override func setUpWithError() throws { + continueAfterFailure = false + app = XCUIApplication() + app.launchArguments = ["-keepiq-reset"] + app.launchEnvironment["KEEPIQ_UITEST_NO_BROWSER"] = "1" + app.launch() + } + + private func shot(_ name: String) { + let screenshot = XCUIScreen.main.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + if let dir = env["KEEPIQ_SHOTS_DIR"] { + try? screenshot.pngRepresentation.write(to: URL(fileURLWithPath: dir).appendingPathComponent("\(name).png")) + } + } + + private func type(_ text: String, into element: XCUIElement) { + XCTAssertTrue(element.waitForExistence(timeout: 20), "\(element) is missing") + // A system prompt on its way out still covers the field for a moment. + let deadline = Date().addingTimeInterval(5) + while !reachable(element) && Date() < deadline { usleep(250_000) } + if !reachable(element) { makeHittable(element) } + element.tap() + element.typeText(text) + dismissKeyboardTip() + } + + /// On screen and not covered. Asking an off-screen element whether it is + /// hittable fails the test ("activation point invalid"), so the frame comes first. + private func reachable(_ element: XCUIElement) -> Bool { + guard element.exists else { return false } + let frame = element.frame + let screen = app.windows.firstMatch.frame + guard !frame.isEmpty, screen.contains(CGPoint(x: frame.midX, y: frame.midY)) else { return false } + return element.isHittable + } + + /// Brings a button into reach: answer a save-password prompt, lower the + /// keyboard with its Return key, then scroll until it can be tapped. + private func makeHittable(_ element: XCUIElement) { + dismissSavePasswordNow() + if reachable(element) { return } + if app.keyboards.count > 0 { + for key in ["Return", "return", "Done", "done"] where app.keyboards.buttons[key].exists { + app.keyboards.buttons[key].tap() + break + } + } + for _ in 0..<4 where !reachable(element) { + app.swipeUp() + } + } + + /// The first element of [query] that is on screen, or its first match. + private func onScreen(_ query: XCUIElementQuery) -> XCUIElement { + _ = query.firstMatch.waitForExistence(timeout: 20) + return query.allElementsBoundByIndex.first { reachable($0) } ?? query.firstMatch + } + + /// A Form builds its rows lazily: a button below the fold is not in the + /// tree until the form scrolls to it. + private func scrolledTo(_ element: XCUIElement) -> XCUIElement { + for _ in 0..<6 where !element.waitForExistence(timeout: 2) { + app.swipeUp() + } + return element + } + + /// The first keyboard use shows a slide-to-type tip that covers the lower half. + private func dismissKeyboardTip() { + let tip = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Speed up your typing")).firstMatch + if tip.exists, app.buttons["Continue"].exists { app.buttons["Continue"].tap() } + } + + private func tap(_ element: XCUIElement, timeout: TimeInterval = 20) { + XCTAssertTrue(element.waitForExistence(timeout: timeout), "\(element) is missing") + dismissKeyboardTip() + dismissSavePasswordNow() + // A screen still sliding in, or a keyboard on its way out, covers it for a moment. + let deadline = Date().addingTimeInterval(5) + while !reachable(element) && Date() < deadline { usleep(250_000) } + if !reachable(element) { makeHittable(element) } + element.tap() + } + + /// Any element whose label is [label] (a list row, a title or a value). + private func text(_ label: String) -> XCUIElement { + app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + } + + private func waitGone(_ element: XCUIElement, timeout: TimeInterval = 30) { + expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: element) + waitForExpectations(timeout: timeout) + } + + /// iOS offers to save the app password the user just typed; the test declines. + private func declineSavePassword() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] { + let notNow = owner.buttons["Not Now"] + if notNow.waitForExistence(timeout: 3) { + notNow.tap() + return + } + } + } + + /// iOS still offers "Save Password?" after Keepiq's item form, even with + /// the fields typed as one-time codes (open in tasks.md, 3.2). The test + /// answers it and records that it came, so the run continues. + private func expectNoSavePasswordPrompt() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].waitForExistence(timeout: 3) { + owner.buttons["Not Now"].tap() + let note = XCTAttachment(string: "iOS offered to save a password from Keepiq's item form") + note.name = "save-password-prompt-seen" + note.lifetime = .keepAlways + add(note) + return + } + } + + /// The save-password prompt, if it is up right now, without waiting for it. + private func dismissSavePasswordNow() { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + for owner in [app!, springboard] where owner.buttons["Not Now"].exists { + owner.buttons["Not Now"].tap() + return + } + } + + /// Leaves the search: "Cancel" up to iOS 18, a close button from iOS 26 on. + private func endSearch(_ search: XCUIElement) { + for label in ["Cancel", "Close"] where app.buttons[label].exists { + app.buttons[label].firstMatch.tap() + return + } + let clear = search.buttons["Clear text"] + if clear.exists { clear.tap() } + } + + private func back() { + let bar = app.navigationBars.firstMatch + for candidate in [bar.buttons["BackButton"], bar.buttons["Back"], bar.buttons["Vault"]] where candidate.exists { + candidate.tap() + return + } + bar.buttons.element(boundBy: 0).tap() + } + + func testVaultSearchRevealCopyTotpCreateEditTrashGeneratorSendAndLock() { + // Pair with an app password and unlock: the vault list opens. + type(server, into: app.textFields["server"]) + tap(app.buttons["useAppPassword"]) + type("admin", into: app.textFields["loginName"]) + type("manual-app-password", into: app.secureTextFields["appPassword"]) + tap(app.buttons["connect"]) + XCTAssertTrue(app.secureTextFields["masterPassword"].waitForExistence(timeout: 60)) + declineSavePassword() + type("Oj", into: app.secureTextFields["masterPassword"]) + tap(app.buttons["unlock"]) + XCTAssertTrue(app.buttons["lock"].waitForExistence(timeout: 60), "the vault opens after unlock") + XCTAssertTrue(text("Webmail (demo)").waitForExistence(timeout: 60)) + XCTAssertTrue(text("Authenticator (demo)").exists) + shot("30-vault-list") + + // Search runs on the device, over every folder. + let search = app.searchFields.firstMatch + if !search.waitForExistence(timeout: 5) { app.swipeDown() } + type("bank", into: search) + XCTAssertTrue(text("Bank (demo)").waitForExistence(timeout: 10)) + waitGone(text("Webmail (demo)")) + shot("31-search") + endSearch(search) + XCTAssertTrue(text("Webmail (demo)").waitForExistence(timeout: 10)) + + // A folder holds its own items. + tap(app.buttons["Open folder Personal"]) + XCTAssertTrue(text("Bank (demo)").waitForExistence(timeout: 20)) + shot("31-folder") + back() + XCTAssertTrue(text("Webmail (demo)").waitForExistence(timeout: 10)) + + // An item: the password hidden until shown, then copied. + tap(text("Webmail (demo)")) + XCTAssertTrue(text("anna.demo@example.com").waitForExistence(timeout: 60)) + XCTAssertFalse(text("Lantern-Orbit-42!").exists) + tap(app.buttons["Show Password"]) + XCTAssertTrue(text("Lantern-Orbit-42!").waitForExistence(timeout: 10)) + shot("32-item-revealed") + tap(app.buttons["Copy Password"]) + let copied = app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "Copied.")).firstMatch + XCTAssertTrue(copied.waitForExistence(timeout: 10)) + // Reading the pasteboard from the test would raise the paste prompt; + // that something text-like is on it can be asked without one. + XCTAssertTrue(UIPasteboard.general.hasStrings) + shot("33-copied") + back() + + // The authenticator code and its seconds left. + tap(text("Authenticator (demo)")) + let code = app.staticTexts["totpCode"] + XCTAssertTrue(code.waitForExistence(timeout: 60)) + XCTAssertNotNil(code.label.range(of: "^[0-9]{6}$", options: .regularExpression), "code: \(code.label)") + let countdown = app.descendants(matching: .any).matching(NSPredicate(format: "label ENDSWITH %@", "seconds left")).firstMatch + XCTAssertTrue(countdown.waitForExistence(timeout: 10)) + shot("34-totp") + back() + + // A new login with a generated password. + tap(app.buttons["Add an item"]) + type("Shop (demo)", into: app.textFields["Name"]) + type("https://shop.example.com", into: app.textFields["Website address"]) + type("anna.demo@example.com", into: app.textFields["User name"]) + tap(scrolledTo(app.buttons["Generate"])) + XCTAssertTrue(app.staticTexts["generated"].waitForExistence(timeout: 20)) + let generated = app.staticTexts["generated"].label + XCTAssertGreaterThanOrEqual(generated.count, 12) + shot("35-generate-in-form") + tap(app.buttons["Use this"]) + tap(scrolledTo(app.buttons["Save"])) + // The item form types its secrets as one-time codes, so iOS does not offer to keep them. + expectNoSavePasswordPrompt() + XCTAssertTrue(text("Shop (demo)").waitForExistence(timeout: 60)) + tap(text("Shop (demo)")) + tap(app.buttons["Show Password"], timeout: 60) + XCTAssertTrue(text(generated).waitForExistence(timeout: 10), "the generated password was saved") + shot("36-created") + + // Edit it. + tap(app.buttons["Edit"]) + let name = app.textFields["Name"] + XCTAssertTrue(name.waitForExistence(timeout: 20)) + // The cursor after the last character, then the old name out. + name.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5)).tap() + name.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 20)) + name.typeText("Shop account (demo)") + tap(scrolledTo(app.buttons["Save"])) + // The item form types its secrets as one-time codes, so iOS does not offer to keep them. + expectNoSavePasswordPrompt() + XCTAssertTrue(text("Shop account (demo)").waitForExistence(timeout: 60)) + shot("37-edited") + + // Trash it: gone from the list. + tap(scrolledTo(app.buttons["Move to trash"])) + // The confirmation is an action sheet up to iOS 18 and a popover from + // iOS 26 on; either way it is the "Move to trash" button the + // dialog does not cover. + let trashButtons = app.buttons.matching(identifier: "Move to trash") + expectation(for: NSPredicate(format: "count >= 2"), evaluatedWith: trashButtons) + waitForExpectations(timeout: 10) + let confirmTrash = trashButtons.allElementsBoundByIndex.first { reachable($0) } + XCTAssertNotNil(confirmTrash, "no confirmation to tap") + confirmTrash?.tap() + XCTAssertTrue(text("Webmail (demo)").waitForExistence(timeout: 60)) + waitGone(text("Shop account (demo)"), timeout: 60) + shot("38-trashed") + + // The generator. + tap(app.tabBars.buttons["Generator"]) + let value = app.staticTexts["generated"] + XCTAssertTrue(value.waitForExistence(timeout: 20)) + let first = value.label + tap(app.buttons["Generate a new value"]) + expectation(for: NSPredicate(format: "label != %@", first), evaluatedWith: value) + waitForExpectations(timeout: 10) + shot("39-generator") + + // A Send and its link. + tap(app.tabBars.buttons["Send"]) + tap(app.buttons["New Send"]) + // A vertical TextField reads as a text view on some iOS versions. + let sendText = app.descendants(matching: .any).matching( + NSPredicate(format: "(elementType == %d OR elementType == %d) AND label == %@", + XCUIElement.ElementType.textField.rawValue, XCUIElement.ElementType.textView.rawValue, "Text to send") + ).firstMatch + type("The demo door code is 2468.", into: sendText) + tap(scrolledTo(app.buttons["Create link"])) + let link = app.staticTexts["sendLink"] + XCTAssertTrue(link.waitForExistence(timeout: 60)) + XCTAssertTrue(link.label.hasPrefix(server) && link.label.contains("#"), "link: \(link.label)") + shot("40-send-link") + tap(app.buttons["Close"]) + shot("41-send-list") + + // The settings route from inside the vault, and back. Every tab has + // the account and lock buttons; the one on screen is the one to tap. + tap(onScreen(app.buttons.matching(identifier: "accounts"))) + tap(app.buttons["settings"]) + XCTAssertTrue(app.secureTextFields["newPin"].waitForExistence(timeout: 20)) + shot("42-settings") + tap(app.buttons["back"]) + + // Lock: the unlock screen again. + tap(onScreen(app.buttons.matching(identifier: "lock")), timeout: 30) + XCTAssertTrue(app.secureTextFields["masterPassword"].waitForExistence(timeout: 30)) + XCTAssertFalse(app.buttons["lock"].exists) + shot("43-locked") + } +} diff --git a/mobile/ios/Shared/AutofillModel.swift b/mobile/ios/Shared/AutofillModel.swift new file mode 100644 index 000000000..11e5d9ecf --- /dev/null +++ b/mobile/ios/Shared/AutofillModel.swift @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import Foundation +import KeepiqShared +import UIKit +import UniformTypeIdentifiers + +/// A localized string of the AutoFill screens (Autofill.strings). +func AL(_ key: String, _ args: CVarArg...) -> String { + let format = NSLocalizedString(key, tableName: "Autofill", bundle: .main, comment: "") + return args.isEmpty ? format : String(format: format, arguments: args) +} + +/// The vault keys the extension opened, kept in its process for the idle +/// time, so a second fill soon after needs no new unlock +/// (provideCredentialWithoutUserInteraction, design D6). +enum ExtensionSession { + private static var keys: VaultKeys? + private static var accountId: String? + private static var until = Date.distantPast + + static func keep(_ keys: VaultKeys, accountId: String, minutes: Int) { + self.keys = keys + self.accountId = accountId + until = Date().addingTimeInterval(Double(minutes) * 60) + } + + static func current(_ accountId: String) -> VaultKeys? { + guard self.accountId == accountId, Date() < until else { + keys = nil + return nil + } + return keys + } + + static func forget() { + keys = nil + accountId = nil + } +} + +/// One login the AutoFill list shows: its name and site, never a password. +struct AutofillRow: Identifiable, Hashable { + let id: String + let name: String + let site: String + let serviceIdentifier: String +} + +/// A filled login. +struct FilledLogin { + let user: String + let password: String +} + +/// A passkey request the extension answers (task 5.2). iOS hands over the +/// rpId it verified and the hash of the clientDataJSON it built. +enum PasskeyRequest { + case assertion(rpId: String, clientDataHash: Data, credentialId: Data?, allowed: [Data]) + case registration(rpId: String, userName: String, userHandle: Data, clientDataHash: Data, algorithms: [Int]) + + var rpId: String { + switch self { + case .assertion(let rpId, _, _, _), .registration(let rpId, _, _, _, _): return rpId + } + } +} + +/// What the extension hands back to iOS for a passkey request. +enum PasskeyResult { + case assertion(AppleAssertion, rpId: String, clientDataHash: Data) + case registration(AppleRegistration, rpId: String, clientDataHash: Data, userHandle: Data) +} + +/// One passkey the sheet lists: its item name and user name, never a key. +struct PasskeyRow: Identifiable, Hashable { + let id: String + let name: String + let user: String +} + +/// The AutoFill extension's state (task 4.4): the unlock, the logins for the +/// asked sites, search over the other sites one file at a time, "Add login", +/// and the fill with the one-time code on the clipboard (task 4.5). For a +/// passkey request (task 5.2) the same unlock, then the passkey signs or is +/// created with the shared core's ES256. +@MainActor +final class AutofillModel: ObservableObject { + enum Phase: Equatable { case noAccount, locked, list, configuration, working } + + @Published private(set) var phase: Phase = .locked + @Published private(set) var rows: [AutofillRow] = [] + @Published var query = "" + @Published var message: String? + @Published private(set) var busy = false + @Published private(set) var passkeyRows: [PasskeyRow] = [] + + let client: KeepiqClient + let biometric = BiometricKeychain() + let accountId: String? + let serviceIdentifiers: [String] + private let record: String? + private let onFill: (FilledLogin) -> Void + let passkey: PasskeyRequest? + private let onPasskey: (PasskeyResult) -> Void + private var passkeyChoices: [PasskeyChoice] = [] + private var keys: VaultKeys? + private var gate: UnlockGate? + + init( + serviceIdentifiers: [String], + record: String? = nil, + configuration: Bool = false, + passkey: PasskeyRequest? = nil, + onPasskey: @escaping (PasskeyResult) -> Void = { _ in }, + onFill: @escaping (FilledLogin) -> Void + ) { + client = KeepiqClientKt.doNewKeepiqClient(storage: KeychainStorage(), clientName: "Keepiq AutoFill for iOS") + accountId = client.accounts.activeId() + self.serviceIdentifiers = serviceIdentifiers + self.record = record + self.onFill = onFill + self.passkey = passkey + self.onPasskey = onPasskey + if accountId == nil { + phase = .noAccount + } else if configuration { + phase = .configuration + } else if let accountId, let kept = ExtensionSession.current(accountId) { + keys = kept + phase = .list + afterUnlock() + } + } + + var hasPin: Bool { accountId.map { client.pins.has(accountId: $0) } ?? false } + var hasBiometric: Bool { accountId.map { biometric.isEnabled($0) } ?? false } + var site: String { serviceIdentifiers.first.map { AutofillSites.shared.hostOf(serviceIdentifier: $0) } ?? "" } + + // MARK: Unlock (design D4: the app's unlock, in the extension) + + func unlock(masterPassword: String) { + open { accountId, suite in try await self.client.unlockWithMasterPassword(accountId: accountId, suite: suite, masterPassword: masterPassword) } + } + + func unlock(pin: String) { + open { accountId, suite in try await self.client.unlockWithPin(accountId: accountId, suite: suite, pin: pin) } + } + + func unlockWithBiometric() { + open { accountId, suite in + let key = try await self.biometric.read(accountId) + return try await self.client.unlockWithKeyBase64(accountId: accountId, suite: suite, unlockKey: key) + } + } + + private func open(_ unlock: @escaping (String, Suite) async throws -> UnlockedVault) { + guard let accountId else { return } + busy = true + message = nil + Task { + defer { self.busy = false } + do { + let gate: UnlockGate + if let known = self.gate { + gate = known + } else { + gate = try await self.client.unlockGate(accountId: accountId) + } + self.gate = gate + if let blocked = gate as? UnlockGate.Blocked { + self.message = blocked.message + return + } + guard let ready = gate as? UnlockGate.Ready else { return } + let vault = try await unlock(accountId, ready.suite) + let keys = try AutofillSites.shared.keysOf(vault: vault) + vault.lock() + self.keys = keys + ExtensionSession.keep(keys, accountId: accountId, minutes: Int(self.client.accounts.settings(id: accountId).idleMinutes)) + self.phase = .list + self.afterUnlock() + } catch { + self.message = Self.text(error) + } + } + } + + private func afterUnlock() { + if passkey != nil { + answerPasskey() + return + } + if let record, let filled = fill(record: record) { + finish(filled) + return + } + load() + } + + // MARK: The list + + /// The logins for the asked sites, from their files only. + func load() { + guard let accountId else { return } + var seen = Set() + var out: [AutofillRow] = [] + for identifier in serviceIdentifiers { + let siteKey = AutofillSites.shared.siteKey(serviceIdentifier: identifier) + guard let json = AutofillFiles.shared.read(accountId: accountId, site: siteKey) else { continue } + for entry in AutofillSites.shared.matches(siteJson: json, serviceIdentifier: identifier) where seen.insert(entry.id).inserted { + out.append(AutofillRow(id: entry.id, name: entry.name, site: siteKey, serviceIdentifier: identifier)) + } + } + rows = out + } + + /// "Pick another login": every site file in turn, keeping only the matches. + func search() { + guard let accountId else { return } + let needle = query.trimmingCharacters(in: .whitespaces) + if needle.isEmpty { load(); return } + var out: [AutofillRow] = [] + for site in AutofillFiles.shared.sites(accountId: accountId) { + guard let json = AutofillFiles.shared.read(accountId: accountId, site: site) else { continue } + for entry in AutofillSites.shared.search(siteJson: json, query: needle) { + out.append(AutofillRow(id: entry.id, name: entry.name, site: site, serviceIdentifier: entry.url ?? site)) + } + if out.count >= 50 { break } + } + rows = out + } + + func choose(_ row: AutofillRow) { + guard let filled = fill(itemId: row.id, site: row.site, serviceIdentifier: row.serviceIdentifier) else { + message = AL("fill.failed") + return + } + finish(filled) + } + + /// The login of a record from the identity store, decrypted. + func fill(record: String) -> FilledLogin? { + guard let accountId, let itemId = AutofillSites.shared.itemId(recordIdentifier: record, accountId: accountId) else { return nil } + for identifier in serviceIdentifiers { + if let filled = fill(itemId: itemId, site: AutofillSites.shared.siteKey(serviceIdentifier: identifier), serviceIdentifier: identifier) { + return filled + } + } + return nil + } + + private func fill(itemId: String, site: String, serviceIdentifier: String) -> FilledLogin? { + guard let accountId, let keys, + let json = AutofillFiles.shared.read(accountId: accountId, site: site), + let entry = AutofillIndex.companion.fromJson(text: json).entries.first(where: { $0.id == itemId }) else { return nil } + guard let user = try? AutofillSites.shared.decrypt(keys: keys, ciphertext: entry.login), + let password = try? AutofillSites.shared.decrypt(keys: keys, ciphertext: entry.key) else { return nil } + copyCode(json: json, serviceIdentifier: serviceIdentifier, keys: keys) + return FilledLogin(user: user, password: password) + } + + /// iOS 17 (task 4.5): the current code of the site's authenticator item + /// on the clipboard, local only, gone after 60 seconds. + private func copyCode(json: String, serviceIdentifier: String, keys: VaultKeys) { + let now = Int64(Date().timeIntervalSince1970 * 1000) + guard let entry = AutofillSites.shared.codeItems(siteJson: json, serviceIdentifier: serviceIdentifier).first, + let code = AutofillSites.shared.code(keys: keys, entry: entry, nowMillis: now) else { return } + UIPasteboard.general.setItems( + [[UTType.plainText.identifier: code]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(60)] + ) + } + + private func finish(_ filled: FilledLogin) { + phase = .working + onFill(filled) + } + + // MARK: Passkeys (task 5.2) + + /// After the unlock: sign at once when one passkey fits, else list them; or create one. + private func answerPasskey() { + guard let accountId, let keys, let passkey else { return } + switch passkey { + case .assertion(let rpId, _, let credentialId, let allowed): + let json = AutofillFiles.shared.read(accountId: accountId, site: ApplePasskeys.shared.siteKey(rpId: rpId)) ?? "[]" + let allow = credentialId.map { [$0.base64EncodedString()] } ?? allowed.map { $0.base64EncodedString() } + passkeyChoices = ApplePasskeys.shared.choices(siteJson: json, rpId: rpId, keys: keys, allowed: allow) + passkeyRows = passkeyChoices.map { PasskeyRow(id: $0.itemId, name: $0.name, user: $0.label) } + if passkeyChoices.count == 1 { sign(passkeyChoices[0]) } + case .registration(let rpId, let userName, let userHandle, let hash, let algorithms): + register(accountId: accountId, keys: keys, rpId: rpId, userName: userName, userHandle: userHandle, hash: hash, algorithms: algorithms) + } + } + + func choosePasskey(_ row: PasskeyRow) { + guard let choice = passkeyChoices.first(where: { $0.itemId == row.id }) else { return } + sign(choice) + } + + private func sign(_ choice: PasskeyChoice) { + guard let accountId, let keys, let account = client.accounts.account(id: accountId), + case .assertion(let rpId, let hash, _, _) = passkey else { return } + busy = true + message = nil + phase = .working + Task { + defer { self.busy = false } + do { + let api = try self.client.api(account: account) + let signed = try await ApplePasskeys.shared.signIn(api: api, keys: keys, choice: choice, clientDataHash: hash.base64EncodedString(), rpId: rpId) + self.onPasskey(.assertion(signed, rpId: rpId, clientDataHash: hash)) + } catch { + self.message = AL("passkey.failed") + " " + Self.text(error) + self.phase = .list + } + } + } + + private func register(accountId: String, keys: VaultKeys, rpId: String, userName: String, userHandle: Data, hash: Data, algorithms: [Int]) { + guard let account = client.accounts.account(id: accountId) else { return } + busy = true + message = nil + phase = .working + Task { + defer { self.busy = false } + do { + let api = try self.client.api(account: account) + let json = AutofillFiles.shared.read(accountId: accountId, site: ApplePasskeys.shared.siteKey(rpId: rpId)) ?? "[]" + let made = try await ApplePasskeys.shared.register( + api: api, + keys: keys, + siteJson: json, + rpId: rpId, + userName: userName, + userHandle: userHandle.base64EncodedString(), + clientDataHash: hash.base64EncodedString(), + algorithms: algorithms.map { KotlinLong(value: Int64($0)) }, + nowMillis: Int64(Date().timeIntervalSince1970 * 1000) + ) + self.onPasskey(.registration(made, rpId: rpId, clientDataHash: hash, userHandle: userHandle)) + } catch { + self.message = AL("passkey.saveFailed") + " " + Self.text(error) + self.phase = .list + } + } + } + + // MARK: Add login (iOS has no save hook for a third-party provider) + + func addLogin(user: String, password: String) { + guard let accountId, let keys, let account = client.accounts.account(id: accountId) else { return } + let host = site + busy = true + message = nil + Task { + defer { self.busy = false } + do { + let api = try self.client.api(account: account) + let json = AutofillFiles.shared.read(accountId: accountId, site: AutofillSites.shared.siteKey(serviceIdentifier: host)) ?? "[]" + let result = try await AutofillSaver(api: api, keys: keys).save( + target: AutofillTarget.Web(host: host), + index: AutofillIndex.companion.fromJson(text: json), + login: user, + password: password, + appLabel: nil, + webScheme: nil + ) + if result == SaveResult.refused { + self.message = AL("add.refused") + } else { + self.finish(FilledLogin(user: user, password: password)) + } + } catch { + self.message = Self.text(error) + } + } + } + + static func text(_ error: Error) -> String { + if let kotlin = (error as NSError).userInfo["KotlinException"] as? KotlinThrowable, let message = kotlin.message { + return message + } + return error.localizedDescription + } +} diff --git a/mobile/ios/Shared/AutofillStore.swift b/mobile/ios/Shared/AutofillStore.swift new file mode 100644 index 000000000..95198b0f0 --- /dev/null +++ b/mobile/ios/Shared/AutofillStore.swift @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import AuthenticationServices +import Combine +import CryptoKit +import Foundation +import KeepiqShared + +/// Whether Keepiq is the AutoFill provider. UI tests that show the +/// extension's screens inside the app count as on, since a simulator test +/// cannot switch the provider on in Settings. +enum AutofillState { + static func isEnabled() async -> Bool { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("-keepiq-autofill-preview") { return true } + #endif + return await withCheckedContinuation { continuation in + ASCredentialIdentityStore.shared.getState { state in continuation.resume(returning: state.isEnabled) } + } + } +} + +/// The per-site index files the AutoFill extension reads (task 4.4): one +/// small file per site in the shared container, sealed with AES-GCM under +/// a key in the shared Keychain, so the extension reads only the sites it +/// is asked about and never the whole vault. They hold names, addresses +/// and the item ciphertext the server sent, never a plaintext secret. +final class AutofillFiles { + static let shared = AutofillFiles() + private let storage = KeychainStorage() + private let keyName = "autofill:files-key" + + /// True when the files are in the shared container, so the extension + /// reads them. A build without the app group entitlement (an unsigned + /// simulator build) keeps them in the app's own Application Support: + /// the app's in-app preview still works, the extension sees nothing. + var isShared: Bool { SharedGroup.container != nil } + + private var dir: URL? { + let base = SharedGroup.container + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + guard let base else { return nil } + let url = base.appendingPathComponent("autofill", isDirectory: true) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func key() -> SymmetricKey { + if let text = storage.read(key: keyName), let data = Data(base64Encoded: text) { return SymmetricKey(data: data) } + let key = SymmetricKey(size: .bits256) + storage.write(key: keyName, value: key.withUnsafeBytes { Data($0) }.base64EncodedString()) + return key + } + + private static func hex(_ text: String) -> String { Data(text.utf8).map { String(format: "%02x", $0) }.joined() } + + private static func unhex(_ text: String) -> String? { + var bytes = [UInt8]() + var index = text.startIndex + while index < text.endIndex { + let next = text.index(index, offsetBy: 2, limitedBy: text.endIndex) ?? text.endIndex + guard let byte = UInt8(text[index.. String { Self.hex(accountId) + "-" } + + /// Replaces the files of an account with [files] (site key to entries JSON). + func write(accountId: String, files: [String: String]) { + clear(accountId: accountId) + guard let dir else { return } + let key = key() + for (site, json) in files { + guard let sealed = try? AES.GCM.seal(Data(json.utf8), using: key).combined else { continue } + let url = dir.appendingPathComponent(prefix(accountId) + Self.hex(site) + ".bin") + try? sealed.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + } + + /// The entries JSON of one site, or nil. + func read(accountId: String, site: String) -> String? { + guard let dir, !site.isEmpty else { return nil } + let url = dir.appendingPathComponent(prefix(accountId) + Self.hex(site) + ".bin") + guard let data = try? Data(contentsOf: url), + let box = try? AES.GCM.SealedBox(combined: data), + let plain = try? AES.GCM.open(box, using: key()) else { return nil } + return String(data: plain, encoding: .utf8) + } + + /// The site keys an account has files for. + func sites(accountId: String) -> [String] { + guard let dir, let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return [] } + let p = prefix(accountId) + return names.filter { $0.hasPrefix(p) && $0.hasSuffix(".bin") } + .compactMap { Self.unhex(String($0.dropFirst(p.count).dropLast(4))) } + .sorted() + } + + func clear(accountId: String) { + guard let dir, let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return } + let p = prefix(accountId) + for name in names where name.hasPrefix(p) { try? FileManager.default.removeItem(at: dir.appendingPathComponent(name)) } + } + + func clearAll() { + guard let dir else { return } + try? FileManager.default.removeItem(at: dir) + } +} + +/// The autofill index on iOS (tasks 4.4 and 4.6): the per-site files for +/// the extension, and ASCredentialIdentityStore with the site and user name +/// of each login, never a password, and each passkey's rpId, user name, +/// credential id and user handle (task 5.2), never its key. Rebuilt after every sync, cleared on +/// unpair, on a suite change and when Keepiq is not the provider. With +/// offline caching off nothing is written: the extension cannot see the +/// app's memory, so it offers nothing then. +final class IOSAutofillIndex: NSObject, AutofillIndexSink, ObservableObject { + static let shared = IOSAutofillIndex() + + /// What the last rebuild wrote, for the app's own check in UI tests. + @Published private(set) var lastIdentityCount = 0 + @Published private(set) var lastSiteCount = 0 + @Published private(set) var lastPasskeyCount = 0 + + private func publish(sites: Int, identities: Int, passkeys: Int = 0) { + DispatchQueue.main.async { + self.lastSiteCount = sites + self.lastIdentityCount = identities + self.lastPasskeyCount = passkeys + } + } + + func replace(accountId: String, index: AutofillIndex, persist: Bool, keys: VaultKeys) { + guard persist else { clear(accountId: accountId); return } + let files = AutofillSites.shared.split(index: index) + let identities = AutofillSites.shared.identities(accountId: accountId, index: index, keys: keys) + // Passkeys (task 5.2): rpId, user name, credential id and user handle, never the key. + let passkeys = ApplePasskeys.shared.identities(accountId: accountId, index: index, keys: keys) + AutofillFiles.shared.write(accountId: accountId, files: files) + publish(sites: files.count, identities: identities.count, passkeys: passkeys.count) + var store: [ASCredentialIdentity] = identities.map { + ASPasswordCredentialIdentity( + serviceIdentifier: ASCredentialServiceIdentifier(identifier: $0.host, type: .domain), + user: $0.user, + recordIdentifier: $0.recordIdentifier + ) + } + store += passkeys.compactMap { p -> ASCredentialIdentity? in + guard let credentialId = Data(base64Encoded: p.credentialId) else { return nil } + return ASPasskeyCredentialIdentity( + relyingPartyIdentifier: p.rpId, + userName: p.userName, + credentialID: credentialId, + userHandle: Data(base64Encoded: p.userHandle) ?? Data(), + recordIdentifier: p.recordIdentifier + ) + } + Task { + guard await AutofillState.isEnabled() else { + AutofillFiles.shared.clear(accountId: accountId) + return + } + ASCredentialIdentityStore.shared.replaceCredentialIdentities(store) { _, _ in } + } + } + + func clear(accountId: String) { + AutofillFiles.shared.clear(accountId: accountId) + publish(sites: 0, identities: 0) + ASCredentialIdentityStore.shared.removeAllCredentialIdentities { _, _ in } + } +} diff --git a/mobile/ios/Shared/AutofillViews.swift b/mobile/ios/Shared/AutofillViews.swift new file mode 100644 index 000000000..1b9c53343 --- /dev/null +++ b/mobile/ios/Shared/AutofillViews.swift @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import SwiftUI + +/// The AutoFill sheet (task 4.4): unlock, then the logins for the site, a +/// search over the vault and "Add login". +struct AutofillRootView: View { + @ObservedObject var model: AutofillModel + let onCancel: () -> Void + + var body: some View { + NavigationStack { + Group { + switch model.phase { + case .noAccount: Text(AL("noAccount")).padding().accessibilityIdentifier("autofillNoAccount") + case .configuration: Text(AL("configured")).padding().accessibilityIdentifier("autofillConfigured") + case .locked: AutofillUnlockView(model: model) + case .list: + if model.passkey != nil { PasskeyListView(model: model) } else { AutofillListView(model: model) } + case .working: ProgressView().accessibilityIdentifier("autofillBusy") + } + } + .navigationTitle("Keepiq") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(AL("cancel"), action: onCancel).accessibilityIdentifier("autofillCancel") + } + } + } + } +} + +struct AutofillUnlockView: View { + @ObservedObject var model: AutofillModel + @State private var masterPassword = "" + @State private var pin = "" + @FocusState private var masterFocused: Bool + + var body: some View { + Form { + Section { + Text(AL("unlock.title")).font(.headline).accessibilityAddTraits(.isHeader) + if !model.site.isEmpty { Text(AL(model.passkey == nil ? "unlock.site" : "unlock.passkey", model.site)) } + } + if model.hasBiometric { + Button(AL("unlock.biometric")) { model.unlockWithBiometric() }.accessibilityIdentifier("autofillBiometric") + } + if model.hasPin { + Section { + SecureField(AL("unlock.pin"), text: $pin) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .accessibilityIdentifier("autofillPin") + Button(AL("unlock.button")) { model.unlock(pin: pin) } + .disabled(pin.isEmpty || model.busy) + .accessibilityIdentifier("autofillUnlockPin") + } + } + Section { + // Keepiq's own secrets are typed as one-time codes, not passwords, so iOS + // never offers to save them in another password manager. + SecureField(AL("unlock.master"), text: $masterPassword) + .textContentType(.oneTimeCode) + .focused($masterFocused) + .accessibilityIdentifier("autofillMasterPassword") + Button(AL("unlock.button")) { model.unlock(masterPassword: masterPassword) } + .disabled(masterPassword.isEmpty || model.busy) + .accessibilityIdentifier("autofillUnlock") + } + if model.busy { ProgressView() } + if let message = model.message { + Text(message).foregroundStyle(.red).accessibilityIdentifier("autofillMessage") + } + } + // Without biometrics or a PIN, the master password is the way in: start there. + .onAppear { if !model.hasBiometric && !model.hasPin { masterFocused = true } } + } +} + +struct AutofillListView: View { + @ObservedObject var model: AutofillModel + @State private var adding = false + @State private var user = "" + @State private var password = "" + + var body: some View { + List { + Section { + TextField(AL("search"), text: $model.query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { model.search() } + .accessibilityIdentifier("autofillSearch") + } + Section(model.query.isEmpty ? AL("list.forSite", model.site) : AL("list.results")) { + if model.rows.isEmpty { + Text(model.query.isEmpty ? AL("list.none", model.site) : AL("list.noMatch")) + .accessibilityIdentifier("autofillEmpty") + } + ForEach(model.rows) { row in + Button { model.choose(row) } label: { + VStack(alignment: .leading) { + Text(row.name) + Text(row.site).font(.caption).foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("autofillRow") + } + } + Section { + if adding { + // No .username or .newPassword: those make iOS offer its own strong + // password and save prompt over the sheet. Keepiq keeps the login itself. + TextField(AL("add.user"), text: $user) + .textInputAutocapitalization(.never) + .accessibilityIdentifier("autofillAddUser") + SecureField(AL("add.password"), text: $password) + .textContentType(.oneTimeCode) + .accessibilityIdentifier("autofillAddPassword") + Button(AL("add.save")) { model.addLogin(user: user, password: password) } + .disabled(password.isEmpty || model.busy) + .accessibilityIdentifier("autofillAddSave") + } else { + Button(AL("add.title", model.site)) { adding = true }.accessibilityIdentifier("autofillAdd") + } + } + if let message = model.message { + Text(message).foregroundStyle(.red).accessibilityIdentifier("autofillMessage") + } + } + } +} + +/// The passkeys for the site (task 5.2), when more than one fits or none +/// does. One that fits signs at once, without this list. +struct PasskeyListView: View { + @ObservedObject var model: AutofillModel + + var body: some View { + List { + Section(AL("passkey.list", model.site)) { + if model.passkeyRows.isEmpty { + Text(AL("passkey.none", model.site)).accessibilityIdentifier("passkeyEmpty") + } + ForEach(model.passkeyRows) { row in + Button { model.choosePasskey(row) } label: { + VStack(alignment: .leading) { + Text(row.user) + Text(row.name).font(.caption).foregroundStyle(.secondary) + } + } + .accessibilityIdentifier("passkeyRow") + } + } + if let message = model.message { + Text(message).foregroundStyle(.red).accessibilityIdentifier("autofillMessage") + } + } + } +} diff --git a/mobile/ios/Shared/Keychain.swift b/mobile/ios/Shared/Keychain.swift new file mode 100644 index 000000000..14657c585 --- /dev/null +++ b/mobile/ios/Shared/Keychain.swift @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import Foundation +import KeepiqShared +import LocalAuthentication +import Security + +/// The app group the app and its AutoFill extension share (design D4: one +/// unlock for both). Its id is also their shared Keychain access group. +enum SharedGroup { + static let id = "group.nl.conduction.keepiq" + + /// The shared Keychain access group, or nil in a build without the + /// entitlement (the unsigned compile check in CI): items then stay in + /// the app's own group and the extension cannot read them. + static let keychainGroup: String? = { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "nl.conduction.keepiq.probe", + kSecAttrAccount as String: "probe", + kSecAttrAccessGroup as String: id, + ] + SecItemDelete(base as CFDictionary) + var add = base + add[kSecValueData as String] = Data([1]) + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(add as CFDictionary, nil) + SecItemDelete(base as CFDictionary) + return status == errSecSuccess ? id : nil + }() + + /// Adds the shared access group to a Keychain query when there is one. + static func scoped(_ query: [String: Any]) -> [String: Any] { + guard let group = keychainGroup else { return query } + var q = query + q[kSecAttrAccessGroup as String] = group + return q + } + + /// The shared container, or nil without the app group entitlement. + static var container: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: id) } +} + +/// The shared core's `SecureStorage` on iOS (design D4): one Keychain item +/// per key, readable after the first unlock of the phone and never restored +/// onto another device. App and AutoFill extension share the items. +final class KeychainStorage: NSObject, SecureStorage { + static let service = "nl.conduction.keepiq.storage" + + private func query(_ key: String) -> [String: Any] { + SharedGroup.scoped([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: key, + ]) + } + + func read(key: String) -> String? { + var q = query(key) + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + var out: CFTypeRef? + guard SecItemCopyMatching(q as CFDictionary, &out) == errSecSuccess, let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func write(key: String, value: String) { + let data = Data(value.utf8) + let update: [String: Any] = [kSecValueData as String: data] + if SecItemUpdate(query(key) as CFDictionary, update as CFDictionary) == errSecItemNotFound { + var add = query(key) + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + SecItemAdd(add as CFDictionary, nil) + } + } + + func delete(key: String) { + SecItemDelete(query(key) as CFDictionary) + } + + /// Deletes every item of the app, for a clean start in the UI tests. + static func deleteAll() { + for service in [service, BiometricKeychain.service] { + SecItemDelete(SharedGroup.scoped([kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]) as CFDictionary) + } + } +} + +enum BiometricError: LocalizedError { + case unavailable + case cancelled + case invalidated + + var errorDescription: String? { + switch self { + case .unavailable: return "Set up Face ID or Touch ID in the phone settings first." + case .cancelled: return "Biometric unlock cancelled." + case .invalidated: + return "A face or fingerprint was added to this phone. Unlock with your master password, then turn biometric unlock on again." + } + } +} + +/// Biometric unlock on iOS (design D4): the 32-byte unlock key in a Keychain +/// item with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` and +/// `.biometryCurrentSet`, so enrolling a new face or finger makes it +/// unreadable. +struct BiometricKeychain { + static let service = "nl.conduction.keepiq.biometric" + + func canUse() -> Bool { + LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + } + + private func base(_ accountId: String) -> [String: Any] { + SharedGroup.scoped([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: accountId, + ]) + } + + /// Whether a wrap exists, without showing a prompt. + func isEnabled(_ accountId: String) -> Bool { + let context = LAContext() + context.interactionNotAllowed = true + var q = base(accountId) + q[kSecUseAuthenticationContext as String] = context + let status = SecItemCopyMatching(q as CFDictionary, nil) + return status == errSecSuccess || status == errSecInteractionNotAllowed + } + + func save(_ accountId: String, unlockKey: String) throws { + guard canUse() else { throw BiometricError.unavailable } + delete(accountId) + var error: Unmanaged? + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .biometryCurrentSet, &error + ) else { throw BiometricError.unavailable } + var add = base(accountId) + add[kSecValueData as String] = Data(unlockKey.utf8) + add[kSecAttrAccessControl as String] = access + guard SecItemAdd(add as CFDictionary, nil) == errSecSuccess else { throw BiometricError.unavailable } + } + + /// The unlock key, after Face ID or Touch ID. + func read(_ accountId: String) async throws -> String { + let query = base(accountId) + return try await Task.detached { + let context = LAContext() + context.localizedReason = "Unlock Keepiq" + var q = query + q[kSecReturnData as String] = true + q[kSecMatchLimit as String] = kSecMatchLimitOne + q[kSecUseAuthenticationContext as String] = context + var out: CFTypeRef? + let status = SecItemCopyMatching(q as CFDictionary, &out) + if status == errSecUserCanceled { throw BiometricError.cancelled } + guard status == errSecSuccess, let data = out as? Data, let text = String(data: data, encoding: .utf8) else { + SecItemDelete(query as CFDictionary) + throw BiometricError.invalidated + } + return text + }.value + } + + func delete(_ accountId: String) { + SecItemDelete(base(accountId) as CFDictionary) + } +} diff --git a/mobile/ios/Shared/Resources/en.lproj/Autofill.strings b/mobile/ios/Shared/Resources/en.lproj/Autofill.strings new file mode 100644 index 000000000..d34d833bf --- /dev/null +++ b/mobile/ios/Shared/Resources/en.lproj/Autofill.strings @@ -0,0 +1,29 @@ +/* SPDX-FileCopyrightText: 2026 Conduction B.V. */ +/* SPDX-License-Identifier: EUPL-1.2 */ +/* The AutoFill extension (clients-mobile-apps task 4.4). */ +"noAccount" = "Connect an account in the Keepiq app first."; +"configured" = "Keepiq fills in your logins. Open the Keepiq app to connect an account or to change settings."; +"cancel" = "Cancel"; +"unlock.title" = "Unlock Keepiq"; +"unlock.site" = "To fill in your login for %@."; +"unlock.biometric" = "Unlock with Face ID or Touch ID"; +"unlock.pin" = "PIN"; +"unlock.master" = "Master password"; +"unlock.button" = "Unlock"; +"search" = "Search your vault"; +"list.forSite" = "Logins for %@"; +"list.results" = "Search results"; +"list.none" = "No login saved for %@."; +"list.noMatch" = "No login matches your search."; +"fill.failed" = "Keepiq could not open this login."; +"add.title" = "Add a login for %@"; +"add.user" = "User name"; +"add.password" = "Password"; +"add.save" = "Save and fill in"; +"add.refused" = "Keepiq does not save logins for this site."; +/* Passkeys (clients-mobile-apps task 5.2). */ +"unlock.passkey" = "To use your passkey for %@."; +"passkey.list" = "Passkeys for %@"; +"passkey.none" = "No passkey saved for %@."; +"passkey.failed" = "Keepiq could not sign in with this passkey."; +"passkey.saveFailed" = "Keepiq could not save this passkey."; diff --git a/mobile/ios/Shared/Resources/nl.lproj/Autofill.strings b/mobile/ios/Shared/Resources/nl.lproj/Autofill.strings new file mode 100644 index 000000000..e0d857ae0 --- /dev/null +++ b/mobile/ios/Shared/Resources/nl.lproj/Autofill.strings @@ -0,0 +1,29 @@ +/* SPDX-FileCopyrightText: 2026 Conduction B.V. */ +/* SPDX-License-Identifier: EUPL-1.2 */ +/* De AutoFill-extensie (clients-mobile-apps taak 4.4). */ +"noAccount" = "Koppel eerst een account in de Keepiq-app."; +"configured" = "Keepiq vult je logins in. Open de Keepiq-app om een account te koppelen of instellingen te wijzigen."; +"cancel" = "Annuleren"; +"unlock.title" = "Keepiq ontgrendelen"; +"unlock.site" = "Om je login voor %@ in te vullen."; +"unlock.biometric" = "Ontgrendelen met Face ID of Touch ID"; +"unlock.pin" = "Pincode"; +"unlock.master" = "Hoofdwachtwoord"; +"unlock.button" = "Ontgrendelen"; +"search" = "Zoek in je kluis"; +"list.forSite" = "Logins voor %@"; +"list.results" = "Zoekresultaten"; +"list.none" = "Geen login opgeslagen voor %@."; +"list.noMatch" = "Geen login past bij je zoekopdracht."; +"fill.failed" = "Keepiq kon deze login niet openen."; +"add.title" = "Login toevoegen voor %@"; +"add.user" = "Gebruikersnaam"; +"add.password" = "Wachtwoord"; +"add.save" = "Opslaan en invullen"; +"add.refused" = "Keepiq slaat geen logins op voor deze site."; +/* Passkeys (clients-mobile-apps taak 5.2). */ +"unlock.passkey" = "Om je passkey voor %@ te gebruiken."; +"passkey.list" = "Passkeys voor %@"; +"passkey.none" = "Geen passkey opgeslagen voor %@."; +"passkey.failed" = "Keepiq kon niet aanmelden met deze passkey."; +"passkey.saveFailed" = "Keepiq kon deze passkey niet opslaan."; diff --git a/mobile/ios/project.yml b/mobile/ios/project.yml new file mode 100644 index 000000000..ac2536a58 --- /dev/null +++ b/mobile/ios/project.yml @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# The iOS app as an XcodeGen spec (MIT licensed tool), so no generated +# .xcodeproj is kept in the tree. Build the shared framework first: +# cd mobile && ./gradlew :shared:assembleKeepiqSharedDebugXCFramework +# cd ios && xcodegen generate +# CI runs the UI tests in .github/workflows/mobile-e2e.yml. +name: Keepiq +options: + bundleIdPrefix: nl.conduction + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + # Simulator builds sign ad hoc; the release workflow (task 6.4) signs + # with the Conduction team. + CODE_SIGN_IDENTITY: "-" + CODE_SIGN_STYLE: Manual + DEVELOPMENT_TEAM: "" +targets: + Keepiq: + type: application + platform: iOS + sources: + - Keepiq + # Shared: the Keychain, the autofill index files and the AutoFill + # screens, compiled into the app and its extension. + - Shared + # The vault, Send and generator screens: the KeepiqApp package's + # sources and strings, built into the app. Keepiq/BundleModule.swift + # points their Bundle.module at the main bundle. + - KeepiqApp/Sources/KeepiqApp + entitlements: + path: Keepiq/Keepiq.entitlements + dependencies: + - target: KeepiqAutofill + embed: true + - framework: ../shared/build/XCFrameworks/debug/KeepiqShared.xcframework + embed: false + - sdk: CryptoKit.framework + - sdk: Security.framework + - sdk: LocalAuthentication.framework + - sdk: AuthenticationServices.framework + - sdk: libsqlite3.tbd + info: + path: Keepiq/Info.plist + properties: + CFBundleDisplayName: Keepiq + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + UILaunchScreen: {} + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] + NSFaceIDUsageDescription: Keepiq uses Face ID to unlock your vault. + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: nl.conduction.keepiq + GENERATE_INFOPLIST_FILE: NO + # The Kotlin runtime in the static KeepiqShared framework is C++. + OTHER_LDFLAGS: ["$(inherited)", "-lc++"] + # The AutoFill credential provider extension (clients-mobile-apps task 4.4). + KeepiqAutofill: + type: app-extension + platform: iOS + sources: [KeepiqAutofill, Shared] + entitlements: + path: KeepiqAutofill/KeepiqAutofill.entitlements + dependencies: + - framework: ../shared/build/XCFrameworks/debug/KeepiqShared.xcframework + embed: false + - sdk: CryptoKit.framework + - sdk: Security.framework + - sdk: LocalAuthentication.framework + - sdk: AuthenticationServices.framework + - sdk: libsqlite3.tbd + info: + path: KeepiqAutofill/Info.plist + properties: + CFBundleDisplayName: Keepiq + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSFaceIDUsageDescription: Keepiq uses Face ID to unlock your vault. + NSExtension: + NSExtensionPointIdentifier: com.apple.authentication-services-credential-provider-ui + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).CredentialProviderViewController + NSExtensionAttributes: + ASCredentialProviderExtensionCapabilities: + ProvidesPasswords: true + # Passkey assertion and registration (task 5.2, iOS 17). + ProvidesPasskeys: true + ShowsConfigurationUI: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: nl.conduction.keepiq.autofill + GENERATE_INFOPLIST_FILE: NO + OTHER_LDFLAGS: ["$(inherited)", "-lc++"] + APPLICATION_EXTENSION_API_ONLY: YES + KeepiqUITests: + type: bundle.ui-testing + platform: iOS + sources: [KeepiqUITests] + dependencies: + - target: Keepiq + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: nl.conduction.keepiq.uitests + GENERATE_INFOPLIST_FILE: YES +schemes: + Keepiq: + build: + targets: + Keepiq: all + KeepiqUITests: [test] + test: + config: Debug + targets: [KeepiqUITests] diff --git a/mobile/settings.gradle.kts b/mobile/settings.gradle.kts new file mode 100644 index 000000000..6bbde5486 --- /dev/null +++ b/mobile/settings.gradle.kts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "keepiq-mobile" + +// The Android parts build only where an Android SDK is installed (CI, a +// developer machine with Android Studio). Without one, `shared` still builds +// and tests on its jvm() target. -Pkeepiq.android=true forces them on. +val localSdkDir = file("local.properties").takeIf { it.isFile }?.readLines() + ?.firstOrNull { it.startsWith("sdk.dir=") }?.substringAfter("=") +val sdkDir = localSdkDir ?: System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") +val withAndroid = providers.gradleProperty("keepiq.android").orNull?.toBoolean() + ?: (sdkDir != null && file(sdkDir).isDirectory) +gradle.extensions.extraProperties["keepiq.android"] = withAndroid + +include(":shared") +if (withAndroid) { + include(":android:app") + // The "other app" of the autofill package-visibility e2e test; never released. + include(":android:otherapp") +} diff --git a/mobile/shared/build.gradle.kts b/mobile/shared/build.gradle.kts new file mode 100644 index 000000000..039a57805 --- /dev/null +++ b/mobile/shared/build.gradle.kts @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework +import java.util.Base64 + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.sqldelight) +} + +val withAndroid = gradle.extensions.extraProperties["keepiq.android"] as Boolean +if (withAndroid) { + apply(plugin = "com.android.library") + extensions.configure("android") { + namespace = "nl.conduction.keepiq.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } +} + +kotlin { + compilerOptions { + // expect/actual objects (Primitives) are Beta in Kotlin 2.2. + freeCompilerArgs.add("-Xexpect-actual-classes") + } + jvm { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + if (withAndroid) { + androidTarget { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + } + } + // mobile/ios links this as KeepiqShared.xcframework. + val xcframework = XCFramework("KeepiqShared") + // Argon2id on iOS: the reference C code, compiled per target into a + // static library that cinterop bundles into the klib (task 1.3.1). + val argon2Dir = layout.projectDirectory.dir("src/nativeInterop/argon2") + val argon2Sources = listOf("argon2.c", "core.c", "encoding.c", "ref.c", "thread.c", "blake2/blake2b.c") + val iosTargets = mapOf( + iosX64() to ("iphonesimulator" to "x86_64-apple-ios17.0-simulator"), + iosArm64() to ("iphoneos" to "arm64-apple-ios17.0"), + iosSimulatorArm64() to ("iphonesimulator" to "arm64-apple-ios17.0-simulator"), + ) + iosTargets.forEach { (target, toolchain) -> + val (sdk, triple) = toolchain + val libDir = layout.buildDirectory.dir("argon2/${target.name}").get().asFile + val buildArgon2 = tasks.register("buildArgon2${target.name.replaceFirstChar { it.uppercase() }}") { + inputs.dir(argon2Dir) + outputs.dir(libDir) + val src = argon2Dir.dir("src").asFile + val include = argon2Dir.dir("include").asFile + val compile = argon2Sources.joinToString("; ") { source -> + "xcrun --sdk $sdk clang -target $triple -O2 -DARGON2_NO_THREADS -I'$include' -I'$src' " + + "-c '$src/$source' -o '${source.substringAfterLast('/').removeSuffix(".c")}.o'" + } + commandLine("bash", "-c", "set -e; rm -rf '$libDir'; mkdir -p '$libDir'; cd '$libDir'; $compile; xcrun --sdk $sdk ar rcs libargon2.a *.o") + } + target.compilations.getByName("main").cinterops.create("argon2") { + definitionFile.set(project.file("src/nativeInterop/cinterop/argon2.def")) + includeDirs(argon2Dir.dir("include")) + extraOpts("-libraryPath", libDir.absolutePath) + } + tasks.matching { it.name == "cinteropArgon2${target.name.replaceFirstChar { c -> c.uppercase() }}" } + .configureEach { dependsOn(buildArgon2) } + target.binaries.framework { + baseName = "KeepiqShared" + isStatic = true + xcframework.add(this) + } + } + + // jvmShared holds the javax.crypto / java.security actuals that the jvm() + // test target and Android share (design D2: Android uses the platform JCA). + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyDefaultHierarchyTemplate { + common { + group("jvmShared") { + withJvm() + withAndroidTarget() + } + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.core) + } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + } + getByName("jvmSharedMain").dependencies { + implementation(libs.bouncycastle.prov) + implementation(libs.ktor.client.okhttp) + } + jvmTest.dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + if (withAndroid) { + androidMain.dependencies { + implementation(libs.sqldelight.android.driver) + implementation(libs.sqlcipher.android) + implementation(libs.androidx.sqlite) + } + } + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + implementation(libs.cryptography.core) + implementation(libs.cryptography.provider.apple) + implementation(libs.cryptography.provider.cryptokit) + } + } +} + +// The offline store (design D5). SQLCipher on Android; see openEncryptedDriver. +sqldelight { + databases { + create("KeepiqDatabase") { + packageName.set("nl.conduction.keepiq.shared.store.db") + } + } +} + +// The shared vectors in tests/vectors/crypto/ are compiled into commonTest as +// base64 constants, so every target (jvm, Android unit tests, the iOS +// simulator) reads the same bytes without file access. +val vectorsDir = layout.projectDirectory.dir("../../tests/vectors/crypto") +val generatedVectors = layout.buildDirectory.dir("generated/vectors/kotlin") +val generateCryptoVectors by tasks.registering { + inputs.dir(vectorsDir).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.dir(generatedVectors) + val sourceDir = vectorsDir.asFile + val outDir = generatedVectors.get().asFile + doLast { + val names = listOf("envelope", "fields", "send", "totp", "passkey") + val body = StringBuilder() + body.append("// Generated from tests/vectors/crypto by :shared:generateCryptoVectors. Do not edit.\n") + body.append("package nl.conduction.keepiq.shared.vectors\n\n") + body.append("internal object GeneratedVectors {\n") + for (name in names) { + val encoded = Base64.getEncoder().encodeToString(File(sourceDir, "$name.json").readBytes()) + val parts = encoded.chunked(16_000).joinToString(",\n ") { "\"$it\"" } + body.append(" val $name: List = listOf(\n $parts,\n )\n") + } + body.append("}\n") + val target = File(outDir, "nl/conduction/keepiq/shared/vectors/GeneratedVectors.kt") + target.parentFile.mkdirs() + target.writeText(body.toString()) + } +} +kotlin.sourceSets.commonTest { + kotlin.srcDir(generateCryptoVectors) +} + +// The passphrase word list is the web generator's own (src/generator/ +// eff-large-wordlist.js), compiled into commonMain, so the two generators +// can never draw from different lists. +val wordlistSource = layout.projectDirectory.file("../../src/generator/eff-large-wordlist.js") +val generatedWordlist = layout.buildDirectory.dir("generated/wordlist/kotlin") +val generateWordlist by tasks.registering { + inputs.file(wordlistSource).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.dir(generatedWordlist) + val sourceFile = wordlistSource.asFile + val outDir = generatedWordlist.get().asFile + doLast { + val js = sourceFile.readText() + val list = js.substringAfter("Object.freeze(").substringBefore(".split(' ')") + val words = Regex("'([^']*)'").findAll(list).joinToString("") { it.groupValues[1] }.split(' ') + check(words.size == 7776 && words.toSet().size == 7776) { "The EFF word list has ${words.size} words, expected 7776" } + val parts = words.joinToString(" ").chunked(8_000).joinToString(",\n ") { "\"$it\"" } + val body = StringBuilder() + body.append("// Generated from src/generator/eff-large-wordlist.js by :shared:generateWordlist. Do not edit.\n") + body.append("// The EFF large word list, by the Electronic Frontier Foundation, CC BY 3.0 US.\n") + body.append("package nl.conduction.keepiq.shared.generator\n\n") + body.append("internal object EffWordlist {\n") + body.append(" val words: List by lazy {\n listOf(\n $parts,\n ).joinToString(\"\").split(' ')\n }\n") + body.append("}\n") + val target = File(outDir, "nl/conduction/keepiq/shared/generator/EffWordlist.kt") + target.parentFile.mkdirs() + target.writeText(body.toString()) + } +} +kotlin.sourceSets.commonMain { + kotlin.srcDir(generateWordlist) +} + +// The generator cases in tests/vectors/generator/ are compiled into +// commonTest the same way as the crypto vectors. +val generatorVectorsFile = layout.projectDirectory.file("../../tests/vectors/generator/cases.json") +val generatedGeneratorVectors = layout.buildDirectory.dir("generated/generator-vectors/kotlin") +val generateGeneratorVectors by tasks.registering { + inputs.file(generatorVectorsFile).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.dir(generatedGeneratorVectors) + val sourceFile = generatorVectorsFile.asFile + val outDir = generatedGeneratorVectors.get().asFile + doLast { + val encoded = Base64.getEncoder().encodeToString(sourceFile.readBytes()) + val parts = encoded.chunked(16_000).joinToString(",\n ") { "\"$it\"" } + val target = File(outDir, "nl/conduction/keepiq/shared/vectors/GeneratedGeneratorVectors.kt") + target.parentFile.mkdirs() + target.writeText( + "// Generated from tests/vectors/generator by :shared:generateGeneratorVectors. Do not edit.\n" + + "package nl.conduction.keepiq.shared.vectors\n\n" + + "internal object GeneratedGeneratorVectors {\n val cases: List = listOf(\n $parts,\n )\n}\n", + ) + } +} +kotlin.sourceSets.commonTest { + kotlin.srcDir(generateGeneratorVectors) +} + +// The autofill cases in tests/vectors/autofill/ (the browser extension's +// site matching and save rules) are compiled into commonTest the same way. +val autofillVectorsFile = layout.projectDirectory.file("../../tests/vectors/autofill/cases.json") +val generatedAutofillVectors = layout.buildDirectory.dir("generated/autofill-vectors/kotlin") +val generateAutofillVectors by tasks.registering { + inputs.file(autofillVectorsFile).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.dir(generatedAutofillVectors) + val sourceFile = autofillVectorsFile.asFile + val outDir = generatedAutofillVectors.get().asFile + doLast { + val encoded = Base64.getEncoder().encodeToString(sourceFile.readBytes()) + val parts = encoded.chunked(16_000).joinToString(",\n ") { "\"$it\"" } + val target = File(outDir, "nl/conduction/keepiq/shared/vectors/GeneratedAutofillVectors.kt") + target.parentFile.mkdirs() + target.writeText( + "// Generated from tests/vectors/autofill by :shared:generateAutofillVectors. Do not edit.\n" + + "package nl.conduction.keepiq.shared.vectors\n\n" + + "internal object GeneratedAutofillVectors {\n val cases: List = listOf(\n $parts,\n )\n}\n", + ) + } +} +kotlin.sourceSets.commonTest { + kotlin.srcDir(generateAutofillVectors) +} + +// The jvm tests write ciphertext produced by this core for the vector inputs. +// tests/vitest/crypto-vectors-kotlin.spec.js opens it with the web modules. +// -Pkeepiq.writeKotlinVectors=true writes the committed copy instead. +val kotlinVectorsOut = if (providers.gradleProperty("keepiq.writeKotlinVectors").orNull == "true") { + vectorsDir.file("kotlin-output.json").asFile +} else { + layout.buildDirectory.file("vectors/kotlin-output.json").get().asFile +} +tasks.named("jvmTest") { + systemProperty("keepiq.kotlinVectorsOut", kotlinVectorsOut.absolutePath) + // LiveServerTest: the mobile e2e workflow points it at the test server + // and its self-signed certificate. Without these it is skipped. + for (name in listOf("keepiq.liveServer", "keepiq.liveMasterPassword", "javax.net.ssl.trustStore", "javax.net.ssl.trustStorePassword")) { + providers.gradleProperty(name).orNull?.let { systemProperty(name, it) } + } + outputs.upToDateWhen { false } +} diff --git a/mobile/shared/src/androidMain/kotlin/nl/conduction/keepiq/shared/store/EncryptedDriver.android.kt b/mobile/shared/src/androidMain/kotlin/nl/conduction/keepiq/shared/store/EncryptedDriver.android.kt new file mode 100644 index 000000000..2332910a1 --- /dev/null +++ b/mobile/shared/src/androidMain/kotlin/nl/conduction/keepiq/shared/store/EncryptedDriver.android.kt @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.store + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.store.db.KeepiqDatabase +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Opens the SQLCipher store of one account. Its key is 32 random bytes, + * wrapped with an AndroidKeyStore AES key that never leaves the device and + * needs no user authentication (design D5). The wrapped key sits in app + * preferences that are never backed up, so a copied database file cannot be opened + * elsewhere. + * + * Runs only on Android: the jvm tests cover the store logic over plain + * SQLite (VaultStoreTest), and CI compiles this. + */ +fun openEncryptedDriver(context: Context, accountId: String): SqlDriver { + System.loadLibrary("sqlcipher") + val key = DeviceBoundDatabaseKey(context).keyFor(accountId) + return AndroidSqliteDriver( + schema = KeepiqDatabase.Schema, + context = context, + name = databaseName(accountId), + factory = SupportOpenHelperFactory(key), + ) +} + +/** Deletes the store of an account, on unpair. */ +fun deleteEncryptedStore(context: Context, accountId: String) { + context.deleteDatabase(databaseName(accountId)) + DeviceBoundDatabaseKey(context).forget(accountId) +} + +private fun databaseName(accountId: String) = "keepiq-vault-${Encoding.hex(Encoding.utf8(accountId))}.db" + +private class DeviceBoundDatabaseKey(context: Context) { + // The app sets android:allowBackup="false", so these never leave the device. + private val prefs = context.getSharedPreferences("keepiq-store-keys", Context.MODE_PRIVATE) + + fun keyFor(accountId: String): ByteArray { + val stored = prefs.getString(accountId, null) + if (stored != null) return unwrap(Encoding.fromBase64(stored)) + val key = ByteArray(32).also { SecureRandom().nextBytes(it) } + prefs.edit().putString(accountId, Encoding.toBase64(wrap(key))).apply() + return key + } + + fun forget(accountId: String) { + prefs.edit().remove(accountId).apply() + } + + private fun wrap(key: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keystoreKey()) + return cipher.iv + cipher.doFinal(key) + } + + private fun unwrap(blob: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, keystoreKey(), GCMParameterSpec(128, blob, 0, 12)) + return cipher.doFinal(blob, 12, blob.size - 12) + } + + private fun keystoreKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + generator.init( + KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return generator.generateKey() + } + + private companion object { + const val ALIAS = "keepiq-store-key" + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqClient.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqClient.kt new file mode 100644 index 000000000..7b54f8204 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqClient.kt @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.account.AccountLimitException +import nl.conduction.keepiq.shared.account.AccountStore +import nl.conduction.keepiq.shared.account.SecureStorage +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.InsecureServerException +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.KeepiqApiException +import nl.conduction.keepiq.shared.api.Suite +import nl.conduction.keepiq.shared.api.platformHttpEngine +import nl.conduction.keepiq.shared.pairing.LoginFlowCredentials +import nl.conduction.keepiq.shared.pairing.LoginFlowStart +import nl.conduction.keepiq.shared.pairing.LoginFlowV2 +import nl.conduction.keepiq.shared.pairing.ServerAddress +import nl.conduction.keepiq.shared.unlock.PinUnlock +import nl.conduction.keepiq.shared.unlock.StaleUnlockKeyException +import nl.conduction.keepiq.shared.unlock.UnlockedVault +import nl.conduction.keepiq.shared.unlock.VaultUnlock +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** Pairing failed. The message says what to do, in the extension's words (router.js pairingProblem). */ +class PairingException(message: String, val status: Int = 0) : Exception(message) + +/** What the unlock screen can offer for an account. */ +sealed class UnlockGate { + /** The master password, and the biometric or PIN unlock when set up. */ + data class Ready(val suite: Suite, val offline: Boolean) : UnlockGate() + + /** The server withholds the key: say why, offer no unlock field. */ + data class Blocked(val code: String, val message: String) : UnlockGate() +} + +/** + * The shared core as the two apps use it (design D3 and D4): pairing with + * Login Flow v2 or an app password, unpairing, and unlocking. Both apps + * keep one instance. Every call that can fail is marked @Throws, so Swift + * sees an error instead of a crash. + * + * [clientName] is what the Nextcloud device list shows for the app password, + * such as "Keepiq for Android": Nextcloud names a Login Flow v2 app password + * after the User-Agent that started the flow. + * + * [onWipe] runs on unpair, before the account is forgotten. The app deletes + * what only it holds there: the biometric wrap, the offline store and the + * autofill index. + */ +@OptIn(ExperimentalTime::class) +class KeepiqClient( + private val storage: SecureStorage, + val clientName: String, + engine: HttpClientEngine = platformHttpEngine(), + private val clock: () -> Long = { Clock.System.now().toEpochMilliseconds() }, +) { + private val http: HttpClient = KeepiqApi.httpClient(engine) + private val loginFlow = LoginFlowV2(http, clientName, clock) + private var loginCancelled = false + private var loginStartedAt = 0L + private val json = Json { ignoreUnknownKeys = true } + + val accounts: AccountStore = AccountStore(storage) + val pins: PinUnlock = PinUnlock(storage) + + /** Called with the account id on unpair, before the account is removed. */ + var onWipe: (String) -> Unit = {} + + /** A cleaned server address, or a [nl.conduction.keepiq.shared.pairing.ServerAddressException] with the reason. */ + @Throws(Exception::class) + fun normalizeServer(input: String): String = ServerAddress.normalize(input) + + /** Step 1 and 2 of Login Flow v2: the page to open in the system browser. */ + @Throws(Exception::class) + suspend fun startLogin(serverInput: String): LoginFlowStart { + checkRoom() + loginCancelled = false + val start = try { + loginFlow.start(serverInput) + } catch (e: KeepiqApiException) { + throw PairingException(e.message ?: "Cannot reach this server.", e.status) + } catch (e: nl.conduction.keepiq.shared.pairing.ServerAddressException) { + throw e + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + throw PairingException("Cannot reach this server. Check the address and your connection.") + } + loginStartedAt = clock() + return start + } + + /** Step 3 to 5: waits for the grant (at most 20 minutes), then pairs. */ + @Throws(Exception::class) + suspend fun finishLogin(start: LoginFlowStart): Account { + val credentials: LoginFlowCredentials = loginFlow.await(start, loginStartedAt) { loginCancelled } + try { + return pairWith(credentials.server, credentials.loginName, credentials.appPassword) + } catch (e: Exception) { + // Nothing is stored, so the app password Login Flow made is + // deleted again rather than left behind in the device list. + runCatching { api(Account("", credentials.server, credentials.loginName, credentials.appPassword)).revokeAppPassword() } + throw e + } + } + + /** + * One last poll, when the user closed the browser: pairs when access was + * granted just before, and answers null when it was not, so the app can + * show the address form again with nothing stored. + */ + @Throws(Exception::class) + suspend fun finishLoginNow(start: LoginFlowStart): Account? { + val credentials = try { + loginFlow.poll(start) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } ?: return null + try { + return pairWith(credentials.server, credentials.loginName, credentials.appPassword) + } catch (e: Exception) { + runCatching { api(Account("", credentials.server, credentials.loginName, credentials.appPassword)).revokeAppPassword() } + throw e + } + } + + /** Stops [finishLogin] at its next poll. */ + fun cancelLogin() { + loginCancelled = true + } + + /** The fallback: pair with an app password the user made in Nextcloud. */ + @Throws(Exception::class) + suspend fun pairManually(serverInput: String, loginName: String, appPassword: String): Account { + val server = ServerAddress.normalize(serverInput) + if (loginName.isBlank() || appPassword.isBlank()) throw PairingException("Enter your user name and the app password.") + return pairWith(server, loginName.trim(), appPassword.trim()) + } + + private suspend fun pairWith(server: String, loginName: String, appPassword: String): Account { + checkRoom() + val response = try { + api(Account("", server, loginName, appPassword)).pair() + } catch (e: InsecureServerException) { + throw PairingException(e.message ?: "") + } catch (e: KeepiqApiException) { + throw PairingException(pairingProblem(e.status), e.status) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + throw PairingException(pairingProblem(0)) + } + if (!response.ok) throw PairingException("This server did not confirm the pairing.") + if (response.apiVersion !in SUPPORTED_API_VERSIONS) { + throw PairingException( + "This server runs a Keepiq version this app does not support (API version ${response.apiVersion ?: "unknown"}). " + + "Ask your administrator to update Keepiq.", + ) + } + return try { + accounts.add(server, loginName, appPassword, response.serverVersion) + } catch (e: AccountLimitException) { + throw PairingException(e.message ?: "") + } + } + + private fun checkRoom() { + if (accounts.accounts().size >= AccountStore.MAX_ACCOUNTS) { + throw PairingException("You can connect up to ${AccountStore.MAX_ACCOUNTS} accounts. Disconnect one first.") + } + } + + /** + * Unpairs (design D3): tells Keepiq, deletes the app password in + * Nextcloud, then wipes everything the device holds for the account, + * also when the server cannot be reached. True when Nextcloud deleted + * the app password. + */ + @Throws(Exception::class) + suspend fun unpair(accountId: String): Boolean { + val account = accounts.account(accountId) ?: return false + val api = api(account) + runCatching { api.unpair() } + val revoked = try { + api.revokeAppPassword() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + false + } + wipe(accountId) + return revoked + } + + /** Deletes every trace of an account on the device. */ + fun wipe(accountId: String) { + runCatching { onWipe(accountId) } + pins.remove(accountId) + storage.delete(suiteKey(accountId)) + accounts.remove(accountId) + } + + /** + * What the unlock screen may offer. Reads the active suite from the + * server, and falls back to the copy from the last unlock when the + * server cannot be reached, so an offline phone still unlocks. + */ + @Throws(Exception::class) + suspend fun unlockGate(accountId: String): UnlockGate { + val account = requireAccount(accountId) + val suite = try { + api(account).activeSuite()?.also { rememberSuite(accountId, it) } + } catch (e: KeepiqApiException) { + if (e.status == 0) cachedSuite(accountId)?.let { return gateOf(it, offline = true) } + throw e + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + cachedSuite(accountId)?.let { return gateOf(it, offline = true) } + throw PairingException("Cannot reach the server, and this phone has not unlocked this vault before.") + } + suite ?: throw nl.conduction.keepiq.shared.unlock.NoVaultException() + return gateOf(suite, offline = false) + } + + private fun gateOf(suite: Suite, offline: Boolean): UnlockGate = suite.unlockBlocked + ?.let { UnlockGate.Blocked(it, nl.conduction.keepiq.shared.unlock.blockedMessage(it)) } + ?: UnlockGate.Ready(suite, offline) + + /** Opens the vault with the master password. PBKDF2 runs off the main thread. */ + @Throws(Exception::class) + suspend fun unlockWithMasterPassword(accountId: String, suite: Suite, masterPassword: String): UnlockedVault = + withContext(Dispatchers.Default) { VaultUnlock.withMasterPassword(accountId, suite, masterPassword) } + + /** + * Opens the vault with an unlock key from the biometric wrap. A key that + * no longer opens the envelope deletes the PIN wrap too, and the app + * deletes its biometric wrap on [StaleUnlockKeyException]. + */ + @Throws(Exception::class) + suspend fun unlockWithKey(accountId: String, suite: Suite, unlockKey: ByteArray): UnlockedVault = + withContext(Dispatchers.Default) { + try { + VaultUnlock.withUnlockKey(accountId, suite, unlockKey) + } catch (e: StaleUnlockKeyException) { + pins.remove(accountId) + throw e + } + } + + /** The Swift side reads the biometric wrap as base64. */ + @Throws(Exception::class) + suspend fun unlockWithKeyBase64(accountId: String, suite: Suite, unlockKey: String): UnlockedVault = + unlockWithKey(accountId, suite, nl.conduction.keepiq.shared.crypto.Encoding.fromBase64(unlockKey)) + + /** Opens the vault with the PIN. Argon2id runs off the main thread. */ + @Throws(Exception::class) + suspend fun unlockWithPin(accountId: String, suite: Suite, pin: String): UnlockedVault { + val key = withContext(Dispatchers.Default) { pins.open(accountId, pin) } + return unlockWithKey(accountId, suite, key) + } + + /** Sets a PIN for an unlocked vault. */ + @Throws(Exception::class) + suspend fun setPin(vault: UnlockedVault, pin: String) { + withContext(Dispatchers.Default) { pins.set(vault.accountId, vault.unlockKey(), pin) } + } + + /** The organisation's idle maximum, or null when it cannot be read. */ + suspend fun maxIdleMinutes(accountId: String): Int? { + val account = accounts.account(accountId) ?: return null + return try { + api(account).maxIdleMinutes() + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } + } + + /** The API for an account, for the vault screens. */ + @Throws(Exception::class) + fun api(account: Account): KeepiqApi = KeepiqApi(http, account) + + private fun requireAccount(accountId: String): Account = + accounts.account(accountId) ?: throw PairingException("This account is no longer connected.") + + private fun suiteKey(accountId: String) = "suite:$accountId" + + private fun rememberSuite(accountId: String, suite: Suite) { + if (suite.unlockBlocked != null || suite.privateKey == null) { + storage.delete(suiteKey(accountId)) + return + } + val obj = buildJsonObject { + put("id", JsonPrimitive(suite.id)) + suite.unlockKeyEpoch?.let { put("unlockKeyEpoch", JsonPrimitive(it)) } + suite.certificate?.let { put("certificate", JsonPrimitive(it)) } + put("privateKey", JsonPrimitive(suite.privateKey)) + } + storage.write(suiteKey(accountId), obj.toString()) + } + + private fun cachedSuite(accountId: String): Suite? { + val text = storage.read(suiteKey(accountId)) ?: return null + val obj = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() ?: return null + return Suite( + id = (obj["id"] as? JsonPrimitive)?.contentOrNull ?: return null, + unlockKeyEpoch = (obj["unlockKeyEpoch"] as? JsonPrimitive)?.longOrNull, + certificate = (obj["certificate"] as? JsonPrimitive)?.contentOrNull, + privateKey = (obj["privateKey"] as? JsonPrimitive)?.contentOrNull, + unlockBlocked = null, + ) + } + + companion object { + /** The pair response's `apiVersion` values this app speaks. */ + val SUPPORTED_API_VERSIONS: Set = setOf(1) + + /** What went wrong when pairing (browser-extension/src/background/router.js pairingProblem). */ + fun pairingProblem(status: Int): String = when { + status == 0 -> "Cannot reach this server. Check the address and your connection." + status == 401 -> "Nextcloud did not accept this user name and app password." + status == 403 -> "This Nextcloud account may not use Keepiq." + status == 404 -> "Keepiq is not installed on this server, or the address is wrong." + status >= 500 -> "The server could not answer. Try again later." + else -> "Connecting failed ($status)." + } + } +} + +/** + * For Swift, which sees no default arguments: a client with the platform + * HTTP engine and the system clock. + */ +fun newKeepiqClient(storage: SecureStorage, clientName: String): KeepiqClient = KeepiqClient(storage, clientName) + +/** The account id under a name Objective-C does not reserve, for Swift. */ +val Account.accountId: String get() = id diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqShared.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqShared.kt new file mode 100644 index 000000000..5c6eaec33 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/KeepiqShared.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared + +/** Entry point both apps link against (design D1: one core, native screens). */ +object KeepiqShared { + /** The product name shown by the placeholder screens. */ + const val APP_NAME: String = "Keepiq" +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/AccountStore.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/AccountStore.kt new file mode 100644 index 000000000..777273e64 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/AccountStore.kt @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.account + +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.Primitives + +/** + * Small values the app keeps across restarts, bound to the device (design + * D4): the paired accounts with their app passwords, and the PIN wraps. + * + * - Android: SharedPreferences whose values are sealed with an AES key in the + * AndroidKeyStore that needs no user authentication. + * - iOS: Keychain items, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. + * + * Neither leaves the device in a backup. + */ +interface SecureStorage { + fun read(key: String): String? + + fun write(key: String, value: String) + + fun delete(key: String) +} + +/** A [SecureStorage] in memory, for tests and previews. */ +class InMemorySecureStorage : SecureStorage { + private val values = LinkedHashMap() + + override fun read(key: String): String? = values[key] + + override fun write(key: String, value: String) { + values[key] = value + } + + override fun delete(key: String) { + values.remove(key) + } + + /** The keys held, for tests that check a wipe. */ + fun keys(): Set = values.keys.toSet() +} + +/** A sixth account, or the same account twice. */ +class AccountLimitException(message: String) : Exception(message) + +/** The user's choices for one account. */ +@Serializable +data class AccountSettings(val idleMinutes: Int = IdlePolicy.DEFAULT_MINUTES, val biometric: Boolean = false) + +@Serializable +private data class StoredAccount( + val id: String, + val server: String, + val loginName: String, + val appPassword: String, + val serverVersion: String? = null, + val settings: AccountSettings = AccountSettings(), +) + +/** + * The paired accounts, at most [MAX_ACCOUNTS] (design D3, the extension's + * limit), in pairing order, with the active one. + */ +class AccountStore(private val storage: SecureStorage) { + private val json = Json { ignoreUnknownKeys = true } + private val serializer = ListSerializer(StoredAccount.serializer()) + + fun accounts(): List = load().map { it.toAccount() } + + fun account(id: String): Account? = load().firstOrNull { it.id == id }?.toAccount() + + fun activeId(): String? = storage.read(ACTIVE_KEY)?.takeIf { id -> load().any { it.id == id } } + ?: load().firstOrNull()?.id + + fun setActive(id: String) { + require(load().any { it.id == id }) { "unknown account" } + storage.write(ACTIVE_KEY, id) + } + + fun settings(id: String): AccountSettings = load().firstOrNull { it.id == id }?.settings ?: AccountSettings() + + fun serverVersion(id: String): String? = load().firstOrNull { it.id == id }?.serverVersion + + fun updateSettings(id: String, settings: AccountSettings) { + require(settings.idleMinutes in IdlePolicy.CHOICES) { "idle minutes must be one of ${IdlePolicy.CHOICES}" } + save(load().map { if (it.id == id) it.copy(settings = settings) else it }) + } + + /** Adds an account and makes it active. Refuses a sixth one and a second copy of the same login. */ + @Throws(AccountLimitException::class) + fun add(server: String, loginName: String, appPassword: String, serverVersion: String?): Account { + val list = load() + if (list.any { it.server == server && it.loginName == loginName }) { + throw AccountLimitException("This account is already connected.") + } + if (list.size >= MAX_ACCOUNTS) { + throw AccountLimitException("You can connect up to $MAX_ACCOUNTS accounts. Disconnect one first.") + } + val stored = StoredAccount(Encoding.hex(Primitives.randomBytes(16)), server, loginName, appPassword, serverVersion) + save(list + stored) + storage.write(ACTIVE_KEY, stored.id) + return stored.toAccount() + } + + /** Removes an account. The caller wipes its other device state first (see [nl.conduction.keepiq.shared.KeepiqClient.unpair]). */ + fun remove(id: String) { + val left = load().filterNot { it.id == id } + save(left) + if (storage.read(ACTIVE_KEY) == id) { + left.firstOrNull()?.let { storage.write(ACTIVE_KEY, it.id) } ?: storage.delete(ACTIVE_KEY) + } + } + + private fun load(): List { + val text = storage.read(ACCOUNTS_KEY) ?: return emptyList() + return runCatching { json.decodeFromString(serializer, text) }.getOrDefault(emptyList()) + } + + private fun save(list: List) { + if (list.isEmpty()) storage.delete(ACCOUNTS_KEY) else storage.write(ACCOUNTS_KEY, json.encodeToString(serializer, list)) + } + + private fun StoredAccount.toAccount() = Account(id, server, loginName, appPassword) + + companion object { + const val MAX_ACCOUNTS = 5 + private const val ACCOUNTS_KEY = "accounts" + private const val ACTIVE_KEY = "accounts.active" + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/IdlePolicy.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/IdlePolicy.kt new file mode 100644 index 000000000..1b4096c00 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/account/IdlePolicy.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.account + +/** + * The idle lock (design D4, "Locking"): the user picks one of [CHOICES], + * capped by the organisation's `maxIdleMinutes` from + * `/api/v1/extension/policy`. When that maximum could not be read, the cap + * is the default, so an unreachable policy never lengthens the delay + * (browser-extension/src/background/router.js FALLBACK_MAX_IDLE_MINUTES). + */ +object IdlePolicy { + val CHOICES: List = listOf(1, 5, 15, 30, 60, 240) + const val DEFAULT_MINUTES = 15 + + /** The delay that applies: the lower of the user's choice and the organisation's maximum. */ + fun effectiveMinutes(choice: Int, organisationMax: Int?): Int { + val picked = if (choice in CHOICES) choice else DEFAULT_MINUTES + val cap = organisationMax?.takeIf { it > 0 } ?: DEFAULT_MINUTES + return minOf(picked, cap) + } + + /** The choices the settings screen offers: those within the organisation's maximum. */ + fun offeredChoices(organisationMax: Int?): List { + val cap = organisationMax?.takeIf { it > 0 } ?: return CHOICES + return CHOICES.filter { it <= cap }.ifEmpty { listOf(CHOICES.first()) } + } +} + +/** + * Counts idle time. The app calls [touch] on every user interaction and + * asks [expired] when it comes back to the foreground and on a timer. The + * clock is wall time, so idle time also runs while the app is in the + * background or the process is gone (the app persists [lastActivityMillis]). + */ +class IdleTimer(private val clock: () -> Long, var minutes: Int, var lastActivityMillis: Long = clock()) { + fun touch() { + lastActivityMillis = clock() + } + + fun expired(): Boolean = clock() - lastActivityMillis >= minutes * 60_000L + + /** Milliseconds until the lock, never below zero. */ + fun remainingMillis(): Long = maxOf(0L, lastActivityMillis + minutes * 60_000L - clock()) +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.kt new file mode 100644 index 000000000..08357e6b0 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.api + +import io.ktor.client.engine.HttpClientEngine + +/** + * The platform HTTP engine, set up so it never stores or sends a cookie and + * never follows a redirect (design D3): OkHttp on Android and jvm, the + * NSURLSession engine on iOS. + */ +expect fun platformHttpEngine(): HttpClientEngine diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/KeepiqApi.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/KeepiqApi.kt new file mode 100644 index 000000000..c2998398d --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/api/KeepiqApi.kt @@ -0,0 +1,315 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.URLProtocol +import io.ktor.http.Url +import io.ktor.http.content.TextContent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.crypto.Encoding + +/** A paired account: the server address and the Nextcloud app password. */ +data class Account(val id: String, val server: String, val loginName: String, val appPassword: String) { + override fun toString(): String = "Account(id=$id, server=$server, loginName=$loginName)" +} + +/** + * A request the server refused or that never reached it. [status] is the + * HTTP status, or the OCS `meta.statuscode` when the server wrapped a + * refusal in an HTTP 200 envelope, or 0 when the request was not sent. + * [code] is the body's `error`, else its `code` (keepiq#673). + */ +open class KeepiqApiException( + val status: Int, + message: String, + val code: String? = null, + val body: String = "", +) : Exception(message) + +/** The server address is not https, so the app password is never sent. */ +class InsecureServerException(server: String) : + KeepiqApiException(0, "Keepiq needs an https address, so your app password is never sent in clear: $server") + +/** + * The keepiq API, called the way the browser extension calls it + * (browser-extension/src/lib/api.js request, design D3): + * `{server}/index.php/apps/keepiq{path}`, HTTP Basic with the app password, + * `OCS-APIRequest: true`, JSON, no cookies, no redirects. An OCS envelope + * whose `meta.statuscode` is 400 or more is an error even under HTTP 200. + * Plain http is refused for every host. + */ +class KeepiqApi(private val client: HttpClient, private val account: Account) { + private val base: String = account.server.trimEnd('/') + + init { + val url = runCatching { Url(base) }.getOrNull() + if (url == null || url.protocol != URLProtocol.HTTPS || !base.startsWith("https://", ignoreCase = true)) { + throw InsecureServerException(account.server) + } + } + + /** Sends one request and returns the parsed JSON body, or null for an empty answer. */ + suspend fun request(method: HttpMethod, path: String, body: JsonElement? = null): JsonElement? { + val auth = "Basic " + Encoding.toBase64(Encoding.utf8("${account.loginName}:${account.appPassword}")) + val response = client.request(base + "/index.php/apps/keepiq" + path) { + this.method = method + header(HttpHeaders.Authorization, auth) + header("OCS-APIRequest", "true") + header(HttpHeaders.Accept, "application/json") + if (body != null) setBody(TextContent(body.toString(), ContentType.Application.Json)) + } + val text = response.bodyAsText() + val status = response.status.value + if (status !in 200..299) { + throw KeepiqApiException(status, "Keepiq ${method.value} $path failed ($status)", refusalCode(text), text) + } + if (status == 204 || text.isBlank()) return null + val data = try { + Json.parseToJsonElement(text) + } catch (e: Exception) { + throw KeepiqApiException(status, "Keepiq ${method.value} $path answered with something that is not JSON", body = text) + } + val meta = ((data as? JsonObject)?.get("ocs") as? JsonObject)?.get("meta") as? JsonObject + val ocsStatus = (meta?.get("statuscode") as? JsonPrimitive)?.let { it.intOrNull ?: it.contentOrNull?.toIntOrNull() } + if (ocsStatus != null && ocsStatus >= 400) { + throw KeepiqApiException(ocsStatus, "Keepiq ${method.value} $path failed ($ocsStatus)", body = text) + } + return data + } + + /** GET /api/v1/offline/manifest: suite, secrets, folders and types in one answer. */ + suspend fun offlineManifest(): Manifest = Manifest.from(request(HttpMethod.Get, "/api/v1/offline/manifest")?.jsonObject ?: JsonObject(emptyMap())) + + /** The newest `updatedAt` and the total, for the cheap freshness check. */ + suspend fun latestSecret(): Freshness { + val data = request(HttpMethod.Get, "/api/v1/secrets?sort=updated_at&direction=desc&limit=1")?.jsonObject + val top = (data?.get("items") as? JsonArray)?.firstOrNull()?.jsonObject?.get("updatedAt")?.jsonPrimitive?.contentOrNull + val total = (data?.get("total") as? JsonPrimitive)?.longOrNull ?: 0L + return Freshness(top, total) + } + + /** Every secret, page by page, for an organisation without offline caching. */ + suspend fun listSecrets(): List { + val items = ArrayList() + for (page in 1..MAX_SECRET_PAGES) { + val data = request(HttpMethod.Get, "/api/v1/secrets?page=$page&limit=$SECRETS_PAGE_SIZE")?.jsonObject + val batch = (data?.get("items") as? JsonArray)?.map { it.jsonObject } ?: emptyList() + items += batch + val total = (data?.get("total") as? JsonPrimitive)?.longOrNull ?: 0L + if (batch.size < SECRETS_PAGE_SIZE || items.size >= total) return items + } + throw KeepiqApiException(413, "The vault has more items than can be read page by page") + } + + suspend fun listFolders(): List = itemsOf(request(HttpMethod.Get, "/api/v1/folders")) + + suspend fun listTypes(): List = itemsOf(request(HttpMethod.Get, "/api/v1/secret-types")) + + /** The active suite, or null when there is none. */ + suspend fun activeSuite(): Suite? = itemsOf(request(HttpMethod.Get, "/api/v1/suites")) + .firstOrNull { it["status"]?.jsonPrimitive?.contentOrNull == "active" } + ?.let { Suite.from(it) } + + /** POST /api/v1/secrets with an already-encrypted body. */ + suspend fun createSecret(body: JsonObject): JsonObject? = request(HttpMethod.Post, "/api/v1/secrets", body) as? JsonObject + + /** PUT /api/v1/secrets/{id} with an already-encrypted body. */ + suspend fun updateSecret(id: String, body: JsonObject): JsonObject? = + request(HttpMethod.Put, "/api/v1/secrets/" + encodePath(id), body) as? JsonObject + + /** + * POST /api/v1/extension/pair: proves the app password works and reports + * the server's `apiVersion` (lib/Controller/ExtensionController::pair). + */ + suspend fun pair(): PairResponse = PairResponse.from(request(HttpMethod.Post, "/api/v1/extension/pair") as? JsonObject ?: JsonObject(emptyMap())) + + /** POST /api/v1/extension/unpair: the acknowledgement before the revoke. */ + suspend fun unpair() { + request(HttpMethod.Post, "/api/v1/extension/unpair") + } + + /** GET /api/v1/extension/policy: the organisation's `maxIdleMinutes`, or null when it has none. */ + suspend fun maxIdleMinutes(): Int? = + ((request(HttpMethod.Get, "/api/v1/extension/policy") as? JsonObject)?.get("maxIdleMinutes") as? JsonPrimitive)?.intOrNull + + /** + * DELETE /ocs/v2.php/core/apppassword: Nextcloud deletes the app password + * this request signs in with (browser-extension/src/lib/api.js + * revokeAppPassword). True when Nextcloud deleted it. + */ + suspend fun revokeAppPassword(): Boolean { + val response = client.request("$base/ocs/v2.php/core/apppassword") { + method = HttpMethod.Delete + header(HttpHeaders.Authorization, basicAuth()) + header("OCS-APIRequest", "true") + header(HttpHeaders.Accept, "application/json") + } + response.bodyAsText() + return response.status.value in 200..299 + } + + private fun basicAuth(): String = + "Basic " + Encoding.toBase64(Encoding.utf8("${account.loginName}:${account.appPassword}")) + + /** GET /api/v1/secrets/{id}: one secret with its ciphertext, fetched fresh. */ + suspend fun getSecret(id: String): JsonObject? = + request(HttpMethod.Get, "/api/v1/secrets/" + encodePath(id)) as? JsonObject + + /** DELETE /api/v1/secrets/{id}: moves the secret to the trash. */ + suspend fun trashSecret(id: String) { + request(HttpMethod.Delete, "/api/v1/secrets/" + encodePath(id)) + } + + /** POST /api/v1/secrets/{id}/used: records a fill of a use-only copy for its owner. */ + suspend fun reportUseOnlyFill(id: String) { + request(HttpMethod.Post, "/api/v1/secrets/" + encodePath(id) + "/used") + } + + /** POST /api/v1/folders. */ + suspend fun createFolder(name: String, parentId: String?): JsonObject? = request( + HttpMethod.Post, + "/api/v1/folders", + JsonObject(mapOf("name" to JsonPrimitive(name), "parentId" to (parentId?.let { JsonPrimitive(it) } ?: JsonNull))), + ) as? JsonObject + + /** PUT /api/v1/folders/{id}: only the name is sent. */ + suspend fun renameFolder(id: String, name: String) { + request(HttpMethod.Put, "/api/v1/folders/" + encodePath(id), JsonObject(mapOf("name" to JsonPrimitive(name)))) + } + + /** GET /api/v1/folders/{id}/children: the direct item count and the subfolders. */ + suspend fun folderChildren(id: String): JsonObject? = + request(HttpMethod.Get, "/api/v1/folders/" + encodePath(id) + "/children") as? JsonObject + + /** DELETE /api/v1/folders/{id}, with `?cascade=` for a leaf folder that holds items. */ + suspend fun deleteFolder(id: String, cascade: String? = null) { + val query = if (cascade != null) "?cascade=" + encodePath(cascade) else "" + request(HttpMethod.Delete, "/api/v1/folders/" + encodePath(id) + query) + } + + /** GET /api/settings/policy, or null when it cannot be read: an unread policy never blocks. */ + suspend fun fetchPolicy(): JsonObject? = try { + request(HttpMethod.Get, "/api/settings/policy") as? JsonObject + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + null + } + + /** POST /api/v1/sends with an already-encrypted body. */ + suspend fun createSend(body: JsonObject): JsonObject? = request(HttpMethod.Post, "/api/v1/sends", body) as? JsonObject + + /** GET /api/v1/sends: the account's own sends, metadata only. */ + suspend fun listSends(): List = (request(HttpMethod.Get, "/api/v1/sends") as? JsonArray) + ?.mapNotNull { it as? JsonObject } ?: emptyList() + + /** DELETE /api/v1/sends/{id}: ends a send. */ + suspend fun revokeSend(id: String) { + request(HttpMethod.Delete, "/api/v1/sends/" + encodePath(id)) + } + + /** The public base for recipient links on this account's server. */ + val publicBase: String get() = "$base/index.php/apps/keepiq/public" + + private fun itemsOf(data: JsonElement?): List = when (data) { + is JsonArray -> data.map { it.jsonObject } + is JsonObject -> (data["items"] as? JsonArray)?.map { it.jsonObject } ?: emptyList() + else -> emptyList() + } + + companion object { + /** SecretService::MAX_LIMIT. */ + const val SECRETS_PAGE_SIZE = 100 + const val MAX_SECRET_PAGES = 1000 + + /** An HttpClient for [KeepiqApi]: no redirects, non-2xx handled by [request]. */ + fun httpClient(engine: HttpClientEngine): HttpClient = HttpClient(engine) { + expectSuccess = false + followRedirects = false + } + + internal fun refusalCode(text: String): String? = try { + val obj = Json.parseToJsonElement(text.ifBlank { "{}" }) as? JsonObject + (obj?.get("error") ?: obj?.get("code"))?.takeIf { it !is JsonNull }?.jsonPrimitive?.contentOrNull + } catch (e: Exception) { + null + } + + private fun encodePath(segment: String): String = nl.conduction.keepiq.shared.crypto.SendCrypto.encodeUriComponent(segment) + } +} + +/** The answer of POST /api/v1/extension/pair. */ +data class PairResponse(val ok: Boolean, val user: String?, val apiVersion: Int?, val serverVersion: String?) { + companion object { + fun from(obj: JsonObject): PairResponse = PairResponse( + ok = (obj["ok"] as? JsonPrimitive)?.contentOrNull == "true", + user = obj.string("user"), + apiVersion = (obj["apiVersion"] as? JsonPrimitive)?.intOrNull, + serverVersion = obj.string("serverVersion"), + ) + } +} + +/** The answer of the cheap freshness check. */ +data class Freshness(val top: String?, val total: Long) + +/** The active encryption suite, as far as sync needs it. */ +data class Suite( + val id: String, + val unlockKeyEpoch: Long?, + val certificate: String?, + val privateKey: String?, + val unlockBlocked: String?, +) { + companion object { + fun from(obj: JsonObject): Suite = Suite( + id = obj["id"]?.jsonPrimitive?.contentOrNull ?: "", + unlockKeyEpoch = (obj["unlockKeyEpoch"] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull, + certificate = (obj["certificate"] as? JsonPrimitive)?.contentOrNull, + privateKey = (obj["privateKey"] as? JsonPrimitive)?.contentOrNull, + unlockBlocked = (obj["unlockBlocked"] as? JsonPrimitive)?.contentOrNull, + ) + } +} + +/** GET /api/v1/offline/manifest (lib/Service/OfflineManifestService::buildForUser). */ +data class Manifest( + val suite: Suite?, + val secrets: List, + val folders: List, + val types: List, + val unlockBlocked: String?, +) { + companion object { + fun from(obj: JsonObject): Manifest = Manifest( + suite = (obj["suite"] as? JsonObject)?.let { Suite.from(it) }, + secrets = (obj["secrets"] as? JsonArray)?.map { it.jsonObject } ?: emptyList(), + folders = (obj["folders"] as? JsonArray)?.map { it.jsonObject } ?: emptyList(), + types = (obj["types"] as? JsonArray)?.map { it.jsonObject } ?: emptyList(), + unlockBlocked = (obj["unlockBlocked"] as? JsonPrimitive)?.contentOrNull, + ) + } +} + +internal fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AppLinks.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AppLinks.kt new file mode 100644 index 000000000..deab2be3e --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AppLinks.kt @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** + * An Android app as the autofill service sees it: its package name and the + * SHA-256 fingerprints of the certificates it is signed with, upper-case hex + * with colons, as Digital Asset Links writes them. + */ +data class AppIdentity(val packageName: String, val certFingerprints: Set) { + companion object { + /** "14:6d:e9" and "146DE9" both read as "14:6D:E9". */ + fun normalizeFingerprint(text: String): String? { + val hex = text.filter { it != ':' }.uppercase() + if (hex.isEmpty() || hex.length % 2 != 0 || !hex.all { it in '0'..'9' || it in 'A'..'F' }) return null + return hex.chunked(2).joinToString(":") + } + } +} + +/** + * The address an item holds when it belongs to an Android app rather than a + * website: `androidapp://#sha256_cert_fingerprints=`. + * The package name alone is the convention other password managers use; the + * fingerprint is added so a look-alike app, with the same package name and + * another signing certificate, never matches (mobile-system-autofill, "A + * look-alike app gets nothing"). An `androidapp://` address without a + * fingerprint matches no app. + */ +data class AppLink(val packageName: String, val certFingerprints: Set) { + /** Whether [app] is this app: the same package, signed with one of the recorded certificates. */ + fun matches(app: AppIdentity): Boolean = + app.packageName == packageName && certFingerprints.isNotEmpty() && app.certFingerprints.any { it in certFingerprints } + + fun toUrl(): String = buildString { + append(SCHEME).append(packageName) + if (certFingerprints.isNotEmpty()) append('#').append(FRAGMENT).append(certFingerprints.sorted().joinToString(",")) + } + + companion object { + const val SCHEME = "androidapp://" + private const val FRAGMENT = "sha256_cert_fingerprints=" + private val PACKAGE = Regex("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$") + + fun of(app: AppIdentity): AppLink = AppLink(app.packageName, app.certFingerprints) + + /** The link in an item address, or null for a website address. */ + fun parse(url: String?): AppLink? { + val text = url?.trim() ?: return null + if (!text.startsWith(SCHEME, ignoreCase = true)) return null + val rest = text.substring(SCHEME.length) + val packageName = rest.substringBefore('#').substringBefore('/').substringBefore('?') + if (!PACKAGE.matches(packageName)) return null + val fragment = rest.substringAfter('#', "") + val prints = if (fragment.startsWith(FRAGMENT)) { + fragment.substring(FRAGMENT.length).split(',').mapNotNull { AppIdentity.normalizeFingerprint(it.trim()) }.toSet() + } else { + emptySet() + } + return AppLink(packageName, prints) + } + } +} + +/** + * Digital Asset Links (https://developers.google.com/digital-asset-links): + * a website lists the apps that may use its logins in + * `/.well-known/assetlinks.json`, and an app names the websites it belongs + * to in its `asset_statements` manifest entry. Keepiq gives an app a + * website's logins only when both sides agree: the app names the site, and + * the site lists the app's package and signing certificate with the + * `delegate_permission/common.get_login_creds` relation. + */ +object AssetLinks { + const val LOGIN_RELATION = "delegate_permission/common.get_login_creds" + private val json = Json { ignoreUnknownKeys = true } + + /** + * The websites an app's `asset_statements` resource names, as https + * origins: statements whose target is a web site, and `include` entries, + * whose site is the origin of the included file. + */ + fun declaredSites(assetStatements: String): List { + val array = runCatching { json.parseToJsonElement(assetStatements) as? JsonArray }.getOrNull() ?: return emptyList() + return array.mapNotNull { element -> + val statement = element as? JsonObject ?: return@mapNotNull null + val include = statement.text("include") + val site = when { + include != null -> originOf(include) + else -> { + val target = statement["target"] as? JsonObject + if (target?.text("namespace") == "web") target.text("site")?.let { originOf(it) } else null + } + } + site + }.distinct() + } + + /** + * Whether a site's assetlinks.json lets [app] use its logins: a + * statement with the login relation whose android_app target names the + * package and one of its certificate fingerprints. + */ + fun allowsLogins(assetLinksJson: String, app: AppIdentity): Boolean { + val array = runCatching { json.parseToJsonElement(assetLinksJson) as? JsonArray }.getOrNull() ?: return false + return array.any { element -> + val statement = element as? JsonObject ?: return@any false + val relations = (statement["relation"] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + val target = statement["target"] as? JsonObject ?: return@any false + if (LOGIN_RELATION !in relations || target.text("namespace") != "android_app") return@any false + if (target.text("package_name") != app.packageName) return@any false + val prints = (target["sha256_cert_fingerprints"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.let { p -> AppIdentity.normalizeFingerprint(p) } } ?: emptyList() + prints.any { it in app.certFingerprints } + } + } + + /** The statement file of a site. */ + fun statementUrl(site: String): String = site.trimEnd('/') + "/.well-known/assetlinks.json" + + /** "https://example.com:8443" from any https URL; null for another scheme. */ + fun originOf(url: String): String? { + val text = url.trim() + if (!text.startsWith("https://", ignoreCase = true)) return null + val authority = text.substring(8).substringBefore('/').substringBefore('?').substringBefore('#') + if (authority.isEmpty() || authority.contains('@')) return null + if (UrlHost.hostname("https://$authority").isNullOrEmpty()) return null + return "https://" + authority.lowercase() + } + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/Autofill.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/Autofill.kt new file mode 100644 index 000000000..4ca93c371 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/Autofill.kt @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.Totp +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemParts +import nl.conduction.keepiq.shared.vault.VaultKeys + +/** A login decrypted for one fill: only the item the form matched. */ +data class LoginChoice(val id: String, val name: String, val login: String, val password: String, val useOnly: Boolean) { + override fun toString(): String = "LoginChoice(id=$id)" +} + +/** A one-time code computed for one fill. */ +data class CodeChoice(val id: String, val name: String, val code: String) { + override fun toString(): String = "CodeChoice(id=$id)" +} + +/** + * What the system autofill offers once the vault is unlocked: the matched + * items, decrypted one by one. An item that cannot be decrypted is left out + * rather than failing the whole fill. + */ +object AutofillChoices { + fun logins(index: AutofillIndex, target: AutofillTarget, keys: VaultKeys): List = + index.candidates(target).mapNotNull { e -> + runCatching { LoginChoice(e.id, e.name, keys.decryptField(e.login), keys.decryptField(e.key), e.useOnly) }.getOrNull() + } + + /** The current code of each matched authenticator item (task 4.3 and 4.5). */ + fun codes(index: AutofillIndex, target: AutofillTarget, keys: VaultKeys, nowMillis: Long): List = + index.candidates(target, totp = true).mapNotNull { e -> + runCatching { CodeChoice(e.id, e.name, Totp.generate(Totp.parse(keys.decryptField(e.key)), nowMillis)) }.getOrNull() + } +} + +/** What saving a submitted login did. */ +enum class SaveResult { SAVED, UPDATED, UNCHANGED, REFUSED } + +/** + * Saves a login typed into an app or a website (task 4.2), as the + * extension's saveHeldCapture does: update the one stored login with this + * user name, do nothing when the same login is stored or several could be + * meant, else create a login item. A use-only item for the site withholds + * the save, and so does the never-save list, which the caller checks first. + */ +class AutofillSaver(private val api: KeepiqApi, private val keys: VaultKeys) { + suspend fun save(target: AutofillTarget, index: AutofillIndex, login: String, password: String, appLabel: String? = null, webScheme: String? = null): SaveResult { + if (password.isEmpty() || index.blocksSave(target)) return SaveResult.REFUSED + val offer = Capture.classifyCandidates(index.saveCandidates(target), login, password) { e -> + PlainLogin(keys.decryptField(e.login), keys.decryptField(e.key)) + } + return when (offer) { + SaveOffer.None -> SaveResult.UNCHANGED + is SaveOffer.Update -> { + val parts = ItemParts(offer.name, "", null, login, password, null) + api.updateSecret(offer.id, ItemCodec.updateBody(parts, setOf("key"), keys)) + SaveResult.UPDATED + } + SaveOffer.Save -> { + val typeId = api.listTypes().firstOrNull { (it["name"] as? JsonPrimitive)?.contentOrNull == "login" } + ?.let { (it["id"] as? JsonPrimitive)?.contentOrNull } + val (name, url) = when (target) { + is AutofillTarget.Web -> { + val host = SiteMatch.hostOf(target.host) + host to ((if (webScheme == "http") "http" else "https") + "://" + host) + } + is AutofillTarget.App -> (appLabel ?: target.identity.packageName) to AppLink.of(target.identity).toUrl() + } + api.createSecret(ItemCodec.createBody(ItemParts(name, url, null, login, password, null), typeId, keys)) + SaveResult.SAVED + } + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndex.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndex.kt new file mode 100644 index 000000000..318c5b1a4 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndex.kt @@ -0,0 +1,224 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultState + +/** + * One fillable item in the autofill index (design D5, "The autofill + * index"). Name and address are the plaintext metadata the server sends; + * [login] and [key] stay the RSA ciphertext the server sent, so the index + * holds no plaintext secret. A locked phone can tell that a form has a + * match, and an unlocked one decrypts only the item the user picks. + */ +data class AutofillEntry( + override val id: String, + override val name: String, + override val url: String?, + /** "login", "totp" or "passkey". */ + val typeName: String, + val login: String?, + val key: String?, + override val useOnly: Boolean, + override val lastUsedAt: String? = null, +) : Matchable { + val isLogin: Boolean get() = typeName == LOGIN + val isTotp: Boolean get() = typeName == TOTP + + /** A passkey: [url] is its rpId and [key] the encrypted passkey item JSON. Never offered as a password. */ + val isPasskey: Boolean get() = typeName == PASSKEY + + override fun toString(): String = "AutofillEntry(id=$id, type=$typeName)" + + companion object { + const val LOGIN = "login" + const val TOTP = "totp" + const val PASSKEY = "passkey" + } +} + +/** Where a fill request comes from: a website the browser names, or an app. */ +sealed class AutofillTarget { + data class Web(val host: String) : AutofillTarget() + + /** + * An app, with the websites Digital Asset Links verified for it + * ([verifiedHosts]): their logins are offered too. + */ + data class App(val identity: AppIdentity, val verifiedHosts: List = emptyList()) : AutofillTarget() + + /** The host a saved login is filed under, and the never-save list is keyed by. */ + val saveKey: String + get() = when (this) { + is Web -> SiteMatch.hostOf(host) + is App -> AppLink.SCHEME + identity.packageName + } +} + +/** The index of one account. */ +class AutofillIndex(val entries: List) { + /** + * The logins (or, with [totp], the authenticator items) for [target], + * best first. A website gets the extension's site match, without the + * items linked to an app. An app gets the + * items linked to it by package and certificate, and the site match of + * every website verified for it. A use-only item fills only on its own + * site, as in the extension. + */ + fun candidates(target: AutofillTarget, totp: Boolean = false): List { + val pool = entries.filter { if (totp) it.isTotp else it.isLogin } + val scored: List> = when (target) { + // An item linked to an app is for that app only: its package + // name reads like a host name, but no website owns it. + is AutofillTarget.Web -> SiteMatch.filterForHost(SiteMatch.matchSecrets(pool.filter { AppLink.parse(it.url) == null }, target.host), target.host) + is AutofillTarget.App -> { + val linked = pool.filter { AppLink.parse(it.url)?.matches(target.identity) == true }.map { Scored(it, 100) } + val viaSites = target.verifiedHosts.flatMap { host -> + SiteMatch.filterForHost(SiteMatch.matchSecrets(pool.filter { AppLink.parse(it.url) == null }, host), host) + } + (linked + viaSites) + .groupBy { it.item.id } + .map { (_, same) -> same.maxBy { it.score } } + .sortedWith { a, b -> + val byScore = b.score - a.score + if (byScore != 0) byScore else (b.item.lastUsedAt ?: "").compareTo(a.item.lastUsedAt ?: "") + } + } + } + return scored.map { it.item } + } + + /** + * The stored logins a submitted one may belong to (classifyCapture + * counts the same site only, score 80 or more): for a website its + * same-site logins, for an app the logins linked to it and the + * same-site logins of its verified websites. + */ + fun saveCandidates(target: AutofillTarget): List { + val logins = entries.filter { it.isLogin } + val sites = logins.filter { AppLink.parse(it.url) == null } + return when (target) { + is AutofillTarget.Web -> SiteMatch.matchSecrets(sites, target.host).filter { it.score >= 80 }.map { it.item } + is AutofillTarget.App -> ( + logins.filter { AppLink.parse(it.url)?.matches(target.identity) == true } + + target.verifiedHosts.flatMap { h -> SiteMatch.matchSecrets(sites, h).filter { it.score >= 80 }.map { it.item } } + ).distinctBy { it.id } + } + } + + /** Whether a save offer is withheld: a use-only item belongs to this site (useOnly.js blocksSavePrompt). */ + fun blocksSave(target: AutofillTarget): Boolean = when (target) { + is AutofillTarget.Web -> SiteMatch.blocksSavePrompt(entries.filter { it.isLogin }, target.host) + is AutofillTarget.App -> candidates(target).any { it.useOnly } + } + + fun toJson(): String = buildJsonArray { + for (e in entries) { + add( + buildJsonObject { + put("id", JsonPrimitive(e.id)) + put("name", JsonPrimitive(e.name)) + e.url?.let { put("url", JsonPrimitive(it)) } + put("type", JsonPrimitive(e.typeName)) + e.login?.let { put("login", JsonPrimitive(it)) } + e.key?.let { put("key", JsonPrimitive(it)) } + if (e.useOnly) put("useOnly", JsonPrimitive(true)) + e.lastUsedAt?.let { put("lastUsedAt", JsonPrimitive(it)) } + }, + ) + } + }.toString() + + companion object { + val EMPTY = AutofillIndex(emptyList()) + + /** + * The index of a vault: logins, authenticator items and passkeys + * that are not trashed and not blocked. Blocked items are never + * offered, as in the extension (extension-fill-and-capture). The + * passkey provider (task group 5) reads the passkeys from here. + */ + fun of(state: VaultState): AutofillIndex { + val typeNames = state.types.associate { it.id to it.name } + return AutofillIndex( + state.rows.filter { !it.trashed && !it.blocked }.mapNotNull { row -> + val typeName = row.typeId?.let { typeNames[it] } ?: AutofillEntry.LOGIN + if (typeName != AutofillEntry.LOGIN && typeName != AutofillEntry.TOTP && typeName != AutofillEntry.PASSKEY) return@mapNotNull null + AutofillEntry(row.id, row.name, row.url, typeName, row.login, row.key, row.useOnly) + }, + ) + } + + fun fromJson(text: String): AutofillIndex { + val array = runCatching { Json.parseToJsonElement(text) as? JsonArray }.getOrNull() ?: return EMPTY + return AutofillIndex( + array.mapNotNull { element -> + val o = element as? JsonObject ?: return@mapNotNull null + AutofillEntry( + id = o.text("id") ?: return@mapNotNull null, + name = o.text("name") ?: "", + url = o.text("url"), + typeName = o.text("type") ?: AutofillEntry.LOGIN, + login = o.text("login"), + key = o.text("key"), + useOnly = o.text("useOnly") == "true", + lastUsedAt = o.text("lastUsedAt"), + ) + }, + ) + } + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + } +} + +/** + * Where the platform keeps the autofill index: on Android a file sealed + * with a device-bound Keystore key, on iOS the system's + * ASCredentialIdentityStore plus a per-site file the AutoFill extension + * reads. [persist] is false when the organisation keeps no offline copy: + * the index then lives in memory only (design D5). + */ +interface AutofillIndexSink { + fun replace(accountId: String, index: AutofillIndex, persist: Boolean, keys: VaultKeys) + + fun clear(accountId: String) +} + +/** + * Keeps the autofill index in step with the vault (task 4.6): every sync + * that returns the vault rebuilds it, and a sync that locked the vault + * (another suite, a new master password, the two-factor block) clears it, + * as the store is cleared. The app clears it on unpair through + * [nl.conduction.keepiq.shared.KeepiqClient.onWipe], and when the user + * turns autofill off. + * + * The app sets [sink] once at start; VaultRepository reports each refresh. + */ +object AutofillIndexHub { + var sink: AutofillIndexSink? = null + + /** Called with every state [nl.conduction.keepiq.shared.vault.VaultRepository.refresh] returns. */ + fun refreshed(accountId: String, state: VaultState, keys: VaultKeys) { + val target = sink ?: return + when { + state.locked != null -> target.clear(accountId) + // Offline without a copy, or a refused read: keep what the last sync built. + state.needsConnection || state.problem != null -> Unit + else -> target.replace(accountId, AutofillIndex.of(state), persist = !state.onlineOnly, keys = keys) + } + } + + fun clear(accountId: String) { + sink?.clear(accountId) + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSites.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSites.kt new file mode 100644 index 000000000..520e3e7a7 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSites.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import nl.conduction.keepiq.shared.unlock.UnlockedVault +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import nl.conduction.keepiq.shared.vault.VaultKeys + +/** One entry for ASCredentialIdentityStore: a site, a user name and the record that finds the item again. */ +data class PasswordIdentity(val host: String, val user: String, val recordIdentifier: String) + +/** + * The iOS side of the autofill index (task 4.4). The AutoFill extension + * runs with a small memory limit, so it never loads the whole vault: the + * app writes one small file per site (registrable domain), and the + * extension reads only the files of the sites it is asked about + * (design, "iOS extension memory"). + */ +object AutofillSites { + /** The index split per site: site key to the JSON of its entries. App-linked items are Android only. */ + fun split(index: AutofillIndex): Map = + index.entries + .filter { AppLink.parse(it.url) == null } + .groupBy { siteKey(it.url ?: "") } + .filterKeys { it.isNotEmpty() } + .mapValues { (_, entries) -> AutofillIndex(entries).toJson() } + + /** The file a service identifier (a domain or a URL) is in. */ + fun siteKey(serviceIdentifier: String): String = SiteMatch.registrableDomain(SiteMatch.hostOf(serviceIdentifier)) + + /** The host a service identifier names, to match on. */ + fun hostOf(serviceIdentifier: String): String = SiteMatch.hostOf(serviceIdentifier) + + /** The logins of one site file that match [serviceIdentifier], best first. */ + fun matches(siteJson: String, serviceIdentifier: String): List = + AutofillIndex.fromJson(siteJson).candidates(AutofillTarget.Web(hostOf(serviceIdentifier)), totp = false) + + /** The authenticator items of one site file for [serviceIdentifier]. */ + fun codeItems(siteJson: String, serviceIdentifier: String): List = + AutofillIndex.fromJson(siteJson).candidates(AutofillTarget.Web(hostOf(serviceIdentifier)), totp = true) + + /** Logins of one site file whose name or address holds [query] ("Pick another login"). */ + fun search(siteJson: String, query: String): List { + val needle = query.trim().lowercase() + return AutofillIndex.fromJson(siteJson).entries.filter { e -> + e.isLogin && (needle.isEmpty() || e.name.lowercase().contains(needle) || (e.url ?: "").lowercase().contains(needle)) + } + } + + /** + * What ASCredentialIdentityStore gets: the site and the user name of + * each login, never a password. Decrypts the user names only. + */ + fun identities(accountId: String, index: AutofillIndex, keys: VaultKeys): List = + index.entries.filter { it.isLogin && AppLink.parse(it.url) == null }.mapNotNull { e -> + val host = SiteMatch.hostOf(e.url ?: "").takeIf { it.isNotEmpty() } ?: return@mapNotNull null + val user = runCatching { keys.decryptField(e.login) }.getOrNull() ?: return@mapNotNull null + PasswordIdentity(host, user, recordIdentifier(accountId, e.id)) + } + + fun recordIdentifier(accountId: String, itemId: String): String = "$accountId|$itemId" + + /** The item id of a record identifier, or null for another account's record. */ + fun itemId(recordIdentifier: String, accountId: String): String? = + recordIdentifier.split('|').takeIf { it.size == 2 && it[0] == accountId }?.get(1) + + /** The keys of an unlocked vault, for Swift. */ + @Throws(Exception::class) + fun keysOf(vault: UnlockedVault): VaultKeys = + RsaVaultKeys.fromPem(vault.privateKeyPem, vault.certificate ?: throw IllegalStateException("The vault has no certificate"), vault.suiteId, vault.unlockKeyEpoch) + + /** Decrypts one field, for Swift. */ + @Throws(Exception::class) + fun decrypt(keys: VaultKeys, ciphertext: String?): String = keys.decryptField(ciphertext) + + /** The current code of an authenticator entry, or null. */ + fun code(keys: VaultKeys, entry: AutofillEntry, nowMillis: Long): String? = runCatching { + nl.conduction.keepiq.shared.crypto.Totp.generate(nl.conduction.keepiq.shared.crypto.Totp.parse(keys.decryptField(entry.key)), nowMillis) + }.getOrNull() +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetector.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetector.kt new file mode 100644 index 000000000..29689c0e3 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetector.kt @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +/** + * What the platform tells about one input field: the autofill hints an app + * set, the HTML tag and attributes a browser or WebView reports, the view id, + * the hint text and the input type. Android's ViewNode maps onto it. + */ +data class FieldFacts( + val autofillHints: List = emptyList(), + val htmlTag: String? = null, + val htmlAttributes: Map = emptyMap(), + val idEntry: String? = null, + val hint: String? = null, + /** The input type marks a password (text, web, visible or number password). */ + val passwordInput: Boolean = false, + /** The input type is a number or a phone number. */ + val numberInput: Boolean = false, + /** A text field the user can type in: not a button, label or hidden input. */ + val editable: Boolean = true, + val visible: Boolean = true, +) + +enum class FieldKind { USERNAME, PASSWORD, NEW_PASSWORD, ONE_TIME_CODE, OTHER } + +/** The fields of one form, as indexes into the list given to [FieldDetector.detect]. */ +data class DetectedFields( + val username: Int?, + val passwords: List, + val newPasswords: List, + val oneTimeCode: Int?, +) { + val isLogin: Boolean get() = passwords.isNotEmpty() || newPasswords.isNotEmpty() || username != null + val isEmpty: Boolean get() = !isLogin && oneTimeCode == null +} + +/** + * Finds user name, password and one-time-code fields, with the browser + * extension's rules (browser-extension/src/lib/field-detect.js and the + * OTP_SELECTORS of content-script.js) applied to what Android reports, and + * Android's own autofill hints first. + */ +object FieldDetector { + /** USERNAME_WORDS in field-detect.js: a leading word boundary only. */ + private val USERNAME_WORDS = Regex("\\b(user ?name|user|e-?mail|login|account|gebruiker)", RegexOption.IGNORE_CASE) + + private val USERNAME_HINTS = setOf("username", "emailaddress", "email", "newusername") + private val PASSWORD_HINTS = setOf("password", "current-password", "currentpassword") + private val NEW_PASSWORD_HINTS = setOf("newpassword", "new-password") + private val OTP_HINTS = setOf("smsotpcode", "2faappotpcode", "emailotpcode", "otpcode", "one-time-code", "onetimecode") + private val TEXT_TYPES = setOf("text", "email", "tel", "") + + fun classify(field: FieldFacts): FieldKind { + if (!field.editable || !field.visible) return FieldKind.OTHER + val type = field.htmlAttributes["type"]?.lowercase() + if (type == "hidden" || type == "submit" || type == "button" || type == "checkbox" || type == "radio") return FieldKind.OTHER + val autocomplete = field.htmlAttributes["autocomplete"]?.lowercase()?.split(' ')?.filter { it.isNotEmpty() } ?: emptyList() + val hints = field.autofillHints.map { it.lowercase().replace("_", "") } + + // An explicit autocomplete token or autofill hint decides. + if ("one-time-code" in autocomplete || hints.any { it in OTP_HINTS || it.contains("otp") }) return FieldKind.ONE_TIME_CODE + if ("new-password" in autocomplete || hints.any { it in NEW_PASSWORD_HINTS }) return FieldKind.NEW_PASSWORD + if ("current-password" in autocomplete || hints.any { it in PASSWORD_HINTS }) return FieldKind.PASSWORD + if ("username" in autocomplete || "email" in autocomplete || hints.any { it in USERNAME_HINTS }) return FieldKind.USERNAME + + // OTP_SELECTORS: name or id with "otp" or "totp", or a six-digit numeric input. + val name = field.htmlAttributes["name"]?.lowercase() ?: "" + val id = (field.htmlAttributes["id"] ?: field.idEntry ?: "").lowercase() + if (name.contains("otp") || id.contains("otp")) return FieldKind.ONE_TIME_CODE + if (field.htmlAttributes["inputmode"]?.lowercase() == "numeric" && field.htmlAttributes["maxlength"] == "6") return FieldKind.ONE_TIME_CODE + + // PASSWORD_SELECTORS, and Android's password input types. + if (type == "password" || field.passwordInput) return FieldKind.PASSWORD + + // USERNAME_SELECTORS. + val textual = type == null || type in TEXT_TYPES + if (!textual) return FieldKind.OTHER + if (type == "email") return FieldKind.USERNAME + if (listOf("user", "email", "login").any { name.contains(it) || id.contains(it) }) return FieldKind.USERNAME + return FieldKind.OTHER + } + + /** + * The login fields of a form, in screen order. findLoginFields: the + * user name by its type and name, else by its label or hint text, else + * the text field right before the first password field. + */ + fun detect(fields: List): DetectedFields { + val kinds = fields.map { classify(it) } + val passwords = kinds.indices.filter { kinds[it] == FieldKind.PASSWORD } + val newPasswords = kinds.indices.filter { kinds[it] == FieldKind.NEW_PASSWORD } + val oneTimeCode = kinds.indices.firstOrNull { kinds[it] == FieldKind.ONE_TIME_CODE } + var username = kinds.indices.firstOrNull { kinds[it] == FieldKind.USERNAME } + val textInputs = fields.indices.filter { i -> + val f = fields[i] + val type = f.htmlAttributes["type"]?.lowercase() + f.editable && f.visible && kinds[i] == FieldKind.OTHER && !f.passwordInput && (type == null || type in TEXT_TYPES) + } + if (username == null) { + username = textInputs.firstOrNull { i -> USERNAME_WORDS.containsMatchIn(describedAs(fields[i])) } + } + val firstPassword = (passwords + newPasswords).minOrNull() + if (username == null && firstPassword != null) { + username = textInputs.lastOrNull { it < firstPassword } + } + return DetectedFields(username, passwords, newPasswords, oneTimeCode) + } + + /** describedAs: the label-like text of a field. */ + private fun describedAs(field: FieldFacts): String = listOfNotNull( + field.hint, + field.htmlAttributes["aria-label"], + field.htmlAttributes["placeholder"], + field.htmlAttributes["label"], + ).joinToString(" ") +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/NeverSaveList.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/NeverSaveList.kt new file mode 100644 index 000000000..eacd262a9 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/NeverSaveList.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.account.SecureStorage + +/** + * The sites the user never wants a save offer on, kept on this device only, + * as the extension keeps its `capture-never` list + * (browser-extension/src/background/router.js neverSites and setNever): an + * exact host, so "never" on login.example.com still offers on + * www.example.com. An app is listed as `androidapp://`. + */ +class NeverSaveList(private val storage: SecureStorage) { + fun sites(): List { + val text = storage.read(KEY) ?: return emptyList() + val array = runCatching { Json.parseToJsonElement(text) as? JsonArray }.getOrNull() ?: return emptyList() + return array.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + } + + fun contains(site: String): Boolean = site.isNotEmpty() && site in sites() + + /** setNever: add or remove a site; the list stays sorted and without duplicates. */ + fun set(site: String, on: Boolean): List { + val list = sites().filter { it != site }.toMutableList() + if (on && site.isNotEmpty()) list.add(site) + list.sort() + storage.write(KEY, JsonArray(list.map { JsonPrimitive(it) }).toString()) + return list + } + + companion object { + const val KEY = "autofill:never-save" + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/SiteMatch.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/SiteMatch.kt new file mode 100644 index 000000000..eadd9b129 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/SiteMatch.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +/** What a matcher needs of an item: its plaintext name and address, never a decrypted value. */ +interface Matchable { + val id: String + val name: String + val url: String? + val useOnly: Boolean + val lastUsedAt: String? get() = null +} + +/** An item with the score it matched with; higher is better. */ +data class Scored(val item: T, val score: Int) + +/** + * Site matching, the same as the browser extension's + * (browser-extension/src/lib/match.js and useOnly.js): a registrable domain + * approximated with a small public-suffix set, an exact host first, then the + * same site, then the item name as a fallback. tests/vectors/autofill holds + * the cases both run. + * + * Matching only narrows what the user picks from. Nothing is filled without + * the user choosing an entry. + */ +object SiteMatch { + /** MULTI_LABEL_SUFFIXES in match.js. */ + val MULTI_LABEL_SUFFIXES: Set = setOf( + "co.uk", "org.uk", "gov.uk", "ac.uk", "co.jp", "or.jp", "ne.jp", "com.au", "net.au", "org.au", + "com.br", "co.nz", "co.za", "com.mx", "co.in", "gov.nl", + ) + + private val SCHEME = Regex("^[a-z]+://", RegexOption.IGNORE_CASE) + + /** hostOf: the host name of a URL or bare host, lower case; "" when it cannot be read. */ + fun hostOf(input: String?): String { + if (input.isNullOrEmpty()) return "" + val value = input.trim() + val withScheme = if (SCHEME.containsMatchIn(value)) value else "https://$value" + return UrlHost.hostname(withScheme)?.lowercase() ?: "" + } + + /** isPublicSuffix: a single label, or one of the multi-label suffixes. */ + fun isPublicSuffix(host: String?): Boolean { + val h = hostOf(host) + return h != "" && (h.indexOf('.') == -1 || h in MULTI_LABEL_SUFFIXES) + } + + /** registrableDomain: the eTLD+1 approximation. */ + fun registrableDomain(host: String?): String { + val h = hostOf(host) + if (h.isEmpty() || h.indexOf('.') == -1) return h + val parts = h.split('.') + val lastTwo = parts.takeLast(2).joinToString(".") + val lastThree = parts.takeLast(3).joinToString(".") + if (parts.size >= 3 && lastTwo in MULTI_LABEL_SUFFIXES) return lastThree + return lastTwo + } + + /** matchScore: 100 for the exact host, 80 for the same site, 40 or 20 for the name. */ + fun matchScore(name: String?, url: String?, targetHost: String?): Int { + val target = hostOf(targetHost) + if (target.isEmpty()) return 0 + val targetReg = registrableDomain(target) + val secretHost = hostOf(url) + if (secretHost.isNotEmpty()) { + if (secretHost == target) return 100 + if (registrableDomain(secretHost) == targetReg && targetReg.isNotEmpty()) return 80 + } + val lowerName = (name ?: "").lowercase() + if (targetReg.isNotEmpty() && lowerName.contains(targetReg)) return 40 + val label = targetReg.split('.')[0] + if (label.isNotEmpty() && label.length >= 3 && lowerName.contains(label)) return 20 + return 0 + } + + /** matchSecrets: the items with a positive score, best first, then the one used last. */ + fun matchSecrets(items: List, targetHost: String?): List> = + items.map { Scored(it, matchScore(it.name, it.url, targetHost)) } + .filter { it.score > 0 } + .sortedWith { a, b -> + val byScore = b.score - a.score + if (byScore != 0) byScore else (b.item.lastUsedAt ?: "").compareTo(a.item.lastUsedAt ?: "") + } + + /** allowedOnHost (useOnly.js): a use-only item fills only on its own registrable domain. */ + fun allowedOnHost(item: Matchable, host: String?): Boolean { + if (!item.useOnly) return true + val own = registrableDomain(hostOf(item.url ?: "")) + return own != "" && own == registrableDomain(host) + } + + /** filterForHost (useOnly.js). */ + fun filterForHost(items: List>, host: String?): List> = + items.filter { allowedOnHost(it.item, host) } + + /** blocksSavePrompt (useOnly.js): a use-only item for this site means no save or update offer. */ + fun blocksSavePrompt(items: List, host: String?): Boolean = + items.any { it.useOnly && allowedOnHost(it, host) } +} + +/** What a submitted login means (browser-extension/src/lib/capture.js classifyCapture). */ +sealed class SaveOffer { + data object Save : SaveOffer() + + data class Update(val id: String, val name: String) : SaveOffer() + + /** Nothing to offer: the same login is stored, or several stored logins could be meant. */ + data object None : SaveOffer() +} + +/** The decrypted login name and password of a stored item, for [Capture.classify]. */ +data class PlainLogin(val login: String, val secret: String) { + override fun toString(): String = "PlainLogin(…)" +} + +/** + * The extension's capture rules (capture.js and useOnly.js) on the device: + * which stored logins a submitted one belongs to. + */ +object Capture { + /** + * classifyCapture: update when one stored login for the site has this + * login name and another password, nothing when it has this password or + * several have this login name, else save. Only same-site matches + * (score 80 or more) count. [decrypt] returns null for an item it + * cannot open, which is skipped. + */ + fun classify(host: String, login: String, secret: String, items: List, decrypt: (T) -> PlainLogin?): SaveOffer { + val target = SiteMatch.hostOf(host) + return classifyCandidates(SiteMatch.matchSecrets(items, target).filter { it.score >= 80 }.map { it.item }, login, secret, decrypt) + } + + /** The decision of [classify] over candidates already narrowed to the site or app. */ + fun classifyCandidates(candidates: List, login: String, secret: String, decrypt: (T) -> PlainLogin?): SaveOffer { + val sameLogin = ArrayList() + for (item in candidates) { + val plain = runCatching { decrypt(item) }.getOrNull() ?: continue + if (plain.login != login) continue + if (plain.secret == secret) return SaveOffer.None + sameLogin.add(item) + } + return when { + sameLogin.size == 1 -> SaveOffer.Update(sameLogin[0].id, sameLogin[0].name) + sameLogin.size > 1 -> SaveOffer.None + else -> SaveOffer.Save + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/UrlHost.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/UrlHost.kt new file mode 100644 index 000000000..0766c7605 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/autofill/UrlHost.kt @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +/** + * The host name the browser extension's `hostOf` reads from a URL + * (browser-extension/src/lib/match.js), which is `new URL(…).hostname` + * lower-cased. Common code has no WHATWG URL parser, so this is one for the + * part `hostOf` uses: the authority. It follows the URL Standard's host + * parser for special schemes (percent-decoding, forbidden code points, + * IPv4 numbers, IPv6 brackets, IDNA as punycode) and the opaque host rules + * for the others, such as `androidapp://`. + * + * IDNA here lower-cases and punycodes each label. The full UTS 46 mapping + * table is not carried; a host that needs more than lower case to map reads + * differently from the extension, which only narrows or widens the list the + * user picks from (match.js says the same of its suffix list). + */ +internal object UrlHost { + private val SPECIAL = setOf("http", "https", "ws", "wss", "ftp", "file") + + /** Code points a domain may not hold after percent-decoding (URL Standard, forbidden domain code point). */ + private const val FORBIDDEN_HOST = " #/:<>?@[\\]^|" + + /** `new URL(input).hostname`, or null where the URL parser throws. */ + fun hostname(url: String): String? { + // The parser trims C0 controls and spaces, and drops tabs and newlines anywhere. + val input = url.trim { it <= ' ' }.filter { it != '\t' && it != '\n' && it != '\r' } + val colon = input.indexOf(':') + if (colon <= 0) return null + val scheme = input.substring(0, colon).lowercase() + if (!scheme[0].isAsciiLetter() || !scheme.all { it.isAsciiLetter() || it.isDigit() || it == '+' || it == '-' || it == '.' }) return null + val special = scheme in SPECIAL + var rest = input.substring(colon + 1) + if (scheme == "file") { + // file://host/path names a host; file:/path and file:///path do not. + if (!rest.startsWith("//")) return "" + val host = rest.substring(2).substringBefore('/').substringBefore('\\').substringBefore('?').substringBefore('#') + if (host.isEmpty()) return "" + return parseSpecialHost(host)?.let { if (it == "localhost") "" else it } + } + if (special) { + // Special schemes take any number of slashes, either way round. + rest = rest.trimStart('/', '\\') + } else { + if (!rest.startsWith("//")) return "" + rest = rest.substring(2) + } + val end = rest.indexOfFirst { it == '/' || it == '?' || it == '#' || (special && it == '\\') } + var authority = if (end < 0) rest else rest.substring(0, end) + val at = authority.lastIndexOf('@') + if (at >= 0) authority = authority.substring(at + 1) + val host = splitPort(authority) ?: return null + if (!special) return parseOpaqueHost(host) + if (host.isEmpty()) return null + return parseSpecialHost(host) + } + + /** The host without its port; null when the port is not a number up to 65535. */ + private fun splitPort(authority: String): String? { + val bracketEnd = authority.lastIndexOf(']') + val colon = authority.lastIndexOf(':') + if (colon < 0 || colon < bracketEnd) return authority + val port = authority.substring(colon + 1) + if (port.isNotEmpty() && (!port.all { it in '0'..'9' } || port.trimStart('0').length > 5 || (port.trimStart('0').toIntOrNull() ?: 0) > 65535)) { + return null + } + return authority.substring(0, colon) + } + + private fun parseOpaqueHost(host: String): String? { + if (host.startsWith("[")) return parseIpv6Literal(host) + if (host.any { it in " #/:<>?@[\\]^|" }) return null + return host + } + + private fun parseSpecialHost(host: String): String? { + if (host.startsWith("[")) return parseIpv6Literal(host) + val decoded = percentDecode(host) ?: return null + val ascii = toAscii(decoded) ?: return null + if (ascii.isEmpty()) return null + if (ascii.any { it in FORBIDDEN_HOST || it.code < 0x20 || it.code == 0x7f || it == '%' }) return null + if (endsInANumber(ascii)) return ipv4(ascii) + return ascii + } + + private fun parseIpv6Literal(host: String): String? { + if (!host.endsWith("]")) return null + val inner = host.substring(1, host.length - 1) + if (inner.isEmpty() || !inner.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == ':' || it == '.' }) return null + // The serialisation compresses zeros; the extension compares the + // result only with other hosts, so the lower-cased literal is kept. + return "[" + inner.lowercase() + "]" + } + + private fun percentDecode(text: String): String? { + if (!text.contains('%')) return text + val bytes = ArrayList() + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '%' && i + 2 < text.length && isHex(text[i + 1]) && isHex(text[i + 2])) { + bytes.add(text.substring(i + 1, i + 3).toInt(16).toByte()) + i += 3 + continue + } + c.toString().encodeToByteArray().forEach { bytes.add(it) } + i++ + } + return bytes.toByteArray().decodeToString() + } + + private fun isHex(c: Char) = c in '0'..'9' || c.lowercaseChar() in 'a'..'f' + + /** Lower case, then punycode for every label with a non-ASCII character. */ + private fun toAscii(domain: String): String? { + val mapped = domain.lowercase().replace('。', '.').replace('.', '.').replace('。', '.') + val labels = ArrayList() + for (label in mapped.split('.')) { + labels += if (label.all { it.code < 0x80 }) label else "xn--" + (Punycode.encode(label) ?: return null) + } + return labels.joinToString(".") + } + + private fun endsInANumber(host: String): Boolean { + val parts = host.split('.').toMutableList() + if (parts.last().isEmpty()) { + if (parts.size == 1) return false + parts.removeAt(parts.size - 1) + } + val last = parts.last() + if (last.isNotEmpty() && last.all { it in '0'..'9' }) return true + return parseIpv4Number(last) != null + } + + /** One IPv4 part: decimal, 0x hexadecimal or 0 octal. */ + private fun parseIpv4Number(part: String): Long? { + if (part.isEmpty()) return null + var text = part + var radix = 10 + if (text.length >= 2 && (text.startsWith("0x") || text.startsWith("0X"))) { + text = text.substring(2) + radix = 16 + } else if (text.length >= 2 && text.startsWith("0")) { + text = text.substring(1) + radix = 8 + } + if (text.isEmpty()) return 0 + val digits = when (radix) { + 16 -> text.all { isHex(it) } + 8 -> text.all { it in '0'..'7' } + else -> text.all { it in '0'..'9' } + } + if (!digits) return null + return text.toBigLongOrNull(radix) + } + + private fun String.toBigLongOrNull(radix: Int): Long? { + var value = 0L + for (c in this) { + value = value * radix + c.digitToInt(radix) + if (value > 0xFFFFFFFFL * 256) return Long.MAX_VALUE + } + return value + } + + private fun ipv4(host: String): String? { + val parts = host.split('.').toMutableList() + if (parts.last().isEmpty() && parts.size > 1) parts.removeAt(parts.size - 1) + if (parts.size > 4) return null + val numbers = parts.map { parseIpv4Number(it) ?: return null } + if (numbers.dropLast(1).any { it > 255 }) return null + val limit = 1L shl (8 * (5 - numbers.size)) + if (numbers.last() >= limit) return null + var address = numbers.last() + numbers.dropLast(1).forEachIndexed { i, n -> address += n shl (8 * (3 - i)) } + return (3 downTo 0).joinToString(".") { ((address shr (8 * it)) and 0xFF).toString() } + } + + private fun Char.isAsciiLetter() = this in 'a'..'z' || this in 'A'..'Z' +} + +/** RFC 3492 punycode encoding of one label, as IDNA's ToASCII applies it. */ +internal object Punycode { + private const val BASE = 36 + private const val TMIN = 1 + private const val TMAX = 26 + private const val SKEW = 38 + private const val DAMP = 700 + private const val INITIAL_BIAS = 72 + private const val INITIAL_N = 128 + + fun encode(label: String): String? { + val codePoints = codePointsOf(label) + val out = StringBuilder() + codePoints.filter { it < 0x80 }.forEach { out.append(it.toChar()) } + val basic = out.length + var handled = basic + if (basic > 0) out.append('-') + var n = INITIAL_N + var delta = 0L + var bias = INITIAL_BIAS + while (handled < codePoints.size) { + val m = codePoints.filter { it >= n }.minOrNull() ?: return null + delta += (m - n).toLong() * (handled + 1) + n = m + for (c in codePoints) { + if (c < n) delta++ + if (c == n) { + var q = delta + var k = BASE + while (true) { + val t = if (k <= bias) TMIN else if (k >= bias + TMAX) TMAX else k - bias + if (q < t) break + out.append(digit((t + (q - t) % (BASE - t)).toInt())) + q = (q - t) / (BASE - t) + k += BASE + } + out.append(digit(q.toInt())) + bias = adapt(delta, handled + 1, handled == basic) + delta = 0 + handled++ + } + } + delta++ + n++ + } + return out.toString() + } + + private fun adapt(deltaIn: Long, points: Int, first: Boolean): Int { + var delta = if (first) deltaIn / DAMP else deltaIn / 2 + delta += delta / points + var k = 0 + while (delta > ((BASE - TMIN) * TMAX) / 2) { + delta /= BASE - TMIN + k += BASE + } + return (k + (BASE - TMIN + 1) * delta / (delta + SKEW)).toInt() + } + + private fun digit(d: Int): Char = if (d < 26) 'a' + d else '0' + (d - 26) + + private fun codePointsOf(text: String): List { + val out = ArrayList() + var i = 0 + while (i < text.length) { + val c = text[i] + if (c.isHighSurrogate() && i + 1 < text.length && text[i + 1].isLowSurrogate()) { + out.add(((c.code - 0xD800) shl 10) + (text[i + 1].code - 0xDC00) + 0x10000) + i += 2 + } else { + out.add(c.code) + i++ + } + } + return out + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Cbor.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Cbor.kt new file mode 100644 index 000000000..7bd4a9512 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Cbor.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * The CBOR subset WebAuthn needs, byte for byte as + * browser-extension/src/passkey/cbor.js cborEncode writes it: integers, + * byte strings, text strings, arrays and maps in insertion order, always in + * the shortest head. Used for the attestation object and the COSE key. + */ +internal object Cbor { + /** A map with its keys in the order given, as the extension's Map keeps them. */ + class OrderedMap(val entries: List>) + + fun encode(value: Any): ByteArray = when (value) { + is Int -> integer(value.toLong()) + is Long -> integer(value) + is ByteArray -> head(2, value.size.toLong()) + value + is String -> Encoding.utf8(value).let { head(3, it.size.toLong()) + it } + is List<*> -> value.fold(head(4, value.size.toLong())) { acc, v -> acc + encode(v ?: error("cbor: null")) } + is OrderedMap -> value.entries.fold(head(5, value.entries.size.toLong())) { acc, (k, v) -> acc + encode(k) + encode(v) } + else -> throw IllegalArgumentException("cbor: unsupported value ${value::class.simpleName}") + } + + private fun integer(value: Long): ByteArray = if (value >= 0) head(0, value) else head(1, -value - 1) + + private fun head(major: Int, value: Long): ByteArray { + val m = major shl 5 + return when { + value < 24 -> byteArrayOf((m or value.toInt()).toByte()) + value < 0x100 -> byteArrayOf((m or 24).toByte(), value.toByte()) + value < 0x10000 -> byteArrayOf((m or 25).toByte(), (value shr 8).toByte(), value.toByte()) + else -> byteArrayOf((m or 26).toByte()) + Encoding.uint32BigEndian(value) + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Digest.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Digest.kt new file mode 100644 index 000000000..7d437804f --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Digest.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** The platform digest and random source, for code outside the crypto package. */ +object Digest { + fun sha256(data: ByteArray): ByteArray = Primitives.sha256(data) +} + +/** Cryptographically secure random bytes from the platform (design D2). */ +object SecureRandomBytes { + fun next(size: Int): ByteArray = Primitives.randomBytes(size) +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/EcKeys.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/EcKeys.kt new file mode 100644 index 000000000..c22a6f1ab --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/EcKeys.kt @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * P-256 keys in the shapes WebCrypto exports them, so a passkey made on the + * phone stores the same bytes the browser extension would + * (browser-extension/src/passkey/webauthn.js createCredential exports + * `pkcs8` and `raw`): + * + * - PKCS#8 with the curve named and the public key included, as Chrome, + * Firefox and Node export it. Firefox imports an EC key only with its + * public key, so the phone always writes it. + * - SubjectPublicKeyInfo and the COSE EC2 key from the uncompressed point. + */ +internal object EcKeys { + private val EC_ALGORITHM = byteArrayOf( + 0x30, 0x13, + 0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01, + 0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07, + ) + + /** DER PrivateKeyInfo: version 0, id-ecPublicKey on prime256v1, ECPrivateKey with the public key. */ + fun pkcs8(d: ByteArray, point: ByteArray): ByteArray { + require(d.size == 32 && point.size == 65 && point[0] == 0x04.toByte()) { "not a P-256 key" } + val publicKey = byteArrayOf(0xA1.toByte(), 0x44, 0x03, 0x42, 0x00) + point + val ecPrivateKey = byteArrayOf(0x30, 0x6B, 0x02, 0x01, 0x01, 0x04, 0x20) + d + publicKey + val body = byteArrayOf(0x02, 0x01, 0x00) + EC_ALGORITHM + byteArrayOf(0x04, 0x6D) + ecPrivateKey + return byteArrayOf(0x30, 0x81.toByte(), 0x87.toByte()) + body + } + + /** DER SubjectPublicKeyInfo of an uncompressed P-256 point. */ + fun spki(point: ByteArray): ByteArray { + require(point.size == 65 && point[0] == 0x04.toByte()) { "not an uncompressed P-256 point" } + return byteArrayOf(0x30, 0x59) + EC_ALGORITHM + byteArrayOf(0x03, 0x42, 0x00) + point + } + + /** The uncompressed point of a P-256 SubjectPublicKeyInfo: its last 65 bytes. */ + fun pointOfSpki(spki: ByteArray): ByteArray { + require(spki.size == 91 && spki[spki.size - 65] == 0x04.toByte()) { "not a P-256 public key" } + return spki.copyOfRange(spki.size - 65, spki.size) + } + + /** + * The scalar and, when the key carries it, the public point of a P-256 + * PKCS#8 key (the shape WebCrypto and this file write). Null for any + * other shape. + */ + fun parsePkcs8(der: ByteArray): Pair? = runCatching { + val outer = Der(der).sequence() + outer.integer() + outer.sequence() + val inner = Der(outer.octets()).sequence() + inner.integer() + val d = inner.octets() + var point: ByteArray? = null + while (inner.hasMore()) { + val (tag, value) = inner.any() + if (tag == 0xA1) point = Der(value).bitString() + } + if (d.size != 32) null else d to point?.takeIf { it.size == 65 && it[0] == 0x04.toByte() } + }.getOrNull() + + /** The COSE EC2 key (kty 2, alg -7, crv 1, x, y) of an uncompressed point, as coseKeyFromRawPoint writes it. */ + fun cose(point: ByteArray): ByteArray = Cbor.encode( + Cbor.OrderedMap( + listOf( + 1 to 2, + 3 to -7, + -1 to 1, + -2 to point.copyOfRange(1, 33), + -3 to point.copyOfRange(33, 65), + ), + ), + ) + + /** PEM with 64-character lines and a final newline, as webauthn.js pemFrom writes it. */ + fun pem(der: ByteArray, label: String): String = + "-----BEGIN $label-----\n" + Encoding.toBase64(der).chunked(64).joinToString("\n") + "\n-----END $label-----\n" + + /** A minimal DER reader: definite lengths only, enough for the keys above. */ + private class Der(private val bytes: ByteArray) { + private var pos = 0 + + fun hasMore(): Boolean = pos < bytes.size + + fun any(): Pair { + val tag = bytes[pos++].toInt() and 0xFF + var length = bytes[pos++].toInt() and 0xFF + if (length and 0x80 != 0) { + val count = length and 0x7F + require(count in 1..2) { "der: length" } + length = 0 + repeat(count) { length = (length shl 8) or (bytes[pos++].toInt() and 0xFF) } + } + require(pos + length <= bytes.size) { "der: truncated" } + val value = bytes.copyOfRange(pos, pos + length) + pos += length + return tag to value + } + + private fun readTag(tag: Int): ByteArray = any().let { (t, v) -> + require(t == tag) { "der: tag $t, expected $tag" } + v + } + + fun sequence(): Der = Der(readTag(0x30)) + fun integer(): ByteArray = readTag(0x02) + fun octets(): ByteArray = readTag(0x04) + fun bitString(): ByteArray = readTag(0x03).let { it.copyOfRange(1, it.size) } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Encoding.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Encoding.kt new file mode 100644 index 000000000..9e98bd2dc --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Encoding.kt @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlin.io.encoding.Base64 + +/** + * Byte and text encodings exactly as the web app's runtime does them, so a + * value encoded here decodes there unchanged and the reverse. + */ +object Encoding { + private val base64 = Base64.Default + private val base64UrlNoPad = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + private val base64UrlAnyPad = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + + /** Standard base64 with padding (`btoa`). */ + fun toBase64(bytes: ByteArray): String = base64.encode(bytes) + + /** Standard base64 (`atob`). Whitespace is not accepted, as the server never sends it. */ + fun fromBase64(text: String): ByteArray = try { + base64.decode(text) + } catch (e: IllegalArgumentException) { + throw KeepiqCryptoException("Not valid base64", e) + } + + /** base64url without padding (sendCrypto.js toBase64Url, webauthn.js b64urlEncode). */ + fun toBase64Url(bytes: ByteArray): String = base64UrlNoPad.encode(bytes) + + /** base64url with or without padding (sendCrypto.js fromBase64Url). */ + fun fromBase64Url(text: String): ByteArray = try { + base64UrlAnyPad.decode(text) + } catch (e: IllegalArgumentException) { + throw KeepiqCryptoException("Not valid base64url", e) + } + + /** + * UTF-8 as `TextEncoder.encode` writes it: a lone surrogate becomes + * U+FFFD (EF BF BD), where the JVM would write `?`. + */ + fun utf8(text: String): ByteArray { + val out = ArrayList(text.length + 8) + var i = 0 + while (i < text.length) { + var cp = text[i].code + if (cp in 0xD800..0xDBFF && i + 1 < text.length && text[i + 1].code in 0xDC00..0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) shl 10) + (text[i + 1].code - 0xDC00) + i++ + } else if (cp in 0xD800..0xDFFF) { + cp = 0xFFFD + } + when { + cp < 0x80 -> out.add(cp.toByte()) + cp < 0x800 -> { + out.add((0xC0 or (cp shr 6)).toByte()) + out.add((0x80 or (cp and 0x3F)).toByte()) + } + cp < 0x10000 -> { + out.add((0xE0 or (cp shr 12)).toByte()) + out.add((0x80 or ((cp shr 6) and 0x3F)).toByte()) + out.add((0x80 or (cp and 0x3F)).toByte()) + } + else -> { + out.add((0xF0 or (cp shr 18)).toByte()) + out.add((0x80 or ((cp shr 12) and 0x3F)).toByte()) + out.add((0x80 or ((cp shr 6) and 0x3F)).toByte()) + out.add((0x80 or (cp and 0x3F)).toByte()) + } + } + i++ + } + return out.toByteArray() + } + + /** + * UTF-8 as `new TextDecoder().decode` reads it: invalid sequences become + * U+FFFD, and one leading byte order mark is dropped (ignoreBOM is false + * by default), which the web app's rsaDecrypt and decryptPrivateKey rely on. + */ + fun fromUtf8(bytes: ByteArray): String { + val start = if (bytes.size >= 3 && bytes[0] == 0xEF.toByte() && bytes[1] == 0xBB.toByte() && + bytes[2] == 0xBF.toByte() + ) { + 3 + } else { + 0 + } + return bytes.decodeToString(start, bytes.size, throwOnInvalidSequence = false) + } + + internal fun uint32BigEndian(value: Long): ByteArray = byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) + + internal fun readUint32BigEndian(bytes: ByteArray, offset: Int): Long = + ((bytes[offset].toLong() and 0xFF) shl 24) or + ((bytes[offset + 1].toLong() and 0xFF) shl 16) or + ((bytes[offset + 2].toLong() and 0xFF) shl 8) or + (bytes[offset + 3].toLong() and 0xFF) + + internal fun hex(bytes: ByteArray): String = bytes.joinToString("") { + (it.toInt() and 0xFF).toString(16).padStart(2, '0') + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Passkey.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Passkey.kt new file mode 100644 index 000000000..2b35ab945 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Passkey.kt @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +/** + * The passkey item: one WebAuthn credential as JSON in the encrypted `key` + * field of a `passkey` item. Field order, defaults and the required fields + * follow src/passkey/passkey.js (buildPasskeyCredential). + */ +data class PasskeyCredential( + val credentialId: String, + val rpId: String, + val rpName: String, + val userName: String, + val userDisplayName: String, + val userHandle: String, + val privateKey: String, + val algorithm: Long, + val counter: Long, + val transports: List, + val createdAt: String, +) { + /** serializePasskey: the JSON stored, encrypted, in the item's `key` field. */ + fun toJson(): String = buildJsonObject { + put("credentialId", credentialId) + put("rpId", rpId) + put("rpName", rpName) + put("userName", userName) + put("userDisplayName", userDisplayName) + put("userHandle", userHandle) + put("privateKey", privateKey) + put("algorithm", algorithm) + put("counter", counter) + put("transports", buildJsonArray { transports.forEach { add(JsonPrimitive(it)) } }) + put("createdAt", createdAt) + }.toString() + + companion object { + const val TYPE_NAME = "passkey" + const val ES256 = -7L + + /** + * parsePasskey: null when the JSON does not parse or a required field + * (credentialId, rpId, privateKey) is missing or empty. [now] fills an + * absent createdAt, as the web app does with the current time. + */ + fun parse(json: String, now: () -> String): PasskeyCredential? { + val obj = try { + Json.parseToJsonElement(json) as? JsonObject + } catch (e: Exception) { + null + } ?: return null + fun str(name: String): String? = (obj[name] as? JsonPrimitive)?.takeIf { it.isString }?.content + fun loose(name: String): String { + val p = obj[name] as? JsonPrimitive ?: return "" + return if (p.isString) p.content else p.toString() + } + val credentialId = str("credentialId")?.ifEmpty { null } ?: return null + val rpId = str("rpId")?.ifEmpty { null } ?: return null + val privateKey = str("privateKey")?.ifEmpty { null } ?: return null + fun integer(name: String): Long? = (obj[name] as? JsonPrimitive)?.takeIf { !it.isString }?.let { + it.longOrNull ?: it.intOrNull?.toLong() + } + return PasskeyCredential( + credentialId = credentialId, + rpId = rpId, + rpName = loose("rpName"), + userName = loose("userName"), + userDisplayName = loose("userDisplayName"), + userHandle = loose("userHandle"), + privateKey = privateKey, + algorithm = integer("algorithm") ?: ES256, + counter = integer("counter") ?: 0L, + transports = (obj["transports"] as? JsonArray)?.map { + (it as? JsonPrimitive)?.let { p -> if (p.isString) p.content else p.toString() } ?: it.toString() + } ?: emptyList(), + createdAt = str("createdAt")?.ifEmpty { null } ?: now(), + ) + } + } +} + +/** + * The client data a WebAuthn signature covers. Built here as the browser + * extension builds it when the caller sends a challenge and Keepiq knows the + * verified origin (an Android app); or only its hash, when the caller built + * the JSON itself (a browser on Android, and every request on iOS). + */ +class ClientData private constructor( + /** The clientDataJSON, or null when the caller sent only its hash. */ + val json: ByteArray?, + /** SHA-256 of the clientDataJSON: what the signature covers. */ + val hash: ByteArray, +) { + companion object { + /** + * `{"type":…,"challenge":…,"origin":…,"crossOrigin":false}`, the + * member order and spelling of webauthn.js. [origin] MUST be the + * origin the operating system verified, never one from the request. + */ + fun build(type: String, challenge: ByteArray, origin: String): ClientData { + val text = "{\"type\":${JsonPrimitive(type)},\"challenge\":${JsonPrimitive(Encoding.toBase64Url(challenge))}," + + "\"origin\":${JsonPrimitive(origin)},\"crossOrigin\":false}" + val json = Encoding.utf8(text) + return ClientData(json, Primitives.sha256(json)) + } + + /** The hash the caller computed over its own clientDataJSON. */ + fun hashed(hash: ByteArray): ClientData { + require(hash.size == 32) { "clientDataHash must be 32 bytes" } + return ClientData(null, hash.copyOf()) + } + + const val GET = "webauthn.get" + const val CREATE = "webauthn.create" + } +} + +/** + * WebAuthn with an ES256 passkey, byte for byte as + * browser-extension/src/passkey/webauthn.js builds it. + * + * - Assertion: clientDataJSON `{"type":"webauthn.get","challenge":…,"origin":…,"crossOrigin":false}`, + * authenticator data SHA-256(rpId) + flags UP|UV + uint32 counter, and a DER + * ECDSA P-256 signature over authenticatorData + SHA-256(clientDataJSON). + * A stored counter of 0 stays 0; a non-zero counter goes up by one. + * - Creation (createCredential): a new P-256 key, a 16-byte random + * credential id, the all-zero AAGUID, flags UP|UV|AT, counter 0 and a + * `none` attestation object. + */ +object WebAuthn { + private const val FLAG_UP = 0x01 + private const val FLAG_UV = 0x04 + private const val FLAG_AT = 0x40 + private val AAGUID = ByteArray(16) + const val CREDENTIAL_ID_BYTES = 16 + + class Assertion( + /** The clientDataJSON signed over, or null when the caller sent only its hash. */ + val clientDataJSON: ByteArray?, + val authenticatorData: ByteArray, + val signature: ByteArray, + val userHandle: ByteArray?, + val rawId: ByteArray, + /** The counter to write back to the item (unchanged when it was 0). */ + val counter: Long, + ) + + /** A new passkey: the item to save, and what the caller gets back. */ + class Registration( + /** The passkey item JSON (PasskeyCredential.toJson) to save as a `passkey` item. */ + val record: PasskeyCredential, + val credentialId: ByteArray, + /** The clientDataJSON, or null when the caller sent only its hash. */ + val clientDataJSON: ByteArray?, + val authenticatorData: ByteArray, + /** CBOR `{fmt: "none", attStmt: {}, authData}`. */ + val attestationObject: ByteArray, + /** DER SubjectPublicKeyInfo of the new key. */ + val publicKeySpki: ByteArray, + ) + + /** Thrown for a request that names only algorithms other than ES256: the caller declines so another provider can answer. */ + const val UNSUPPORTED_ALGORITHM = "unsupported-algorithm" + + /** Whether a request's pubKeyCredParams allow ES256. An empty list allows the default, which includes it. */ + fun supports(algorithms: List): Boolean = algorithms.isEmpty() || PasskeyCredential.ES256 in algorithms + + /** + * Signs [challenge] for [rpId] at [origin]. The rpId MUST come from the + * operating system's verified origin, never from page content (design D7). + */ + fun getAssertion(challenge: ByteArray, rpId: String, origin: String, stored: PasskeyCredential): Assertion = + getAssertion(ClientData.build(ClientData.GET, challenge, origin), rpId, stored) + + /** Signs [clientData] for [rpId] with the stored key. */ + fun getAssertion(clientData: ClientData, rpId: String, stored: PasskeyCredential): Assertion { + if (stored.algorithm != PasskeyCredential.ES256) { + throw KeepiqCryptoException(UNSUPPORTED_ALGORITHM) + } + val nextCounter = if (stored.counter > 0) stored.counter + 1 else 0L + val authData = authenticatorData(rpId, FLAG_UP or FLAG_UV, nextCounter) + val signature = Primitives.ecdsaP256Sign(pkcs8FromPem(stored.privateKey), authData + clientData.hash) + return Assertion( + clientDataJSON = clientData.json, + authenticatorData = authData, + signature = signature, + userHandle = stored.userHandle.takeIf { it.isNotEmpty() }?.let { Encoding.fromBase64Url(it) }, + rawId = Encoding.fromBase64Url(stored.credentialId), + counter = nextCounter, + ) + } + + /** + * Creates a passkey for [rpId], as createCredential in webauthn.js does. + * Throws [KeepiqCryptoException] with [UNSUPPORTED_ALGORITHM] when + * [algorithms] does not allow ES256. [createdAt] is the ISO time the + * web app's buildPasskeyCredential would fill in. + */ + fun createCredential( + rpId: String, + rpName: String?, + userName: String, + userDisplayName: String, + userHandle: ByteArray?, + algorithms: List, + clientData: ClientData, + createdAt: String, + ): Registration { + if (!supports(algorithms)) throw KeepiqCryptoException(UNSUPPORTED_ALGORITHM) + val keys = Primitives.ecdsaP256Generate() + val credentialId = Primitives.randomBytes(CREDENTIAL_ID_BYTES) + val authData = authenticatorData(rpId, FLAG_UP or FLAG_UV or FLAG_AT, 0L) + + attestedCredentialData(credentialId, keys.point) + val record = PasskeyCredential( + credentialId = Encoding.toBase64Url(credentialId), + rpId = rpId, + rpName = rpName?.ifEmpty { null } ?: rpId, + userName = userName, + userDisplayName = userDisplayName, + userHandle = userHandle?.let { Encoding.toBase64Url(it) } ?: "", + privateKey = EcKeys.pem(EcKeys.pkcs8(keys.d, keys.point), "PRIVATE KEY"), + algorithm = PasskeyCredential.ES256, + counter = 0L, + transports = emptyList(), + createdAt = createdAt, + ) + keys.d.fill(0) + return Registration( + record = record, + credentialId = credentialId, + clientDataJSON = clientData.json, + authenticatorData = authData, + attestationObject = attestationObject(authData), + publicKeySpki = EcKeys.spki(keys.point), + ) + } + + /** Verifies a DER ES256 assertion signature with a DER SPKI public key. */ + fun verify(spki: ByteArray, authenticatorData: ByteArray, clientDataJSON: ByteArray, signature: ByteArray): Boolean = + verifyHash(spki, authenticatorData, Primitives.sha256(clientDataJSON), signature) + + /** Verifies a DER ES256 signature over authenticatorData + clientDataHash. */ + fun verifyHash(spki: ByteArray, authenticatorData: ByteArray, clientDataHash: ByteArray, signature: ByteArray): Boolean = + Primitives.ecdsaP256Verify(spki, authenticatorData + clientDataHash, signature) + + /** AAGUID ‖ uint16 length ‖ credentialId ‖ COSE key. */ + internal fun attestedCredentialData(credentialId: ByteArray, point: ByteArray): ByteArray = + AAGUID + byteArrayOf((credentialId.size shr 8).toByte(), credentialId.size.toByte()) + credentialId + EcKeys.cose(point) + + /** `{fmt: "none", attStmt: {}, authData}` in the extension's key order. */ + internal fun attestationObject(authData: ByteArray): ByteArray = Cbor.encode( + Cbor.OrderedMap(listOf("fmt" to "none", "attStmt" to Cbor.OrderedMap(emptyList()), "authData" to authData)), + ) + + internal fun authenticatorData(rpId: String, flags: Int, counter: Long): ByteArray = + Primitives.sha256(Encoding.utf8(rpId)) + byteArrayOf(flags.toByte()) + Encoding.uint32BigEndian(counter) + + internal fun pkcs8FromPem(pem: String): ByteArray = Encoding.fromBase64( + pem.replace(Regex("-----BEGIN [^-]+-----"), "") + .replace(Regex("-----END [^-]+-----"), "") + .filterNot { it.isWhitespace() }, + ) +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.kt new file mode 100644 index 000000000..564340f4b --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.kt @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * The platform crypto primitives the core builds on (clients-mobile-apps + * design D2, "Platform primitives"). Every format decision lives in common + * code; an actual only runs the named primitive on raw bytes. + * + * - jvm() and Android: javax.crypto and java.security, Bouncy Castle for Argon2id. + * - iOS: Security framework and CryptoKit through cryptography-kotlin. + */ +internal expect object Primitives { + /** Cryptographically secure random bytes. */ + fun randomBytes(size: Int): ByteArray + + /** SHA-256 digest. */ + fun sha256(data: ByteArray): ByteArray + + /** HMAC with SHA-1, SHA-256 or SHA-512. */ + fun hmac(algorithm: HmacAlgorithm, key: ByteArray, data: ByteArray): ByteArray + + /** PBKDF2-HMAC-SHA256 over the password BYTES (the caller UTF-8 encodes). */ + fun pbkdf2Sha256(password: ByteArray, salt: ByteArray, iterations: Int, lengthBytes: Int): ByteArray + + /** AES-256-GCM, 128-bit tag, no AAD. Returns ciphertext followed by the tag. */ + fun aesGcmEncrypt(key: ByteArray, iv: ByteArray, plaintext: ByteArray): ByteArray + + /** AES-256-GCM, 128-bit tag, no AAD. Throws when the tag does not verify. */ + fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertextAndTag: ByteArray): ByteArray + + /** RSA-OAEP with SHA-256 for the hash and MGF1, empty label. [spki] is DER SubjectPublicKeyInfo. */ + fun rsaOaepSha256Encrypt(spki: ByteArray, plaintext: ByteArray): ByteArray + + /** RSA-OAEP with SHA-256 for the hash and MGF1, empty label. [pkcs8] is DER PrivateKeyInfo. */ + fun rsaOaepSha256Decrypt(pkcs8: ByteArray, block: ByteArray): ByteArray + + /** ECDSA P-256 with SHA-256 over [data]. [pkcs8] is DER PrivateKeyInfo. Returns a DER signature. */ + fun ecdsaP256Sign(pkcs8: ByteArray, data: ByteArray): ByteArray + + /** Verifies a DER ECDSA P-256 / SHA-256 signature. [spki] is DER SubjectPublicKeyInfo. */ + fun ecdsaP256Verify(spki: ByteArray, data: ByteArray, signatureDer: ByteArray): Boolean + + /** A new random ECDSA P-256 key pair, as raw bytes; [EcKeys] turns it into the formats the extension writes. */ + fun ecdsaP256Generate(): RawEcKeyPair +} + +/** A P-256 key pair as raw bytes: the 32-byte scalar [d] and the 65-byte uncompressed [point] (0x04 ‖ x ‖ y). */ +internal class RawEcKeyPair(val d: ByteArray, val point: ByteArray) + +/** HMAC hash functions TOTP accepts (src/totp/totp.js HASH_BY_ALGORITHM). */ +internal enum class HmacAlgorithm { SHA1, SHA256, SHA512 } + +/** + * Argon2id (RFC 9106, version 0x13) over the password BYTES. + * + * jvm() and Android use Bouncy Castle. On iOS this is not available yet and + * throws [UnsupportedOperationException]; see [argon2idAvailable]. + */ +internal expect fun argon2id( + password: ByteArray, + salt: ByteArray, + memoryKiB: Int, + iterations: Int, + parallelism: Int, + lengthBytes: Int, +): ByteArray + +/** Whether [argon2id] works on this target. */ +internal expect val argon2idAvailable: Boolean + +/** A crypto operation failed or its input is not in a format the web app writes. */ +open class KeepiqCryptoException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** A private-key envelope whose version word is not 1 (src/crypto/envelope.js). */ +class UnsupportedEnvelopeVersionException(val version: Long) : + KeepiqCryptoException("Unsupported envelope version: $version") diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/PrivateKeyEnvelope.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/PrivateKeyEnvelope.kt new file mode 100644 index 000000000..d6c062333 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/PrivateKeyEnvelope.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * The private-key envelope and the unlock key. + * + * Envelope: base64(uint32 big-endian version 1, 16-byte salt, 12-byte IV, + * AES-256-GCM ciphertext with its 16-byte tag), no AAD. Any other version is + * refused before anything is decrypted. Source: src/crypto/envelope.js. + * + * Unlock key: PBKDF2-HMAC-SHA256, 600,000 iterations, over the UTF-8 master + * password and the envelope's salt, 32 bytes. Source: src/crypto/aes.js. + */ +object PrivateKeyEnvelope { + const val VERSION = 1L + const val SALT_LENGTH = 16 + const val IV_LENGTH = 12 + const val TAG_LENGTH = 16 + const val PBKDF2_ITERATIONS = 600_000 + const val KEY_LENGTH = 32 + private const val HEADER_LENGTH = 4 + SALT_LENGTH + IV_LENGTH + + /** The parts of an envelope. [ciphertextWithTag] is the GCM ciphertext followed by its tag. */ + class Parts(val salt: ByteArray, val iv: ByteArray, val ciphertextWithTag: ByteArray) + + /** Splits an envelope; refuses a short one or a version other than 1. */ + fun decode(envelope: String): Parts { + val raw = Encoding.fromBase64(envelope) + if (raw.size < HEADER_LENGTH + TAG_LENGTH) throw KeepiqCryptoException("Envelope too short") + val version = Encoding.readUint32BigEndian(raw, 0) + if (version != VERSION) throw UnsupportedEnvelopeVersionException(version) + return Parts( + salt = raw.copyOfRange(4, 4 + SALT_LENGTH), + iv = raw.copyOfRange(4 + SALT_LENGTH, HEADER_LENGTH), + ciphertextWithTag = raw.copyOfRange(HEADER_LENGTH, raw.size), + ) + } + + /** Joins the parts into the base64 envelope (encodeEnvelope). */ + fun encode(salt: ByteArray, iv: ByteArray, ciphertextWithTag: ByteArray): String = + Encoding.toBase64(Encoding.uint32BigEndian(VERSION) + salt + iv + ciphertextWithTag) + + /** deriveUnlockKeyRaw: the 32-byte key that opens the envelope. */ + fun deriveUnlockKey(masterPassword: String, salt: ByteArray): ByteArray = + Primitives.pbkdf2Sha256(Encoding.utf8(masterPassword), salt, PBKDF2_ITERATIONS, KEY_LENGTH) + + /** decryptPrivateKey: opens the envelope with the master password, returns the PKCS#8 PEM. */ + fun open(envelope: String, masterPassword: String): String { + val parts = decode(envelope) + return openParts(parts, deriveUnlockKey(masterPassword, parts.salt)) + } + + /** decryptPrivateKeyWithRawKey: opens the envelope with an unlock key held from earlier. */ + fun openWithUnlockKey(envelope: String, unlockKey: ByteArray): String = openParts(decode(envelope), unlockKey) + + /** encryptPrivateKey: seals a PEM under the master password with a fresh salt and IV. */ + fun seal(privateKeyPem: String, masterPassword: String): String { + val salt = Primitives.randomBytes(SALT_LENGTH) + val iv = Primitives.randomBytes(IV_LENGTH) + val key = deriveUnlockKey(masterPassword, salt) + return encode(salt, iv, Primitives.aesGcmEncrypt(key, iv, Encoding.utf8(privateKeyPem))) + } + + private fun openParts(parts: Parts, key: ByteArray): String { + if (key.size != KEY_LENGTH) throw KeepiqCryptoException("Unlock key must be $KEY_LENGTH bytes") + return Encoding.fromUtf8(Primitives.aesGcmDecrypt(key, parts.iv, parts.ciphertextWithTag)) + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Rsa.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Rsa.kt new file mode 100644 index 000000000..1133208e1 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Rsa.kt @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * An RSA public key a field is encrypted to: the suite or user key, given as + * an SPKI PEM or as an X.509 certificate PEM (src/crypto/rsa.js importPublicKey). + */ +class RsaPublicKey private constructor(internal val spki: ByteArray) { + companion object { + /** Reads `BEGIN PUBLIC KEY` (SPKI) or `BEGIN CERTIFICATE` (X.509, SPKI taken out). */ + fun fromPem(pem: String): RsaPublicKey { + val isCertificate = pem.contains("-----BEGIN CERTIFICATE-----") + val body = pem + .replaceFirst("-----BEGIN PUBLIC KEY-----", "") + .replaceFirst("-----END PUBLIC KEY-----", "") + .replaceFirst("-----BEGIN CERTIFICATE-----", "") + .replaceFirst("-----END CERTIFICATE-----", "") + .filterNot { it.isWhitespace() } + val der = Encoding.fromBase64(body) + return RsaPublicKey(if (isCertificate) Der.spkiFromCertificate(der) else der) + } + } +} + +/** An RSA private key from a PKCS#8 PEM (src/crypto/rsa.js importPrivateKey). */ +class RsaPrivateKey private constructor(internal val pkcs8: ByteArray) { + /** True once [forget] ran: the key bytes are zeros and the key opens nothing. */ + var forgotten: Boolean = false + private set + + /** Overwrites the key bytes, on lock (design D4). */ + fun forget() { + pkcs8.fill(0) + forgotten = true + } + + companion object { + fun fromPem(pem: String): RsaPrivateKey { + val body = pem + .replaceFirst("-----BEGIN PRIVATE KEY-----", "") + .replaceFirst("-----END PRIVATE KEY-----", "") + .replaceFirst("-----BEGIN RSA PRIVATE KEY-----", "") + .replaceFirst("-----END RSA PRIVATE KEY-----", "") + .filterNot { it.isWhitespace() } + return RsaPrivateKey(Encoding.fromBase64(body)) + } + } +} + +/** + * Field encryption: RSA-OAEP-4096 with SHA-256, the UTF-8 text split into + * 446-byte chunks, each encrypted to a 512-byte block, stored as + * base64(uint32 big-endian chunk count + blocks). Empty text is one chunk. + * Decryption joins the chunk bytes and decodes UTF-8 once. + * + * Source of truth: src/crypto/rsa.js (RSA_BLOCK_SIZE, RSA_CHUNK_SIZE, + * rsaEncrypt, rsaDecrypt). + */ +object RsaFields { + const val BLOCK_SIZE = 512 + const val CHUNK_SIZE = 446 + + fun encrypt(plaintext: String, publicKey: RsaPublicKey): String { + val data = Encoding.utf8(plaintext) + val chunks = if (data.isEmpty()) { + listOf(ByteArray(0)) + } else { + (data.indices step CHUNK_SIZE).map { data.copyOfRange(it, minOf(it + CHUNK_SIZE, data.size)) } + } + val out = ByteArray(4 + chunks.size * BLOCK_SIZE) + Encoding.uint32BigEndian(chunks.size.toLong()).copyInto(out, 0) + chunks.forEachIndexed { i, chunk -> + val block = Primitives.rsaOaepSha256Encrypt(publicKey.spki, chunk) + if (block.size != BLOCK_SIZE) { + throw KeepiqCryptoException("RSA block is ${block.size} bytes, expected $BLOCK_SIZE (not a 4096-bit key)") + } + block.copyInto(out, 4 + i * BLOCK_SIZE) + } + return Encoding.toBase64(out) + } + + fun decrypt(ciphertext: String, privateKey: RsaPrivateKey): String { + val raw = Encoding.fromBase64(ciphertext) + if (raw.size < 4) throw KeepiqCryptoException("Field ciphertext too short") + val count = Encoding.readUint32BigEndian(raw, 0) + // Like rsaDecrypt: read `count` blocks and ignore anything after them. + if (raw.size.toLong() < 4 + count * BLOCK_SIZE) { + throw KeepiqCryptoException("Field ciphertext holds ${raw.size} bytes for $count chunks") + } + val joined = ArrayList(count.toInt()) + for (i in 0 until count.toInt()) { + val block = raw.copyOfRange(4 + i * BLOCK_SIZE, 4 + (i + 1) * BLOCK_SIZE) + joined.add(Primitives.rsaOaepSha256Decrypt(privateKey.pkcs8, block)) + } + val total = joined.sumOf { it.size } + val bytes = ByteArray(total) + var offset = 0 + for (part in joined) { + part.copyInto(bytes, offset) + offset += part.size + } + return Encoding.fromUtf8(bytes) + } +} + +/** The minimal DER walking the web app does to take the SPKI out of a certificate. */ +internal object Der { + private const val SEQUENCE = 0x30 + private const val CONTEXT_0 = 0xA0 + + private class Length(val length: Int, val headerEnd: Int) + + private fun readLength(der: ByteArray, offset: Int): Length { + val first = der[offset].toInt() and 0xFF + if (first and 0x80 == 0) return Length(first, offset + 1) + val count = first and 0x7F + if (count > 4) throw KeepiqCryptoException("DER length too long") + var length = 0 + for (i in 0 until count) { + length = (length shl 8) or (der[offset + 1 + i].toInt() and 0xFF) + } + return Length(length, offset + 1 + count) + } + + /** + * Mirrors extractSpkiFromCertificate in src/crypto/rsa.js: the + * SubjectPublicKeyInfo is TBSCertificate child 6 after an explicit [0] + * version, or child 5 without one. + */ + fun spkiFromCertificate(cert: ByteArray): ByteArray { + try { + if (cert[0].toInt() and 0xFF != SEQUENCE) throw KeepiqCryptoException("Not a DER SEQUENCE (certificate expected)") + val outer = readLength(cert, 1) + if (cert[outer.headerEnd].toInt() and 0xFF != SEQUENCE) { + throw KeepiqCryptoException("Malformed certificate: tbsCertificate not a SEQUENCE") + } + val tbs = readLength(cert, outer.headerEnd + 1) + var pos = tbs.headerEnd + val end = tbs.headerEnd + tbs.length + val starts = ArrayList() + while (pos < end) { + val tag = cert[pos].toInt() and 0xFF + val len = readLength(cert, pos + 1) + val fieldEnd = len.headerEnd + len.length + starts.add(intArrayOf(tag, pos, fieldEnd)) + pos = fieldEnd + } + val hasVersion = starts.isNotEmpty() && starts[0][0] == CONTEXT_0 + val spki = starts.getOrNull(if (hasVersion) 6 else 5) + if (spki == null || cert[spki[1]].toInt() and 0xFF != SEQUENCE) { + throw KeepiqCryptoException("Could not locate SubjectPublicKeyInfo in certificate") + } + return cert.copyOfRange(spki[1], spki[2]) + } catch (e: IndexOutOfBoundsException) { + throw KeepiqCryptoException("Malformed certificate", e) + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/SendCrypto.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/SendCrypto.kt new file mode 100644 index 000000000..5d658b61a --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/SendCrypto.kt @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * Send crypto. + * + * Payload: a fresh AES-256-GCM key, stored as base64(12-byte IV + ciphertext + * and tag). Without a password the raw key rides the link fragment + * `#k=`. Source: src/send/sendCrypto.js. + * + * Password: Argon2id (65,536 KiB, 3 passes, parallelism 1, 32 bytes) over the + * UTF-8 password and a 16-byte salt derives a key that wraps the raw key with + * the same IV-prefixed AES-GCM blob. The request body carries `wrappedKey` and + * `argon2idSalt` (base64). Sources: src/crypto/argon2.js, + * src/store/modules/ephemeralSend.js createSend. + */ +object SendCrypto { + const val IV_LENGTH = 12 + const val KEY_LENGTH = 32 + const val ARGON2_MEMORY_KIB = 65_536 + const val ARGON2_ITERATIONS = 3 + const val ARGON2_PARALLELISM = 1 + const val ARGON2_SALT_LENGTH = 16 + + /** A sealed payload: [encryptedPayload] goes to the server, [rawKey] never does. */ + class Sealed(val encryptedPayload: String, val rawKey: ByteArray) + + /** The password wrap of a raw key, as the create request carries it. */ + class PasswordWrap(val wrappedKey: String, val argon2idSalt: String) + + /** aesEncrypt: base64(IV + ciphertext and tag) under [key] with a fresh IV. */ + fun aesEncrypt(key: ByteArray, plaintext: ByteArray): String { + val iv = Primitives.randomBytes(IV_LENGTH) + return Encoding.toBase64(iv + Primitives.aesGcmEncrypt(key, iv, plaintext)) + } + + /** aesDecrypt: opens an IV-prefixed blob. */ + fun aesDecrypt(key: ByteArray, blob: String): ByteArray { + val combined = Encoding.fromBase64(blob) + if (combined.size < IV_LENGTH + 16) throw KeepiqCryptoException("Send blob too short") + return Primitives.aesGcmDecrypt( + key, + combined.copyOfRange(0, IV_LENGTH), + combined.copyOfRange(IV_LENGTH, combined.size), + ) + } + + /** sealPayload: encrypts [payload] under a fresh content key. */ + fun sealPayload(payload: String): Sealed { + val rawKey = Primitives.randomBytes(KEY_LENGTH) + return Sealed(aesEncrypt(rawKey, Encoding.utf8(payload)), rawKey) + } + + /** Opens a payload with its raw content key. */ + fun openPayload(encryptedPayload: String, rawKey: ByteArray): String = + Encoding.fromUtf8(aesDecrypt(rawKey, encryptedPayload)) + + /** + * sendLink: `{publicBase}/send/{token}`, plus `#k=` when the + * send has no password. [publicBase] is the origin plus `/…/apps/keepiq/public`. + */ + fun sendLink(publicBase: String, token: String, rawKey: ByteArray?): String { + val base = "$publicBase/send/${encodeUriComponent(token)}" + return if (rawKey != null) "$base#k=${Encoding.toBase64Url(rawKey)}" else base + } + + /** The key-encryption key for a password send. */ + fun deriveKek(password: String, salt: ByteArray): ByteArray = argon2id( + Encoding.utf8(password), + salt, + ARGON2_MEMORY_KIB, + ARGON2_ITERATIONS, + ARGON2_PARALLELISM, + KEY_LENGTH, + ) + + /** Wraps a raw content key under a password, with a fresh salt. */ + fun wrapKey(rawKey: ByteArray, password: String): PasswordWrap { + val salt = Primitives.randomBytes(ARGON2_SALT_LENGTH) + return PasswordWrap(aesEncrypt(deriveKek(password, salt), rawKey), Encoding.toBase64(salt)) + } + + /** Unwraps the raw content key of a password send. Throws on a wrong password. */ + fun unwrapKey(wrappedKey: String, argon2idSalt: String, password: String): ByteArray = + aesDecrypt(deriveKek(password, Encoding.fromBase64(argon2idSalt)), wrappedKey) + + /** JavaScript encodeURIComponent over UTF-8. */ + internal fun encodeUriComponent(value: String): String { + val unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()" + val hexDigits = "0123456789ABCDEF" + val sb = StringBuilder() + for (b in Encoding.utf8(value)) { + val c = b.toInt() and 0xFF + if (c < 0x80 && unreserved.indexOf(c.toChar()) >= 0) { + sb.append(c.toChar()) + } else { + sb.append('%').append(hexDigits[c shr 4]).append(hexDigits[c and 0xF]) + } + } + return sb.toString() + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Totp.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Totp.kt new file mode 100644 index 000000000..8f3919cee --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/crypto/Totp.kt @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +/** + * RFC 6238 TOTP, parsed and computed as src/totp/totp.js does: an + * `otpauth://totp/` URI or a bare base32 secret; SHA1, SHA256 or SHA512; + * 6 or 8 digits (anything else falls back to 6); a positive period, default + * 30 seconds. HOTP and other otpauth types are refused. + */ +object Totp { + private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + /** Parsed parameters; [issuer] and [account] are for display only. */ + data class Params( + val secret: String, + val algorithm: String, + val digits: Int, + val period: Int, + val issuer: String?, + val account: String?, + ) + + /** base32Decode: upper-cases, drops trailing `=` and all whitespace. */ + fun base32Decode(input: String): ByteArray { + val clean = input.uppercase().trimEnd('=').filterNot { it.isWhitespace() } + if (clean.isEmpty()) throw KeepiqCryptoException("Empty base32 secret") + var bits = 0 + var value = 0 + val out = ArrayList() + for (char in clean) { + val idx = BASE32_ALPHABET.indexOf(char) + if (idx == -1) throw KeepiqCryptoException("Invalid base32 character") + value = (value shl 5) or idx + bits += 5 + if (bits >= 8) { + bits -= 8 + out.add(((value ushr bits) and 0xFF).toByte()) + } + } + if (out.isEmpty()) throw KeepiqCryptoException("Base32 secret too short") + return out.toByteArray() + } + + /** parseOtpauth. */ + fun parse(raw: String): Params { + val value = raw.trim() + if (value.isEmpty()) throw KeepiqCryptoException("Empty TOTP seed") + if (!value.startsWith("otpauth://", ignoreCase = true)) { + base32Decode(value) + return Params(value.filterNot { it.isWhitespace() }.uppercase(), "SHA1", 6, 30, null, null) + } + val rest = value.substring("otpauth://".length) + val hostEnd = rest.indexOfFirst { it == '/' || it == '?' || it == '#' }.let { if (it < 0) rest.length else it } + if (rest.substring(0, hostEnd).lowercase() != "totp") throw KeepiqCryptoException("Not an otpauth://totp URI") + val afterHost = rest.substring(hostEnd).substringBefore('#') + val path = afterHost.substringBefore('?') + val query = if (afterHost.contains('?')) afterHost.substringAfter('?') else "" + val params = parseQuery(query) + + val secretParam = params["secret"] + if (secretParam.isNullOrEmpty()) throw KeepiqCryptoException("otpauth URI has no secret") + base32Decode(secretParam) + + val algorithm = params["algorithm"].let { a -> + if (a.isNullOrEmpty()) { + "SHA1" + } else { + a.uppercase().also { + if (it !in setOf("SHA1", "SHA256", "SHA512")) throw KeepiqCryptoException("Unsupported TOTP algorithm: $it") + } + } + } + val digits = if (parseIntLikeJs(params["digits"]) == 8) 8 else 6 + val period = parseIntLikeJs(params["period"])?.takeIf { it > 0 } ?: 30 + + val label = percentDecode(path.removePrefix("/")) + var account: String? = label.ifEmpty { null } + var issuer: String? = params["issuer"] + if (label.contains(':')) { + val parts = label.split(':') + if (issuer.isNullOrEmpty()) issuer = parts[0].trim().ifEmpty { null } + account = parts.drop(1).joinToString(":").trim().ifEmpty { null } + } + return Params( + secret = secretParam.filterNot { it.isWhitespace() }.uppercase(), + algorithm = algorithm, + digits = digits, + period = period, + issuer = issuer?.ifEmpty { null }, + account = account, + ) + } + + /** generateTotp at [epochMillis]. */ + fun generate(params: Params, epochMillis: Long): String { + val key = base32Decode(params.secret) + val algorithm = when (params.algorithm) { + "SHA256" -> HmacAlgorithm.SHA256 + "SHA512" -> HmacAlgorithm.SHA512 + else -> HmacAlgorithm.SHA1 + } + val counter = floorDiv(epochMillis, 1000L * params.period) + val counterBytes = ByteArray(8) + var temp = counter + for (i in 7 downTo 0) { + counterBytes[i] = (temp and 0xFF).toByte() + temp = temp ushr 8 + } + val hmac = Primitives.hmac(algorithm, key, counterBytes) + val offset = hmac[hmac.size - 1].toInt() and 0x0F + val binary = ((hmac[offset].toInt() and 0x7F) shl 24) or + ((hmac[offset + 1].toInt() and 0xFF) shl 16) or + ((hmac[offset + 2].toInt() and 0xFF) shl 8) or + (hmac[offset + 3].toInt() and 0xFF) + var mod = 1 + repeat(params.digits) { mod *= 10 } + return (binary % mod).toString().padStart(params.digits, '0') + } + + /** Seconds left in the current window. */ + fun secondsRemaining(period: Int, epochMillis: Long): Int = + period - (floorDiv(epochMillis, 1000L) % period).toInt() + + private fun floorDiv(a: Long, b: Long): Long { + val q = a / b + return if ((a % b != 0L) && ((a < 0) != (b < 0))) q - 1 else q + } + + /** parseInt(raw, 10): leading whitespace and sign, then digits; null when none. */ + private fun parseIntLikeJs(raw: String?): Int? { + if (raw == null) return null + val s = raw.trimStart() + var i = 0 + var negative = false + if (i < s.length && (s[i] == '+' || s[i] == '-')) { + negative = s[i] == '-' + i++ + } + val start = i + while (i < s.length && s[i] in '0'..'9') i++ + if (i == start) return null + val n = s.substring(start, i).take(10).toLongOrNull() ?: return null + val v = if (negative) -n else n + return v.coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + } + + /** URLSearchParams: `+` is a space, percent-decoding, first value wins. */ + private fun parseQuery(query: String): Map { + val out = LinkedHashMap() + if (query.isEmpty()) return out + for (pair in query.split('&')) { + if (pair.isEmpty()) continue + val name = percentDecode(pair.substringBefore('=').replace('+', ' ')) + val value = if (pair.contains('=')) percentDecode(pair.substringAfter('=').replace('+', ' ')) else "" + if (name !in out) out[name] = value + } + return out + } + + private fun percentDecode(text: String): String { + if (!text.contains('%')) return text + val bytes = ArrayList() + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '%' && i + 2 < text.length && text.substring(i + 1, i + 3).toIntOrNull(16) != null) { + bytes.add(text.substring(i + 1, i + 3).toInt(16).toByte()) + i += 3 + } else { + Encoding.utf8(c.toString()).forEach { bytes.add(it) } + i++ + } + } + return Encoding.fromUtf8(bytes.toByteArray()) + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/Generator.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/Generator.kt new file mode 100644 index 000000000..3e5522d83 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/Generator.kt @@ -0,0 +1,411 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.generator + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.crypto.SecureRandomBytes + +/** Why the generator refused a request. [message] is the web generator's English text, for logs and tests. */ +enum class GeneratorErrorCode { + LENGTH_TOO_SHORT, LENGTH_TOO_LONG, CHARSET_EMPTY, CHARSET_TOO_SMALL, NO_KIND_CHOSEN, + REGEX_INVALID, REGEX_NO_QUANTIFIER, REGEX_RANGE_INVALID, REGEX_NO_CLASS, REGEX_TOO_SHORT, + REGEX_BELOW_POLICY, REGEX_EXCLUDES_REQUIRED, REGEX_NO_MATCH, PASSPHRASE_WORDS, PASSPHRASE_OFF, +} + +/** A request the generator refuses (GeneratorError in src/generator/generator.js). */ +class GeneratorException(val code: GeneratorErrorCode, message: String, val argument: String? = null) : Exception(message) + +/** The generator-relevant part of the organisation's policy (generatorPolicy). */ +data class GeneratorPolicy( + val minLength: Int, + val requireUpper: Boolean, + val requireLower: Boolean, + val requireDigit: Boolean, + val requireSymbol: Boolean, + val allowPassphrase: Boolean, +) { + companion object { + /** From `GET /api/settings/policy`; null when no policy applies. */ + fun from(raw: JsonObject?): GeneratorPolicy? { + if (raw == null || !raw.isTrue("policy_enabled")) return null + val floorRaw = raw["generator_min_length"] + val floor = if (floorRaw == null || floorRaw is kotlinx.serialization.json.JsonNull) 12 else parseIntLikeJs((floorRaw as? JsonPrimitive)?.contentOrNull) + return GeneratorPolicy( + minLength = maxOf(Generator.MIN_LENGTH, floor ?: 12), + requireUpper = raw.isTrue("generator_require_upper"), + requireLower = raw.isTrue("generator_require_lower"), + requireDigit = raw.isTrue("generator_require_digit"), + requireSymbol = raw.isTrue("generator_require_symbol"), + allowPassphrase = !raw.isFalse("generator_allow_passphrase"), + ) + } + + /** Reads the policy from the server; null when none applies or it cannot be read (it never blocks). */ + suspend fun fetch(api: nl.conduction.keepiq.shared.api.KeepiqApi): GeneratorPolicy? = from(api.fetchPolicy()) + + private fun JsonObject.isTrue(name: String): Boolean = (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull == true + + private fun JsonObject.isFalse(name: String): Boolean = (this[name] as? JsonPrimitive)?.takeIf { !it.isString }?.booleanOrNull == false + + /** `Number.parseInt(value, 10)`: leading whitespace, a sign, then digits; null for NaN. */ + internal fun parseIntLikeJs(value: String?): Int? { + val s = value?.trimStart() ?: return null + var i = 0 + var sign = 1 + if (i < s.length && (s[i] == '+' || s[i] == '-')) { + if (s[i] == '-') sign = -1 + i++ + } + val start = i + while (i < s.length && s[i] in '0'..'9') i++ + if (i == start) return null + return s.substring(start, i).take(9).toInt() * sign + } + } +} + +/** Password options (generateKey). Defaults are the web generator's defaults for a missing option. */ +data class PasswordOptions( + val length: Int = 16, + val includeUppercase: Boolean = true, + val includeLowercase: Boolean = true, + val includeDigits: Boolean = true, + val includeSpecialCharacters: Boolean = true, + val minDigits: Int = 0, + val minSpecial: Int = 0, + val excludedCharacters: String = "", + val avoidAmbiguous: Boolean = false, + val regex: String? = null, +) + +/** Passphrase options (generatePassphrase). */ +data class PassphraseOptions( + val words: Int = Generator.DEFAULT_WORDS, + val separator: String = "-", + val capitalise: Boolean = false, + val includeNumber: Boolean = false, +) + +/** + * The key and passphrase generator, a port of src/generator/generator.js + * with the same options, policy clamp, refusals and order of random draws. + * tests/vectors/generator/cases.json holds outputs of the web module for + * fixed random sources; the commonTest suite and a vitest test check both + * implementations against them. + * + * [rand] draws a uniform integer in [min, max]; the default is the platform's + * cryptographic random source with rejection sampling, as randomInt does. + */ +object Generator { + const val MIN_LENGTH = 8 + const val MAX_LENGTH = 128 + const val MIN_CHARSET_SIZE = 2 + private const val MAX_REGEX_ATTEMPTS = 3 + const val MIN_WORDS = 4 + const val MAX_WORDS = 12 + const val DEFAULT_WORDS = 5 + + const val UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LOWERCASE = "abcdefghijklmnopqrstuvwxyz" + const val DIGITS = "0123456789" + const val SPECIAL = "!@#\$%^&*()-_=+[]{}|;:,.<>?/" + private const val AMBIGUOUS = "IOl01" + + /** randomInt: rejection sampling over 32-bit values, so no value is favoured. */ + val secureRandomInt: (Int, Int) -> Int = { min, max -> + val range = max.toLong() - min + 1 + if (range <= 1) { + min + } else { + val limit = (0x100000000L / range) * range + var value: Long + do { + val b = SecureRandomBytes.next(4) + value = ((b[0].toLong() and 0xFF) shl 24) or ((b[1].toLong() and 0xFF) shl 16) or + ((b[2].toLong() and 0xFF) shl 8) or (b[3].toLong() and 0xFF) + } while (value >= limit) + (min + value % range).toInt() + } + } + + /** generateKey. */ + fun generateKey(options: PasswordOptions = PasswordOptions(), policy: GeneratorPolicy? = null, rand: (Int, Int) -> Int = secureRandomInt): String { + if (!options.regex.isNullOrEmpty()) return generateFromRegex(options.regex, policy, rand) + return generateFromCharset(options, policy, rand) + } + + /** generatePassphrase: EFF large word list, made to meet the policy rather than refused. */ + fun generatePassphrase(options: PassphraseOptions = PassphraseOptions(), policy: GeneratorPolicy? = null, rand: (Int, Int) -> Int = secureRandomInt): String { + if (policy != null && !policy.allowPassphrase) { + throw GeneratorException(GeneratorErrorCode.PASSPHRASE_OFF, "Your organisation has switched passphrases off") + } + val count = options.words + if (count < MIN_WORDS || count > MAX_WORDS) { + throw GeneratorException(GeneratorErrorCode.PASSPHRASE_WORDS, "A passphrase must have $MIN_WORDS to $MAX_WORDS words") + } + var separator = options.separator + var capitalise = options.capitalise + var includeNumber = options.includeNumber + if (policy != null) { + capitalise = capitalise || policy.requireUpper + includeNumber = includeNumber || policy.requireDigit + if (policy.requireSymbol && !intersects(separator, SPECIAL)) { + separator = SPECIAL[rand(0, SPECIAL.length - 1)].toString() + } + } + val list = EffWordlist.words + fun draw(): String { + val word = list[rand(0, list.size - 1)] + return if (capitalise) word[0].uppercase() + word.substring(1) else word + } + val words = MutableList(count) { draw() } + if (includeNumber) { + val index = rand(0, words.size - 1) + words[index] = words[index] + rand(0, 9).toString() + } + if (policy != null) { + while (words.joinToString(separator).length < policy.minLength) words += draw() + } + return words.joinToString(separator) + } + + private fun requiredClasses(policy: GeneratorPolicy): List> = buildList { + if (policy.requireUpper) add("uppercase" to UPPERCASE) + if (policy.requireLower) add("lowercase" to LOWERCASE) + if (policy.requireDigit) add("digit" to DIGITS) + if (policy.requireSymbol) add("symbol" to SPECIAL) + } + + private fun intersects(a: String, b: String): Boolean = b.any { a.contains(it) } + + private fun dedupe(chars: Iterable): String = LinkedHashSet().apply { addAll(chars) }.joinToString("") + + private fun assertLengthInRange(length: Int) { + if (length < MIN_LENGTH) throw GeneratorException(GeneratorErrorCode.LENGTH_TOO_SHORT, "Length must be at least $MIN_LENGTH characters") + if (length > MAX_LENGTH) throw GeneratorException(GeneratorErrorCode.LENGTH_TOO_LONG, "Length must not exceed $MAX_LENGTH characters") + } + + private fun assertCharsetViable(charset: String) { + if (charset.isEmpty()) throw GeneratorException(GeneratorErrorCode.CHARSET_EMPTY, "The character set is empty after exclusions") + if (charset.length < MIN_CHARSET_SIZE) { + throw GeneratorException( + GeneratorErrorCode.CHARSET_TOO_SMALL, + "The character set must contain at least $MIN_CHARSET_SIZE distinct characters", + ) + } + } + + private fun buildString(charset: String, length: Int, rand: (Int, Int) -> Int): String { + val sb = StringBuilder(length) + repeat(length) { sb.append(charset[rand(0, charset.length - 1)]) } + return sb.toString() + } + + private fun forceRequiredClasses(result: String, policy: GeneratorPolicy, charset: String, rand: (Int, Int) -> Int): String { + val chars = result.toCharArray() + val used = HashSet() + for ((_, classSet) in requiredClasses(policy)) { + if (intersects(result, classSet)) continue + val allowed = classSet.filter { charset.contains(it) } + if (allowed.isEmpty()) continue + var position: Int + do { + position = rand(0, chars.size - 1) + } while (position in used) + used += position + chars[position] = allowed[rand(0, allowed.length - 1)] + } + return chars.concatToString() + } + + private fun generateFromCharset(options: PasswordOptions, policy: GeneratorPolicy?, rand: (Int, Int) -> Int): String { + var length = options.length + var includeUpper = options.includeUppercase + var includeLower = options.includeLowercase + var includeDigits = options.includeDigits + var includeSpecial = options.includeSpecialCharacters + var minDigits = maxOf(0, options.minDigits) + var minSpecial = maxOf(0, options.minSpecial) + val excluded = options.excludedCharacters.toCharArray().toMutableSet() + if (options.avoidAmbiguous) excluded.addAll(AMBIGUOUS.toList()) + + if (policy != null) { + length = maxOf(length, policy.minLength) + includeUpper = includeUpper || policy.requireUpper + includeLower = includeLower || policy.requireLower + includeDigits = includeDigits || policy.requireDigit + includeSpecial = includeSpecial || policy.requireSymbol + } + if (!includeUpper && !includeLower && !includeDigits && !includeSpecial) { + throw GeneratorException(GeneratorErrorCode.NO_KIND_CHOSEN, "Choose at least one kind of character") + } + minDigits = if (includeDigits) minDigits else 0 + minSpecial = if (includeSpecial) minSpecial else 0 + length = maxOf(length, minDigits + minSpecial) + assertLengthInRange(length) + + var charset = (if (includeUpper) UPPERCASE else "") + (if (includeLower) LOWERCASE else "") + + (if (includeDigits) DIGITS else "") + (if (includeSpecial) SPECIAL else "") + charset = dedupe(charset.filter { c -> c !in excluded }.toList()) + if (policy != null) { + for ((_, classSet) in requiredClasses(policy)) { + if (!intersects(charset, classSet)) charset += classSet + } + } + assertCharsetViable(charset) + + var result = buildString(charset, length, rand) + result = ensureMinimums( + result, + charset, + listOf( + UPPERCASE to (if (includeUpper) 1 else 0), + LOWERCASE to (if (includeLower) 1 else 0), + DIGITS to (if (includeDigits) maxOf(minDigits, 1) else 0), + SPECIAL to (if (includeSpecial) maxOf(minSpecial, 1) else 0), + ), + rand, + ) + return if (policy != null) forceRequiredClasses(result, policy, charset, rand) else result + } + + private fun ensureMinimums(value: String, charset: String, minimums: List>, rand: (Int, Int) -> Int): String { + val chars = value.toCharArray() + val reserved = HashSet() + for ((classSet, count) in minimums) { + val allowed = classSet.filter { charset.contains(it) } + if (count == 0 || allowed.isEmpty()) continue + val have = chars.indices.filter { classSet.contains(chars[it]) } + have.take(count).forEach { reserved += it } + var missing = count - minOf(have.size, count) + while (missing > 0) { + val free = chars.indices.filter { it !in reserved } + if (free.isEmpty()) break + val position = free[rand(0, free.size - 1)] + chars[position] = allowed[rand(0, allowed.length - 1)] + reserved += position + missing-- + } + } + return chars.concatToString() + } + + /** compilePattern: PHP-style delimiters (`/…/i`, `#…#`, `~…~`) are accepted. */ + internal fun compilePattern(pattern: String): Regex { + var body = pattern + var flags = "" + val first = pattern.firstOrNull() + if (first != null && first in "/#~" && pattern.length >= 2) { + val end = pattern.lastIndexOf(first) + val tail = pattern.substring(end + 1) + if (end > 0 && tail.all { it in 'a'..'z' || it in 'A'..'Z' }) { + body = pattern.substring(1, end) + flags = tail.filter { it in "imsu" } + } + } + val options = buildSet { + if ('i' in flags) add(RegexOption.IGNORE_CASE) + if ('m' in flags) add(RegexOption.MULTILINE) + } + return try { + Regex(body, options) + } catch (e: Exception) { + throw GeneratorException(GeneratorErrorCode.REGEX_INVALID, "The regex pattern is syntactically invalid") + } + } + + /** extractLength: the window of the first `{n}` or `{n,m}`. */ + internal fun extractLength(regex: String): Pair { + val match = Regex("""\{(\d+)(?:,(\d+))?\}""").find(regex) + ?: throw GeneratorException(GeneratorErrorCode.REGEX_NO_QUANTIFIER, "The regex must contain a length quantifier (e.g. {16} or {8,16})") + val min = match.groupValues[1].take(9).toInt() + val max = match.groups[2]?.value?.take(9)?.toInt() ?: min + if (max < min) throw GeneratorException(GeneratorErrorCode.REGEX_RANGE_INVALID, "The regex length range is invalid (max < min)") + return min to max + } + + private fun expandEscape(escape: Char): List = when (escape) { + 'd' -> DIGITS.toList() + 'w' -> (UPPERCASE + LOWERCASE + DIGITS + "_").toList() + 's' -> listOf(' ') + else -> listOf(escape) + } + + private fun expandCharacterClass(body: String): List { + val chars = ArrayList() + var i = 0 + while (i < body.length) { + val c = body[i] + if (c == '\\' && i + 1 < body.length) { + chars += expandEscape(body[i + 1]) + i += 2 + continue + } + if (i + 2 < body.length && body[i + 1] == '-' && body[i + 2] != ']') { + val start = body[i].code + val end = body[i + 2].code + if (end >= start) { + for (code in start..end) chars += code.toChar() + i += 3 + continue + } + } + chars += c + i += 1 + } + return chars + } + + private fun complementAscii(disallowed: List): List { + val blocked = disallowed.toSet() + return (0x21..0x7e).map { it.toChar() }.filter { it !in blocked } + } + + /** extractCharset: the set of the first character class. */ + internal fun extractCharset(regex: String): String { + val match = Regex("""\[(\^?)((?:\\.|[^\]\\])*)\]""").find(regex) + ?: throw GeneratorException(GeneratorErrorCode.REGEX_NO_CLASS, "The regex must contain a character class (e.g. [a-zA-Z0-9])") + var allowed = expandCharacterClass(match.groupValues[2]) + if (match.groupValues[1] == "^") allowed = complementAscii(allowed) + return dedupe(allowed) + } + + private fun generateFromRegex(regex: String, policy: GeneratorPolicy?, rand: (Int, Int) -> Int): String { + val compiled = compilePattern(regex) + val (quantifierMin, maxLength) = extractLength(regex) + var minLength = quantifierMin + val charset = extractCharset(regex) + if (minLength < MIN_LENGTH) { + throw GeneratorException(GeneratorErrorCode.REGEX_TOO_SHORT, "The regex length must be at least $MIN_LENGTH characters") + } + if (policy != null) { + if (maxLength < policy.minLength) { + throw GeneratorException( + GeneratorErrorCode.REGEX_BELOW_POLICY, + "The regex cannot reach the org policy minimum length of ${policy.minLength} characters", + policy.minLength.toString(), + ) + } + for ((label, classSet) in requiredClasses(policy)) { + if (!intersects(charset, classSet)) { + throw GeneratorException( + GeneratorErrorCode.REGEX_EXCLUDES_REQUIRED, + "The regex excludes the $label characters the org policy requires", + label, + ) + } + } + minLength = maxOf(minLength, minOf(policy.minLength, maxLength)) + } + assertCharsetViable(charset) + repeat(MAX_REGEX_ATTEMPTS) { + val candidate = buildString(charset, rand(minLength, maxLength), rand) + if (compiled.containsMatchIn(candidate)) return candidate + } + throw GeneratorException(GeneratorErrorCode.REGEX_NO_MATCH, "Unable to generate a value matching the supplied regex") + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/GeneratorSettings.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/GeneratorSettings.kt new file mode 100644 index 000000000..10d2c77bd --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/generator/GeneratorSettings.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.generator + +/** Password or passphrase: the generator screen's two modes. */ +enum class GeneratorMode { PASSWORD, PASSPHRASE } + +/** What the generator screen edits and the app remembers per account. */ +data class GeneratorSettings( + val mode: GeneratorMode = GeneratorMode.PASSWORD, + val password: PasswordOptions = DEFAULT_PASSWORD, + val passphrase: PassphraseOptions = PassphraseOptions(), +) { + /** + * The settings made safe (browser-extension/src/lib/generator-state.js + * sanitizeOptions): numbers clamped to the supported ranges and raised to + * the policy, the classes the policy requires switched on, a passphrase + * mode the organisation switched off turned back to password. + */ + fun sanitized(policy: GeneratorPolicy?): GeneratorSettings { + var p = password.copy( + length = password.length.coerceIn(Generator.MIN_LENGTH, Generator.MAX_LENGTH), + minDigits = password.minDigits.coerceIn(0, 9), + minSpecial = password.minSpecial.coerceIn(0, 9), + regex = null, + ) + if (policy != null) { + p = p.copy( + length = maxOf(p.length, policy.minLength), + includeUppercase = p.includeUppercase || policy.requireUpper, + includeLowercase = p.includeLowercase || policy.requireLower, + includeDigits = p.includeDigits || policy.requireDigit, + includeSpecialCharacters = p.includeSpecialCharacters || policy.requireSymbol, + minDigits = if (policy.requireDigit) maxOf(p.minDigits, 1) else p.minDigits, + minSpecial = if (policy.requireSymbol) maxOf(p.minSpecial, 1) else p.minSpecial, + ) + } + if (!p.includeUppercase && !p.includeLowercase && !p.includeDigits && !p.includeSpecialCharacters) { + p = p.copy(includeLowercase = true) + } + val w = passphrase.copy( + words = passphrase.words.coerceIn(Generator.MIN_WORDS, Generator.MAX_WORDS), + separator = passphrase.separator.take(3), + ) + val m = if (mode == GeneratorMode.PASSPHRASE && policy != null && !policy.allowPassphrase) GeneratorMode.PASSWORD else mode + return GeneratorSettings(m, p, w) + } + + /** Generates one value with these settings under [policy]. */ + fun generate(policy: GeneratorPolicy?, rand: (Int, Int) -> Int = Generator.secureRandomInt): String { + val safe = sanitized(policy) + return when (safe.mode) { + GeneratorMode.PASSWORD -> Generator.generateKey(safe.password, policy, rand) + GeneratorMode.PASSPHRASE -> Generator.generatePassphrase(safe.passphrase, policy, rand) + } + } + + /** [generate] without throwing: the value, or why it was refused. For Swift. */ + fun tryGenerate(policy: GeneratorPolicy?): GeneratorOutcome = try { + GeneratorOutcome(generate(policy), null) + } catch (e: GeneratorException) { + GeneratorOutcome(null, e.code) + } + + /** The shortest password length the screen may offer under [policy]. */ + fun minimumLength(policy: GeneratorPolicy?): Int = maxOf(Generator.MIN_LENGTH, policy?.minLength ?: 0) + + companion object { + /** DEFAULT_OPTIONS.password of the extension, close to Bitwarden's defaults. */ + val DEFAULT_PASSWORD = PasswordOptions( + length = 14, + includeUppercase = true, + includeLowercase = true, + includeDigits = true, + includeSpecialCharacters = false, + minDigits = 1, + minSpecial = 1, + avoidAmbiguous = true, + ) + } +} + +/** A generated value, or the code of the refusal. */ +data class GeneratorOutcome(val value: String?, val error: GeneratorErrorCode?) { + override fun toString(): String = "GeneratorOutcome(error=$error)" +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2.kt new file mode 100644 index 000000000..be286b1bc --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2.kt @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.pairing + +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.content.TextContent +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.api.KeepiqApiException + +/** A started Login Flow v2: the page to open in the system browser, and where to poll. */ +class LoginFlowStart(val server: String, val loginUrl: String, internal val pollEndpoint: String, internal val pollToken: String) { + override fun toString(): String = "LoginFlowStart(server=$server, loginUrl=$loginUrl)" +} + +/** What a finished flow hands over. The app password is never printed. */ +class LoginFlowCredentials(val server: String, val loginName: String, val appPassword: String) { + override fun toString(): String = "LoginFlowCredentials(server=$server, loginName=$loginName)" +} + +/** The flow ran out of time (20 minutes) or the user cancelled it. Nothing was stored. */ +class LoginFlowStoppedException(val timedOut: Boolean) : Exception( + if (timedOut) "The sign-in took longer than 20 minutes. Start again." else "Sign-in cancelled.", +) + +/** + * Nextcloud Login Flow v2 (design D3), the way the Nextcloud desktop and + * mobile clients use it: + * + * 1. `POST {server}/index.php/login/v2` answers a login page and a poll token. + * Nextcloud names the app password after this request's User-Agent, so + * [userAgent] is what the user later sees in the device list. + * 2. The app opens the login page in the system browser. + * 3. `POST {poll endpoint}` with the token answers 404 until the user granted + * access, then once the server, login name and a new app password. + * + * Polling stops after [TIMEOUT_MILLIS] or when [isCancelled] says so. The + * poll endpoint must be https on the same host and port as the server, so + * the token that buys an app password never goes anywhere else. + */ +class LoginFlowV2( + private val client: HttpClient, + private val userAgent: String, + private val clock: () -> Long, + private val pollIntervalMillis: Long = POLL_INTERVAL_MILLIS, +) { + @Throws(Exception::class) + suspend fun start(serverInput: String): LoginFlowStart { + val server = ServerAddress.normalize(serverInput) + val response = client.request("$server/index.php/login/v2") { + method = HttpMethod.Post + header(HttpHeaders.UserAgent, userAgent) + header(HttpHeaders.Accept, "application/json") + } + val text = response.bodyAsText() + val status = response.status.value + if (status !in 200..299) { + throw KeepiqApiException(status, "This address does not answer as a Nextcloud server ($status).") + } + val body = runCatching { Json.parseToJsonElement(text) as? JsonObject }.getOrNull() + ?: throw KeepiqApiException(status, "This address does not answer as a Nextcloud server.") + val poll = body["poll"] as? JsonObject + val login = body.text("login") + val endpoint = poll?.text("endpoint") + val token = poll?.text("token") + if (login == null || endpoint == null || token == null) { + throw KeepiqApiException(status, "This address does not answer as a Nextcloud server.") + } + val origin = ServerAddress.origin(server) + if (ServerAddress.origin(endpoint) != origin) { + throw KeepiqApiException(0, "The server sent a sign-in address on another host. Keepiq stopped, for your safety.") + } + if (ServerAddress.origin(login) == null) { + throw KeepiqApiException(0, "The server sent a sign-in page that is not https. Keepiq stopped, for your safety.") + } + return LoginFlowStart(server, login, endpoint, token) + } + + /** One poll: the credentials, or null while the user has not granted access yet. */ + @Throws(Exception::class) + suspend fun poll(start: LoginFlowStart): LoginFlowCredentials? { + val response = client.request(start.pollEndpoint) { + method = HttpMethod.Post + header(HttpHeaders.UserAgent, userAgent) + header(HttpHeaders.Accept, "application/json") + setBody(TextContent("token=" + formEncode(start.pollToken), ContentType.Application.FormUrlEncoded)) + } + val text = response.bodyAsText() + val status = response.status.value + if (status == 404) return null + if (status !in 200..299) throw KeepiqApiException(status, "Signing in failed ($status).") + val body = runCatching { Json.parseToJsonElement(text) as? JsonObject }.getOrNull() + val loginName = body?.text("loginName") + val appPassword = body?.text("appPassword") + if (loginName.isNullOrEmpty() || appPassword.isNullOrEmpty()) { + throw KeepiqApiException(status, "The server finished the sign-in without an app password.") + } + // The account is kept under the address the user typed: the flow's + // own `server` can carry http behind a proxy that hides https. + return LoginFlowCredentials(start.server, loginName, appPassword) + } + + /** + * Polls until the user granted access. Throws [LoginFlowStoppedException] + * after 20 minutes, counted from [startedAtMillis], or when [isCancelled] + * turns true. A network error while polling is retried: the phone may + * switch networks while the user is in the browser. + */ + @Throws(Exception::class) + suspend fun await(start: LoginFlowStart, startedAtMillis: Long, isCancelled: () -> Boolean = { false }): LoginFlowCredentials { + while (true) { + if (isCancelled()) throw LoginFlowStoppedException(timedOut = false) + if (clock() - startedAtMillis >= TIMEOUT_MILLIS) throw LoginFlowStoppedException(timedOut = true) + val result = try { + poll(start) + } catch (e: KeepiqApiException) { + throw e + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + null + } + if (result != null) return result + delay(pollIntervalMillis) + } + } + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + + private fun formEncode(value: String): String { + val safe = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.*" + val hex = "0123456789ABCDEF" + val out = StringBuilder() + for (b in value.encodeToByteArray()) { + val c = b.toInt() and 0xFF + when { + c < 0x80 && safe.indexOf(c.toChar()) >= 0 -> out.append(c.toChar()) + c == 0x20 -> out.append('+') + else -> out.append('%').append(hex[c shr 4]).append(hex[c and 0xF]) + } + } + return out.toString() + } + + companion object { + /** Design D3: the poll stops after 20 minutes. */ + const val TIMEOUT_MILLIS = 20L * 60_000L + + /** Nextcloud's own clients poll every few seconds. */ + const val POLL_INTERVAL_MILLIS = 2_000L + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddress.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddress.kt new file mode 100644 index 000000000..dba3a17df --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddress.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.pairing + +import io.ktor.http.URLProtocol +import io.ktor.http.Url + +/** The address the user typed cannot be used. The message says why, in words the user can act on. */ +class ServerAddressException(message: String) : Exception(message) + +/** + * Cleans up a server address as typed or pasted, as the extension does + * (browser-extension/src/lib/server-url.js normalizeServerUrl): a missing + * scheme becomes https, a pasted Nextcloud page is cut back to the server + * folder, and the trailing slash goes. Unlike the extension, plain http is + * refused for every host, local ones too (design D3): a phone has no local + * Nextcloud to pair with. + */ +object ServerAddress { + // Where a pasted Nextcloud page address stops being the server address. + private val pagePath = Regex("/(index\\.php|apps|login|ocs|remote\\.php|settings|s)(/|$)") + private val scheme = Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://") + + /** @throws ServerAddressException when the address is empty, malformed, carries credentials or is not https. */ + @Throws(ServerAddressException::class) + fun normalize(raw: String): String { + var text = raw.trim() + if (text.isEmpty()) throw ServerAddressException("Enter the address of your Nextcloud.") + if (!scheme.containsMatchIn(text)) text = "https://$text" + val url = runCatching { Url(text) }.getOrNull() + ?: throw ServerAddressException("This is not a valid address.") + if (url.protocol != URLProtocol.HTTPS && url.protocol != URLProtocol.HTTP) { + throw ServerAddressException("This is not a valid address.") + } + if (url.host.isBlank() || text.substringAfter("://").startsWith("/")) { + throw ServerAddressException("This is not a valid address.") + } + if (url.user != null || url.password != null) { + throw ServerAddressException("Leave the user name and password out of the address.") + } + if (url.protocol != URLProtocol.HTTPS) { + throw ServerAddressException("Keepiq needs an https address, so your app password is never sent in clear.") + } + val path = url.encodedPath + val cut = pagePath.find(path)?.range?.first ?: -1 + val folder = (if (cut == -1) path else path.substring(0, cut)).trimEnd('/') + val port = if (url.specifiedPort == 0 || url.specifiedPort == 443) "" else ":${url.specifiedPort}" + return "https://${url.host.lowercase()}$port$folder" + } + + /** The host and port of an https address, for comparing two addresses. */ + internal fun origin(address: String): String? { + val url = runCatching { Url(address) }.getOrNull() ?: return null + if (url.protocol != URLProtocol.HTTPS) return null + return "${url.host.lowercase()}:${url.port}" + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/ApplePasskeys.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/ApplePasskeys.kt new file mode 100644 index 000000000..ca253646b --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/ApplePasskeys.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.passkey + +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.autofill.AutofillSites +import nl.conduction.keepiq.shared.crypto.ClientData +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.vault.VaultKeys + +/** + * One passkey for ASCredentialIdentityStore (design D7): the rpId, the user + * name, the credential id and the user handle, never the key. Bytes are + * standard base64, which Swift's Data reads and writes directly. + */ +data class PasskeyIdentity( + val rpId: String, + val userName: String, + val credentialId: String, + val userHandle: String, + val recordIdentifier: String, +) { + override fun toString(): String = "PasskeyIdentity(rpId=$rpId)" +} + +/** What ASPasskeyAssertionCredential needs, in standard base64. */ +class AppleAssertion(val userHandle: String, val signature: String, val authenticatorData: String, val credentialId: String) + +/** What ASPasskeyRegistrationCredential needs, in standard base64. */ +class AppleRegistration(val credentialId: String, val attestationObject: String, val userName: String) + +/** + * The iOS AutoFill extension's passkey calls (task 5.2), with bytes as + * base64 so Swift never handles a Kotlin byte array. iOS always hands the + * extension the hash of a clientDataJSON it built for a verified origin, + * and the rpId the system checked against the app's associated domains or + * the page, so both arrive here already verified. + */ +object ApplePasskeys { + /** The passkeys of the index, for the identity store. Decrypts each passkey item once. */ + fun identities(accountId: String, index: AutofillIndex, keys: VaultKeys): List = + index.entries.filter { it.isPasskey }.mapNotNull { e -> + val record = runCatching { PasskeyCredential.parse(keys.decryptField(e.key)) { "" } }.getOrNull() ?: return@mapNotNull null + PasskeyIdentity( + rpId = record.rpId, + userName = record.userName.ifEmpty { record.userDisplayName }.ifEmpty { e.name }, + credentialId = base64(Encoding.fromBase64Url(record.credentialId)), + userHandle = record.userHandle.takeIf { it.isNotEmpty() }?.let { base64(Encoding.fromBase64Url(it)) } ?: "", + recordIdentifier = AutofillSites.recordIdentifier(accountId, e.id), + ) + } + + /** The passkeys for [rpId] in one site file; [allowed] narrows them to those credential ids (base64). */ + fun choices(siteJson: String, rpId: String, keys: VaultKeys, allowed: List): List = + Passkeys.candidates( + AutofillIndex.fromJson(siteJson), + rpId, + keys, + allowed.mapNotNull { runCatching { Encoding.toBase64Url(Encoding.fromBase64(it)) }.getOrNull() }, + ) + + /** The site file a passkey for [rpId] is in. */ + fun siteKey(rpId: String): String = AutofillSites.siteKey(rpId) + + @Throws(Exception::class) + suspend fun signIn(api: KeepiqApi, keys: VaultKeys, choice: PasskeyChoice, clientDataHash: String, rpId: String): AppleAssertion { + val a = Passkeys.sign(api, keys, choice, ClientData.hashed(Encoding.fromBase64(clientDataHash)), rpId) + return AppleAssertion( + userHandle = a.userHandle?.let { base64(it) } ?: "", + signature = base64(a.signature), + authenticatorData = base64(a.authenticatorData), + credentialId = base64(a.rawId), + ) + } + + @Throws(Exception::class) + suspend fun register( + api: KeepiqApi, + keys: VaultKeys, + siteJson: String, + rpId: String, + userName: String, + userHandle: String, + clientDataHash: String, + algorithms: List, + nowMillis: Long, + ): AppleRegistration { + val request = CreateRequest( + rpId = rpId, + rpName = null, + userName = userName, + userDisplayName = userName, + userHandle = userHandle.takeIf { it.isNotEmpty() }?.let { Encoding.fromBase64(it) }, + algorithms = algorithms, + excludeCredentialIds = emptyList(), + ) + val r = Passkeys.create(api, keys, AutofillIndex.fromJson(siteJson), request, ClientData.hashed(Encoding.fromBase64(clientDataHash)), nowMillis) + return AppleRegistration(base64(r.credentialId), base64(r.attestationObject), userName) + } + + private fun base64(bytes: ByteArray): String = Encoding.toBase64(bytes) +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/Passkeys.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/Passkeys.kt new file mode 100644 index 000000000..113222833 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/Passkeys.kt @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.passkey + +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.autofill.SiteMatch +import nl.conduction.keepiq.shared.crypto.ClientData +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.crypto.WebAuthn +import nl.conduction.keepiq.shared.send.IsoTime +import nl.conduction.keepiq.shared.vault.ItemCodec +import nl.conduction.keepiq.shared.vault.ItemParts +import nl.conduction.keepiq.shared.vault.VaultKeys + +/** A stored passkey for the asked relying party, decrypted for one request. */ +class PasskeyChoice(val itemId: String, val name: String, val credential: PasskeyCredential) { + /** What the platform shows: the user name, else the display name, else the item name. */ + val label: String get() = credential.userName.ifEmpty { credential.userDisplayName }.ifEmpty { name } + + override fun toString(): String = "PasskeyChoice(id=$itemId)" +} + +/** The site already has one of the passkeys the request excludes (WebAuthn InvalidStateError). */ +class ExcludedCredentialException : IllegalStateException("A passkey for this account is already in your vault.") + +/** The server has no `passkey` type, so a new passkey cannot be saved. */ +class NoPasskeyTypeException : IllegalStateException("This server cannot store passkeys yet.") + +/** + * Keepiq as a passkey provider on the phones (mobile-passkey-provider), as + * browser-extension/src/passkey/orchestrator.js does it in the extension: + * + * - [candidates]: the stored passkeys for an rpId, matched on the item's + * plaintext address first and then on the decrypted record's rpId, which + * must be equal. An allow list narrows them to its credential ids. + * - [sign]: the assertion, and a non-zero counter written back with PUT + * (orchestrator.js handleGet). A write-back that fails fails the request, + * as in the extension. + * - [create]: a new ES256 passkey saved with POST /secrets as a `passkey` + * item, name rpName or rpId, address the rpId (orchestrator.js + * handleCreate). An excluded credential refuses before anything is made. + * + * The rpId MUST come from the origin the operating system verified; the + * callers check that before they come here. + */ +object Passkeys { + fun candidates(index: AutofillIndex, rpId: String, keys: VaultKeys, allowCredentialIds: List = emptyList()): List { + val rp = rpId.lowercase() + val site = SiteMatch.registrableDomain(rp) + if (site.isEmpty()) return emptyList() + return index.entries.filter { it.isPasskey && SiteMatch.registrableDomain(it.url ?: "") == site }.mapNotNull { e -> + val record = runCatching { PasskeyCredential.parse(keys.decryptField(e.key)) { "" } }.getOrNull() ?: return@mapNotNull null + if (record.rpId.lowercase() != rp) return@mapNotNull null + if (allowCredentialIds.isNotEmpty() && record.credentialId !in allowCredentialIds) return@mapNotNull null + PasskeyChoice(e.id, e.name, record) + } + } + + /** Signs for [rpId] with [choice] and writes a non-zero counter back to its item. */ + suspend fun sign(api: KeepiqApi, keys: VaultKeys, choice: PasskeyChoice, clientData: ClientData, rpId: String): WebAuthn.Assertion { + val assertion = WebAuthn.getAssertion(clientData, rpId, choice.credential) + if (assertion.counter > 0 && assertion.counter != choice.credential.counter) { + val updated = choice.credential.copy(counter = assertion.counter) + val parts = ItemParts(choice.name, rpId, null, "", updated.toJson(), null) + api.updateSecret(choice.itemId, ItemCodec.updateBody(parts, setOf("key"), keys)) + } + return assertion + } + + /** + * Creates a passkey for [rpId] and saves it in the vault. Throws + * [ExcludedCredentialException] when the vault holds one of + * [excludeCredentialIds] for this rpId, and a + * [nl.conduction.keepiq.shared.crypto.KeepiqCryptoException] with + * [WebAuthn.UNSUPPORTED_ALGORITHM] when ES256 is not allowed. + */ + suspend fun create( + api: KeepiqApi, + keys: VaultKeys, + index: AutofillIndex, + request: CreateRequest, + clientData: ClientData, + nowMillis: Long, + ): WebAuthn.Registration { + if (!WebAuthn.supports(request.algorithms)) { + throw nl.conduction.keepiq.shared.crypto.KeepiqCryptoException(WebAuthn.UNSUPPORTED_ALGORITHM) + } + if (request.excludeCredentialIds.isNotEmpty() && candidates(index, request.rpId, keys, request.excludeCredentialIds).isNotEmpty()) { + throw ExcludedCredentialException() + } + val typeId = api.listTypes().firstOrNull { (it["name"] as? JsonPrimitive)?.contentOrNull == PasskeyCredential.TYPE_NAME } + ?.let { (it["id"] as? JsonPrimitive)?.contentOrNull } ?: throw NoPasskeyTypeException() + val registration = WebAuthn.createCredential( + rpId = request.rpId, + rpName = request.rpName, + userName = request.userName, + userDisplayName = request.userDisplayName, + userHandle = request.userHandle, + algorithms = request.algorithms, + clientData = clientData, + createdAt = IsoTime.format(nowMillis), + ) + val record = registration.record + val parts = ItemParts(record.rpName.ifEmpty { request.rpId }, request.rpId, null, "", record.toJson(), null) + api.createSecret(ItemCodec.createBody(parts, typeId, keys)) + return registration + } + + /** + * The extension's relying-party rule (browser-extension/src/passkey/rp.js + * rpIdAllowed), for a request a browser makes for a web origin: the rpId + * is the origin's host or a parent domain of it that is not a public + * suffix, and the origin is https (or localhost). + */ + fun rpIdAllowed(rpId: String, origin: String): Boolean { + val scheme = origin.substringBefore("://", "").lowercase() + val host = SiteMatch.hostOf(origin) + if (host.isEmpty()) return false + if (scheme != "https" && host != "localhost") return false + val rp = rpId.lowercase() + if (rp.isEmpty()) return false + if (rp == host) return true + return host.endsWith(".$rp") && !SiteMatch.isPublicSuffix(rp) + } +} + +/** A passkey creation request, whatever platform it came from. */ +class CreateRequest( + val rpId: String, + val rpName: String?, + val userName: String, + val userDisplayName: String, + val userHandle: ByteArray?, + val algorithms: List, + val excludeCredentialIds: List, +) { + override fun toString(): String = "CreateRequest(rpId=$rpId)" +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/WebAuthnJson.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/WebAuthnJson.kt new file mode 100644 index 000000000..1c4594623 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/passkey/WebAuthnJson.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.passkey + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.crypto.WebAuthn + +/** A get request: the challenge, the rpId it names, and the credential ids it allows (base64url). */ +class GetRequest(val challenge: ByteArray, val rpId: String?, val allowCredentialIds: List) { + override fun toString(): String = "GetRequest(rpId=$rpId)" +} + +/** + * The WebAuthn JSON that Android's Credential Manager hands a provider + * (PublicKeyCredentialCreationOptionsJSON and PublicKeyCredentialRequestOptionsJSON, + * binary members in base64url), and the JSON a provider answers with + * (RegistrationResponseJSON and AuthenticationResponseJSON). Null for a + * request that does not parse: the provider then offers nothing. + */ +object WebAuthnJson { + private val json = Json { ignoreUnknownKeys = true } + + fun creation(text: String): CreateRequest? { + val o = parse(text) ?: return null + val rp = o["rp"] as? JsonObject + val user = o["user"] as? JsonObject ?: return null + val rpId = rp?.str("id") ?: return null + return CreateRequest( + rpId = rpId, + rpName = rp.str("name"), + userName = user.str("name") ?: return null, + userDisplayName = user.str("displayName") ?: "", + userHandle = user.str("id")?.let { runCatching { Encoding.fromBase64Url(it) }.getOrNull() }, + algorithms = (o["pubKeyCredParams"] as? JsonArray)?.mapNotNull { p -> + ((p as? JsonObject)?.get("alg") as? JsonPrimitive)?.longOrNull + } ?: emptyList(), + excludeCredentialIds = ids(o["excludeCredentials"]), + ) + } + + /** The creation request's challenge, for the clientDataJSON Keepiq builds itself. */ + fun challenge(text: String): ByteArray? = parse(text)?.str("challenge")?.let { runCatching { Encoding.fromBase64Url(it) }.getOrNull() } + + fun get(text: String): GetRequest? { + val o = parse(text) ?: return null + val challenge = o.str("challenge")?.let { runCatching { Encoding.fromBase64Url(it) }.getOrNull() } ?: return null + return GetRequest(challenge, o.str("rpId"), ids(o["allowCredentials"])) + } + + fun registrationResponse(r: WebAuthn.Registration): String = buildJsonObject { + val id = Encoding.toBase64Url(r.credentialId) + put("id", id) + put("rawId", id) + put("type", "public-key") + put("authenticatorAttachment", "platform") + put( + "response", + buildJsonObject { + // A caller that sent only the hash built the clientDataJSON itself and uses its own. + put("clientDataJSON", r.clientDataJSON?.let { Encoding.toBase64Url(it) } ?: "") + put("attestationObject", Encoding.toBase64Url(r.attestationObject)) + put("transports", buildJsonArray { add(JsonPrimitive("internal")); add(JsonPrimitive("hybrid")) }) + put("authenticatorData", Encoding.toBase64Url(r.authenticatorData)) + put("publicKey", Encoding.toBase64Url(r.publicKeySpki)) + put("publicKeyAlgorithm", PasskeyCredential.ES256) + }, + ) + put("clientExtensionResults", buildJsonObject {}) + }.toString() + + fun assertionResponse(a: WebAuthn.Assertion): String = buildJsonObject { + val id = Encoding.toBase64Url(a.rawId) + put("id", id) + put("rawId", id) + put("type", "public-key") + put("authenticatorAttachment", "platform") + put( + "response", + buildJsonObject { + put("clientDataJSON", a.clientDataJSON?.let { Encoding.toBase64Url(it) } ?: "") + put("authenticatorData", Encoding.toBase64Url(a.authenticatorData)) + put("signature", Encoding.toBase64Url(a.signature)) + a.userHandle?.let { put("userHandle", Encoding.toBase64Url(it)) } + }, + ) + put("clientExtensionResults", buildJsonObject {}) + }.toString() + + private fun parse(text: String): JsonObject? = runCatching { json.parseToJsonElement(text) as? JsonObject }.getOrNull() + + /** Credential ids as the stored record writes them: base64url without padding. */ + private fun ids(element: kotlinx.serialization.json.JsonElement?): List = + (element as? JsonArray)?.mapNotNull { (it as? JsonObject)?.str("id") }?.map { id -> + runCatching { Encoding.toBase64Url(Encoding.fromBase64Url(id.trimEnd('='))) }.getOrDefault(id) + } ?: emptyList() + + private fun JsonObject.str(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.contentOrNull +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/IsoTime.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/IsoTime.kt new file mode 100644 index 000000000..0fa12dffd --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/IsoTime.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.send + +/** + * Reads the server's ISO 8601 times (PHP `format('c')`: + * `2026-10-04T12:00:00+00:00`, also with `Z` or fractions) as epoch + * milliseconds. Common code has no date library; null when unreadable. + */ +object IsoTime { + private val pattern = Regex("""^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:?\d{2})?$""") + + fun parseMillis(text: String?): Long? { + val m = pattern.matchEntire(text?.trim() ?: return null) ?: return null + val g = m.groupValues + val year = g[1].toInt() + val month = g[2].toInt() + val day = g[3].toInt() + if (month !in 1..12 || day !in 1..31) return null + val seconds = g[6].ifEmpty { "0" }.toInt() + val millis = g[7].padEnd(3, '0').take(3).ifEmpty { "0" }.toInt() + val zone = g[8] + val offsetMinutes = when { + zone.isEmpty() || zone == "Z" -> 0 + else -> { + val digits = zone.substring(1).replace(":", "") + val minutes = digits.substring(0, 2).toInt() * 60 + digits.substring(2, 4).toInt() + if (zone[0] == '-') -minutes else minutes + } + } + val days = daysFromCivil(year, month, day) + val local = ((days * 24 + g[4].toInt()) * 60 + g[5].toInt()) * 60 + seconds + return (local - offsetMinutes * 60L) * 1000 + millis + } + + /** Epoch milliseconds as JavaScript's toISOString writes them: `2026-10-04T12:00:00.000Z`. */ + fun format(millis: Long): String { + val days = millis.floorDiv(86_400_000L) + val rest = millis - days * 86_400_000L + val (year, month, day) = civilFromDays(days) + fun two(n: Long) = n.toString().padStart(2, '0') + return "${year.toString().padStart(4, '0')}-${two(month)}-${two(day)}T${two(rest / 3_600_000)}:${two(rest / 60_000 % 60)}:" + + "${two(rest / 1000 % 60)}.${(rest % 1000).toString().padStart(3, '0')}Z" + } + + /** The proleptic Gregorian date of a day count since 1970-01-01 (H. Hinnant's algorithm). */ + private fun civilFromDays(days: Long): Triple { + val z = days + 719468 + val era = (if (z >= 0) z else z - 146096) / 146097 + val doe = z - era * 146097 + val yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 + val doy = doe - (365 * yoe + yoe / 4 - yoe / 100) + val mp = (5 * doy + 2) / 153 + val day = doy - (153 * mp + 2) / 5 + 1 + val month = if (mp < 10) mp + 3 else mp - 9 + return Triple(yoe + era * 400 + (if (month <= 2) 1 else 0), month, day) + } + + /** Days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm). */ + private fun daysFromCivil(year: Int, month: Int, day: Int): Long { + val y = (if (month <= 2) year - 1 else year).toLong() + val era = (if (y >= 0) y else y - 399) / 400 + val yoe = y - era * 400 + val mp = (month + 9) % 12 + val doy = (153 * mp + 2) / 5 + day - 1 + val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146097 + doe - 719468 + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/OpenSend.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/OpenSend.kt new file mode 100644 index 000000000..c8c02c01f --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/OpenSend.kt @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.send + +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.platformHttpEngine +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.SendCrypto + +/** + * A Keepiq Send link: `{origin}{/prefix}/apps/keepiq/public/send/{token}`, + * with `#k=` when no password protects it + * (src/send/sendCrypto.js sendLink). The prefix is `/index.php` on a server + * without pretty URLs, or a subdirectory. + */ +data class SendLink(val apiBase: String, val token: String, val fragmentKey: String?) { + override fun toString(): String = "SendLink(apiBase=$apiBase)" + + companion object { + private const val MARKER = "/apps/keepiq/public/send/" + + /** Reads a link, or null when it is not an https Keepiq Send link. */ + fun parse(link: String): SendLink? { + val text = link.trim() + if (!text.startsWith("https://", ignoreCase = true)) return null + val hashAt = text.indexOf('#') + val beforeHash = if (hashAt >= 0) text.substring(0, hashAt) else text + val fragment = if (hashAt >= 0) text.substring(hashAt + 1) else "" + val noQuery = beforeHash.substringBefore('?') + val at = noQuery.indexOf(MARKER) + if (at < 0) return null + val token = percentDecode(noQuery.substring(at + MARKER.length).trimEnd('/')) + if (token.isEmpty() || token.contains('/')) return null + // As EphemeralSendAccess.vue: `#k=` first, a legacy `?k=` second. + val key = when { + fragment.startsWith("k=") -> percentDecode(fragment.substring(2)) + else -> beforeHash.substringAfter('?', "").split('&').firstOrNull { it.startsWith("k=") }?.substring(2)?.let { percentDecode(it) } + }?.takeIf { it.isNotEmpty() } + return SendLink(noQuery.substring(0, at) + "/apps/keepiq/api/v1/public/sends/" + SendCrypto.encodeUriComponent(token), token, key) + } + + private fun percentDecode(text: String): String { + if (!text.contains('%')) return text + val out = ArrayList() + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == '%' && i + 2 < text.length) { + val hex = text.substring(i + 1, i + 3).toIntOrNull(16) + if (hex != null) { + out += hex.toByte() + i += 3 + continue + } + } + Encoding.utf8(c.toString()).forEach { out += it } + i++ + } + return Encoding.fromUtf8(out.toByteArray()) + } + } +} + +/** What opening a Send ended in. */ +sealed class OpenSendResult { + /** The Send can be opened with the key in the link; opening uses a view. */ + data class Ready(val payloadType: String) : OpenSendResult() + + /** The Send asks for a password before it can be opened. */ + data class NeedsPassword(val payloadType: String) : OpenSendResult() + + /** Opened: one view is used up. [burned] when that was the last one. */ + data class Opened(val payload: String, val payloadType: String, val burned: Boolean) : OpenSendResult() { + override fun toString(): String = "Opened(payloadType=$payloadType, burned=$burned)" + } + + /** A wrong password. The Send is destroyed after 5. */ + data class WrongPassword(val attemptsLeft: Long, val burned: Boolean) : OpenSendResult() + + /** Missing, expired or used up: the server does not tell them apart. */ + data object Gone : OpenSendResult() + + /** The link has no key and the Send has no password: it cannot be opened. */ + data object NoKey : OpenSendResult() + + /** The server could not be reached, or answered something else. */ + data class Failed(val status: Int) : OpenSendResult() +} + +/** + * Opens a Send link in the app, as the public page does it + * (src/views/EphemeralSendAccess.vue and ephemeralSend.accessSend): peek, + * fetch the ciphertext, decrypt on the device, and only then confirm the + * view. A wrong password is reported, so the Send burns after 5. Nothing + * here is signed in: the recipient may have no account on that server. + */ +class OpenSendClient(private val client: HttpClient) { + /** Peeks: whether a password is needed. Uses no view. */ + suspend fun peek(link: SendLink): OpenSendResult = call(HttpMethod.Get, link.apiBase) { data -> + val type = data.text("payloadType") ?: "text" + when { + data.bool("hasPassword") -> OpenSendResult.NeedsPassword(type) + link.fragmentKey == null -> OpenSendResult.NoKey + else -> OpenSendResult.Ready(type) + } + } ?: OpenSendResult.Failed(0) + + /** Fetches, decrypts and confirms. [password] is used when the Send has one. */ + suspend fun open(link: SendLink, password: String): OpenSendResult { + var data: JsonObject? = null + val early = call(HttpMethod.Post, link.apiBase + "/access") { data = it; null } + if (early != null) return early + val access = data ?: return OpenSendResult.Failed(0) + val hasPassword = access.bool("hasPassword") + val plaintext = try { + val rawKey = if (hasPassword) { + SendCrypto.unwrapKey(access.text("wrappedKey") ?: "", access.text("argon2idSalt") ?: "", password) + } else { + Encoding.fromBase64Url(link.fragmentKey ?: return OpenSendResult.NoKey) + } + SendCrypto.openPayload(access.text("encryptedPayload") ?: "", rawKey) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (!hasPassword) return OpenSendResult.Failed(0) + var failure: JsonObject? = null + call(HttpMethod.Post, link.apiBase + "/failure") { failure = it; null }?.let { return it } + val f = failure ?: JsonObject(emptyMap()) + return OpenSendResult.WrongPassword((f["attemptsLeft"] as? JsonPrimitive)?.longOrNull ?: 0, f.bool("burned")) + } + var confirm: JsonObject? = null + call(HttpMethod.Post, link.apiBase + "/confirm") { confirm = it; null }?.let { return it } + return OpenSendResult.Opened(plaintext, access.text("payloadType") ?: "text", confirm?.bool("burned") == true) + } + + /** One public request. Returns a result to stop with, or what [onData] returns. */ + private suspend fun call(method: HttpMethod, url: String, onData: (JsonObject) -> OpenSendResult?): OpenSendResult? { + val (status, text) = try { + val response = client.request(url) { + this.method = method + header(HttpHeaders.Accept, "application/json") + } + response.status.value to response.bodyAsText() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + return OpenSendResult.Failed(0) + } + if (status == 404) return OpenSendResult.Gone + if (status !in 200..299) return OpenSendResult.Failed(status) + val data = runCatching { Json.parseToJsonElement(text) as? JsonObject }.getOrNull() ?: return OpenSendResult.Failed(status) + return onData(data) + } + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + + private fun JsonObject.bool(name: String): Boolean = (this[name] as? JsonPrimitive)?.booleanOrNull == true + + companion object { + /** On the platform engine: no cookies, no redirects (design D3). */ + fun platform(): OpenSendClient = OpenSendClient(KeepiqApi.httpClient(platformHttpEngine())) + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/Sends.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/Sends.kt new file mode 100644 index 000000000..99a8796ae --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/send/Sends.kt @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.send + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.SendCrypto +import nl.conduction.keepiq.shared.vault.WriteProblem +import nl.conduction.keepiq.shared.vault.WriteProblemKind + +/** An expiry choice (browser-extension/src/lib/send-form.js EXPIRY_PRESETS); [seconds] is null for Custom. */ +enum class SendExpiry(val seconds: Long?) { + HOUR(3_600), DAY(86_400), TWO_DAYS(2 * 86_400), THREE_DAYS(3 * 86_400), + SEVEN_DAYS(7 * 86_400), THIRTY_DAYS(30 * 86_400), CUSTOM(null), +} + +/** Why a Send form cannot be sent. */ +enum class SendFormProblem { NOTHING_TO_SEND, CUSTOM_HOURS, CUSTOM_HOURS_TOO_MANY, VIEWS_OUT_OF_RANGE, PASSWORD_NOT_AVAILABLE } + +/** What a Send carries: free text, or a user name and password as two lines. */ +enum class SendPayloadType(val wire: String) { TEXT("text"), CREDENTIAL("credential") } + +/** One of the account's own sends: metadata only, never the payload. */ +data class SendSummary( + val id: String, + val payloadType: String, + val createdAt: String?, + val expiresAt: String?, + val viewCount: Long, + val maxViews: Long, + val hasPassword: Boolean, +) + +/** A created Send: the link to share. Without a password the key rides the fragment. */ +data class CreatedSend(val id: String?, val link: String, val hasPassword: Boolean) { + override fun toString(): String = "CreatedSend(id=$id, hasPassword=$hasPassword)" +} + +sealed class SendResult { + data class Done(val value: T) : SendResult() + + data class Problem(val form: SendFormProblem? = null, val write: WriteProblem? = null) : SendResult() + + /** The value when done, else null. For Swift, which cannot match a generic subclass. */ + val valueOrNull: T? get() = (this as? Done)?.value + + /** The problem when not done, else null. */ + val problemOrNull: Problem? get() = this as? Problem +} + +/** + * The Send form's rules (browser-extension/src/lib/send-form.js) and the + * create, list and delete calls, encrypted on the device as the web app + * and the extension do (src/store/modules/ephemeralSend.js createSend, + * the extension's send-create): a fresh AES-256-GCM key, the payload sealed + * under it, and with a password the key wrapped under Argon2id of it. + */ +object SendForm { + /** EphemeralSendService::MAX_VIEWS_CAP. */ + const val MAX_VIEWS_CAP = 100 + + /** The longest custom expiry, in hours (30 days). */ + const val MAX_CUSTOM_HOURS = 720 + + /** expirySeconds, or the problem to show under the field. */ + fun expirySeconds(expiry: SendExpiry, customHours: String?): Pair { + expiry.seconds?.let { return it to null } + val hours = customHours?.trim()?.toLongOrNull() ?: return null to SendFormProblem.CUSTOM_HOURS + if (hours < 1) return null to SendFormProblem.CUSTOM_HOURS + if (hours > MAX_CUSTOM_HOURS) return null to SendFormProblem.CUSTOM_HOURS_TOO_MANY + return hours * 3_600 to null + } + + /** maxViewsFrom: 1 to 100. */ + fun maxViews(value: String?): Int? = value?.trim()?.toIntOrNull()?.takeIf { it in 1..MAX_VIEWS_CAP } + + /** credentialPayload: exactly two lines, as the recipient page shows the payload as text. */ + fun credentialPayload(username: String, password: String): String = "Username: $username\nPassword: $password" + + /** + * expiresIn, as numbers the screens put into words: minutes left (0 or + * less: expired), or null when the time cannot be read. + */ + fun minutesLeft(expiresAtMillis: Long?, nowMillis: Long): Long? = + expiresAtMillis?.let { (it - nowMillis + 30_000) / 60_000 } +} + +/** Create, list and delete the account's sends. */ +class SendService(private val api: KeepiqApi) { + /** + * Creates a Send. A password needs Argon2id, which iOS does not have yet + * (task 1.3.1); [passwordAvailable] says whether this device can wrap. + */ + suspend fun create( + payloadType: SendPayloadType, + plaintext: String, + maxViews: String?, + expiry: SendExpiry, + customHours: String?, + password: String, + passwordAvailable: Boolean, + ): SendResult { + if (plaintext.trim().isEmpty()) return SendResult.Problem(SendFormProblem.NOTHING_TO_SEND) + val views = SendForm.maxViews(maxViews) ?: return SendResult.Problem(SendFormProblem.VIEWS_OUT_OF_RANGE) + val (ttl, ttlProblem) = SendForm.expirySeconds(expiry, customHours) + if (ttlProblem != null || ttl == null) return SendResult.Problem(ttlProblem ?: SendFormProblem.CUSTOM_HOURS) + if (password.isNotEmpty() && !passwordAvailable) return SendResult.Problem(SendFormProblem.PASSWORD_NOT_AVAILABLE) + + val sealed = SendCrypto.sealPayload(plaintext) + val body = LinkedHashMap() + body["encryptedPayload"] = JsonPrimitive(sealed.encryptedPayload) + body["payloadType"] = JsonPrimitive(payloadType.wire) + body["maxViews"] = JsonPrimitive(views) + body["ttlSeconds"] = JsonPrimitive(ttl) + body["hasPassword"] = JsonPrimitive(password.isNotEmpty()) + if (password.isNotEmpty()) { + val wrap = SendCrypto.wrapKey(sealed.rawKey, password) + body["wrappedKey"] = JsonPrimitive(wrap.wrappedKey) + body["argon2idSalt"] = JsonPrimitive(wrap.argon2idSalt) + } + return try { + val send = api.createSend(JsonObject(body)) + val token = (send?.get("token") as? JsonPrimitive)?.contentOrNull + ?: return SendResult.Problem(write = WriteProblem(WriteProblemKind.FAILED)) + val link = SendCrypto.sendLink(api.publicBase, token, if (password.isNotEmpty()) null else sealed.rawKey) + SendResult.Done(CreatedSend((send["id"] as? JsonPrimitive)?.contentOrNull, link, password.isNotEmpty())) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SendResult.Problem(write = WriteProblem.from(e).offlineWhenUnreachable()) + } + } + + suspend fun list(): SendResult> = try { + SendResult.Done( + api.listSends().mapNotNull { s -> + val id = s.text("id") ?: return@mapNotNull null + SendSummary( + id = id, + payloadType = s.text("payloadType") ?: "text", + createdAt = s.text("createdAt"), + expiresAt = s.text("expiresAt"), + viewCount = (s["viewCount"] as? JsonPrimitive)?.longOrNull ?: 0, + maxViews = (s["maxViews"] as? JsonPrimitive)?.longOrNull ?: 1, + hasPassword = (s["hasPassword"] as? JsonPrimitive)?.booleanOrNull == true, + ) + }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SendResult.Problem(write = WriteProblem.from(e).offlineWhenUnreachable()) + } + + /** Ends a send: its link stops working. */ + suspend fun delete(id: String): SendResult = try { + api.revokeSend(id) + SendResult.Done(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SendResult.Problem(write = WriteProblem.from(e).offlineWhenUnreachable()) + } + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull +} + +/** No send while the server is away (extension-send-details "no send while offline"). */ +internal fun WriteProblem.offlineWhenUnreachable(): WriteProblem = + if (kind == WriteProblemKind.UNREACHABLE) WriteProblem(WriteProblemKind.OFFLINE) else this diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/store/VaultStore.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/store/VaultStore.kt new file mode 100644 index 000000000..774b51c6d --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/store/VaultStore.kt @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.store + +import app.cash.sqldelight.db.SqlDriver +import kotlin.concurrent.Volatile +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.SendCrypto +import nl.conduction.keepiq.shared.store.db.KeepiqDatabase +import nl.conduction.keepiq.shared.vault.VaultLockedException + +/** + * Seals the plaintext metadata the server sends (names, URLs, folder names) + * before it is written to disk, and opens it again. [UnlockKeySealer] is the + * one the app uses; the interface exists so the store logic can be tested on + * its own. + */ +interface MetadataSealer { + fun seal(plaintext: String): String + fun open(sealed: String): String + + /** Drops the key from memory, on lock. */ + fun forget() {} +} + +/** + * AES-256-GCM under the 32-byte unlock key, stored as base64(IV + ciphertext + * and tag), the same blob shape as a Send (src/send/sendCrypto.js aesEncrypt). + * The unlock key lives only while the vault is unlocked, so the names on disk + * are unreadable without the master password (offline-readonly-cache). + */ +class UnlockKeySealer(unlockKey: ByteArray) : MetadataSealer { + private val key = unlockKey.copyOf() + + init { + if (key.size != 32) throw KeepiqCryptoException("The unlock key must be 32 bytes") + } + + override fun seal(plaintext: String): String = SendCrypto.aesEncrypt(key, Encoding.utf8(plaintext)) + + override fun open(sealed: String): String = Encoding.fromUtf8(SendCrypto.aesDecrypt(key, sealed)) + + override fun forget() = key.fill(0) +} + +/** One item as the store gives it back: ciphertext untouched, names opened. */ +data class StoredSecret( + val id: String, + val name: String, + val url: String?, + val typeId: String?, + val folderId: String?, + val key: String?, + val login: String?, + val additionalFields: String?, + val encryptionSuiteId: String?, + val updatedAt: String?, + /** A use-only copy: fill only, never reveal or copy (use-only-shares). */ + val useOnly: Boolean = false, + /** The user may read this item but not change it. */ + val readOnly: Boolean = false, + /** The item's key cannot be used here; [blockedReason] says why. */ + val blocked: Boolean = false, + val blockedReason: String? = null, +) + +data class StoredFolder(val id: String, val name: String, val parentId: String?) + +/** What the last full sync recorded. */ +data class SyncState( + val suiteId: String?, + val unlockKeyEpoch: Long?, + val syncedAtMillis: Long, + val checkTop: String?, + val checkTotal: Long, +) + +/** A full vault as the server sent it, before it is stored. */ +data class VaultSnapshot( + val secrets: List, + val folders: List, + val types: List, + val suiteId: String?, + val unlockKeyEpoch: Long?, + val checkTop: String?, + val checkTotal: Long, +) + +/** + * The offline store of one account (design D5, mobile-shared-core + * "Encrypted offline store"). The [driver] is SQLCipher on Android + * ([openEncryptedDriver] in androidMain) with a random key held under a + * device-bound Keystore key. Item ciphertext is stored as the server sent + * it; names, URLs and folder names are sealed with [sealer]. + * + * Every write replaces the whole vault in one transaction, so a reader never + * sees half a vault. [clear] empties it on unpair, on a suite change, on a + * master-password change and when offline caching is turned off. + */ +class VaultStore(private val driver: SqlDriver, private val sealer: MetadataSealer) { + private val db = KeepiqDatabase(driver) + + @Volatile + private var closed = false + + /** + * On lock: forgets the sealing key and closes the database. The store is + * reopened at the next unlock. A read or write still under way, or one + * started later, throws [VaultLockedException]. + */ + fun close() { + closed = true + sealer.forget() + driver.close() + } + + /** Runs [block] on an open store; a store closed under it reads as locked. */ + private inline fun open(block: () -> T): T { + if (closed) throw VaultLockedException() + return try { + block() + } catch (e: IllegalStateException) { + if (closed) throw VaultLockedException() + println("Keepiq store failed: $e") + throw e + } catch (e: Exception) { + println("Keepiq store failed: $e") + throw e + } + } + + fun replaceAll(snapshot: VaultSnapshot, nowMillis: Long) = open { + db.transaction { + clearRows() + snapshot.secrets.forEachIndexed { index, s -> + db.vaultQueries.insertSecret( + nl.conduction.keepiq.shared.store.db.Secret( + id = s.text("id") ?: return@forEachIndexed, + name_sealed = sealer.seal(s.text("name") ?: ""), + url_sealed = s.text("url")?.let { sealer.seal(it) }, + type_id = s.text("typeId"), + folder_id = s.text("folderId"), + key_ciphertext = s.text("key"), + login_ciphertext = s.text("login"), + additional_fields_ciphertext = s.text("additionalFields"), + encryption_suite_id = s.text("encryptionSuiteId"), + updated_at = s.text("updatedAt"), + position = index.toLong(), + use_only = if (s.flag("useOnly")) 1L else 0L, + read_only = if (s.flag("readOnly")) 1L else 0L, + blocked = if (s.flag("blocked")) 1L else 0L, + blocked_reason = s.text("blockedReason"), + ), + ) + } + for (f in snapshot.folders) { + db.vaultQueries.insertFolder( + nl.conduction.keepiq.shared.store.db.Folder( + id = f.text("id") ?: continue, + name_sealed = sealer.seal(f.text("name") ?: ""), + parent_id = f.text("parentId"), + ), + ) + } + for (t in snapshot.types) { + db.vaultQueries.insertType( + nl.conduction.keepiq.shared.store.db.Secret_type(id = t.text("id") ?: continue, json = t.toString()), + ) + } + db.vaultQueries.putState( + snapshot.suiteId, + snapshot.unlockKeyEpoch, + nowMillis, + snapshot.checkTop, + snapshot.checkTotal, + ) + } + } + + /** Records a cheap check that found nothing new. */ + fun touch(nowMillis: Long) = open { db.vaultQueries.touchState(nowMillis) } + + fun state(): SyncState? = open { + db.vaultQueries.state().executeAsOneOrNull()?.let { + SyncState(it.suite_id, it.unlock_key_epoch, it.synced_at_millis, it.check_top, it.check_total) + } + } + + fun secrets(): List = open { db.vaultQueries.secrets().executeAsList().map { it.open() } } + + fun secret(id: String): StoredSecret? = open { db.vaultQueries.secretById(id).executeAsOneOrNull()?.open() } + + fun folders(): List = open { + db.vaultQueries.folders().executeAsList().map { StoredFolder(it.id, sealer.open(it.name_sealed), it.parent_id) } + } + + fun types(): List = open { db.vaultQueries.types().executeAsList().map { it.json } } + + /** Empties the store: unpair, suite change, master-password change, offline caching off. */ + fun clear() = open { db.transaction { clearRows() } } + + private fun clearRows() { + // Deleted rows are overwritten with zeros, not left in free pages. Set + // inside the transaction: a driver may hand out a fresh connection + // per call outside one, and the pragma is per connection. A query, + // not an execute: the pragma answers with a row, and Android's + // SQLite refuses a statement that returns rows from execute. + driver.executeQuery(null, "PRAGMA secure_delete = ON", { cursor -> cursor.next() }, 0) + db.vaultQueries.deleteSecrets() + db.vaultQueries.deleteFolders() + db.vaultQueries.deleteTypes() + db.vaultQueries.deleteState() + } + + private fun nl.conduction.keepiq.shared.store.db.Secret.open() = StoredSecret( + id = id, + name = sealer.open(name_sealed), + url = url_sealed?.let { sealer.open(it) }, + typeId = type_id, + folderId = folder_id, + key = key_ciphertext, + login = login_ciphertext, + additionalFields = additional_fields_ciphertext, + encryptionSuiteId = encryption_suite_id, + updatedAt = updated_at, + useOnly = use_only != 0L, + readOnly = read_only != 0L, + blocked = blocked != 0L, + blockedReason = blocked_reason, + ) + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + + private fun JsonObject.flag(name: String): Boolean = (this[name] as? JsonPrimitive)?.contentOrNull == "true" +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/sync/VaultSync.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/sync/VaultSync.kt new file mode 100644 index 000000000..8c5e0befb --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/sync/VaultSync.kt @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.sync + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.KeepiqApiException +import nl.conduction.keepiq.shared.api.Manifest +import nl.conduction.keepiq.shared.store.VaultSnapshot +import nl.conduction.keepiq.shared.store.VaultStore + +/** Why a sync ran (design D5). */ +enum class SyncTrigger { + /** The app opened. Always a full sync. */ + START, + + /** The app came back to the foreground. Cheap check first. */ + FOREGROUND, + + /** A write went through. Always a full sync. */ + AFTER_WRITE, + + /** The 15-minute timer while the app is open. Cheap check first. */ + TIMER, + + /** The user asked for it. Always a full sync. */ + MANUAL, +} + +/** Why the vault was locked by a sync. */ +enum class LockReason { SUITE_CHANGED, MASTER_PASSWORD_CHANGED, UNLOCK_BLOCKED } + +/** The suite the vault was unlocked with. */ +data class UnlockedSession(val suiteId: String, val unlockKeyEpoch: Long?) + +sealed class SyncOutcome { + /** The cheap check found nothing new; the stored copy is current. */ + data class Fresh(val syncedAtMillis: Long) : SyncOutcome() + + /** A full sync replaced the stored copy. */ + data class Synced(val syncedAtMillis: Long, val secrets: Int) : SyncOutcome() + + /** + * Offline caching is off for the organisation. Nothing is kept on disk; + * the vault is only in this answer, for the current unlocked session. + */ + data class OnlineOnly( + val secrets: List, + val folders: List, + val types: List, + ) : SyncOutcome() + + /** The sync locked the vault and emptied the store. */ + data class Locked(val reason: LockReason) : SyncOutcome() +} + +/** What the app does when a sync locks the vault. */ +interface SyncListener { + /** Lock now and ask for the master password. */ + fun lock(reason: LockReason) + + /** + * Delete the biometric and PIN wraps of the unlock key: they wrap a key + * that no longer opens the envelope (design D4). + */ + fun deleteUnlockWraps() +} + +/** + * Sync without a change feed, as browser-extension/src/background/vault-sync.js + * does it: the offline manifest on start, after a write and on request; on + * foreground and on the timer a cheap check of the newest `updatedAt` and + * the total first, and the manifest only when either changed or the copy is + * older than 15 minutes. + * + * Every full sync compares the suite id and its `unlockKeyEpoch` with the + * suite the vault was unlocked with. A new suite, a new epoch (a + * master-password change elsewhere) or an `unlockBlocked` signal empties the + * store and locks; an epoch change also deletes the unlock wraps. + */ +class VaultSync( + private val api: KeepiqApi, + private val store: VaultStore, + private val listener: SyncListener, + private val clock: () -> Long, +) { + suspend fun sync(trigger: SyncTrigger, session: UnlockedSession): SyncOutcome { + val cached = store.state() + val force = trigger == SyncTrigger.START || trigger == SyncTrigger.AFTER_WRITE || trigger == SyncTrigger.MANUAL + if (!force && cached != null) { + val latest = api.latestSecret() + val fresh = clock() - cached.syncedAtMillis < SYNC_INTERVAL_MILLIS + if (fresh && latest.top == cached.checkTop && latest.total == cached.checkTotal) { + val now = clock() + store.touch(now) + return SyncOutcome.Fresh(now) + } + } + + val manifest = try { + api.offlineManifest() + } catch (e: KeepiqApiException) { + // Offline caching off: 403 (OfflineController), 428 (design D5), + // 404 without an active suite. The extension treats all three alike. + if (e.status != 403 && e.status != 428 && e.status != 404) throw e + null + } + + if (manifest == null) { + store.clear() + val suite = api.activeSuite() + checkSuite(suite?.id, suite?.unlockKeyEpoch, suite?.unlockBlocked, session)?.let { return it } + return SyncOutcome.OnlineOnly(api.listSecrets(), api.listFolders(), api.listTypes()) + } + + checkSuite(manifest.suite?.id, manifest.suite?.unlockKeyEpoch, manifest.unlockBlocked, session)?.let { return it } + + val now = clock() + store.replaceAll(snapshotOf(manifest), now) + return SyncOutcome.Synced(now, manifest.secrets.size) + } + + private fun checkSuite(suiteId: String?, epoch: Long?, unlockBlocked: String?, session: UnlockedSession): SyncOutcome? { + if (unlockBlocked != null) return lock(LockReason.UNLOCK_BLOCKED, deleteWraps = false) + if (suiteId != null && suiteId != session.suiteId) return lock(LockReason.SUITE_CHANGED, deleteWraps = true) + if (epoch != null && session.unlockKeyEpoch != null && epoch != session.unlockKeyEpoch) { + return lock(LockReason.MASTER_PASSWORD_CHANGED, deleteWraps = true) + } + return null + } + + private fun lock(reason: LockReason, deleteWraps: Boolean): SyncOutcome { + store.clear() + if (deleteWraps) listener.deleteUnlockWraps() + listener.lock(reason) + return SyncOutcome.Locked(reason) + } + + private fun snapshotOf(manifest: Manifest): VaultSnapshot { + // As vault-sync.js: the newest updatedAt by string order, and the count. + fun updatedAt(secret: JsonObject) = (secret["updatedAt"] as? JsonPrimitive)?.contentOrNull + val newest = manifest.secrets.mapNotNull { updatedAt(it) }.maxOrNull() + return VaultSnapshot( + secrets = manifest.secrets, + folders = manifest.folders, + types = manifest.types, + suiteId = manifest.suite?.id, + unlockKeyEpoch = manifest.suite?.unlockKeyEpoch, + checkTop = newest, + checkTotal = manifest.secrets.size.toLong(), + ) + } + + companion object { + /** The extension's SYNC_INTERVAL_MINUTES. */ + const val SYNC_INTERVAL_MILLIS = 15L * 60_000L + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/PinUnlock.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/PinUnlock.kt new file mode 100644 index 000000000..5e98c1da9 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/PinUnlock.kt @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.unlock + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import nl.conduction.keepiq.shared.account.SecureStorage +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.Primitives +import nl.conduction.keepiq.shared.crypto.SendCrypto +import nl.conduction.keepiq.shared.crypto.argon2idAvailable + +/** A wrong PIN, or the PIN was wiped. [triesLeft] is 0 when it was wiped. */ +class WrongPinException(val triesLeft: Int) : Exception( + when (triesLeft) { + 0 -> "Too many wrong PINs. Unlock with your master password." + 1 -> "Wrong PIN. 1 try left." + else -> "Wrong PIN. $triesLeft tries left." + }, +) + +@Serializable +private data class PinRecord(val salt: String, val wrapped: String, val attempts: Int = 0) + +/** + * Unlock with a PIN (design D4), the scheme of + * browser-extension/src/lib/pin-unlock.js: the 32-byte unlock key is wrapped + * with AES-256-GCM under Argon2id(PIN) with a fresh 16-byte salt (the Send + * parameters: 64 MiB, 3 passes, 1 lane). After [MAX_ATTEMPTS] wrong PINs the + * wrap is deleted. + * + * Unlike the extension, the wrap survives a restart. It stays bound to the + * device because [SecureStorage] seals it under a Keystore or Keychain key. + */ +class PinUnlock(private val storage: SecureStorage) { + private val json = Json { ignoreUnknownKeys = true } + + /** Whether PIN unlock works on this platform (Argon2id is available). */ + val available: Boolean get() = argon2idAvailable + + fun has(accountId: String): Boolean = record(accountId) != null + + /** Wraps [unlockKey] under [pin]. Throws [IllegalArgumentException] with the reason for a PIN that is too short or long. */ + @Throws(Exception::class) + fun set(accountId: String, unlockKey: ByteArray, pin: String) { + problem(pin)?.let { throw IllegalArgumentException(it) } + if (unlockKey.size != 32) throw KeepiqCryptoException("The unlock key must be 32 bytes") + val salt = Primitives.randomBytes(SendCrypto.ARGON2_SALT_LENGTH) + val wrapped = SendCrypto.aesEncrypt(SendCrypto.deriveKek(pin, salt), unlockKey) + storage.write(key(accountId), json.encodeToString(PinRecord.serializer(), PinRecord(Encoding.toBase64(salt), wrapped))) + } + + /** The unlock key, or [WrongPinException]. The fifth wrong PIN deletes the wrap. */ + @Throws(Exception::class) + fun open(accountId: String, pin: String): ByteArray { + val record = record(accountId) ?: throw WrongPinException(0) + val kek = SendCrypto.deriveKek(pin, Encoding.fromBase64(record.salt)) + return try { + SendCrypto.aesDecrypt(kek, record.wrapped).also { + if (record.attempts != 0) storage.write(key(accountId), json.encodeToString(PinRecord.serializer(), record.copy(attempts = 0))) + } + } catch (e: KeepiqCryptoException) { + val attempts = record.attempts + 1 + if (attempts >= MAX_ATTEMPTS) { + remove(accountId) + throw WrongPinException(0) + } + storage.write(key(accountId), json.encodeToString(PinRecord.serializer(), record.copy(attempts = attempts))) + throw WrongPinException(MAX_ATTEMPTS - attempts) + } + } + + fun remove(accountId: String) = storage.delete(key(accountId)) + + private fun record(accountId: String): PinRecord? = + storage.read(key(accountId))?.let { runCatching { json.decodeFromString(PinRecord.serializer(), it) }.getOrNull() } + + private fun key(accountId: String) = "pin:$accountId" + + companion object { + const val MAX_ATTEMPTS = 5 + const val MIN_LENGTH = 6 + const val MAX_LENGTH = 64 + + /** What is wrong with a PIN, or null (pin-unlock.js pinProblem). */ + fun problem(pin: String): String? = when { + pin.length < MIN_LENGTH -> "A PIN has at least $MIN_LENGTH characters." + pin.length > MAX_LENGTH -> "A PIN has at most $MAX_LENGTH characters." + else -> null + } + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/VaultUnlock.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/VaultUnlock.kt new file mode 100644 index 000000000..f2fed04ff --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/unlock/VaultUnlock.kt @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.unlock + +import nl.conduction.keepiq.shared.api.Suite +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.PrivateKeyEnvelope +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultLockedException + +/** The server withholds the private key (`unlockBlocked`). [code] is the server's reason, such as `two_factor_required`. */ +class UnlockBlockedException(val code: String) : Exception(blockedMessage(code)) + +/** The master password does not open the envelope. Nothing was unlocked. */ +class WrongMasterPasswordException : Exception("This master password is not correct.") + +/** + * A stored unlock key (biometric or PIN) no longer opens the envelope: the + * master password changed elsewhere. The wraps are deleted. + */ +class StaleUnlockKeyException : Exception("Your master password changed. Unlock with the new master password.") + +/** The account has no vault yet: it was never set up in the web app. */ +class NoVaultException : Exception("This account has no vault yet. Open Keepiq in the browser once to set it up.") + +/** + * An unlocked vault. The private key and the unlock key live in memory only + * (design D4). [lock] overwrites the unlock key and the private key bytes of + * the [keys] handed out, and drops the PEM text, so nothing decrypts after + * it. The PEM is an immutable string: lock drops the last reference to it, + * which is as far as the JVM and Kotlin/Native let a string be erased. + */ +class UnlockedVault( + val accountId: String, + val suiteId: String, + val unlockKeyEpoch: Long?, + val certificate: String?, + privateKeyPem: String, + unlockKey: ByteArray, +) { + private val key = unlockKey.copyOf() + private var pem: String? = privateKeyPem + private var vaultKeys: RsaVaultKeys? = null + + /** True once [lock] ran. */ + var isLocked: Boolean = false + private set + + /** The opened private key (PKCS#8 PEM). Throws once locked. */ + val privateKeyPem: String get() = pem ?: throw VaultLockedException() + + /** A copy of the 32-byte unlock key, to wrap it for biometric or PIN unlock. */ + @Throws(VaultLockedException::class) + fun unlockKey(): ByteArray { + if (isLocked) throw VaultLockedException() + return key.copyOf() + } + + /** The unlock key in base64, for the Swift side, which keeps it in the Keychain. */ + @Throws(VaultLockedException::class) + fun unlockKeyBase64(): String = Encoding.toBase64(unlockKey()) + + /** + * The keys the vault, Send and generator screens encrypt and decrypt + * with, built once from the opened key and the suite's certificate. + */ + @Throws(VaultLockedException::class, NoVaultException::class) + fun keys(): VaultKeys { + if (isLocked) throw VaultLockedException() + vaultKeys?.let { return it } + val cert = certificate ?: throw NoVaultException() + return RsaVaultKeys.fromPem(privateKeyPem, cert, suiteId, unlockKeyEpoch).also { vaultKeys = it } + } + + fun lock() { + key.fill(0) + vaultKeys?.forget() + vaultKeys = null + pem = null + isLocked = true + } + + override fun toString(): String = "UnlockedVault(accountId=$accountId, suiteId=$suiteId)" +} + +/** + * Opens the private-key envelope of a suite (design D4, "First unlock"). A + * suite with `unlockBlocked` is refused before any key is derived, and + * carries no envelope anyway (admin-vault-policies D3). + */ +object VaultUnlock { + @Throws(Exception::class) + fun withMasterPassword(accountId: String, suite: Suite, masterPassword: String): UnlockedVault { + val envelope = checkSuite(suite) + val parts = try { + PrivateKeyEnvelope.decode(envelope) + } catch (e: KeepiqCryptoException) { + throw KeepiqCryptoException("The vault on the server is in a format this app cannot open.", e) + } + val key = PrivateKeyEnvelope.deriveUnlockKey(masterPassword, parts.salt) + val pem = try { + PrivateKeyEnvelope.openWithUnlockKey(envelope, key) + } catch (e: KeepiqCryptoException) { + throw WrongMasterPasswordException() + } + return UnlockedVault(accountId, suite.id, suite.unlockKeyEpoch, suite.certificate, pem, key) + } + + @Throws(Exception::class) + fun withUnlockKey(accountId: String, suite: Suite, unlockKey: ByteArray): UnlockedVault { + val envelope = checkSuite(suite) + val pem = try { + PrivateKeyEnvelope.openWithUnlockKey(envelope, unlockKey) + } catch (e: KeepiqCryptoException) { + throw StaleUnlockKeyException() + } + return UnlockedVault(accountId, suite.id, suite.unlockKeyEpoch, suite.certificate, pem, unlockKey) + } + + private fun checkSuite(suite: Suite): String { + suite.unlockBlocked?.let { throw UnlockBlockedException(it) } + return suite.privateKey ?: throw NoVaultException() + } +} + +/** The words the unlock screen shows for a blocked unlock. */ +fun blockedMessage(code: String): String = when (code) { + "two_factor_required" -> + "Your organisation requires two-factor authentication before you can open your vault. " + + "Set it up in Nextcloud, under Personal settings and then Security, and come back." + else -> "Your organisation does not allow unlocking your vault right now ($code)." +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/ItemCodec.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/ItemCodec.kt new file mode 100644 index 000000000..8d2459caf --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/ItemCodec.kt @@ -0,0 +1,378 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.crypto.Totp + +/** How the screens treat a type (browser-extension/src/lib/item-form.js formKind). */ +enum class FormKind { + LOGIN, NOTE, TOTP, CARD, IDENTITY, PASSKEY, GENERIC; + + companion object { + fun of(typeName: String): FormKind = when (typeName) { + "login" -> LOGIN + "note" -> NOTE + "totp" -> TOTP + "card" -> CARD + "identity" -> IDENTITY + "passkey" -> PASSKEY + else -> GENERIC + } + } +} + +/** The card and identity members, in the order the web app writes them (src/cardIdentity/cardIdentity.js). */ +object Composite { + val CARD_FIELDS = listOf("number", "expiry", "cvv", "pin", "cardholder") + val IDENTITY_FIELDS = listOf("firstName", "lastName", "address", "phone", "email", "bsn") + + /** MASKED_COMPOSITE: shown hidden until revealed. */ + val MASKED = setOf("number", "cvv", "pin", "bsn") + + fun fieldsOf(kind: FormKind): List = when (kind) { + FormKind.CARD -> CARD_FIELDS + FormKind.IDENTITY -> IDENTITY_FIELDS + else -> emptyList() + } + + /** parsePayload: a JSON object, or null. */ + fun parse(raw: String): Map? { + if (raw.isEmpty() || raw[0] != '{') return null + val obj = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return null + return obj.mapValues { (_, v) -> (v as? JsonPrimitive)?.contentOrNull ?: v.toString() } + } + + /** serializeCard / serializeIdentity: every member, in order, as a string. */ + fun serialize(kind: FormKind, values: Map): String = + JsonObject(fieldsOf(kind).associateWith { JsonPrimitive(values[it] ?: "") }).toString() + + /** cardLast4. */ + fun last4(number: String): String = number.filter { it.isDigit() }.let { if (it.length >= 4) it.takeLast(4) else "" } +} + +/** What a passkey item shows: never its private key (extension-vault "a passkey's private key stays in the worker"). */ +data class PasskeySummary(val rpId: String, val rpName: String?, val userName: String?, val createdAt: String?) + +/** + * An opened item. [secret] is the decrypted `key` field; it is empty for a + * passkey and for a use-only copy, whose value the app never shows. + */ +class DecryptedItem( + val row: VaultRow, + val typeName: String, + val type: SecretType?, + val login: String, + val secret: String, + val additionalFields: JsonObject?, + val additionalFieldsError: Boolean, + val passkey: PasskeySummary?, + /** Read from the offline store because the server could not be reached. */ + val fromCache: Boolean, +) { + val kind: FormKind get() = FormKind.of(typeName) + + /** The notes: a note's value, else the additional field named "notes" (any case). */ + val notes: String + get() = if (kind == FormKind.NOTE) { + secret + } else { + notesKey()?.let { additionalFields?.get(it)?.asText() } ?: "" + } + + /** The card or identity members, or null when the value is not one. */ + val composite: Map? get() = Composite.parse(secret) + + /** The typed fields of the type, with their values, in the type's order. */ + val typedValues: List> + get() = (type?.fields ?: emptyList()).map { f -> f to (additionalFields?.get(f.label)?.asText() ?: "") } + + /** Additional fields that are neither notes nor typed fields. */ + val extraFields: List> + get() { + val typed = type?.fields?.map { it.label }?.toSet() ?: emptySet() + val notes = notesKey() + return additionalFields?.entries + ?.filter { (k, _) -> k != notes && k !in typed } + ?.map { (k, v) -> k to v.asText() } ?: emptyList() + } + + /** The TOTP parameters, or null when the value is not an authenticator secret. */ + val totp: Totp.Params? + get() = if (kind == FormKind.TOTP && secret.isNotBlank()) runCatching { Totp.parse(secret) }.getOrNull() else null + + private fun notesKey(): String? = additionalFields?.keys?.firstOrNull { it.lowercase() == ItemCodec.NOTES_FIELD } + + override fun toString(): String = "DecryptedItem(id=${row.id}, type=$typeName)" +} + +/** + * The plaintext parts of an item, before encryption + * (browser-extension/src/lib/item-form.js partsFromDraft). + */ +data class ItemParts( + val name: String, + val url: String, + val folderId: String?, + val login: String, + val key: String, + val additionalFields: JsonObject?, +) { + override fun toString(): String = "ItemParts(name=$name, folderId=$folderId)" +} + +/** The form's draft (draftFromItem). */ +data class ItemDraft( + val kind: FormKind, + val typeId: String?, + val name: String = "", + val url: String = "", + val folderId: String? = null, + val login: String = "", + val secret: String = "", + val notes: String = "", + val composite: Map = emptyMap(), + /** Typed field values by field key. */ + val typed: Map = emptyMap(), + /** Other additional fields, name and value, in order. */ + val fields: List> = emptyList(), + /** Additional members that are not text: kept as they are, never shown. */ + val preserved: Map = emptyMap(), +) { + override fun toString(): String = "ItemDraft(kind=$kind, name=$name)" + + val fieldNames: List get() = fields.map { it.first } + val fieldValues: List get() = fields.map { it.second } + + /** + * The same draft with what a form edited; kind, type and preserved + * members stay. For Swift, which sees no default arguments. + */ + fun edited( + name: String, + url: String, + folderId: String?, + login: String, + secret: String, + notes: String, + composite: Map, + typed: Map, + fieldNames: List, + fieldValues: List, + ): ItemDraft = copy( + name = name, url = url, folderId = folderId, login = login, secret = secret, notes = notes, + composite = composite, typed = typed, fields = fieldNames.zip(fieldValues), + ) +} + +/** Why a draft cannot be saved, per field. */ +enum class DraftProblem { NAME_MISSING, TOO_LONG, FIELD_NAME_MISSING, FIELD_NAME_RESERVED, FIELD_NAME_TAKEN, NOT_AN_AUTHENTICATOR_SECRET, REQUIRED } + +/** + * Decrypting an item for the detail screen, and turning a form into the + * request body: the field shapes the web app writes (src/store/modules/secret.js + * createSecret and updateSecret), encrypted on the device. + */ +object ItemCodec { + const val NOTES_FIELD = "notes" + const val MAX_NAME_CHARS = 255 + const val MAX_FIELD_CHARS = 4096 + const val MAX_PAYLOAD_BYTES = 65536 + + /** RESERVED_MEMBER_NAMES (src/utils/additionalFields.js), plus the notes member. */ + val RESERVED_FIELD_NAMES = setOf("key", "login", "url") + + /** + * Opens an item. A blocked item is not decrypted. A use-only copy shows + * its login name and, for an authenticator, the current code, as the web + * app may (use-only-shares); its value and additional fields stay closed. + */ + fun open(row: VaultRow, type: SecretType?, keys: VaultKeys, fromCache: Boolean, now: () -> String = { "" }): DecryptedItem { + val typeName = type?.name ?: "login" + val kind = FormKind.of(typeName) + if (row.blocked) { + return DecryptedItem(row, typeName, type, "", "", null, false, null, fromCache) + } + val login = keys.decryptField(row.login) + if (row.useOnly) { + val code = if (kind == FormKind.TOTP) keys.decryptField(row.key) else "" + return DecryptedItem(row, typeName, type, login, code, null, false, null, fromCache) + } + var secret = keys.decryptField(row.key) + var passkey: PasskeySummary? = null + if (kind == FormKind.PASSKEY) { + passkey = PasskeyCredential.parse(secret, now)?.let { + PasskeySummary(it.rpId, it.rpName.ifEmpty { null }, (it.userName.ifEmpty { it.userDisplayName }).ifEmpty { null }, it.createdAt.ifEmpty { null }) + } + secret = "" + } + var fields: JsonObject? = null + var fieldsError = false + if (!row.additionalFields.isNullOrEmpty()) { + val parsed = runCatching { Json.parseToJsonElement(keys.decryptField(row.additionalFields)) }.getOrNull() + if (parsed is JsonObject) fields = parsed else fieldsError = true + } + return DecryptedItem(row, typeName, type, login, secret, fields, fieldsError, passkey, fromCache) + } + + /** draftFromItem; an empty draft of [type] when [item] is null. */ + fun draft(item: DecryptedItem?, type: SecretType?): ItemDraft { + val kind = FormKind.of(type?.name ?: item?.typeName ?: "login") + if (item == null) return ItemDraft(kind = kind, typeId = type?.id, typed = type?.fields?.associate { it.key to "" } ?: emptyMap()) + val fields = item.additionalFields ?: JsonObject(emptyMap()) + val notesKey = fields.keys.firstOrNull { it.lowercase() == NOTES_FIELD } + val typedLabels = type?.fields?.associate { it.label to it.key } ?: emptyMap() + val typed = LinkedHashMap() + type?.fields?.forEach { typed[it.key] = "" } + val extra = ArrayList>() + val preserved = LinkedHashMap() + for ((name, value) in fields) { + if (name == notesKey) continue + val key = typedLabels[name] + when { + key != null -> typed[key] = value.asText() + value is JsonPrimitive && value.isString -> extra += name to value.content + else -> preserved[name] = value + } + } + val composite = if (kind == FormKind.CARD || kind == FormKind.IDENTITY) { + val parsed = Composite.parse(item.secret) ?: emptyMap() + Composite.fieldsOf(kind).associateWith { parsed[it] ?: "" } + } else { + emptyMap() + } + return ItemDraft( + kind = kind, + typeId = item.row.typeId, + name = item.row.name, + url = item.row.url ?: "", + folderId = item.row.folderId, + login = item.login, + secret = if (kind == FormKind.NOTE || kind == FormKind.CARD || kind == FormKind.IDENTITY) "" else item.secret, + notes = item.notes, + composite = composite, + typed = typed, + fields = extra, + preserved = preserved, + ) + } + + /** partsFromDraft, with the typed fields merged under their labels (mergeTypedValues). */ + fun parts(draft: ItemDraft, type: SecretType?): ItemParts { + val key = when (draft.kind) { + FormKind.NOTE -> draft.notes + FormKind.CARD, FormKind.IDENTITY -> Composite.serialize(draft.kind, draft.composite) + else -> draft.secret + } + val fields = LinkedHashMap() + fields.putAll(draft.preserved) + for ((name, value) in draft.fields) fields[name.trim()] = JsonPrimitive(value) + for (f in type?.fields ?: emptyList()) { + val value = draft.typed[f.key] ?: "" + if (value != "") fields[f.label] = JsonPrimitive(value) else fields.remove(f.label) + } + if (draft.kind != FormKind.NOTE && draft.notes.trim().isNotEmpty()) fields[NOTES_FIELD] = JsonPrimitive(draft.notes) + return ItemParts( + name = draft.name.trim(), + url = draft.url.trim(), + folderId = draft.folderId?.takeIf { it.isNotEmpty() }, + login = if (draft.kind == FormKind.LOGIN || draft.kind == FormKind.GENERIC) draft.login else "", + key = key, + additionalFields = if (fields.isEmpty()) null else JsonObject(fields), + ) + } + + /** validateDraft: problems by field ("name", "url", "login", "secret", "fields", "field-", "typed-"). */ + fun validate(draft: ItemDraft, type: SecretType?): Map { + val errors = LinkedHashMap() + if (draft.name.trim().isEmpty()) errors["name"] = DraftProblem.NAME_MISSING + if (draft.name.length > MAX_NAME_CHARS) errors["name"] = DraftProblem.TOO_LONG + if (draft.url.length > MAX_FIELD_CHARS) errors["url"] = DraftProblem.TOO_LONG + if (draft.login.length > MAX_FIELD_CHARS) errors["login"] = DraftProblem.TOO_LONG + val seen = HashSet() + val typedLabels = type?.fields?.map { it.label.lowercase() }?.toSet() ?: emptySet() + draft.fields.forEachIndexed { i, (name, value) -> + val clean = name.trim().lowercase() + when { + clean.isEmpty() -> errors["field-$i"] = DraftProblem.FIELD_NAME_MISSING + clean in RESERVED_FIELD_NAMES || clean == NOTES_FIELD -> errors["field-$i"] = DraftProblem.FIELD_NAME_RESERVED + clean in seen || clean in typedLabels -> errors["field-$i"] = DraftProblem.FIELD_NAME_TAKEN + value.length > MAX_FIELD_CHARS -> errors["field-$i"] = DraftProblem.TOO_LONG + } + seen += clean + } + for (f in type?.fields ?: emptyList()) { + if (f.required && (draft.typed[f.key] ?: "").trim().isEmpty()) errors["typed-${f.key}"] = DraftProblem.REQUIRED + } + if (draft.kind == FormKind.TOTP && draft.secret.trim().isNotEmpty() && runCatching { Totp.parse(draft.secret) }.isFailure) { + errors["secret"] = DraftProblem.NOT_AN_AUTHENTICATOR_SECRET + } + val parts = parts(draft, type) + if (Encoding.utf8(parts.key).size > MAX_PAYLOAD_BYTES) errors["secret"] = DraftProblem.TOO_LONG + if (parts.additionalFields != null && Encoding.utf8(parts.additionalFields.toString()).size > MAX_PAYLOAD_BYTES) { + errors["fields"] = DraftProblem.TOO_LONG + } + return errors + } + + /** The fields whose plaintext differs (changedParts): only those are sent on an update. */ + fun changed(before: ItemParts, after: ItemParts): Set = buildSet { + if (before.name != after.name) add("name") + if (before.url != after.url) add("url") + if ((before.folderId ?: "") != (after.folderId ?: "")) add("folderId") + if (before.login != after.login) add("login") + if (before.key != after.key) add("key") + if (before.additionalFields != after.additionalFields) add("additionalFields") + } + + /** + * The create body, as src/store/modules/secret.js createSecret writes it: + * `key` always encrypted (an empty value is one chunk), `login` only when + * not empty, `additionalFields` as one encrypted JSON object when present. + */ + fun createBody(parts: ItemParts, typeId: String?, keys: VaultKeys): JsonObject { + val body = LinkedHashMap() + body["name"] = JsonPrimitive(parts.name) + body["url"] = parts.url.ifEmpty { null }.json() + body["typeId"] = typeId.json() + body["folderId"] = parts.folderId.json() + body["key"] = JsonPrimitive(keys.encryptField(parts.key)) + if (parts.login.isNotEmpty()) body["login"] = JsonPrimitive(keys.encryptField(parts.login)) + parts.additionalFields?.let { body["additionalFields"] = JsonPrimitive(keys.encryptField(it.toString())) } + return JsonObject(body) + } + + /** + * A sparse update body with only [changed] fields, as updateSecret writes + * it: an emptied login is sent as null, emptied additional fields as null + * (the extension's vault-save), anything else encrypted. + */ + fun updateBody(parts: ItemParts, changed: Set, keys: VaultKeys): JsonObject { + val body = LinkedHashMap() + if ("name" in changed) body["name"] = JsonPrimitive(parts.name) + if ("url" in changed) body["url"] = parts.url.ifEmpty { null }.json() + if ("folderId" in changed) body["folderId"] = parts.folderId.json() + if ("key" in changed) body["key"] = JsonPrimitive(keys.encryptField(parts.key)) + if ("login" in changed) body["login"] = if (parts.login.isEmpty()) JsonNull else JsonPrimitive(keys.encryptField(parts.login)) + if ("additionalFields" in changed) { + body["additionalFields"] = parts.additionalFields?.let { JsonPrimitive(keys.encryptField(it.toString())) } ?: JsonNull + } + return JsonObject(body) + } + + private fun String?.json(): JsonElement = if (this == null) JsonNull else JsonPrimitive(this) +} + +internal fun JsonElement.asText(): String = when (this) { + is JsonNull -> "" + is JsonPrimitive -> contentOrNull ?: "" + else -> toString() +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/MobileSession.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/MobileSession.kt new file mode 100644 index 000000000..4837e31bb --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/MobileSession.kt @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.autofill.AutofillIndexHub +import nl.conduction.keepiq.shared.api.InsecureServerException +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.platformHttpEngine +import nl.conduction.keepiq.shared.send.SendService +import nl.conduction.keepiq.shared.store.VaultStore +import nl.conduction.keepiq.shared.sync.VaultSync +import nl.conduction.keepiq.shared.unlock.UnlockedVault +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * One unlocked account as the vault, Send and generator screens use it, + * built in common code so the iOS app gets it without Ktor or clock types. + */ +class MobileSession( + val account: Account, + val api: KeepiqApi, + val keys: VaultKeys, + store: VaultStore?, + sync: VaultSync?, +) { + val repository = VaultRepository( + api, keys, store, sync, + clock = ::nowMillis, + onRefreshed = { AutofillIndexHub.refreshed(account.id, it, keys) }, + ) + val sends = SendService(api) + + /** "alice · cloud.example.nl", for the account switcher. */ + val label: String get() = labelOf(account) + + /** On lock: the private key leaves memory, and nothing decrypts with this session again. */ + fun close() = keys.forget() + + companion object { + /** + * Without an offline store (iOS until task 1.6.1): the vault is read + * from the server. Throws for a server address that is not https, so + * Swift sees a throwing call rather than a crash. + */ + @Throws(InsecureServerException::class) + fun online(account: Account, keys: VaultKeys): MobileSession = + MobileSession(account, KeepiqApi(KeepiqApi.httpClient(platformHttpEngine()), account), keys, null, null) + + /** + * The session of a vault the unlock flow just opened: [online] with + * the keys of [vault], which [UnlockedVault.lock] forgets again. + */ + @Throws(Exception::class) + fun forVault(account: Account, vault: UnlockedVault): MobileSession = online(account, vault.keys()) + + fun labelOf(account: Account): String = + "${account.loginName} · ${account.server.removePrefix("https://").trimEnd('/')}" + + @OptIn(ExperimentalTime::class) + fun nowMillis(): Long = Clock.System.now().toEpochMilliseconds() + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboard.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboard.kt new file mode 100644 index 000000000..6a68ffcf4 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboard.kt @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +/** + * The platform clipboard, as the copy timer needs it. Android marks the clip + * sensitive (ClipDescription.EXTRA_IS_SENSITIVE on API 33+); iOS writes it + * local only with an expiration date, so the system clears it even when the + * app is gone. + */ +interface ClipboardPort { + /** Writes [text] marked as sensitive, so the system hides its preview. */ + fun writeSensitive(text: String, expiresInSeconds: Int) + + /** Clears the clipboard if it still holds what this app wrote, by [token] (a hash, never the value). */ + fun clearIfOurs(token: String) +} + +/** + * Schedules one delayed action and cancels it; the platform's main-thread + * timer. Interfaces rather than function types, so Swift implements them + * without Kotlin's boxed function signatures. + */ +interface ClearScheduler { + fun schedule(delayMillis: Long, action: ScheduledAction): PendingClear +} + +fun interface ScheduledAction { + fun run() +} + +fun interface PendingClear { + fun cancel() +} + +/** The user's clipboard delay in seconds, read at each copy. */ +fun interface ClearDelay { + fun seconds(): Int +} + +/** + * Copy with a timeout (mobile-vault "View and copy an item"). Every copy is + * marked sensitive, and cleared after the user's delay: the extension's + * choices (browser-extension/src/background/clipboard-clear.js), with the + * mobile spec's default of 60 seconds. A later copy restarts the timer, so + * only the newest value is on the clipboard and it gets its full delay. + * 0 means never cleared by the app. + */ +class SensitiveClipboard( + private val port: ClipboardPort, + private val scheduler: ClearScheduler, + private val clearSeconds: ClearDelay, +) { + private var pending: PendingClear? = null + + /** + * Copies [text] and returns the seconds until it is cleared (0: not + * cleared). Not named copy: Objective-C reads copy… as a method family. + */ + fun write(text: String): Int { + val seconds = clearSeconds.seconds().takeIf { it in CLEAR_CHOICES } ?: DEFAULT_CLEAR_SECONDS + pending?.cancel() + pending = null + port.writeSensitive(text, seconds) + if (seconds > 0) { + val token = tokenOf(text) + pending = scheduler.schedule( + seconds * 1000L, + ScheduledAction { + pending = null + port.clearIfOurs(token) + }, + ) + } + return seconds + } + + /** Clears now, for a lock or an unpair. */ + fun clearNow(text: String?) { + pending?.cancel() + pending = null + if (text != null) port.clearIfOurs(tokenOf(text)) + } + + companion object { + /** CLEAR_CHOICES of the extension, in seconds. */ + val CLEAR_CHOICES = listOf(0, 10, 20, 30, 60, 120, 300) + + /** The mobile spec's default clipboard timeout. */ + const val DEFAULT_CLEAR_SECONDS = 60 + + /** A token for "is this still our copy": a SHA-256 of the text, never the text itself. */ + fun tokenOf(text: String): String = nl.conduction.keepiq.shared.crypto.Encoding.toBase64( + nl.conduction.keepiq.shared.crypto.Digest.sha256(nl.conduction.keepiq.shared.crypto.Encoding.utf8(text)), + ) + } +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultIndex.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultIndex.kt new file mode 100644 index 000000000..daa6c5a99 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultIndex.kt @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import nl.conduction.keepiq.shared.store.StoredFolder +import nl.conduction.keepiq.shared.store.StoredSecret + +/** + * One item as the server or the store gives it: plaintext metadata and the + * ciphertext untouched. Nothing here is decrypted. + */ +data class VaultRow( + val id: String, + val name: String, + val url: String?, + val typeId: String?, + val folderId: String?, + val key: String?, + val login: String?, + val additionalFields: String?, + val updatedAt: String?, + val useOnly: Boolean, + val readOnly: Boolean, + val blocked: Boolean, + val blockedReason: String?, + val trashed: Boolean = false, +) { + override fun toString(): String = "VaultRow(id=$id, typeId=$typeId, folderId=$folderId)" + + companion object { + fun from(stored: StoredSecret): VaultRow = VaultRow( + id = stored.id, + name = stored.name, + url = stored.url, + typeId = stored.typeId, + folderId = stored.folderId, + key = stored.key, + login = stored.login, + additionalFields = stored.additionalFields, + updatedAt = stored.updatedAt, + useOnly = stored.useOnly, + readOnly = stored.readOnly, + blocked = stored.blocked, + blockedReason = stored.blockedReason, + ) + + /** From a server row (Secret::jsonSerialize). Returns null without an id. */ + fun from(obj: JsonObject): VaultRow? { + val id = obj.text("id") ?: return null + return VaultRow( + id = id, + name = obj.text("name") ?: "", + url = obj.text("url"), + typeId = obj.text("typeId"), + folderId = obj.text("folderId"), + key = obj.text("key"), + login = obj.text("login"), + additionalFields = obj.text("additionalFields"), + updatedAt = obj.text("updatedAt"), + useOnly = obj.flag("useOnly"), + readOnly = obj.flag("readOnly"), + blocked = obj.flag("blocked"), + blockedReason = obj.text("blockedReason"), + trashed = obj.text("trashedAt") != null || obj.text("archivedAt") != null, + ) + } + } +} + +/** A secret type from `/api/v1/secret-types`: its name and the typed fields it adds. */ +data class SecretType(val id: String, val name: String, val label: String?, val fields: List) { + companion object { + fun from(obj: JsonObject): SecretType? { + val id = obj.text("id") ?: return null + val fields = (obj["fields"] as? JsonArray)?.mapNotNull { f -> + val o = f as? JsonObject ?: return@mapNotNull null + val label = o.text("label")?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + TypeField( + key = o.text("key") ?: label, + label = label, + kind = o.text("kind")?.takeIf { it in TypeField.KINDS } ?: "text", + required = o.flag("required"), + ) + } ?: emptyList() + return SecretType(id, obj.text("name") ?: obj.text("slug") ?: "login", obj.text("label"), fields) + } + } +} + +/** + * One typed field of a secret type (src/utils/typedFields.js). Its value + * lives in the encrypted additional fields under the field's [label]. + */ +data class TypeField(val key: String, val label: String, val kind: String, val required: Boolean) { + val hidden: Boolean get() = kind == "hidden" + + companion object { + /** FIELD_KINDS in src/utils/typedFields.js. */ + val KINDS = setOf("text", "hidden", "url", "email") + } +} + +data class VaultFolder(val id: String, val name: String, val parentId: String?) { + companion object { + fun from(stored: StoredFolder) = VaultFolder(stored.id, stored.name, stored.parentId) + + fun from(obj: JsonObject): VaultFolder? = + obj.text("id")?.let { VaultFolder(it, obj.text("name") ?: "", obj.text("parentId")) } + } +} + +/** One list entry: what the vault list shows, never a decrypted value. */ +data class IndexEntry( + val id: String, + val name: String, + val url: String, + val typeName: String, + val folderId: String?, + val folderName: String, + val blocked: Boolean, + val useOnly: Boolean, + val readOnly: Boolean, +) + +/** A folder in tree order, with its depth (browser-extension/src/lib/folder-rules.js folderTree). */ +data class FolderNode(val id: String, val name: String, val parentId: String?, val depth: Int) + +/** What the vault list says (browser-extension/src/lib/vault-index.js listState). */ +enum class ListState { LOADING, EMPTY, NO_MATCH, ALL_BLOCKED, ITEMS } + +/** + * The vault index the list browses (browser-extension/src/lib/vault-index.js): + * names, addresses, types and folders. Search runs on the device over names + * and addresses only; user names are encrypted, and the list decrypts + * nothing (design, "RSA-4096 on a phone"). + */ +object VaultIndex { + /** The folder filter value for items in no folder (NO_FOLDER). */ + const val NO_FOLDER = "__none__" + + /** buildIndex: trashed and archived rows left out, sorted by name. */ + fun build(rows: List, types: List, folders: List): List { + val typeNames = types.associate { it.id to it.name } + val folderNames = folders.associate { it.id to it.name } + return rows.filter { !it.trashed }.map { r -> + IndexEntry( + id = r.id, + name = r.name, + url = r.url ?: "", + typeName = r.typeId?.let { typeNames[it] } ?: "login", + folderId = r.folderId, + folderName = r.folderId?.let { folderNames[it] } ?: "", + blocked = r.blocked, + useOnly = r.useOnly, + readOnly = r.readOnly, + ) + }.sortedWith(byName) + } + + /** filterIndex: the query matches name and address, case-insensitive; [folderId] narrows to one folder. */ + fun filter(entries: List, query: String = "", folderId: String? = null, typeName: String? = null): List { + val needle = query.trim().lowercase() + return entries.filter { e -> + (folderId.isNullOrEmpty() || (if (folderId == NO_FOLDER) e.folderId == null else e.folderId == folderId)) && + (typeName.isNullOrEmpty() || e.typeName == typeName) && + (needle.isEmpty() || e.name.lowercase().contains(needle) || e.url.lowercase().contains(needle)) + } + } + + /** listState. [index] is null while loading. */ + fun listState(index: List?, shown: List): ListState = when { + index == null -> ListState.LOADING + index.isEmpty() -> ListState.EMPTY + shown.isEmpty() -> ListState.NO_MATCH + index.all { it.blocked } -> ListState.ALL_BLOCKED + else -> ListState.ITEMS + } + + /** folderTree: depth first, siblings sorted by name, a folder whose parent is missing at the top. */ + fun folderTree(folders: List): List { + val ids = folders.map { it.id }.toSet() + val children = folders.groupBy { f -> f.parentId?.takeIf { it in ids } } + val out = ArrayList() + val seen = HashSet() + fun walk(parent: String?, depth: Int) { + for (f in (children[parent] ?: emptyList()).sortedWith(compareBy(Collation.comparator) { it.name })) { + if (!seen.add(f.id)) continue + out += FolderNode(f.id, f.name, f.parentId, depth) + walk(f.id, depth + 1) + } + } + walk(null, 0) + return out + } + + /** The direct subfolders of [parentId] (null: the top level), sorted by name. */ + fun subfolders(folders: List, parentId: String?): List { + val ids = folders.map { it.id }.toSet() + return folders.filter { f -> f.parentId?.takeIf { it in ids } == parentId } + .sortedWith(compareBy(Collation.comparator) { it.name }) + } + + /** folderPath: "Work / Clients", or null for no folder. */ + fun folderPath(folders: List, folderId: String?): String? { + val byId = folders.associateBy { it.id } + val names = ArrayList() + val seen = HashSet() + var current = folderId?.let { byId[it] } + while (current != null && seen.add(current.id)) { + names.add(0, current.name) + current = current.parentId?.let { byId[it] } + } + return if (names.isEmpty()) null else names.joinToString(" / ") + } + + /** byName: case- and accent-insensitive, then a fixed order by id so the list does not shuffle. */ + val byName: Comparator = Comparator { a, b -> + val c = Collation.comparator.compare(a.name, b.name) + if (c != 0) c else a.id.compareTo(b.id) + } +} + +/** + * A stand-in for `localeCompare(…, { sensitivity: 'base' })`: letters are + * compared without case and without the accents of the Latin alphabets the + * vault's users write in. Common code has no ICU collator. + */ +object Collation { + private val folds: Map = buildMap { + fun add(base: String, accented: String) = accented.forEach { put(it, base) } + add("a", "àáâãäåāăąǎ"); add("c", "çćĉċč"); add("d", "ďđ"); add("e", "èéêëēĕėęě") + add("g", "ĝğġģ"); add("h", "ĥħ"); add("i", "ìíîïĩīĭįı"); add("j", "ĵ"); add("k", "ķ") + add("l", "ĺļľŀł"); add("n", "ñńņňʼn"); add("o", "òóôõöøōŏőǒ"); add("r", "ŕŗř") + add("s", "śŝşšș"); add("t", "ţťŧț"); add("u", "ùúûüũūŭůűųǔ"); add("w", "ŵ") + add("y", "ýÿŷ"); add("z", "źżž"); put('ß', "ss"); put('æ', "ae"); put('œ', "oe"); put('ij', "ij") + } + + fun fold(text: String): String { + val sb = StringBuilder(text.length) + for (c in text.lowercase()) sb.append(folds[c] ?: c.toString()) + return sb.toString() + } + + val comparator: Comparator = Comparator { a, b -> fold(a).compareTo(fold(b)) } +} + +internal fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.contentOrNull + +internal fun JsonObject.flag(name: String): Boolean = (this[name] as? JsonPrimitive)?.contentOrNull == "true" diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultKeys.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultKeys.kt new file mode 100644 index 000000000..376e2c6d3 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultKeys.kt @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import nl.conduction.keepiq.shared.crypto.RsaFields +import nl.conduction.keepiq.shared.crypto.RsaPrivateKey +import nl.conduction.keepiq.shared.crypto.RsaPublicKey +import nl.conduction.keepiq.shared.sync.UnlockedSession + +/** + * The key material of an unlocked vault, as the vault, Send and generator + * screens need it. The unlock flow (task group 2) produces it; these screens + * only consume it, so they never see the master password or the envelope. + * + * Fields are encrypted to the active suite's public key, the key the vault + * was unlocked with, as the web app (src/store/modules/secret.js, the + * session certificate) and the extension (browser-extension/src/lib/vault.js + * encryptField) do. There is no per-item key (design D2). + */ +interface VaultKeys { + /** The active suite the vault was unlocked with. */ + val suiteId: String + + /** The suite's `unlockKeyEpoch` at unlock, or null when the server sends none. */ + val unlockKeyEpoch: Long? + + /** Decrypts one field. An absent or empty ciphertext reads as "", as in vault.js decryptField. */ + fun decryptField(ciphertext: String?): String + + /** Encrypts one field to the suite's public key (src/crypto/rsa.js rsaEncrypt). */ + fun encryptField(plaintext: String): String + + /** Drops the private key from memory, on lock. Every later call throws [VaultLockedException]. */ + fun forget() {} +} + +/** The vault was locked: its key is gone from memory. */ +class VaultLockedException : IllegalStateException("The vault is locked.") + +/** [VaultKeys] over the decrypted private key and the suite's certificate, held in memory only. */ +class RsaVaultKeys( + private val privateKey: RsaPrivateKey, + private val publicKey: RsaPublicKey, + override val suiteId: String, + override val unlockKeyEpoch: Long?, +) : VaultKeys { + override fun decryptField(ciphertext: String?): String { + if (privateKey.forgotten) throw VaultLockedException() + return if (ciphertext.isNullOrEmpty()) "" else RsaFields.decrypt(ciphertext, privateKey) + } + + override fun encryptField(plaintext: String): String { + if (privateKey.forgotten) throw VaultLockedException() + return RsaFields.encrypt(plaintext, publicKey) + } + + override fun forget() = privateKey.forget() + + companion object { + /** + * From the opened envelope (PKCS#8 PEM) and the suite's certificate or + * SPKI PEM, as browser-extension/src/lib/vault.js hold() keeps them. + */ + fun fromPem(privateKeyPem: String, certificatePem: String, suiteId: String, unlockKeyEpoch: Long?): RsaVaultKeys = + RsaVaultKeys(RsaPrivateKey.fromPem(privateKeyPem), RsaPublicKey.fromPem(certificatePem), suiteId, unlockKeyEpoch) + } +} + +/** The session [nl.conduction.keepiq.shared.sync.VaultSync] compares a manifest with. */ +fun VaultKeys.syncSession(): UnlockedSession = UnlockedSession(suiteId, unlockKeyEpoch) diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultRepository.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultRepository.kt new file mode 100644 index 000000000..06e4c0260 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/VaultRepository.kt @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.KeepiqApiException +import nl.conduction.keepiq.shared.store.VaultStore +import nl.conduction.keepiq.shared.sync.LockReason +import nl.conduction.keepiq.shared.sync.SyncOutcome +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.sync.VaultSync + +/** What the vault screens show: rows without values, and where they came from. */ +data class VaultState( + val rows: List, + val folders: List, + val types: List, + /** When the stored copy was last synced, or null for none. */ + val syncedAtMillis: Long?, + /** The server could not be reached: reading from the store, edits refused. */ + val offline: Boolean, + /** The organisation turned offline caching off: nothing is kept on the device. */ + val onlineOnly: Boolean, + /** Offline with no stored copy, because caching is off or there is no store: the vault needs a connection. */ + val needsConnection: Boolean = false, + /** The sync locked the vault; the app asks for the master password again. */ + val locked: LockReason? = null, + /** A read the server refused, for the list to show. */ + val problem: WriteProblem? = null, +) { + val index: List get() = VaultIndex.build(rows, types, folders) + + fun type(typeId: String?): SecretType? = types.firstOrNull { it.id == typeId } + + fun typeNamed(name: String): SecretType? = types.firstOrNull { it.name == name } + + companion object { + val EMPTY = VaultState(emptyList(), emptyList(), emptyList(), null, offline = false, onlineOnly = false) + } +} + +/** An opened item, or why it could not be opened. */ +sealed class OpenResult { + data class Opened(val item: DecryptedItem) : OpenResult() + + data class Failed(val problem: WriteProblem) : OpenResult() + + /** The item is gone from the server and from the store. */ + data object Missing : OpenResult() +} + +/** What deleting a folder needs (browser-extension/src/lib/folder-rules.js deleteKind). */ +enum class FolderDeleteKind { EMPTY, ITEMS, SUBFOLDERS } + +/** + * The vault of one unlocked account: reading through the offline store and + * sync (tasks 1.6 and 1.7), opening items fresh from the server, and every + * write the vault screens make, through the same endpoints as the web app. + * + * [store] and [sync] are null where there is no offline store (iOS until + * task 1.6.1): the vault is then read from the server only, and needs a + * connection. While [VaultState.offline] is set every write is refused + * before it is sent, with the message that edits need a connection. + */ +class VaultRepository( + private val api: KeepiqApi, + private val keys: VaultKeys, + private val store: VaultStore?, + private val sync: VaultSync?, + private val clock: () -> Long, + /** + * Sees every state [refresh] returns. The sessions pass + * [nl.conduction.keepiq.shared.autofill.AutofillIndexHub.refreshed], so + * the autofill index follows each sync (task 4.6). + */ + private val onRefreshed: (VaultState) -> Unit = {}, +) { + /** The last state [refresh] produced. */ + var state: VaultState = VaultState.EMPTY + private set + + /** Syncs for [trigger] and returns what the list shows. */ + suspend fun refresh(trigger: SyncTrigger): VaultState { + state = try { + if (sync != null && store != null) syncThroughStore(trigger) else readOnline() + } catch (e: CancellationException) { + throw e + } catch (e: KeepiqApiException) { + if (e.status == 0) offlineState() else fromStore(offline = false).copy(problem = WriteProblem.from(e)) + } catch (e: Exception) { + offlineState() + } + try { + onRefreshed(state) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The index is rebuilt at the next sync; the list still shows the vault. + } + return state + } + + private suspend fun syncThroughStore(trigger: SyncTrigger): VaultState = + when (val outcome = sync!!.sync(trigger, keys.syncSession())) { + is SyncOutcome.Fresh, is SyncOutcome.Synced -> fromStore(offline = false) + is SyncOutcome.OnlineOnly -> fromJson(outcome.secrets, outcome.folders, outcome.types) + is SyncOutcome.Locked -> VaultState.EMPTY.copy(locked = outcome.reason) + } + + /** Without a store: the manifest, or the lists when offline caching is off, held in memory only. */ + private suspend fun readOnline(): VaultState { + val manifest = try { + api.offlineManifest() + } catch (e: KeepiqApiException) { + if (e.status != 403 && e.status != 428 && e.status != 404) throw e + null + } + if (manifest == null) { + val suite = api.activeSuite() + lockReason(suite?.id, suite?.unlockKeyEpoch, suite?.unlockBlocked)?.let { return VaultState.EMPTY.copy(locked = it) } + return fromJson(api.listSecrets(), api.listFolders(), api.listTypes()) + } + lockReason(manifest.suite?.id, manifest.suite?.unlockKeyEpoch, manifest.unlockBlocked)?.let { + return VaultState.EMPTY.copy(locked = it) + } + return fromJson(manifest.secrets, manifest.folders, manifest.types).copy(onlineOnly = false, syncedAtMillis = clock()) + } + + /** VaultSync.checkSuite, for the path without a store. */ + private fun lockReason(suiteId: String?, epoch: Long?, unlockBlocked: String?): LockReason? = when { + unlockBlocked != null -> LockReason.UNLOCK_BLOCKED + suiteId != null && suiteId != keys.suiteId -> LockReason.SUITE_CHANGED + epoch != null && keys.unlockKeyEpoch != null && epoch != keys.unlockKeyEpoch -> LockReason.MASTER_PASSWORD_CHANGED + else -> null + } + + private fun fromStore(offline: Boolean): VaultState { + val s = store ?: return VaultState.EMPTY.copy(offline = offline, needsConnection = offline) + val synced = s.state() + if (synced == null && offline) { + // Nothing stored: caching is off for the organisation, or there was never a sync. + return VaultState.EMPTY.copy(offline = true, onlineOnly = state.onlineOnly, needsConnection = true) + } + return VaultState( + rows = s.secrets().map { VaultRow.from(it) }, + folders = s.folders().map { VaultFolder.from(it) }, + types = s.types().mapNotNull { runCatching { SecretType.from(kotlinx.serialization.json.Json.parseToJsonElement(it) as JsonObject) }.getOrNull() }, + syncedAtMillis = synced?.syncedAtMillis, + offline = offline, + onlineOnly = false, + ) + } + + private fun fromJson(secrets: List, folders: List, types: List) = VaultState( + rows = secrets.mapNotNull { VaultRow.from(it) }, + folders = folders.mapNotNull { VaultFolder.from(it) }, + types = types.mapNotNull { SecretType.from(it) }, + syncedAtMillis = null, + offline = false, + onlineOnly = true, + ) + + private fun offlineState(): VaultState = if (state.onlineOnly || store == null) { + VaultState.EMPTY.copy(offline = true, onlineOnly = state.onlineOnly, needsConnection = true) + } else { + fromStore(offline = true) + } + + /** + * Opens one item, fetched fresh so a stale row never seeds an edit + * (the extension's vault-item). Offline, the stored row is read instead. + */ + suspend fun open(id: String): OpenResult { + val types = state.types + val (row, fromCache) = try { + if (state.offline) throw OfflineSignal() + val fresh = api.getSecret(id)?.let { VaultRow.from(it) } + (fresh ?: return OpenResult.Missing) to false + } catch (e: CancellationException) { + throw e + } catch (e: KeepiqApiException) { + if (e.status == 404) return OpenResult.Missing + if (e.status != 0 && e.status < 500) return OpenResult.Failed(WriteProblem.from(e)) + (cachedRow(id) ?: return OpenResult.Failed(WriteProblem.from(e))) to true + } catch (e: Exception) { + (cachedRow(id) ?: return OpenResult.Failed(WriteProblem(WriteProblemKind.UNREACHABLE))) to true + } + return try { + OpenResult.Opened(ItemCodec.open(row, types.firstOrNull { it.id == row.typeId }, keys, fromCache)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + OpenResult.Failed(WriteProblem(WriteProblemKind.FAILED, e.message)) + } + } + + private fun cachedRow(id: String): VaultRow? = + store?.secret(id)?.let { VaultRow.from(it) } ?: state.rows.firstOrNull { it.id == id } + + /** Creates an item of [type] from [draft]. */ + suspend fun create(draft: ItemDraft, type: SecretType?): WriteResult = write { + if (draft.kind == FormKind.PASSKEY) return@write WriteResult.Refused(WriteProblem(WriteProblemKind.REFUSED)) + val body = ItemCodec.createBody(ItemCodec.parts(draft, type), type?.id ?: draft.typeId, keys) + val saved = api.createSecret(body) + WriteResult.Saved(saved?.let { it["id"] as? JsonPrimitive }?.contentOrNull) + } + + /** + * Updates [item] with only the parts that changed. A passkey's key is + * never rewritten: only its name, address, folder and notes change. + */ + suspend fun update(item: DecryptedItem, draft: ItemDraft): WriteResult = write { + if (item.row.useOnly || item.row.blocked) return@write WriteResult.Refused(WriteProblem(WriteProblemKind.REFUSED)) + val before = ItemCodec.parts(ItemCodec.draft(item, item.type), item.type) + val after = ItemCodec.parts(draft, item.type) + val changed = ItemCodec.changed(before, after).let { if (item.kind == FormKind.PASSKEY) it - "key" - "login" else it } + if (changed.isEmpty()) return@write WriteResult.Saved(item.row.id) + api.updateSecret(item.row.id, ItemCodec.updateBody(after, changed, keys)) + WriteResult.Saved(item.row.id) + } + + /** Moves an item to another folder: only its folder changes (the extension's vault-move). */ + suspend fun move(id: String, folderId: String?): WriteResult = write { + api.updateSecret(id, JsonObject(mapOf("folderId" to (folderId?.let { JsonPrimitive(it) } ?: kotlinx.serialization.json.JsonNull)))) + WriteResult.Saved(id) + } + + /** Moves an item to the trash; the web app can restore it. */ + suspend fun trash(id: String): WriteResult = write { + api.trashSecret(id) + WriteResult.Saved(id) + } + + suspend fun createFolder(name: String, parentId: String?): WriteResult = write { + val folder = api.createFolder(name.trim(), parentId) + WriteResult.Saved(folder?.let { it["id"] as? JsonPrimitive }?.contentOrNull) + } + + suspend fun renameFolder(id: String, name: String): WriteResult = write { + api.renameFolder(id, name.trim()) + WriteResult.Saved(id) + } + + /** What a folder holds, to choose how to delete it. */ + suspend fun folderDeleteKind(id: String): FolderDeleteKind? = try { + val children = api.folderChildren(id) + val subfolders = (children?.get("subfolders") as? kotlinx.serialization.json.JsonArray)?.size ?: 0 + val items = (children?.get("directSecretCount") as? JsonPrimitive)?.longOrNull ?: 0L + when { + subfolders > 0 -> FolderDeleteKind.SUBFOLDERS + items > 0 -> FolderDeleteKind.ITEMS + else -> FolderDeleteKind.EMPTY + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + + /** + * Deletes a folder. An empty folder goes plainly; one that holds items + * moves them out first (`cascade=move`) or deletes them with it. A folder + * with subfolders is deleted in the web app, which plans each subfolder. + */ + suspend fun deleteFolder(id: String, kind: FolderDeleteKind, deleteItems: Boolean): WriteResult = write { + when (kind) { + FolderDeleteKind.EMPTY -> api.deleteFolder(id) + FolderDeleteKind.ITEMS -> api.deleteFolder(id, if (deleteItems) "delete" else "move") + FolderDeleteKind.SUBFOLDERS -> return@write WriteResult.Refused(WriteProblem(WriteProblemKind.REFUSED)) + } + WriteResult.Saved(id) + } + + /** Records a fill of a use-only copy for its owner (sharing-use-only-and-expiring-shares 3.3). */ + suspend fun reportUseOnlyFill(id: String): Boolean = try { + api.reportUseOnlyFill(id) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + false + } + + /** + * Runs one write. Offline it is refused before anything is sent. After + * a write that went through, the vault syncs (the after-write trigger). + */ + private suspend fun write(block: suspend () -> WriteResult): WriteResult { + if (state.offline) return WriteResult.Refused(WriteProblem(WriteProblemKind.OFFLINE)) + val result = try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val problem = WriteProblem.from(e) + WriteResult.Refused(if (problem.kind == WriteProblemKind.UNREACHABLE) WriteProblem(WriteProblemKind.OFFLINE) else problem) + } + if (result is WriteResult.Saved) refresh(SyncTrigger.AFTER_WRITE) + return result + } + + private class OfflineSignal : Exception() +} diff --git a/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/WriteProblem.kt b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/WriteProblem.kt new file mode 100644 index 000000000..a05536c52 --- /dev/null +++ b/mobile/shared/src/commonMain/kotlin/nl/conduction/keepiq/shared/vault/WriteProblem.kt @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import nl.conduction.keepiq.shared.api.KeepiqApiException + +/** Why a write did not go through. The screens turn the kind into text in the user's language. */ +enum class WriteProblemKind { + /** The device is offline: edits need a connection (design D5, no offline edits in v1). */ + OFFLINE, + + /** 423: a key migration holds the vault. */ + KEY_MIGRATION, + + /** 403 or 428 without a reason: the encryption suite is blocked. */ + SUITE_BLOCKED, + + /** The server refused and said why; [WriteProblem.serverMessage] holds its words. */ + SERVER_MESSAGE, + + /** The server refused without saying why. */ + REFUSED, + + /** The server could not be reached. */ + UNREACHABLE, + + /** Something else failed. */ + FAILED, +} + +/** A refused or failed write. The old value stays; nothing is shown as saved. */ +data class WriteProblem(val kind: WriteProblemKind, val serverMessage: String? = null, val status: Int = 0) { + companion object { + /** + * writeErrorMessage (browser-extension/src/lib/item-form.js): Keepiq's + * OCS routes refuse with 428 and a code (keepiq#673), a plain + * controller with 403; 400 and 409 carry their own message. + */ + fun from(error: Throwable): WriteProblem { + if (error is CancellationException) throw error + if (error !is KeepiqApiException) return WriteProblem(WriteProblemKind.UNREACHABLE) + val status = error.status + val outer = parseBody(error.body) + // An OCS envelope carries the refusal in ocs.data (statuscode >= 400 under HTTP 200). + val ocs = outer["ocs"] as? JsonObject + val body = (ocs?.get("data") as? JsonObject) ?: outer + val message = body.text("message") ?: (ocs?.get("meta") as? JsonObject)?.text("message")?.takeIf { it.isNotBlank() } + return when { + status == 0 -> WriteProblem(WriteProblemKind.UNREACHABLE) + status == 423 -> WriteProblem(WriteProblemKind.KEY_MIGRATION, status = status) + status == 403 || status == 428 -> { + val code = body.text("code") ?: error.code + val err = body.text("error") + if (code != null || (err != null && err != "forbidden")) { + if (message != null) WriteProblem(WriteProblemKind.SERVER_MESSAGE, message, status) else WriteProblem(WriteProblemKind.REFUSED, status = status) + } else { + WriteProblem(WriteProblemKind.SUITE_BLOCKED, status = status) + } + } + status == 400 || status == 409 -> + if (message != null) WriteProblem(WriteProblemKind.SERVER_MESSAGE, message, status) else WriteProblem(WriteProblemKind.REFUSED, status = status) + status in 400..499 -> + if (message != null) WriteProblem(WriteProblemKind.SERVER_MESSAGE, message, status) else WriteProblem(WriteProblemKind.REFUSED, status = status) + else -> WriteProblem(WriteProblemKind.FAILED, message, status) + } + } + + private fun parseBody(text: String): JsonObject = + runCatching { Json.parseToJsonElement(text.ifBlank { "{}" }) as? JsonObject }.getOrNull() ?: JsonObject(emptyMap()) + } +} + +/** The result of a write. */ +sealed class WriteResult { + data class Saved(val id: String?) : WriteResult() + + data class Refused(val problem: WriteProblem) : WriteResult() +} diff --git a/mobile/shared/src/commonMain/sqldelight/nl/conduction/keepiq/shared/store/db/Vault.sq b/mobile/shared/src/commonMain/sqldelight/nl/conduction/keepiq/shared/store/db/Vault.sq new file mode 100644 index 000000000..b0b377b1c --- /dev/null +++ b/mobile/shared/src/commonMain/sqldelight/nl/conduction/keepiq/shared/store/db/Vault.sq @@ -0,0 +1,89 @@ +-- SPDX-FileCopyrightText: 2026 Conduction B.V. +-- SPDX-License-Identifier: EUPL-1.2 +-- +-- The offline copy of one account's vault (design D5). The database file is +-- SQLCipher-encrypted on the device. On top of that, item ciphertext stays +-- as the server sent it (RSA chunks), and every name, URL and folder name is +-- sealed again under the unlock key (columns ending in _sealed). + +CREATE TABLE secret ( + id TEXT NOT NULL PRIMARY KEY, + name_sealed TEXT NOT NULL, + url_sealed TEXT, + type_id TEXT, + folder_id TEXT, + key_ciphertext TEXT, + login_ciphertext TEXT, + additional_fields_ciphertext TEXT, + encryption_suite_id TEXT, + updated_at TEXT, + position INTEGER NOT NULL, + -- What the client may do with the item (Secret::jsonSerialize). Not + -- secret: they decide which actions the screens offer. + use_only INTEGER NOT NULL DEFAULT 0, + read_only INTEGER NOT NULL DEFAULT 0, + blocked INTEGER NOT NULL DEFAULT 0, + blocked_reason TEXT +); + +CREATE TABLE folder ( + id TEXT NOT NULL PRIMARY KEY, + name_sealed TEXT NOT NULL, + parent_id TEXT +); + +CREATE TABLE secret_type ( + id TEXT NOT NULL PRIMARY KEY, + json TEXT NOT NULL +); + +CREATE TABLE sync_state ( + singleton INTEGER NOT NULL PRIMARY KEY CHECK (singleton = 1), + suite_id TEXT, + unlock_key_epoch INTEGER, + synced_at_millis INTEGER NOT NULL, + check_top TEXT, + check_total INTEGER NOT NULL +); + +insertSecret: +INSERT INTO secret VALUES ?; + +insertFolder: +INSERT INTO folder VALUES ?; + +insertType: +INSERT INTO secret_type VALUES ?; + +putState: +INSERT OR REPLACE INTO sync_state VALUES (1, ?, ?, ?, ?, ?); + +touchState: +UPDATE sync_state SET synced_at_millis = ? WHERE singleton = 1; + +state: +SELECT * FROM sync_state WHERE singleton = 1; + +secrets: +SELECT * FROM secret ORDER BY position; + +secretById: +SELECT * FROM secret WHERE id = ?; + +folders: +SELECT * FROM folder; + +types: +SELECT * FROM secret_type; + +deleteSecrets: +DELETE FROM secret; + +deleteFolders: +DELETE FROM folder; + +deleteTypes: +DELETE FROM secret_type; + +deleteState: +DELETE FROM sync_state; diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/KeepiqClientTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/KeepiqClientTest.kt new file mode 100644 index 000000000..ba2d4c1c3 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/KeepiqClientTest.kt @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.io.IOException +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import nl.conduction.keepiq.shared.account.InMemorySecureStorage +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.argon2idAvailable +import nl.conduction.keepiq.shared.unlock.StaleUnlockKeyException +import nl.conduction.keepiq.shared.unlock.UnlockBlockedException +import nl.conduction.keepiq.shared.unlock.WrongMasterPasswordException +import nl.conduction.keepiq.shared.unlock.WrongPinException +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.str +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tasks 2.1, 2.2, 2.3 (PIN), 2.6: pairing, unlock and unpair against a fake + * server that answers as ExtensionController, Nextcloud's Login Flow v2 and + * its app-password endpoint do. + */ +class KeepiqClientTest { + private val envelope = Vectors.envelope + private val seen = mutableListOf() + private val storage = InMemorySecureStorage() + + private var apiVersion = 1 + private var acceptedPassword = "app-password-1" + private var unlockBlocked: String? = null + private var revoked = mutableListOf() + private var offline = false + + private fun suiteJson() = buildJsonArray { + add( + buildJsonObject { + put("id", JsonPrimitive("suite-1")) + put("status", JsonPrimitive("active")) + put("unlockKeyEpoch", JsonPrimitive(3)) + put("certificate", JsonPrimitive(envelope.str("certificatePem"))) + if (unlockBlocked == null) put("privateKey", JsonPrimitive(envelope.str("envelope"))) + unlockBlocked?.let { put("unlockBlocked", JsonPrimitive(it)) } + }, + ) + }.toString() + + private val engine = MockEngine { request -> + seen += request + if (offline) throw IOException("no network") + val path = request.url.encodedPath + val auth = request.headers[HttpHeaders.Authorization] + val password = auth?.removePrefix("Basic ")?.let { Encoding.fromUtf8(Encoding.fromBase64(it)).substringAfter(':') } + val json = headersOf(HttpHeaders.ContentType, "application/json") + when { + path.endsWith("/index.php/login/v2") -> respond( + """{"poll":{"token":"t","endpoint":"https://cloud.example.com/index.php/login/v2/poll"},"login":"https://cloud.example.com/index.php/login/v2/flow/x"}""", + HttpStatusCode.OK, + json, + ) + path.endsWith("/login/v2/poll") -> respond( + """{"server":"https://cloud.example.com","loginName":"alice","appPassword":"app-password-1"}""", + HttpStatusCode.OK, + json, + ) + path.endsWith("/ocs/v2.php/core/apppassword") && request.method == HttpMethod.Delete -> { + revoked += password ?: "" + respond("""{"ocs":{"meta":{"status":"ok","statuscode":200},"data":[]}}""", HttpStatusCode.OK, json) + } + password != acceptedPassword -> respond("""{"error":"unauthorized"}""", HttpStatusCode.Unauthorized, json) + path.endsWith("/api/v1/extension/pair") -> respond( + """{"ok":true,"user":"alice","apiVersion":$apiVersion,"serverVersion":"2.4.0","capabilities":["match"]}""", + HttpStatusCode.OK, + json, + ) + path.endsWith("/api/v1/extension/unpair") -> respond("""{"ok":true}""", HttpStatusCode.OK, json) + path.endsWith("/api/v1/extension/policy") -> respond("""{"maxIdleMinutes":5}""", HttpStatusCode.OK, json) + path.endsWith("/api/v1/suites") -> respond(suiteJson(), HttpStatusCode.OK, json) + else -> respond("", HttpStatusCode.NotFound) + } + } + + private val client = KeepiqClient(storage, "Keepiq for Android", engine, clock = { 0L }) + + @Test + fun manualPairingStoresTheAccount() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + assertEquals("https://cloud.example.com", account.server) + assertEquals(listOf(account.id), client.accounts.accounts().map { it.id }) + assertEquals(account.id, client.accounts.activeId()) + assertEquals("2.4.0", client.accounts.serverVersion(account.id)) + } + + @Test + fun anUnsupportedApiVersionStoresNothing() = runTest { + apiVersion = 2 + val e = assertFailsWith { client.pairManually("cloud.example.com", "alice", "app-password-1") } + assertTrue(e.message!!.contains("API version 2")) + assertTrue(client.accounts.accounts().isEmpty()) + } + + @Test + fun aWrongAppPasswordIsNamedAndStoresNothing() = runTest { + acceptedPassword = "app-password-1" + val e = assertFailsWith { client.pairManually("cloud.example.com", "alice", "wrong") } + assertEquals(401, e.status) + assertEquals(KeepiqClient.pairingProblem(404), "Keepiq is not installed on this server, or the address is wrong.") + assertTrue(client.accounts.accounts().isEmpty()) + } + + @Test + fun loginFlowPairsWithTheGrantedAppPassword() = runTest { + val start = client.startLogin("https://cloud.example.com") + val account = client.finishLogin(start) + assertEquals("alice", account.loginName) + assertEquals("app-password-1", account.appPassword) + assertEquals("Keepiq for Android", seen.first().headers[HttpHeaders.UserAgent]) + } + + @Test + fun aLoginFlowThatCannotPairDeletesItsAppPassword() = runTest { + apiVersion = 9 + val start = client.startLogin("https://cloud.example.com") + assertFailsWith { client.finishLogin(start) } + assertEquals(listOf("app-password-1"), revoked) + assertTrue(client.accounts.accounts().isEmpty()) + } + + @Test + fun aSixthAccountIsRefused() = runTest { + for (i in 1..5) client.accounts.add("https://cloud$i.example.com", "alice", "p$i", null) + val e = assertFailsWith { client.startLogin("https://cloud.example.com") } + assertTrue(e.message!!.contains("up to 5")) + } + + @Test + fun unpairRevokesTheAppPasswordAndWipesTheDevice() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + storage.write("pin:${account.id}", "{}") + val wiped = mutableListOf() + client.onWipe = { wiped += it } + assertTrue(client.unpair(account.id)) + assertEquals(listOf("app-password-1"), revoked) + assertTrue(seen.any { it.url.encodedPath.endsWith("/api/v1/extension/unpair") }) + assertEquals(listOf(account.id), wiped) + assertTrue(client.accounts.accounts().isEmpty()) + assertTrue(storage.keys().none { it.contains(account.id) }, "left behind: ${storage.keys()}") + } + + @Test + fun unpairWipesTheDeviceAlsoOffline() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + offline = true + assertFalse(client.unpair(account.id)) + assertTrue(client.accounts.accounts().isEmpty()) + } + + @Test + fun theMasterPasswordOpensTheVault() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + val gate = assertIs(client.unlockGate(account.id)) + val vault = client.unlockWithMasterPassword(account.id, gate.suite, envelope.str("password")) + assertEquals(envelope.str("privateKeyPem"), vault.privateKeyPem) + assertEquals(envelope.str("unlockKeyHex"), Encoding.hex(vault.unlockKey())) + assertEquals(3L, vault.unlockKeyEpoch) + } + + @Test + fun aWrongMasterPasswordUnlocksNothing() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + val gate = assertIs(client.unlockGate(account.id)) + assertFailsWith { + client.unlockWithMasterPassword(account.id, gate.suite, envelope.str("wrongPassword")) + } + } + + @Test + fun theTwoFactorBlockOffersNoUnlock() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + unlockBlocked = "two_factor_required" + val gate = assertIs(client.unlockGate(account.id)) + assertEquals("two_factor_required", gate.code) + assertTrue(gate.message.contains("two-factor")) + } + + @Test + fun anOfflinePhoneUnlocksWithTheSuiteFromTheLastUnlock() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + client.unlockGate(account.id) + offline = true + val gate = assertIs(client.unlockGate(account.id)) + assertTrue(gate.offline) + assertEquals("suite-1", gate.suite.id) + } + + @Test + fun aBlockedSuiteIsNotKeptForOfflineUse() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + client.unlockGate(account.id) + unlockBlocked = "two_factor_required" + client.unlockGate(account.id) + offline = true + assertFailsWith { client.unlockGate(account.id) } + } + + @Test + fun theDirectGateRefusesABlockedSuiteToo() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + val gate = assertIs(client.unlockGate(account.id)) + assertFailsWith { + client.unlockWithMasterPassword(account.id, gate.suite.copy(unlockBlocked = "two_factor_required"), envelope.str("password")) + } + } + + @Test + fun aStaleUnlockKeyDeletesThePin() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + val gate = assertIs(client.unlockGate(account.id)) + storage.write("pin:${account.id}", "{}") + assertFailsWith { client.unlockWithKey(account.id, gate.suite, ByteArray(32)) } + assertFalse(client.pins.has(account.id)) + } + + @Test + fun thePinOpensTheVaultAndFiveWrongOnesWipeIt() = runTest { + if (!argon2idAvailable) return@runTest + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + val gate = assertIs(client.unlockGate(account.id)) + val vault = client.unlockWithMasterPassword(account.id, gate.suite, envelope.str("password")) + assertFailsWith { client.setPin(vault, "12345") } + client.setPin(vault, "246810") + assertEquals(envelope.str("privateKeyPem"), client.unlockWithPin(account.id, gate.suite, "246810").privateKeyPem) + for (left in 4 downTo 1) { + assertEquals(left, assertFailsWith { client.unlockWithPin(account.id, gate.suite, "000000") }.triesLeft) + } + // The right PIN after four wrong ones still works, and resets the count. + client.unlockWithPin(account.id, gate.suite, "246810") + repeat(4) { assertFailsWith { client.unlockWithPin(account.id, gate.suite, "000000") } } + assertEquals(0, assertFailsWith { client.unlockWithPin(account.id, gate.suite, "000000") }.triesLeft) + assertFalse(client.pins.has(account.id)) + assertEquals(0, assertFailsWith { client.unlockWithPin(account.id, gate.suite, "246810") }.triesLeft) + } + + @Test + fun theOrganisationsIdleMaximumIsRead() = runTest { + val account = client.pairManually("cloud.example.com", "alice", "app-password-1") + assertEquals(5, client.maxIdleMinutes(account.id)) + offline = true + assertNull(client.maxIdleMinutes(account.id)) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/account/IdlePolicyTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/account/IdlePolicyTest.kt new file mode 100644 index 000000000..24e22b9ab --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/account/IdlePolicyTest.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.account + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Task 2.5: the idle choices, the organisation cap and the timer. */ +class IdlePolicyTest { + @Test + fun theOrganisationCapsTheUsersChoice() { + // The spec's scenario: a maximum of 5 and a choice of 60 locks after 5. + assertEquals(5, IdlePolicy.effectiveMinutes(60, 5)) + assertEquals(1, IdlePolicy.effectiveMinutes(1, 5)) + assertEquals(240, IdlePolicy.effectiveMinutes(240, 240)) + } + + @Test + fun anUnreadablePolicyNeverLengthensTheDelay() { + assertEquals(15, IdlePolicy.effectiveMinutes(60, null)) + assertEquals(5, IdlePolicy.effectiveMinutes(5, null)) + } + + @Test + fun anUnknownChoiceFallsBackToTheDefault() { + assertEquals(15, IdlePolicy.effectiveMinutes(7, 60)) + } + + @Test + fun theSettingsOfferOnlyChoicesWithinTheCap() { + assertEquals(listOf(1, 5), IdlePolicy.offeredChoices(5)) + assertEquals(IdlePolicy.CHOICES, IdlePolicy.offeredChoices(null)) + } + + @Test + fun theTimerExpiresAfterTheDelayAndATouchRestartsIt() { + var now = 0L + val timer = IdleTimer({ now }, minutes = 5) + now = 4 * 60_000L + 59_999L + assertFalse(timer.expired()) + timer.touch() + now += 4 * 60_000L + assertFalse(timer.expired()) + assertEquals(60_000L, timer.remainingMillis()) + now += 60_000L + assertTrue(timer.expired()) + assertEquals(0L, timer.remainingMillis()) + } + + @Test + fun theStoreKeepsOnlyOfferedChoices() { + val store = AccountStore(InMemorySecureStorage()) + val account = store.add("https://cloud.example.com", "alice", "p", null) + store.updateSettings(account.id, AccountSettings(idleMinutes = 60, biometric = true)) + assertEquals(AccountSettings(60, true), store.settings(account.id)) + assertFailsWith { store.updateSettings(account.id, AccountSettings(idleMinutes = 7)) } + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/api/KeepiqApiTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/api/KeepiqApiTest.kt new file mode 100644 index 000000000..4f73a1928 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/api/KeepiqApiTest.kt @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.api + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The API contract shared with the browser extension (task 1.5), against + * responses recorded from the server's controllers. + */ +class KeepiqApiTest { + private val account = Account("acc-1", "https://cloud.example.nl/nextcloud/", "alice", "app-pass-WORD-1") + private val seen = mutableListOf() + + private fun api(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData): KeepiqApi = + KeepiqApi( + KeepiqApi.httpClient( + MockEngine { request -> + seen += request + handler(request) + }, + ), + account, + ) + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK, extra: Map = emptyMap()) = + respond( + body, + status, + headersOf(*(mapOf(HttpHeaders.ContentType to "application/json") + extra).map { it.key to listOf(it.value) }.toTypedArray()), + ) + + @Test + fun requestsCarryTheExtensionsHeadersAndPath() = runTest { + api { json("""{"items":[{"id":"s1","updatedAt":"2026-10-04T10:00:00+00:00"}],"total":12}""") }.latestSecret() + val r = seen.single() + assertEquals( + "https://cloud.example.nl/nextcloud/index.php/apps/keepiq/api/v1/secrets?sort=updated_at&direction=desc&limit=1", + r.url.toString(), + ) + assertEquals("Basic YWxpY2U6YXBwLXBhc3MtV09SRC0x", r.headers[HttpHeaders.Authorization]) + assertEquals("true", r.headers["OCS-APIRequest"]) + assertEquals("application/json", r.headers[HttpHeaders.Accept]) + assertNull(r.headers[HttpHeaders.Cookie]) + } + + @Test + fun theFreshnessCheckReadsTopAndTotal() = runTest { + val f = api { json("""{"items":[{"id":"s1","updatedAt":"2026-10-04T10:00:00+00:00"}],"total":12}""") }.latestSecret() + assertEquals(Freshness("2026-10-04T10:00:00+00:00", 12), f) + val empty = api { json("""{"items":[],"total":0}""") }.latestSecret() + assertEquals(Freshness(null, 0), empty) + } + + @Test + fun aRefusalInsideHttp200IsAnError() = runTest { + // Recorded: Nextcloud's OCSMiddleware wraps a 403 from an OCSController in HTTP 200. + val e = assertFailsWith { + api { + json("""{"ocs":{"meta":{"status":"failure","statuscode":403,"message":"Forbidden"},"data":[]}}""") + }.updateSecret("s1", buildJsonObject { put("name", JsonPrimitive("x")) }) + } + assertEquals(403, e.status) + } + + @Test + fun anHttpErrorCarriesStatusAndRefusalCode() = runTest { + val e = assertFailsWith { + api { json("""{"error":"offline_cache_disabled","message":"Offline caching is disabled"}""", HttpStatusCode(428, "Precondition Required")) } + .offlineManifest() + } + assertEquals(428, e.status) + assertEquals("offline_cache_disabled", e.code) + val unauthorized = assertFailsWith { + api { json("""{"message":""}""", HttpStatusCode.Unauthorized) }.listFolders() + } + assertEquals(401, unauthorized.status) + } + + @Test + fun noCookieIsStoredOrSent() = runTest { + val client = api { json("""{"items":[],"total":0}""", extra = mapOf(HttpHeaders.SetCookie to "nc_session_id=abc; Path=/; Secure")) } + client.latestSecret() + client.latestSecret() + assertEquals(2, seen.size) + assertTrue(seen.all { it.headers[HttpHeaders.Cookie] == null }) + } + + @Test + fun redirectsAreNotFollowed() = runTest { + val e = assertFailsWith { + api { respond("", HttpStatusCode.Found, headersOf(HttpHeaders.Location, "http://evil.example/steal")) }.listTypes() + } + assertEquals(302, e.status) + assertEquals(1, seen.size) + } + + @Test + fun plainHttpIsRefusedBeforeAnythingIsSent() { + for (server in listOf("http://cloud.example.nl", "http://localhost:8080", "cloud.example.nl", "ftp://x")) { + assertFailsWith(server) { + KeepiqApi(KeepiqApi.httpClient(MockEngine { error("must not send") }), account.copy(server = server)) + } + } + } + + @Test + fun theManifestParsesSuiteSecretsAndTheBlockSignal() = runTest { + val m = api { + json( + """{"suite":{"id":"suite-9","status":"active","unlockKeyEpoch":3,"certificate":"-----BEGIN CERTIFICATE-----","privateKey":"AAAA"}, + "secrets":[{"id":"s1","name":"Bank","url":"https://bank.example","key":"AAAB","login":null,"updatedAt":"2026-10-01T00:00:00+00:00"}], + "folders":[{"id":"f1","name":"Werk","parentId":null}],"types":[{"id":"1","name":"login"}], + "syncedAt":"2026-10-04T10:00:00+00:00","unlockBlocked":null,"offlineEditsEnabled":false}""", + ) + }.offlineManifest() + assertEquals("suite-9", m.suite?.id) + assertEquals(3L, m.suite?.unlockKeyEpoch) + assertEquals(1, m.secrets.size) + assertNull(m.unlockBlocked) + val blocked = api { json("""{"suite":null,"secrets":[],"folders":[],"types":[],"unlockBlocked":"two_factor_required"}""") } + .offlineManifest() + assertNull(blocked.suite) + assertEquals("two_factor_required", blocked.unlockBlocked) + } + + @Test + fun secretsAreReadPageByPage() = runTest { + val client = api { request -> + val page = request.url.parameters["page"]!!.toInt() + val count = if (page < 3) 100 else 7 + val items = (1..count).joinToString(",") { """{"id":"p$page-$it"}""" } + json("""{"items":[$items],"total":207}""") + } + assertEquals(207, client.listSecrets().size) + assertEquals(listOf("1", "2", "3"), seen.map { it.url.parameters["page"] }) + } + + @Test + fun writesSendJsonWithTheMethodAndEncodedId() = runTest { + api { json("""{"id":"a/b"}""") }.updateSecret("a/b", buildJsonObject { put("key", JsonPrimitive("CT")) }) + val r = seen.single() + assertEquals(HttpMethod.Put, r.method) + assertTrue(r.url.encodedPath.endsWith("/api/v1/secrets/a%2Fb"), r.url.encodedPath) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndexTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndexTest.kt new file mode 100644 index 000000000..aee270680 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillIndexTest.kt @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import nl.conduction.keepiq.shared.account.InMemorySecureStorage +import nl.conduction.keepiq.shared.sync.LockReason +import nl.conduction.keepiq.shared.vault.SecretType +import nl.conduction.keepiq.shared.vault.VaultKeys +import nl.conduction.keepiq.shared.vault.VaultRow +import nl.conduction.keepiq.shared.vault.VaultState +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** The autofill index (tasks 4.1, 4.2 and 4.6), app links and Digital Asset Links. */ +class AutofillIndexTest { + private val bankPrint = "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" + private val otherPrint = "AA:BB:CC:DD" + private val bank = AppIdentity("com.example.bank", setOf(bankPrint)) + private val lookAlike = AppIdentity("com.example.bank", setOf(otherPrint)) + + private val types = listOf( + SecretType("t-login", "login", null, emptyList()), + SecretType("t-totp", "totp", null, emptyList()), + SecretType("t-note", "note", null, emptyList()), + ) + + private fun row(id: String, name: String, url: String?, type: String = "t-login", trashed: Boolean = false, blocked: Boolean = false, useOnly: Boolean = false) = + VaultRow(id, name, url, type, null, "key-$id", "login-$id", null, null, useOnly, false, blocked, null, trashed) + + private fun state(vararg rows: VaultRow, locked: LockReason? = null, onlineOnly: Boolean = false, needsConnection: Boolean = false) = + VaultState(rows.toList(), emptyList(), types, 1L, offline = false, onlineOnly = onlineOnly, needsConnection = needsConnection, locked = locked) + + private val vault = state( + row("web", "Example", "https://example.com"), + row("sub", "Example login", "https://login.example.com"), + row("app", "Bank app", AppLink("com.example.bank", setOf(bankPrint)).toUrl()), + row("bare", "Bank app without certificate", "androidapp://com.example.bank"), + row("code", "Example code", "https://example.com", type = "t-totp"), + row("note", "Example note", "https://example.com", type = "t-note"), + row("gone", "Example trashed", "https://example.com", trashed = true), + row("blocked", "Example blocked", "https://example.com", blocked = true), + ) + + @AfterTest + fun resetHub() { + AutofillIndexHub.sink = null + } + + @Test + fun theIndexHoldsLoginsAndCodesNeverTrashedOrBlockedItems() { + val index = AutofillIndex.of(vault) + assertEquals(listOf("web", "sub", "app", "bare", "code"), index.entries.map { it.id }) + // Ciphertext as the server sent it; nothing decrypted. + assertEquals("key-web", index.entries.first().key) + assertEquals("login-web", index.entries.first().login) + } + + @Test + fun aWebsiteGetsTheExtensionsSiteMatch() { + val index = AutofillIndex.of(vault) + assertEquals(listOf("sub", "web"), index.candidates(AutofillTarget.Web("login.example.com")).map { it.id }) + assertEquals(listOf("code"), index.candidates(AutofillTarget.Web("example.com"), totp = true).map { it.id }) + assertTrue(index.candidates(AutofillTarget.Web("unrelated.net")).isEmpty()) + // The name fallback of the extension: "Example" mentions the label of example.org. + assertEquals(listOf("web", "sub"), index.candidates(AutofillTarget.Web("example.org")).map { it.id }) + // An app link is never offered to a website, even one whose name reads like the package. + assertEquals(listOf("web", "sub"), index.candidates(AutofillTarget.Web("example.bank")).map { it.id }) + } + + @Test + fun anAppIsMatchedByPackageAndCertificate() { + val index = AutofillIndex.of(vault) + assertEquals(listOf("app"), index.candidates(AutofillTarget.App(bank)).map { it.id }) + // Same package, another signing certificate: nothing, not even the bare link. + assertTrue(index.candidates(AutofillTarget.App(lookAlike)).isEmpty()) + } + + @Test + fun anAppGetsTheLoginsOfTheWebsitesItIsVerifiedFor() { + val index = AutofillIndex.of(vault) + val target = AutofillTarget.App(bank, verifiedHosts = listOf("example.com")) + assertEquals(listOf("app", "web", "sub"), index.candidates(target).map { it.id }) + assertEquals(listOf("code"), index.candidates(target, totp = true).map { it.id }) + } + + @Test + fun aUseOnlyItemFillsOnlyOnItsOwnSiteAndWithholdsTheSaveOffer() { + val index = AutofillIndex.of(state(row("shared", "example.com shared", "https://other.org", useOnly = true), row("own", "Own", "https://example.com", useOnly = true))) + assertEquals(listOf("own"), index.candidates(AutofillTarget.Web("example.com")).map { it.id }) + assertTrue(index.blocksSave(AutofillTarget.Web("example.com"))) + assertFalse(index.blocksSave(AutofillTarget.Web("example.net"))) + } + + @Test + fun theIndexSurvivesItsOwnJson() { + val index = AutofillIndex.of(vault) + val back = AutofillIndex.fromJson(index.toJson()) + assertEquals(index.entries, back.entries) + assertTrue(AutofillIndex.fromJson("not json").entries.isEmpty()) + } + + @Test + fun theHubRebuildsOnEverySyncAndClearsWhenASyncLocks() { + val sink = RecordingSink() + AutofillIndexHub.sink = sink + AutofillIndexHub.refreshed("acc", vault, NoKeys) + assertEquals(5, sink.indexes["acc"]?.entries?.size) + assertEquals(true, sink.persisted["acc"]) + + // An item deleted in the web app is gone after the next sync. + AutofillIndexHub.refreshed("acc", state(row("web", "Example", "https://example.com")), NoKeys) + assertEquals(listOf("web"), sink.indexes["acc"]?.entries?.map { it.id }) + + // Offline caching off: kept in memory only. + AutofillIndexHub.refreshed("acc", state(row("web", "Example", "https://example.com"), onlineOnly = true), NoKeys) + assertEquals(false, sink.persisted["acc"]) + + // Offline without a copy: the last index stays. + AutofillIndexHub.refreshed("acc", state(needsConnection = true), NoKeys) + assertEquals(listOf("web"), sink.indexes["acc"]?.entries?.map { it.id }) + + // A suite change or a new master password locks and clears. + for (reason in LockReason.entries) { + AutofillIndexHub.refreshed("acc", vault, NoKeys) + AutofillIndexHub.refreshed("acc", state(locked = reason), NoKeys) + assertNull(sink.indexes["acc"], "cleared on $reason") + } + } + + @Test + fun appLinksNeedAFingerprint() { + assertEquals(AppLink("com.example.bank", setOf(bankPrint)), AppLink.parse("androidapp://com.example.bank#sha256_cert_fingerprints=" + bankPrint.lowercase())) + assertEquals(emptySet(), AppLink.parse("androidapp://com.example.bank")?.certFingerprints) + assertFalse(AppLink.parse("androidapp://com.example.bank")!!.matches(bank)) + assertNull(AppLink.parse("https://example.com")) + assertNull(AppLink.parse("androidapp://not a package")) + assertEquals("14:6D:E9", AppIdentity.normalizeFingerprint("146de9")) + assertNull(AppIdentity.normalizeFingerprint("xyz")) + } + + @Test + fun digitalAssetLinksNeedBothSidesAndTheLoginRelation() { + val statements = """[{"include":"https://example.com/.well-known/assetlinks.json"}, + {"relation":["delegate_permission/common.get_login_creds"],"target":{"namespace":"web","site":"https://Login.Example.org:8443"}}, + {"relation":["x"],"target":{"namespace":"web","site":"http://insecure.example"}}]""" + assertEquals(listOf("https://example.com", "https://login.example.org:8443"), AssetLinks.declaredSites(statements)) + assertTrue(AssetLinks.declaredSites("{").isEmpty()) + + val site = """[{"relation":["delegate_permission/common.get_login_creds"], + "target":{"namespace":"android_app","package_name":"com.example.bank","sha256_cert_fingerprints":["$bankPrint"]}}]""" + assertTrue(AssetLinks.allowsLogins(site, bank)) + assertFalse(AssetLinks.allowsLogins(site, lookAlike)) + assertFalse(AssetLinks.allowsLogins(site.replace("get_login_creds", "handle_all_urls"), bank)) + assertFalse(AssetLinks.allowsLogins(site.replace("com.example.bank", "com.example.other"), bank)) + assertEquals("https://example.com/.well-known/assetlinks.json", AssetLinks.statementUrl("https://example.com/")) + } + + @Test + fun theNeverSaveListIsExactSortedAndLocal() { + val list = NeverSaveList(InMemorySecureStorage()) + list.set("login.example.com", true) + list.set("androidapp://com.example.bank", true) + list.set("login.example.com", true) + assertEquals(listOf("androidapp://com.example.bank", "login.example.com"), list.sites()) + assertTrue(list.contains("login.example.com")) + assertFalse(list.contains("www.example.com")) + list.set("login.example.com", false) + assertEquals(listOf("androidapp://com.example.bank"), list.sites()) + assertEquals("example.com", AutofillTarget.Web("https://Example.com/x").saveKey) + assertEquals("androidapp://com.example.bank", AutofillTarget.App(bank).saveKey) + } + + @Test + fun iosSiteFilesHoldOneSiteEachAndNoAppLinks() { + val files = AutofillSites.split(AutofillIndex.of(vault)) + assertEquals(setOf("example.com"), files.keys) + assertEquals(listOf("sub", "web"), AutofillSites.matches(files.getValue("example.com"), "https://login.example.com/x").map { it.id }) + assertEquals(listOf("code"), AutofillSites.codeItems(files.getValue("example.com"), "example.com").map { it.id }) + assertEquals(listOf("sub"), AutofillSites.search(files.getValue("example.com"), "LOGIN").map { it.id }) + assertEquals("example.com", AutofillSites.siteKey("https://www.example.com/sign-in")) + assertEquals("sub", AutofillSites.itemId(AutofillSites.recordIdentifier("acc", "sub"), "acc")) + assertNull(AutofillSites.itemId("other|sub", "acc")) + } + + private class RecordingSink : AutofillIndexSink { + val indexes = HashMap() + val persisted = HashMap() + + override fun replace(accountId: String, index: AutofillIndex, persist: Boolean, keys: VaultKeys) { + indexes[accountId] = index + persisted[accountId] = persist + } + + override fun clear(accountId: String) { + indexes.remove(accountId) + } + } + + private object NoKeys : VaultKeys { + override val suiteId = "suite" + override val unlockKeyEpoch: Long? = null + + override fun decryptField(ciphertext: String?): String = "" + + override fun encryptField(plaintext: String): String = "" + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillVectorsTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillVectorsTest.kt new file mode 100644 index 000000000..b25f6ba53 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillVectorsTest.kt @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.vectors.GeneratedAutofillVectors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The shared autofill cases: tests/vectors/autofill/cases.json holds what + * the browser extension's matcher, use-only rules and save classifier + * answered. The phone must answer the same; tests/vitest/autofill-vectors.spec.js + * re-runs the extension on the same file. + */ +class AutofillVectorsTest { + private val cases: JsonObject by lazy { + Json.parseToJsonElement(Encoding.fromUtf8(Encoding.fromBase64(GeneratedAutofillVectors.cases.joinToString("")))).jsonObject + } + + private data class Item( + override val id: String, + override val name: String, + override val url: String?, + override val useOnly: Boolean, + override val lastUsedAt: String?, + ) : Matchable + + private fun JsonObject.text(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it !is JsonNull }?.contentOrNull + + private val items: List by lazy { + cases.getValue("items").jsonArray.map { it.jsonObject }.map { + Item(it.text("id")!!, it.text("name") ?: "", it.text("url"), it.text("useOnly") == "true", it.text("lastUsedAt")) + } + } + + @Test + fun hostsReadAsTheExtensionReadsThem() { + val hosts = cases.getValue("hosts").jsonArray.map { it.jsonObject } + assertTrue(hosts.size > 30) + for (h in hosts) { + val input = h.text("input") ?: "" + assertEquals(h.text("host"), SiteMatch.hostOf(input), "hostOf($input)") + assertEquals(h.text("registrable"), SiteMatch.registrableDomain(input), "registrableDomain($input)") + assertEquals(h.getValue("publicSuffix").jsonPrimitive.boolean, SiteMatch.isPublicSuffix(input), "isPublicSuffix($input)") + } + } + + @Test + fun scoresMatchTheExtension() { + val scores = cases.getValue("scores").jsonArray.map { it.jsonObject } + assertTrue(scores.size > 100) + for (s in scores) { + assertEquals( + s.getValue("score").jsonPrimitive.int, + SiteMatch.matchScore(s.text("name"), s.text("url"), s.text("target")), + "matchScore(${s.text("name")}, ${s.text("url")}, ${s.text("target")})", + ) + } + } + + @Test + fun rankingAndUseOnlyRulesMatchTheExtension() { + for (m in cases.getValue("matches").jsonArray.map { it.jsonObject }) { + val target = m.text("target") ?: "" + val ranked = SiteMatch.matchSecrets(items, target) + assertEquals(m.getValue("ids").jsonArray.map { it.jsonPrimitive.content }, ranked.map { it.item.id }, "matchSecrets($target)") + assertEquals( + m.getValue("filtered").jsonArray.map { it.jsonPrimitive.content }, + SiteMatch.filterForHost(ranked, target).map { it.item.id }, + "filterForHost($target)", + ) + assertEquals(m.getValue("blocksSave").jsonPrimitive.boolean, SiteMatch.blocksSavePrompt(items, target), "blocksSavePrompt($target)") + } + } + + @Test + fun saveOffersMatchTheExtension() { + val stored = cases.getValue("stored").jsonArray.map { it.jsonObject } + val rows = stored.map { Item(it.text("id")!!, it.text("name") ?: "", it.text("url"), false, null) } + val plain = stored.associate { o -> + val p = o["plain"] as? JsonObject + o.text("id")!! to p?.let { PlainLogin(it.text("login") ?: "", it.text("secret") ?: "") } + } + for (c in cases.getValue("classify").jsonArray.map { it.jsonObject }) { + val offer = Capture.classify(c.text("host")!!, c.text("login") ?: "", c.text("secret") ?: "", rows) { plain[it.id] } + val (action, id) = when (offer) { + SaveOffer.Save -> "save" to null + SaveOffer.None -> "none" to null + is SaveOffer.Update -> "update" to offer.id + } + assertEquals(c.text("action"), action, "classify($c)") + assertEquals(c.text("id"), id, "classify($c)") + } + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetectorTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetectorTest.kt new file mode 100644 index 000000000..d84f0546a --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/autofill/FieldDetectorTest.kt @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Field detection (task 4.1): Android hints, HTML attributes and view ids, with the extension's selectors. */ +class FieldDetectorTest { + private fun html(vararg attrs: Pair) = FieldFacts(htmlTag = "input", htmlAttributes = attrs.toMap()) + + @Test + fun androidHintsDecideFirst() { + assertEquals(FieldKind.USERNAME, FieldDetector.classify(FieldFacts(autofillHints = listOf("username")))) + assertEquals(FieldKind.USERNAME, FieldDetector.classify(FieldFacts(autofillHints = listOf("emailAddress")))) + assertEquals(FieldKind.PASSWORD, FieldDetector.classify(FieldFacts(autofillHints = listOf("password")))) + assertEquals(FieldKind.NEW_PASSWORD, FieldDetector.classify(FieldFacts(autofillHints = listOf("newPassword")))) + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(FieldFacts(autofillHints = listOf("smsOTPCode")))) + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(FieldFacts(autofillHints = listOf("2faAppOTPCode")))) + } + + @Test + fun htmlAttributesFollowTheExtensionsSelectors() { + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(html("autocomplete" to "one-time-code"))) + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(html("name" to "totp_code"))) + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(html("inputmode" to "numeric", "maxlength" to "6"))) + assertEquals(FieldKind.PASSWORD, FieldDetector.classify(html("type" to "password"))) + assertEquals(FieldKind.NEW_PASSWORD, FieldDetector.classify(html("type" to "password", "autocomplete" to "new-password"))) + assertEquals(FieldKind.USERNAME, FieldDetector.classify(html("type" to "email"))) + assertEquals(FieldKind.USERNAME, FieldDetector.classify(html("autocomplete" to "section-x username"))) + assertEquals(FieldKind.USERNAME, FieldDetector.classify(html("type" to "text", "name" to "Login_Name"))) + assertEquals(FieldKind.OTHER, FieldDetector.classify(html("type" to "hidden", "name" to "username"))) + assertEquals(FieldKind.OTHER, FieldDetector.classify(html("type" to "submit"))) + assertEquals(FieldKind.OTHER, FieldDetector.classify(html("type" to "text", "name" to "city"))) + } + + @Test + fun viewIdsAndInputTypesInApps() { + assertEquals(FieldKind.USERNAME, FieldDetector.classify(FieldFacts(idEntry = "user_name"))) + assertEquals(FieldKind.PASSWORD, FieldDetector.classify(FieldFacts(idEntry = "pw", passwordInput = true))) + assertEquals(FieldKind.ONE_TIME_CODE, FieldDetector.classify(FieldFacts(idEntry = "otp_field", numberInput = true))) + assertEquals(FieldKind.OTHER, FieldDetector.classify(FieldFacts(idEntry = "username", editable = false))) + } + + @Test + fun aUserNameIsFoundByItsLabelOrBeforeThePassword() { + val byLabel = FieldDetector.detect(listOf(FieldFacts(idEntry = "a"), FieldFacts(idEntry = "b", hint = "E-mailadres"), FieldFacts(idEntry = "c", passwordInput = true))) + assertEquals(1, byLabel.username) + assertEquals(listOf(2), byLabel.passwords) + + val before = FieldDetector.detect(listOf(FieldFacts(idEntry = "first"), FieldFacts(idEntry = "second"), FieldFacts(idEntry = "c", passwordInput = true))) + assertEquals(1, before.username) + + val signUp = FieldDetector.detect(listOf(html("type" to "email"), html("type" to "password", "autocomplete" to "new-password"))) + assertEquals(0, signUp.username) + assertEquals(listOf(1), signUp.newPasswords) + assertTrue(signUp.isLogin) + + val code = FieldDetector.detect(listOf(html("autocomplete" to "one-time-code"))) + assertNull(code.username) + assertEquals(0, code.oneTimeCode) + assertTrue(!code.isLogin && !code.isEmpty) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/CryptoVectorsTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/CryptoVectorsTest.kt new file mode 100644 index 000000000..774b06592 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/CryptoVectorsTest.kt @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.arr +import nl.conduction.keepiq.shared.vectors.num +import nl.conduction.keepiq.shared.vectors.obj +import nl.conduction.keepiq.shared.vectors.objects +import nl.conduction.keepiq.shared.vectors.str +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The phone reads what the web app wrote (mobile-shared-core spec, scenario + * "The phone reads what the web app wrote"). Every value comes from + * tests/vectors/crypto, produced by the web app's own modules. + */ +class CryptoVectorsTest { + private val envelope = Vectors.envelope + private val privateKey by lazy { RsaPrivateKey.fromPem(envelope.str("privateKeyPem")) } + + @Test + fun envelopeOpensWithItsPassword() { + assertEquals(envelope.str("privateKeyPem"), PrivateKeyEnvelope.open(envelope.str("envelope"), envelope.str("password"))) + } + + @Test + fun unlockKeyMatchesAndOpensTheEnvelope() { + val parts = PrivateKeyEnvelope.decode(envelope.str("envelope")) + val key = PrivateKeyEnvelope.deriveUnlockKey(envelope.str("password"), parts.salt) + assertEquals(envelope.str("unlockKeyHex"), Encoding.hex(key)) + assertEquals(envelope.str("privateKeyPem"), PrivateKeyEnvelope.openWithUnlockKey(envelope.str("envelope"), key)) + } + + @Test + fun envelopeRefusesTheWrongPassword() { + assertFailsWith { + PrivateKeyEnvelope.open(envelope.str("envelope"), envelope.str("wrongPassword")) + } + } + + @Test + fun envelopeRefusesAnUnknownVersion() { + val e = assertFailsWith { + PrivateKeyEnvelope.open(envelope.str("unsupportedVersionEnvelope"), envelope.str("password")) + } + assertEquals(2L, e.version) + } + + @Test + fun everyFieldCiphertextDecrypts() { + val cases = Vectors.fields.arr("cases").objects() + assertTrue(cases.size >= 7) + for (c in cases) { + val raw = Encoding.fromBase64(c.str("ciphertext")) + assertEquals(c.num("chunkCount"), Encoding.readUint32BigEndian(raw, 0), c.str("name")) + assertEquals(c.str("decryptsTo"), RsaFields.decrypt(c.str("ciphertext"), privateKey), c.str("name")) + } + } + + @Test + fun fieldsRoundTripThroughTheKeyAndTheCertificate() { + val text = "a".repeat(445) + "é" + "漢字🔐".repeat(200) + for (pem in listOf(envelope.str("publicKeyPem"), envelope.str("certificatePem"))) { + val ciphertext = RsaFields.encrypt(text, RsaPublicKey.fromPem(pem)) + val raw = Encoding.fromBase64(ciphertext) + val chunks = (Encoding.utf8(text).size + 445) / 446 + assertEquals(chunks.toLong(), Encoding.readUint32BigEndian(raw, 0)) + assertEquals(4 + 512 * chunks, raw.size) + assertEquals(text, RsaFields.decrypt(ciphertext, privateKey)) + } + // The core chunks like the web app: the same chunk count for every vector plaintext. + val publicKey = RsaPublicKey.fromPem(envelope.str("publicKeyPem")) + for (c in Vectors.fields.arr("cases").objects()) { + val mine = Encoding.fromBase64(RsaFields.encrypt(c.str("plaintext"), publicKey)) + assertEquals(c.num("chunkCount"), Encoding.readUint32BigEndian(mine, 0), c.str("name")) + } + val empty = RsaFields.encrypt("", RsaPublicKey.fromPem(envelope.str("publicKeyPem"))) + assertEquals(4 + 512, Encoding.fromBase64(empty).size) + assertEquals("", RsaFields.decrypt(empty, privateKey)) + } + + @Test + fun sendWithoutPasswordOpensAndBuildsTheSameLink() { + val v = Vectors.send.obj("withoutPassword") + val rawKey = Encoding.fromBase64Url(v.str("rawKeyBase64Url")) + assertEquals(v.str("payload"), SendCrypto.openPayload(v.str("encryptedPayload"), rawKey)) + assertEquals(v.str("link"), SendCrypto.sendLink(v.str("publicBase"), v.str("token"), rawKey)) + assertEquals(v.str("linkWithoutKey"), SendCrypto.sendLink(v.str("publicBase"), v.str("token"), null)) + } + + @Test + fun sendWithPasswordUnwrapsAndOpens() { + val v = Vectors.send.obj("withPassword") + if (!argon2idAvailable) { + assertFailsWith { + SendCrypto.unwrapKey(v.str("wrappedKey"), v.str("argon2idSalt"), v.str("password")) + } + return + } + val rawKey = SendCrypto.unwrapKey(v.str("wrappedKey"), v.str("argon2idSalt"), v.str("password")) + assertEquals(v.str("rawKeyBase64Url"), Encoding.toBase64Url(rawKey)) + assertEquals(v.str("payload"), SendCrypto.openPayload(v.str("encryptedPayload"), rawKey)) + assertFailsWith { + SendCrypto.unwrapKey(v.str("wrappedKey"), v.str("argon2idSalt"), v.str("password") + "x") + } + } + + @Test + fun argon2idKnownAnswer() { + if (!argon2idAvailable) return + val v = Vectors.send.obj("argon2idKnownAnswer") + val salt = ByteArray(16) { 1 } + assertEquals(Encoding.hex(salt), v.str("saltHex")) + val key = argon2id( + Encoding.utf8(v.str("password")), + salt, + v.num("memoryKiB").toInt(), + v.num("iterations").toInt(), + v.num("parallelism").toInt(), + 32, + ) + assertEquals(v.str("keyHex"), Encoding.hex(key)) + } + + @Test + fun everyTotpCodeMatches() { + val cases = Vectors.totp.arr("cases").objects() + val seen = mutableSetOf>() + for (c in cases) { + val params = Totp.parse(c.str("uri")) + assertEquals(c.str("algorithm"), params.algorithm, c.str("uri")) + assertEquals(c.num("digits").toInt(), params.digits, c.str("uri")) + assertEquals(c.num("period").toInt(), params.period, c.str("uri")) + assertEquals(c.str("code"), Totp.generate(params, c.num("epochMs")), "${c.str("uri")} at ${c.num("epochMs")}") + seen += params.algorithm to params.digits + } + for (alg in listOf("SHA1", "SHA256", "SHA512")) { + for (digits in listOf(6, 8)) assertTrue((alg to digits) in seen, "$alg/$digits covered") + } + } + + @Test + fun totpRefusesWhatTheWebAppRefuses() { + for (r in Vectors.totp.arr("refused").objects()) { + assertFailsWith(r.str("uri")) { Totp.parse(r.str("uri")) } + } + } + + @Test + fun passkeyItemJsonIsStable() { + val json = Vectors.passkey.str("itemJson") + val credential = assertNotNull(PasskeyCredential.parse(json) { "unused" }) + assertEquals(json, credential.toJson()) + } + + @Test + fun webAssertionsVerifyAndKotlinBuildsTheSameBytes() { + val v = Vectors.passkey + val spki = Encoding.fromBase64(v.str("publicKeySpki")) + val credential = assertNotNull(PasskeyCredential.parse(v.str("itemJson")) { "unused" }) + val challenge = Encoding.fromBase64Url(v.str("challengeBase64Url")) + for (a in v.arr("assertions").objects()) { + val authData = Encoding.fromBase64(a.str("authenticatorData")) + val clientData = Encoding.fromBase64(a.str("clientDataJSON")) + assertTrue(WebAuthn.verify(spki, authData, clientData, Encoding.fromBase64(a.str("signatureDer")))) + + val stored = credential.copy(counter = a.num("storedCounter")) + val mine = WebAuthn.getAssertion(challenge, stored.rpId, v.str("origin"), stored) + assertContentEquals(clientData, mine.clientDataJSON) + assertContentEquals(authData, mine.authenticatorData) + assertContentEquals(Encoding.fromBase64(a.str("userHandle")), mine.userHandle) + assertContentEquals(Encoding.fromBase64(a.str("rawId")), mine.rawId) + assertEquals(a.num("nextCounter"), mine.counter) + assertTrue(WebAuthn.verify(spki, mine.authenticatorData, mine.clientDataJSON!!, mine.signature)) + // DER: SEQUENCE of two INTEGERs. + assertEquals(0x30, mine.signature[0].toInt() and 0xFF) + } + } + + @Test + fun aTamperedSignatureDoesNotVerify() { + val v = Vectors.passkey + val a = v.arr("assertions").objects().first() + val authData = Encoding.fromBase64(a.str("authenticatorData")) + authData[authData.size - 1] = (authData[authData.size - 1] + 1).toByte() + val ok = WebAuthn.verify( + Encoding.fromBase64(v.str("publicKeySpki")), + authData, + Encoding.fromBase64(a.str("clientDataJSON")), + Encoding.fromBase64(a.str("signatureDer")), + ) + assertEquals(false, ok) + assertEquals(v.getValue("origin").jsonPrimitive.content, "https://login.example.nl") + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/EncodingTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/EncodingTest.kt new file mode 100644 index 000000000..dae474133 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/EncodingTest.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals + +class EncodingTest { + @Test + fun aLoneSurrogateEncodesAsTextEncoderDoes() { + assertEquals("61efbfbd62", Encoding.hex(Encoding.utf8("a\uD800b"))) + assertEquals("f09f9490", Encoding.hex(Encoding.utf8("🔐"))) + } + + @Test + fun aLeadingByteOrderMarkIsDroppedOnDecodeLikeTextDecoder() { + assertEquals("bom", Encoding.fromUtf8(Encoding.utf8("bom"))) + assertEquals("a", Encoding.fromUtf8(Encoding.utf8("a"))) + } + + @Test + fun base64UrlHasNoPaddingAndReadsBothForms() { + val bytes = byteArrayOf(-5, -1, 0) + assertEquals("-_8A", Encoding.toBase64Url(bytes)) + assertEquals("+/8A", Encoding.toBase64(bytes)) + assertEquals(Encoding.hex(byteArrayOf(-5)), Encoding.hex(Encoding.fromBase64Url("-w"))) + assertEquals(Encoding.hex(byteArrayOf(-5)), Encoding.hex(Encoding.fromBase64Url("-w=="))) + } + + @Test + fun sendLinkEncodesTheTokenLikeEncodeUriComponent() { + assertEquals("a%20b%2F%C3%A9-_.!~*'()", SendCrypto.encodeUriComponent("a b/é-_.!~*'()")) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/PasskeyCreateTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/PasskeyCreateTest.kt new file mode 100644 index 000000000..99da264d3 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/crypto/PasskeyCreateTest.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import nl.conduction.keepiq.shared.send.IsoTime +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.obj +import nl.conduction.keepiq.shared.vectors.str +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Passkey creation in the core (tasks 5.1 to 5.3), against the rules of + * browser-extension/src/passkey/webauthn.js createCredential: ES256 on + * P-256, the all-zero AAGUID, `none` attestation, a 16-byte credential id, + * flags UP, UV and AT, and the passkey item JSON the web app reads. The + * extension's own registration (passkey.json `registration`) is rebuilt + * byte for byte from its parts. + */ +class PasskeyCreateTest { + private val challenge = ByteArray(32) { (it * 3).toByte() } + private val origin = "https://login.example.nl" + + private fun create(algorithms: List = listOf(-7L, -257L), clientData: ClientData = ClientData.build(ClientData.CREATE, challenge, origin)) = + WebAuthn.createCredential( + rpId = "login.example.nl", + rpName = "", + userName = "alice@example.nl", + userDisplayName = "Alice de Vries", + userHandle = Encoding.utf8("user-7f3a"), + algorithms = algorithms, + clientData = clientData, + createdAt = "2026-10-05T10:00:00.000Z", + ) + + @Test + fun aNewPasskeyFollowsTheExtensionsShape() { + val r = create() + assertEquals(16, r.credentialId.size) + val a = r.authenticatorData + assertContentEquals(Primitives.sha256(Encoding.utf8("login.example.nl")), a.copyOfRange(0, 32)) + assertEquals(0x45, a[32].toInt() and 0xFF, "flags UP, UV and AT") + assertContentEquals(ByteArray(4), a.copyOfRange(33, 37), "counter 0") + assertContentEquals(ByteArray(16), a.copyOfRange(37, 53), "all-zero AAGUID") + assertContentEquals(byteArrayOf(0, 16), a.copyOfRange(53, 55)) + assertContentEquals(r.credentialId, a.copyOfRange(55, 71)) + val point = EcKeys.pointOfSpki(r.publicKeySpki) + assertContentEquals(EcKeys.cose(point), a.copyOfRange(71, a.size)) + assertEquals(148, a.size) + + // CBOR {fmt: "none", attStmt: {}, authData} in the extension's order. + val head = Encoding.hex(r.attestationObject.copyOfRange(0, 30)) + assertEquals("a363666d74646e6f6e656761747453746d74a068617574684461746158" + "94", head) + assertContentEquals(a, r.attestationObject.copyOfRange(30, r.attestationObject.size)) + + // The item JSON the web app parses, with a PKCS#8 that carries its public key. + val record = r.record + assertEquals(Encoding.toBase64Url(r.credentialId), record.credentialId) + assertEquals("login.example.nl", record.rpName, "an empty rpName falls back to the rpId") + assertEquals(Encoding.toBase64Url(Encoding.utf8("user-7f3a")), record.userHandle) + assertEquals(0L, record.counter) + assertEquals(PasskeyCredential.ES256, record.algorithm) + assertEquals(record, PasskeyCredential.parse(record.toJson()) { "unused" }) + assertTrue(record.privateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") && record.privateKey.endsWith("-----END PRIVATE KEY-----\n")) + val (_, embedded) = assertNotNull(EcKeys.parsePkcs8(WebAuthn.pkcs8FromPem(record.privateKey))) + assertContentEquals(point, embedded) + + val clientData = assertNotNull(r.clientDataJSON) + assertEquals( + "{\"type\":\"webauthn.create\",\"challenge\":\"${Encoding.toBase64Url(challenge)}\",\"origin\":\"$origin\",\"crossOrigin\":false}", + Encoding.fromUtf8(clientData), + ) + } + + @Test + fun thePasskeySignsAndTheSignatureVerifiesWithItsPublicKey() { + val r = create() + val assertion = WebAuthn.getAssertion(ByteArray(32) { 9 }, "login.example.nl", origin, r.record) + assertTrue(WebAuthn.verify(r.publicKeySpki, assertion.authenticatorData, assertion.clientDataJSON!!, assertion.signature)) + assertEquals(0L, assertion.counter) + assertContentEquals(r.credentialId, assertion.rawId) + assertContentEquals(Encoding.utf8("user-7f3a"), assertion.userHandle) + } + + @Test + fun aHashFromTheCallerIsSignedAsGiven() { + val hash = Primitives.sha256(Encoding.utf8("the browser's own clientDataJSON")) + val r = create(clientData = ClientData.hashed(hash)) + assertNull(r.clientDataJSON) + val stored = r.record.copy(counter = 4) + val assertion = WebAuthn.getAssertion(ClientData.hashed(hash), "login.example.nl", stored) + assertNull(assertion.clientDataJSON) + assertEquals(5L, assertion.counter) + assertContentEquals(Encoding.uint32BigEndian(5), assertion.authenticatorData.copyOfRange(33, 37)) + assertTrue(WebAuthn.verifyHash(r.publicKeySpki, assertion.authenticatorData, hash, assertion.signature)) + } + + @Test + fun aRequestForAnotherAlgorithmIsDeclined() { + val e = assertFailsWith { create(algorithms = listOf(-257L)) } + assertEquals(WebAuthn.UNSUPPORTED_ALGORITHM, e.message) + assertTrue(WebAuthn.supports(emptyList()), "no list means the default, which includes ES256") + } + + @Test + fun theExtensionsRegistrationIsRebuiltByteForByte() { + val v = Vectors.passkey.obj("registration") + val record = assertNotNull(PasskeyCredential.parse(v.str("itemJson")) { "unused" }) + val (_, point) = assertNotNull(EcKeys.parsePkcs8(WebAuthn.pkcs8FromPem(record.privateKey))) + val credentialId = Encoding.fromBase64Url(record.credentialId) + assertEquals(16, credentialId.size) + val authData = WebAuthn.authenticatorData(record.rpId, 0x45, 0L) + WebAuthn.attestedCredentialData(credentialId, assertNotNull(point)) + assertContentEquals(Encoding.fromBase64(v.str("attestationObject")), WebAuthn.attestationObject(authData)) + + // The phone signs with the key the extension made, and the extension's public key verifies it. + val clientData = Encoding.fromBase64(v.str("clientDataJSON")) + assertTrue(Encoding.fromUtf8(clientData).startsWith("{\"type\":\"webauthn.create\"")) + val assertion = WebAuthn.getAssertion(ByteArray(32) { 1 }, record.rpId, v.str("origin"), record) + assertTrue(WebAuthn.verify(EcKeys.spki(point), assertion.authenticatorData, assertion.clientDataJSON!!, assertion.signature)) + } + + @Test + fun isoTimeIsWrittenAsJavaScriptWritesIt() { + assertEquals("1970-01-01T00:00:00.000Z", IsoTime.format(0)) + assertEquals("2024-02-29T23:59:59.999Z", IsoTime.format(IsoTime.parseMillis("2024-02-29T23:59:59.999Z")!!)) + assertEquals("2026-10-05T10:00:00.050Z", IsoTime.format(IsoTime.parseMillis("2026-10-05T12:00:00.05+02:00")!!)) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/generator/GeneratorVectorsTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/generator/GeneratorVectorsTest.kt new file mode 100644 index 000000000..d589c6d5d --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/generator/GeneratorVectorsTest.kt @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.generator + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.longOrNull +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.vectors.GeneratedGeneratorVectors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * The shared generator cases (task 3.5): tests/vectors/generator/cases.json + * holds what the web generator returned for seeded random sources. This + * generator must give the same value, or the same refusal, for the same + * draws. tests/vitest/generator-vectors.spec.js runs the web module on the + * same file. + */ +class GeneratorVectorsTest { + private val cases: List by lazy { + val json = Encoding.fromUtf8(Encoding.fromBase64(GeneratedGeneratorVectors.cases.joinToString(""))) + Json.parseToJsonElement(json).jsonObject.getValue("cases").jsonArray.map { it.jsonObject } + } + + /** seededRandom in tests/vectors/seeded-random.mjs: xorshift32, one step per draw. */ + private fun seeded(seed: Long): (Int, Int) -> Int { + var x = seed.toInt().let { if (it == 0) 1 else it } + return { min, max -> + x = x xor (x shl 13) + x = x xor (x ushr 17) + x = x xor (x shl 5) + val range = max - min + 1 + if (range <= 1) min else min + (x.toUInt() % range.toUInt()).toInt() + } + } + + private fun JsonObject.bool(name: String, default: Boolean) = (this[name] as? JsonPrimitive)?.booleanOrNull ?: default + private fun JsonObject.int(name: String, default: Int) = (this[name] as? JsonPrimitive)?.intOrNull ?: default + private fun JsonObject.string(name: String, default: String) = (this[name] as? JsonPrimitive)?.contentOrNull ?: default + + private fun passwordOptions(o: JsonObject) = PasswordOptions( + length = o.int("length", 16), + includeUppercase = o.bool("includeUppercase", true), + includeLowercase = o.bool("includeLowercase", true), + includeDigits = o.bool("includeDigits", true), + includeSpecialCharacters = o.bool("includeSpecialCharacters", true), + minDigits = o.int("minDigits", 0), + minSpecial = o.int("minSpecial", 0), + excludedCharacters = o.string("excludedCharacters", ""), + avoidAmbiguous = o.bool("avoidAmbiguous", false), + regex = (o["regex"] as? JsonPrimitive)?.contentOrNull, + ) + + private fun passphraseOptions(o: JsonObject) = PassphraseOptions( + words = o.int("words", Generator.DEFAULT_WORDS), + separator = o.string("separator", "-"), + capitalise = o.bool("capitalise", false), + includeNumber = o.bool("includeNumber", false), + ) + + @Test + fun everySharedCaseGivesTheWebGeneratorsValueOrRefusal() { + assertTrue(cases.size > 100, "expected the shared cases, found ${cases.size}") + val failures = ArrayList() + for (c in cases) { + val name = "${c.string("name", "")} (seed ${c["seed"]})" + val options = c.getValue("options").jsonObject + val policy = GeneratorPolicy.from(c["policy"] as? JsonObject) + val rand = seeded((c.getValue("seed") as JsonPrimitive).longOrNull!!) + val outcome = runCatching { + if (c.string("kind", "") == "passphrase") { + Generator.generatePassphrase(passphraseOptions(options), policy, rand) + } else { + Generator.generateKey(passwordOptions(options), policy, rand) + } + } + val expected = (c["expected"] as? JsonPrimitive)?.contentOrNull + val error = (c["error"] as? JsonPrimitive)?.contentOrNull + when { + expected != null && outcome.getOrNull() != expected -> + failures += "$name: expected \"$expected\", got ${outcome.getOrNull()?.let { "\"$it\"" } ?: outcome.exceptionOrNull()?.message}" + error != null && outcome.exceptionOrNull()?.message != error -> + failures += "$name: expected refusal \"$error\", got ${outcome.getOrNull() ?: outcome.exceptionOrNull()?.message}" + } + } + if (failures.isNotEmpty()) fail("${failures.size} of ${cases.size} cases differ:\n" + failures.joinToString("\n")) + } + + @Test + fun theWordListIsTheWebOne() { + assertEquals(7776, EffWordlist.words.size) + assertEquals("abacus", EffWordlist.words.first()) + assertEquals("zoom", EffWordlist.words.last()) + } + + @Test + fun aPolicyOfTwentyWithADigitIsMetWithTheSecureSource() { + val policy = GeneratorPolicy.from(Json.parseToJsonElement("""{"policy_enabled":true,"generator_min_length":20,"generator_require_digit":true}""").jsonObject)!! + repeat(50) { + val value = GeneratorSettings(password = GeneratorSettings.DEFAULT_PASSWORD.copy(length = 8, includeDigits = false)).generate(policy) + assertTrue(value.length >= 20 && value.any { it.isDigit() }, value) + } + assertEquals(20, GeneratorSettings().minimumLength(policy)) + } + + @Test + fun aSixWordPassphraseHasSixListWords() { + val words = EffWordlist.words.toSet() + repeat(20) { + val phrase = GeneratorSettings(GeneratorMode.PASSPHRASE, passphrase = PassphraseOptions(words = 6, separator = " ")).generate(null) + val parts = phrase.split(' ') + assertEquals(6, parts.size) + assertTrue(parts.all { it in words }, phrase) + } + } + + @Test + fun thePolicyIsReadAsTheWebReadsIt() { + fun p(json: String) = GeneratorPolicy.from(Json.parseToJsonElement(json).jsonObject) + assertEquals(null, p("""{"policy_enabled":false,"generator_min_length":64}""")) + assertEquals(null, p("""{"policy_enabled":"true"}""")) + assertEquals(8, p("""{"policy_enabled":true,"generator_min_length":"4"}""")!!.minLength) + assertEquals(12, p("""{"policy_enabled":true}""")!!.minLength) + assertEquals(12, p("""{"policy_enabled":true,"generator_min_length":"abc"}""")!!.minLength) + assertEquals(true, p("""{"policy_enabled":true}""")!!.allowPassphrase) + assertEquals(false, p("""{"policy_enabled":true,"generator_allow_passphrase":false}""")!!.allowPassphrase) + assertEquals(false, p("""{"policy_enabled":true,"generator_require_digit":"true"}""")!!.requireDigit) + } + + @Test + fun aPassphraseSwitchedOffFallsBackToAPassword() { + val policy = GeneratorPolicy.from(Json.parseToJsonElement("""{"policy_enabled":true,"generator_allow_passphrase":false}""").jsonObject) + assertEquals(GeneratorMode.PASSWORD, GeneratorSettings(GeneratorMode.PASSPHRASE).sanitized(policy).mode) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2Test.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2Test.kt new file mode 100644 index 000000000..9a585186d --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/LoginFlowV2Test.kt @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.pairing + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.api.KeepiqApiException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Task 2.1: Login Flow v2 against answers recorded from Nextcloud 35 (core/Controller/ClientFlowLoginV2Controller). */ +class LoginFlowV2Test { + private val seen = mutableListOf() + + private val initAnswer = """{"poll":{"token":"pollToken+/=","endpoint":"https://cloud.example.com/nextcloud/index.php/login/v2/poll"},""" + + """"login":"https://cloud.example.com/nextcloud/index.php/login/v2/flow/loginToken"}""" + + private fun MockRequestHandleScope.json(body: String, status: HttpStatusCode = HttpStatusCode.OK) = + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + + private fun TestScope.flow(handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData) = LoginFlowV2( + KeepiqApi.httpClient(MockEngine { request -> seen += request; handler(request) }), + "Keepiq for Android", + { testScheduler.currentTime }, + ) + + @Test + fun startPostsToLoginV2WithTheClientNameAsUserAgent() = runTest { + val start = flow { json(initAnswer) }.start("cloud.example.com/nextcloud/") + val r = seen.single() + assertEquals("https://cloud.example.com/nextcloud/index.php/login/v2", r.url.toString()) + assertEquals("POST", r.method.value) + // Nextcloud names the app password after this header (task 2.4). + assertEquals("Keepiq for Android", r.headers[HttpHeaders.UserAgent]) + assertEquals("https://cloud.example.com/nextcloud", start.server) + assertEquals("https://cloud.example.com/nextcloud/index.php/login/v2/flow/loginToken", start.loginUrl) + } + + @Test + fun aPollEndpointOnAnotherHostIsRefused() = runTest { + val e = assertFailsWith { + flow { json(initAnswer.replace("https://cloud.example.com/nextcloud/index.php/login/v2/poll", "https://evil.example.net/poll")) } + .start("https://cloud.example.com/nextcloud") + } + assertTrue(e.message!!.contains("another host")) + } + + @Test + fun aPollEndpointOverHttpIsRefused() = runTest { + assertFailsWith { + flow { json(initAnswer.replace("https://cloud.example.com/nextcloud/index.php/login/v2/poll", "http://cloud.example.com/nextcloud/index.php/login/v2/poll")) } + .start("https://cloud.example.com/nextcloud") + } + } + + @Test + fun plainHttpIsRefusedBeforeAnyRequest() = runTest { + assertFailsWith { flow { json(initAnswer) }.start("http://cloud.example.com") } + assertTrue(seen.isEmpty()) + } + + @Test + fun aServerWithoutLoginFlowIsNamed() = runTest { + val e = assertFailsWith { flow { json("", HttpStatusCode.NotFound) }.start("https://example.com") } + assertEquals(404, e.status) + } + + @Test + fun pollSendsTheTokenAsAFormAndWaitsOn404() = runTest { + var polls = 0 + val f = flow { request -> + if (request.url.encodedPath.endsWith("/login/v2")) { + json(initAnswer) + } else { + polls++ + if (polls < 3) { + json("[]", HttpStatusCode.NotFound) + } else { + json("""{"server":"http://cloud.example.com/nextcloud","loginName":"alice","appPassword":"granted-app-password"}""") + } + } + } + val start = f.start("https://cloud.example.com/nextcloud") + assertNull(f.poll(start)) + val body = (seen.last().body as TextContent).text + assertEquals("token=pollToken%2B%2F%3D", body) + val credentials = f.await(start, testScheduler.currentTime) + assertEquals("alice", credentials.loginName) + assertEquals("granted-app-password", credentials.appPassword) + // The account keeps the https address the user typed, not the + // flow's http one from behind a proxy. + assertEquals("https://cloud.example.com/nextcloud", credentials.server) + assertTrue(!credentials.toString().contains("granted-app-password")) + } + + @Test + fun pollingStopsAfterTwentyMinutes() = runTest { + val f = flow { request -> + if (request.url.encodedPath.endsWith("/login/v2")) json(initAnswer) else json("[]", HttpStatusCode.NotFound) + } + val start = f.start("https://cloud.example.com/nextcloud") + val startedAt = testScheduler.currentTime + val e = assertFailsWith { f.await(start, startedAt) } + assertTrue(e.timedOut) + assertEquals(LoginFlowV2.TIMEOUT_MILLIS, testScheduler.currentTime - startedAt) + } + + @Test + fun aCancelledFlowStopsAtTheNextPoll() = runTest { + var polls = 0 + val f = flow { request -> + if (request.url.encodedPath.endsWith("/login/v2")) json(initAnswer) else { polls++; json("[]", HttpStatusCode.NotFound) } + } + val start = f.start("https://cloud.example.com/nextcloud") + val e = assertFailsWith { f.await(start, testScheduler.currentTime) { polls >= 2 } } + assertEquals(false, e.timedOut) + assertEquals(2, polls) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddressTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddressTest.kt new file mode 100644 index 000000000..3adf1ee4d --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/pairing/ServerAddressTest.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.pairing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** Task 2.1: the address form, the extension's normalizeServerUrl without its local-http exception. */ +class ServerAddressTest { + @Test + fun aBareHostBecomesHttps() { + assertEquals("https://cloud.example.com", ServerAddress.normalize(" cloud.example.com/ ")) + } + + @Test + fun aPastedPageIsCutBackToTheServerFolder() { + assertEquals("https://cloud.example.com/nextcloud", ServerAddress.normalize("https://cloud.example.com/nextcloud/index.php/apps/files/")) + assertEquals("https://cloud.example.com", ServerAddress.normalize("https://Cloud.Example.com/apps/keepiq")) + assertEquals("https://cloud.example.com:8443", ServerAddress.normalize("https://cloud.example.com:8443/login")) + assertEquals("https://cloud.example.com", ServerAddress.normalize("https://cloud.example.com:443/")) + } + + @Test + fun plainHttpIsRefusedForEveryHost() { + for (address in listOf("http://cloud.example.com", "http://localhost:8080", "http://10.0.2.2")) { + val e = assertFailsWith { ServerAddress.normalize(address) } + assertTrue(e.message!!.contains("https"), address) + } + } + + @Test + fun emptyCredentialsAndOtherSchemesAreRefused() { + assertFailsWith { ServerAddress.normalize(" ") } + assertFailsWith { ServerAddress.normalize("https://alice:secret@cloud.example.com") } + assertFailsWith { ServerAddress.normalize("ftp://cloud.example.com") } + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/send/SendLinkTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/send/SendLinkTest.kt new file mode 100644 index 000000000..5d88d42c0 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/send/SendLinkTest.kt @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.send + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Reading a Send link (task 3.6) and the server's times. */ +class SendLinkTest { + @Test + fun readsTheLinkTheWebAppWrites() { + val link = SendLink.parse("https://cloud.example.nl/index.php/apps/keepiq/public/send/Ab3_tOkEn-42#k=FtBbamZW74VqO39N3C80iXwtemGi_DXXFacJpw8wZVI")!! + assertEquals("Ab3_tOkEn-42", link.token) + assertEquals("FtBbamZW74VqO39N3C80iXwtemGi_DXXFacJpw8wZVI", link.fragmentKey) + assertEquals("https://cloud.example.nl/index.php/apps/keepiq/api/v1/public/sends/Ab3_tOkEn-42", link.apiBase) + } + + @Test + fun readsASubdirectoryAndALegacyQueryKey() { + val link = SendLink.parse("https://example.org/nextcloud/apps/keepiq/public/send/t%2Dk?k=abc")!! + assertEquals("t-k", link.token) + assertEquals("abc", link.fragmentKey) + assertEquals("https://example.org/nextcloud/apps/keepiq/api/v1/public/sends/t-k", link.apiBase) + } + + @Test + fun refusesWhatIsNotAnHttpsSendLink() { + assertNull(SendLink.parse("http://cloud.example.nl/index.php/apps/keepiq/public/send/x#k=a")) + assertNull(SendLink.parse("https://cloud.example.nl/index.php/apps/keepiq/public/share/link/x")) + assertNull(SendLink.parse("https://cloud.example.nl/index.php/apps/keepiq/public/send/")) + assertNull(SendLink.parse("niet een link")) + } + + @Test + fun readsIsoTimes() { + assertEquals(1_791_194_400_000, IsoTime.parseMillis("2026-10-05T10:00:00+00:00")) + assertEquals(1_791_194_400_000, IsoTime.parseMillis("2026-10-05T12:00:00+02:00")) + assertEquals(1_791_194_400_123, IsoTime.parseMillis("2026-10-05T10:00:00.123Z")) + assertEquals(0, IsoTime.parseMillis("1970-01-01T00:00:00Z")) + assertNull(IsoTime.parseMillis("gisteren")) + assertNull(IsoTime.parseMillis(null)) + } + + @Test + fun minutesLeftRoundsAsTheExtensionDoes() { + assertEquals(90, SendForm.minutesLeft(90 * 60_000L, 0)) + assertEquals(0, SendForm.minutesLeft(10_000L, 0)) + assertNull(SendForm.minutesLeft(null, 0)) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboardTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboardTest.kt new file mode 100644 index 000000000..51a9bedff --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/SensitiveClipboardTest.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The sensitive clipboard with a timeout (task 3.3), on a fake clock. */ +class SensitiveClipboardTest { + private val written = mutableListOf>() + private val cleared = mutableListOf() + private val timers = mutableListOf Unit, BooleanArray>>() + private var setting = 60 + + private val clipboard = SensitiveClipboard( + port = object : ClipboardPort { + override fun writeSensitive(text: String, expiresInSeconds: Int) { + written += text to expiresInSeconds + } + + override fun clearIfOurs(token: String) { + cleared += token + } + }, + scheduler = object : ClearScheduler { + override fun schedule(delayMillis: Long, action: ScheduledAction): PendingClear { + val cancelled = booleanArrayOf(false) + timers += Triple(delayMillis, { action.run() }, cancelled) + return PendingClear { cancelled[0] = true } + } + }, + clearSeconds = ClearDelay { setting }, + ) + + private fun fireLive() = timers.filter { !it.third[0] }.forEach { it.second() } + + @Test + fun aCopyIsMarkedSensitiveAndClearedAfterTheDefaultMinute() { + assertEquals(60, clipboard.write("geheim")) + assertEquals(listOf("geheim" to 60), written) + assertEquals(60_000L, timers.single().first) + assertTrue(cleared.isEmpty()) + fireLive() + assertEquals(listOf(SensitiveClipboard.tokenOf("geheim")), cleared) + assertTrue(cleared.single() != "geheim", "the token is a hash, not the value") + } + + @Test + fun aNewCopyRestartsTheTimerSoOnlyTheNewestIsCleared() { + clipboard.write("eerste") + setting = 10 + clipboard.write("tweede") + fireLive() + assertEquals(listOf(SensitiveClipboard.tokenOf("tweede")), cleared) + assertEquals(10_000L, timers.last().first) + } + + @Test + fun zeroNeverClearsAndAnUnknownDelayFallsBackToTheDefault() { + setting = 0 + assertEquals(0, clipboard.write("blijft")) + assertTrue(timers.isEmpty()) + setting = 7 + assertEquals(60, clipboard.write("x")) + } + + @Test + fun lockingClearsAtOnce() { + clipboard.write("geheim") + clipboard.clearNow("geheim") + fireLive() + assertEquals(listOf(SensitiveClipboard.tokenOf("geheim")), cleared) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/VaultIndexTest.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/VaultIndexTest.kt new file mode 100644 index 000000000..0015da974 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vault/VaultIndexTest.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** The vault index rules (task 3.1) on every target, the iOS simulator included. */ +class VaultIndexTest { + private fun row(id: String, name: String, folderId: String? = null, url: String? = null, trashed: Boolean = false) = VaultRow( + id = id, name = name, url = url, typeId = null, folderId = folderId, key = null, login = null, + additionalFields = null, updatedAt = null, useOnly = false, readOnly = false, blocked = false, + blockedReason = null, trashed = trashed, + ) + + @Test + fun namesSortWithoutCaseOrAccentsThenById() { + val index = VaultIndex.build( + listOf(row("3", "zeeland"), row("2", "Énergie"), row("1", "energie"), row("4", "Bank"), row("5", "oud", trashed = true)), + emptyList(), + emptyList(), + ) + assertEquals(listOf("4", "1", "2", "3"), index.map { it.id }) + assertEquals("login", index.first().typeName) + } + + @Test + fun theTreeIsDepthFirstAndSurvivesAMissingParentAndACycle() { + val folders = listOf( + VaultFolder("a", "Werk", null), + VaultFolder("b", "Klanten", "a"), + VaultFolder("c", "Archief", "a"), + VaultFolder("d", "Wees", "weg"), + VaultFolder("x", "Lus 1", "y"), + VaultFolder("y", "Lus 2", "x"), + ) + assertEquals( + listOf("Wees" to 0, "Werk" to 0, "Archief" to 1, "Klanten" to 1), + VaultIndex.folderTree(folders).map { it.name to it.depth }, + ) + assertEquals(listOf("Archief", "Klanten"), VaultIndex.subfolders(folders, "a").map { it.name }) + assertEquals("Werk / Klanten", VaultIndex.folderPath(folders, "b")) + assertNull(VaultIndex.folderPath(folders, null)) + } + + @Test + fun searchIgnoresCaseAndMatchesTheAddress() { + val index = VaultIndex.build(listOf(row("1", "Huisbank", url = "https://Mijn.Bank.example"), row("2", "Portaal")), emptyList(), emptyList()) + assertEquals(listOf("1"), VaultIndex.filter(index, " bank.EX ", null, null).map { it.id }) + assertEquals(ListState.ITEMS, VaultIndex.listState(index, index)) + assertEquals(ListState.LOADING, VaultIndex.listState(null, emptyList())) + } +} diff --git a/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vectors/Vectors.kt b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vectors/Vectors.kt new file mode 100644 index 000000000..e25492d06 --- /dev/null +++ b/mobile/shared/src/commonTest/kotlin/nl/conduction/keepiq/shared/vectors/Vectors.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vectors + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import nl.conduction.keepiq.shared.crypto.Encoding + +/** The vectors from tests/vectors/crypto, written by the web app's own modules. */ +internal object Vectors { + private fun load(parts: List): JsonObject = + Json.parseToJsonElement(Encoding.fromUtf8(Encoding.fromBase64(parts.joinToString("")))).jsonObject + + val envelope: JsonObject by lazy { load(GeneratedVectors.envelope) } + val fields: JsonObject by lazy { load(GeneratedVectors.fields) } + val send: JsonObject by lazy { load(GeneratedVectors.send) } + val totp: JsonObject by lazy { load(GeneratedVectors.totp) } + val passkey: JsonObject by lazy { load(GeneratedVectors.passkey) } +} + +internal fun JsonObject.str(name: String): String = getValue(name).jsonPrimitive.content +internal fun JsonObject.num(name: String): Long = getValue(name).jsonPrimitive.long +internal fun JsonObject.obj(name: String): JsonObject = getValue(name).jsonObject +internal fun JsonObject.arr(name: String): JsonArray = getValue(name).jsonArray +internal fun JsonArray.objects(): List = map { it.jsonObject } diff --git a/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.ios.kt b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.ios.kt new file mode 100644 index 000000000..1022d8825 --- /dev/null +++ b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.ios.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.api + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.darwin.Darwin +import platform.Foundation.NSHTTPCookieAcceptPolicy + +// NSURLSession shares HTTPCookieStorage by default; switch it off so a +// Nextcloud session cookie never rides along. +actual fun platformHttpEngine(): HttpClientEngine = Darwin.create { + configureSession { + setHTTPCookieStorage(null) + setHTTPShouldSetCookies(false) + setHTTPCookieAcceptPolicy(NSHTTPCookieAcceptPolicy.NSHTTPCookieAcceptPolicyNever) + } +} diff --git a/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Argon2.ios.kt b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Argon2.ios.kt new file mode 100644 index 000000000..7f7848874 --- /dev/null +++ b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Argon2.ios.kt @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import nl.conduction.keepiq.shared.argon2.argon2id_hash_raw + +/** + * Argon2id on iOS (task 1.3.1): neither the Security framework nor CryptoKit + * has it, so this calls the reference C code (src/nativeInterop/argon2, + * CC0 or Apache 2.0) through cinterop, as design D1 names it. Same + * parameters and output as Bouncy Castle on Android; the known-answer and + * password-Send vectors check both. + */ +@OptIn(ExperimentalForeignApi::class) +internal actual fun argon2id( + password: ByteArray, + salt: ByteArray, + memoryKiB: Int, + iterations: Int, + parallelism: Int, + lengthBytes: Int, +): ByteArray { + if (salt.isEmpty() || lengthBytes <= 0) throw KeepiqCryptoException("Argon2id needs a salt and an output length") + val out = ByteArray(lengthBytes) + // An empty array has no address to pin; Argon2 accepts NULL with length 0. + val pwd = if (password.isEmpty()) ByteArray(1) else password + val rc = pwd.usePinned { p -> + salt.usePinned { s -> + out.usePinned { o -> + argon2id_hash_raw( + iterations.convert(), + memoryKiB.convert(), + parallelism.convert(), + if (password.isEmpty()) null else p.addressOf(0), + password.size.convert(), + s.addressOf(0), + salt.size.convert(), + o.addressOf(0), + lengthBytes.convert(), + ) + } + } + } + if (rc != 0) throw KeepiqCryptoException("Argon2id failed (code $rc)") + return out +} + +internal actual val argon2idAvailable: Boolean = true diff --git a/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.ios.kt b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.ios.kt new file mode 100644 index 000000000..5196e420c --- /dev/null +++ b/mobile/shared/src/iosMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.ios.kt @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import dev.whyoleg.cryptography.BinarySize.Companion.bits +import dev.whyoleg.cryptography.CryptographyAlgorithmId +import dev.whyoleg.cryptography.CryptographyAlgorithm +import dev.whyoleg.cryptography.CryptographyProvider +import dev.whyoleg.cryptography.DelicateCryptographyApi +import dev.whyoleg.cryptography.algorithms.AES +import dev.whyoleg.cryptography.algorithms.EC +import dev.whyoleg.cryptography.algorithms.ECDSA +import dev.whyoleg.cryptography.algorithms.HMAC +import dev.whyoleg.cryptography.algorithms.PBKDF2 +import dev.whyoleg.cryptography.algorithms.RSA +import dev.whyoleg.cryptography.algorithms.SHA1 +import dev.whyoleg.cryptography.algorithms.SHA256 +import dev.whyoleg.cryptography.algorithms.SHA512 +import dev.whyoleg.cryptography.providers.apple.Apple +import dev.whyoleg.cryptography.providers.cryptokit.CryptoKit +import dev.whyoleg.cryptography.random.CryptographyRandom + +/** + * iOS: Security framework (SecKey) and CommonCrypto through + * cryptography-kotlin's Apple provider, CryptoKit for what the Apple provider + * lacks (AES-GCM). Design D2 names the same frameworks; the library only + * bridges them to Kotlin/Native. + */ +@OptIn(DelicateCryptographyApi::class) +internal actual object Primitives { + private fun algorithm(id: CryptographyAlgorithmId): A = + CryptographyProvider.Apple.getOrNull(id) ?: CryptographyProvider.CryptoKit.get(id) + + actual fun randomBytes(size: Int): ByteArray = CryptographyRandom.nextBytes(size) + + actual fun sha256(data: ByteArray): ByteArray = algorithm(SHA256).hasher().hashBlocking(data) + + actual fun hmac(algorithm: HmacAlgorithm, key: ByteArray, data: ByteArray): ByteArray { + val digest = when (algorithm) { + HmacAlgorithm.SHA1 -> SHA1 + HmacAlgorithm.SHA256 -> SHA256 + HmacAlgorithm.SHA512 -> SHA512 + } + val hmacKey = algorithm(HMAC).keyDecoder(digest).decodeFromByteArrayBlocking(HMAC.Key.Format.RAW, key) + return hmacKey.signatureGenerator().generateSignatureBlocking(data) + } + + actual fun pbkdf2Sha256(password: ByteArray, salt: ByteArray, iterations: Int, lengthBytes: Int): ByteArray = + algorithm(PBKDF2) + .secretDerivation(SHA256, iterations, (lengthBytes * 8).bits, salt) + .deriveSecretToByteArrayBlocking(password) + + actual fun aesGcmEncrypt(key: ByteArray, iv: ByteArray, plaintext: ByteArray): ByteArray = + aesKey(key).cipher().encryptWithIvBlocking(iv, plaintext) + + actual fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertextAndTag: ByteArray): ByteArray = wrap { + aesKey(key).cipher().decryptWithIvBlocking(iv, ciphertextAndTag) + } + + actual fun rsaOaepSha256Encrypt(spki: ByteArray, plaintext: ByteArray): ByteArray = wrap { + algorithm(RSA.OAEP).publicKeyDecoder(SHA256) + .decodeFromByteArrayBlocking(RSA.PublicKey.Format.DER, spki) + .encryptor().encryptBlocking(plaintext) + } + + actual fun rsaOaepSha256Decrypt(pkcs8: ByteArray, block: ByteArray): ByteArray = wrap { + algorithm(RSA.OAEP).privateKeyDecoder(SHA256) + .decodeFromByteArrayBlocking(RSA.PrivateKey.Format.DER, pkcs8) + .decryptor().decryptBlocking(block) + } + + actual fun ecdsaP256Sign(pkcs8: ByteArray, data: ByteArray): ByteArray = wrap { + algorithm(ECDSA).privateKeyDecoder(EC.Curve.P256) + .decodeFromByteArrayBlocking(EC.PrivateKey.Format.DER, pkcs8) + .signatureGenerator(SHA256, ECDSA.SignatureFormat.DER) + .generateSignatureBlocking(data) + } + + actual fun ecdsaP256Verify(spki: ByteArray, data: ByteArray, signatureDer: ByteArray): Boolean = try { + algorithm(ECDSA).publicKeyDecoder(EC.Curve.P256) + .decodeFromByteArrayBlocking(EC.PublicKey.Format.DER, spki) + .signatureVerifier(SHA256, ECDSA.SignatureFormat.DER) + .tryVerifySignatureBlocking(data, signatureDer) + } catch (e: Exception) { + false + } + + actual fun ecdsaP256Generate(): RawEcKeyPair = wrap { + val pair = algorithm(ECDSA).keyPairGenerator(EC.Curve.P256).generateKeyBlocking() + RawEcKeyPair( + d = pair.privateKey.encodeToByteArrayBlocking(EC.PrivateKey.Format.RAW), + point = pair.publicKey.encodeToByteArrayBlocking(EC.PublicKey.Format.RAW), + ) + } + + private fun aesKey(key: ByteArray) = + algorithm(AES.GCM).keyDecoder().decodeFromByteArrayBlocking(AES.Key.Format.RAW, key) + + private inline fun wrap(block: () -> T): T = try { + block() + } catch (e: KeepiqCryptoException) { + throw e + } catch (e: Exception) { + throw KeepiqCryptoException(e.message ?: "crypto failure", e) + } +} diff --git a/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.jvmShared.kt b/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.jvmShared.kt new file mode 100644 index 000000000..f60d2cbd3 --- /dev/null +++ b/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/api/HttpEngine.jvmShared.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.api + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.okhttp.OkHttp +import okhttp3.CookieJar + +actual fun platformHttpEngine(): HttpClientEngine = OkHttp.create { + config { + cookieJar(CookieJar.NO_COOKIES) + followRedirects(false) + followSslRedirects(false) + } +} diff --git a/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.jvmShared.kt b/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.jvmShared.kt new file mode 100644 index 000000000..e7759defd --- /dev/null +++ b/mobile/shared/src/jvmSharedMain/kotlin/nl/conduction/keepiq/shared/crypto/Primitives.jvmShared.kt @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Signature +import java.security.interfaces.ECPrivateKey +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.MGF1ParameterSpec +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource +import javax.crypto.spec.SecretKeySpec + +/** javax.crypto and java.security, shared by the jvm() target and Android (API 26+). */ +internal actual object Primitives { + private val random = SecureRandom() + + // Explicit: "OAEPWithSHA-256AndMGF1Padding" alone gives MGF1-SHA1 on the JDK. + private val oaep = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT) + + actual fun randomBytes(size: Int): ByteArray = ByteArray(size).also { random.nextBytes(it) } + + actual fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + + actual fun hmac(algorithm: HmacAlgorithm, key: ByteArray, data: ByteArray): ByteArray { + val name = when (algorithm) { + HmacAlgorithm.SHA1 -> "HmacSHA1" + HmacAlgorithm.SHA256 -> "HmacSHA256" + HmacAlgorithm.SHA512 -> "HmacSHA512" + } + val mac = Mac.getInstance(name) + mac.init(SecretKeySpec(key, name)) + return mac.doFinal(data) + } + + /** + * RFC 8018 PBKDF2 written over HmacSHA256 so the password stays raw UTF-8 + * bytes. PBEKeySpec takes a char[] and providers differ in how they turn + * it into bytes; WebCrypto uses the bytes as given. + */ + actual fun pbkdf2Sha256(password: ByteArray, salt: ByteArray, iterations: Int, lengthBytes: Int): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + // An empty password is a valid HMAC key for PBKDF2 but SecretKeySpec refuses it. + mac.init(if (password.isEmpty()) EmptyKey else SecretKeySpec(password, "HmacSHA256")) + val hLen = mac.macLength + val blocks = (lengthBytes + hLen - 1) / hLen + val out = ByteArray(blocks * hLen) + for (block in 1..blocks) { + mac.update(salt) + mac.update(Encoding.uint32BigEndian(block.toLong())) + var u = mac.doFinal() + val t = u.copyOf() + for (i in 2..iterations) { + u = mac.doFinal(u) + for (j in t.indices) t[j] = (t[j].toInt() xor u[j].toInt()).toByte() + } + t.copyInto(out, (block - 1) * hLen) + } + return out.copyOf(lengthBytes) + } + + actual fun aesGcmEncrypt(key: ByteArray, iv: ByteArray, plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + return cipher.doFinal(plaintext) + } + + actual fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertextAndTag: ByteArray): ByteArray = wrap { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + cipher.doFinal(ciphertextAndTag) + } + + actual fun rsaOaepSha256Encrypt(spki: ByteArray, plaintext: ByteArray): ByteArray = wrap { + val key = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(spki)) + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, oaep) + cipher.doFinal(plaintext) + } + + actual fun rsaOaepSha256Decrypt(pkcs8: ByteArray, block: ByteArray): ByteArray = wrap { + val key = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8)) + val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding") + cipher.init(Cipher.DECRYPT_MODE, key, oaep) + cipher.doFinal(block) + } + + actual fun ecdsaP256Sign(pkcs8: ByteArray, data: ByteArray): ByteArray = wrap { + val key = KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(pkcs8)) + Signature.getInstance("SHA256withECDSA").run { + initSign(key) + update(data) + sign() + } + } + + actual fun ecdsaP256Verify(spki: ByteArray, data: ByteArray, signatureDer: ByteArray): Boolean = try { + val key = KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(spki)) + Signature.getInstance("SHA256withECDSA").run { + initVerify(key) + update(data) + verify(signatureDer) + } + } catch (e: GeneralSecurityException) { + false + } + + actual fun ecdsaP256Generate(): RawEcKeyPair = wrap { + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1"), random) + val pair = generator.generateKeyPair() + val point = (pair.public as ECPublicKey).w + RawEcKeyPair( + d = fixed32((pair.private as ECPrivateKey).s), + point = byteArrayOf(0x04) + fixed32(point.affineX) + fixed32(point.affineY), + ) + } + + /** A non-negative integer as exactly 32 big-endian bytes (BigInteger adds a sign byte or drops leading zeros). */ + private fun fixed32(value: java.math.BigInteger): ByteArray { + val bytes = value.toByteArray() + return when { + bytes.size == 32 -> bytes + bytes.size > 32 -> bytes.copyOfRange(bytes.size - 32, bytes.size) + else -> ByteArray(32 - bytes.size) + bytes + } + } + + private inline fun wrap(block: () -> T): T = try { + block() + } catch (e: GeneralSecurityException) { + throw KeepiqCryptoException(e.message ?: e::class.simpleName ?: "crypto failure", e) + } + + private object EmptyKey : javax.crypto.SecretKey { + private fun readResolve(): Any = EmptyKey + override fun getAlgorithm() = "HmacSHA256" + override fun getFormat() = "RAW" + override fun getEncoded() = ByteArray(0) + } +} + +internal actual fun argon2id( + password: ByteArray, + salt: ByteArray, + memoryKiB: Int, + iterations: Int, + parallelism: Int, + lengthBytes: Int, +): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withMemoryAsKB(memoryKiB) + .withIterations(iterations) + .withParallelism(parallelism) + .withSalt(salt) + .build() + val out = ByteArray(lengthBytes) + Argon2BytesGenerator().apply { init(params) }.generateBytes(password, out) + return out +} + +internal actual val argon2idAvailable: Boolean = true diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/LiveServerTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/LiveServerTest.kt new file mode 100644 index 000000000..dcaf2167f --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/LiveServerTest.kt @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared + +import kotlinx.coroutines.runBlocking +import nl.conduction.keepiq.shared.account.InMemorySecureStorage +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApiException +import nl.conduction.keepiq.shared.api.platformHttpEngine +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.unlock.WrongMasterPasswordException +import nl.conduction.keepiq.shared.vault.MobileSession +import nl.conduction.keepiq.shared.vault.OpenResult +import nl.conduction.keepiq.shared.vault.VaultLockedException +import org.junit.Assume.assumeTrue +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The shared core against the real test server, before any emulator boots + * (.github/workflows/mobile-e2e.yml runs it with -Pkeepiq.liveServer and + * fails when it was skipped). Pair through Login Flow v2, unlock, read and + * decrypt the demo items `server.mjs seed` wrote, lock (after which nothing + * decrypts), unpair, and the old app password gets 401. + */ +class LiveServerTest { + private val server = System.getProperty("keepiq.liveServer").orEmpty() + private val masterPassword = System.getProperty("keepiq.liveMasterPassword").orEmpty() + + @Test + fun liveServerPairUnlockUnpair() = runBlocking { + assumeTrue("set -Pkeepiq.liveServer to run against a test server", server.startsWith("https://")) + val client = KeepiqClient(InMemorySecureStorage(), "Keepiq for Android (live jvm test)", platformHttpEngine()) + + val start = client.startLogin(server) + val grant = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI("$server/__e2e/grant")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString("""{"loginUrl":"${start.loginUrl}"}""")) + .build(), + HttpResponse.BodyHandlers.ofString(), + ) + assertEquals(200, grant.statusCode(), grant.body()) + val account = client.finishLogin(start) + + val gate = assertIs(client.unlockGate(account.id)) + assertFailsWith { client.unlockWithMasterPassword(account.id, gate.suite, "wrong") } + val vault = client.unlockWithMasterPassword(account.id, gate.suite, masterPassword) + assertTrue(vault.privateKeyPem.contains("PRIVATE KEY")) + assertTrue((client.maxIdleMinutes(account.id) ?: 0) > 0) + + // The vault screens' session over the seeded demo vault. + val session = MobileSession.forVault(account, vault) + val state = session.repository.refresh(SyncTrigger.START) + val names = state.rows.map { it.name } + assertTrue("Webmail (demo)" in names && "Authenticator (demo)" in names, "demo items: $names") + val webmail = state.rows.first { it.name == "Webmail (demo)" } + val opened = assertIs(session.repository.open(webmail.id)).item + assertEquals("anna.demo@example.com", opened.login) + assertEquals("Lantern-Orbit-42!", opened.secret) + val totp = assertIs(session.repository.open(state.rows.first { it.name == "Authenticator (demo)" }.id)).item + assertTrue(totp.totp != null, "the demo authenticator item holds a valid TOTP secret") + + // Lock: the keys the session holds are forgotten. + vault.lock() + assertFailsWith { session.keys.decryptField(webmail.key) } + assertFailsWith { vault.privateKeyPem } + + assertTrue(client.unpair(account.id), "Nextcloud deleted the app password") + val e = assertFailsWith { client.api(Account("", account.server, account.loginName, account.appPassword)).pair() } + assertEquals(401, e.status) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSaverTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSaverTest.kt new file mode 100644 index 000000000..666668ecf --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/autofill/AutofillSaverTest.kt @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.autofill + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.RsaFields +import nl.conduction.keepiq.shared.crypto.RsaPrivateKey +import nl.conduction.keepiq.shared.crypto.RsaPublicKey +import nl.conduction.keepiq.shared.crypto.Totp +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.str +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Filling and saving with real keys against a fake server (tasks 4.1 to + * 4.3): only matched items are decrypted, a code is the item's current + * TOTP, and a submitted login is saved, updated or left alone as the + * extension decides, encrypted to the suite key. + */ +class AutofillSaverTest { + private val privatePem = Vectors.envelope.str("privateKeyPem") + private val keys = RsaVaultKeys.fromPem(privatePem, Vectors.envelope.str("certificatePem"), "suite-9", 2) + private val publicKey = RsaPublicKey.fromPem(Vectors.envelope.str("publicKeyPem")) + private val privateKey = RsaPrivateKey.fromPem(privatePem) + private fun enc(text: String) = RsaFields.encrypt(text, publicKey) + private fun dec(text: String) = RsaFields.decrypt(text, privateKey) + + private val print = "AB:CD:EF:01" + private val app = AppIdentity("com.example.bank", setOf(print)) + private val seed = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example" + + private val index = AutofillIndex( + listOf( + AutofillEntry("w1", "Example", "https://example.com", "login", enc("alice"), enc("one"), false), + AutofillEntry("w2", "Example two", "https://www.example.com", "login", enc("bob"), enc("two"), false), + AutofillEntry("a1", "Bank", AppLink("com.example.bank", setOf(print)).toUrl(), "login", enc("carol"), enc("three"), false), + AutofillEntry("t1", "Example code", "https://example.com", "totp", null, enc(seed), false), + AutofillEntry("x", "Broken", "https://example.com", "login", "not ciphertext", "not ciphertext", false), + ), + ) + + private val requests = mutableListOf>() + private val bodies = mutableListOf() + private val engine = MockEngine { request -> + val path = request.url.encodedPath.substringAfter("/apps/keepiq") + requests += request.method.value to path + (request.body as? TextContent)?.let { bodies += Json.parseToJsonElement(it.text).jsonObject } + val body = when { + path == "/api/v1/secret-types" -> """[{"id":"7","name":"login","fields":[]},{"id":"8","name":"totp","fields":[]}]""" + request.method == HttpMethod.Post -> """{"id":"new"}""" + else -> """{"id":"w1"}""" + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + private val api = KeepiqApi(KeepiqApi.httpClient(engine), Account("acc", "https://cloud.example.nl", "alice", "app-password")) + private val saver = AutofillSaver(api, keys) + + @Test + fun onlyMatchedItemsAreDecryptedAndABrokenOneIsLeftOut() { + val web = AutofillChoices.logins(index, AutofillTarget.Web("www.example.com"), keys) + assertEquals(listOf("w2", "w1"), web.map { it.id }) + assertEquals("bob" to "two", web[0].login to web[0].password) + assertEquals(listOf("a1"), AutofillChoices.logins(index, AutofillTarget.App(app), keys).map { it.id }) + assertTrue(AutofillChoices.logins(index, AutofillTarget.App(app.copy(certFingerprints = setOf("00:11"))), keys).isEmpty()) + } + + @Test + fun aCodeIsTheCurrentTotpOfTheMatchedItem() { + val now = 1_760_000_000_000L + val codes = AutofillChoices.codes(index, AutofillTarget.Web("example.com"), keys, now) + assertEquals(listOf("t1"), codes.map { it.id }) + assertEquals(Totp.generate(Totp.parse(seed), now), codes[0].code) + assertTrue(AutofillChoices.codes(index, AutofillTarget.Web("other.org"), keys, now).isEmpty()) + } + + @Test + fun aNewLoginIsSavedForTheSiteEncryptedToTheSuiteKey() = runTest { + assertEquals(SaveResult.SAVED, saver.save(AutofillTarget.Web("signup.example.org"), index, "dave", "fresh")) + val body = bodies.single() + assertEquals(listOf("GET" to "/api/v1/secret-types", "POST" to "/api/v1/secrets"), requests) + assertEquals("signup.example.org", body["name"]!!.jsonPrimitive.content) + assertEquals("https://signup.example.org", body["url"]!!.jsonPrimitive.content) + assertEquals("7", body["typeId"]!!.jsonPrimitive.content) + assertEquals("dave", dec(body["login"]!!.jsonPrimitive.content)) + assertEquals("fresh", dec(body["key"]!!.jsonPrimitive.content)) + } + + @Test + fun anAppLoginIsSavedUnderItsPackageAndCertificate() = runTest { + assertEquals(SaveResult.SAVED, saver.save(AutofillTarget.App(AppIdentity("org.example.shop", setOf(print))), index, "erin", "e", appLabel = "Shop")) + val body = bodies.single() + assertEquals("Shop", body["name"]!!.jsonPrimitive.content) + assertEquals("androidapp://org.example.shop#sha256_cert_fingerprints=$print", body["url"]!!.jsonPrimitive.content) + } + + @Test + fun aChangedPasswordUpdatesTheOneLoginWithThatName() = runTest { + assertEquals(SaveResult.UPDATED, saver.save(AutofillTarget.Web("example.com"), index, "alice", "changed")) + assertEquals(listOf("PUT" to "/api/v1/secrets/w1"), requests) + assertEquals(setOf("key"), bodies.single().keys) + assertEquals("changed", dec(bodies.single()["key"]!!.jsonPrimitive.content)) + } + + @Test + fun theSameLoginOrAUseOnlySiteSendsNothing() = runTest { + assertEquals(SaveResult.UNCHANGED, saver.save(AutofillTarget.Web("example.com"), index, "alice", "one")) + val shared = AutofillIndex(listOf(AutofillEntry("u", "Shared", "https://example.com", "login", enc("x"), enc("y"), true))) + assertEquals(SaveResult.REFUSED, saver.save(AutofillTarget.Web("example.com"), shared, "alice", "new")) + assertEquals(SaveResult.REFUSED, saver.save(AutofillTarget.Web("example.com"), index, "alice", "")) + assertTrue(requests.isEmpty()) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/crypto/KotlinVectorsWriterTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/crypto/KotlinVectorsWriterTest.kt new file mode 100644 index 000000000..9c2f1be6a --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/crypto/KotlinVectorsWriterTest.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.crypto + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.arr +import nl.conduction.keepiq.shared.vectors.obj +import nl.conduction.keepiq.shared.vectors.objects +import nl.conduction.keepiq.shared.vectors.str +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * The reverse direction (task 1.4): this core encrypts the vector inputs and + * writes the result for tests/vitest/crypto-vectors-kotlin.spec.js, which + * opens it with src/crypto/rsa.js, src/crypto/aes.js and src/send/sendCrypto.js. + */ +class KotlinVectorsWriterTest { + private val json = Json { prettyPrint = true; prettyPrintIndent = "\t" } + + @Test + fun writeCiphertextForTheWebApp() { + val out = File(System.getProperty("keepiq.kotlinVectorsOut") ?: "build/vectors/kotlin-output.json") + val envelope = Vectors.envelope + val publicKey = RsaPublicKey.fromPem(envelope.str("publicKeyPem")) + val certificateKey = RsaPublicKey.fromPem(envelope.str("certificatePem")) + val fieldCases = Vectors.fields.arr("cases").objects() + .filter { it.str("name") != "leading byte order mark" } + + val plain = SendCrypto.sealPayload("Kotlin → web: één Send 🔐") + val pwPayload = "Kotlin password send" + val pwSealed = SendCrypto.sealPayload(pwPayload) + val wrap = SendCrypto.wrapKey(pwSealed.rawKey, "web opens this") + val masterPassword = "Kotlin master – wachtwoord ✓" + + val doc = buildJsonObject { + put( + "description", + "Written by mobile/shared (KotlinVectorsWriterTest) for the inputs in tests/vectors/crypto. " + + "Fields are encrypted to envelope.json publicKeyPem; open them with its privateKeyPem.", + ) + put( + "fields", + buildJsonArray { + for (c in fieldCases) { + add( + buildJsonObject { + put("name", c.str("name")) + put("plaintext", c.str("plaintext")) + put("ciphertext", RsaFields.encrypt(c.str("plaintext"), publicKey)) + }, + ) + } + add( + buildJsonObject { + put("name", "encrypted to the X.509 certificate") + put("plaintext", "certificate path") + put("ciphertext", RsaFields.encrypt("certificate path", certificateKey)) + }, + ) + }, + ) + put( + "envelope", + buildJsonObject { + put("password", masterPassword) + put("privateKeyPem", envelope.str("privateKeyPem")) + put("envelope", PrivateKeyEnvelope.seal(envelope.str("privateKeyPem"), masterPassword)) + }, + ) + put( + "sendWithoutPassword", + buildJsonObject { + put("payload", "Kotlin → web: één Send 🔐") + put("encryptedPayload", plain.encryptedPayload) + put("link", SendCrypto.sendLink("https://cloud.example.nl/apps/keepiq/public", "tok", plain.rawKey)) + }, + ) + put( + "sendWithPassword", + buildJsonObject { + put("payload", pwPayload) + put("password", "web opens this") + put("encryptedPayload", pwSealed.encryptedPayload) + put("wrappedKey", wrap.wrappedKey) + put("argon2idSalt", wrap.argon2idSalt) + }, + ) + val pk = Vectors.passkey + val credential = PasskeyCredential.parse(pk.str("itemJson")) { "unused" }!! + val assertion = WebAuthn.getAssertion( + Encoding.fromBase64Url(pk.str("challengeBase64Url")), + credential.rpId, + pk.str("origin"), + credential, + ) + put( + "passkeyAssertion", + buildJsonObject { + put("publicKeySpki", pk.str("publicKeySpki")) + put("clientDataJSON", Encoding.toBase64(assertion.clientDataJSON!!)) + put("authenticatorData", Encoding.toBase64(assertion.authenticatorData)) + put("signatureDer", Encoding.toBase64(assertion.signature)) + put("counter", assertion.counter) + }, + ) + // Task 5.3: a passkey the core creates, for the extension to sign with, + // and an assertion with the passkey the extension created. + val challenge = Encoding.fromBase64Url(pk.str("challengeBase64Url")) + val created = WebAuthn.createCredential( + rpId = "login.example.nl", + rpName = "Example Login", + userName = "bob@example.nl", + userDisplayName = "Bob Jansen", + userHandle = Encoding.utf8("user-kotlin"), + algorithms = listOf(-7L, -257L), + clientData = ClientData.build(ClientData.CREATE, challenge, pk.str("origin")), + createdAt = "2026-10-05T10:00:00.000Z", + ) + put( + "passkeyRegistration", + buildJsonObject { + put("origin", pk.str("origin")) + put("challengeBase64Url", pk.str("challengeBase64Url")) + put("itemJson", created.record.toJson()) + put("clientDataJSON", Encoding.toBase64(created.clientDataJSON!!)) + put("attestationObject", Encoding.toBase64(created.attestationObject)) + }, + ) + val fromExtension = PasskeyCredential.parse(pk.obj("registration").str("itemJson")) { "unused" }!! + val signed = WebAuthn.getAssertion(challenge, fromExtension.rpId, pk.str("origin"), fromExtension) + put( + "passkeyFromExtension", + buildJsonObject { + put("clientDataJSON", Encoding.toBase64(signed.clientDataJSON!!)) + put("authenticatorData", Encoding.toBase64(signed.authenticatorData)) + put("signatureDer", Encoding.toBase64(signed.signature)) + }, + ) + } + out.parentFile.mkdirs() + out.writeText(json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), doc) + "\n") + assertTrue(out.length() > 0) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/passkey/PasskeysTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/passkey/PasskeysTest.kt new file mode 100644 index 000000000..1ce888687 --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/passkey/PasskeysTest.kt @@ -0,0 +1,207 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.passkey + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.autofill.AutofillEntry +import nl.conduction.keepiq.shared.autofill.AutofillIndex +import nl.conduction.keepiq.shared.crypto.ClientData +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.PasskeyCredential +import nl.conduction.keepiq.shared.crypto.RsaFields +import nl.conduction.keepiq.shared.crypto.RsaPrivateKey +import nl.conduction.keepiq.shared.crypto.RsaPublicKey +import nl.conduction.keepiq.shared.crypto.WebAuthn +import nl.conduction.keepiq.shared.vault.RsaVaultKeys +import nl.conduction.keepiq.shared.vault.SecretType +import nl.conduction.keepiq.shared.vault.VaultRow +import nl.conduction.keepiq.shared.vault.VaultState +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.str +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * The passkey provider's vault side (tasks 5.1 and 5.2) with real keys + * against a fake server, as orchestrator.js does it: a passkey is found by + * its rpId, a counter of 4 signs as 5 and the item stores 5, a counter of 0 + * stays 0 and writes nothing, a new passkey is saved as a `passkey` item, + * and a request that allows only RS256 is declined before anything is sent. + */ +class PasskeysTest { + private val privatePem = Vectors.envelope.str("privateKeyPem") + private val keys = RsaVaultKeys.fromPem(privatePem, Vectors.envelope.str("certificatePem"), "suite-9", 2) + private val publicKey = RsaPublicKey.fromPem(Vectors.envelope.str("publicKeyPem")) + private val privateKey = RsaPrivateKey.fromPem(privatePem) + private fun enc(text: String) = RsaFields.encrypt(text, publicKey) + private fun dec(text: String) = RsaFields.decrypt(text, privateKey) + + private val requests = mutableListOf>() + private val bodies = mutableListOf() + private val engine = MockEngine { request -> + val path = request.url.encodedPath.substringAfter("/apps/keepiq") + requests += request.method.value to path + (request.body as? TextContent)?.let { bodies += Json.parseToJsonElement(it.text).jsonObject } + val body = when { + path == "/api/v1/secret-types" -> """[{"id":"7","name":"login","fields":[]},{"id":"9","name":"passkey","fields":[]}]""" + request.method == HttpMethod.Post -> """{"id":"new"}""" + else -> """{"id":"p1"}""" + } + respond(body, headers = headersOf(HttpHeaders.ContentType, "application/json")) + } + private val api = KeepiqApi(KeepiqApi.httpClient(engine), Account("acc", "https://cloud.example.nl", "alice", "app-password")) + + /** A passkey made by the core itself, with its public key to verify against. */ + private fun made(rpId: String, counter: Long): Pair { + val r = WebAuthn.createCredential(rpId, "Example", "alice@example.nl", "Alice", Encoding.utf8("u1"), listOf(-7L), ClientData.hashed(ByteArray(32)), "2026-10-05T10:00:00.000Z") + return r.record.copy(counter = counter) to r.publicKeySpki + } + + private fun entry(id: String, url: String, record: PasskeyCredential) = + AutofillEntry(id, "Example", url, AutofillEntry.PASSKEY, null, enc(record.toJson()), false) + + @Test + fun theIndexKeepsPasskeysAndNeverOffersThemAsPasswords() { + val types = listOf(SecretType("t-login", "login", null, emptyList()), SecretType("t-pk", "passkey", null, emptyList())) + val rows = listOf( + VaultRow.from(Json.parseToJsonElement("""{"id":"p1","name":"Example","url":"login.example.nl","typeId":"t-pk","key":"x"}""").jsonObject)!!, + VaultRow.from(Json.parseToJsonElement("""{"id":"l1","name":"Example","url":"https://login.example.nl","typeId":"t-login","key":"y","login":"z"}""").jsonObject)!!, + ) + val index = AutofillIndex.of(VaultState(rows, emptyList(), types, 1L, offline = false, onlineOnly = false, needsConnection = false, locked = null)) + assertEquals(listOf("p1" to true, "l1" to false), index.entries.map { it.id to it.isPasskey }) + val web = nl.conduction.keepiq.shared.autofill.AutofillTarget.Web("login.example.nl") + assertEquals(listOf("l1"), index.candidates(web).map { it.id }) + assertEquals(index.entries, AutofillIndex.fromJson(index.toJson()).entries) + } + + @Test + fun candidatesMatchTheExactRpIdAndTheAllowList() { + val (mine, _) = made("login.example.nl", 0) + val (sibling, _) = made("other.example.nl", 0) + val (elsewhere, _) = made("login.example.org", 0) + val index = AutofillIndex( + listOf( + entry("p1", "login.example.nl", mine), + entry("p2", "other.example.nl", sibling), + entry("p3", "login.example.org", elsewhere), + AutofillEntry("broken", "Broken", "login.example.nl", AutofillEntry.PASSKEY, null, "not ciphertext", false), + ), + ) + assertEquals(listOf("p1"), Passkeys.candidates(index, "login.example.nl", keys).map { it.itemId }) + assertEquals(listOf("p1"), Passkeys.candidates(index, "LOGIN.example.nl", keys, listOf(mine.credentialId)).map { it.itemId }) + assertTrue(Passkeys.candidates(index, "login.example.nl", keys, listOf(sibling.credentialId)).isEmpty()) + assertTrue(Passkeys.candidates(index, "example.nl", keys).isEmpty(), "a parent domain is another rpId") + assertEquals("alice@example.nl", Passkeys.candidates(index, "login.example.nl", keys).single().label) + } + + @Test + fun aCounterOfFourSignsAsFiveAndTheItemStoresFive() = runTest { + val (record, spki) = made("login.example.nl", 4) + val choice = Passkeys.candidates(AutofillIndex(listOf(entry("p1", "login.example.nl", record))), "login.example.nl", keys).single() + val hash = ByteArray(32) { 7 } + val assertion = Passkeys.sign(api, keys, choice, ClientData.hashed(hash), "login.example.nl") + assertEquals(5L, assertion.counter) + assertContentEquals(Encoding.uint32BigEndian(5), assertion.authenticatorData.copyOfRange(33, 37)) + assertTrue(WebAuthn.verifyHash(spki, assertion.authenticatorData, hash, assertion.signature)) + assertEquals(listOf("PUT" to "/api/v1/secrets/p1"), requests) + assertEquals(setOf("key"), bodies.single().keys) + assertEquals(5L, PasskeyCredential.parse(dec(bodies.single()["key"]!!.jsonPrimitive.content)) { "" }!!.counter) + } + + @Test + fun aCounterOfZeroStaysZeroAndWritesNothing() = runTest { + val (record, _) = made("login.example.nl", 0) + val choice = PasskeyChoice("p1", "Example", record) + assertEquals(0L, Passkeys.sign(api, keys, choice, ClientData.build(ClientData.GET, ByteArray(32), "https://login.example.nl"), "login.example.nl").counter) + assertTrue(requests.isEmpty()) + } + + @Test + fun aNewPasskeyIsSavedAsAPasskeyItemForItsRpId() = runTest { + val request = WebAuthnJson.creation( + """{"challenge":"AAEC","rp":{"id":"login.example.nl","name":"Example Login"},"user":{"id":"dTE","name":"alice@example.nl","displayName":"Alice"}, + "pubKeyCredParams":[{"type":"public-key","alg":-8},{"type":"public-key","alg":-7}],"excludeCredentials":[]}""", + )!! + val r = Passkeys.create(api, keys, AutofillIndex.EMPTY, request, ClientData.hashed(ByteArray(32)), 1_791_194_400_000L) + assertEquals(listOf("GET" to "/api/v1/secret-types", "POST" to "/api/v1/secrets"), requests) + val body = bodies.single() + assertEquals("Example Login", body["name"]!!.jsonPrimitive.content) + assertEquals("login.example.nl", body["url"]!!.jsonPrimitive.content) + assertEquals("9", body["typeId"]!!.jsonPrimitive.content) + assertFalse("login" in body) + val saved = assertNotNull(PasskeyCredential.parse(dec(body["key"]!!.jsonPrimitive.content)) { "" }) + assertEquals(r.record, saved) + assertEquals("2026-10-05T10:00:00.000Z", saved.createdAt) + assertContentEquals(Encoding.utf8("u1"), Encoding.fromBase64Url(saved.userHandle)) + } + + @Test + fun onlyRs256OrAnExcludedCredentialIsDeclinedBeforeAnythingIsSent() = runTest { + val rs256 = WebAuthnJson.creation("""{"challenge":"AA","rp":{"id":"login.example.nl"},"user":{"id":"dTE","name":"a"},"pubKeyCredParams":[{"type":"public-key","alg":-257}]}""")!! + val e = assertFailsWith { Passkeys.create(api, keys, AutofillIndex.EMPTY, rs256, ClientData.hashed(ByteArray(32)), 0) } + assertEquals(WebAuthn.UNSUPPORTED_ALGORITHM, e.message) + + val (record, _) = made("login.example.nl", 0) + val index = AutofillIndex(listOf(entry("p1", "login.example.nl", record))) + val excluded = WebAuthnJson.creation( + """{"challenge":"AA","rp":{"id":"login.example.nl"},"user":{"id":"dTE","name":"a"},"excludeCredentials":[{"type":"public-key","id":"${record.credentialId}"}]}""", + )!! + assertFailsWith { Passkeys.create(api, keys, index, excluded, ClientData.hashed(ByteArray(32)), 0) } + assertTrue(requests.isEmpty()) + } + + @Test + fun theJsonAnswersCarryWhatTheCallerVerifies() { + val get = assertNotNull(WebAuthnJson.get("""{"challenge":"AAEC","rpId":"login.example.nl","allowCredentials":[{"type":"public-key","id":"qrvM3Q=="}]}""")) + assertContentEquals(byteArrayOf(0, 1, 2), get.challenge) + assertEquals(listOf("qrvM3Q"), get.allowCredentialIds, "padding is dropped, as the stored record writes ids") + assertEquals(null, WebAuthnJson.get("not json")) + assertEquals(null, WebAuthnJson.creation("""{"rp":{"id":"x"},"user":{}}"""), "a request without user.name is not a request") + + val (record, _) = made("login.example.nl", 0) + val a = WebAuthn.getAssertion(ClientData.build(ClientData.GET, byteArrayOf(1), "android:apk-key-hash:abc"), "login.example.nl", record) + val json = Json.parseToJsonElement(WebAuthnJson.assertionResponse(a)).jsonObject + assertEquals(record.credentialId, json["id"]!!.jsonPrimitive.content) + assertEquals("public-key", json["type"]!!.jsonPrimitive.content) + val response = json["response"]!!.jsonObject + assertContentEquals(a.signature, Encoding.fromBase64Url(response["signature"]!!.jsonPrimitive.content)) + assertContentEquals(a.clientDataJSON, Encoding.fromBase64Url(response["clientDataJSON"]!!.jsonPrimitive.content)) + assertEquals(record.userHandle, response["userHandle"]!!.jsonPrimitive.content) + + val r = WebAuthn.createCredential("login.example.nl", null, "a", "", null, emptyList(), ClientData.hashed(ByteArray(32)), "") + val reg = Json.parseToJsonElement(WebAuthnJson.registrationResponse(r)).jsonObject["response"]!!.jsonObject + assertContentEquals(r.attestationObject, Encoding.fromBase64Url(reg["attestationObject"]!!.jsonPrimitive.content)) + assertContentEquals(r.publicKeySpki, Encoding.fromBase64Url(reg["publicKey"]!!.jsonPrimitive.content)) + assertEquals("-7", reg["publicKeyAlgorithm"]!!.jsonPrimitive.content) + } + + @Test + fun aBrowserMayUseItsOwnHostOrAParentThatIsNotAPublicSuffix() { + assertTrue(Passkeys.rpIdAllowed("login.example.nl", "https://login.example.nl")) + assertTrue(Passkeys.rpIdAllowed("example.nl", "https://login.example.nl")) + assertFalse(Passkeys.rpIdAllowed("evil.nl", "https://login.example.nl")) + assertFalse(Passkeys.rpIdAllowed("nl", "https://login.example.nl")) + assertFalse(Passkeys.rpIdAllowed("login.example.nl", "http://login.example.nl")) + assertTrue(Passkeys.rpIdAllowed("localhost", "http://localhost:8080")) + assertFalse(Passkeys.rpIdAllowed("", "https://login.example.nl")) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/send/SendServiceTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/send/SendServiceTest.kt new file mode 100644 index 000000000..8d8a63c27 --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/send/SendServiceTest.kt @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.send + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.Encoding +import nl.conduction.keepiq.shared.crypto.SendCrypto +import nl.conduction.keepiq.shared.vault.WriteProblemKind +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Send create, list and delete (task 3.6) against a fake server, and opening + * a Send link in the app as the public page does. The payload is sealed on + * the device: the server sees ciphertext, and the key rides the fragment only + * without a password. + */ +class SendServiceTest { + private val requests = mutableListOf>() + private var created: JsonObject? = null + private var offline = false + private var failures = 0 + + private val engine = MockEngine { request -> + val path = request.url.encodedPath + requests += request.method.value to path + if (offline) throw IOException("no network") + (request.body as? TextContent)?.let { created = Json.parseToJsonElement(it.text).jsonObject } + val c = created + val body = when { + path.endsWith("/api/v1/sends") && request.method == HttpMethod.Post -> """{"id":"send-1","token":"Tok_en-42"}""" + path.endsWith("/api/v1/sends") -> """[{"id":"send-1","payloadType":"credential","createdAt":"2026-10-04T10:00:00+00:00","expiresAt":"2026-10-05T10:00:00+00:00","viewCount":0,"maxViews":1,"hasPassword":true,"status":"active"}]""" + path.endsWith("/public/sends/Tok_en-42") -> """{"payloadType":"text","hasPassword":${c?.get("hasPassword")},"remainingViews":1}""" + path.endsWith("/public/sends/Tok_en-42/access") -> + """{"encryptedPayload":${c!!["encryptedPayload"]},"payloadType":"text","hasPassword":${c["hasPassword"]},"wrappedKey":${c["wrappedKey"] ?: "null"},"argon2idSalt":${c["argon2idSalt"] ?: "null"}}""" + path.endsWith("/confirm") -> """{"burned":true,"remainingViews":0}""" + path.endsWith("/failure") -> { failures++; """{"burned":false,"attemptsLeft":${5 - failures}}""" } + path.endsWith("/public/sends/gone") -> return@MockEngine respond("""{"message":"Send not found"}""", HttpStatusCode.NotFound) + else -> "{}" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + private val api = KeepiqApi(KeepiqApi.httpClient(engine), Account("a", "https://cloud.example.nl/", "alice", "pw")) + private val sends = SendService(api) + private val opener = OpenSendClient(KeepiqApi.httpClient(engine)) + + @Test + fun aTextSendCarriesItsKeyInTheFragmentOnly() = runTest { + val result = assertIs>( + sends.create(SendPayloadType.TEXT, "Wi-Fi: zomer☀2026", "1", SendExpiry.DAY, null, "", passwordAvailable = true), + ).value + val body = created!! + assertEquals("text", body["payloadType"]!!.jsonPrimitive.content) + assertEquals(1, body["maxViews"]!!.jsonPrimitive.content.toInt()) + assertEquals(86_400, body["ttlSeconds"]!!.jsonPrimitive.content.toInt()) + assertEquals("false", body["hasPassword"]!!.jsonPrimitive.content) + assertFalse(body.toString().contains("zomer")) + assertTrue(result.link.startsWith("https://cloud.example.nl/index.php/apps/keepiq/public/send/Tok_en-42#k=")) + val key = Encoding.fromBase64Url(result.link.substringAfter("#k=")) + assertEquals("Wi-Fi: zomer☀2026", SendCrypto.openPayload(body["encryptedPayload"]!!.jsonPrimitive.content, key)) + assertFalse(body.toString().contains(result.link.substringAfter("#k=")), "the key never reaches the server") + } + + @Test + fun theLinkOpensInTheAppOnceAndConfirmsTheView() = runTest { + val link = assertIs>( + sends.create(SendPayloadType.TEXT, "eenmalig", "1", SendExpiry.HOUR, null, "", passwordAvailable = true), + ).value.link + val parsed = assertNotNull(SendLink.parse(link)) + assertEquals("https://cloud.example.nl/index.php/apps/keepiq/api/v1/public/sends/Tok_en-42", parsed.apiBase) + assertIs(opener.peek(parsed)) + assertEquals(OpenSendResult.Opened("eenmalig", "text", burned = true), opener.open(parsed, "")) + assertEquals(listOf("GET", "POST", "POST"), requests.filter { it.second.contains("/public/") }.map { it.first }) + } + + @Test + fun aPasswordSendHasNoKeyInTheLinkAndCountsWrongPasswords() = runTest { + val made = assertIs>( + sends.create(SendPayloadType.CREDENTIAL, SendForm.credentialPayload("alice", "geheim"), "3", SendExpiry.CUSTOM, "48", "lang wachtwoord", passwordAvailable = true), + ).value + assertTrue(made.hasPassword) + assertFalse(made.link.contains("#")) + val body = created!! + assertEquals(172_800, body["ttlSeconds"]!!.jsonPrimitive.content.toInt()) + assertNotNull(body["wrappedKey"]) + assertNotNull(body["argon2idSalt"]) + val parsed = SendLink.parse(made.link)!! + assertNull(parsed.fragmentKey) + assertIs(opener.peek(parsed)) + assertEquals(OpenSendResult.WrongPassword(4, false), opener.open(parsed, "fout")) + assertEquals(OpenSendResult.Opened("Username: alice\nPassword: geheim", "text", true), opener.open(parsed, "lang wachtwoord")) + } + + @Test + fun theFormRefusesWhatTheExtensionRefuses() = runTest { + fun problem(r: SendResult<*>) = assertIs(r).form + assertEquals(SendFormProblem.NOTHING_TO_SEND, problem(sends.create(SendPayloadType.TEXT, " ", "1", SendExpiry.DAY, null, "", true))) + assertEquals(SendFormProblem.VIEWS_OUT_OF_RANGE, problem(sends.create(SendPayloadType.TEXT, "x", "101", SendExpiry.DAY, null, "", true))) + assertEquals(SendFormProblem.CUSTOM_HOURS, problem(sends.create(SendPayloadType.TEXT, "x", "1", SendExpiry.CUSTOM, "0", "", true))) + assertEquals(SendFormProblem.CUSTOM_HOURS_TOO_MANY, problem(sends.create(SendPayloadType.TEXT, "x", "1", SendExpiry.CUSTOM, "721", "", true))) + assertEquals(SendFormProblem.PASSWORD_NOT_AVAILABLE, problem(sends.create(SendPayloadType.TEXT, "x", "1", SendExpiry.DAY, null, "pw", false))) + assertTrue(requests.isEmpty()) + } + + @Test + fun listAndDeleteAndNoSendWhileOffline() = runTest { + val list = assertIs>>(sends.list()).value + assertEquals(listOf("send-1"), list.map { it.id }) + assertTrue(list.single().hasPassword) + assertEquals(1_791_194_400_000, IsoTime.parseMillis(list.single().expiresAt)) + assertIs>(sends.delete("send-1")) + assertEquals("DELETE" to "/index.php/apps/keepiq/api/v1/sends/send-1", requests.last()) + offline = true + val refused = assertIs(sends.create(SendPayloadType.TEXT, "x", "1", SendExpiry.DAY, null, "", true)) + assertEquals(WriteProblemKind.OFFLINE, refused.write!!.kind) + } + + @Test + fun aGoneSendSaysSo() = runTest { + val link = SendLink.parse("https://cloud.example.nl/apps/keepiq/public/send/gone#k=AAAA")!! + assertEquals("https://cloud.example.nl/apps/keepiq/api/v1/public/sends/gone", link.apiBase) + assertEquals(OpenSendResult.Gone, opener.peek(link)) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/store/VaultStoreTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/store/VaultStoreTest.kt new file mode 100644 index 000000000..50007a8d0 --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/store/VaultStoreTest.kt @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.store + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import nl.conduction.keepiq.shared.crypto.KeepiqCryptoException +import nl.conduction.keepiq.shared.crypto.Primitives +import nl.conduction.keepiq.shared.store.db.KeepiqDatabase +import nl.conduction.keepiq.shared.vault.VaultLockedException +import java.io.File +import java.util.Properties +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The store logic (task 1.6) on the jvm target, over plain SQLite through + * the JDBC driver. SQLCipher itself (the encrypted file and its + * device-bound key) only runs on Android; see openEncryptedDriver. + */ +class VaultStoreTest { + private val file: File = File.createTempFile("keepiq-store", ".db") + private val unlockKey = Primitives.randomBytes(32) + + private fun open(key: ByteArray = unlockKey): VaultStore { + // Creates the schema when the file is new (user_version 0), as the app does. + val driver = JdbcSqliteDriver("jdbc:sqlite:${file.absolutePath}", Properties(), KeepiqDatabase.Schema) + return VaultStore(driver, UnlockKeySealer(key)) + } + + @AfterTest + fun cleanUp() { + file.delete() + } + + private val snapshot = VaultSnapshot( + secrets = listOf( + obj("""{"id":"s1","name":"Huisbank Zuidas","url":"https://mijn.huisbank.example","typeId":1,"folderId":"f1","key":"UlNBLWNodW5r","login":"TE9HSU4=","additionalFields":null,"encryptionSuiteId":"suite-9","updatedAt":"2026-10-02T09:00:00+00:00"}"""), + obj("""{"id":"s2","name":"Gemeentelijk portaal","url":null,"typeId":2,"folderId":null,"key":"S0VZ","login":null,"additionalFields":"QURE","encryptionSuiteId":"suite-9","updatedAt":"2026-10-03T09:00:00+00:00"}"""), + ), + folders = listOf(obj("""{"id":"f1","name":"Privé financiën","parentId":null}""")), + types = listOf(obj("""{"id":1,"name":"login"}""")), + suiteId = "suite-9", + unlockKeyEpoch = 2, + checkTop = "2026-10-03T09:00:00+00:00", + checkTotal = 2, + ) + + @Test + fun aSyncedVaultReadsBackWithCiphertextUntouched() { + val store = open() + store.replaceAll(snapshot, 1_000L) + val s1 = store.secret("s1")!! + assertEquals("Huisbank Zuidas", s1.name) + assertEquals("https://mijn.huisbank.example", s1.url) + assertEquals("UlNBLWNodW5r", s1.key) + assertEquals("TE9HSU4=", s1.login) + assertEquals("1", s1.typeId) + assertEquals(listOf("s1", "s2"), store.secrets().map { it.id }) + assertNull(store.secret("s2")!!.url) + assertEquals("Privé financiën", store.folders().single().name) + assertEquals(SyncState("suite-9", 2, 1_000L, "2026-10-03T09:00:00+00:00", 2), store.state()) + } + + @Test + fun theDatabaseFileHoldsNoPlaintextNameUrlOrFolder() { + open().replaceAll(snapshot, 1_000L) + val bytes = file.readBytes() + for (plain in listOf("Huisbank Zuidas", "mijn.huisbank.example", "Gemeentelijk portaal", "Privé financiën")) { + assertFalse(contains(bytes, plain.encodeToByteArray()), "\"$plain\" is readable in the file") + } + // A control: the server's ciphertext is stored as it came. + assertTrue(contains(bytes, "UlNBLWNodW5r".encodeToByteArray())) + } + + @Test + fun anotherUnlockKeyCannotReadTheNames() { + open().replaceAll(snapshot, 1_000L) + assertFailsWith { open(Primitives.randomBytes(32)).secrets() } + } + + @Test + fun clearEmptiesEverythingAndLeavesNothingInTheFile() { + val store = open() + store.replaceAll(snapshot, 1_000L) + store.clear() + assertTrue(store.secrets().isEmpty()) + assertTrue(store.folders().isEmpty()) + assertNull(store.state()) + assertFalse(contains(file.readBytes(), "UlNBLWNodW5r".encodeToByteArray()), "secure_delete left the ciphertext behind") + } + + @Test + fun aClosedStoreReadsAsLocked() { + val store = open() + store.replaceAll(snapshot, 1_000L) + store.close() + assertFailsWith { store.secrets() } + assertFailsWith { store.state() } + assertFailsWith { store.replaceAll(snapshot, 2_000L) } + } + + @Test + fun aSecondSyncReplacesTheFirst() { + val store = open() + store.replaceAll(snapshot, 1_000L) + store.replaceAll(snapshot.copy(secrets = snapshot.secrets.take(1), checkTotal = 1), 2_000L) + assertEquals(listOf("s1"), store.secrets().map { it.id }) + store.touch(3_000L) + assertEquals(3_000L, store.state()!!.syncedAtMillis) + } + + private fun obj(json: String) = Json.parseToJsonElement(json).jsonObject + + private fun contains(haystack: ByteArray, needle: ByteArray): Boolean { + outer@ for (i in 0..haystack.size - needle.size) { + for (j in needle.indices) if (haystack[i + j] != needle[j]) continue@outer + return true + } + return false + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/sync/VaultSyncTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/sync/VaultSyncTest.kt new file mode 100644 index 000000000..71b99dfdf --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/sync/VaultSyncTest.kt @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.sync + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.Primitives +import nl.conduction.keepiq.shared.store.UnlockKeySealer +import nl.conduction.keepiq.shared.store.VaultStore +import nl.conduction.keepiq.shared.store.db.KeepiqDatabase +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Sync without a change feed (task 1.7): every trigger, the cheap check and the epoch comparison. */ +class VaultSyncTest { + private val session = UnlockedSession("suite-9", 2) + private var now = 10_000_000L + private val calls = mutableListOf() + private val locks = mutableListOf() + private var wrapsDeleted = 0 + + /** What the fake server answers; tests change it between syncs. */ + private var manifestStatus = HttpStatusCode.OK + private var epoch = 2 + private var suiteId = "suite-9" + private var unlockBlocked = "null" + private var top = "2026-10-03T09:00:00+00:00" + private var name = "Huisbank" + + private val store = VaultStore( + JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).also { KeepiqDatabase.Schema.create(it) }, + UnlockKeySealer(Primitives.randomBytes(32)), + ) + + private val engine = MockEngine { request -> + val path = request.url.encodedPath.substringAfter("/apps/keepiq") + val query = request.url.encodedQuery + calls += if (query.isEmpty()) path else "$path?$query" + val suite = """{"id":"$suiteId","status":"active","unlockKeyEpoch":$epoch,"unlockBlocked":null}""" + val body = when { + path == "/api/v1/offline/manifest" && manifestStatus != HttpStatusCode.OK -> """{"message":"off"}""" + path == "/api/v1/offline/manifest" -> """{"suite":${if (unlockBlocked == "null") suite else "null"}, + "secrets":[{"id":"s1","name":"$name","url":null,"key":"CT","updatedAt":"$top"}, + {"id":"s2","name":"Portaal","url":null,"key":"CT2","updatedAt":"2026-10-01T09:00:00+00:00"}], + "folders":[],"types":[],"unlockBlocked":$unlockBlocked}""" + path == "/api/v1/secrets" && query.startsWith("sort=") -> """{"items":[{"id":"s1","updatedAt":"$top"}],"total":2}""" + path == "/api/v1/secrets" -> """{"items":[{"id":"s1","name":"$name","updatedAt":"$top"}],"total":1}""" + path == "/api/v1/suites" -> "[$suite]" + else -> "[]" + } + val status = if (path == "/api/v1/offline/manifest") manifestStatus else HttpStatusCode.OK + respond(body, status, headersOf(HttpHeaders.ContentType, "application/json")) + } + + private val sync = VaultSync( + KeepiqApi(KeepiqApi.httpClient(engine), Account("a", "https://cloud.example.nl", "alice", "pw")), + store, + object : SyncListener { + override fun lock(reason: LockReason) { + locks += reason + } + + override fun deleteUnlockWraps() { + wrapsDeleted++ + } + }, + clock = { now }, + ) + + private val manifest = "/api/v1/offline/manifest" + private val cheap = "/api/v1/secrets?sort=updated_at&direction=desc&limit=1" + + @Test + fun startFetchesTheManifestAndStoresIt() = runTest { + assertEquals(SyncOutcome.Synced(now, 2), sync.sync(SyncTrigger.START, session)) + assertEquals(listOf(manifest), calls) + assertEquals("Huisbank", store.secret("s1")!!.name) + assertEquals("2026-10-03T09:00:00+00:00", store.state()!!.checkTop) + } + + @Test + fun foregroundWithNothingNewOnlyRunsTheCheapCheck() = runTest { + sync.sync(SyncTrigger.START, session) + calls.clear() + now += 60_000 + assertEquals(SyncOutcome.Fresh(now), sync.sync(SyncTrigger.FOREGROUND, session)) + assertEquals(listOf(cheap), calls) + assertEquals(now, store.state()!!.syncedAtMillis) + } + + @Test + fun aChangeOnTheWebReachesThePhoneOnForeground() = runTest { + sync.sync(SyncTrigger.START, session) + calls.clear() + top = "2026-10-04T12:00:00+00:00" + name = "Huisbank (nieuw)" + assertIs(sync.sync(SyncTrigger.FOREGROUND, session)) + assertEquals(listOf(cheap, manifest), calls) + assertEquals("Huisbank (nieuw)", store.secret("s1")!!.name) + } + + @Test + fun theTimerSyncsFullyOnceTheCopyIsOlderThan15Minutes() = runTest { + sync.sync(SyncTrigger.START, session) + calls.clear() + now += VaultSync.SYNC_INTERVAL_MILLIS + assertIs(sync.sync(SyncTrigger.TIMER, session)) + assertEquals(listOf(cheap, manifest), calls) + } + + @Test + fun aWriteAndAManualRefreshAlwaysSyncFully() = runTest { + sync.sync(SyncTrigger.START, session) + for (trigger in listOf(SyncTrigger.AFTER_WRITE, SyncTrigger.MANUAL)) { + calls.clear() + assertIs(sync.sync(trigger, session)) + assertEquals(listOf(manifest), calls, trigger.name) + } + } + + @Test + fun aHigherUnlockKeyEpochLocksDeletesTheWrapsAndEmptiesTheStore() = runTest { + sync.sync(SyncTrigger.START, session) + epoch = 3 + assertEquals(SyncOutcome.Locked(LockReason.MASTER_PASSWORD_CHANGED), sync.sync(SyncTrigger.MANUAL, session)) + assertEquals(listOf(LockReason.MASTER_PASSWORD_CHANGED), locks) + assertEquals(1, wrapsDeleted) + assertTrue(store.secrets().isEmpty()) + assertNull(store.state()) + } + + @Test + fun theSameEpochDoesNotLock() = runTest { + sync.sync(SyncTrigger.START, session) + sync.sync(SyncTrigger.MANUAL, session) + assertTrue(locks.isEmpty()) + assertEquals(0, wrapsDeleted) + } + + @Test + fun aNewSuiteLocksAndEmptiesTheStore() = runTest { + sync.sync(SyncTrigger.START, session) + suiteId = "suite-10" + assertEquals(SyncOutcome.Locked(LockReason.SUITE_CHANGED), sync.sync(SyncTrigger.MANUAL, session)) + assertTrue(store.secrets().isEmpty()) + } + + @Test + fun theTwoFactorBlockLocksWithoutTouchingTheWraps() = runTest { + sync.sync(SyncTrigger.START, session) + unlockBlocked = "\"two_factor_required\"" + assertEquals(SyncOutcome.Locked(LockReason.UNLOCK_BLOCKED), sync.sync(SyncTrigger.MANUAL, session)) + assertEquals(0, wrapsDeleted) + assertTrue(store.secrets().isEmpty()) + } + + @Test + fun offlineCachingTurnedOffDeletesTheLocalCopyAndWorksOnline() = runTest { + sync.sync(SyncTrigger.START, session) + for (status in listOf(HttpStatusCode(428, "Precondition Required"), HttpStatusCode.Forbidden)) { + manifestStatus = status + val outcome = sync.sync(SyncTrigger.MANUAL, session) + assertIs(outcome) + assertEquals(listOf("s1"), outcome.secrets.map { it["id"].toString().trim('"') }) + assertTrue(store.secrets().isEmpty(), "store emptied on $status") + assertNull(store.state()) + } + } + + @Test + fun anEpochChangeIsAlsoSeenWithOfflineCachingOff() = runTest { + manifestStatus = HttpStatusCode(428, "Precondition Required") + epoch = 5 + assertEquals(SyncOutcome.Locked(LockReason.MASTER_PASSWORD_CHANGED), sync.sync(SyncTrigger.START, session)) + assertEquals(1, wrapsDeleted) + } +} diff --git a/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/vault/VaultRepositoryTest.kt b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/vault/VaultRepositoryTest.kt new file mode 100644 index 000000000..eed56ddb6 --- /dev/null +++ b/mobile/shared/src/jvmTest/kotlin/nl/conduction/keepiq/shared/vault/VaultRepositoryTest.kt @@ -0,0 +1,346 @@ +// SPDX-FileCopyrightText: 2026 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +package nl.conduction.keepiq.shared.vault + +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import nl.conduction.keepiq.shared.api.Account +import nl.conduction.keepiq.shared.api.KeepiqApi +import nl.conduction.keepiq.shared.crypto.Primitives +import nl.conduction.keepiq.shared.crypto.RsaFields +import nl.conduction.keepiq.shared.crypto.RsaPrivateKey +import nl.conduction.keepiq.shared.crypto.RsaPublicKey +import nl.conduction.keepiq.shared.store.UnlockKeySealer +import nl.conduction.keepiq.shared.store.VaultStore +import nl.conduction.keepiq.shared.store.db.KeepiqDatabase +import nl.conduction.keepiq.shared.sync.LockReason +import nl.conduction.keepiq.shared.sync.SyncListener +import nl.conduction.keepiq.shared.sync.SyncTrigger +import nl.conduction.keepiq.shared.sync.VaultSync +import nl.conduction.keepiq.shared.vectors.Vectors +import nl.conduction.keepiq.shared.vectors.str +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The vault screens' logic (tasks 3.1 to 3.4) against a fake server and the + * real store: reading without decrypting, opening and decrypting one item, + * writes encrypted to the suite's key in the web app's shapes, a refusal + * inside an HTTP 200 shown as refused, use-only copies kept closed, and + * offline reading with edits refused before anything is sent. + */ +class VaultRepositoryTest { + private val privatePem = Vectors.envelope.str("privateKeyPem") + private val keys = RsaVaultKeys.fromPem(privatePem, Vectors.envelope.str("certificatePem"), "suite-9", 2) + private val publicKey = RsaPublicKey.fromPem(Vectors.envelope.str("publicKeyPem")) + private val privateKey = RsaPrivateKey.fromPem(privatePem) + + private fun enc(text: String) = RsaFields.encrypt(text, publicKey) + private fun dec(text: String) = RsaFields.decrypt(text, privateKey) + + private val requests = mutableListOf>() + private val bodies = mutableListOf() + private var offline = false + private var refuseWrites = false + private var cachingOff = false + + private val loginRow = """{"id":"s1","name":"Huisbank","url":"https://mijn.huisbank.example","typeId":"1","folderId":"f2", + "key":"${enc("geheim-1")}","login":"${enc("alice")}","additionalFields":"${enc("""{"notes":"pin bij balie","Klantnummer":"123","extra":42}""")}", + "useOnly":false,"readOnly":false,"blocked":false,"updatedAt":"2026-10-03T09:00:00+00:00"}""" + private val useOnlyRow = """{"id":"s2","name":"Leveranciersportaal","url":"https://portal.supplier.example","typeId":"1","folderId":null, + "key":"${enc("niet-tonen")}","login":"${enc("bob")}","additionalFields":"${enc("""{"geheim":"x"}""")}", + "useOnly":true,"readOnly":true,"blocked":false,"updatedAt":"2026-10-01T09:00:00+00:00"}""" + private val blockedRow = """{"id":"s3","name":"Ánders","url":null,"typeId":"2","folderId":null,"blocked":true,"blockedReason":"Old suite","updatedAt":"2026-10-01T08:00:00+00:00"}""" + private val types = """[{"id":"1","name":"login","fields":[{"key":"klantnummer","label":"Klantnummer","kind":"text","required":true}]},{"id":"2","name":"note","fields":[]}]""" + private val folders = """[{"id":"f1","name":"Werk","parentId":null},{"id":"f2","name":"Bank","parentId":"f1"},{"id":"f3","name":"Algemeen","parentId":null}]""" + + private val engine = MockEngine { request -> + val path = request.url.encodedPath.substringAfter("/apps/keepiq") + requests += request.method.value to path + (request.body as? TextContent)?.let { bodies += Json.parseToJsonElement(it.text).jsonObject } + if (offline) throw IOException("no network") + val write = request.method != HttpMethod.Get + val body = when { + write && refuseWrites -> """{"ocs":{"meta":{"status":"failure","statuscode":403,"message":""},"data":{"error":"read_only","message":"You can only read this item."}}}""" + path == "/api/v1/offline/manifest" && cachingOff -> + return@MockEngine respond("""{"message":"Offline caching is off"}""", HttpStatusCode(428, "Precondition Required")) + path == "/api/v1/suites" -> """[{"id":"suite-9","status":"active","unlockKeyEpoch":2}]""" + path == "/api/v1/secrets" && request.method == HttpMethod.Get -> """{"items":[$loginRow],"total":1}""" + path == "/api/v1/folders" && request.method == HttpMethod.Get -> folders + path == "/api/v1/secret-types" -> types + path == "/api/v1/offline/manifest" -> """{"suite":{"id":"suite-9","status":"active","unlockKeyEpoch":2},"secrets":[$loginRow,$useOnlyRow,$blockedRow],"folders":$folders,"types":$types,"unlockBlocked":null}""" + path == "/api/v1/secrets/s1" && request.method == HttpMethod.Get -> loginRow + path == "/api/v1/secrets/s2" && request.method == HttpMethod.Get -> useOnlyRow + path == "/api/v1/secrets" && request.method == HttpMethod.Post -> """{"id":"new-1"}""" + path.startsWith("/api/v1/folders/") && path.endsWith("/children") -> """{"directSecretCount":2,"subfolders":[]}""" + else -> "{}" + } + respond(body, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + + private val api = KeepiqApi(KeepiqApi.httpClient(engine), Account("a", "https://cloud.example.nl", "alice", "pw")) + private val store = VaultStore( + JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY).also { KeepiqDatabase.Schema.create(it) }, + UnlockKeySealer(Primitives.randomBytes(32)), + ) + private val locks = mutableListOf() + private var now = 50_000_000L + private val sync = VaultSync(api, store, object : SyncListener { + override fun lock(reason: LockReason) { + locks += reason + } + + override fun deleteUnlockWraps() = Unit + }, clock = { now }) + private val repo = VaultRepository(api, keys, store, sync, clock = { now }) + + @Test + fun theListShowsNamesAndUrlsWithoutDecryptingAnything() = runTest { + val state = repo.refresh(SyncTrigger.START) + assertEquals(listOf("Ánders", "Huisbank", "Leveranciersportaal"), state.index.map { it.name }) + val huisbank = state.index.single { it.id == "s1" } + assertEquals("Bank", huisbank.folderName) + assertEquals("login", huisbank.typeName) + assertTrue(state.index.single { it.id == "s2" }.useOnly) + assertEquals(now, state.syncedAtMillis) + assertFalse(state.offline) + // Only the manifest: no item was fetched to build the list. + assertEquals(listOf("GET" to "/api/v1/offline/manifest"), requests) + } + + @Test + fun searchMatchesNameAndAddressAndFoldersFollowTheTree() = runTest { + val state = repo.refresh(SyncTrigger.START) + assertEquals(listOf("s1"), VaultIndex.filter(state.index, "HUISBANK.ex").map { it.id }) + assertEquals(listOf("s2"), VaultIndex.filter(state.index, "supplier").map { it.id }) + assertEquals(listOf("s1"), VaultIndex.filter(state.index, folderId = "f2").map { it.id }) + assertEquals(listOf("s3", "s2"), VaultIndex.filter(state.index, folderId = VaultIndex.NO_FOLDER).map { it.id }) + assertEquals(listOf("Algemeen" to 0, "Werk" to 0, "Bank" to 1), VaultIndex.folderTree(state.folders).map { it.name to it.depth }) + assertEquals("Werk / Bank", VaultIndex.folderPath(state.folders, "f2")) + assertEquals(ListState.NO_MATCH, VaultIndex.listState(state.index, VaultIndex.filter(state.index, "zzz"))) + } + + @Test + fun openingAnItemDecryptsItsFieldsAndTypedValues() = runTest { + repo.refresh(SyncTrigger.START) + val item = assertIs(repo.open("s1")).item + assertEquals("alice", item.login) + assertEquals("geheim-1", item.secret) + assertEquals("pin bij balie", item.notes) + assertEquals(listOf("Klantnummer" to "123"), item.typedValues.map { it.first.label to it.second }) + assertEquals(listOf("extra" to "42"), item.extraFields) + assertFalse(item.fromCache) + } + + @Test + fun aUseOnlyCopyNeverOpensItsValue() = runTest { + repo.refresh(SyncTrigger.START) + val item = assertIs(repo.open("s2")).item + assertEquals("", item.secret) + assertNull(item.additionalFields) + assertEquals("bob", item.login) + assertTrue(item.row.useOnly) + // An edit of a use-only copy is refused before anything is sent. + val before = requests.size + assertIs(repo.update(item, ItemCodec.draft(item, item.type).copy(secret = "nieuw"))) + assertEquals(before, requests.size) + } + + @Test + fun aBlockedItemIsNotDecrypted() { + val row = VaultRow.from(Json.parseToJsonElement(blockedRow).jsonObject)!! + val item = ItemCodec.open(row, null, keys, fromCache = false) + assertEquals("", item.secret) + assertEquals("Old suite", item.row.blockedReason) + } + + @Test + fun aCreatedLoginIsEncryptedToTheSuiteKeyInTheWebAppsShape() = runTest { + val state = repo.refresh(SyncTrigger.START) + val type = state.typeNamed("login")!! + val draft = ItemCodec.draft(null, type).copy( + name = " Gemeente portaal ", url = "https://gemeente.example", login = "", secret = "Gegenereerd-20-tekens!", + typed = mapOf("klantnummer" to "998"), notes = "", + ) + assertTrue(ItemCodec.validate(draft, type).isEmpty()) + assertEquals(WriteResult.Saved("new-1"), repo.create(draft, type)) + val body = bodies.single() + assertEquals("Gemeente portaal", body["name"]!!.jsonPrimitive.content) + assertEquals("1", body["typeId"]!!.jsonPrimitive.content) + assertEquals(JsonNull, body["folderId"]) + assertEquals("Gegenereerd-20-tekens!", dec(body["key"]!!.jsonPrimitive.content)) + assertFalse("login" in body, "an empty login is left out, as the web app does") + assertEquals("""{"Klantnummer":"998"}""", dec(body["additionalFields"]!!.jsonPrimitive.content)) + assertFalse(body.toString().contains("Gegenereerd"), "no plaintext value in the request") + // A write syncs after it went through. + assertEquals("GET" to "/api/v1/offline/manifest", requests.last()) + } + + @Test + fun anUpdateSendsOnlyWhatChanged() = runTest { + repo.refresh(SyncTrigger.START) + val item = assertIs(repo.open("s1")).item + val draft = ItemCodec.draft(item, item.type) + assertEquals("123", draft.typed["klantnummer"]) + assertEquals(mapOf("extra" to JsonPrimitive(42)), draft.preserved) + assertIs(repo.update(item, draft.copy(secret = "nieuw-wachtwoord", login = ""))) + val body = bodies.single() + assertEquals(setOf("key", "login"), body.keys) + assertEquals("nieuw-wachtwoord", dec(body["key"]!!.jsonPrimitive.content)) + assertEquals(JsonNull, body["login"]) + } + + @Test + fun editingTheNotesRewritesTheAdditionalFieldsAndKeepsTheRest() = runTest { + repo.refresh(SyncTrigger.START) + val item = assertIs(repo.open("s1")).item + assertIs(repo.update(item, ItemCodec.draft(item, item.type).copy(notes = "nieuw"))) + val body = bodies.single() + assertEquals(setOf("additionalFields"), body.keys) + val fields = Json.parseToJsonElement(dec(body["additionalFields"]!!.jsonPrimitive.content)).jsonObject + assertEquals(JsonPrimitive(42), fields["extra"]) + assertEquals("123", fields["Klantnummer"]!!.jsonPrimitive.content) + assertEquals("nieuw", fields["notes"]!!.jsonPrimitive.content) + } + + @Test + fun aRefusalInsideAnHttp200IsShownAsRefusedNeverAsSaved() = runTest { + repo.refresh(SyncTrigger.START) + val item = assertIs(repo.open("s1")).item + refuseWrites = true + val result = assertIs(repo.update(item, ItemCodec.draft(item, item.type).copy(secret = "x"))) + assertEquals(WriteProblemKind.SERVER_MESSAGE, result.problem.kind) + assertEquals("You can only read this item.", result.problem.serverMessage) + // No sync after a refused write: the stored value stays. + assertEquals("PUT" to "/api/v1/secrets/s1", requests.last()) + assertEquals("geheim-1", dec(store.secret("s1")!!.key!!)) + } + + @Test + fun moveSendsOnlyTheFolderAndTrashDeletes() = runTest { + repo.refresh(SyncTrigger.START) + assertIs(repo.move("s1", "f3")) + assertEquals(JsonObject(mapOf("folderId" to JsonPrimitive("f3"))), bodies.single()) + assertIs(repo.trash("s1")) + assertTrue(("DELETE" to "/api/v1/secrets/s1") in requests) + } + + @Test + fun foldersAreCreatedRenamedAndDeleted() = runTest { + repo.refresh(SyncTrigger.START) + assertIs(repo.createFolder(" Privé ", "f1")) + assertEquals("""{"name":"Privé","parentId":"f1"}""", bodies.last().toString()) + assertIs(repo.renameFolder("f3", "Thuis")) + assertEquals("""{"name":"Thuis"}""", bodies.last().toString()) + assertEquals(FolderDeleteKind.ITEMS, repo.folderDeleteKind("f3")) + assertIs(repo.deleteFolder("f3", FolderDeleteKind.ITEMS, deleteItems = false)) + assertTrue(requests.any { it.first == "DELETE" && it.second == "/api/v1/folders/f3" }) + assertIs(repo.deleteFolder("f1", FolderDeleteKind.SUBFOLDERS, deleteItems = false)) + } + + @Test + fun offlineTheVaultReadsFromTheStoreAndRefusesEditsBeforeSending() = runTest { + repo.refresh(SyncTrigger.START) + val syncedAt = now + offline = true + now += 3_600_000 + val state = repo.refresh(SyncTrigger.FOREGROUND) + assertTrue(state.offline) + assertEquals(syncedAt, state.syncedAtMillis) + assertEquals(3, state.index.size) + val item = assertIs(repo.open("s1")).item + assertTrue(item.fromCache) + assertEquals("geheim-1", item.secret) + val sent = requests.size + val result = assertIs(repo.update(item, ItemCodec.draft(item, item.type).copy(secret = "x"))) + assertEquals(WriteProblemKind.OFFLINE, result.problem.kind) + assertIs(repo.createFolder("x", null)) + assertEquals(sent, requests.size, "nothing is sent while offline") + } + + @Test + fun withCachingOffAndNoNetworkTheVaultNeedsAConnection() = runTest { + val noStore = VaultRepository(api, keys, store = null, sync = null, clock = { now }) + assertEquals(3, noStore.refresh(SyncTrigger.START).index.size) + offline = true + val state = noStore.refresh(SyncTrigger.FOREGROUND) + assertTrue(state.needsConnection) + assertTrue(state.index.isEmpty()) + } + + @Test + fun withCachingOffTheStoreStaysEmptyAndOfflineTheVaultNeedsAConnection() = runTest { + repo.refresh(SyncTrigger.START) + cachingOff = true + val online = repo.refresh(SyncTrigger.MANUAL) + assertTrue(online.onlineOnly) + assertEquals(listOf("Huisbank"), online.index.map { it.name }) + assertTrue(store.secrets().isEmpty(), "nothing is kept on the device") + offline = true + val state = repo.refresh(SyncTrigger.FOREGROUND) + assertTrue(state.needsConnection) + assertTrue(state.index.isEmpty()) + } + + @Test + fun anEpochChangeLocksWithoutAStoreToo() = runTest { + val other = RsaVaultKeys.fromPem(privatePem, Vectors.envelope.str("certificatePem"), "suite-9", 1) + val state = VaultRepository(api, other, store = null, sync = null, clock = { now }).refresh(SyncTrigger.START) + assertEquals(LockReason.MASTER_PASSWORD_CHANGED, state.locked) + assertNotNull(repo.refresh(SyncTrigger.START)) + } + + @Test + fun writeProblemsReadTheExtensionsRules() { + fun p(status: Int, body: String) = WriteProblem.from(nl.conduction.keepiq.shared.api.KeepiqApiException(status, "x", null, body)) + assertEquals(WriteProblemKind.KEY_MIGRATION, p(423, "").kind) + assertEquals(WriteProblemKind.SUITE_BLOCKED, p(403, """{"error":"forbidden"}""").kind) + assertEquals(WriteProblem(WriteProblemKind.SERVER_MESSAGE, "Policy says no", 428), p(428, """{"code":"policy","message":"Policy says no"}""")) + assertEquals(WriteProblem(WriteProblemKind.SERVER_MESSAGE, "Name taken", 409), p(409, """{"message":"Name taken"}""")) + assertEquals(WriteProblemKind.UNREACHABLE, WriteProblem.from(IOException()).kind) + } + + @Test + fun draftsAreCheckedAsTheExtensionChecksThem() { + val type = SecretType("1", "login", null, listOf(TypeField("k", "Klantnummer", "text", true))) + val draft = ItemCodec.draft(null, type).copy(fields = listOf("url" to "x", "Klantnummer" to "y", "" to "z")) + val problems = ItemCodec.validate(draft, type) + assertEquals(DraftProblem.NAME_MISSING, problems["name"]) + assertEquals(DraftProblem.FIELD_NAME_RESERVED, problems["field-0"]) + assertEquals(DraftProblem.FIELD_NAME_TAKEN, problems["field-1"]) + assertEquals(DraftProblem.FIELD_NAME_MISSING, problems["field-2"]) + assertEquals(DraftProblem.REQUIRED, problems["typed-k"]) + val totp = ItemCodec.draft(null, SecretType("3", "totp", null, emptyList())).copy(name = "x", secret = "not base32 !") + assertEquals(DraftProblem.NOT_AN_AUTHENTICATOR_SECRET, ItemCodec.validate(totp, null)["secret"]) + } + + @Test + fun cardsKeepTheWebAppsMemberOrder() { + val type = SecretType("4", "card", null, emptyList()) + val draft = ItemCodec.draft(null, type).copy(name = "Pas", composite = mapOf("cvv" to "123", "number" to "4111111111111111")) + assertEquals( + """{"number":"4111111111111111","expiry":"","cvv":"123","pin":"","cardholder":""}""", + ItemCodec.parts(draft, type).key, + ) + } +} diff --git a/mobile/shared/src/nativeInterop/argon2/LICENSE b/mobile/shared/src/nativeInterop/argon2/LICENSE new file mode 100644 index 000000000..fa611f7ac --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/LICENSE @@ -0,0 +1,314 @@ +Argon2 reference source code package - reference C implementations + +Copyright 2015 +Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + +You may use this work under the terms of a Creative Commons CC0 1.0 +License/Waiver or the Apache Public License 2.0, at your option. The terms of +these licenses can be found at: + +- CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 +- Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + +The terms of the licenses are reproduced below. + +-------------------------------------------------------------------------------- + +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. + +-------------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/mobile/shared/src/nativeInterop/argon2/include/argon2.h b/mobile/shared/src/nativeInterop/argon2/include/argon2.h new file mode 100644 index 000000000..fc8682c2d --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/include/argon2.h @@ -0,0 +1,437 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_H +#define ARGON2_H + +#include +#include +#include + +#if defined(__cplusplus) +extern "C" { +#endif + +/* Symbols visibility control */ +#ifdef A2_VISCTL +#define ARGON2_PUBLIC __attribute__((visibility("default"))) +#define ARGON2_LOCAL __attribute__ ((visibility ("hidden"))) +#elif _MSC_VER +#define ARGON2_PUBLIC __declspec(dllexport) +#define ARGON2_LOCAL +#else +#define ARGON2_PUBLIC +#define ARGON2_LOCAL +#endif + +/* + * Argon2 input parameter restrictions + */ + +/* Minimum and maximum number of lanes (degree of parallelism) */ +#define ARGON2_MIN_LANES UINT32_C(1) +#define ARGON2_MAX_LANES UINT32_C(0xFFFFFF) + +/* Minimum and maximum number of threads */ +#define ARGON2_MIN_THREADS UINT32_C(1) +#define ARGON2_MAX_THREADS UINT32_C(0xFFFFFF) + +/* Number of synchronization points between lanes per pass */ +#define ARGON2_SYNC_POINTS UINT32_C(4) + +/* Minimum and maximum digest size in bytes */ +#define ARGON2_MIN_OUTLEN UINT32_C(4) +#define ARGON2_MAX_OUTLEN UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum number of memory blocks (each of BLOCK_SIZE bytes) */ +#define ARGON2_MIN_MEMORY (2 * ARGON2_SYNC_POINTS) /* 2 blocks per slice */ + +#define ARGON2_MIN(a, b) ((a) < (b) ? (a) : (b)) +/* Max memory size is addressing-space/2, topping at 2^32 blocks (4 TB) */ +#define ARGON2_MAX_MEMORY_BITS \ + ARGON2_MIN(UINT32_C(32), (sizeof(void *) * CHAR_BIT - 10 - 1)) +#define ARGON2_MAX_MEMORY \ + ARGON2_MIN(UINT32_C(0xFFFFFFFF), UINT64_C(1) << ARGON2_MAX_MEMORY_BITS) + +/* Minimum and maximum number of passes */ +#define ARGON2_MIN_TIME UINT32_C(1) +#define ARGON2_MAX_TIME UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum password length in bytes */ +#define ARGON2_MIN_PWD_LENGTH UINT32_C(0) +#define ARGON2_MAX_PWD_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum associated data length in bytes */ +#define ARGON2_MIN_AD_LENGTH UINT32_C(0) +#define ARGON2_MAX_AD_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum salt length in bytes */ +#define ARGON2_MIN_SALT_LENGTH UINT32_C(8) +#define ARGON2_MAX_SALT_LENGTH UINT32_C(0xFFFFFFFF) + +/* Minimum and maximum key length in bytes */ +#define ARGON2_MIN_SECRET UINT32_C(0) +#define ARGON2_MAX_SECRET UINT32_C(0xFFFFFFFF) + +/* Flags to determine which fields are securely wiped (default = no wipe). */ +#define ARGON2_DEFAULT_FLAGS UINT32_C(0) +#define ARGON2_FLAG_CLEAR_PASSWORD (UINT32_C(1) << 0) +#define ARGON2_FLAG_CLEAR_SECRET (UINT32_C(1) << 1) + +/* Global flag to determine if we are wiping internal memory buffers. This flag + * is defined in core.c and defaults to 1 (wipe internal memory). */ +extern int FLAG_clear_internal_memory; + +/* Error codes */ +typedef enum Argon2_ErrorCodes { + ARGON2_OK = 0, + + ARGON2_OUTPUT_PTR_NULL = -1, + + ARGON2_OUTPUT_TOO_SHORT = -2, + ARGON2_OUTPUT_TOO_LONG = -3, + + ARGON2_PWD_TOO_SHORT = -4, + ARGON2_PWD_TOO_LONG = -5, + + ARGON2_SALT_TOO_SHORT = -6, + ARGON2_SALT_TOO_LONG = -7, + + ARGON2_AD_TOO_SHORT = -8, + ARGON2_AD_TOO_LONG = -9, + + ARGON2_SECRET_TOO_SHORT = -10, + ARGON2_SECRET_TOO_LONG = -11, + + ARGON2_TIME_TOO_SMALL = -12, + ARGON2_TIME_TOO_LARGE = -13, + + ARGON2_MEMORY_TOO_LITTLE = -14, + ARGON2_MEMORY_TOO_MUCH = -15, + + ARGON2_LANES_TOO_FEW = -16, + ARGON2_LANES_TOO_MANY = -17, + + ARGON2_PWD_PTR_MISMATCH = -18, /* NULL ptr with non-zero length */ + ARGON2_SALT_PTR_MISMATCH = -19, /* NULL ptr with non-zero length */ + ARGON2_SECRET_PTR_MISMATCH = -20, /* NULL ptr with non-zero length */ + ARGON2_AD_PTR_MISMATCH = -21, /* NULL ptr with non-zero length */ + + ARGON2_MEMORY_ALLOCATION_ERROR = -22, + + ARGON2_FREE_MEMORY_CBK_NULL = -23, + ARGON2_ALLOCATE_MEMORY_CBK_NULL = -24, + + ARGON2_INCORRECT_PARAMETER = -25, + ARGON2_INCORRECT_TYPE = -26, + + ARGON2_OUT_PTR_MISMATCH = -27, + + ARGON2_THREADS_TOO_FEW = -28, + ARGON2_THREADS_TOO_MANY = -29, + + ARGON2_MISSING_ARGS = -30, + + ARGON2_ENCODING_FAIL = -31, + + ARGON2_DECODING_FAIL = -32, + + ARGON2_THREAD_FAIL = -33, + + ARGON2_DECODING_LENGTH_FAIL = -34, + + ARGON2_VERIFY_MISMATCH = -35 +} argon2_error_codes; + +/* Memory allocator types --- for external allocation */ +typedef int (*allocate_fptr)(uint8_t **memory, size_t bytes_to_allocate); +typedef void (*deallocate_fptr)(uint8_t *memory, size_t bytes_to_allocate); + +/* Argon2 external data structures */ + +/* + ***** + * Context: structure to hold Argon2 inputs: + * output array and its length, + * password and its length, + * salt and its length, + * secret and its length, + * associated data and its length, + * number of passes, amount of used memory (in KBytes, can be rounded up a bit) + * number of parallel threads that will be run. + * All the parameters above affect the output hash value. + * Additionally, two function pointers can be provided to allocate and + * deallocate the memory (if NULL, memory will be allocated internally). + * Also, three flags indicate whether to erase password, secret as soon as they + * are pre-hashed (and thus not needed anymore), and the entire memory + ***** + * Simplest situation: you have output array out[8], password is stored in + * pwd[32], salt is stored in salt[16], you do not have keys nor associated + * data. You need to spend 1 GB of RAM and you run 5 passes of Argon2d with + * 4 parallel lanes. + * You want to erase the password, but you're OK with last pass not being + * erased. You want to use the default memory allocator. + * Then you initialize: + Argon2_Context(out,8,pwd,32,salt,16,NULL,0,NULL,0,5,1<<20,4,4,NULL,NULL,true,false,false,false) + */ +typedef struct Argon2_Context { + uint8_t *out; /* output array */ + uint32_t outlen; /* digest length */ + + uint8_t *pwd; /* password array */ + uint32_t pwdlen; /* password length */ + + uint8_t *salt; /* salt array */ + uint32_t saltlen; /* salt length */ + + uint8_t *secret; /* key array */ + uint32_t secretlen; /* key length */ + + uint8_t *ad; /* associated data array */ + uint32_t adlen; /* associated data length */ + + uint32_t t_cost; /* number of passes */ + uint32_t m_cost; /* amount of memory requested (KB) */ + uint32_t lanes; /* number of lanes */ + uint32_t threads; /* maximum number of threads */ + + uint32_t version; /* version number */ + + allocate_fptr allocate_cbk; /* pointer to memory allocator */ + deallocate_fptr free_cbk; /* pointer to memory deallocator */ + + uint32_t flags; /* array of bool options */ +} argon2_context; + +/* Argon2 primitive type */ +typedef enum Argon2_type { + Argon2_d = 0, + Argon2_i = 1, + Argon2_id = 2 +} argon2_type; + +/* Version of the algorithm */ +typedef enum Argon2_version { + ARGON2_VERSION_10 = 0x10, + ARGON2_VERSION_13 = 0x13, + ARGON2_VERSION_NUMBER = ARGON2_VERSION_13 +} argon2_version; + +/* + * Function that gives the string representation of an argon2_type. + * @param type The argon2_type that we want the string for + * @param uppercase Whether the string should have the first letter uppercase + * @return NULL if invalid type, otherwise the string representation. + */ +ARGON2_PUBLIC const char *argon2_type2string(argon2_type type, int uppercase); + +/* + * Function that performs memory-hard hashing with certain degree of parallelism + * @param context Pointer to the Argon2 internal structure + * @return Error code if smth is wrong, ARGON2_OK otherwise + */ +ARGON2_PUBLIC int argon2_ctx(argon2_context *context, argon2_type type); + +/** + * Hashes a password with Argon2i, producing an encoded hash + * @param t_cost Number of iterations + * @param m_cost Sets memory usage to m_cost kibibytes + * @param parallelism Number of threads and compute lanes + * @param pwd Pointer to password + * @param pwdlen Password size in bytes + * @param salt Pointer to salt + * @param saltlen Salt size in bytes + * @param hashlen Desired length of the hash in bytes + * @param encoded Buffer where to write the encoded hash + * @param encodedlen Size of the buffer (thus max size of the encoded hash) + * @pre Different parallelism levels will give different results + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +/** + * Hashes a password with Argon2i, producing a raw hash at @hash + * @param t_cost Number of iterations + * @param m_cost Sets memory usage to m_cost kibibytes + * @param parallelism Number of threads and compute lanes + * @param pwd Pointer to password + * @param pwdlen Password size in bytes + * @param salt Pointer to salt + * @param saltlen Salt size in bytes + * @param hash Buffer where to write the raw hash - updated by the function + * @param hashlen Desired length of the hash in bytes + * @pre Different parallelism levels will give different results + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +ARGON2_PUBLIC int argon2d_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +ARGON2_PUBLIC int argon2d_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +ARGON2_PUBLIC int argon2id_hash_encoded(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, + const void *pwd, const size_t pwdlen, + const void *salt, const size_t saltlen, + const size_t hashlen, char *encoded, + const size_t encodedlen); + +ARGON2_PUBLIC int argon2id_hash_raw(const uint32_t t_cost, + const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, + const size_t hashlen, char *encoded, + const size_t encodedlen, argon2_type type, + const uint32_t version); + +/** + * Verifies a password against an encoded string + * Encoded string is restricted as in validate_inputs() + * @param encoded String encoding parameters, salt, hash + * @param pwd Pointer to password + * @pre Returns ARGON2_OK if successful + */ +ARGON2_PUBLIC int argon2i_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +ARGON2_PUBLIC int argon2d_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +ARGON2_PUBLIC int argon2id_verify(const char *encoded, const void *pwd, + const size_t pwdlen); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_verify(const char *encoded, const void *pwd, + const size_t pwdlen, argon2_type type); + +/** + * Argon2d: Version of Argon2 that picks memory blocks depending + * on the password and salt. Only for side-channel-free + * environment!! + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2d_ctx(argon2_context *context); + +/** + * Argon2i: Version of Argon2 that picks memory blocks + * independent on the password and salt. Good for side-channels, + * but worse w.r.t. tradeoff attacks if only one pass is used. + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2i_ctx(argon2_context *context); + +/** + * Argon2id: Version of Argon2 where the first half-pass over memory is + * password-independent, the rest are password-dependent (on the password and + * salt). OK against side channels (they reduce to 1/2-pass Argon2i), and + * better with w.r.t. tradeoff attacks (similar to Argon2d). + ***** + * @param context Pointer to current Argon2 context + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2id_ctx(argon2_context *context); + +/** + * Verify if a given password is correct for Argon2d hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2d_verify_ctx(argon2_context *context, const char *hash); + +/** + * Verify if a given password is correct for Argon2i hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2i_verify_ctx(argon2_context *context, const char *hash); + +/** + * Verify if a given password is correct for Argon2id hashing + * @param context Pointer to current Argon2 context + * @param hash The password hash to verify. The length of the hash is + * specified by the context outlen member + * @return Zero if successful, a non zero error code otherwise + */ +ARGON2_PUBLIC int argon2id_verify_ctx(argon2_context *context, + const char *hash); + +/* generic function underlying the above ones */ +ARGON2_PUBLIC int argon2_verify_ctx(argon2_context *context, const char *hash, + argon2_type type); + +/** + * Get the associated error message for given error code + * @return The error message associated with the given error code + */ +ARGON2_PUBLIC const char *argon2_error_message(int error_code); + +/** + * Returns the encoded hash length for the given input parameters + * @param t_cost Number of iterations + * @param m_cost Memory usage in kibibytes + * @param parallelism Number of threads; used to compute lanes + * @param saltlen Salt size in bytes + * @param hashlen Hash size in bytes + * @param type The argon2_type that we want the encoded length for + * @return The encoded hash length in bytes + */ +ARGON2_PUBLIC size_t argon2_encodedlen(uint32_t t_cost, uint32_t m_cost, + uint32_t parallelism, uint32_t saltlen, + uint32_t hashlen, argon2_type type); + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/argon2.c b/mobile/shared/src/nativeInterop/argon2/src/argon2.c new file mode 100644 index 000000000..795b429e7 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/argon2.c @@ -0,0 +1,452 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "argon2.h" +#include "encoding.h" +#include "core.h" + +const char *argon2_type2string(argon2_type type, int uppercase) { + switch (type) { + case Argon2_d: + return uppercase ? "Argon2d" : "argon2d"; + case Argon2_i: + return uppercase ? "Argon2i" : "argon2i"; + case Argon2_id: + return uppercase ? "Argon2id" : "argon2id"; + } + + return NULL; +} + +int argon2_ctx(argon2_context *context, argon2_type type) { + /* 1. Validate all inputs */ + int result = validate_inputs(context); + uint32_t memory_blocks, segment_length; + argon2_instance_t instance; + + if (ARGON2_OK != result) { + return result; + } + + if (Argon2_d != type && Argon2_i != type && Argon2_id != type) { + return ARGON2_INCORRECT_TYPE; + } + + /* 2. Align memory size */ + /* Minimum memory_blocks = 8L blocks, where L is the number of lanes */ + memory_blocks = context->m_cost; + + if (memory_blocks < 2 * ARGON2_SYNC_POINTS * context->lanes) { + memory_blocks = 2 * ARGON2_SYNC_POINTS * context->lanes; + } + + segment_length = memory_blocks / (context->lanes * ARGON2_SYNC_POINTS); + /* Ensure that all segments have equal length */ + memory_blocks = segment_length * (context->lanes * ARGON2_SYNC_POINTS); + + instance.version = context->version; + instance.memory = NULL; + instance.passes = context->t_cost; + instance.memory_blocks = memory_blocks; + instance.segment_length = segment_length; + instance.lane_length = segment_length * ARGON2_SYNC_POINTS; + instance.lanes = context->lanes; + instance.threads = context->threads; + instance.type = type; + + if (instance.threads > instance.lanes) { + instance.threads = instance.lanes; + } + + /* 3. Initialization: Hashing inputs, allocating memory, filling first + * blocks + */ + result = initialize(&instance, context); + + if (ARGON2_OK != result) { + return result; + } + + /* 4. Filling memory */ + result = fill_memory_blocks(&instance); + + if (ARGON2_OK != result) { + return result; + } + /* 5. Finalization */ + finalize(context, &instance); + + return ARGON2_OK; +} + +int argon2_hash(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, const size_t saltlen, + void *hash, const size_t hashlen, char *encoded, + const size_t encodedlen, argon2_type type, + const uint32_t version){ + + argon2_context context; + int result; + uint8_t *out; + + if (pwdlen > ARGON2_MAX_PWD_LENGTH) { + return ARGON2_PWD_TOO_LONG; + } + + if (saltlen > ARGON2_MAX_SALT_LENGTH) { + return ARGON2_SALT_TOO_LONG; + } + + if (hashlen > ARGON2_MAX_OUTLEN) { + return ARGON2_OUTPUT_TOO_LONG; + } + + if (hashlen < ARGON2_MIN_OUTLEN) { + return ARGON2_OUTPUT_TOO_SHORT; + } + + out = malloc(hashlen); + if (!out) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + context.out = (uint8_t *)out; + context.outlen = (uint32_t)hashlen; + context.pwd = CONST_CAST(uint8_t *)pwd; + context.pwdlen = (uint32_t)pwdlen; + context.salt = CONST_CAST(uint8_t *)salt; + context.saltlen = (uint32_t)saltlen; + context.secret = NULL; + context.secretlen = 0; + context.ad = NULL; + context.adlen = 0; + context.t_cost = t_cost; + context.m_cost = m_cost; + context.lanes = parallelism; + context.threads = parallelism; + context.allocate_cbk = NULL; + context.free_cbk = NULL; + context.flags = ARGON2_DEFAULT_FLAGS; + context.version = version; + + result = argon2_ctx(&context, type); + + if (result != ARGON2_OK) { + clear_internal_memory(out, hashlen); + free(out); + return result; + } + + /* if raw hash requested, write it */ + if (hash) { + memcpy(hash, out, hashlen); + } + + /* if encoding requested, write it */ + if (encoded && encodedlen) { + if (encode_string(encoded, encodedlen, &context, type) != ARGON2_OK) { + clear_internal_memory(out, hashlen); /* wipe buffers if error */ + clear_internal_memory(encoded, encodedlen); + free(out); + return ARGON2_ENCODING_FAIL; + } + } + clear_internal_memory(out, hashlen); + free(out); + + return ARGON2_OK; +} + +int argon2i_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_i, + ARGON2_VERSION_NUMBER); +} + +int argon2i_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_i, ARGON2_VERSION_NUMBER); +} + +int argon2d_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_d, + ARGON2_VERSION_NUMBER); +} + +int argon2d_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_d, ARGON2_VERSION_NUMBER); +} + +int argon2id_hash_encoded(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, const size_t hashlen, + char *encoded, const size_t encodedlen) { + + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + NULL, hashlen, encoded, encodedlen, Argon2_id, + ARGON2_VERSION_NUMBER); +} + +int argon2id_hash_raw(const uint32_t t_cost, const uint32_t m_cost, + const uint32_t parallelism, const void *pwd, + const size_t pwdlen, const void *salt, + const size_t saltlen, void *hash, const size_t hashlen) { + return argon2_hash(t_cost, m_cost, parallelism, pwd, pwdlen, salt, saltlen, + hash, hashlen, NULL, 0, Argon2_id, + ARGON2_VERSION_NUMBER); +} + +static int argon2_compare(const uint8_t *b1, const uint8_t *b2, size_t len) { + size_t i; + uint8_t d = 0U; + + for (i = 0U; i < len; i++) { + d |= b1[i] ^ b2[i]; + } + return (int)((1 & ((d - 1) >> 8)) - 1); +} + +int argon2_verify(const char *encoded, const void *pwd, const size_t pwdlen, + argon2_type type) { + + argon2_context ctx; + uint8_t *desired_result = NULL; + + int ret = ARGON2_OK; + + size_t encoded_len; + uint32_t max_field_len; + + if (pwdlen > ARGON2_MAX_PWD_LENGTH) { + return ARGON2_PWD_TOO_LONG; + } + + if (encoded == NULL) { + return ARGON2_DECODING_FAIL; + } + + encoded_len = strlen(encoded); + if (encoded_len > UINT32_MAX) { + return ARGON2_DECODING_FAIL; + } + + /* No field can be longer than the encoded length */ + max_field_len = (uint32_t)encoded_len; + + ctx.saltlen = max_field_len; + ctx.outlen = max_field_len; + + ctx.salt = malloc(ctx.saltlen); + ctx.out = malloc(ctx.outlen); + if (!ctx.salt || !ctx.out) { + ret = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + ctx.pwd = (uint8_t *)pwd; + ctx.pwdlen = (uint32_t)pwdlen; + + ret = decode_string(&ctx, encoded, type); + if (ret != ARGON2_OK) { + goto fail; + } + + /* Set aside the desired result, and get a new buffer. */ + desired_result = ctx.out; + ctx.out = malloc(ctx.outlen); + if (!ctx.out) { + ret = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + ret = argon2_verify_ctx(&ctx, (char *)desired_result, type); + if (ret != ARGON2_OK) { + goto fail; + } + +fail: + free(ctx.salt); + free(ctx.out); + free(desired_result); + + return ret; +} + +int argon2i_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_i); +} + +int argon2d_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_d); +} + +int argon2id_verify(const char *encoded, const void *pwd, const size_t pwdlen) { + + return argon2_verify(encoded, pwd, pwdlen, Argon2_id); +} + +int argon2d_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_d); +} + +int argon2i_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_i); +} + +int argon2id_ctx(argon2_context *context) { + return argon2_ctx(context, Argon2_id); +} + +int argon2_verify_ctx(argon2_context *context, const char *hash, + argon2_type type) { + int ret = argon2_ctx(context, type); + if (ret != ARGON2_OK) { + return ret; + } + + if (argon2_compare((uint8_t *)hash, context->out, context->outlen)) { + return ARGON2_VERIFY_MISMATCH; + } + + return ARGON2_OK; +} + +int argon2d_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_d); +} + +int argon2i_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_i); +} + +int argon2id_verify_ctx(argon2_context *context, const char *hash) { + return argon2_verify_ctx(context, hash, Argon2_id); +} + +const char *argon2_error_message(int error_code) { + switch (error_code) { + case ARGON2_OK: + return "OK"; + case ARGON2_OUTPUT_PTR_NULL: + return "Output pointer is NULL"; + case ARGON2_OUTPUT_TOO_SHORT: + return "Output is too short"; + case ARGON2_OUTPUT_TOO_LONG: + return "Output is too long"; + case ARGON2_PWD_TOO_SHORT: + return "Password is too short"; + case ARGON2_PWD_TOO_LONG: + return "Password is too long"; + case ARGON2_SALT_TOO_SHORT: + return "Salt is too short"; + case ARGON2_SALT_TOO_LONG: + return "Salt is too long"; + case ARGON2_AD_TOO_SHORT: + return "Associated data is too short"; + case ARGON2_AD_TOO_LONG: + return "Associated data is too long"; + case ARGON2_SECRET_TOO_SHORT: + return "Secret is too short"; + case ARGON2_SECRET_TOO_LONG: + return "Secret is too long"; + case ARGON2_TIME_TOO_SMALL: + return "Time cost is too small"; + case ARGON2_TIME_TOO_LARGE: + return "Time cost is too large"; + case ARGON2_MEMORY_TOO_LITTLE: + return "Memory cost is too small"; + case ARGON2_MEMORY_TOO_MUCH: + return "Memory cost is too large"; + case ARGON2_LANES_TOO_FEW: + return "Too few lanes"; + case ARGON2_LANES_TOO_MANY: + return "Too many lanes"; + case ARGON2_PWD_PTR_MISMATCH: + return "Password pointer is NULL, but password length is not 0"; + case ARGON2_SALT_PTR_MISMATCH: + return "Salt pointer is NULL, but salt length is not 0"; + case ARGON2_SECRET_PTR_MISMATCH: + return "Secret pointer is NULL, but secret length is not 0"; + case ARGON2_AD_PTR_MISMATCH: + return "Associated data pointer is NULL, but ad length is not 0"; + case ARGON2_MEMORY_ALLOCATION_ERROR: + return "Memory allocation error"; + case ARGON2_FREE_MEMORY_CBK_NULL: + return "The free memory callback is NULL"; + case ARGON2_ALLOCATE_MEMORY_CBK_NULL: + return "The allocate memory callback is NULL"; + case ARGON2_INCORRECT_PARAMETER: + return "Argon2_Context context is NULL"; + case ARGON2_INCORRECT_TYPE: + return "There is no such version of Argon2"; + case ARGON2_OUT_PTR_MISMATCH: + return "Output pointer mismatch"; + case ARGON2_THREADS_TOO_FEW: + return "Not enough threads"; + case ARGON2_THREADS_TOO_MANY: + return "Too many threads"; + case ARGON2_MISSING_ARGS: + return "Missing arguments"; + case ARGON2_ENCODING_FAIL: + return "Encoding failed"; + case ARGON2_DECODING_FAIL: + return "Decoding failed"; + case ARGON2_THREAD_FAIL: + return "Threading failure"; + case ARGON2_DECODING_LENGTH_FAIL: + return "Some of encoded parameters are too long or too short"; + case ARGON2_VERIFY_MISMATCH: + return "The password does not match the supplied hash"; + default: + return "Unknown error code"; + } +} + +size_t argon2_encodedlen(uint32_t t_cost, uint32_t m_cost, uint32_t parallelism, + uint32_t saltlen, uint32_t hashlen, argon2_type type) { + return strlen("$$v=$m=,t=,p=$$") + strlen(argon2_type2string(type, 0)) + + numlen(t_cost) + numlen(m_cost) + numlen(parallelism) + + b64len(saltlen) + b64len(hashlen) + numlen(ARGON2_VERSION_NUMBER) + 1; +} diff --git a/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2-impl.h b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2-impl.h new file mode 100644 index 000000000..241f0beb3 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2-impl.h @@ -0,0 +1,156 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef PORTABLE_BLAKE2_IMPL_H +#define PORTABLE_BLAKE2_IMPL_H + +#include +#include + +#if defined(_MSC_VER) +#define BLAKE2_INLINE __inline +#elif defined(__GNUC__) || defined(__clang__) +#define BLAKE2_INLINE __inline__ +#else +#define BLAKE2_INLINE +#endif + +/* Argon2 Team - Begin Code */ +/* + Not an exhaustive list, but should cover the majority of modern platforms + Additionally, the code will always be correct---this is only a performance + tweak. +*/ +#if (defined(__BYTE_ORDER__) && \ + (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__)) || \ + defined(__LITTLE_ENDIAN__) || defined(__ARMEL__) || defined(__MIPSEL__) || \ + defined(__AARCH64EL__) || defined(__amd64__) || defined(__i386__) || \ + defined(_M_IX86) || defined(_M_X64) || defined(_M_AMD64) || \ + defined(_M_ARM) +#define NATIVE_LITTLE_ENDIAN +#endif +/* Argon2 Team - End Code */ + +static BLAKE2_INLINE uint32_t load32(const void *src) { +#if defined(NATIVE_LITTLE_ENDIAN) + uint32_t w; + memcpy(&w, src, sizeof w); + return w; +#else + const uint8_t *p = (const uint8_t *)src; + uint32_t w = *p++; + w |= (uint32_t)(*p++) << 8; + w |= (uint32_t)(*p++) << 16; + w |= (uint32_t)(*p++) << 24; + return w; +#endif +} + +static BLAKE2_INLINE uint64_t load64(const void *src) { +#if defined(NATIVE_LITTLE_ENDIAN) + uint64_t w; + memcpy(&w, src, sizeof w); + return w; +#else + const uint8_t *p = (const uint8_t *)src; + uint64_t w = *p++; + w |= (uint64_t)(*p++) << 8; + w |= (uint64_t)(*p++) << 16; + w |= (uint64_t)(*p++) << 24; + w |= (uint64_t)(*p++) << 32; + w |= (uint64_t)(*p++) << 40; + w |= (uint64_t)(*p++) << 48; + w |= (uint64_t)(*p++) << 56; + return w; +#endif +} + +static BLAKE2_INLINE void store32(void *dst, uint32_t w) { +#if defined(NATIVE_LITTLE_ENDIAN) + memcpy(dst, &w, sizeof w); +#else + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +#endif +} + +static BLAKE2_INLINE void store64(void *dst, uint64_t w) { +#if defined(NATIVE_LITTLE_ENDIAN) + memcpy(dst, &w, sizeof w); +#else + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +#endif +} + +static BLAKE2_INLINE uint64_t load48(const void *src) { + const uint8_t *p = (const uint8_t *)src; + uint64_t w = *p++; + w |= (uint64_t)(*p++) << 8; + w |= (uint64_t)(*p++) << 16; + w |= (uint64_t)(*p++) << 24; + w |= (uint64_t)(*p++) << 32; + w |= (uint64_t)(*p++) << 40; + return w; +} + +static BLAKE2_INLINE void store48(void *dst, uint64_t w) { + uint8_t *p = (uint8_t *)dst; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; + w >>= 8; + *p++ = (uint8_t)w; +} + +static BLAKE2_INLINE uint32_t rotr32(const uint32_t w, const unsigned c) { + return (w >> c) | (w << (32 - c)); +} + +static BLAKE2_INLINE uint64_t rotr64(const uint64_t w, const unsigned c) { + return (w >> c) | (w << (64 - c)); +} + +void clear_internal_memory(void *v, size_t n); + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2.h b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2.h new file mode 100644 index 000000000..57276a776 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2.h @@ -0,0 +1,89 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef PORTABLE_BLAKE2_H +#define PORTABLE_BLAKE2_H + +#include + +#if defined(__cplusplus) +extern "C" { +#endif + +enum blake2b_constant { + BLAKE2B_BLOCKBYTES = 128, + BLAKE2B_OUTBYTES = 64, + BLAKE2B_KEYBYTES = 64, + BLAKE2B_SALTBYTES = 16, + BLAKE2B_PERSONALBYTES = 16 +}; + +#pragma pack(push, 1) +typedef struct __blake2b_param { + uint8_t digest_length; /* 1 */ + uint8_t key_length; /* 2 */ + uint8_t fanout; /* 3 */ + uint8_t depth; /* 4 */ + uint32_t leaf_length; /* 8 */ + uint64_t node_offset; /* 16 */ + uint8_t node_depth; /* 17 */ + uint8_t inner_length; /* 18 */ + uint8_t reserved[14]; /* 32 */ + uint8_t salt[BLAKE2B_SALTBYTES]; /* 48 */ + uint8_t personal[BLAKE2B_PERSONALBYTES]; /* 64 */ +} blake2b_param; +#pragma pack(pop) + +typedef struct __blake2b_state { + uint64_t h[8]; + uint64_t t[2]; + uint64_t f[2]; + uint8_t buf[BLAKE2B_BLOCKBYTES]; + unsigned buflen; + unsigned outlen; + uint8_t last_node; +} blake2b_state; + +/* Ensure param structs have not been wrongly padded */ +/* Poor man's static_assert */ +enum { + blake2_size_check_0 = 1 / !!(CHAR_BIT == 8), + blake2_size_check_2 = + 1 / !!(sizeof(blake2b_param) == sizeof(uint64_t) * CHAR_BIT) +}; + +/* Streaming API */ +ARGON2_LOCAL int blake2b_init(blake2b_state *S, size_t outlen); +ARGON2_LOCAL int blake2b_init_key(blake2b_state *S, size_t outlen, const void *key, + size_t keylen); +ARGON2_LOCAL int blake2b_init_param(blake2b_state *S, const blake2b_param *P); +ARGON2_LOCAL int blake2b_update(blake2b_state *S, const void *in, size_t inlen); +ARGON2_LOCAL int blake2b_final(blake2b_state *S, void *out, size_t outlen); + +/* Simple API */ +ARGON2_LOCAL int blake2b(void *out, size_t outlen, const void *in, size_t inlen, + const void *key, size_t keylen); + +/* Argon2 Team - Begin Code */ +ARGON2_LOCAL int blake2b_long(void *out, size_t outlen, const void *in, size_t inlen); +/* Argon2 Team - End Code */ + +#if defined(__cplusplus) +} +#endif + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2b.c b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2b.c new file mode 100644 index 000000000..ca05df598 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/blake2/blake2b.c @@ -0,0 +1,390 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "blake2.h" +#include "blake2-impl.h" + +static const uint64_t blake2b_IV[8] = { + UINT64_C(0x6a09e667f3bcc908), UINT64_C(0xbb67ae8584caa73b), + UINT64_C(0x3c6ef372fe94f82b), UINT64_C(0xa54ff53a5f1d36f1), + UINT64_C(0x510e527fade682d1), UINT64_C(0x9b05688c2b3e6c1f), + UINT64_C(0x1f83d9abfb41bd6b), UINT64_C(0x5be0cd19137e2179)}; + +static const unsigned int blake2b_sigma[12][16] = { + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, + {11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4}, + {7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8}, + {9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13}, + {2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9}, + {12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11}, + {13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10}, + {6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5}, + {10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0}, + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3}, +}; + +static BLAKE2_INLINE void blake2b_set_lastnode(blake2b_state *S) { + S->f[1] = (uint64_t)-1; +} + +static BLAKE2_INLINE void blake2b_set_lastblock(blake2b_state *S) { + if (S->last_node) { + blake2b_set_lastnode(S); + } + S->f[0] = (uint64_t)-1; +} + +static BLAKE2_INLINE void blake2b_increment_counter(blake2b_state *S, + uint64_t inc) { + S->t[0] += inc; + S->t[1] += (S->t[0] < inc); +} + +static BLAKE2_INLINE void blake2b_invalidate_state(blake2b_state *S) { + clear_internal_memory(S, sizeof(*S)); /* wipe */ + blake2b_set_lastblock(S); /* invalidate for further use */ +} + +static BLAKE2_INLINE void blake2b_init0(blake2b_state *S) { + memset(S, 0, sizeof(*S)); + memcpy(S->h, blake2b_IV, sizeof(S->h)); +} + +int blake2b_init_param(blake2b_state *S, const blake2b_param *P) { + const unsigned char *p = (const unsigned char *)P; + unsigned int i; + + if (NULL == P || NULL == S) { + return -1; + } + + blake2b_init0(S); + /* IV XOR Parameter Block */ + for (i = 0; i < 8; ++i) { + S->h[i] ^= load64(&p[i * sizeof(S->h[i])]); + } + S->outlen = P->digest_length; + return 0; +} + +/* Sequential blake2b initialization */ +int blake2b_init(blake2b_state *S, size_t outlen) { + blake2b_param P; + + if (S == NULL) { + return -1; + } + + if ((outlen == 0) || (outlen > BLAKE2B_OUTBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + /* Setup Parameter Block for unkeyed BLAKE2 */ + P.digest_length = (uint8_t)outlen; + P.key_length = 0; + P.fanout = 1; + P.depth = 1; + P.leaf_length = 0; + P.node_offset = 0; + P.node_depth = 0; + P.inner_length = 0; + memset(P.reserved, 0, sizeof(P.reserved)); + memset(P.salt, 0, sizeof(P.salt)); + memset(P.personal, 0, sizeof(P.personal)); + + return blake2b_init_param(S, &P); +} + +int blake2b_init_key(blake2b_state *S, size_t outlen, const void *key, + size_t keylen) { + blake2b_param P; + + if (S == NULL) { + return -1; + } + + if ((outlen == 0) || (outlen > BLAKE2B_OUTBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + if ((key == 0) || (keylen == 0) || (keylen > BLAKE2B_KEYBYTES)) { + blake2b_invalidate_state(S); + return -1; + } + + /* Setup Parameter Block for keyed BLAKE2 */ + P.digest_length = (uint8_t)outlen; + P.key_length = (uint8_t)keylen; + P.fanout = 1; + P.depth = 1; + P.leaf_length = 0; + P.node_offset = 0; + P.node_depth = 0; + P.inner_length = 0; + memset(P.reserved, 0, sizeof(P.reserved)); + memset(P.salt, 0, sizeof(P.salt)); + memset(P.personal, 0, sizeof(P.personal)); + + if (blake2b_init_param(S, &P) < 0) { + blake2b_invalidate_state(S); + return -1; + } + + { + uint8_t block[BLAKE2B_BLOCKBYTES]; + memset(block, 0, BLAKE2B_BLOCKBYTES); + memcpy(block, key, keylen); + blake2b_update(S, block, BLAKE2B_BLOCKBYTES); + /* Burn the key from stack */ + clear_internal_memory(block, BLAKE2B_BLOCKBYTES); + } + return 0; +} + +static void blake2b_compress(blake2b_state *S, const uint8_t *block) { + uint64_t m[16]; + uint64_t v[16]; + unsigned int i, r; + + for (i = 0; i < 16; ++i) { + m[i] = load64(block + i * sizeof(m[i])); + } + + for (i = 0; i < 8; ++i) { + v[i] = S->h[i]; + } + + v[8] = blake2b_IV[0]; + v[9] = blake2b_IV[1]; + v[10] = blake2b_IV[2]; + v[11] = blake2b_IV[3]; + v[12] = blake2b_IV[4] ^ S->t[0]; + v[13] = blake2b_IV[5] ^ S->t[1]; + v[14] = blake2b_IV[6] ^ S->f[0]; + v[15] = blake2b_IV[7] ^ S->f[1]; + +#define G(r, i, a, b, c, d) \ + do { \ + a = a + b + m[blake2b_sigma[r][2 * i + 0]]; \ + d = rotr64(d ^ a, 32); \ + c = c + d; \ + b = rotr64(b ^ c, 24); \ + a = a + b + m[blake2b_sigma[r][2 * i + 1]]; \ + d = rotr64(d ^ a, 16); \ + c = c + d; \ + b = rotr64(b ^ c, 63); \ + } while ((void)0, 0) + +#define ROUND(r) \ + do { \ + G(r, 0, v[0], v[4], v[8], v[12]); \ + G(r, 1, v[1], v[5], v[9], v[13]); \ + G(r, 2, v[2], v[6], v[10], v[14]); \ + G(r, 3, v[3], v[7], v[11], v[15]); \ + G(r, 4, v[0], v[5], v[10], v[15]); \ + G(r, 5, v[1], v[6], v[11], v[12]); \ + G(r, 6, v[2], v[7], v[8], v[13]); \ + G(r, 7, v[3], v[4], v[9], v[14]); \ + } while ((void)0, 0) + + for (r = 0; r < 12; ++r) { + ROUND(r); + } + + for (i = 0; i < 8; ++i) { + S->h[i] = S->h[i] ^ v[i] ^ v[i + 8]; + } + +#undef G +#undef ROUND +} + +int blake2b_update(blake2b_state *S, const void *in, size_t inlen) { + const uint8_t *pin = (const uint8_t *)in; + + if (inlen == 0) { + return 0; + } + + /* Sanity check */ + if (S == NULL || in == NULL) { + return -1; + } + + /* Is this a reused state? */ + if (S->f[0] != 0) { + return -1; + } + + if (S->buflen + inlen > BLAKE2B_BLOCKBYTES) { + /* Complete current block */ + size_t left = S->buflen; + size_t fill = BLAKE2B_BLOCKBYTES - left; + memcpy(&S->buf[left], pin, fill); + blake2b_increment_counter(S, BLAKE2B_BLOCKBYTES); + blake2b_compress(S, S->buf); + S->buflen = 0; + inlen -= fill; + pin += fill; + /* Avoid buffer copies when possible */ + while (inlen > BLAKE2B_BLOCKBYTES) { + blake2b_increment_counter(S, BLAKE2B_BLOCKBYTES); + blake2b_compress(S, pin); + inlen -= BLAKE2B_BLOCKBYTES; + pin += BLAKE2B_BLOCKBYTES; + } + } + memcpy(&S->buf[S->buflen], pin, inlen); + S->buflen += (unsigned int)inlen; + return 0; +} + +int blake2b_final(blake2b_state *S, void *out, size_t outlen) { + uint8_t buffer[BLAKE2B_OUTBYTES] = {0}; + unsigned int i; + + /* Sanity checks */ + if (S == NULL || out == NULL || outlen < S->outlen) { + return -1; + } + + /* Is this a reused state? */ + if (S->f[0] != 0) { + return -1; + } + + blake2b_increment_counter(S, S->buflen); + blake2b_set_lastblock(S); + memset(&S->buf[S->buflen], 0, BLAKE2B_BLOCKBYTES - S->buflen); /* Padding */ + blake2b_compress(S, S->buf); + + for (i = 0; i < 8; ++i) { /* Output full hash to temp buffer */ + store64(buffer + sizeof(S->h[i]) * i, S->h[i]); + } + + memcpy(out, buffer, S->outlen); + clear_internal_memory(buffer, sizeof(buffer)); + clear_internal_memory(S->buf, sizeof(S->buf)); + clear_internal_memory(S->h, sizeof(S->h)); + return 0; +} + +int blake2b(void *out, size_t outlen, const void *in, size_t inlen, + const void *key, size_t keylen) { + blake2b_state S; + int ret = -1; + + /* Verify parameters */ + if (NULL == in && inlen > 0) { + goto fail; + } + + if (NULL == out || outlen == 0 || outlen > BLAKE2B_OUTBYTES) { + goto fail; + } + + if ((NULL == key && keylen > 0) || keylen > BLAKE2B_KEYBYTES) { + goto fail; + } + + if (keylen > 0) { + if (blake2b_init_key(&S, outlen, key, keylen) < 0) { + goto fail; + } + } else { + if (blake2b_init(&S, outlen) < 0) { + goto fail; + } + } + + if (blake2b_update(&S, in, inlen) < 0) { + goto fail; + } + ret = blake2b_final(&S, out, outlen); + +fail: + clear_internal_memory(&S, sizeof(S)); + return ret; +} + +/* Argon2 Team - Begin Code */ +int blake2b_long(void *pout, size_t outlen, const void *in, size_t inlen) { + uint8_t *out = (uint8_t *)pout; + blake2b_state blake_state; + uint8_t outlen_bytes[sizeof(uint32_t)] = {0}; + int ret = -1; + + if (outlen > UINT32_MAX) { + goto fail; + } + + /* Ensure little-endian byte order! */ + store32(outlen_bytes, (uint32_t)outlen); + +#define TRY(statement) \ + do { \ + ret = statement; \ + if (ret < 0) { \ + goto fail; \ + } \ + } while ((void)0, 0) + + if (outlen <= BLAKE2B_OUTBYTES) { + TRY(blake2b_init(&blake_state, outlen)); + TRY(blake2b_update(&blake_state, outlen_bytes, sizeof(outlen_bytes))); + TRY(blake2b_update(&blake_state, in, inlen)); + TRY(blake2b_final(&blake_state, out, outlen)); + } else { + uint32_t toproduce; + uint8_t out_buffer[BLAKE2B_OUTBYTES]; + uint8_t in_buffer[BLAKE2B_OUTBYTES]; + TRY(blake2b_init(&blake_state, BLAKE2B_OUTBYTES)); + TRY(blake2b_update(&blake_state, outlen_bytes, sizeof(outlen_bytes))); + TRY(blake2b_update(&blake_state, in, inlen)); + TRY(blake2b_final(&blake_state, out_buffer, BLAKE2B_OUTBYTES)); + memcpy(out, out_buffer, BLAKE2B_OUTBYTES / 2); + out += BLAKE2B_OUTBYTES / 2; + toproduce = (uint32_t)outlen - BLAKE2B_OUTBYTES / 2; + + while (toproduce > BLAKE2B_OUTBYTES) { + memcpy(in_buffer, out_buffer, BLAKE2B_OUTBYTES); + TRY(blake2b(out_buffer, BLAKE2B_OUTBYTES, in_buffer, + BLAKE2B_OUTBYTES, NULL, 0)); + memcpy(out, out_buffer, BLAKE2B_OUTBYTES / 2); + out += BLAKE2B_OUTBYTES / 2; + toproduce -= BLAKE2B_OUTBYTES / 2; + } + + memcpy(in_buffer, out_buffer, BLAKE2B_OUTBYTES); + TRY(blake2b(out_buffer, toproduce, in_buffer, BLAKE2B_OUTBYTES, NULL, + 0)); + memcpy(out, out_buffer, toproduce); + } +fail: + clear_internal_memory(&blake_state, sizeof(blake_state)); + return ret; +#undef TRY +} +/* Argon2 Team - End Code */ diff --git a/mobile/shared/src/nativeInterop/argon2/src/blake2/blamka-round-ref.h b/mobile/shared/src/nativeInterop/argon2/src/blake2/blamka-round-ref.h new file mode 100644 index 000000000..b8f2cf471 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/blake2/blamka-round-ref.h @@ -0,0 +1,56 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef BLAKE_ROUND_MKA_H +#define BLAKE_ROUND_MKA_H + +#include "blake2.h" +#include "blake2-impl.h" + +/* designed by the Lyra PHC team */ +static BLAKE2_INLINE uint64_t fBlaMka(uint64_t x, uint64_t y) { + const uint64_t m = UINT64_C(0xFFFFFFFF); + const uint64_t xy = (x & m) * (y & m); + return x + y + 2 * xy; +} + +#define G(a, b, c, d) \ + do { \ + a = fBlaMka(a, b); \ + d = rotr64(d ^ a, 32); \ + c = fBlaMka(c, d); \ + b = rotr64(b ^ c, 24); \ + a = fBlaMka(a, b); \ + d = rotr64(d ^ a, 16); \ + c = fBlaMka(c, d); \ + b = rotr64(b ^ c, 63); \ + } while ((void)0, 0) + +#define BLAKE2_ROUND_NOMSG(v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, \ + v12, v13, v14, v15) \ + do { \ + G(v0, v4, v8, v12); \ + G(v1, v5, v9, v13); \ + G(v2, v6, v10, v14); \ + G(v3, v7, v11, v15); \ + G(v0, v5, v10, v15); \ + G(v1, v6, v11, v12); \ + G(v2, v7, v8, v13); \ + G(v3, v4, v9, v14); \ + } while ((void)0, 0) + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/core.c b/mobile/shared/src/nativeInterop/argon2/src/core.c new file mode 100644 index 000000000..65f053769 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/core.c @@ -0,0 +1,648 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +/*For memory wiping*/ +#ifdef _MSC_VER +#include +#include /* For SecureZeroMemory */ +#endif +#if defined __STDC_LIB_EXT1__ +#define __STDC_WANT_LIB_EXT1__ 1 +#endif +#define VC_GE_2005(version) (version >= 1400) + +/* for explicit_bzero() on glibc */ +#define _DEFAULT_SOURCE + +#include +#include +#include + +#include "core.h" +#include "thread.h" +#include "blake2/blake2.h" +#include "blake2/blake2-impl.h" + +#ifdef GENKAT +#include "genkat.h" +#endif + +#if defined(__clang__) +#if __has_attribute(optnone) +#define NOT_OPTIMIZED __attribute__((optnone)) +#endif +#elif defined(__GNUC__) +#define GCC_VERSION \ + (__GNUC__ * 10000 + __GNUC_MINOR__ * 100 + __GNUC_PATCHLEVEL__) +#if GCC_VERSION >= 40400 +#define NOT_OPTIMIZED __attribute__((optimize("O0"))) +#endif +#endif +#ifndef NOT_OPTIMIZED +#define NOT_OPTIMIZED +#endif + +/***************Instance and Position constructors**********/ +void init_block_value(block *b, uint8_t in) { memset(b->v, in, sizeof(b->v)); } + +void copy_block(block *dst, const block *src) { + memcpy(dst->v, src->v, sizeof(uint64_t) * ARGON2_QWORDS_IN_BLOCK); +} + +void xor_block(block *dst, const block *src) { + int i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + dst->v[i] ^= src->v[i]; + } +} + +static void load_block(block *dst, const void *input) { + unsigned i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + dst->v[i] = load64((const uint8_t *)input + i * sizeof(dst->v[i])); + } +} + +static void store_block(void *output, const block *src) { + unsigned i; + for (i = 0; i < ARGON2_QWORDS_IN_BLOCK; ++i) { + store64((uint8_t *)output + i * sizeof(src->v[i]), src->v[i]); + } +} + +/***************Memory functions*****************/ + +int allocate_memory(const argon2_context *context, uint8_t **memory, + size_t num, size_t size) { + size_t memory_size = num*size; + if (memory == NULL) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + /* 1. Check for multiplication overflow */ + if (size != 0 && memory_size / size != num) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + /* 2. Try to allocate with appropriate allocator */ + if (context->allocate_cbk) { + (context->allocate_cbk)(memory, memory_size); + } else { + *memory = malloc(memory_size); + } + + if (*memory == NULL) { + return ARGON2_MEMORY_ALLOCATION_ERROR; + } + + return ARGON2_OK; +} + +void free_memory(const argon2_context *context, uint8_t *memory, + size_t num, size_t size) { + size_t memory_size = num*size; + clear_internal_memory(memory, memory_size); + if (context->free_cbk) { + (context->free_cbk)(memory, memory_size); + } else { + free(memory); + } +} + +#if defined(__OpenBSD__) +#define HAVE_EXPLICIT_BZERO 1 +#elif defined(__GLIBC__) && defined(__GLIBC_PREREQ) +#if __GLIBC_PREREQ(2,25) +#define HAVE_EXPLICIT_BZERO 1 +#endif +#endif + +void NOT_OPTIMIZED secure_wipe_memory(void *v, size_t n) { +#if defined(_MSC_VER) && VC_GE_2005(_MSC_VER) + SecureZeroMemory(v, n); +#elif defined memset_s + memset_s(v, n, 0, n); +#elif defined(HAVE_EXPLICIT_BZERO) + explicit_bzero(v, n); +#else + static void *(*const volatile memset_sec)(void *, int, size_t) = &memset; + memset_sec(v, 0, n); +#endif +} + +/* Memory clear flag defaults to true. */ +int FLAG_clear_internal_memory = 1; +void clear_internal_memory(void *v, size_t n) { + if (FLAG_clear_internal_memory && v) { + secure_wipe_memory(v, n); + } +} + +void finalize(const argon2_context *context, argon2_instance_t *instance) { + if (context != NULL && instance != NULL) { + block blockhash; + uint32_t l; + + copy_block(&blockhash, instance->memory + instance->lane_length - 1); + + /* XOR the last blocks */ + for (l = 1; l < instance->lanes; ++l) { + uint32_t last_block_in_lane = + l * instance->lane_length + (instance->lane_length - 1); + xor_block(&blockhash, instance->memory + last_block_in_lane); + } + + /* Hash the result */ + { + uint8_t blockhash_bytes[ARGON2_BLOCK_SIZE]; + store_block(blockhash_bytes, &blockhash); + blake2b_long(context->out, context->outlen, blockhash_bytes, + ARGON2_BLOCK_SIZE); + /* clear blockhash and blockhash_bytes */ + clear_internal_memory(blockhash.v, ARGON2_BLOCK_SIZE); + clear_internal_memory(blockhash_bytes, ARGON2_BLOCK_SIZE); + } + +#ifdef GENKAT + print_tag(context->out, context->outlen); +#endif + + free_memory(context, (uint8_t *)instance->memory, + instance->memory_blocks, sizeof(block)); + } +} + +uint32_t index_alpha(const argon2_instance_t *instance, + const argon2_position_t *position, uint32_t pseudo_rand, + int same_lane) { + /* + * Pass 0: + * This lane : all already finished segments plus already constructed + * blocks in this segment + * Other lanes : all already finished segments + * Pass 1+: + * This lane : (SYNC_POINTS - 1) last segments plus already constructed + * blocks in this segment + * Other lanes : (SYNC_POINTS - 1) last segments + */ + uint32_t reference_area_size; + uint64_t relative_position; + uint32_t start_position, absolute_position; + + if (0 == position->pass) { + /* First pass */ + if (0 == position->slice) { + /* First slice */ + reference_area_size = + position->index - 1; /* all but the previous */ + } else { + if (same_lane) { + /* The same lane => add current segment */ + reference_area_size = + position->slice * instance->segment_length + + position->index - 1; + } else { + reference_area_size = + position->slice * instance->segment_length + + ((position->index == 0) ? (-1) : 0); + } + } + } else { + /* Second pass */ + if (same_lane) { + reference_area_size = instance->lane_length - + instance->segment_length + position->index - + 1; + } else { + reference_area_size = instance->lane_length - + instance->segment_length + + ((position->index == 0) ? (-1) : 0); + } + } + + /* 1.2.4. Mapping pseudo_rand to 0.. and produce + * relative position */ + relative_position = pseudo_rand; + relative_position = relative_position * relative_position >> 32; + relative_position = reference_area_size - 1 - + (reference_area_size * relative_position >> 32); + + /* 1.2.5 Computing starting position */ + start_position = 0; + + if (0 != position->pass) { + start_position = (position->slice == ARGON2_SYNC_POINTS - 1) + ? 0 + : (position->slice + 1) * instance->segment_length; + } + + /* 1.2.6. Computing absolute position */ + absolute_position = (start_position + relative_position) % + instance->lane_length; /* absolute position */ + return absolute_position; +} + +/* Single-threaded version for p=1 case */ +static int fill_memory_blocks_st(argon2_instance_t *instance) { + uint32_t r, s, l; + + for (r = 0; r < instance->passes; ++r) { + for (s = 0; s < ARGON2_SYNC_POINTS; ++s) { + for (l = 0; l < instance->lanes; ++l) { + argon2_position_t position = {r, l, (uint8_t)s, 0}; + fill_segment(instance, position); + } + } +#ifdef GENKAT + internal_kat(instance, r); /* Print all memory blocks */ +#endif + } + return ARGON2_OK; +} + +#if !defined(ARGON2_NO_THREADS) + +#ifdef _WIN32 +static unsigned __stdcall fill_segment_thr(void *thread_data) +#else +static void *fill_segment_thr(void *thread_data) +#endif +{ + argon2_thread_data *my_data = thread_data; + fill_segment(my_data->instance_ptr, my_data->pos); + argon2_thread_exit(); + return 0; +} + +/* Multi-threaded version for p > 1 case */ +static int fill_memory_blocks_mt(argon2_instance_t *instance) { + uint32_t r, s; + argon2_thread_handle_t *thread = NULL; + argon2_thread_data *thr_data = NULL; + int rc = ARGON2_OK; + + /* 1. Allocating space for threads */ + thread = calloc(instance->lanes, sizeof(argon2_thread_handle_t)); + if (thread == NULL) { + rc = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + thr_data = calloc(instance->lanes, sizeof(argon2_thread_data)); + if (thr_data == NULL) { + rc = ARGON2_MEMORY_ALLOCATION_ERROR; + goto fail; + } + + for (r = 0; r < instance->passes; ++r) { + for (s = 0; s < ARGON2_SYNC_POINTS; ++s) { + uint32_t l, ll; + + /* 2. Calling threads */ + for (l = 0; l < instance->lanes; ++l) { + argon2_position_t position; + + /* 2.1 Join a thread if limit is exceeded */ + if (l >= instance->threads) { + if (argon2_thread_join(thread[l - instance->threads])) { + rc = ARGON2_THREAD_FAIL; + goto fail; + } + } + + /* 2.2 Create thread */ + position.pass = r; + position.lane = l; + position.slice = (uint8_t)s; + position.index = 0; + thr_data[l].instance_ptr = + instance; /* preparing the thread input */ + memcpy(&(thr_data[l].pos), &position, + sizeof(argon2_position_t)); + if (argon2_thread_create(&thread[l], &fill_segment_thr, + (void *)&thr_data[l])) { + /* Wait for already running threads */ + for (ll = 0; ll < l; ++ll) + argon2_thread_join(thread[ll]); + rc = ARGON2_THREAD_FAIL; + goto fail; + } + + /* fill_segment(instance, position); */ + /*Non-thread equivalent of the lines above */ + } + + /* 3. Joining remaining threads */ + for (l = instance->lanes - instance->threads; l < instance->lanes; + ++l) { + if (argon2_thread_join(thread[l])) { + rc = ARGON2_THREAD_FAIL; + goto fail; + } + } + } + +#ifdef GENKAT + internal_kat(instance, r); /* Print all memory blocks */ +#endif + } + +fail: + if (thread != NULL) { + free(thread); + } + if (thr_data != NULL) { + free(thr_data); + } + return rc; +} + +#endif /* ARGON2_NO_THREADS */ + +int fill_memory_blocks(argon2_instance_t *instance) { + if (instance == NULL || instance->lanes == 0) { + return ARGON2_INCORRECT_PARAMETER; + } +#if defined(ARGON2_NO_THREADS) + return fill_memory_blocks_st(instance); +#else + return instance->threads == 1 ? + fill_memory_blocks_st(instance) : fill_memory_blocks_mt(instance); +#endif +} + +int validate_inputs(const argon2_context *context) { + if (NULL == context) { + return ARGON2_INCORRECT_PARAMETER; + } + + if (NULL == context->out) { + return ARGON2_OUTPUT_PTR_NULL; + } + + /* Validate output length */ + if (ARGON2_MIN_OUTLEN > context->outlen) { + return ARGON2_OUTPUT_TOO_SHORT; + } + + if (ARGON2_MAX_OUTLEN < context->outlen) { + return ARGON2_OUTPUT_TOO_LONG; + } + + /* Validate password (required param) */ + if (NULL == context->pwd) { + if (0 != context->pwdlen) { + return ARGON2_PWD_PTR_MISMATCH; + } + } + + if (ARGON2_MIN_PWD_LENGTH > context->pwdlen) { + return ARGON2_PWD_TOO_SHORT; + } + + if (ARGON2_MAX_PWD_LENGTH < context->pwdlen) { + return ARGON2_PWD_TOO_LONG; + } + + /* Validate salt (required param) */ + if (NULL == context->salt) { + if (0 != context->saltlen) { + return ARGON2_SALT_PTR_MISMATCH; + } + } + + if (ARGON2_MIN_SALT_LENGTH > context->saltlen) { + return ARGON2_SALT_TOO_SHORT; + } + + if (ARGON2_MAX_SALT_LENGTH < context->saltlen) { + return ARGON2_SALT_TOO_LONG; + } + + /* Validate secret (optional param) */ + if (NULL == context->secret) { + if (0 != context->secretlen) { + return ARGON2_SECRET_PTR_MISMATCH; + } + } else { + if (ARGON2_MIN_SECRET > context->secretlen) { + return ARGON2_SECRET_TOO_SHORT; + } + if (ARGON2_MAX_SECRET < context->secretlen) { + return ARGON2_SECRET_TOO_LONG; + } + } + + /* Validate associated data (optional param) */ + if (NULL == context->ad) { + if (0 != context->adlen) { + return ARGON2_AD_PTR_MISMATCH; + } + } else { + if (ARGON2_MIN_AD_LENGTH > context->adlen) { + return ARGON2_AD_TOO_SHORT; + } + if (ARGON2_MAX_AD_LENGTH < context->adlen) { + return ARGON2_AD_TOO_LONG; + } + } + + /* Validate memory cost */ + if (ARGON2_MIN_MEMORY > context->m_cost) { + return ARGON2_MEMORY_TOO_LITTLE; + } + + if (ARGON2_MAX_MEMORY < context->m_cost) { + return ARGON2_MEMORY_TOO_MUCH; + } + + if (context->m_cost < 8 * context->lanes) { + return ARGON2_MEMORY_TOO_LITTLE; + } + + /* Validate time cost */ + if (ARGON2_MIN_TIME > context->t_cost) { + return ARGON2_TIME_TOO_SMALL; + } + + if (ARGON2_MAX_TIME < context->t_cost) { + return ARGON2_TIME_TOO_LARGE; + } + + /* Validate lanes */ + if (ARGON2_MIN_LANES > context->lanes) { + return ARGON2_LANES_TOO_FEW; + } + + if (ARGON2_MAX_LANES < context->lanes) { + return ARGON2_LANES_TOO_MANY; + } + + /* Validate threads */ + if (ARGON2_MIN_THREADS > context->threads) { + return ARGON2_THREADS_TOO_FEW; + } + + if (ARGON2_MAX_THREADS < context->threads) { + return ARGON2_THREADS_TOO_MANY; + } + + if (NULL != context->allocate_cbk && NULL == context->free_cbk) { + return ARGON2_FREE_MEMORY_CBK_NULL; + } + + if (NULL == context->allocate_cbk && NULL != context->free_cbk) { + return ARGON2_ALLOCATE_MEMORY_CBK_NULL; + } + + return ARGON2_OK; +} + +void fill_first_blocks(uint8_t *blockhash, const argon2_instance_t *instance) { + uint32_t l; + /* Make the first and second block in each lane as G(H0||0||i) or + G(H0||1||i) */ + uint8_t blockhash_bytes[ARGON2_BLOCK_SIZE]; + for (l = 0; l < instance->lanes; ++l) { + + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, 0); + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH + 4, l); + blake2b_long(blockhash_bytes, ARGON2_BLOCK_SIZE, blockhash, + ARGON2_PREHASH_SEED_LENGTH); + load_block(&instance->memory[l * instance->lane_length + 0], + blockhash_bytes); + + store32(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, 1); + blake2b_long(blockhash_bytes, ARGON2_BLOCK_SIZE, blockhash, + ARGON2_PREHASH_SEED_LENGTH); + load_block(&instance->memory[l * instance->lane_length + 1], + blockhash_bytes); + } + clear_internal_memory(blockhash_bytes, ARGON2_BLOCK_SIZE); +} + +void initial_hash(uint8_t *blockhash, argon2_context *context, + argon2_type type) { + blake2b_state BlakeHash; + uint8_t value[sizeof(uint32_t)]; + + if (NULL == context || NULL == blockhash) { + return; + } + + blake2b_init(&BlakeHash, ARGON2_PREHASH_DIGEST_LENGTH); + + store32(&value, context->lanes); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->outlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->m_cost); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->t_cost); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->version); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, (uint32_t)type); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + store32(&value, context->pwdlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->pwd != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->pwd, + context->pwdlen); + + if (context->flags & ARGON2_FLAG_CLEAR_PASSWORD) { + secure_wipe_memory(context->pwd, context->pwdlen); + context->pwdlen = 0; + } + } + + store32(&value, context->saltlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->salt != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->salt, + context->saltlen); + } + + store32(&value, context->secretlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->secret != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->secret, + context->secretlen); + + if (context->flags & ARGON2_FLAG_CLEAR_SECRET) { + secure_wipe_memory(context->secret, context->secretlen); + context->secretlen = 0; + } + } + + store32(&value, context->adlen); + blake2b_update(&BlakeHash, (const uint8_t *)&value, sizeof(value)); + + if (context->ad != NULL) { + blake2b_update(&BlakeHash, (const uint8_t *)context->ad, + context->adlen); + } + + blake2b_final(&BlakeHash, blockhash, ARGON2_PREHASH_DIGEST_LENGTH); +} + +int initialize(argon2_instance_t *instance, argon2_context *context) { + uint8_t blockhash[ARGON2_PREHASH_SEED_LENGTH]; + int result = ARGON2_OK; + + if (instance == NULL || context == NULL) + return ARGON2_INCORRECT_PARAMETER; + instance->context_ptr = context; + + /* 1. Memory allocation */ + result = allocate_memory(context, (uint8_t **)&(instance->memory), + instance->memory_blocks, sizeof(block)); + if (result != ARGON2_OK) { + return result; + } + + /* 2. Initial hashing */ + /* H_0 + 8 extra bytes to produce the first blocks */ + /* uint8_t blockhash[ARGON2_PREHASH_SEED_LENGTH]; */ + /* Hashing all inputs */ + initial_hash(blockhash, context, instance->type); + /* Zeroing 8 extra bytes */ + clear_internal_memory(blockhash + ARGON2_PREHASH_DIGEST_LENGTH, + ARGON2_PREHASH_SEED_LENGTH - + ARGON2_PREHASH_DIGEST_LENGTH); + +#ifdef GENKAT + initial_kat(blockhash, context, instance->type); +#endif + + /* 3. Creating first blocks, we always have at least two blocks in a slice + */ + fill_first_blocks(blockhash, instance); + /* Clearing the hash */ + clear_internal_memory(blockhash, ARGON2_PREHASH_SEED_LENGTH); + + return ARGON2_OK; +} diff --git a/mobile/shared/src/nativeInterop/argon2/src/core.h b/mobile/shared/src/nativeInterop/argon2/src/core.h new file mode 100644 index 000000000..78000ba9e --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/core.h @@ -0,0 +1,228 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_CORE_H +#define ARGON2_CORE_H + +#include "argon2.h" + +#define CONST_CAST(x) (x)(uintptr_t) + +/**********************Argon2 internal constants*******************************/ + +enum argon2_core_constants { + /* Memory block size in bytes */ + ARGON2_BLOCK_SIZE = 1024, + ARGON2_QWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 8, + ARGON2_OWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 16, + ARGON2_HWORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 32, + ARGON2_512BIT_WORDS_IN_BLOCK = ARGON2_BLOCK_SIZE / 64, + + /* Number of pseudo-random values generated by one call to Blake in Argon2i + to + generate reference block positions */ + ARGON2_ADDRESSES_IN_BLOCK = 128, + + /* Pre-hashing digest length and its extension*/ + ARGON2_PREHASH_DIGEST_LENGTH = 64, + ARGON2_PREHASH_SEED_LENGTH = 72 +}; + +/*************************Argon2 internal data types***********************/ + +/* + * Structure for the (1KB) memory block implemented as 128 64-bit words. + * Memory blocks can be copied, XORed. Internal words can be accessed by [] (no + * bounds checking). + */ +typedef struct block_ { uint64_t v[ARGON2_QWORDS_IN_BLOCK]; } block; + +/*****************Functions that work with the block******************/ + +/* Initialize each byte of the block with @in */ +void init_block_value(block *b, uint8_t in); + +/* Copy block @src to block @dst */ +void copy_block(block *dst, const block *src); + +/* XOR @src onto @dst bytewise */ +void xor_block(block *dst, const block *src); + +/* + * Argon2 instance: memory pointer, number of passes, amount of memory, type, + * and derived values. + * Used to evaluate the number and location of blocks to construct in each + * thread + */ +typedef struct Argon2_instance_t { + block *memory; /* Memory pointer */ + uint32_t version; + uint32_t passes; /* Number of passes */ + uint32_t memory_blocks; /* Number of blocks in memory */ + uint32_t segment_length; + uint32_t lane_length; + uint32_t lanes; + uint32_t threads; + argon2_type type; + int print_internals; /* whether to print the memory blocks */ + argon2_context *context_ptr; /* points back to original context */ +} argon2_instance_t; + +/* + * Argon2 position: where we construct the block right now. Used to distribute + * work between threads. + */ +typedef struct Argon2_position_t { + uint32_t pass; + uint32_t lane; + uint8_t slice; + uint32_t index; +} argon2_position_t; + +/*Struct that holds the inputs for thread handling FillSegment*/ +typedef struct Argon2_thread_data { + argon2_instance_t *instance_ptr; + argon2_position_t pos; +} argon2_thread_data; + +/*************************Argon2 core functions********************************/ + +/* Allocates memory to the given pointer, uses the appropriate allocator as + * specified in the context. Total allocated memory is num*size. + * @param context argon2_context which specifies the allocator + * @param memory pointer to the pointer to the memory + * @param size the size in bytes for each element to be allocated + * @param num the number of elements to be allocated + * @return ARGON2_OK if @memory is a valid pointer and memory is allocated + */ +int allocate_memory(const argon2_context *context, uint8_t **memory, + size_t num, size_t size); + +/* + * Frees memory at the given pointer, uses the appropriate deallocator as + * specified in the context. Also cleans the memory using clear_internal_memory. + * @param context argon2_context which specifies the deallocator + * @param memory pointer to buffer to be freed + * @param size the size in bytes for each element to be deallocated + * @param num the number of elements to be deallocated + */ +void free_memory(const argon2_context *context, uint8_t *memory, + size_t num, size_t size); + +/* Function that securely cleans the memory. This ignores any flags set + * regarding clearing memory. Usually one just calls clear_internal_memory. + * @param mem Pointer to the memory + * @param s Memory size in bytes + */ +void secure_wipe_memory(void *v, size_t n); + +/* Function that securely clears the memory if FLAG_clear_internal_memory is + * set. If the flag isn't set, this function does nothing. + * @param mem Pointer to the memory + * @param s Memory size in bytes + */ +void clear_internal_memory(void *v, size_t n); + +/* + * Computes absolute position of reference block in the lane following a skewed + * distribution and using a pseudo-random value as input + * @param instance Pointer to the current instance + * @param position Pointer to the current position + * @param pseudo_rand 32-bit pseudo-random value used to determine the position + * @param same_lane Indicates if the block will be taken from the current lane. + * If so we can reference the current segment + * @pre All pointers must be valid + */ +uint32_t index_alpha(const argon2_instance_t *instance, + const argon2_position_t *position, uint32_t pseudo_rand, + int same_lane); + +/* + * Function that validates all inputs against predefined restrictions and return + * an error code + * @param context Pointer to current Argon2 context + * @return ARGON2_OK if everything is all right, otherwise one of error codes + * (all defined in + */ +int validate_inputs(const argon2_context *context); + +/* + * Hashes all the inputs into @a blockhash[PREHASH_DIGEST_LENGTH], clears + * password and secret if needed + * @param context Pointer to the Argon2 internal structure containing memory + * pointer, and parameters for time and space requirements. + * @param blockhash Buffer for pre-hashing digest + * @param type Argon2 type + * @pre @a blockhash must have at least @a PREHASH_DIGEST_LENGTH bytes + * allocated + */ +void initial_hash(uint8_t *blockhash, argon2_context *context, + argon2_type type); + +/* + * Function creates first 2 blocks per lane + * @param instance Pointer to the current instance + * @param blockhash Pointer to the pre-hashing digest + * @pre blockhash must point to @a PREHASH_SEED_LENGTH allocated values + */ +void fill_first_blocks(uint8_t *blockhash, const argon2_instance_t *instance); + +/* + * Function allocates memory, hashes the inputs with Blake, and creates first + * two blocks. Returns the pointer to the main memory with 2 blocks per lane + * initialized + * @param context Pointer to the Argon2 internal structure containing memory + * pointer, and parameters for time and space requirements. + * @param instance Current Argon2 instance + * @return Zero if successful, -1 if memory failed to allocate. @context->state + * will be modified if successful. + */ +int initialize(argon2_instance_t *instance, argon2_context *context); + +/* + * XORing the last block of each lane, hashing it, making the tag. Deallocates + * the memory. + * @param context Pointer to current Argon2 context (use only the out parameters + * from it) + * @param instance Pointer to current instance of Argon2 + * @pre instance->state must point to necessary amount of memory + * @pre context->out must point to outlen bytes of memory + * @pre if context->free_cbk is not NULL, it should point to a function that + * deallocates memory + */ +void finalize(const argon2_context *context, argon2_instance_t *instance); + +/* + * Function that fills the segment using previous segments also from other + * threads + * @param context current context + * @param instance Pointer to the current instance + * @param position Current position + * @pre all block pointers must be valid + */ +void fill_segment(const argon2_instance_t *instance, + argon2_position_t position); + +/* + * Function that fills the entire memory t_cost times based on the first two + * blocks in each lane + * @param instance Pointer to the current instance + * @return ARGON2_OK if successful, @context->state + */ +int fill_memory_blocks(argon2_instance_t *instance); + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/encoding.c b/mobile/shared/src/nativeInterop/argon2/src/encoding.c new file mode 100644 index 000000000..12cfda4d0 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/encoding.c @@ -0,0 +1,463 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include +#include +#include "encoding.h" +#include "core.h" + +/* + * Example code for a decoder and encoder of "hash strings", with Argon2 + * parameters. + * + * This code comprises three sections: + * + * -- The first section contains generic Base64 encoding and decoding + * functions. It is conceptually applicable to any hash function + * implementation that uses Base64 to encode and decode parameters, + * salts and outputs. It could be made into a library, provided that + * the relevant functions are made public (non-static) and be given + * reasonable names to avoid collisions with other functions. + * + * -- The second section is specific to Argon2. It encodes and decodes + * the parameters, salts and outputs. It does not compute the hash + * itself. + * + * The code was originally written by Thomas Pornin , + * to whom comments and remarks may be sent. It is released under what + * should amount to Public Domain or its closest equivalent; the + * following mantra is supposed to incarnate that fact with all the + * proper legal rituals: + * + * --------------------------------------------------------------------- + * This file is provided under the terms of Creative Commons CC0 1.0 + * Public Domain Dedication. To the extent possible under law, the + * author (Thomas Pornin) has waived all copyright and related or + * neighboring rights to this file. This work is published from: Canada. + * --------------------------------------------------------------------- + * + * Copyright (c) 2015 Thomas Pornin + */ + +/* ==================================================================== */ +/* + * Common code; could be shared between different hash functions. + * + * Note: the Base64 functions below assume that uppercase letters (resp. + * lowercase letters) have consecutive numerical codes, that fit on 8 + * bits. All modern systems use ASCII-compatible charsets, where these + * properties are true. If you are stuck with a dinosaur of a system + * that still defaults to EBCDIC then you already have much bigger + * interoperability issues to deal with. + */ + +/* + * Some macros for constant-time comparisons. These work over values in + * the 0..255 range. Returned value is 0x00 on "false", 0xFF on "true". + */ +#define EQ(x, y) ((((0U - ((unsigned)(x) ^ (unsigned)(y))) >> 8) & 0xFF) ^ 0xFF) +#define GT(x, y) ((((unsigned)(y) - (unsigned)(x)) >> 8) & 0xFF) +#define GE(x, y) (GT(y, x) ^ 0xFF) +#define LT(x, y) GT(y, x) +#define LE(x, y) GE(y, x) + +/* + * Convert value x (0..63) to corresponding Base64 character. + */ +static int b64_byte_to_char(unsigned x) { + return (LT(x, 26) & (x + 'A')) | + (GE(x, 26) & LT(x, 52) & (x + ('a' - 26))) | + (GE(x, 52) & LT(x, 62) & (x + ('0' - 52))) | (EQ(x, 62) & '+') | + (EQ(x, 63) & '/'); +} + +/* + * Convert character c to the corresponding 6-bit value. If character c + * is not a Base64 character, then 0xFF (255) is returned. + */ +static unsigned b64_char_to_byte(int c) { + unsigned x; + + x = (GE(c, 'A') & LE(c, 'Z') & (c - 'A')) | + (GE(c, 'a') & LE(c, 'z') & (c - ('a' - 26))) | + (GE(c, '0') & LE(c, '9') & (c - ('0' - 52))) | (EQ(c, '+') & 62) | + (EQ(c, '/') & 63); + return x | (EQ(x, 0) & (EQ(c, 'A') ^ 0xFF)); +} + +/* + * Convert some bytes to Base64. 'dst_len' is the length (in characters) + * of the output buffer 'dst'; if that buffer is not large enough to + * receive the result (including the terminating 0), then (size_t)-1 + * is returned. Otherwise, the zero-terminated Base64 string is written + * in the buffer, and the output length (counted WITHOUT the terminating + * zero) is returned. + */ +static size_t to_base64(char *dst, size_t dst_len, const void *src, + size_t src_len) { + size_t olen; + const unsigned char *buf; + unsigned acc, acc_len; + + olen = (src_len / 3) << 2; + switch (src_len % 3) { + case 2: + olen++; + /* fall through */ + case 1: + olen += 2; + break; + } + if (dst_len <= olen) { + return (size_t)-1; + } + acc = 0; + acc_len = 0; + buf = (const unsigned char *)src; + while (src_len-- > 0) { + acc = (acc << 8) + (*buf++); + acc_len += 8; + while (acc_len >= 6) { + acc_len -= 6; + *dst++ = (char)b64_byte_to_char((acc >> acc_len) & 0x3F); + } + } + if (acc_len > 0) { + *dst++ = (char)b64_byte_to_char((acc << (6 - acc_len)) & 0x3F); + } + *dst++ = 0; + return olen; +} + +/* + * Decode Base64 chars into bytes. The '*dst_len' value must initially + * contain the length of the output buffer '*dst'; when the decoding + * ends, the actual number of decoded bytes is written back in + * '*dst_len'. + * + * Decoding stops when a non-Base64 character is encountered, or when + * the output buffer capacity is exceeded. If an error occurred (output + * buffer is too small, invalid last characters leading to unprocessed + * buffered bits), then NULL is returned; otherwise, the returned value + * points to the first non-Base64 character in the source stream, which + * may be the terminating zero. + */ +static const char *from_base64(void *dst, size_t *dst_len, const char *src) { + size_t len; + unsigned char *buf; + unsigned acc, acc_len; + + buf = (unsigned char *)dst; + len = 0; + acc = 0; + acc_len = 0; + for (;;) { + unsigned d; + + d = b64_char_to_byte(*src); + if (d == 0xFF) { + break; + } + src++; + acc = (acc << 6) + d; + acc_len += 6; + if (acc_len >= 8) { + acc_len -= 8; + if ((len++) >= *dst_len) { + return NULL; + } + *buf++ = (acc >> acc_len) & 0xFF; + } + } + + /* + * If the input length is equal to 1 modulo 4 (which is + * invalid), then there will remain 6 unprocessed bits; + * otherwise, only 0, 2 or 4 bits are buffered. The buffered + * bits must also all be zero. + */ + if (acc_len > 4 || (acc & (((unsigned)1 << acc_len) - 1)) != 0) { + return NULL; + } + *dst_len = len; + return src; +} + +/* + * Decode decimal integer from 'str'; the value is written in '*v'. + * Returned value is a pointer to the next non-decimal character in the + * string. If there is no digit at all, or the value encoding is not + * minimal (extra leading zeros), or the value does not fit in an + * 'unsigned long', then NULL is returned. + */ +static const char *decode_decimal(const char *str, unsigned long *v) { + const char *orig; + unsigned long acc; + + acc = 0; + for (orig = str;; str++) { + int c; + + c = *str; + if (c < '0' || c > '9') { + break; + } + c -= '0'; + if (acc > (ULONG_MAX / 10)) { + return NULL; + } + acc *= 10; + if ((unsigned long)c > (ULONG_MAX - acc)) { + return NULL; + } + acc += (unsigned long)c; + } + if (str == orig || (*orig == '0' && str != (orig + 1))) { + return NULL; + } + *v = acc; + return str; +} + +/* ==================================================================== */ +/* + * Code specific to Argon2. + * + * The code below applies the following format: + * + * $argon2[$v=]$m=,t=,p=$$ + * + * where is either 'd', 'id', or 'i', is a decimal integer (positive, + * fits in an 'unsigned long'), and is Base64-encoded data (no '=' padding + * characters, no newline or whitespace). + * + * The last two binary chunks (encoded in Base64) are, in that order, + * the salt and the output. Both are required. The binary salt length and the + * output length must be in the allowed ranges defined in argon2.h. + * + * The ctx struct must contain buffers large enough to hold the salt and pwd + * when it is fed into decode_string. + */ + +int decode_string(argon2_context *ctx, const char *str, argon2_type type) { + +/* check for prefix */ +#define CC(prefix) \ + do { \ + size_t cc_len = strlen(prefix); \ + if (strncmp(str, prefix, cc_len) != 0) { \ + return ARGON2_DECODING_FAIL; \ + } \ + str += cc_len; \ + } while ((void)0, 0) + +/* optional prefix checking with supplied code */ +#define CC_opt(prefix, code) \ + do { \ + size_t cc_len = strlen(prefix); \ + if (strncmp(str, prefix, cc_len) == 0) { \ + str += cc_len; \ + { code; } \ + } \ + } while ((void)0, 0) + +/* Decoding prefix into decimal */ +#define DECIMAL(x) \ + do { \ + unsigned long dec_x; \ + str = decode_decimal(str, &dec_x); \ + if (str == NULL) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (x) = dec_x; \ + } while ((void)0, 0) + + +/* Decoding prefix into uint32_t decimal */ +#define DECIMAL_U32(x) \ + do { \ + unsigned long dec_x; \ + str = decode_decimal(str, &dec_x); \ + if (str == NULL || dec_x > UINT32_MAX) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (x) = (uint32_t)dec_x; \ + } while ((void)0, 0) + + +/* Decoding base64 into a binary buffer */ +#define BIN(buf, max_len, len) \ + do { \ + size_t bin_len = (max_len); \ + str = from_base64(buf, &bin_len, str); \ + if (str == NULL || bin_len > UINT32_MAX) { \ + return ARGON2_DECODING_FAIL; \ + } \ + (len) = (uint32_t)bin_len; \ + } while ((void)0, 0) + + size_t maxsaltlen = ctx->saltlen; + size_t maxoutlen = ctx->outlen; + int validation_result; + const char* type_string; + + /* We should start with the argon2_type we are using */ + type_string = argon2_type2string(type, 0); + if (!type_string) { + return ARGON2_INCORRECT_TYPE; + } + + CC("$"); + CC(type_string); + + /* Reading the version number if the default is suppressed */ + ctx->version = ARGON2_VERSION_10; + CC_opt("$v=", DECIMAL_U32(ctx->version)); + + CC("$m="); + DECIMAL_U32(ctx->m_cost); + CC(",t="); + DECIMAL_U32(ctx->t_cost); + CC(",p="); + DECIMAL_U32(ctx->lanes); + ctx->threads = ctx->lanes; + + CC("$"); + BIN(ctx->salt, maxsaltlen, ctx->saltlen); + CC("$"); + BIN(ctx->out, maxoutlen, ctx->outlen); + + /* The rest of the fields get the default values */ + ctx->secret = NULL; + ctx->secretlen = 0; + ctx->ad = NULL; + ctx->adlen = 0; + ctx->allocate_cbk = NULL; + ctx->free_cbk = NULL; + ctx->flags = ARGON2_DEFAULT_FLAGS; + + /* On return, must have valid context */ + validation_result = validate_inputs(ctx); + if (validation_result != ARGON2_OK) { + return validation_result; + } + + /* Can't have any additional characters */ + if (*str == 0) { + return ARGON2_OK; + } else { + return ARGON2_DECODING_FAIL; + } +#undef CC +#undef CC_opt +#undef DECIMAL +#undef BIN +} + +int encode_string(char *dst, size_t dst_len, argon2_context *ctx, + argon2_type type) { +#define SS(str) \ + do { \ + size_t pp_len = strlen(str); \ + if (pp_len >= dst_len) { \ + return ARGON2_ENCODING_FAIL; \ + } \ + memcpy(dst, str, pp_len + 1); \ + dst += pp_len; \ + dst_len -= pp_len; \ + } while ((void)0, 0) + +#define SX(x) \ + do { \ + char tmp[30]; \ + sprintf(tmp, "%lu", (unsigned long)(x)); \ + SS(tmp); \ + } while ((void)0, 0) + +#define SB(buf, len) \ + do { \ + size_t sb_len = to_base64(dst, dst_len, buf, len); \ + if (sb_len == (size_t)-1) { \ + return ARGON2_ENCODING_FAIL; \ + } \ + dst += sb_len; \ + dst_len -= sb_len; \ + } while ((void)0, 0) + + const char* type_string = argon2_type2string(type, 0); + int validation_result = validate_inputs(ctx); + + if (!type_string) { + return ARGON2_ENCODING_FAIL; + } + + if (validation_result != ARGON2_OK) { + return validation_result; + } + + + SS("$"); + SS(type_string); + + SS("$v="); + SX(ctx->version); + + SS("$m="); + SX(ctx->m_cost); + SS(",t="); + SX(ctx->t_cost); + SS(",p="); + SX(ctx->lanes); + + SS("$"); + SB(ctx->salt, ctx->saltlen); + + SS("$"); + SB(ctx->out, ctx->outlen); + return ARGON2_OK; + +#undef SS +#undef SX +#undef SB +} + +size_t b64len(uint32_t len) { + size_t olen = ((size_t)len / 3) << 2; + + switch (len % 3) { + case 2: + olen++; + /* fall through */ + case 1: + olen += 2; + break; + } + + return olen; +} + +size_t numlen(uint32_t num) { + size_t len = 1; + while (num >= 10) { + ++len; + num = num / 10; + } + return len; +} + diff --git a/mobile/shared/src/nativeInterop/argon2/src/encoding.h b/mobile/shared/src/nativeInterop/argon2/src/encoding.h new file mode 100644 index 000000000..7e83ec928 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/encoding.h @@ -0,0 +1,57 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ENCODING_H +#define ENCODING_H +#include "argon2.h" + +#define ARGON2_MAX_DECODED_LANES UINT32_C(255) +#define ARGON2_MIN_DECODED_SALT_LEN UINT32_C(8) +#define ARGON2_MIN_DECODED_OUT_LEN UINT32_C(12) + +/* +* encode an Argon2 hash string into the provided buffer. 'dst_len' +* contains the size, in characters, of the 'dst' buffer; if 'dst_len' +* is less than the number of required characters (including the +* terminating 0), then this function returns ARGON2_ENCODING_ERROR. +* +* on success, ARGON2_OK is returned. +*/ +int encode_string(char *dst, size_t dst_len, argon2_context *ctx, + argon2_type type); + +/* +* Decodes an Argon2 hash string into the provided structure 'ctx'. +* The only fields that must be set prior to this call are ctx.saltlen and +* ctx.outlen (which must be the maximal salt and out length values that are +* allowed), ctx.salt and ctx.out (which must be buffers of the specified +* length), and ctx.pwd and ctx.pwdlen which must hold a valid password. +* +* Invalid input string causes an error. On success, the ctx is valid and all +* fields have been initialized. +* +* Returned value is ARGON2_OK on success, other ARGON2_ codes on error. +*/ +int decode_string(argon2_context *ctx, const char *str, argon2_type type); + +/* Returns the length of the encoded byte stream with length len */ +size_t b64len(uint32_t len); + +/* Returns the length of the encoded number num */ +size_t numlen(uint32_t num); + +#endif diff --git a/mobile/shared/src/nativeInterop/argon2/src/ref.c b/mobile/shared/src/nativeInterop/argon2/src/ref.c new file mode 100644 index 000000000..ad1cf461f --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/ref.c @@ -0,0 +1,194 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#include +#include +#include + +#include "argon2.h" +#include "core.h" + +#include "blake2/blamka-round-ref.h" +#include "blake2/blake2-impl.h" +#include "blake2/blake2.h" + + +/* + * Function fills a new memory block and optionally XORs the old block over the new one. + * @next_block must be initialized. + * @param prev_block Pointer to the previous block + * @param ref_block Pointer to the reference block + * @param next_block Pointer to the block to be constructed + * @param with_xor Whether to XOR into the new block (1) or just overwrite (0) + * @pre all block pointers must be valid + */ +static void fill_block(const block *prev_block, const block *ref_block, + block *next_block, int with_xor) { + block blockR, block_tmp; + unsigned i; + + copy_block(&blockR, ref_block); + xor_block(&blockR, prev_block); + copy_block(&block_tmp, &blockR); + /* Now blockR = ref_block + prev_block and block_tmp = ref_block + prev_block */ + if (with_xor) { + /* Saving the next block contents for XOR over: */ + xor_block(&block_tmp, next_block); + /* Now blockR = ref_block + prev_block and + block_tmp = ref_block + prev_block + next_block */ + } + + /* Apply Blake2 on columns of 64-bit words: (0,1,...,15) , then + (16,17,..31)... finally (112,113,...127) */ + for (i = 0; i < 8; ++i) { + BLAKE2_ROUND_NOMSG( + blockR.v[16 * i], blockR.v[16 * i + 1], blockR.v[16 * i + 2], + blockR.v[16 * i + 3], blockR.v[16 * i + 4], blockR.v[16 * i + 5], + blockR.v[16 * i + 6], blockR.v[16 * i + 7], blockR.v[16 * i + 8], + blockR.v[16 * i + 9], blockR.v[16 * i + 10], blockR.v[16 * i + 11], + blockR.v[16 * i + 12], blockR.v[16 * i + 13], blockR.v[16 * i + 14], + blockR.v[16 * i + 15]); + } + + /* Apply Blake2 on rows of 64-bit words: (0,1,16,17,...112,113), then + (2,3,18,19,...,114,115).. finally (14,15,30,31,...,126,127) */ + for (i = 0; i < 8; i++) { + BLAKE2_ROUND_NOMSG( + blockR.v[2 * i], blockR.v[2 * i + 1], blockR.v[2 * i + 16], + blockR.v[2 * i + 17], blockR.v[2 * i + 32], blockR.v[2 * i + 33], + blockR.v[2 * i + 48], blockR.v[2 * i + 49], blockR.v[2 * i + 64], + blockR.v[2 * i + 65], blockR.v[2 * i + 80], blockR.v[2 * i + 81], + blockR.v[2 * i + 96], blockR.v[2 * i + 97], blockR.v[2 * i + 112], + blockR.v[2 * i + 113]); + } + + copy_block(next_block, &block_tmp); + xor_block(next_block, &blockR); +} + +static void next_addresses(block *address_block, block *input_block, + const block *zero_block) { + input_block->v[6]++; + fill_block(zero_block, input_block, address_block, 0); + fill_block(zero_block, address_block, address_block, 0); +} + +void fill_segment(const argon2_instance_t *instance, + argon2_position_t position) { + block *ref_block = NULL, *curr_block = NULL; + block address_block, input_block, zero_block; + uint64_t pseudo_rand, ref_index, ref_lane; + uint32_t prev_offset, curr_offset; + uint32_t starting_index; + uint32_t i; + int data_independent_addressing; + + if (instance == NULL) { + return; + } + + data_independent_addressing = + (instance->type == Argon2_i) || + (instance->type == Argon2_id && (position.pass == 0) && + (position.slice < ARGON2_SYNC_POINTS / 2)); + + if (data_independent_addressing) { + init_block_value(&zero_block, 0); + init_block_value(&input_block, 0); + + input_block.v[0] = position.pass; + input_block.v[1] = position.lane; + input_block.v[2] = position.slice; + input_block.v[3] = instance->memory_blocks; + input_block.v[4] = instance->passes; + input_block.v[5] = instance->type; + } + + starting_index = 0; + + if ((0 == position.pass) && (0 == position.slice)) { + starting_index = 2; /* we have already generated the first two blocks */ + + /* Don't forget to generate the first block of addresses: */ + if (data_independent_addressing) { + next_addresses(&address_block, &input_block, &zero_block); + } + } + + /* Offset of the current block */ + curr_offset = position.lane * instance->lane_length + + position.slice * instance->segment_length + starting_index; + + if (0 == curr_offset % instance->lane_length) { + /* Last block in this lane */ + prev_offset = curr_offset + instance->lane_length - 1; + } else { + /* Previous block */ + prev_offset = curr_offset - 1; + } + + for (i = starting_index; i < instance->segment_length; + ++i, ++curr_offset, ++prev_offset) { + /*1.1 Rotating prev_offset if needed */ + if (curr_offset % instance->lane_length == 1) { + prev_offset = curr_offset - 1; + } + + /* 1.2 Computing the index of the reference block */ + /* 1.2.1 Taking pseudo-random value from the previous block */ + if (data_independent_addressing) { + if (i % ARGON2_ADDRESSES_IN_BLOCK == 0) { + next_addresses(&address_block, &input_block, &zero_block); + } + pseudo_rand = address_block.v[i % ARGON2_ADDRESSES_IN_BLOCK]; + } else { + pseudo_rand = instance->memory[prev_offset].v[0]; + } + + /* 1.2.2 Computing the lane of the reference block */ + ref_lane = ((pseudo_rand >> 32)) % instance->lanes; + + if ((position.pass == 0) && (position.slice == 0)) { + /* Can not reference other lanes yet */ + ref_lane = position.lane; + } + + /* 1.2.3 Computing the number of possible reference block within the + * lane. + */ + position.index = i; + ref_index = index_alpha(instance, &position, pseudo_rand & 0xFFFFFFFF, + ref_lane == position.lane); + + /* 2 Creating a new block */ + ref_block = + instance->memory + instance->lane_length * ref_lane + ref_index; + curr_block = instance->memory + curr_offset; + if (ARGON2_VERSION_10 == instance->version) { + /* version 1.2.1 and earlier: overwrite, not XOR */ + fill_block(instance->memory + prev_offset, ref_block, curr_block, 0); + } else { + if(0 == position.pass) { + fill_block(instance->memory + prev_offset, ref_block, + curr_block, 0); + } else { + fill_block(instance->memory + prev_offset, ref_block, + curr_block, 1); + } + } + } +} diff --git a/mobile/shared/src/nativeInterop/argon2/src/thread.c b/mobile/shared/src/nativeInterop/argon2/src/thread.c new file mode 100644 index 000000000..e099a00d2 --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/thread.c @@ -0,0 +1,57 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#if !defined(ARGON2_NO_THREADS) + +#include "thread.h" +#if defined(_WIN32) +#include +#endif + +int argon2_thread_create(argon2_thread_handle_t *handle, + argon2_thread_func_t func, void *args) { + if (NULL == handle || func == NULL) { + return -1; + } +#if defined(_WIN32) + *handle = _beginthreadex(NULL, 0, func, args, 0, NULL); + return *handle != 0 ? 0 : -1; +#else + return pthread_create(handle, NULL, func, args); +#endif +} + +int argon2_thread_join(argon2_thread_handle_t handle) { +#if defined(_WIN32) + if (WaitForSingleObject((HANDLE)handle, INFINITE) == WAIT_OBJECT_0) { + return CloseHandle((HANDLE)handle) != 0 ? 0 : -1; + } + return -1; +#else + return pthread_join(handle, NULL); +#endif +} + +void argon2_thread_exit(void) { +#if defined(_WIN32) + _endthreadex(0); +#else + pthread_exit(NULL); +#endif +} + +#endif /* ARGON2_NO_THREADS */ diff --git a/mobile/shared/src/nativeInterop/argon2/src/thread.h b/mobile/shared/src/nativeInterop/argon2/src/thread.h new file mode 100644 index 000000000..49d88367b --- /dev/null +++ b/mobile/shared/src/nativeInterop/argon2/src/thread.h @@ -0,0 +1,67 @@ +/* + * Argon2 reference source code package - reference C implementations + * + * Copyright 2015 + * Daniel Dinu, Dmitry Khovratovich, Jean-Philippe Aumasson, and Samuel Neves + * + * You may use this work under the terms of a Creative Commons CC0 1.0 + * License/Waiver or the Apache Public License 2.0, at your option. The terms of + * these licenses can be found at: + * + * - CC0 1.0 Universal : http://creativecommons.org/publicdomain/zero/1.0 + * - Apache 2.0 : http://www.apache.org/licenses/LICENSE-2.0 + * + * You should have received a copy of both of these licenses along with this + * software. If not, they may be obtained at the above URLs. + */ + +#ifndef ARGON2_THREAD_H +#define ARGON2_THREAD_H + +#if !defined(ARGON2_NO_THREADS) + +/* + Here we implement an abstraction layer for the simpĺe requirements + of the Argon2 code. We only require 3 primitives---thread creation, + joining, and termination---so full emulation of the pthreads API + is unwarranted. Currently we wrap pthreads and Win32 threads. + + The API defines 2 types: the function pointer type, + argon2_thread_func_t, + and the type of the thread handle---argon2_thread_handle_t. +*/ +#if defined(_WIN32) +#include +typedef unsigned(__stdcall *argon2_thread_func_t)(void *); +typedef uintptr_t argon2_thread_handle_t; +#else +#include +typedef void *(*argon2_thread_func_t)(void *); +typedef pthread_t argon2_thread_handle_t; +#endif + +/* Creates a thread + * @param handle pointer to a thread handle, which is the output of this + * function. Must not be NULL. + * @param func A function pointer for the thread's entry point. Must not be + * NULL. + * @param args Pointer that is passed as an argument to @func. May be NULL. + * @return 0 if @handle and @func are valid pointers and a thread is successfully + * created. + */ +int argon2_thread_create(argon2_thread_handle_t *handle, + argon2_thread_func_t func, void *args); + +/* Waits for a thread to terminate + * @param handle Handle to a thread created with argon2_thread_create. + * @return 0 if @handle is a valid handle, and joining completed successfully. +*/ +int argon2_thread_join(argon2_thread_handle_t handle); + +/* Terminate the current thread. Must be run inside a thread created by + * argon2_thread_create. +*/ +void argon2_thread_exit(void); + +#endif /* ARGON2_NO_THREADS */ +#endif diff --git a/mobile/shared/src/nativeInterop/cinterop/argon2.def b/mobile/shared/src/nativeInterop/cinterop/argon2.def new file mode 100644 index 000000000..8b266d792 --- /dev/null +++ b/mobile/shared/src/nativeInterop/cinterop/argon2.def @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +# +# The reference Argon2 C code in ../argon2 (CC0 or Apache 2.0), for the iOS +# targets (clients-mobile-apps design D1, task 1.3.1). shared/build.gradle.kts +# compiles it per target into libargon2.a and passes its folder with +# -libraryPath; the static library is bundled into the klib. +package = nl.conduction.keepiq.shared.argon2 +headers = argon2.h +headerFilter = argon2.h +staticLibraries = libargon2.a diff --git a/openspec/architecture/adr-004-secrets-in-app-local-encrypted-tables.md b/openspec/architecture/adr-004-secrets-in-app-local-encrypted-tables.md index 7076fb013..cd31074d7 100644 --- a/openspec/architecture/adr-004-secrets-in-app-local-encrypted-tables.md +++ b/openspec/architecture/adr-004-secrets-in-app-local-encrypted-tables.md @@ -4,6 +4,9 @@ **Date**: 2026-07-27 +- **References:** hydra ADR-022 (Apps consume OpenRegister abstractions), exception clause +- **Gate 23 rules:** 7 + ## Context Org ADR-070 (`hydra/openspec/architecture/adr-070-or-backed-persistence-default.md`) @@ -56,6 +59,29 @@ clause; the paths named below are the suppression scope for `lib/Db/**`, `lib/Migration/**`, `lib/Search/SecretSearchProvider.php`, and `lib/Service/{AuditService,SiemService,SettingsService,JwtAuthService,AttachmentService,ShareService,GroupShareService,LinkShareService,DelegationService,TeamFolderService,GdprService,ComplianceReportService,ImportService,SecretService,SecretVersionService}.php`. +### Vault audit and share checks stay app-local (gate-23 rules 2 and 6) + +Decided 4 October 2026 by the product owner (keepiq#673 follow-up). OpenRegister's +audit trail and RBAC act on objects the server can read. Keepiq's audit events +and share checks are about vault structure: who reached which secret, through +which share, delegation or team folder. Writing them through OpenRegister would +put that structure in OpenRegister's audit store, search index and exports, the +same exposure the persistence exception above exists to prevent. So the audit +trails and the share authorization stay app-local: + +- `lib/Service/ShareAuditTrail.php` (gate 23 rules: 2) +- `lib/Service/LinkShareAuditTrail.php` (gate 23 rules: 2) +- `lib/Service/EmergencyAccessAuditTrail.php` (gate 23 rules: 2) +- `lib/Service/ApplicationAuditTrail.php` (gate 23 rules: 2) +- `lib/Service/SiemAuditTrail.php` (gate 23 rules: 2) +- `lib/Service/FederatedShareAuditTrail.php` (gate 23 rules: 2) +- `lib/Listener/AuditListener.php` (gate 23 rules: 2) +- `lib/Service/ShareAuthorizationService.php` (gate 23 rules: 6) + +These classes log identifiers only, never a value, login or ciphertext, and the +share checks decide who may receive a re-wrapped key, which no row-level ACL can +express. + ### Drift boundary — what Keepiq still consumes org-wide The exception covers **persistence only**. Keepiq remains bound to: 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 index b6148e354..82e691da3 100644 --- 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 @@ -27,7 +27,8 @@ 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 +inbound `ShareTarget`s, promotes their temporary delegations (or, on a +compromise, revokes them; see below), and the emergency listener clears their break-glass recovery envelopes; all secret reads are then refused. @@ -69,6 +70,18 @@ revokes **any** suite by id (user- or application-owned), guarded by: re-confirms their **own** password; there is no vault key to prove. This is the app's first use of `PasswordConfirmationRequired`. +**Typed confirmation** (keepiq#871, decided 2 October 2026). Sudo mode is not a +second factor on every deployment. Nextcloud skips the password confirmation for +accounts that cannot confirm a password (user_oidc, user_saml, sessions with +`SCOPE_SKIP_PASSWORD_VALIDATION`), and it accepts any confirmation from the last +30 minutes. On the single sign-on setups common for government tenants, a +hijacked admin session would be enough. So the administrator also types the +suite id, which the request carries as `confirmSuiteId`. The controller refuses +the request with `400` (`confirmation_mismatch`) before anything else when it is +missing or differs from the route's id, and audits the refusal. This check does +not depend on the user backend. `#[PasswordConfirmationRequired]` stays, for the +backends where it does apply. + It reuses `EncryptionSuiteService::revokeSuite()`, which is owner-agnostic and records `revokedBy` (the administrator). @@ -88,6 +101,18 @@ recordable). No new column, no migration. - When `false`, no cascade runs, and the UI shows a warning that the revoked user still knows these secrets and rotation may be warranted. +**Temporary delegations on a compromise** (keepiq#817, decided 2 October 2026). +Every revoke used to promote the revoked user's temporary delegations to +permanent. On a `markCompromised: true` force-revoke, `EncryptionSuiteRevokedListener` +now deletes those temporary delegations instead, and audits each removal as +`share.delegation_reclaimed` with the administrator as actor. Permanent +delegations are not touched. A temporary delegation is the cheapest foothold to +create from a stolen session, and promoting it would make the foothold +permanent during the incident response. The cost is that a legitimate stand-in +loses delegated rights (they keep their own share) and has to be re-delegated +by the re-onboarded owner. A revoke that is not marked compromised still +promotes, as before. + **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 @@ -124,6 +149,9 @@ suite through the existing onboarding flow. 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. +- Sudo mode is skipped on single sign-on backends and lasts 30 minutes, so on + its own it is not a second factor there; the typed suite id is the + confirmation that holds on every backend (keepiq#871). ## Alternatives Considered diff --git a/openspec/changes/admin-public-api/tasks.md b/openspec/changes/admin-public-api/tasks.md deleted file mode 100644 index 75fcbf820..000000000 --- a/openspec/changes/admin-public-api/tasks.md +++ /dev/null @@ -1,27 +0,0 @@ -## 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/apps-client-libraries-and-ci/tasks.md b/openspec/changes/apps-client-libraries-and-ci/tasks.md deleted file mode 100644 index b76ebe432..000000000 --- a/openspec/changes/apps-client-libraries-and-ci/tasks.md +++ /dev/null @@ -1,28 +0,0 @@ -## 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/tasks.md b/openspec/changes/apps-kubernetes-injection/tasks.md deleted file mode 100644 index b7ba83158..000000000 --- a/openspec/changes/apps-kubernetes-injection/tasks.md +++ /dev/null @@ -1,27 +0,0 @@ -## 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/tasks.md b/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md deleted file mode 100644 index a51c06ecc..000000000 --- a/openspec/changes/apps-secret-sync-and-rotation-runner/tasks.md +++ /dev/null @@ -1,32 +0,0 @@ -## 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/tasks.md b/openspec/changes/apps-terraform-provider/tasks.md deleted file mode 100644 index fa601ffef..000000000 --- a/openspec/changes/apps-terraform-provider/tasks.md +++ /dev/null @@ -1,26 +0,0 @@ -## 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/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/design.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/design.md new file mode 100644 index 000000000..ff9462023 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/design.md @@ -0,0 +1,39 @@ +# Design: an inactivity lock that follows activity, and a timeout the user keeps + +## Context + +At development `156cd800`: + +- `src/store/modules/session.js:41` initialises `lastActivity`; `:121` and `:159` set it at unlock; `:194` `checkTimeout` compares against it; `:204` `updateActivity()` is never called. +- `src/App.vue:137-143` renders an in-memory timeout select; `:873-874` `saveTimeout` maps `session`, 10 and 30 minutes and falls back with `|| 600000`; `:497` always starts at `session`; `:779` polls `checkTimeout`. +- `src/components/settings/SessionTimeoutSection.vue:52-66` does GET and PUT of `session_timeout` and is mounted nowhere. +- `browser-extension/src/background/service-worker.js:37-38` already has a true idle lock; the extension is not changed. + +## Goals / Non-Goals + +**Goals** +- The lock means inactivity, everywhere in the web app. +- A timeout choice is remembered. + +**Non-Goals** +- An administrator maximum (`admin-24`, decided no). +- Changing the extension idle lock. +- Lock on system sleep. + +## Decisions + +### D1: Throttle activity events + +A single listener set on `document` calls `updateActivity()` at most once every 15 seconds, so typing does not cost a store write per key. + +### D2: Hold the value as milliseconds with an explicit never + +The store keeps `null` for Nextcloud session and a number for 10 or 30 minutes, so the falsy-zero fallback that caused the defect cannot recur. + +### D3: One control, and it saves + +Corrected at build time (29 Sep). keepiq's personal settings are the user-settings dialog in `App.vue`, which already held the select; `SessionTimeoutSection.vue` was a second, never-mounted copy with a native select. So the select in the user-settings dialog stays and becomes the one control: it reads `sessionStore.timeoutChoice` and saves through `saveTimeoutPreference()` (`PUT /api/settings/user`). The unmounted `SessionTimeoutSection.vue` is deleted. The saved value is loaded when the app mounts, so a reload followed by an unlock uses it. + +### D4: An unset timeout is ten minutes + +Once Nextcloud session really means no idle timer, the old unset default (`'session'` in `SettingsService::getUserPreferences()` and `AdminSettingsService`) would have switched the idle lock off for every user who never chose. In practice the vault always locked after ten minutes, so the unset default becomes `10min` (`AdminSettingsService::DEFAULT_SESSION_TIMEOUT`). Nextcloud session is now always a choice someone made. diff --git a/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/proposal.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/proposal.md new file mode 100644 index 000000000..b32a45401 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/proposal.md @@ -0,0 +1,57 @@ +--- +kind: code +--- + +# An inactivity lock that follows activity, and a timeout the user keeps + +## Why + +The web vault locks itself after a timeout, but `src/store/modules/session.js:194` compares against `lastActivity`, which is set only at unlock (`:121`, `:159`); `updateActivity()` (`:204`) has no caller. An active user is locked out after the timeout however busy they are. The user's timeout choice in `src/App.vue:873-874` lives in memory only, the component that saves it (`src/components/settings/SessionTimeoutSection.vue:52-66`) is mounted nowhere, and choosing Nextcloud session maps to 0 which `|| 600000` turns into 10 minutes. Four competitors rate yes on each row. Both rows are one service (the session store) and one control (the timeout select), so they are one change. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `crypto-06` | Lock the vault by hand, and have it lock itself after a period of inactivity. | `partial`: `partial`: the web app auto-lock is a timer from unlock, because `updateActivity` has no caller, so an active user is locked out mid-work | +| `crypto-07` | Choose your own session timeout. | `partial`: `partial`: the chosen timeout applies to the page session only, is never saved, and the Nextcloud session choice silently becomes 10 minutes | + +### Demand + +- `crypto-06`: no demand row. +- `crypto-07`: no demand row. + +### Competitors rated yes + +- `crypto-06`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts:59 checkVaultTimeout (periodic, :36); apps/web/src/locales/en/messages.json:2536 'lockNow'; apps/browser/src/manifest.v" +- `crypto-06`, onepassword: "https://support.1password.com/unlock-auto-lock/ : 'Lock after system is idle for' minutes, also locks on sleep" +- `crypto-06`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : Logout Timer 'to automatically log out a user from Keeper when they are inactive' for Web, Mobile and Desktop" +- `crypto-06`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/lib/core/addon/components/sidebar/user-menu.hbs:63 Log out; ui/app/services/auth.js:32 IDLE_TIMEOUT 3 min, :382 stops token renewal after idle so the session ends at token expiry; ui/app/components/token-" +- `crypto-07`, bitwarden: "bitwarden/clients@web-v2026.9.0 libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts timeout value and action (lock or log out); apps/web/src/locales/en/messages.json:7534 'vaultTimeout'; bitward" +- `crypto-07`, onepassword: "https://support.1password.com/unlock-auto-lock/ : adjust Auto-Lock minutes; Business presets in https://support.1password.com/unlock-auto-lock-policy/" +- `crypto-07`, keeper: "https://docs.keeper.io/enterprise-guide/roles/enforcement-policies#account-settings : the admin timer is the maximum; users choose their own timer up to it ('If a Keeper user's current timer is set greater than this value, it will" +- `crypto-07`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Section/Settings.vue:92-106 'End session after' select (1 to 60 minutes) bound to user.session.lifetime; src/lib/Helper/Settings/UserSettingsHelper.php session/lifetime default 600 Not" + +## What Changes + +- Call `updateActivity()` on pointer, key and scroll events, throttled, so the lock counts inactivity. +- Mount `SessionTimeoutSection` in personal settings, remove the in-memory select from `App.vue`, and load the saved value at unlock. +- Fix the Nextcloud session option so it means no idle timer beyond the Nextcloud session, not 10 minutes. + +## Capabilities + +### New Capabilities + +- `vault-session-lock` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `src/store/modules/session.js`, `src/App.vue`, `SessionTimeoutSection.vue`, the settings page that hosts it. +- **Backend**: none; `session_timeout` is already a user preference (`lib/Service/SettingsService.php:93`). +- **Database**: none. +- **Cross-row**: `admin-24` (an administrator cap on the timeout) is decided no on its own; this change leaves the select ready for a maximum but adds none. diff --git a/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md new file mode 100644 index 000000000..e53bab802 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/specs/vault-session-lock/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Inactivity lock + +The system MUST lock the web vault after the configured period without user activity. Pointer movement, key presses, scrolling and touch MUST reset the period. Activity in another browser tab MUST NOT keep a locked tab unlocked, and a lock MUST still clear the master key from memory. + +#### Scenario: An active user is not locked out + +@e2e exclude A 25 minute real-time flow; covered by vitest tests/store/session.timeout.spec.js 'keeps an active user unlocked past the timeout' with fake timers. + +- **GIVEN** a user with a 10 minute timeout who has been working for 25 minutes with clicks every minute +- **WHEN** the user keeps working +- **THEN** the vault stays unlocked + +#### Scenario: An idle user is locked + +@e2e exclude A ten minute real-time wait; covered by vitest tests/store/session.timeout.spec.js 'locks an idle user after the timeout and clears the key'. + +- **GIVEN** a user with a 10 minute timeout who stops interacting +- **WHEN** ten minutes pass +- **THEN** the lock screen is shown and the master key is cleared + +#### Scenario: Manual lock still works + +@e2e exclude The menu action calls sessionStore.lock(), covered with the lock transition by tests/components/appLockWiring.spec.js. + +- **GIVEN** an unlocked user +- **WHEN** the user chooses Lock vault from the menu +- **THEN** the lock screen is shown at once + +### Requirement: Saved session timeout + +The system MUST let a user choose a timeout in personal settings, MUST persist it through `PUT` on the session timeout preference, and MUST apply the saved value when the vault is unlocked in a new page load. When neither the user nor the administrator chose, the timeout MUST be ten minutes. The option Nextcloud session MUST mean no separate idle timer and MUST NOT be converted to another value. + +#### Scenario: The choice survives a reload + +@e2e exclude Needs a vault with a master password on the test instance to unlock after the reload; covered by vitest tests/store/session.timeout.spec.js 'loads the saved choice' and 'saves a choice through PUT', and PHPUnit SettingsServiceSessionTimeoutTest. + +- **GIVEN** a user who picked 30 minutes in personal settings +- **WHEN** the user reloads the page and unlocks +- **THEN** the vault uses a 30 minute timeout and the select shows 30 minutes + +#### Scenario: Nextcloud session means no idle timer + +@e2e exclude An hour of real-time idling; covered by vitest tests/store/session.timeout.spec.js 'never locks on an idle timer for the Nextcloud session choice'. + +- **GIVEN** a user who picked Nextcloud session +- **WHEN** the user stays idle for an hour within the Nextcloud session +- **THEN** the vault does not lock on an idle timer diff --git a/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/tasks.md b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/tasks.md new file mode 100644 index 000000000..66e36def7 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-crypto-session-timeout-and-inactivity-lock/tasks.md @@ -0,0 +1,15 @@ +# Tasks: an inactivity lock that follows activity, and a timeout the user keeps + +## 1. Inactivity + +- [x] 1.1 Attach throttled activity listeners in `App.vue` that call `sessionStore.updateActivity()`. Verify: vitest with fake timers for reset, no reset while idle, and throttling. +- [x] 1.2 Represent Nextcloud session as `null` in the session store and stop the `|| 600000` fallback. Verify: vitest that `null` never locks and a number does. + +## 2. Saved choice + +- [x] 2.1 Make the user-settings select save through the session store and delete the unmounted `SessionTimeoutSection` and `saveTimeout` (design D3); load the saved value when the app mounts. Verify: vitest `tests/store/session.timeout.spec.js` and `tests/components/appSessionWiring.spec.js`. The Playwright flow is excluded (a reload and unlock need a vault with a master password on the test instance); the scenario carries the reason. + +## 3. Close out + +- [x] 3.1 Set rows `crypto-06` and `crypto-07` to built, clear their defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/archive/2026-09-29-portability-import-field-mapping/design.md b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/design.md new file mode 100644 index 000000000..2c60930d1 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/design.md @@ -0,0 +1,33 @@ +# Design: adjust the column mapping in the import wizard + +## Context + +At development `156cd800`: + +- `src/dialogs/ImportWizardDialog.vue:66-117` preview table; `:468` masks sensitive cells but nothing sets `revealed[key]`; `:506` `parseFile` call passes only the passphrase. +- `src/import/parsers/csv.js:120` accepts `options.mapping`; `:142` declares `adjustableMapping`. +- `src/store/modules/import.js:57` holds a `mapping` state that nothing writes. +- Vendor formats (Bitwarden, 1Password, and so on) have fixed mappings and keep them. + +## Goals / Non-Goals + +**Goals** +- The user controls the column mapping of a generic CSV before anything is stored. + +**Non-Goals** +- Mapping for vendor formats. +- Saving a mapping as a preset. + +## Decisions + +### D1: Mapping for generic CSV only + +Vendor exports have a known shape; showing selects for them adds a way to break a working import. The step appears only when the parser reports `adjustableMapping`. + +### D2: The store owns the mapping + +The wizard writes the mapping into the import store and both the preview and the import read it from there, so they cannot disagree. + +### D3: The store keeps the file text until reset + +Added at build time (29 Sep). Re-parsing after a remap needs the file text, so the import store keeps it in `sourceText` for a CSV with an adjustable mapping. It is plaintext of the same kind the parsed rows already hold in memory, it never leaves the tab, and `reset()` (wizard close) clears it with the rows. A single-valued field picked for a second column moves: the column that held it falls back to Do not import. diff --git a/openspec/changes/archive/2026-09-29-portability-import-field-mapping/proposal.md b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/proposal.md new file mode 100644 index 000000000..821e4fc5c --- /dev/null +++ b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# Adjust the column mapping in the import wizard + +## Why + +The import wizard shows a five-row preview (`src/dialogs/ImportWizardDialog.vue:66-117`) but the user cannot change which column feeds which field. The CSV parser already accepts `options.mapping` and declares `adjustableMapping` (`src/import/parsers/csv.js:120,142`), yet the wizard calls `parseFile(text, format, {passphrase})` (`:506`) and the import store's `mapping` state (`src/store/modules/import.js:57`) is never written. A wrongly guessed column silently imports a password as a note. Keeper and Nextcloud Passwords rate yes. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `portability-03` | Preview and adjust the field mapping before importing. | `partial`: `partial`: the wizard previews the first five rows read-only; the CSV parser accepts a mapping but the wizard never passes one | + +### Demand + +- `portability-03`: no demand row. + +### Competitors rated yes + +- `portability-03`, keeper: "https://docs.keeper.io/user-guides/web-vault#field-mapping : field mapping screen ('Click any field to open a dropdown menu'), then 'a summary screen will display a preview of your vault' before import" +- `portability-03`, nextcloud-passwords: "marius-wieschollek/passwords@2026.9.0 src/vue/Components/Import.vue:172-189 'Preview Line' select and csv-mapping field selectors via csvFieldMapping() Note: Custom CSV imports show a preview row and let you map each column; prede" + +## What Changes + +- Add a mapping step for CSV imports: one select per target field (name, url, login, password, notes, folder, type) pre-filled from the detected header. +- Re-run the preview when the mapping changes, and pass the mapping to `parseFile`. +- Make masked preview cells revealable, which the current code cannot do. + +## Capabilities + +### New Capabilities + +- `portability-import-mapping` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `ImportWizardDialog.vue` (split the mapping step into its own dialog file per the modal-isolation gate), `import.js` store, `csv.js` call site. +- **Backend**: none; import is client-side then batch create. +- **Database**: none. diff --git a/openspec/changes/archive/2026-09-29-portability-import-field-mapping/specs/portability-import-mapping/spec.md b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/specs/portability-import-mapping/spec.md new file mode 100644 index 000000000..8f1d6c12c --- /dev/null +++ b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/specs/portability-import-mapping/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Adjustable CSV mapping + +The import wizard MUST show, for a generic CSV file, the detected mapping from each target field to a source column and MUST let the user change any of them before the import starts. The preview MUST update to the changed mapping, and the import MUST use exactly the mapping shown. + +#### Scenario: A user fixes a wrong guess + +@e2e exclude Needs an unlocked vault with a master password on the test instance; covered by vitest tests/store/importMapping.spec.js 're-parses the rows when a column is mapped to another field'. + +- **GIVEN** a user importing a CSV whose password column is named Secret Key +- **WHEN** the wizard guessed Notes for it and the user picks Password for that column +- **THEN** the preview shows the values under Password and the import stores them as passwords + +#### Scenario: A required field is unmapped + +@e2e exclude Needs an unlocked vault on the test instance; covered by vitest tests/dialogs/ImportWizardDialog.mapping.spec.js 'blocks Import until a column is mapped to the name'. + +- **GIVEN** a user in the mapping step +- **WHEN** the user sets Name to no column +- **THEN** the Import button is disabled and the step says a name column is required + +### Requirement: Revealing sensitive preview cells + +The preview MUST mask login and password cells and MUST let the user reveal one cell at a time. + +#### Scenario: A user checks a password cell + +@e2e exclude Needs an unlocked vault on the test instance; covered by vitest tests/dialogs/ImportWizardDialog.mapping.spec.js 'reveals only the cell that was asked for'. + +- **GIVEN** the preview of a CSV import +- **WHEN** the user chooses Reveal on one password cell +- **THEN** that cell shows its value and the others stay masked diff --git a/openspec/changes/archive/2026-09-29-portability-import-field-mapping/tasks.md b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/tasks.md new file mode 100644 index 000000000..a24f75578 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-portability-import-field-mapping/tasks.md @@ -0,0 +1,13 @@ +# Tasks: adjust the column mapping in the import wizard + +## 1. Mapping + +- [x] 1.1 Write and read `mapping` in the import store and pass it to `parseFile`. Verify: vitest that a changed mapping changes the parsed rows. +- [x] 1.2 Add the mapping step as its own dialog component with the target field selects and the required-name rule. Verify: vitest tests/dialogs/ImportWizardDialog.mapping.spec.js (one select per column, Import blocked without a name column). The Playwright flow is excluded: it needs an unlocked vault on the test instance; the scenarios carry the reason. +- [x] 1.3 Wire the per-cell reveal in the preview. Verify: vitest that only the chosen cell is revealed. + +## 2. Close out + +- [x] 2.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [x] 2.2 Set row `portability-03` to built, clear its defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md new file mode 100644 index 000000000..2cf532e30 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/design.md @@ -0,0 +1,38 @@ +# Design: share a secret with a Nextcloud group from the secret sidebar + +## Context + +At development `156cd800`: + +- `lib/Controller/GroupShareController.php` `index`, `create` (`:103`), `destroy` (`:143`), `approveNewMember` (`:179`), `denyNewMember` (`:233`) are routed at `appinfo/routes.php:133-135` and onward. +- `lib/Service/GroupShareService.php:100` `createGroupShare($secretId, $groupId, $userId)` encrypts per member server-side; `:224` `getGroupMembers`; `:250` `handleNewGroupMember`. +- `src/components/share/GroupShareForm.vue` takes a free text group id and a `members` prop no caller supplies, and calls `useShareStore.encryptForRecipient`. +- `src/components/SecretDetailSidebar.vue:680-687` mounts the sharing components for owners and recipients. +- `grep -rn group-shares src browser-extension cli` finds no caller. + +## Goals / Non-Goals + +**Goals** +- An owner reaches the finished group share backend from the UI. + +**Non-Goals** +- Changing the group share crypto. +- Group roles on a share (`sharing-team-folder-manager-role`). + +## Decisions + +### D1: The browser encrypts for members, the server keeps the link + +Corrected at build time (29 Sep). The first draft said the server encrypts per member; it does not and must not. `GroupShareService::createGroupShare()` returns the eligible members with their PUBLIC certificates, and the plaintext never leaves the browser. So `useGroupShareStore.shareWithGroup()` creates the group share, decrypts the owner's copy in the tab, encrypts it once per member, and registers the copies through `POST /api/v1/shares/register-batch` with a `groupShareId` on each row. `DirectShareRegistrar` accepts that id only for a group share of the same source secret. The old `members` prop and the per-member loop in `GroupShareForm.vue` go. + +### D1b: Revoke deletes the copies + +`revokeGroupShare()` used to delete the ShareTarget rows only, leaving each member's encrypted copy in place. It now revokes every linked share through `ShareRevocationService::revokeShare()`, which deletes the copy and its attachment grants, then deletes the leftovers and the group share row. + +### D1c: Nextcloud's share settings apply + +The server refuses a group share when Nextcloud has group sharing off, and when sharing is limited to the sharer's own groups and the sharer is not a member. The second refusal reads "Group not found", the same as a missing group, so it does not confirm the group exists. The picker uses Nextcloud's sharee search, which applies the same settings. + +### D2: Pick, do not type + +A group search select replaces the free text field so a typo cannot target the wrong group. diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/proposal.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/proposal.md new file mode 100644 index 000000000..023dcca2c --- /dev/null +++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Share a secret with a Nextcloud group from the secret sidebar + +## Why + +A user cannot share a secret with a Nextcloud group today. The backend is finished: `POST /api/v1/secrets/{secretId}/group-shares` (`appinfo/routes.php:134`) and `GroupShareService::createGroupShare()` (`lib/Service/GroupShareService.php:100`). The form `src/components/share/GroupShareForm.vue` is registered but has no opener, and its submit path calls the per-user store rather than the group route. Five competitors rate yes. It is a share-path completion, the same class as the shipped user sharing. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `sharing-02` | Share a secret with a Nextcloud group. | `no`: `no`: `GroupShareController` and `GroupShareService` are complete, but nothing in the app opens the group share form or calls the group-share routes | + +### Demand + +- `sharing-02`: no demand row. + +### Competitors rated yes + +- `sharing-02`, bitwarden: "bitwarden/server@v2026.9.1 src/Api/AdminConsole/Controllers/GroupsController.cs:23 organizations/{orgId}/groups, :123 POST, :147 PUT with collection access; bitwarden/clients@web-v2026.9.0 apps/web/src/app/admin-console/organizati" +- `sharing-02`, onepassword: "https://support.1password.com/custom-groups/ : 'give everyone in a group access to specific vaults and assign vault permissions'" +- `sharing-02`, passbolt: "passbolt/passbolt_api@v5.16.0 src/Controller/Share/ShareController.php:101 share (groups are AROs); passbolt/passbolt_styleguide@v5.16.0 src/react-extension/components/Share/GroupPermissionItem.js, ShareDialog.js:515 Note: Groups " +- `sharing-02`, keeper: "https://docs.keeper.io/enterprise-guide/teams : 'Teams can be added to Shared Folders in the vault', teams provisioned from the IdP via SCIM or AD Bridge" +- `sharing-02`, hashicorp-vault: "hashicorp/vault@v2.1.1 ui/app/models/identity/group.js:15 fields name, type, policies, metadata (internal or external group); ui/app/router.js access.identity create/edit routes; vault/identity_store_util.go:3164 refreshExternalGr" + +## What Changes + +- Add a Share with group action to the sharing section of the secret sidebar, next to Share with user. +- Rework `GroupShareForm.vue` to pick a group (search over the caller's Nextcloud groups) and to call a `useGroupShareStore` action that posts to the group-share route. +- List the group shares of a secret with a revoke action, using `GET /api/v1/secrets/{secretId}/group-shares` and `DELETE /api/v1/group-shares/{id}`. + +## Capabilities + +### New Capabilities + +- `sharing-group` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: `SecretDetailSidebar.vue`, `GroupShareForm.vue`, a new `src/store/modules/groupShare.js`. +- **Backend**: a group search endpoint limited to the caller's visible groups if none exists; the group-share routes are unchanged. +- **Database**: none. +- **Security**: the group id comes from a picker but the server already validates membership visibility; the change adds a test that a user cannot share into a group they cannot see. diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md new file mode 100644 index 000000000..312863411 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/specs/sharing-group/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Share with a group + +The system MUST let the owner of a secret share it with a Nextcloud group from the secret detail sidebar, within Nextcloud's share settings (group sharing on; own groups only when that restriction is set). The action MUST call `POST /api/v1/secrets/{secretId}/group-shares` and MUST show how many members received the share and how many were skipped for lack of an active encryption suite. Members who join the group later MUST follow the existing approval path of `GroupShareService::handleNewGroupMember()`. + +#### Scenario: An owner shares with a group + +@e2e exclude Needs two users with active vault suites and a shared group on the test instance; covered by vitest tests/store/groupShare.spec.js and tests/components/GroupShareList.spec.js, and PHPUnit GroupShareServiceTest and ShareServiceTest::testRegisterDirectSharesLinksAGroupShareOfTheSameSecret. + +- **GIVEN** an owner viewing a secret in the sidebar at /secrets and a group Finance with four members of whom three have an encryption suite +- **WHEN** the owner picks Share with group, selects Finance and confirms +- **THEN** the sidebar lists a Finance group share and says three members received it and one was skipped + +#### Scenario: A non-owner cannot share with a group + +@e2e exclude Needs a second user holding a shared copy; covered by vitest tests/components/SecretDetailSidebar.sharing.spec.js (a recipient gets no group share list). + +- **GIVEN** a recipient who holds a shared copy +- **WHEN** the recipient opens the sidebar +- **THEN** the Share with group action is not offered + +#### Scenario: The owner revokes a group share + +@e2e exclude Needs group members with vault suites; covered by vitest tests/components/GroupShareList.spec.js and PHPUnit GroupShareServiceTest::testRevokeGroupShareCascades. + +- **GIVEN** a secret shared with Finance +- **WHEN** the owner revokes the Finance share in the sidebar +- **THEN** the group share is gone and members of Finance lose their copies diff --git a/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md new file mode 100644 index 000000000..de2c7d8b3 --- /dev/null +++ b/openspec/changes/archive/2026-09-29-sharing-group-share-entry-point/tasks.md @@ -0,0 +1,16 @@ +# Tasks: share a secret with a Nextcloud group from the secret sidebar + +## 1. Wire the form + +- [x] 1.1 Create `src/store/modules/groupShare.js` with list, create and revoke actions. Verify: vitest with mocked axios asserting the three routes and payloads. +- [x] 1.2 Rework `GroupShareForm.vue` to a group picker and the store action, and open it from the sidebar. Verify: vitest on the form (tests/components/GroupShareList.spec.js). The Playwright flow is excluded: it needs two users with vault suites; the spec scenarios carry the reason. +- [x] 1.3 List and revoke group shares in the sidebar. Verify: vitest (tests/components/GroupShareList.spec.js revoke) and PHPUnit GroupShareServiceTest::testRevokeGroupShareCascades (each member copy revoked through ShareRevocationService). + +## 2. Backend check + +- [x] 2.1 Add a PHPUnit test that a user cannot create a group share for a secret they do not own and cannot target a group they cannot see. Verify: PHPUnit; hydra no-admin-idor gate. + +## 3. Close out + +- [x] 3.1 Set row `sharing-02` to built, clear its defect, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/archive/2026-09-30-admin-secret-type-editor/design.md b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/design.md new file mode 100644 index 000000000..aa51d5af9 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/design.md @@ -0,0 +1,43 @@ +# Design: an administrator defines item types and the fields they carry + +## Context + +At development `156cd800`: + +- `lib/Db/SecretType.php` has `name`, `label`, `scope`, `ownerId`, `createdAt` and no fields. +- `lib/Controller/SecretTypeController.php:99` `create` passes `$isAdmin` to `typeService->createType`; global scope needs the admin role. +- `src/store/modules/secretType.js:73-110` has `createType`, `updateType`, `deleteType` and no caller for the first. +- `lib/Repair/SeedSecretTypes.php` seeds the built-in types; built-ins have no field list and keep their current forms. +- `src/dialogs/SecretCreateDialog.vue` picks the form from the type name. + +## Goals / Non-Goals + +**Goals** +- An administrator can define a type and its fields without code. + +**Non-Goals** +- Per-role publishing of a type. +- Field validation patterns. +- User-scope custom types beyond what the API already allows. + +## Decisions + +### D1: Fields are metadata, values are ciphertext + +The definition (labels and kinds) is not secret and is stored in plain text on the type, like folder names. Values go into the existing encrypted blob, so the server never sees them. + +### D2: Built-in types keep their forms + +Only types with a non-empty `fields` list render the generic typed form, so nothing changes for login, note or SSH key. + +### D3: The Item types page is an admin settings section (added at build, 2026-09-30) + +The proposal said "an admin page". Keepiq's admin surfaces live on the Nextcloud admin settings page (`src/views/settings/Settings.vue`), and an admin view in the app's own router is forbidden (ADR-004, hydra gate admin-router). So Item types is a `CnSettingsSection` there, and the editor and the delete confirmation are dialogs in `src/dialogs/`. + +### D4: Field keys are fixed, values are stored under the label (added at build) + +A field's key is derived from its label when it is added and never changes, so a relabel keeps the field. Values are stored in the additional-fields blob under the label, because every other reader of the blob (CLI, extension, apps) shows members by name. Labels are therefore unique per type and may not be `key`, `login` or `url`, which route to built-in columns. + +### D5: The migration ships with a version bump (added at build) + +`Version001001Date20260930000000` adds the nullable `fields` column. Nextcloud only runs a migration when `` in `appinfo/info.xml` rises, so this change bumps the timestamp part of the version (hydra gate-110); the release pipeline stamps its own version on release as before. diff --git a/openspec/changes/archive/2026-09-30-admin-secret-type-editor/proposal.md b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/proposal.md new file mode 100644 index 000000000..6cde7ca68 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/proposal.md @@ -0,0 +1,48 @@ +--- +kind: code +--- + +# An administrator defines item types and the fields they carry + +## Why + +Custom secret types exist in the API (`appinfo/routes.php:74-77`) and in the store (`src/store/modules/secretType.js:73` `createType`), but no page lets anyone create one, and a type is only `name`, `label`, `scope` and `ownerId` (`lib/Db/SecretType.php`): it cannot say which fields an item of that type carries. The row has a feature request from the Passbolt community and Keeper rates yes (a record template with labelled and required fields, published to roles). A request plus one competitor yes is a build under the decision rule. + +One row, one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `admin-18` | An administrator defines new item types and the fields they carry. | `no`: `no`: the secret-type routes and store exist, no page creates a type, and a type has no field definition at all | + +### Demand + +- `admin-18`: featureRequest, https://community.passbolt.com/t/as-an-administrator-i-can-create-new-secret-types-and-define-their-associated-input-fields/19 + +### Competitors rated yes + +- `admin-18`, keeper: "https://docs.keeper.io/enterprise-guide/creating-new-record-types : an admin with 'Manage Record Types in Vault' creates a record template with labelled and required fields and publishes it to roles." + +## What Changes + +- Add a `fields` definition to a secret type: an ordered list of `{key, label, kind, required}` with kinds `text`, `hidden`, `url`, `email`. +- Add an admin page, Item types, to create, relabel, edit fields and delete global types. +- Make the create and edit dialogs render the fields of the chosen type, storing values in the encrypted additional fields blob under the field label. + +## Capabilities + +### New Capabilities + +- `admin-secret-types` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Database**: one migration adds a `fields` JSON text column to the secret types table; `` bump. +- **Backend**: `SecretType` entity, `SecretTypeService::createType` and `updateType` validate the field list. +- **Frontend**: new admin view and route, changes in `SecretCreateDialog.vue` and `SecretEditDialog.vue`. +- **Cross-row**: reuses the hidden field kind from `vault-custom-field-kinds-and-ssh-key`; the two land in either order because a `hidden` kind falls back to text until that change lands. diff --git a/openspec/changes/archive/2026-09-30-admin-secret-type-editor/specs/admin-secret-types/spec.md b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/specs/admin-secret-types/spec.md new file mode 100644 index 000000000..93ee59974 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/specs/admin-secret-types/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Item type definitions + +The system MUST let an administrator create a global item type with a name, a label and an ordered list of fields, each with a label, a kind and a required flag, and MUST let the administrator edit and delete it. A type definition MUST be visible to every user as a choice in the create dialog. Deleting a type MUST leave its secrets readable and move them to the login type as the type service already does. + +#### Scenario: An administrator creates a type + +- **GIVEN** an administrator on the Item types admin page +- **WHEN** the administrator creates Server access with fields Host (url, required), Port (text) and Root password (hidden) and saves +- **THEN** the type appears in every user's create dialog + +#### Scenario: A user fills a typed item + +- **GIVEN** a user choosing Server access in the create dialog +- **WHEN** the user leaves Host empty and saves +- **THEN** the dialog blocks the save and marks Host as required + +#### Scenario: A regular user cannot define a global type + +- **GIVEN** a user without the admin role +- **WHEN** the user posts a global type to the secret types route +- **THEN** the server answers 403 + +### Requirement: Typed fields storage + +Values entered for the fields of a type MUST be stored inside the encrypted additional fields blob, and the server MUST NOT receive them in plain text. + +#### Scenario: Values stay encrypted + +- **GIVEN** a user saving a Server access item +- **WHEN** the request is sent to the server +- **THEN** the request body holds only ciphertext for the field values diff --git a/openspec/changes/archive/2026-09-30-admin-secret-type-editor/tasks.md b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/tasks.md new file mode 100644 index 000000000..c822b35d3 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-admin-secret-type-editor/tasks.md @@ -0,0 +1,19 @@ +# Tasks: an administrator defines item types and the fields they carry + +## 1. Data and API + +- [x] 1.1 Add the migration for `fields` and extend the entity and `SecretTypeService` validation (unique keys, at most 30 fields, known kinds). Verify: PHPUnit for valid, duplicate key and unknown kind. +- [x] 1.2 Accept and return `fields` on the create and update routes. Verify: PHPUnit on the controller; hydra route-auth and semantic-auth gates. + +## 2. Admin page + +- [x] 2.1 Build the Item types admin view and route with a field list editor, in its own dialog files. Verify: vitest on the editor and the section. Built as a section of the admin settings page, not an app route (design D3). The Playwright flow is excluded (browser service not available to this lane); the scenario carries the reason. + +## 3. Typed form + +- [x] 3.1 Render a generic typed form in the create and edit dialogs for types with fields and store values in the encrypted blob. Verify: vitest for required and hidden in both dialogs. The Playwright flow is excluded; the scenarios carry the reason. + +## 4. Close out + +- [x] 4.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [x] 4.2 Set row `admin-18` to built and archive the change. Verify: parity_verify --strict. diff --git a/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/design.md b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/design.md new file mode 100644 index 000000000..e0514bfee --- /dev/null +++ b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/design.md @@ -0,0 +1,46 @@ +# Design: offer to save or update a login at once, and pin passkey requests to the page origin + +## Context + +At development `156cd800`: + +- `browser-extension/src/content/content-script.js:134` `attachSubmitCapture`, `:150` `captureCurrent` (no id), `:192` `injectShim`, `:204-212` relay forwards `data.origin`. +- `browser-extension/src/background/service-worker.js:186` `doSaveCapture`, `:198` updates only with `payload.id`, `:230` passkey routes, `:240` `pendingCapture` in memory. +- `browser-extension/src/popup/popup.js:79` shows the `pending-capture` prompt only in the popup. +- `browser-extension/src/passkey/orchestrator.js:74` `handleCreate` and `handleGet`; `src/passkey/registration.js:23` native proxy registration behind an optional permission that is never requested. + +## Goals / Non-Goals + +**Goals** +- A submit leads to a visible offer at once, and an update never duplicates. +- A passkey request cannot claim an origin it does not have. + +**Non-Goals** +- Store release and Safari (see the two sibling changes). +- Changing passkey storage. + +## Decisions + +### D1: A shadow root bar, not a popup + +The prompt is injected in a closed shadow root and takes no input from the page, so page script cannot read or click it. The popup prompt stays as a fallback. + +### D2: Match in the service worker + +The service worker already holds the vault cache; it matches by origin and username and returns the id with the capture, so the content script never sees saved passwords. + +### D3: Registrable suffix rule + +The rpId check follows the WebAuthn rule: equal to the host or a parent domain that is not a public suffix. + +### D4: The origin comes from the message sender, and the native proxy path is deleted (added at build, 2026-09-30) + +The content script now sends `location.origin`, but the service worker does not rely on it: it takes the origin from the runtime message sender (`sender.origin` on Chromium, the origin of `sender.url` on Firefox), which the page cannot forge. `browser-extension/src/passkey/rp.js` applies the rpId rule. The native `chrome.webAuthenticationProxy` path was dead (its optional permission was never requested) and its fallback derived the origin from the request's own rpId, the very defect this change closes; the shim relay already runs in every browser, so the path and the permission are deleted rather than revived. + +### D5: The public suffix check is the extension's approximation (added at build) + +`isPublicSuffix` uses the same list as autofill matching (single labels plus common multi-label suffixes such as `co.uk` and `gov.nl`), not the full public suffix list. The consequence of a miss is narrow: a passkey is only found for the exact rpId it was created with, so a page could at most use a passkey created for a bare public suffix, which no real site has. + +### D6: A locked vault offers nothing in the page (added at build) + +Without the key the worker cannot tell save from update, so it offers nothing in the page and keeps the capture for the popup, which asks to unlock first. diff --git a/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/proposal.md b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/proposal.md new file mode 100644 index 000000000..8cb3c7755 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/proposal.md @@ -0,0 +1,59 @@ +--- +kind: code +--- + +# Offer to save or update a login at once, and pin passkey requests to the page origin + +## Why + +After a form submit the extension captures the login (`browser-extension/src/content/content-script.js:134-150`) but holds it in memory until the user happens to open the popup (`service-worker.js:240`), and `doSaveCapture` updates only when `payload.id` is set (`:198`) while the capture never carries an id, so a changed password creates a duplicate. For passkeys, the content script forwards `data.origin` from the page's own message instead of `location.origin` (`content-script.js:212`) and nothing checks the relying party id against the origin, so a hostile page can ask for an assertion for another site. Five and three competitors rate yes. Both rows are the same extension surface, so they are one change. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `clients-03` | Be offered to save or update a login after submitting a form. | `partial`: `partial`: a submitted login is captured but the offer appears only when the popup is opened, and an existing login is never updated | +| `clients-05` | Use the extension as a passkey provider on websites. | `partial`: `partial`: passkey create and sign work, but the relay trusts an origin the page supplies and the native proxy path never activates | + +### Demand + +- `clients-03`: no demand row. +- `clients-05`: no demand row. + +### Competitors rated yes + +- `clients-03`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/background/notification.background.ts:638 triggerAddLoginNotification, :663 getEnableAddedLoginPrompt, :144 bgSaveCipher Note: Add-login and change-password prompts after f" +- `clients-03`, onepassword: "https://support.1password.com/save-fill-passwords/ : '1Password will automatically offer to save your login' and asks to update an existing item" +- `clients-03`, passbolt: "passbolt/passbolt_browser_extension@v5.16.0 src/all/background_page/controller/webIntegration/webIntegrationController.js:34 autosave opens the save-credentials flow (feature autosave-credentials); passbolt/passbolt_styleguide@v5." +- `clients-03`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#save-prompt : 'Keeper will offer to save a password to the vault, if you login manually on a site'; 'Prompt to Change' policy for updates" +- `clients-03`, nextcloud-passwords: "not in the cloned repos, docs rating kept: marius-wieschollek/passwords@2026.9.0 code not public for this (passwords-webextension repo not read); docs rating kept: https://git.mdns.eu/nextcloud/passwords/-/wikis/Administrators/Fea" +- `clients-05`, bitwarden: "bitwarden/clients@web-v2026.9.0 apps/browser/src/autofill/fido2/background/fido2.background.ts; libs/common/src/platform/services/fido2/fido2-authenticator.service.ts:188 creates passkey in a login Note: The extension intercepts W" +- `clients-05`, onepassword: "https://support.1password.com/save-use-passkeys/ : save and sign in with passkeys in the browser extension" +- `clients-05`, keeper: "https://docs.keeper.io/user-guides/browser-extensions#passkeys : create a passkey and log in with a passkey through KeeperFill" + +## What Changes + +- Show an in-page prompt (a closed shadow root bar) after a submit: Save, Update or Not now, with Update offered when a saved login matches the site and username. +- Match the captured login against saved logins by origin and username and send the matched id with the capture. +- Take the origin for passkey requests from `location.origin` in the content script and check that the relying party id is a registrable suffix of it before any assertion. +- Request the optional native proxy permission when the user enables the passkey provider, or remove the dead path. + +## Capabilities + +### New Capabilities + +- `clients-save-prompt` +- `clients-passkey-origin` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Extension**: `content-script.js`, `service-worker.js`, `popup.js`, `src/passkey/orchestrator.js`, manifest permissions. +- **Backend**: none. +- **Security**: closes the forged origin defect recorded on `clients-05`. +- **Dependency**: none; store release is `clients-extension-store-release`. diff --git a/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md new file mode 100644 index 000000000..6a7e2b831 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-passkey-origin/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Passkey origin binding + +The extension MUST take the origin of a passkey request from the browser's record of the requesting frame (the runtime message sender), never from the page's own message, and MUST refuse a request whose relying party id is not equal to, or a registrable suffix of, that origin's host. The `clientDataJSON` origin MUST be that derived origin. + +#### Scenario: A page asks for another site's rpId + +- **GIVEN** a page on evil.example calling navigator.credentials.get with rpId bank.example +- **WHEN** the request reaches the extension +- **THEN** the extension refuses and no assertion is created + +#### Scenario: A page uses its own rpId + +- **GIVEN** a page on login.bank.example with rpId bank.example +- **WHEN** the user consents +- **THEN** the assertion is created and its client data origin is https://login.bank.example diff --git a/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md new file mode 100644 index 000000000..8efc9ede2 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/specs/clients-save-prompt/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Save or update prompt + +After a login form is submitted, the extension MUST show a prompt on the page offering to save the login. When a saved login exists for the same origin and username with a different password, the prompt MUST offer Update and MUST update that secret instead of creating a new one. The prompt MUST NOT appear for a submit that matches an existing saved password, and MUST NOT be readable by page scripts. + +#### Scenario: A new login is offered + +- **GIVEN** a user logged in to the extension who submits a login form on a site with no saved login +- **WHEN** the page reloads after the submit +- **THEN** a prompt offers Save, and Save creates one secret + +#### Scenario: A changed password is offered as an update + +- **GIVEN** a saved login for the same site and username +- **WHEN** the user submits a different password +- **THEN** the prompt offers Update and choosing it changes that secret without creating a duplicate + +#### Scenario: An unchanged login is not offered + +- **GIVEN** a saved login +- **WHEN** the user submits the same password +- **THEN** no prompt appears diff --git a/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/tasks.md b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/tasks.md new file mode 100644 index 000000000..66ebd415d --- /dev/null +++ b/openspec/changes/archive/2026-09-30-clients-extension-save-prompt-and-passkey-origin/tasks.md @@ -0,0 +1,16 @@ +# Tasks: offer to save or update a login at once, and pin passkey requests to the page origin + +## 1. Save prompt + +- [x] 1.1 Return a matched secret id from the service worker for a capture and pass it to `doSaveCapture`. Verify: node tests on the service worker for match, no match and unchanged password. +- [x] 1.2 Inject the closed shadow root prompt from the content script with Save, Update and Not now. Verify: extension test in the existing `tests/extension` harness (`saveCapture.spec.js`). The Playwright flow with the extension loaded is excluded (browser service not available to this lane); the scenarios carry the reason. + +## 2. Passkey origin + +- [x] 2.1 Use `location.origin` in the content script relay and add the rpId suffix check in the orchestrator. Verify: node tests for own rpId, parent domain, foreign rpId and a public suffix. +- [x] 2.2 Request the optional native proxy permission on enable, or delete the path. Done: deleted (`registration.js` and the optional `webAuthenticationProxy` permission); the shim relay covers every browser, see design D4. Verify: `grep -rn webAuthenticationProxy browser-extension` finds nothing. + +## 3. Close out + +- [x] 3.1 Set rows `clients-03` and `clients-05` to built and clear their defects, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/design.md b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/design.md new file mode 100644 index 000000000..4171a644d --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/design.md @@ -0,0 +1,31 @@ +# Design: default item type and view, and a recently used list on the dashboard + +## Context + +At development `156cd800`: + +- `lib/Service/SettingsService.php:98-99` holds `default_secret_type` (default `login`) and `default_view` (default `list`); `src/store/modules/dashboardSettings.js` already allow-lists `default_view`. +- `src/views/DashboardSettingsView.vue:36` binds `form.default_view` and is in no route or registry (matrix defect, issue #208). +- `src/components/settings/SessionTimeoutSection.vue` shows the pattern for a mounted personal settings section. +- `lib/Service/AuditService.php:200` `recentlyAccessed()` calls `findRecentReadsByActor()`; the audit rows carry an object id and a name. +- `src/manifest.json:294` is the `recent-activity-feed` widget on `/api/v1/audit/me`. + +## Goals / Non-Goals + +**Goals** +- Preferences that the server already stores take effect. +- A user sees the secrets they used last, not a log of events. + +**Non-Goals** +- A last-used timestamp on the secret row; that belongs to `vault-favourites-tags-and-last-used`. +- Per-folder defaults. + +## Decisions + +### D1: Mount a section, not the old view + +The old `DashboardSettingsView` mixes unrelated keys. A small `DefaultsSection.vue` next to `SessionTimeoutSection.vue` edits only the two keys and reuses the store; the dead view is deleted. + +### D2: Distinct secrets from the audit query + +The widget endpoint asks `recentlyAccessed()` for more rows than it shows, groups by secret id and drops ids the user no longer holds, so a secret opened twice appears once. diff --git a/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/proposal.md b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/proposal.md new file mode 100644 index 000000000..335440d42 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/proposal.md @@ -0,0 +1,52 @@ +--- +kind: code +--- + +# Default item type and view, and a recently used list on the dashboard + +## Why + +The backend keeps two per-user preferences, `default_secret_type` and `default_view` (`lib/Service/SettingsService.php:98-99`), but the only form that edits `default_view` is `src/views/DashboardSettingsView.vue`, which no route mounts, and neither the create dialog nor the list reads them (issue #208 is recorded on the matrix row). On the dashboard, `recent-activity-feed` lists the last five audit events of any kind (`src/manifest.json:294`), while `AuditService::recentlyAccessed()` (`lib/Service/AuditService.php:200`) is a finished query with no caller. Both rows are in the vault area, the core area, and both are wiring of finished backend parts, so one change fixes the two. + +The rows share one screen or service, so they are one change. + +### Matrix rows (`keepiq` `openspec/parity/capabilities.json`) + +| row | capability | Today | +|---|---|---| +| `vault-20` | Choose a default item type and default view for new items. | `no`: `no`: the server stores `default_secret_type` and `default_view` but no reached screen edits them and nothing reads them | +| `vault-21` | See the secrets you used most recently on the dashboard. | `partial`: `partial`: the dashboard shows the last five audit events of any kind; the recently-accessed query has no caller | + +### Demand + +- `vault-20`: no demand row. +- `vault-21`: no demand row. + +### Competitors rated yes + +- `vault-20`: no competitor rated yes. +- `vault-21`: no competitor rated yes. + +## What Changes + +- Mount the preferences form in personal settings so a user can pick a default item type and a default list view. +- Preselect the saved default type in the create dialog, and open the secret list in the saved view. +- Add a Recently used widget to the dashboard, fed by `recentlyAccessed()`, one row per secret (not per event), where a row opens the secret. + +## Capabilities + +### New Capabilities + +- `vault-defaults` +- `vault-recently-used` + +### Modified Capabilities + +- None in delta form. + +## Impact + +- **Frontend**: a preferences section in personal settings, `SecretCreateDialog.vue`, `SecretList.vue`, a new dashboard widget entry in `src/manifest.json`. +- **Backend**: one route that returns recently read secrets joined to the caller's secrets, using `AuditService::recentlyAccessed()`. +- **Database**: none. +- **l10n**: new strings in every shipped locale. diff --git a/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md new file mode 100644 index 000000000..bee865b1d --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-defaults/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Default item type and view + +The system MUST let a user choose a default item type and a default list view in personal settings and MUST persist both through the existing preferences endpoint. The create dialog MUST preselect the saved type, and the secret list MUST open in the saved view. A saved type that no longer exists MUST fall back to `login`. + +#### Scenario: A user sets SSH Key as the default type + +- **GIVEN** a signed-in user on personal settings +- **WHEN** the user picks SSH Key as the default item type and saves, then clicks New secret +- **THEN** the create dialog opens with SSH Key selected + +#### Scenario: The list opens in the saved view + +- **GIVEN** a user who saved the cards view +- **WHEN** the user opens /secrets in a new session +- **THEN** the list renders in the cards view + +#### Scenario: A deleted type falls back + +- **GIVEN** a user whose saved default type was deleted by an administrator +- **WHEN** the user clicks New secret +- **THEN** the dialog preselects Login and shows no error diff --git a/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md new file mode 100644 index 000000000..879d0f564 --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/specs/vault-recently-used/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Recently used on the dashboard + +The dashboard MUST show a Recently used widget that lists the signed-in user's most recently read secrets, newest first, at most five, one row per secret with its name and a relative time. A row MUST open that secret. The widget MUST list only secrets the user still holds and MUST show an empty state when there are none. + +#### Scenario: A user sees what they opened last + +- **GIVEN** a user who opened three different secrets and one of them twice +- **WHEN** the user opens the dashboard +- **THEN** the widget lists three rows, the twice-opened secret once, newest first + +#### Scenario: A row opens the secret + +- **GIVEN** the Recently used widget with rows +- **WHEN** the user clicks a row +- **THEN** the secret list opens with that secret selected in the sidebar + +#### Scenario: A deleted secret is not listed + +- **GIVEN** a user who read a secret that has since been deleted +- **WHEN** the user opens the dashboard +- **THEN** the widget does not list it diff --git a/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/tasks.md b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/tasks.md new file mode 100644 index 000000000..04d831abf --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-defaults-and-recently-used-widget/tasks.md @@ -0,0 +1,17 @@ +# Tasks: default item type and view, and a recently used list on the dashboard + +## 1. Defaults + +- [x] 1.1 Add `DefaultsSection.vue` to personal settings, delete `DashboardSettingsView.vue`, and save through the settings store. Verify: vitest on the section. The Playwright flow is excluded (the browser service is not available to this lane); the scenarios carry the reason. +- [x] 1.2 Read `default_secret_type` in `SecretCreateDialog.vue` and `default_view` in `SecretList.vue`, with the fallback to `login`. Verify: vitest for the saved and the missing type. + +## 2. Recently used + +- [x] 2.1 Add `GET /api/v1/secrets/recent` returning distinct secrets from `recentlyAccessed()` filtered to the caller's live secrets. Verify: PHPUnit for distinctness, ordering and a deleted secret; hydra route-auth and no-admin-idor gates. +- [x] 2.2 Add the widget to `src/manifest.json` with a row route to the secret. Verify: `npm run check:manifest`; the Playwright flow is excluded, the scenario carries the reason. + +## 3. Close out + +- [x] 3.1 Add strings to every shipped locale. Verify: `npm run test:l10n`. +- [x] 3.2 Set rows `vault-20` and `vault-21` to built, close the defect on `vault-20`, archive the change. Verify: parity_verify --strict. + diff --git a/openspec/changes/vault-trash-and-archive/design.md b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/design.md similarity index 81% rename from openspec/changes/vault-trash-and-archive/design.md rename to openspec/changes/archive/2026-09-30-vault-trash-and-archive/design.md index e28866016..8697ff4d4 100644 --- a/openspec/changes/vault-trash-and-archive/design.md +++ b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/design.md @@ -51,3 +51,11 @@ Keepiq owns its tables (keepiq ADR-001) and has no OpenRegister register, so the ## Migration One migration after `Version001000Date20260908000000`: add `trashed_at` and `archived_at` (datetime, nullable) to `keepiq_secrets` and an index on (`owner_id`, `trashed_at`). Existing rows are live (both null). `` in `appinfo/info.xml` bumps so the migration runs. + +## Corrections made while building (2026-09-30) + +- **D3**: the state argument defaults to *every row*, not to live. A live default would have hidden trashed rows from GDPR export, account deletion, key rotation, rotation flags and child-data cleanup, which must all keep seeing them. Callers that show the everyday vault pass `live` explicitly (list, fuzzy search, dashboard count, offline cache); the two search queries (extension match and unified search) are always live. The filter lives in `SecretStateFilter` and the cross-user purge query in `SecretTrashMapper`, so `SecretMapper` keeps its size. +- **D4**: the retention field sits in the existing Attachments and version history admin section, next to the version retention it resembles. The purge loop is in the job. +- **Folders**: a folder deleted with its contents keeps its immediate delete; the non-goal wording "secrets it deletes go to the trash" is withdrawn for this change. +- **Trash view**: it opens no detail sidebar; a trashed secret is restored before it is opened. `DELETE /api/v1/secrets/{id}` answers `status: deleted, trashed: true`. +- **Seed data**: no dev fixture rows were added. diff --git a/openspec/changes/vault-trash-and-archive/proposal.md b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/proposal.md similarity index 100% rename from openspec/changes/vault-trash-and-archive/proposal.md rename to openspec/changes/archive/2026-09-30-vault-trash-and-archive/proposal.md diff --git a/openspec/changes/vault-trash-and-archive/specs/vault-trash-and-archive/spec.md b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/specs/vault-trash-and-archive/spec.md similarity index 100% rename from openspec/changes/vault-trash-and-archive/specs/vault-trash-and-archive/spec.md rename to openspec/changes/archive/2026-09-30-vault-trash-and-archive/specs/vault-trash-and-archive/spec.md diff --git a/openspec/changes/archive/2026-09-30-vault-trash-and-archive/tasks.md b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/tasks.md new file mode 100644 index 000000000..108fbf75c --- /dev/null +++ b/openspec/changes/archive/2026-09-30-vault-trash-and-archive/tasks.md @@ -0,0 +1,30 @@ +# Tasks: a trash bin and an archive for vault items + +## 1. Data and service + +- [x] 1.1 Add the migration with `trashed_at`, `archived_at` and the (`owner_id`, `trashed_at`) index; add the fields to `lib/Db/Secret.php` and its `jsonSerialize()`; bump ``. Verify: PHPUnit on the entity serialisation, and `occ migrations:status keepiq` on a dev instance. Done: Version001002Date20260930120000, version bumped; PHPUnit on the entity through SecretTrashServiceTest. +- [x] 1.2 Split `SecretService::delete()` into `trash()` (sharing cascade plus `trashed_at`) and `purge()` (the remaining cascade plus row delete); keep `delete()` as the trash path for the existing callers. Verify: PHPUnit that trash revokes link, user and group shares and delegations and keeps attachments and versions, and that purge removes them. Built as SecretTrashService::trash (sharing revoked through SecretSharingRevoker) and a purge that runs SecretService::delete with a purge reason; placeholder cleanup keeps calling delete() as a hard delete. +- [x] 1.3 Add `restore()`, `archive()` and `unarchive()` with ownership checks through `loadOwned()` and the five audit event types in `AuditEventTypes`. Verify: PHPUnit for each, including a 403 for a non-owner. SecretTrashService restore/archive/unarchive/purge; ownership through SecretService::findOwned. +- [x] 1.4 Add the state argument to `SecretMapper::findByOwner()`, `countByOwner()`, `searchByNameOrUrl()` and `findForUnifiedSearch()` with default live. Verify: PHPUnit that trashed and archived rows are absent from the default list, the unified search provider and the extension match. Design D3 corrected: the state argument defaults to null (every row) in findByOwner and countByOwner, because GDPR export, account deletion, key rotation and child-data cleanup must keep seeing trashed rows; the list, fuzzy search, dashboard count and offline cache pass live explicitly, and searchByNameOrUrl and findForUnifiedSearch are always live. + +## 2. API and jobs + +- [x] 2.1 Add routes `POST /api/v1/secrets/{id}/restore`, `DELETE /api/v1/secrets/{id}/purge`, `POST /api/v1/secrets/{id}/archive`, `POST /api/v1/secrets/{id}/unarchive` and a `state` query parameter (`live`, `trashed`, `archived`) on `GET /api/v1/secrets`, before the SPA catch-all. Verify: hydra route-auth, route-reachability and no-admin-idor gates, and a Newman request per route. DELETE /api/v1/secrets/{id} moved to SecretTrashController::trash. Hydra gates run at CI's version; no Newman collection is added in this PR. +- [x] 2.2 Add `PurgeTrashedSecretsJob` (daily) and register it in `appinfo/info.xml`; add `trash_retention_days` (default 30, 1 to 365) to `AdminSettingsService`. Verify: PHPUnit with a clock that an item past the retention is purged and one inside it is kept. The purge loop lives in PurgeTrashedSecretsJob (retention read and clamped there). + +## 3. Frontend + +- [x] 3.1 Add Trash and Archive entries to the vault navigation that open the secret list with `state=trashed` or `state=archived`, with Restore and Delete permanently actions on trashed rows and Unarchive on archived rows. Verify: vitest on the store actions and a Playwright flow delete, open Trash, restore. Trash (/trash) and Archive (/archive) menu entries and pages; selection strip carries Restore and Delete for good in the Trash view, Unarchive in the Archive view. Verified with vitest; the Playwright flow is excluded (browser service not available to this lane), the scenarios carry the reason. +- [x] 3.2 Add Archive to the detail sidebar menu and to the bulk selection strip; bulk delete moves to the trash. Verify: Playwright flow archive, item gone from list and search, unarchive. Archive in the sidebar menu and the live selection strip (BulkStateDialog); vitest. +- [x] 3.3 Rewrite the copy in `SecretDeleteConfirmDialog.vue` and `BulkDeleteDialog.vue` to say the item goes to the trash for the retention period and its shares end now; add the retention field as a new admin settings section. Verify: `npm run lint`, l10n check, and a Playwright check of the new dialog text. The retention field joins the existing Attachments and version history admin section instead of a new section. vitest pins the dialog copy. + +## 4. Docs + +- [x] 4.1 Document trash, restore, retention and archive in `docs/` and update the bulk-actions note that says there is no trash. Verify: docs build. docs/trash-and-archive.md; the bulk-actions spec notes updated. + +## Acceptance criteria + +- A deleted secret appears in Trash and can be restored with its value, attachments and version history. +- Nobody but the owner can read a trashed secret from the moment it is trashed. +- A trashed secret is purged after the retention period with the full delete cascade. +- An archived secret is absent from the vault list, in-app search, unified search, the extension candidates and the health report, keeps its shares, and returns with Unarchive. diff --git a/openspec/changes/admin-suite-revocation/.openspec.yaml b/openspec/changes/archive/2026-10-02-admin-suite-revocation/.openspec.yaml similarity index 100% rename from openspec/changes/admin-suite-revocation/.openspec.yaml rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/.openspec.yaml diff --git a/openspec/changes/admin-suite-revocation/design.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/design.md similarity index 87% rename from openspec/changes/admin-suite-revocation/design.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/design.md index bf6fc9e68..97827c8e9 100644 --- a/openspec/changes/admin-suite-revocation/design.md +++ b/openspec/changes/archive/2026-10-02-admin-suite-revocation/design.md @@ -81,3 +81,15 @@ No data migration and no `` bump. `revoked_reason` is reused; `markComp ## 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. + +## Follow-up: containment after the #691 review (2 Oct 2026) + +The post-merge review of #691 found the compromise branch incomplete. The changes, and how they adjust the decisions above: + +- **D2, revised.** The cascade is no longer a listener on `EncryptionSuiteRevokedEvent`. As a listener it could not report back to the administrator (keepiq#863), and it read ShareTargets that the first revoke of a migration had already swept (keepiq#864). `CompromiseContainmentService` now runs it from the force-revoke: `collect()` before any revoke, `contain()` after. The event still carries `compromised` for its listeners. +- **Containment covers the account.** Link shares and passkeys go through `MigrationService::revokeKeyMaterialOfOwner()`, the same helper the owner's recovery uses (keepiq#858). Every session and app password is ended through `OCP\Authentication\Token\IProvider` (keepiq#860), and `create()` refuses a revoked user without a fresh password confirmation; `reenrol()` carries the sudo guard. +- **D6, revised.** `reinstate()` now carries `#[PasswordConfirmationRequired]`, and `reinstateSuite()` refuses a compromise revoke (read from the last `SUITE_REVOKED` audit entry, fail closed when there is none) and a reinstate next to another active suite (keepiq#865). D3 still holds: nothing about the compromise is stored on the suite row. +- **Owner notice.** A force-revoke that deletes usable emergency contacts notifies the owner with the count (keepiq#876). + +Still open: what a compromise revoke does to the revoked user's temporary delegations (keepiq#817). Today they are promoted to permanent on every revoke. That is a policy decision and is collected for the product owner; once decided it belongs in ADR-005. + diff --git a/openspec/changes/admin-suite-revocation/plan.json b/openspec/changes/archive/2026-10-02-admin-suite-revocation/plan.json similarity index 100% rename from openspec/changes/admin-suite-revocation/plan.json rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/plan.json diff --git a/openspec/changes/admin-suite-revocation/proposal.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/proposal.md similarity index 100% rename from openspec/changes/admin-suite-revocation/proposal.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/proposal.md diff --git a/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/encryption-suites/spec.md similarity index 57% rename from openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/encryption-suites/spec.md index 03ae8edf1..58e36df69 100644 --- a/openspec/changes/admin-suite-revocation/specs/encryption-suites/spec.md +++ b/openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/encryption-suites/spec.md @@ -76,6 +76,104 @@ The `SUITE_REVOKED` audit event's metadata MUST carry `{ reason, markCompromised - **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 Compromise Force-Revoke Contains The Account +A force-revoke with `markCompromised: true` MUST contain everything the compromised key could reach, not only flag the secrets sealed under it. The system MUST collect the blast radius of every suite it is about to revoke (the named suite and, during an in-progress migration, the other end) BEFORE it revokes any of them, because the revoke cascade deletes the ShareTargets and invalidates the emergency contacts the lookup reads (keepiq#864). The revoke cascade MUST sweep only the ShareTargets of copies sealed under the suite being revoked. + +After the revoke the system MUST: + +- stamp and flag every secret in the blast radius and, for a shared copy, its source, and warn each affected owner once with the first secret's name and the number of other affected secrets (keepiq#875); +- warn every user who holds a copy of the revoked user's own secrets, and every grantor whose `approved` emergency grant is sealed to a revoked suite, because that envelope escrows the grantor's private key (keepiq#872); +- revoke the revoked user's link shares and passkeys through the same helper the owner's compromise recovery uses (keepiq#858); +- end every Nextcloud session and app password of the revoked user (keepiq#860). + +Each step MUST contain its own failure: a failure on one secret, one notification or one cleanup step MUST NOT stop the others. The number of failed steps MUST be returned in the response as `cascade.failed`, with `cascadeIncomplete: true` when it is above zero, so the administrator is not told containment ran when part of it did not (keepiq#863). + +Every force-revoke, compromise or not, that deletes usable emergency contacts MUST notify the suite's owner with the number deleted (both ends of a terminated migration counted together), because revocation leaves the owner nothing to look at afterwards (keepiq#876). The response MUST report the other end's count as `alsoRevokedEmergencyContactsDestroyed` (keepiq#877). + +#### Scenario: A copy on the second suite of a migration warns its source owner +@e2e exclude Needs two vault users and an open migration; covered by PHPUnit on CompromiseContainmentService with the real revoke listener. +- **GIVEN** user A holds a copy of C's secret, sealed under B, the new end of A's in-progress migration +- **WHEN** an administrator force-revokes A's old suite with `markCompromised: true` +- **THEN** C MUST be warned about the source secret +- **AND** the source secret MUST be stamped `possibly_compromised_at` and flagged for rotation + +#### Scenario: One failing notification does not stop the cascade +@e2e exclude Failure injection; covered by PHPUnit on CompromiseContainmentService and EncryptionSuiteController. +- **GIVEN** the notification for the first owner fails +- **WHEN** the compromise cascade runs +- **THEN** the second owner's secrets MUST still be stamped, flagged and warned +- **AND** the response MUST carry `cascadeIncomplete: true` + +#### Scenario: An owner with several affected secrets is told how many +@e2e exclude Notification content; covered by PHPUnit on CompromiseContainmentService and KeepiqNotifier. +- **GIVEN** three of A's secrets are in the blast radius +- **WHEN** the compromise cascade runs +- **THEN** A MUST get one notification naming the first secret and counting the two others + +#### Scenario: Grantors and share recipients are warned +@e2e exclude Needs several vault users; covered by PHPUnit on CompromiseContainmentService. +- **GIVEN** D designated A as emergency contact and approved A's request, and B holds a copy of A's secret +- **WHEN** an administrator force-revokes A's suite with `markCompromised: true` +- **THEN** D MUST be warned that their vault may be exposed +- **AND** B MUST be warned about the copy + +#### Scenario: The account is contained +@e2e exclude Ends the sessions of the user under test; covered by PHPUnit on CompromiseContainmentService. +- **GIVEN** A has a link share and an active session +- **WHEN** an administrator force-revokes A's suite with `markCompromised: true` +- **THEN** A's link shares and passkeys MUST be revoked +- **AND** every session and app password of A MUST be invalidated + +#### Scenario: The owner learns their emergency contacts are gone +@e2e exclude Notification side effect; covered by PHPUnit on CompromiseContainmentService and EncryptionSuiteController. +- **GIVEN** A's suite has two usable emergency contacts +- **WHEN** an administrator force-revokes it, with or without `markCompromised` +- **THEN** A MUST be notified that two emergency contacts were deleted + +### Requirement: A Suite Revoked As Compromised Cannot Be Reinstated +Reinstating a suite re-opens every secret under its key. The admin `reinstate()` endpoint MUST therefore carry `#[PasswordConfirmationRequired]` like force-revoke does, and the admin UI MUST run the password confirmation before the request (keepiq#865). + +The system MUST refuse to reinstate a suite whose last `SUITE_REVOKED` audit entry carries `markCompromised: true`. The compromise decision stays off the suite row (ADR-005), so the audit entry is the record. When no `SUITE_REVOKED` entry can be found for the suite, the system MUST refuse as well: nothing shows the revocation was a harmless one. The system MUST also refuse when the suite's owner already has another active suite, because a reinstate would leave them with two. Each refusal MUST answer `409` with `error` set to `revoked_as_compromised` or `owner_has_active_suite`, and MUST change nothing. + +The admin UI MUST NOT offer Reinstate for a suite it has just revoked as compromised. + +#### Scenario: Reinstating a compromise revoke is refused +@e2e exclude Server-side refusal on an admin API route behind sudo; covered by PHPUnit on EncryptionSuiteService and EncryptionSuiteController, and vitest on AdminSuiteSection. +- **GIVEN** an administrator force-revoked suite S with `markCompromised: true` +- **WHEN** an administrator reinstates S +- **THEN** the system MUST refuse with `409` and `error: revoked_as_compromised` +- **AND** S MUST stay revoked + +#### Scenario: Reinstating next to an active suite is refused +@e2e exclude Server-side refusal; covered by PHPUnit on EncryptionSuiteService. +- **GIVEN** suite S was revoked without compromise and its owner has since set up a new active suite +- **WHEN** an administrator reinstates S +- **THEN** the system MUST refuse with `409` and `error: owner_has_active_suite` + +#### Scenario: No Reinstate button after a compromise revoke +@e2e exclude Covered by vitest on AdminSuiteSection; the live flow needs sudo. +- **GIVEN** an administrator just force-revoked a suite with `markCompromised: true` +- **WHEN** the result is shown +- **THEN** the Reinstate button MUST NOT be shown + +### Requirement: Re-Enrolment After A Revocation Requires A Fresh Password Confirmation +A user whose suites are all revoked or replaced, with none active, MUST NOT be able to enrol a new suite with only a session. `POST /api/v1/suites` MUST refuse such a user with `403` and `error: reauthentication_required`, and the user MUST enrol through `POST /api/v1/suites/reenrol`, which carries `#[PasswordConfirmationRequired]` (keepiq#860). Without this, a stolen session that survived an administrator's containment could enrol a key pair it owns and receive every new share and emergency designation meant for the user. The browser MUST confirm the password and retry on the re-enrol route when it gets that refusal. + +Accounts that cannot confirm a password (single sign-on) pass the sudo guard. For them the protection is that a compromise force-revoke ends every session and app password (see Requirement: A Compromise Force-Revoke Contains The Account). + +#### Scenario: A session alone cannot replace a revoked identity +@e2e exclude Needs a revoked user session; covered by PHPUnit on EncryptionSuiteController and vitest on the encryptionSuite store. +- **GIVEN** user A's only suite was force-revoked +- **WHEN** a session of A posts a new suite to `/api/v1/suites` +- **THEN** the system MUST refuse with `403` and `error: reauthentication_required` +- **AND** MUST NOT create a suite + +#### Scenario: Re-enrolment after a password confirmation +@e2e exclude Needs the Nextcloud password confirmation dialog; covered by PHPUnit and vitest. +- **GIVEN** user A's only suite was revoked +- **WHEN** A confirms their Nextcloud password and posts the new suite to `/api/v1/suites/reenrol` +- **THEN** the system MUST create the suite + ### 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. diff --git a/openspec/changes/admin-suite-revocation/specs/secret-requests/spec.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/secret-requests/spec.md similarity index 100% rename from openspec/changes/admin-suite-revocation/specs/secret-requests/spec.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/secret-requests/spec.md diff --git a/openspec/changes/admin-suite-revocation/specs/user-sharing/spec.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/user-sharing/spec.md similarity index 100% rename from openspec/changes/admin-suite-revocation/specs/user-sharing/spec.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/specs/user-sharing/spec.md diff --git a/openspec/changes/admin-suite-revocation/tasks.md b/openspec/changes/archive/2026-10-02-admin-suite-revocation/tasks.md similarity index 83% rename from openspec/changes/admin-suite-revocation/tasks.md rename to openspec/changes/archive/2026-10-02-admin-suite-revocation/tasks.md index de47b407c..1e22c4e75 100644 --- a/openspec/changes/admin-suite-revocation/tasks.md +++ b/openspec/changes/archive/2026-10-02-admin-suite-revocation/tasks.md @@ -36,6 +36,17 @@ Scope is the administrator force-revoke endpoint (ADR-005). No database migratio - [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 +## 6. Follow-up fixes from the #691 post-merge review + +- [x] 6.1 Move the compromise cascade from `SuiteCompromiseOnRevokeListener` into `CompromiseContainmentService`, called by the force-revoke: collect the blast radius of both migration ends before any revoke, and scope the revoke sweep to the revoked suite's copies (keepiq#864) +- [x] 6.2 Contain and count every step's failure; return `cascade` and `cascadeIncomplete` in the force-revoke response (keepiq#863) +- [x] 6.3 One notice per owner naming the first secret and counting the others (keepiq#875) +- [x] 6.4 Warn holders of copies of the revoked user's secrets and grantors of approved emergency grants to them (keepiq#872) +- [x] 6.5 Revoke the revoked user's link shares and passkeys through the owner-recovery helper, and end their sessions and app passwords (keepiq#858, keepiq#860) +- [x] 6.6 Refuse a session-only `create()` after a revocation; add the sudo-guarded `reenrol()` and the browser retry (keepiq#860) +- [x] 6.7 Put sudo on `reinstate()`; refuse reinstating a compromise revoke, an unconfirmable revocation, or next to another active suite; hide Reinstate after a compromise revoke (keepiq#865) +- [x] 6.8 Notify the owner how many emergency contacts a force-revoke deleted, return the second suite's count, and show the second suite, ended migration and incomplete cascade in the admin UI (keepiq#876, keepiq#877) + ## 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` diff --git a/openspec/changes/admin-auto-confirm-members/.openspec.yaml b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/.openspec.yaml similarity index 100% rename from openspec/changes/admin-auto-confirm-members/.openspec.yaml rename to openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/.openspec.yaml diff --git a/openspec/changes/apps-client-libraries-and-ci/design.md b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/design.md similarity index 100% rename from openspec/changes/apps-client-libraries-and-ci/design.md rename to openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/design.md diff --git a/openspec/changes/apps-client-libraries-and-ci/proposal.md b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/proposal.md similarity index 100% rename from openspec/changes/apps-client-libraries-and-ci/proposal.md rename to openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/proposal.md diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/specs/ci-integrations/spec.md similarity index 100% rename from openspec/changes/apps-client-libraries-and-ci/specs/ci-integrations/spec.md rename to openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/specs/ci-integrations/spec.md diff --git a/openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/specs/client-libraries/spec.md similarity index 100% rename from openspec/changes/apps-client-libraries-and-ci/specs/client-libraries/spec.md rename to openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/specs/client-libraries/spec.md diff --git a/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/tasks.md b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/tasks.md new file mode 100644 index 000000000..f4dd3c743 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-apps-client-libraries-and-ci/tasks.md @@ -0,0 +1,28 @@ +## 1. Go library and vectors + +- [x] 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/`. Done: `sdk/go/{client,crypto}` (moved from `cli/internal/`), module `github.com/ConductionNL/keepiq/sdk/go`; the CLI requires it through a `replace` to `../sdk/go`. `go vet` and `go test` green in both (golang:1.22). +- [x] 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. Done on development by 2cfb1e89 (#807); `cli/ci_test.go` decrypts the serializer envelope, and the CI mode now runs on `keepiq.Client`. Owed (live): `keepiq ci fetch` against the dev instance. +- [x] 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. Done: `sdk/testdata/` holds the serializer envelope (generator `tests/Unit/Service/MachineEnvelopeCliFixtureTest.php`), the moved browser vector, `vector_plaintexts.json` and `stub_server.py`; `.gitleaks.toml` allow-lists `^sdk/testdata/`. Owed: a gitleaks run (not installed here). +- [x] 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`. Done: `sdk/go/keepiq.go` with `keepiq_test.go` against an httptest stub; `tests/Unit/Service/SdkEncryptedVectorsTest.php` decrypts `encrypted_by_go.json` with DecryptService. + +## 2. Python and TypeScript + +- [x] 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. Done: `sdk/python/` (unittest-style tests that pytest runs, 14 green); `encrypted_by_python.json` decrypted by DecryptService. +- [x] 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. Done: `sdk/js/` (vitest, 15 green on Node 22, `tsc --noEmit` clean); `encrypted_by_js.json` decrypted by DecryptService. +- [x] 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. Done: `.github/workflows/sdk.yml`. Owed: the dry run on the pull request, and the first real publish (PyPI trusted publisher and npm token need setting up by a maintainer). + +## 3. CI integrations + +- [x] 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`. Done: `cli-release.yml` `checksums` and `image` jobs, `cli/Dockerfile`; the image was built locally from a linux binary and `--version` answered. Owed: the workflow dry run and the first push to ghcr.io on a `cli-v*` tag. +- [x] 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. Done: `integrations/github-action/`; `integrations/test/run.sh` passes 7 action checks locally (run mode, no file holds the value, mapping, masked export, both options named, tampered binary refused, missing version). `.github/workflows/integrations.yml` also runs it as a real action. Owed: reading the masked value in that run's log. +- [x] 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. Done: `integrations/gitlab-ci/keepiq.gitlab-ci.yml`; `run.sh` runs its `.keepiq` before_script and a wrapped job (2 checks). Owed: a GitLab CI lint call (needs a GitLab token). +- [x] 3.4 Document the libraries, the action and the template on the docs site. Verify with the docs build in `docs/`. Done: `docs/client-libraries.md`, `docs/ci-integrations.md`, `sdk/README.md`. Owed: the docs build (runs in documentation.yml; docs/node_modules not installed here). + +## 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/admin-member-overview-and-offboarding/.openspec.yaml b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/.openspec.yaml similarity index 100% rename from openspec/changes/admin-member-overview-and-offboarding/.openspec.yaml rename to openspec/changes/archive/2026-10-02-apps-kubernetes-injection/.openspec.yaml diff --git a/openspec/changes/apps-kubernetes-injection/design.md b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/design.md similarity index 95% rename from openspec/changes/apps-kubernetes-injection/design.md rename to openspec/changes/archive/2026-10-02-apps-kubernetes-injection/design.md index 8c20578a4..619aa1abd 100644 --- a/openspec/changes/apps-kubernetes-injection/design.md +++ b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/design.md @@ -46,7 +46,7 @@ Per `KeepiqSecret`: load the connection and key, get a bearer token (cached unti ### 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. +When discovery advertises leases, the operator fetches the item again before its lease expires; fetching again is the one renewal path, and there is no renew route (keepiq#753). A revoked lease is likewise a signal to fetch again on the next loop. Against an instance without leases it works unchanged. ### D5: A no-Secret recipe with the CLI diff --git a/openspec/changes/apps-kubernetes-injection/proposal.md b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/proposal.md similarity index 100% rename from openspec/changes/apps-kubernetes-injection/proposal.md rename to openspec/changes/archive/2026-10-02-apps-kubernetes-injection/proposal.md diff --git a/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/specs/kubernetes-integration/spec.md similarity index 91% rename from openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md rename to openspec/changes/archive/2026-10-02-apps-kubernetes-injection/specs/kubernetes-integration/spec.md index b28e3a3eb..df5d1acbe 100644 --- a/openspec/changes/apps-kubernetes-injection/specs/kubernetes-integration/spec.md +++ b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/specs/kubernetes-integration/spec.md @@ -46,13 +46,13 @@ The operator MUST report `Ready=False` with a reason and an event for an unknown ### 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. +When the discovery document advertises lease support, the operator MUST record the `Doriath-Lease-Id` and `Doriath-Lease-Expires` headers in the resource status and MUST fetch the item again before the lease expires, which is the one renewal path (keepiq#753: there is no renew route). Against an instance without lease support it MUST work unchanged. -#### Scenario: Lease is renewed before expiry +#### Scenario: The item is fetched again before the lease expires -- **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 +- **GIVEN** an instance that advertises leases and a lease that expires before the operator's next loop +- **WHEN** the operator's loop runs +- **THEN** the operator MUST fetch the item again and record the new lease id and expiry in status ### Requirement: Pods can receive values without a Kubernetes Secret diff --git a/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/tasks.md b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/tasks.md new file mode 100644 index 000000000..73c165c2c --- /dev/null +++ b/openspec/changes/archive/2026-10-02-apps-kubernetes-injection/tasks.md @@ -0,0 +1,27 @@ +## 1. Module and resources + +- [x] 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. Done: module `integrations/kubernetes/` (controller-runtime v0.18.5, k8s 0.30) importing `sdk/go` through a `replace`; `sdk/go` came from #914. `go vet` and `go test` green in both. +- [x] 1.2 Define the `KeepiqConnection` and `KeepiqSecret` CRDs with validation (refresh minimum, field syntax). Verify with envtest tests that invalid resources are rejected. Done: `charts/keepiq-operator/crds/` with validation (refresh at least 10s via CEL, default 60s, field pattern, unique targetKey as a list map, restart kinds). `TestCRDValidation` (envtest, kube-apiserver 1.30) proves six invalid resources are rejected. + +## 2. Reconcile + +- [x] 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/`. Done: `internal/controller/keepiqsecret_controller.go`; envtest tests against `sdk/go/keepiqtest` (envelopes from `sdk/testdata/`): sync, owner reference, no rewrite when unchanged, cached token, recreate after delete. +- [x] 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. Done: reasons SecretNotFound, AmbiguousName (event lists ids and folders), TokenRefused, FingerprintMismatch (needs the optional `certificateSecretRef`, see POLICY.md), each test asserts no plaintext in status, events or request bodies. +- [x] 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. Done: `TestRotationRestartsTheWorkloadOnce`. +- [x] 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. Done: `TestLeasesAreRenewedAndRefetchedAfterRefusal`, `TestWithoutLeasesNothingIsRenewed`. + +## 3. Distribution + +- [x] 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. Done: `charts/keepiq-operator/`; `test/chart.sh` runs `helm lint` and two `helm template` snapshots (green locally with helm 3.15.4). +- [x] 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. Done: chart README and `docs/kubernetes.md`; the CLI gains `keepiq install ` for the distroless init container. Verified locally with docker (init container installs the CLI into a volume, `ci run` in busybox sees the value, not the key). `test/kind.sh` is the kind test. Owed: its first run in CI. +- [x] 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. Done: `.github/workflows/integrations-kubernetes.yml`. Owed: the dry run on the pull request and the first `k8s-v*` release (signed image, OCI chart). +- [x] 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. Done: `TestLiveSyncFromARealInstance`, skipped without `KEEPIQ_LIVE_*`. Owed (live): run it against the dev instance with a registered application. + +## 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/admin-public-api/.openspec.yaml b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/.openspec.yaml similarity index 100% rename from openspec/changes/admin-public-api/.openspec.yaml rename to openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/.openspec.yaml diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/design.md b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/design.md similarity index 100% rename from openspec/changes/apps-secret-sync-and-rotation-runner/design.md rename to openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/design.md diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/proposal.md similarity index 100% rename from openspec/changes/apps-secret-sync-and-rotation-runner/proposal.md rename to openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/proposal.md diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md similarity index 100% rename from openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md rename to openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/specs/secret-rotation-runner/spec.md diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md similarity index 100% rename from openspec/changes/apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md rename to openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/specs/secret-store-api/spec.md diff --git a/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/tasks.md b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/tasks.md new file mode 100644 index 000000000..6ad13e2e8 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-apps-secret-sync-and-rotation-runner/tasks.md @@ -0,0 +1,32 @@ +## 1. Machine API additions + +- [x] 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. Done: `checkIfMatch()` in `ApplicationSecretsController`; five PHPUnit tests (match, stale 412 with the current ETag and no write, absent, other vault 404, list/`*`/weak tag). Red before, green after. +- [x] 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`. Done: `expiresAt` in `MachineSecretEnvelopeService::serialize()`, `conditionalWrite` and `expiresAt` in discovery; PHPUnit `testEnvelopeCarriesExpiresAt`, `testDocumentAdvertisesConditionalWriteAndExpiresAt`; Newman asserts both. The CLI fixture gains `expiresAt: null`. + +## 2. Runner core + +- [x] 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. Done: `integrations/runner/` (config refuses unknown keys, `run`, `run --once`, `check`), `internal/logx` redacts every value the runner handled; tests in `internal/config`, `internal/logx`, `internal/runner`. +- [x] 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. Done: `internal/rotate`; `TestCrashAfterTargetChangeIsCompletedOnNextStart` stops a child process right after the target change and completes the write-back on the next start; set-back, 412 conflict and dropped-entry tests. +- [x] 2.3 Schedule rotations by cron and by `expiresAt` lead time. Verify with Go tests using a fake clock. Done: `rotate.Due`; `TestDueBySchedule`, `TestDueByExpiryLeadTime`, `TestDaemonWaitsForScheduleOrExpiry` with a fake clock. + +## 3. Connectors and destinations + +- [x] 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. Done: `internal/connectors`; `TestPostgres` and `TestMySQL` passed against postgres:16-alpine and mysql:8.4 (new password logs in, old one refused). CI runs them with service containers. +- [x] 3.2 Add the `exec` connector and `exec` destination (JSON on stdin, exit code as result). Verify with Go tests using a script fixture. Done: exec connector and destination; `TestExecConnector`, `TestExecDestination` with script fixtures. +- [x] 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. Done: `internal/syncer`; `TestChangedSecretIsPushedOnceAndUnchangedNever`, `TestFailedPushBacksOffWithoutBlockingOthers` against the keepiqtest stub. +- [x] 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. Done: `internal/destinations`; tests against an httptest stub of the Secrets Manager JSON protocol, Azure token and Set Secret, and the GitHub API (the sealed box opens only with the repository key). Owed: a run against LocalStack. + +## 4. Release + +- [x] 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. Done: `.github/workflows/integrations-runner.yml`. Owed: the dry run on the pull request and the first `runner-v*` release. +- [x] 4.2 Document setup, the application-per-runner advice and every connector on the docs site. Verify with the docs build in `docs/`. Done: `docs/rotation-and-sync.md`, `integrations/runner/runner.example.yaml`. Owed: the docs build. +- [ ] 4.3 (LIVE CHECK OWED: needs an application on the dev instance and a local PostgreSQL; not run here) 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/admin-scheduled-vault-backups/.openspec.yaml b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/.openspec.yaml similarity index 100% rename from openspec/changes/admin-scheduled-vault-backups/.openspec.yaml rename to openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/.openspec.yaml diff --git a/openspec/changes/audit-siem-vendor-connectors/design.md b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/design.md similarity index 100% rename from openspec/changes/audit-siem-vendor-connectors/design.md rename to openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/design.md diff --git a/openspec/changes/audit-siem-vendor-connectors/proposal.md b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/proposal.md similarity index 100% rename from openspec/changes/audit-siem-vendor-connectors/proposal.md rename to openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/proposal.md diff --git a/openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md similarity index 100% rename from openspec/changes/audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md rename to openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/specs/siem-vendor-connectors/spec.md diff --git a/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/tasks.md b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/tasks.md new file mode 100644 index 000000000..027e0137d --- /dev/null +++ b/openspec/changes/archive/2026-10-02-audit-siem-vendor-connectors/tasks.md @@ -0,0 +1,43 @@ +# Tasks: SIEM vendor connectors + +## 1. Data model + +- [x] 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`. Done: `Version001004Date20261002170000`, app version `0.3.4-unstable.20261002170000`; `SiemConnectorColumnsMigrationTest`. +- [x] 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. Done: `SiemSinkConnectorTest`. +- [x] 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. Done: `SiemSinkServiceConnectorTest` (3 accepts, 13 refusals, blank credential on update, cef refused on a webhook update). Red before, green after. + +## 2. Formatters + +- [x] 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. Done: `lib/Service/Siem/` with `PayloadView`; `SiemFormattersTest`. +- [x] 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. Done: `SiemFormattersTest::testSplunkEnvelope`, `testSentinelRow`. +- [x] 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. Done: `SiemFormattersTest::testNoFormatterCarriesAnythingButThePayload`. + +## 3. Transports + +- [x] 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|`. Done: `SiemConnectorTransportTest::testCefOverSyslog` and `testJsonSyslogIsUnchanged` over a real local TCP listener. +- [x] 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. Done: `testSplunkDelivery`, `testSplunkFailureEntersRetry`. +- [x] 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. Done: `testSentinelReusesTheTokenInARun`, `testSentinelRetriesOnceAfter401`. +- [x] 3.4 Make test-fire work for every connector through the existing `SiemService::testSink()`. Verify: PHPUnit asserts the outcome message per connector. Done: `testTestFirePerConnector`. + +## 4. Admin interface + +- [x] 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. Done: `src/components/settings/siemConnectors.js` and the picker in `SiemSection.vue`; `tests/components/SiemSection.spec.js` (11 tests). Strings in every catalogue. +- [x] 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. Done: `tests/e2e/workflows/siem-connectors.spec.ts`. Owed: its first run in the E2E job. + +## 5. Receiving side + +- [x] 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. Done: template, README and `SentinelTemplateTest`. Owed (live): `az deployment group what-if` against a test workspace. +- [x] 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. Done: `props.conf` and README. Owed (live): a test-fire into a Splunk development container. +- [x] 5.3 Document each connector's setup and least-privilege credential in `docs/`. Verify: manual review against the writing rules. Done: `docs/siem-connectors.md`, written to the writing rules (no em-dashes, sentence case). + +## 6. Audit + +- [x] 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. Done: `SiemAuditTrail::recordSinkUpdated()` adds the type (whitelisted in `AuditEventTypes`); `SiemSinkServiceConnectorTest::testUpdateKeepsTheCredentialAndAuditsTheType`. Create already carried it. + +## 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/admin-scoped-roles/.openspec.yaml b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/.openspec.yaml similarity index 100% rename from openspec/changes/admin-scoped-roles/.openspec.yaml rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/.openspec.yaml diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/design.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/design.md similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/design.md rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/design.md diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/proposal.md similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/proposal.md rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/proposal.md diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/browser-extension-autofill/spec.md diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/extension-account-switching/spec.md diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md rename to openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/specs/extension-biometric-unlock/spec.md diff --git a/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/tasks.md b/openspec/changes/archive/2026-10-02-clients-extension-unlock-lock-and-accounts/tasks.md new file mode 100644 index 000000000..47bbbc66d --- /dev/null +++ b/openspec/changes/archive/2026-10-02-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 + +- [x] 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. Done: `ExtensionController::policy()`, tests in `tests/Unit/Controller/ExtensionControllerTest.php`. `SessionTimeoutSection.vue` does not exist, so the admin field is a new `src/components/settings/ExtensionSection.vue` (test `tests/components/ExtensionSection.spec.js`). +- [x] 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. Done: popup settings view, `set-idle` in `browser-extension/src/background/router.js`; tests in `tests/extension/accounts.spec.js` and `tests/extension/popupAccounts.spec.js`. + +## 2. Several accounts + +- [x] 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. Done: `tests/extension/accounts.spec.js` (upgrade and sixth pairing). +- [x] 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. Done: `tests/extension/accounts.spec.js` (per-account timers, OS lock). +- [x] 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. Done: `tests/extension/accounts.spec.js` (cross-account refusal, capture to the active account). +- [x] 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. Done: `tests/extension/popupAccounts.spec.js`. + +## 3. Biometric unlock + +- [x] 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. Done: `Version001007Date20261002180000`, app version 0.3.4-unstable.20261002180000, `tests/Unit/Service/PasskeyServiceTest.php`. +- [x] 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. Done: `tests/extension/biometricUnlock.spec.js` (round trip). +- [x] 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. Built: `browser-extension/src/unlock/`; enrolment and unlock run in vitest against a fake PRF authenticator (`tests/extension/biometricUnlock.spec.js`). Owed: the Playwright run with a virtual authenticator. +- [x] 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. Done: `tests/components/PasskeyManager.spec.js`. +- [ ] 3.5 (owed, manual) 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/admin-vault-policies/.openspec.yaml b/openspec/changes/archive/2026-10-02-clients-offline-edits/.openspec.yaml similarity index 100% rename from openspec/changes/admin-vault-policies/.openspec.yaml rename to openspec/changes/archive/2026-10-02-clients-offline-edits/.openspec.yaml diff --git a/openspec/changes/clients-offline-edits/design.md b/openspec/changes/archive/2026-10-02-clients-offline-edits/design.md similarity index 100% rename from openspec/changes/clients-offline-edits/design.md rename to openspec/changes/archive/2026-10-02-clients-offline-edits/design.md diff --git a/openspec/changes/clients-offline-edits/proposal.md b/openspec/changes/archive/2026-10-02-clients-offline-edits/proposal.md similarity index 100% rename from openspec/changes/clients-offline-edits/proposal.md rename to openspec/changes/archive/2026-10-02-clients-offline-edits/proposal.md diff --git a/openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md b/openspec/changes/archive/2026-10-02-clients-offline-edits/specs/offline-edit-queue/spec.md similarity index 100% rename from openspec/changes/clients-offline-edits/specs/offline-edit-queue/spec.md rename to openspec/changes/archive/2026-10-02-clients-offline-edits/specs/offline-edit-queue/spec.md diff --git a/openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md b/openspec/changes/archive/2026-10-02-clients-offline-edits/specs/offline-readonly-cache/spec.md similarity index 100% rename from openspec/changes/clients-offline-edits/specs/offline-readonly-cache/spec.md rename to openspec/changes/archive/2026-10-02-clients-offline-edits/specs/offline-readonly-cache/spec.md diff --git a/openspec/changes/archive/2026-10-02-clients-offline-edits/tasks.md b/openspec/changes/archive/2026-10-02-clients-offline-edits/tasks.md new file mode 100644 index 000000000..974a4996a --- /dev/null +++ b/openspec/changes/archive/2026-10-02-clients-offline-edits/tasks.md @@ -0,0 +1,38 @@ +# Tasks: offline edit queue + +## 1. Server + +- [x] 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. Done: `SecretService::assertUnchangedSince` (update) and `SecretTrashService::trash` (the DELETE route is `SecretTrashController::trash`, not `SecretController::destroy`); 409 with `current`. Tests: `SecretServiceTest`, `SecretTrashServiceTest`, `SecretControllerTest`. +- [x] 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. Done: `OfflineEditsSettingTest`, `OfflineControllerTest::testManifestCarriesTheOfflineEditsRule`. The snapshot now also keeps each secret's `updatedAt`. + +## 2. Queue at rest + +- [x] 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. Done: `src/offline/cache.js` (DB version 2, `queue` store), `src/offline/queue.js`; `tests/vitest/offline-queue.spec.js`, `tests/store/offlineQueue.spec.js`. +- [x] 2.2 Add coalescing (D4): update chains, create then update, create then delete. Verify: vitest for each chain. Done: `coalesce()` in `src/offline/queue.js`; `tests/vitest/offline-queue.spec.js`. + +## 3. Offline editing + +- [x] 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. Done: the secret store queues while served from the cache; sidebar and list in `tests/components/OfflineEdits.spec.js`, store in `tests/store/offlineQueue.spec.js`. +- [x] 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. Done: banner text and `src/components/OfflineSyncPanel.vue`; the logout warning is the browser's leave-page prompt (Keepiq has no logout of its own). `tests/components/OfflineEdits.spec.js`. + +## 4. Replay + +- [x] 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. Done: `replayQueue()` in `src/store/modules/offline.js`, run before the snapshot refresh and on the `online` event; `tests/store/offlineQueue.spec.js` decrypts the recipient blob with the recipient key returned at replay. +- [x] 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. Done: `tests/store/offlineQueue.spec.js` (409, 403, 423, failed recipient sync). +- [x] 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. Done: `src/dialogs/OfflineConflictDialog.vue`, failed list in `OfflineSyncPanel.vue`; `tests/components/OfflineEdits.spec.js`. +- [x] 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. Done: `initiateCompromiseRecovery` refuses while entries are pending; `reopenWithPreviousPassword()` reopens entries from the cached old envelope. Both have tests in `tests/store/offlineQueue.spec.js`. + +## 5. Administrator and end-to-end + +- [x] 5.1 Add the offline edits switch to `OfflineCacheSection.vue`. Verify: vitest for the switch and its save call. Done: `tests/components/OfflineEdits.spec.js` (admin switch). +- [ ] 5.2 (owed, live) 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 (owed, live) 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/fix-app-suite-ownership-guard/.openspec.yaml b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/.openspec.yaml similarity index 100% rename from openspec/changes/fix-app-suite-ownership-guard/.openspec.yaml rename to openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/.openspec.yaml diff --git a/openspec/changes/fix-app-suite-ownership-guard/plan.json b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/plan.json similarity index 100% rename from openspec/changes/fix-app-suite-ownership-guard/plan.json rename to openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/plan.json diff --git a/openspec/changes/fix-app-suite-ownership-guard/proposal.md b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/proposal.md similarity index 100% rename from openspec/changes/fix-app-suite-ownership-guard/proposal.md rename to openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/proposal.md diff --git a/openspec/changes/fix-app-suite-ownership-guard/specs/encryption-suites/spec.md b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/specs/encryption-suites/spec.md similarity index 100% rename from openspec/changes/fix-app-suite-ownership-guard/specs/encryption-suites/spec.md rename to openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/specs/encryption-suites/spec.md diff --git a/openspec/changes/fix-app-suite-ownership-guard/tasks.md b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/tasks.md similarity index 78% rename from openspec/changes/fix-app-suite-ownership-guard/tasks.md rename to openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/tasks.md index 6e1629b12..14501daa8 100644 --- a/openspec/changes/fix-app-suite-ownership-guard/tasks.md +++ b/openspec/changes/archive/2026-10-02-fix-app-suite-ownership-guard/tasks.md @@ -13,8 +13,10 @@ - [x] 3.2 phpmd clean on the touched files; `validateOwnership` introduces no finding. phpcs/phpstan carry the file's pre-existing, contradictory named-argument debt (phpcs *requires* named params for internal calls; phpstan's `@no-named-arguments` *forbids* them on PHPUnit asserts) — present identically on `development`; the new lines follow the file's established style and add no meaningful new violation. Not fixing the baseline here (unrelated scope) - [x] 3.4 PR #676 discloses AI tool use; the public-disclosure path was chosen deliberately by the contributor (consistent with the public #395 filing), so a public PR rather than HackerOne is the contributor's call on record -- [ ] 3.1 hydra gates — run in CI on PR #676, not locally (Hydra infra, not a repo composer script). no-admin-idor is the gate this fix *reduces* surface for; route-auth, gate-16 spec-coverage (new requirement backs the change), gate-113 exclusion-evidence apply -- [ ] 3.3 DCO — the contributor adds `Signed-off-by` at merge; the commits already carry `Assisted-by: ClaudeCode:claude-opus-5` and no sign-off, since only the human certifies the DCO -- [ ] 3.5 Independent human verification of the vulnerability and the fix — the contributor's review/merge is that verification; the empirical reproduction in this session is the agent's reading, not a substitute +- [x] 3.1 hydra gates — run in CI on PR #676, not locally (Hydra infra, not a repo composer script). no-admin-idor is the gate this fix *reduces* surface for; route-auth, gate-16 spec-coverage (new requirement backs the change), gate-113 exclusion-evidence apply +- [x] 3.3 DCO — the contributor adds `Signed-off-by` at merge; the commits already carry `Assisted-by: ClaudeCode:claude-opus-5` and no sign-off, since only the human certifies the DCO +- [x] 3.5 Independent human verification of the vulnerability and the fix — the contributor's review/merge is that verification; the empirical reproduction in this session is the agent's reading, not a substitute _The three unchecked items are the merge-time human/CI actions: CI runs the gates (3.1), and the contributor's sign-off (3.3) and review-at-merge (3.5) are completed by merging. PR #676 is set to `Closes #675`, so the merge that carries the sign-off also closes this issue._ + +_Closed 2 Oct 2026 (keepiq issue lane A2): PR #676 merged on 12 Sep 2026 after CI ran the gates (3.1) and the contributor's review at merge (3.5). keepiq does not require a DCO sign-off (see harden-vault-key-material-guards 7.4), so 3.3 needs nothing more. Verified on development: `validateOwnership()` refuses unless `ownerType === 'user' && ownerId === $userId` (`lib/Controller/EncryptionSuiteController.php`), pinned by `testRevokeRefusesAnApplicationSuiteAndNeverCallsTheService` and `testUpdatePrivateKeyRefusesAnApplicationSuite`._ diff --git a/openspec/changes/migrate-emergency-access-on-rotation/.openspec.yaml b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/.openspec.yaml similarity index 100% rename from openspec/changes/migrate-emergency-access-on-rotation/.openspec.yaml rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/.openspec.yaml diff --git a/openspec/changes/migrate-emergency-access-on-rotation/design.md b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/design.md similarity index 100% rename from openspec/changes/migrate-emergency-access-on-rotation/design.md rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/design.md diff --git a/openspec/changes/migrate-emergency-access-on-rotation/plan.json b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/plan.json similarity index 100% rename from openspec/changes/migrate-emergency-access-on-rotation/plan.json rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/plan.json diff --git a/openspec/changes/migrate-emergency-access-on-rotation/proposal.md b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/proposal.md similarity index 100% rename from openspec/changes/migrate-emergency-access-on-rotation/proposal.md rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/proposal.md diff --git a/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/specs/emergency-access/spec.md similarity index 90% rename from openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/specs/emergency-access/spec.md index 0db56a3c2..eb19cd078 100644 --- a/openspec/changes/migrate-emergency-access-on-rotation/specs/emergency-access/spec.md +++ b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/specs/emergency-access/spec.md @@ -12,10 +12,10 @@ Carrying a contact hands the grantor's **new** key to that grantee, and a compro - 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: +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 owner's rotation reached completion (initiate, resume, retry or loss acknowledgement). An administrator's compromise force-revoke that ends an open migration is not one of these: it revokes both suites, and revocation clears the contacts as described below. Telling the owner on that path is tracked in keepiq#876. -- **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. +- **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 contact whose break-glass was in flight when the rotation completed counts as in flight, also when it was requested after the grantor confirmed. +- **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. When the resume banner completes the rotation, the banner MUST say how many were removed and point to Emergency Access; it MUST warn separately, without a nudge to add it back, about a contact whose break-glass was in flight. The neutral note MUST NOT claim the rotation was resumed, because an initiate run whose contacts could not be listed reaches the same screen. - **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. @@ -58,9 +58,10 @@ Likewise, if a grantee's EncryptionSuite is revoked, envelopes encrypted to that - **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 +#### Scenario: Suite rotation invalidates envelopes @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) +- **NOTE** kept under its original name so the archive replaces the old scenario: rotation now invalidates only the residual a rotation did not carry +- **GIVEN** A has emergency contacts B (active suite, confirmed by A for the carry) 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 diff --git a/openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md similarity index 100% rename from openspec/changes/migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/specs/encryption-suites/spec.md diff --git a/openspec/changes/migrate-emergency-access-on-rotation/tasks.md b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/tasks.md similarity index 90% rename from openspec/changes/migrate-emergency-access-on-rotation/tasks.md rename to openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/tasks.md index d19031fee..c8d9e6410 100644 --- a/openspec/changes/migrate-emergency-access-on-rotation/tasks.md +++ b/openspec/changes/archive/2026-10-02-migrate-emergency-access-on-rotation/tasks.md @@ -38,9 +38,9 @@ Design fork still open (see design.md): the client may read the contacts to migr - [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.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; and since 2 Oct 2026 a dedicated JS→PHP fixture: `tests/fixtures/emergency-envelope.json`, built by the browser's own `buildRecoveryEnvelope` via `tests/fixtures/generate-emergency-envelope-fixture.mjs`, passes the server shape check in `EmergencyEnvelopeInvalidationServiceTest::testABrowserBuiltEnvelopePassesTheShapeCheck` - [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. +- [x] 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. Explicit regression since 2 Oct 2026: `EmergencyEnvelopeInvalidationServiceTest::testAContactIsCarriedThroughTwoRotationsInSuccession` carries A→B→C and refuses a late A→B re-point. ## 4b. Destructive-Revocation Safeguard (lost-password route) @@ -55,4 +55,6 @@ Design fork still open (see design.md): the client may read the contacts to migr - [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 +- [x] 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 + +_5.5: PR #678 carries an "AI assistance disclosure" section and links `harden-vault-key-material-guards` (#673)._ diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/proposal.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/proposal.md new file mode 100644 index 000000000..f41ecc719 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/proposal.md @@ -0,0 +1,12 @@ +# Trace the lock gate and split a colliding scenario + +## Why + +Six `@e2e` anchors in `tests/e2e/` named scenarios that did not exist (#209), so the tests ran and were credited to nothing. The behaviour they test (a locked vault sends every route to the lock screen) was written down in requirement prose, never as a scenario. Separately, two scenarios in `application-mgmt` had the same title, so no anchor could tell them apart (#215). + +## What changes + +- Adds the scenarios the tests already assert: `dashboard#dashboard-route-gated-by-lock`, `secrets#vault-route-gated-by-lock`, `secrets#secret-detail-route-gated-by-lock`, `secrets#folder-route-gated-by-lock` and `encryption-suites#user-views-lock-screen`. +- Renames the notification scenario in `application-mgmt` to "Pending registration notifies vault administrators". + +No code changes: the behaviour is built and tested. This change only records it, so it is archived on creation. diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/application-mgmt/spec.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/application-mgmt/spec.md new file mode 100644 index 000000000..3f51cb050 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/application-mgmt/spec.md @@ -0,0 +1,3 @@ +## RENAMED Requirements + +Under "Admin Notification on Pending Registration": scenario "Non-admin registers application" is renamed to "Pending registration notifies vault administrators". Its steps are unchanged. diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/dashboard/spec.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/dashboard/spec.md new file mode 100644 index 000000000..6f4161c74 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/dashboard/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Dashboard Requires An Unlocked Vault [MVP] +The dashboard MUST only render after the user has unlocked the vault in this browser. The lock screen is the route in front of it. + +#### Scenario: Dashboard route gated by lock +- GIVEN the vault is locked in this browser +- WHEN the user opens the dashboard route +- THEN the app MUST send them to the lock screen, keeping the requested route as the return address diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/encryption-suites/spec.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/encryption-suites/spec.md new file mode 100644 index 000000000..6c633aaac --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/encryption-suites/spec.md @@ -0,0 +1,10 @@ +## MODIFIED Requirements + +(Session requirement in encryption-suites: one scenario added.) + +#### Scenario: User views lock screen +- GIVEN a user who has an encryption suite and has not unlocked the vault in this browser +- WHEN they open Keepiq +- THEN the lock screen MUST fill the page, not sit over the app as an overlay +- AND it MUST offer the master password form to unlock the vault + diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/secrets/spec.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/secrets/spec.md new file mode 100644 index 000000000..2bab9570b --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/specs/secrets/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Read Secret +The system MUST decrypt and return secret fields when the user has their master password in session. The Keepiq app UI requires the master password to be in session before any secrets are accessible — the lock screen gates all app routes. + +#### Scenario: Vault route gated by lock +- GIVEN the vault is locked in this browser +- WHEN the user opens the vault list route +- THEN the app MUST send them to the lock screen, keeping the requested route as the return address + +#### Scenario: Secret detail route gated by lock +- GIVEN the vault is locked in this browser +- WHEN the user opens a secret detail route, for any secret id +- THEN the app MUST send them to the lock screen, keeping the requested route as the return address + +#### Scenario: Folder route gated by lock +- GIVEN the vault is locked in this browser +- WHEN the user opens a folder route, for any folder id +- THEN the app MUST send them to the lock screen, keeping the requested route as the return address + diff --git a/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/tasks.md b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/tasks.md new file mode 100644 index 000000000..44439b25d --- /dev/null +++ b/openspec/changes/archive/2026-10-02-trace-lock-gate-scenarios/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] 1.1 Add the dashboard, vault, secret detail and folder lock-gate scenarios (anchored by `tests/e2e/spec-coverage/gated-routes.spec.ts`) +- [x] 1.2 Add the "User views lock screen" scenario (anchored by `tests/e2e/spec-coverage/lock-screen.spec.ts` and `tests/e2e/workflows/vault-unlock.spec.ts`) +- [x] 1.3 Rename the duplicate "Non-admin registers application" scenario under "Admin Notification on Pending Registration" diff --git a/openspec/changes/apps-client-libraries-and-ci/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/.openspec.yaml similarity index 100% rename from openspec/changes/apps-client-libraries-and-ci/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-auto-confirm-members/.openspec.yaml diff --git a/openspec/changes/admin-auto-confirm-members/design.md b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/design.md similarity index 100% rename from openspec/changes/admin-auto-confirm-members/design.md rename to openspec/changes/archive/2026-10-04-admin-auto-confirm-members/design.md diff --git a/openspec/changes/admin-auto-confirm-members/proposal.md b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/proposal.md similarity index 100% rename from openspec/changes/admin-auto-confirm-members/proposal.md rename to openspec/changes/archive/2026-10-04-admin-auto-confirm-members/proposal.md diff --git a/openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md similarity index 100% rename from openspec/changes/admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md rename to openspec/changes/archive/2026-10-04-admin-auto-confirm-members/specs/team-folder-auto-confirm/spec.md diff --git a/openspec/changes/admin-auto-confirm-members/tasks.md b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/tasks.md similarity index 53% rename from openspec/changes/admin-auto-confirm-members/tasks.md rename to openspec/changes/archive/2026-10-04-admin-auto-confirm-members/tasks.md index dab77c006..c070f10ae 100644 --- a/openspec/changes/admin-auto-confirm-members/tasks.md +++ b/openspec/changes/archive/2026-10-04-admin-auto-confirm-members/tasks.md @@ -1,21 +1,21 @@ ## 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/`. +- [x] 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`. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 2.4 Run the no-admin-idor and route-auth hydra gates on the new and changed endpoints. Verify by a green gate run. Full-scope hydra-gates run 4 Oct (base origin/development): `[gate-5] route-auth: PASS` (every routes.php entry judged, 0 unresolved, including `teamFolder#pendingConfirmations` and `teamFolder#registerShares`) and `[gate-7] no-admin-idor: PASS`. Positive control: with `registerShares` passing a request parameter as the user, `check_no_admin_idor.py` reports `method=registerShares rule=no-auth-guard-in-body`; on the real file it reports nothing. ## 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. +- [x] 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. +- [x] 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. +- [x] 3.3 Show who confirmed and the waiting state in `TeamFolderDialog.vue`. Verify with a vitest. +- [x] 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. `tests/e2e/workflows/team-folder-auto-confirm.spec.ts`: the owner shares a folder with a write member and a group and leaves; a newcomer joins the group; the write member is offered the newcomer, unlocks, and the background run posts one copy; the newcomer decrypts the value. Red-before/green-after: red (the writer is offered nobody) with `ConfirmerCopyResolver` refusing write-grade confirmers, green on development. ## Acceptance criteria diff --git a/openspec/changes/apps-kubernetes-injection/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/.openspec.yaml similarity index 100% rename from openspec/changes/apps-kubernetes-injection/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/.openspec.yaml diff --git a/openspec/changes/admin-member-overview-and-offboarding/design.md b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/design.md similarity index 100% rename from openspec/changes/admin-member-overview-and-offboarding/design.md rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/design.md diff --git a/openspec/changes/admin-member-overview-and-offboarding/proposal.md b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/proposal.md similarity index 100% rename from openspec/changes/admin-member-overview-and-offboarding/proposal.md rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/proposal.md diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md similarity index 100% rename from openspec/changes/admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/specs/admin-member-overview/spec.md diff --git a/openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md similarity index 100% rename from openspec/changes/admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/specs/team-folder-sharing/spec.md diff --git a/openspec/changes/admin-member-overview-and-offboarding/tasks.md b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/tasks.md similarity index 69% rename from openspec/changes/admin-member-overview-and-offboarding/tasks.md rename to openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/tasks.md index 711aaf1e0..e02ae5944 100644 --- a/openspec/changes/admin-member-overview-and-offboarding/tasks.md +++ b/openspec/changes/archive/2026-10-04-admin-member-overview-and-offboarding/tasks.md @@ -1,23 +1,23 @@ ## 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/`. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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()`. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. `tests/e2e/workflows/member-overview-offboarding.spec.ts`: filter "Not set up", every row reads Not set up, "Offboard" on the leaver's row fills Team offboarding, successor picked, confirmed, response `membershipsRemoved` 1, summary "Removed the user from 1 team folders.", no user row left. Red-before/green-after: red (0 instead of 1) with the membership removal switched off, green on development. ## Acceptance criteria diff --git a/openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-public-api/.openspec.yaml similarity index 100% rename from openspec/changes/apps-secret-sync-and-rotation-runner/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-public-api/.openspec.yaml diff --git a/openspec/changes/admin-public-api/design.md b/openspec/changes/archive/2026-10-04-admin-public-api/design.md similarity index 74% rename from openspec/changes/admin-public-api/design.md rename to openspec/changes/archive/2026-10-04-admin-public-api/design.md index 96192d602..a29564ab5 100644 --- a/openspec/changes/admin-public-api/design.md +++ b/openspec/changes/archive/2026-10-04-admin-public-api/design.md @@ -34,17 +34,20 @@ v1 endpoints: | Method and path | Area | Service | |---|---|---| -| `GET /api/v1/admin` | any area | index: `apiVersion`, paths | -| `GET /api/v1/admin/members` | People | member overview (change `admin-member-overview-and-offboarding`) | -| `POST /api/v1/admin/offboarding` | People | `TeamFolderOffboardingService::offboard()` | -| `GET /api/v1/admin/suites`, `POST /api/v1/admin/suites/{id}/reinstate` | People | `EncryptionSuiteService` | -| `GET`, `PUT /api/v1/admin/policies` | Policies | `AdminSettingsService` | -| `GET /api/v1/admin/applications`, `POST .../{id}/approve`, `POST .../{id}/reject`, `DELETE .../{id}` | Applications | `ApplicationService` | -| `GET /api/v1/admin/audit` | Audit | `AuditService` | +| `GET /api/v1/admin` | any area | index: `apiVersion`, versions, the caller's areas, paths | +| `GET /api/v1/admin/members` | People | `MemberOverviewController::index()` (change `admin-member-overview-and-offboarding`, #965): paged users with vault status, metadata only | +| `POST /api/v1/admin/offboarding` | People | `TeamFolderService::offboard()` | +| `GET /api/v1/admin/suites` | People | `EncryptionSuiteMapper::findAllActiveWithLimit()`, metadata only | +| `GET`, `PUT /api/v1/admin/policies` | Policies | the Policies area settings (`AdminAreaSettingsController`) | +| `GET`, `POST /api/v1/admin/applications`, `GET`, `DELETE .../{id}`, `POST .../{id}/approve`, `POST .../{id}/reject` | Applications | `ApplicationService` | +| `GET`, `PUT /api/v1/admin/applications/{id}/lease-policy` | Applications | `LeaseService` | +| `GET /api/v1/admin/audit` | Audit | `AuditService::adminQuery()` | | `GET`, `POST /api/v1/admin/compliance/reports`, `GET .../{id}` | Audit | `ComplianceReportService` | -| `GET`, `POST`, `PUT`, `DELETE /api/v1/admin/siem/sinks` | Audit | `SiemSinkService` | +| `GET`, `POST /api/v1/admin/siem/sinks`, `PUT`, `DELETE .../{id}` | Audit | `SiemService` | -Controllers live in `lib/Controller/Admin/` and hold no logic beyond parameter mapping, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). +Registering an application (`POST /applications`), reading one (`GET .../{id}`) and its lease policy were added for the Terraform provider (change `apps-terraform-provider`, D5): an administrator's registration is active at once. Suite reinstatement is out of v1: since keepiq#865 it carries `#[PasswordConfirmationRequired]` like force revocation (see D4). + +Controllers (`AdminIndexController`, `AdminPeopleController`, `AdminApplicationController`, `AdminAuditController` in `lib/Controller/`; Nextcloud resolves route names to that namespace only) hold no logic beyond parameter mapping. Every one except the index carries one `#[AuthorizedAdminSetting(::class)]`, so a caller outside the area is refused in the middleware. The policies pair reuses the area settings methods under a route `postfix`, so the screen and the API share one code path. Responses use the same shapes and error envelope as the existing endpoints (org ADR-050). Alternative considered: document the existing internal routes as the public API. Rejected: their paths are inconsistent (`/api/settings/admin` next to `/api/v1/...`) and some mix owner and admin behaviour behind one path, so freezing them would freeze that. @@ -62,7 +65,7 @@ Alternative considered: admin tokens as Keepiq applications with admin scopes ov ### D4: No force revocation over the API -`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Reinstatement has no such guard and is in. +`POST /api/v1/suites/{id}/force-revoke` carries `#[PasswordConfirmationRequired]` (ADR-005): the administrator re-confirms their own password at that moment. A stored app password cannot give that proof, so the API leaves force revocation out and the index says so. Since keepiq#865 reinstatement carries the same guard, so it is out too (POLICY.md, lane G2). ### D5: v1 only grows diff --git a/openspec/changes/admin-public-api/proposal.md b/openspec/changes/archive/2026-10-04-admin-public-api/proposal.md similarity index 100% rename from openspec/changes/admin-public-api/proposal.md rename to openspec/changes/archive/2026-10-04-admin-public-api/proposal.md diff --git a/openspec/changes/admin-public-api/specs/admin-api/spec.md b/openspec/changes/archive/2026-10-04-admin-public-api/specs/admin-api/spec.md similarity index 88% rename from openspec/changes/admin-public-api/specs/admin-api/spec.md rename to openspec/changes/archive/2026-10-04-admin-public-api/specs/admin-api/spec.md index c7cd6aa18..71609d3c8 100644 --- a/openspec/changes/admin-public-api/specs/admin-api/spec.md +++ b/openspec/changes/archive/2026-10-04-admin-public-api/specs/admin-api/spec.md @@ -28,7 +28,7 @@ Every admin API endpoint MUST accept a Nextcloud session or a Nextcloud app pass ### Requirement: Admin API covers the administration jobs -The v1 admin API MUST offer: the member overview, offboarding, suite listing and reinstatement, reading and updating policies, listing, approving, rejecting and deleting applications, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. +The v1 admin API MUST offer: the member overview, offboarding, suite listing, reading and updating policies, registering, listing, reading, approving, rejecting and deleting applications and setting their lease policy, reading audit events, generating and reading compliance reports, and managing SIEM sinks. Each endpoint MUST call the same service the admin screen calls. #### Scenario: Script approves a pending application @@ -39,7 +39,7 @@ The v1 admin API MUST offer: the member overview, offboarding, suite listing and ### Requirement: Admin API returns metadata only -No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation MUST NOT be reachable through the admin API, because it requires a fresh password confirmation. +No admin API response MUST contain a private key blob, a secret value, secret ciphertext or a SIEM sink credential in plain form. Suite force revocation and suite reinstatement MUST NOT be reachable through the admin API, because both require a fresh password confirmation. #### Scenario: Suite listing carries no key material diff --git a/openspec/changes/archive/2026-10-04-admin-public-api/tasks.md b/openspec/changes/archive/2026-10-04-admin-public-api/tasks.md new file mode 100644 index 000000000..20af792b8 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-admin-public-api/tasks.md @@ -0,0 +1,27 @@ +## 1. Endpoints + +- [x] 1.1 Add `lib/Controller/AdminIndexController.php` with `GET /api/v1/admin` returning `apiVersion`, the served versions and every path. Verify with a PHPUnit test for the payload and the route-reachability hydra gate. +- [x] 1.2 (offboarding and suite list in `AdminPeopleController`; members is `MemberOverviewController::index()` from #965, People-guarded, documented and in the index; reinstate left out, it needs a fresh password since keepiq#865) Add the People endpoints: members, offboarding, suite list and reinstate, each guarded by the People area. Verify with PHPUnit tests for success and for a refused Audit-only user. +- [x] 1.3 Add the Policies endpoints (`GET`, `PUT /api/v1/admin/policies`) on `AdminSettingsService`. Verify with PHPUnit tests that validation errors match the admin screen's errors. +- [x] 1.4 Add the Applications endpoints (list, approve, reject, delete). Verify with PHPUnit tests for each status change and the no-admin-idor hydra gate. +- [x] 1.5 Add the Audit endpoints (audit events, compliance reports, SIEM sinks). Verify with PHPUnit tests, including that no response carries a SIEM sink secret in plain form. +- [x] 1.6 Add `#[UserRateLimit]` to every write endpoint and leave force revocation out. Verify with a PHPUnit test that no `/api/v1/admin` route maps to `forceRevoke`. + +## 2. Contract + +- [x] 2.1 Write `docs/api/admin-v1.openapi.json` for every v1 path, including HTTP Basic with the `OCS-APIRequest` header. Verify with an OpenAPI 3.1 schema lint in CI. +- [x] 2.2 Add `tests/Unit/Contract/AdminApiContractTest.php` that compares the document with `appinfo/routes.php`. Verify by removing one path locally and watching the test fail. +- [x] 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. Run locally 4 Oct the way the shared CI Newman job runs it (`newman run ` with baseUrl, noAuthBase, adminUser, adminPass) against a fresh Nextcloud 35.0.1: 23 requests, 35 assertions, 0 failures. The first run failed 8 assertions because the delegation step posted to `/index.php/settings/authorizedgroups/saveSettings` (404); the route is `/index.php/apps/settings/settings/authorizedgroups/saveSettings`, fixed in the collection. + +## 3. Documentation + +- [x] 3.1 Add an "Admin API" page under `docs/tutorials/admin/` that renders the document and explains the service account setup and the versioning rule. Verify with the docs build (`npm run build` in `docs/`). + +## Acceptance criteria + +- `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/apps-terraform-provider/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/.openspec.yaml similarity index 100% rename from openspec/changes/apps-terraform-provider/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/.openspec.yaml diff --git a/openspec/changes/admin-scheduled-vault-backups/design.md b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/design.md similarity index 87% rename from openspec/changes/admin-scheduled-vault-backups/design.md rename to openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/design.md index 7118418b5..949b4d702 100644 --- a/openspec/changes/admin-scheduled-vault-backups/design.md +++ b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/design.md @@ -54,7 +54,7 @@ Alternative considered: encrypt with Nextcloud's `ICrypto`. Rejected: the key si | `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. +`restore` switches Nextcloud maintenance mode on for itself, so no request writes while tables are replaced, and always switches it off again, also when the restore fails. It refuses when maintenance mode is already on, because switching it off at the end would cut short someone else's maintenance. It cannot ask the administrator to switch maintenance on first: Nextcloud loads no app commands in maintenance mode (decided 4 Oct after the live check). 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. A `--dry-run` prints that rule as a notice instead, so the counts are visible before choosing `--force`. ### D5: Restore gives back ciphertext as it was diff --git a/openspec/changes/admin-scheduled-vault-backups/proposal.md b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/proposal.md similarity index 96% rename from openspec/changes/admin-scheduled-vault-backups/proposal.md rename to openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/proposal.md index d05f6404b..5e7a65d24 100644 --- a/openspec/changes/admin-scheduled-vault-backups/proposal.md +++ b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/proposal.md @@ -31,7 +31,7 @@ The server never holds plaintext secret values or master passwords (ADR-003). A - 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`. +- Four `occ` commands: `keepiq:backup:create`, `keepiq:backup:list`, `keepiq:backup:verify` and `keepiq:backup:restore`. Restore switches maintenance mode on and off itself, 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. diff --git a/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/specs/vault-backups/spec.md similarity index 68% rename from openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md rename to openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/specs/vault-backups/spec.md index c0494dc52..b888d0b68 100644 --- a/openspec/changes/admin-scheduled-vault-backups/specs/vault-backups/spec.md +++ b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/specs/vault-backups/spec.md @@ -34,20 +34,29 @@ When a backup public key is configured, each archive MUST be encrypted with a ra ### 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`. +The system MUST offer `occ keepiq:backup:list`, `occ keepiq:backup:verify` and `occ keepiq:backup:restore`. Restore MUST switch Nextcloud maintenance mode on for the duration of the restore and MUST switch it off again afterwards, also when the restore fails. It MUST refuse to start when maintenance mode is already on, because switching it off at the end would cut short maintenance someone else started. Nextcloud loads no app commands in maintenance mode, so the command cannot ask the administrator to switch it on first. Restore 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`; a `--dry-run` MUST print that rule as a notice instead of refusing, so the administrator sees the counts before choosing `--force`. Every restore MUST be audited as `BACKUP_RESTORED`. -#### Scenario: Restore outside maintenance mode is refused +#### Scenario: Restore runs inside maintenance mode it sets itself -- **GIVEN** maintenance mode is off +- **GIVEN** maintenance mode is off and a valid archive +- **WHEN** an administrator runs `occ keepiq:backup:restore ` and confirms +- **THEN** maintenance mode MUST be on while the tables and blobs are replaced +- **AND** maintenance mode MUST be off when the command ends, also when the restore failed + +#### Scenario: Restore refuses when maintenance mode is already on + +- **GIVEN** maintenance mode is on - **WHEN** an administrator runs `occ keepiq:backup:restore ` -- **THEN** the command MUST exit with an error and no table MUST change +- **THEN** the command MUST exit with an error that says why +- **AND** no table MUST change and maintenance mode MUST stay on #### 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` +- **GIVEN** maintenance mode is off and a valid archive older than the newest audit entry +- **WHEN** an administrator runs `occ keepiq:backup:restore --dry-run` without `--force` - **THEN** the command MUST print current and archive row counts per table -- **AND** no table MUST change +- **AND** it MUST print as a notice that a real restore needs `--force` +- **AND** no table MUST change and maintenance mode MUST stay off ### Requirement: A restore returns ciphertext that still needs each user's key diff --git a/openspec/changes/admin-scheduled-vault-backups/tasks.md b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/tasks.md similarity index 53% rename from openspec/changes/admin-scheduled-vault-backups/tasks.md rename to openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/tasks.md index cb2effd14..7bc33ac6f 100644 --- a/openspec/changes/admin-scheduled-vault-backups/tasks.md +++ b/openspec/changes/archive/2026-10-04-admin-scheduled-vault-backups/tasks.md @@ -1,32 +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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. Verified 4 Oct on a fresh Nextcloud 35.0.1 + PostgreSQL 16: both commands listed under `occ list keepiq`; create wrote `keepiq-backup-20261003-225701.zip (33234 bytes, not encrypted)`, rc 0, with a `backup.created` audit row; list showed it with size, time and path, rc 0. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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/`. +- [x] 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. Verified 4 Oct on Nextcloud 35.0.1 + PostgreSQL 16: secret `__r_backup_probe` set to `value-before-backup-R1`, `occ keepiq:backup:create` wrote `keepiq-backup-20261004-060630.zip`, value changed to `value-after-backup-R2`. A `--dry-run` printed the counts and the age notice, rc 0; without `--force` the restore refused, rc 1. `occ keepiq:backup:restore --force` switched maintenance mode on (06:07:13.996) and off again (06:07:14.362), restored 334 rows with a `backup.restored` audit row, rc 0. Admin unlocked with the unchanged master password and revealed `value-before-backup-R1`. Earlier the same day PostgreSQL refused `false` bound as '' (fixed in `TableStore`, `TableStoreTest`). ## 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. +- `occ keepiq:backup:restore` runs in maintenance mode it switches on and off itself, refuses when maintenance mode is already on, 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/audit-siem-vendor-connectors/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-scoped-roles/.openspec.yaml similarity index 100% rename from openspec/changes/audit-siem-vendor-connectors/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-scoped-roles/.openspec.yaml diff --git a/openspec/changes/admin-scoped-roles/design.md b/openspec/changes/archive/2026-10-04-admin-scoped-roles/design.md similarity index 67% rename from openspec/changes/admin-scoped-roles/design.md rename to openspec/changes/archive/2026-10-04-admin-scoped-roles/design.md index d36d2212e..f7f4e87cf 100644 --- a/openspec/changes/admin-scoped-roles/design.md +++ b/openspec/changes/archive/2026-10-04-admin-scoped-roles/design.md @@ -12,6 +12,8 @@ Read at development `4c214a9d`. - 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. +- OpenRegister's AppHost `Bootstrap::registerAdminSettings()` binds `OCA\Keepiq\Settings\AdminSettings` to an instance of `GenericAdminSettings`. `get_class()` on Nextcloud's delegation page and in `getAllowedAdminSettings()` therefore names the generic class, so a delegation of the Keepiq section never satisfied `#[AuthorizedAdminSetting(AdminSettings::class)]`. +- `GET/PUT /api/settings/admin` (`SettingsController::getAdminSettings()` / `updateAdminSettings()`) carried keys of four areas in one request. Nextcloud's middleware reads one class per method, so one guard could not express "the area of the keys you send". ## Goals / Non-Goals @@ -35,13 +37,13 @@ Keepiq registers five settings classes, each implementing `IDelegatedSettings` w | 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 | +| `AdminSettings` | General | version, CA health and actions, attachment limits, offline cache, breach check, secret types, vault backups | +| `PolicyAdminSettings` | Policies | master password, org password, rotation, session timeout, vault policies, team folder auto-confirm, version history and trash retention | | `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. +`AdminSettings` keeps its class name, so its existing delegations keep meaning something. Each class returns the Keepiq section from `getSection()` and an ascending priority (10 to 14). All five extend a Keepiq base class (`AdminAreaSettings`) and are registered as themselves in `DomainOverrideRegistrar`, after the AppHost engine, so the registered instance is the class a guard names. Version and trash retention are Policies (decision of 2 Oct: they are vault rules). 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. @@ -51,9 +53,13 @@ Each of the 14 attribute guards changes to its area class. Each inline `isAdmin( 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. +The combined admin settings endpoint is split per area (decision of 2 Oct). `GET` and `PUT /api/settings/admin/{general,policies,applications,audit}` each have their own method with their own area guard, and each writes only that area's key groups (`AdminSettingsService::AREA_KEYS`; Policies owns every other admin key). A key of another area answers 400 and nothing is written, so a caller never mistakes a partial save for a whole one. The combined `GET/PUT /api/settings/admin` is removed. People owns no settings keys and has no settings route. `settings#update` and `settings#create` write the master password floor, so they take the Policies guard; `settings#load` (re-import) stays General. Two-factor gaps are Policies; vault backups are General. + +Alternative considered: one `#[NoAdminRequired]` endpoint that checks `holds()` per key group. Rejected: it moves the guard out of the middleware, which is the reason for this decision. + ### 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. +Each settings class provides its own initial-state flag `area-` and renders the same template with its own mount element `#keepiq-settings-`. One key per area, because `IInitialState` keeps only the last value of a repeated key, and a full administrator sees all five forms on one page. `settings.js` mounts the bundle once per flag it finds. `Settings.vue` renders only the sections listed for that area (`src/views/settings/adminAreas.js`). `CnAdminSettingsShell` with the version card renders in the General area only; General provides the version state itself now. No DOM data attribute is read, per the initial-state gate. ### D4: `vault_admin` becomes an alias with an end date @@ -63,7 +69,7 @@ Alternative considered: a repair step that turns `vault_admin` into a delegation ### 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. +`DelegationController::capabilities()` returns `canHandover` from `holds($userId, PeopleAdminSettings::class)`. `DelegationAuthorizer::requireHandoverAdmin()` (formerly `requireVaultAdmin()`) and `TeamFolderOffboardingService::assertOffboardingAdmin()` call the same method, so the button and the enforcement can never disagree. An instance administrator outside `vault_admin` now also gets the handover, as the spec requires. ## Security and zero-knowledge @@ -84,4 +90,4 @@ None. Delegations are made on Nextcloud's own page. PHPUnit tests mock `IManager ## Migration -No table or column. `appinfo/info.xml` gains four `` entries; bump `` so existing installs pick up the new settings classes on upgrade. +No table or column. `appinfo/info.xml` gains four `` entries. Nextcloud registers them from info.xml when the app loads; the `` is bumped anyway, as every change does. diff --git a/openspec/changes/admin-scoped-roles/proposal.md b/openspec/changes/archive/2026-10-04-admin-scoped-roles/proposal.md similarity index 100% rename from openspec/changes/admin-scoped-roles/proposal.md rename to openspec/changes/archive/2026-10-04-admin-scoped-roles/proposal.md diff --git a/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md b/openspec/changes/archive/2026-10-04-admin-scoped-roles/specs/admin-scoped-roles/spec.md similarity index 84% rename from openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md rename to openspec/changes/archive/2026-10-04-admin-scoped-roles/specs/admin-scoped-roles/spec.md index 9fbf9bfa4..1a0482fa0 100644 --- a/openspec/changes/admin-scoped-roles/specs/admin-scoped-roles/spec.md +++ b/openspec/changes/archive/2026-10-04-admin-scoped-roles/specs/admin-scoped-roles/spec.md @@ -13,14 +13,20 @@ The system MUST register five named Keepiq admin settings areas that Nextcloud c ### 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. +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. The admin settings MUST be read and written per area, through `/api/settings/admin/{general,policies,applications,audit}`, and an area write MUST refuse a key of another area. Version and trash retention MUST belong to Policies. #### 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` +- **WHEN** they call `PUT /api/settings/admin/policies` - **THEN** Nextcloud MUST refuse the request and no setting MUST change +#### Scenario: An area write carries only its own keys + +- **GIVEN** a member of a group delegated only the "Audit and compliance" area +- **WHEN** they call `PUT /api/settings/admin/audit` with `audit_retention_days` and `min_password_length` +- **THEN** the request MUST answer 400 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 diff --git a/openspec/changes/admin-scoped-roles/tasks.md b/openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md similarity index 50% rename from openspec/changes/admin-scoped-roles/tasks.md rename to openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md index 751b7ca3f..71d67452b 100644 --- a/openspec/changes/admin-scoped-roles/tasks.md +++ b/openspec/changes/archive/2026-10-04-admin-scoped-roles/tasks.md @@ -1,27 +1,27 @@ ## 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. +- [x] 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. +- [x] 1.2 Register the four classes in `appinfo/info.xml` and bump ``; register all five as themselves in `DomainOverrideRegistrar` (done, `AdminAreaSettingsTest`). Verified live 4 Oct on a fresh Nextcloud 35.0.1: "Administration privileges" lists Keepiq - General, Keepiq - Policies, Keepiq - Applications and machine access, Keepiq - People and offboarding, Keepiq - Audit and compliance; a group delegated only Audit sees only the Audit area (see 3.4). +- [x] 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. +- [x] 2.1 Split `GET/PUT /api/settings/admin` into one route pair per settings area (general, policies, applications, audit), each guarded by its area and writing only its keys (decision of 2 Oct). 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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`. +- [x] 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. +- [x] 3.2 Switch `AdminHandoverPanel.vue` and the delegation store to `canHandover`. Verify with a vitest in `tests/store/`. +- [x] 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. +- [x] 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/policies`. `tests/e2e/workflows/admin-area-delegation.spec.ts`: seeds the group, member and Audit-only delegation, checks the audit sections render and no other area mounts, `PUT .../policies` answers 403, an audit write with a policy key answers 400, nothing changes. Red-before/green-after: red (200 instead of 403) with the policies guard pointed at the Audit area, green on development. ## 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. +- [x] 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. Moved to keepiq#1043 on 4 Oct 2026: it waits for the next minor release after #962 (decision 4 Oct: outside steps go to follow-up issues). ## Acceptance criteria diff --git a/openspec/changes/clients-extension-store-release/.openspec.yaml b/openspec/changes/archive/2026-10-04-admin-vault-policies/.openspec.yaml similarity index 100% rename from openspec/changes/clients-extension-store-release/.openspec.yaml rename to openspec/changes/archive/2026-10-04-admin-vault-policies/.openspec.yaml diff --git a/openspec/changes/admin-vault-policies/design.md b/openspec/changes/archive/2026-10-04-admin-vault-policies/design.md similarity index 100% rename from openspec/changes/admin-vault-policies/design.md rename to openspec/changes/archive/2026-10-04-admin-vault-policies/design.md diff --git a/openspec/changes/admin-vault-policies/proposal.md b/openspec/changes/archive/2026-10-04-admin-vault-policies/proposal.md similarity index 100% rename from openspec/changes/admin-vault-policies/proposal.md rename to openspec/changes/archive/2026-10-04-admin-vault-policies/proposal.md diff --git a/openspec/changes/admin-vault-policies/specs/vault-policies/spec.md b/openspec/changes/archive/2026-10-04-admin-vault-policies/specs/vault-policies/spec.md similarity index 100% rename from openspec/changes/admin-vault-policies/specs/vault-policies/spec.md rename to openspec/changes/archive/2026-10-04-admin-vault-policies/specs/vault-policies/spec.md diff --git a/openspec/changes/admin-vault-policies/tasks.md b/openspec/changes/archive/2026-10-04-admin-vault-policies/tasks.md similarity index 59% rename from openspec/changes/admin-vault-policies/tasks.md rename to openspec/changes/archive/2026-10-04-admin-vault-policies/tasks.md index ba64627c5..9400e6122 100644 --- a/openspec/changes/admin-vault-policies/tasks.md +++ b/openspec/changes/archive/2026-10-04-admin-vault-policies/tasks.md @@ -1,28 +1,28 @@ ## 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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`. +- [x] 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`. +- [x] 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. +- [x] 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. Verified 4 Oct in docker golang:1.25 (go1.25.14): `cli` go test ./... ok (cli, cli/sshagent) after repointing `cli/useonly_test.go` from the removed `cli/internal/client` to `sdk/go/client` (the package did not build before); `sdk/go` go test ./... ok (the `ErrTwoFactorRequired` test lives in `sdk/go/client/client_test.go`); `npx vitest run tests/extension` 30 files, 243 tests passed. ## 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. +- [x] 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. `tests/e2e/workflows/vault-policies.spec.ts` (two tests, a fresh group with two browser-made vaults). Both went red first on real defects, fixed here: (1) the export dialog kept the policy cached when the vault page loaded, so a ban switched on during the session still showed every mode; it now reads the policy each time it opens (`tests/dialogs/ExportDialogPolicy.spec.js`, red/green). (2) `SecretController` was an OCSController, and Nextcloud's OCSMiddleware turned its 403 `org_ownership_required` into HTTP 200 with an OCS envelope, so the browser took a refused personal login for a saved secret; it is now a plain Controller (`SecretControllerTest::testPolicyRefusalIsNotRewrittenByTheOcsMiddleware`, red/green). Green against a fresh Nextcloud 35.0.1 after both fixes. ## Acceptance criteria diff --git a/openspec/changes/adopt-connection-registry/.openspec.yaml b/openspec/changes/archive/2026-10-04-adopt-connection-registry/.openspec.yaml similarity index 100% rename from openspec/changes/adopt-connection-registry/.openspec.yaml rename to openspec/changes/archive/2026-10-04-adopt-connection-registry/.openspec.yaml diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/archive/2026-10-04-adopt-connection-registry/design.md similarity index 100% rename from openspec/changes/adopt-connection-registry/design.md rename to openspec/changes/archive/2026-10-04-adopt-connection-registry/design.md diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/archive/2026-10-04-adopt-connection-registry/proposal.md similarity index 100% rename from openspec/changes/adopt-connection-registry/proposal.md rename to openspec/changes/archive/2026-10-04-adopt-connection-registry/proposal.md diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/archive/2026-10-04-adopt-connection-registry/specs/admin-integrations/spec.md similarity index 100% rename from openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md rename to openspec/changes/archive/2026-10-04-adopt-connection-registry/specs/admin-integrations/spec.md diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/archive/2026-10-04-adopt-connection-registry/tasks.md similarity index 82% rename from openspec/changes/adopt-connection-registry/tasks.md rename to openspec/changes/archive/2026-10-04-adopt-connection-registry/tasks.md index 116c34023..6bfcfdc7e 100644 --- a/openspec/changes/adopt-connection-registry/tasks.md +++ b/openspec/changes/archive/2026-10-04-adopt-connection-registry/tasks.md @@ -36,4 +36,4 @@ ## 6. After integriq ships -- [ ] 6.1 Run the e2e spec against an instance with both apps, then archive this change. +- [x] 6.1 Run the e2e spec against an instance with both apps, then archive this change. Evidence (4 Oct 2026, lane M): `tests/e2e/workflows/integrations-page.spec.ts` 3 passed against a Nextcloud 35.0.1 instance with keepiq 0.3.4-unstable.20261002230000, openregister 2.1.34-unstable.20260930110000 and integriq 0.4.8-unstable.20260930170001, all from development. Red control: the disabled-status expectation flipped to `unconfigured` fails with `Received: "disabled|..."`. diff --git a/openspec/changes/secret-export-gdpr/.openspec.yaml b/openspec/changes/archive/2026-10-04-app-own-certificate/.openspec.yaml similarity index 50% rename from openspec/changes/secret-export-gdpr/.openspec.yaml rename to openspec/changes/archive/2026-10-04-app-own-certificate/.openspec.yaml index e0c0898ff..ad94badae 100644 --- a/openspec/changes/secret-export-gdpr/.openspec.yaml +++ b/openspec/changes/archive/2026-10-04-app-own-certificate/.openspec.yaml @@ -1,2 +1,2 @@ schema: spec-driven -created: 2026-06-11 +created: 2026-10-02 diff --git a/openspec/changes/archive/2026-10-04-app-own-certificate/proposal.md b/openspec/changes/archive/2026-10-04-app-own-certificate/proposal.md new file mode 100644 index 000000000..0cd68fff0 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-app-own-certificate/proposal.md @@ -0,0 +1,27 @@ +--- +kind: code +--- + +# An application reads its own certificate + +## Why + +The client libraries and the Kubernetes operator (keepiq#776, #777) check every envelope's `certificateFingerprint` against the application's certificate before they decrypt. A private key cannot produce its certificate, so today the operator has to configure the certificate next to the key, or the check does not run. Ruben's decision of 2 Oct: keep the certificate optional in the clients, and give an application a way to fetch its own certificate from the server. + +## What Changes + +- `GET /api/v1/app/certificate` on the Bearer machine surface returns the calling application's active certificate (public), its suite id and the `sha256:` fingerprint, computed exactly as the envelope's `certificateFingerprint`. +- The discovery document names the path under `certificate`. +- The Go, Python and TypeScript library docs and the Kubernetes docs say how to use it. + +## Capabilities + +### Modified Capabilities + +- `secret-store-api`: an application can read its own certificate and fingerprint. + +## Impact + +- **Backend**: one controller (`ApplicationCertificateController`), one route, one discovery field. +- **Security**: the endpoint returns only the public certificate of the calling application's own active suite. JwtAuthMiddleware binds the application before the controller runs. No private material, no other application's certificate. +- **Database**: none. diff --git a/openspec/changes/archive/2026-10-04-app-own-certificate/specs/secret-store-api/spec.md b/openspec/changes/archive/2026-10-04-app-own-certificate/specs/secret-store-api/spec.md new file mode 100644 index 000000000..6fc59a6bd --- /dev/null +++ b/openspec/changes/archive/2026-10-04-app-own-certificate/specs/secret-store-api/spec.md @@ -0,0 +1,18 @@ +## ADDED Requirements + +### Requirement: An application reads its own certificate + +The system MUST serve `GET /api/v1/app/certificate` to a Bearer-authenticated application. The response MUST contain the application id, the id of its active encryption suite, that suite's certificate in PEM and its `certificateFingerprint`, computed exactly as in the response envelope (`sha256:` over the certificate DER). The response MUST NOT contain private key material or another application's certificate. The discovery document MUST name the path under `certificate`. + +#### Scenario: A client verifies envelopes without configuring the certificate + +- **GIVEN** an approved application with an active encryption suite and a valid access token +- **WHEN** it calls `GET /api/v1/app/certificate` +- **THEN** the response MUST carry its certificate and `certificateFingerprint` +- **AND** that fingerprint MUST equal the `certificateFingerprint` of every envelope encrypted to that suite + +#### Scenario: No token, no certificate + +- **GIVEN** a request without a Bearer token +- **WHEN** it calls `GET /api/v1/app/certificate` +- **THEN** the response MUST be 401 diff --git a/openspec/changes/archive/2026-10-04-app-own-certificate/tasks.md b/openspec/changes/archive/2026-10-04-app-own-certificate/tasks.md new file mode 100644 index 000000000..7db8f64d1 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-app-own-certificate/tasks.md @@ -0,0 +1,10 @@ +## 1. Endpoint + +- [x] 1.1 Add `GET /api/v1/app/certificate` (`ApplicationCertificateController::show()`) returning `applicationId`, `suiteId`, `certificate` and `certificateFingerprint` for the Bearer application's active suite, 401 without a token and 404 without an active suite. Verify with `ApplicationCertificateControllerTest`. +- [x] 1.2 Name the path in the discovery document under `certificate`. Verify with `DiscoveryControllerTest`. +- [x] 1.3 Add the bearer-required refusal and the discovery field to `tests/integration/machine-secret-api.postman_collection.json`. + +## 2. Docs + +- [x] 2.1 Mention the endpoint in `docs/client-libraries.md`, `docs/kubernetes.md` and `sdk/README.md`. +- [x] 2.2 Live check: an approved application's fingerprint from this endpoint equals the `certificateFingerprint` of an envelope it fetches. Verified 4 Oct on a fresh Nextcloud 35.0.1: an application registered with a CSR through `POST /api/v1/admin/applications` (active), a JWT-bearer token from `/api/v1/token`, `GET /api/v1/app/certificate` answered `sha256:a5e40ca2...b9be47` (equal to `sha256` of the certificate DER, and the certificate's public key equals the application key); a written-back secret fetched by name carried `encryption.certificateFingerprint` `sha256:a5e40ca2...b9be47` and the same `suiteId`, and decrypted with the application key. diff --git a/openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml b/openspec/changes/archive/2026-10-04-apps-terraform-provider/.openspec.yaml similarity index 100% rename from openspec/changes/clients-extension-unlock-lock-and-accounts/.openspec.yaml rename to openspec/changes/archive/2026-10-04-apps-terraform-provider/.openspec.yaml diff --git a/openspec/changes/apps-terraform-provider/design.md b/openspec/changes/archive/2026-10-04-apps-terraform-provider/design.md similarity index 100% rename from openspec/changes/apps-terraform-provider/design.md rename to openspec/changes/archive/2026-10-04-apps-terraform-provider/design.md diff --git a/openspec/changes/apps-terraform-provider/proposal.md b/openspec/changes/archive/2026-10-04-apps-terraform-provider/proposal.md similarity index 100% rename from openspec/changes/apps-terraform-provider/proposal.md rename to openspec/changes/archive/2026-10-04-apps-terraform-provider/proposal.md diff --git a/openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md b/openspec/changes/archive/2026-10-04-apps-terraform-provider/specs/terraform-provider/spec.md similarity index 100% rename from openspec/changes/apps-terraform-provider/specs/terraform-provider/spec.md rename to openspec/changes/archive/2026-10-04-apps-terraform-provider/specs/terraform-provider/spec.md diff --git a/openspec/changes/archive/2026-10-04-apps-terraform-provider/tasks.md b/openspec/changes/archive/2026-10-04-apps-terraform-provider/tasks.md new file mode 100644 index 000000000..8eafe5933 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-apps-terraform-provider/tasks.md @@ -0,0 +1,26 @@ +## 1. Provider + +- [x] 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. Done: module `integrations/terraform-provider-keepiq/` (framework v1.14.1) on `sdk/go`, provider config with env defaults (`KEEPIQ_URL`, `KEEPIQ_APP_ID`, `KEEPIQ_APP_KEY`/`_FILE`, `KEEPIQ_APP_CERT_FILE`). `TestSchemas`. The admin arguments wait for 2.1. +- [x] 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. Done: `ephemeral_secret.go`; `TestTerraformEndToEnd` (Terraform 1.11.4, dev_overrides) copies the ephemeral value into another resource and checks neither the saved plan nor the state holds it. `terraform-plugin-testing` needs Go 1.23, so the test drives the Terraform CLI directly. +- [x] 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. Done: `resource_secret.go`; the end-to-end test creates, ignores a value change without a version bump, updates on `value_wo_version`, imports, and checks plan and state hold no value. Fingerprint check through the optional `certificate` (see POLICY.md). The resource has no `folder` argument: the server refuses application secrets in a folder (`SecretService::createByApplication`); `folder_path` is computed. +- [x] 1.4 Make destroy of `keepiq_secret` remove from state with a warning naming the secret. Verify with a test on the diagnostics. Done: `TestDestroyWarnsAndNamesTheSecret` and the end-to-end destroy (secret still in Keepiq, warning names it). +- [x] 1.5 Add the data source `keepiq_secret_metadata`. Verify with a test that it exposes no value attribute. Done: `TestMetadataHasNoValue`. +- [x] 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. Done: `ValidateConfig` with `WriteOnlyUnsupported`; `TestWriteOnlyRefusedOnOlderTerraform`. + +## 2. Applications + +- [x] 2.1 Done on the admin API of PR #966 (admin-public-api): `admin_user`/`admin_password` provider arguments, `AdminClient`, `keepiq_application` and `keepiq_application_lease_policy`; stub tests in `admin_test.go`, live test `TestAdminAPILive` (skips without `KEEPIQ_LIVE_URL`). 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 + +- [x] 3.1 Generate docs with `tfplugindocs` and add examples for each resource. Verify with a CI check that the generated docs are current. Done: `docs/` generated by tfplugindocs v0.20.1 through `scripts/docs.sh` (schema exported from a dev_overrides build, because tfplugindocs cannot verify the Terraform release key here); examples for each resource; CI runs it with `--check`. +- [x] 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. Done: `.github/workflows/integrations-terraform.yml`. Owed: the dry run on the pull request. +- [x] 3.3 (OWED: organisation admin step) 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. Moved to keepiq#1041 on 4 Oct 2026: an organisation admin step, tracked there (decision 4 Oct: outside steps go to follow-up issues). + +## 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/archive/2026-10-04-clients-extension-complete/design.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/design.md new file mode 100644 index 000000000..2474fa0f7 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/design.md @@ -0,0 +1,11 @@ +# Design: the rest of the browser extension + +## Step 1: generator and send + +**D1. One generator, more options.** `src/generator/generator.js` gains class toggles, minimum digits and symbols and ambiguity avoidance; defaults keep the server's behaviour, so the web app is unchanged. Usernames are a new pure module, `src/generator/username.js`. + +**D2. State in the worker, values in the popup.** Options (per account, `storage.local`), the cached policy and the account's email live in the worker (`background/generator-handlers.js`). Values are generated in the popup. The history lives in `storage.session`, so the browser drops it on restart; the worker clears it whenever an account locks (`vault.onLock`) and forgets everything when an account is removed. History writes are queued per account: values generated in quick succession otherwise read the same list and dropped each other, which the popup test caught. + +**D3. Argon2id bundled, not fetched.** esbuild's binary loader puts argon2-browser's WebAssembly into the worker bundle; `loadArgon2WasmBinary` hands it to the web app's KDF. The extension CSP gains `'wasm-unsafe-eval'`, which MV3 requires for WebAssembly. The emscripten glue's Node-only modules (`fs`, `path`, `crypto`) are external; they never run in a browser. + +**D4. The load check opens the real popup.** Since #921 the worker answers its own pages only, never a tab, so the load check (which opened `popup.html` as a tab) failed on development. It now opens the action popup and reaches it over the DevTools protocol. diff --git a/openspec/changes/archive/2026-10-04-clients-extension-complete/proposal.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/proposal.md new file mode 100644 index 000000000..7148b2add --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/proposal.md @@ -0,0 +1,27 @@ +--- +kind: code +--- + +# The rest of the browser extension + +## Why + +`clients-extension-generator-vault-send` built the core of the popup's Generator, Vault and Send tabs and listed what it left for follow-ups, measured against the specs of `ConductionNL/keepiq-extension` (`ext-generator`, `ext-vault-browse`, `ext-vault-edit`, `ext-send`). This change builds those follow-ups, in five steps that each land on their own. + +## What Changes + +1. **Generator and send.** Password, Passphrase and Username sub-tabs; character classes, minimum digits and symbols, ambiguous characters; usernames (random word, plus-addressed email, catch-all email, with the website name); colour-coded output; a history of 50 values that goes on lock; options remembered per account; a pick mode that hands a value to the item form; the generator from the lock screen and offline with the cached policy. Password-protected sends with the web app's Argon2id, bundled in the extension. +2. **Item detail and editing.** TOTP with countdown, websites, additional fields, notes, card and identity, passkey and metadata sections; clone, move, input limits and an unsaved-changes warning. +3. **Folder manager.** Create, rename and delete folders. +4. **Offline vault cache and sync.** +5. **Popup shell.** Bottom tab bar, pop-out window, last tab remembered, theme tokens. + +## Where Keepiq's own specs win + +Where the keepiq-extension specs follow Bitwarden and a Keepiq spec is stricter, Keepiq's spec applies and the difference is recorded: a generated password has at least 8 characters (`key-generator`, Bitwarden allows 5), a passphrase 4 to 12 words (`passphrase-generator`, Bitwarden 3 to 20), and symbols come from Keepiq's OWASP set. + +## Capabilities + +### Modified Capabilities + +- `extension-generator`, `extension-send` (step 1); `extension-vault` (steps 2 and 3); new `extension-vault-sync` (step 4) and `extension-popup-shell` (step 5). diff --git a/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-generator/spec.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-generator/spec.md new file mode 100644 index 000000000..cdd599a79 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-generator/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Generator sub-tabs +The Generator tab MUST offer the sub-tabs Password, Passphrase and Username, open on the sub-tab last used for the account, generate a value when it opens and again when an option changes, and show the value in a monospace font with digits and special characters in their own colours, with Regenerate and Copy. Copy MUST copy the whole value. + +#### Scenario: Switch to Username +@e2e exclude Browser-extension popup. Covered by tests/extension/popupVaultSendGenerator.spec.js ("makes passwords, passphrases and usernames") and live in Chromium. +- **GIVEN** the Generator tab open on Password +- **WHEN** the user chooses Username +- **THEN** a capitalised list word with four digits is shown + +### Requirement: Password options +The Password sub-tab MUST offer a length from 8 to 128 (default 14), toggles for uppercase, lowercase, numbers and special characters (on, on, on, off), minimum numbers and minimum special characters (0 to 9, default 1), and avoiding ambiguous characters (on; leaves out I, O, l, 0 and 1). At least one class MUST stay on, the length MUST grow to fit the minimums, and the org policy MUST be applied. + +#### Scenario: Avoid ambiguous characters +@e2e exclude Browser-extension popup. Covered by tests/vitest/generator-options.spec.js and live in Chromium. +- **GIVEN** the default options +- **WHEN** a password is generated +- **THEN** it has 14 characters and none of I, O, l, 0 or 1 + +### Requirement: Username generator +The Username sub-tab MUST offer Random word (a list word, capitalised, with four digits, both switchable), Plus addressed email (the account's email, prefilled from Nextcloud, with `+` and eight random characters or the website name before the `@`) and Catch-all email (a domain with eight random characters or the website name). Without an active website the website choice MUST be disabled with "No website detected". + +#### Scenario: Plus-addressed email with the website name +@e2e exclude Browser-extension popup. Covered by tests/vitest/generator-options.spec.js and live in Chromium (admin+localhost@example.org). +- **GIVEN** the email `admin@example.org` and the active tab on `localhost` +- **WHEN** the user picks Plus addressed email with Website name +- **THEN** the value is `admin+localhost@example.org` + +### Requirement: Generator history +The extension MUST keep the last 50 generated values per account with their kind and time, newest first, in session storage only, and MUST clear them when the account locks, when it is removed and when the browser restarts. A History view MUST list them with Copy per entry and Clear. + +#### Scenario: Lock clears the history +@e2e exclude Browser-extension worker. Covered by tests/extension/generatorState.spec.js ("clears the history when the vault locks"). +- **GIVEN** a generated value in the history +- **WHEN** the vault locks +- **THEN** the history is empty + +### Requirement: Options remembered per account +The extension MUST store the generator options and the last sub-tab per account, sanitise them against the ranges and the policy when loading, and forget them when the account is removed. + +#### Scenario: Options come back +@e2e exclude Browser-extension popup. Covered by tests/extension/popupVaultSendGenerator.spec.js ("remembers the options and the sub-tab"). +- **GIVEN** the user set the length to 24 and chose Passphrase +- **WHEN** the popup opens again +- **THEN** Passphrase is selected and the length shows 24 + +### Requirement: Pick mode from the item form +From the item form the user MUST be able to open the Generator to pick a password or a username; "Use this value" MUST return it to the field that asked and show the form again with the rest of its input intact. + +#### Scenario: Pick a password +@e2e exclude Browser-extension popup. Covered by tests/extension/popupVaultSendGenerator.spec.js (pick mode in "browses, reveals, edits and creates items"). +- **GIVEN** a new item with a name and username typed +- **WHEN** the user generates a password and chooses Use this value +- **THEN** the password field holds it and the name and username are unchanged + +### Requirement: Works while locked and offline +The Generator MUST be reachable from the lock screen and MUST generate, copy and keep history while locked and while the server is unreachable, using the last policy it saw. No generation path may need the vault key or the network. + +#### Scenario: Generate while locked +@e2e exclude Browser-extension popup. Covered by tests/extension/popupVaultSendGenerator.spec.js ("generates while the vault is locked"), tests/extension/generatorState.spec.js (offline policy) and live in Chromium. +- **GIVEN** a locked extension +- **WHEN** the user chooses Generate a password on the lock screen +- **THEN** a value is generated diff --git a/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-popup-shell/spec.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-popup-shell/spec.md new file mode 100644 index 000000000..ad7dbef7d --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-popup-shell/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: A tab bar at the bottom +The unlocked popup MUST show its tabs in a bar fixed to the bottom: This site, Vault, Generator, Send and Settings. The header with the account and the lock stays fixed at the top, and only the content between them scrolls. The popup is 380 pixels wide and at most 600 pixels high. The tabs carry text labels only; the "This site" tab is kept as its own tab rather than folded into Vault as suggestions. + +#### Scenario: Settings from the tab bar +@e2e exclude Browser-extension popup. Covered by tests/extension/popupShell.spec.js ("opens Settings from the tab bar"). +- **GIVEN** an unlocked popup +- **WHEN** the user picks Settings in the tab bar +- **THEN** the settings view opens + +### Requirement: Reopen on the last tab +The popup MUST reopen on the tab it was last on, for the rest of the browser session only. The remembered tab MUST be forgotten when the account locks, so an unlock starts on This site. + +#### Scenario: Last tab, then a lock +@e2e exclude Browser-extension popup. Covered by tests/extension/popupShell.spec.js ("reopens on the last tab, and on This site after a lock"). +- **GIVEN** the popup was last on Generator +- **WHEN** it opens again, and later opens after a lock and an unlock +- **THEN** it opens on Generator, and after the lock on This site + +### Requirement: Pop out into a window +The popup MUST offer to open itself in its own window, which stays open while the user switches tabs. The window MUST act on the tab it was opened over: This site, the generator's site and fill all use that tab, and fill still refuses when that tab moved to another site. The pop-out button is hidden inside the window, and the window fills its own width. + +#### Scenario: Pop out +@e2e exclude Browser-extension popup. Covered by tests/extension/popupShell.spec.js ("pops out into a window pinned to the current tab"). +- **GIVEN** an unlocked popup over a website +- **WHEN** the user picks pop out +- **THEN** a popup window opens pinned to that tab and the small popup closes + +#### Scenario: Fill from the window +@e2e exclude Browser-extension popup. Covered by tests/extension/popupShell.spec.js ("fills the pinned tab when popped out, not the active one") and ("refuses to fill a pinned tab that moved to another site"). +- **GIVEN** a popped-out window pinned to a tab on example.com, while another tab is active +- **WHEN** the user fills a login +- **THEN** the pinned tab is filled, and a pinned tab that moved to another site is refused + +### Requirement: Say when there is no website +When the tab is not an http or https page, This site MUST say "Open a website to see its logins." and offer nothing to fill. + +#### Scenario: A browser page +@e2e exclude Browser-extension popup. Covered by tests/extension/popupShell.spec.js ("says to open a website when the tab is not one"). +- **GIVEN** the active tab shows a browser page +- **WHEN** the popup opens +- **THEN** This site shows the hint and no logins + +### Requirement: Follow the light or dark theme +The popup MUST take its colours from theme tokens, use dark values when the system prefers dark, and follow an explicit light or dark theme on the root element over the system preference. + +#### Scenario: Dark system +@e2e exclude Visual theming of the extension popup. Checked live in Chromium with the dark colour scheme emulated. +- **GIVEN** a system set to dark +- **WHEN** the popup opens +- **THEN** it shows the dark tokens diff --git a/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-send/spec.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-send/spec.md new file mode 100644 index 000000000..7842fa2e0 --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-send/spec.md @@ -0,0 +1,11 @@ +## ADDED Requirements + +### Requirement: Password-protected sends +The Send form MUST offer an optional password. With one, the content key MUST be wrapped under an Argon2id key derived from the password in the extension, as the web app does; the server receives the wrapped key and the salt, never the password, and the link carries no key. The list MUST offer Copy link only for sends made while the popup is open. + +#### Scenario: Send with a password +@e2e exclude Browser-extension popup. Covered by tests/extension/vaultSendGenerator.spec.js ("wraps the key under a password") and live in Chromium, where the web app opened the send with its password. +- **GIVEN** a text and a password in the Send form +- **WHEN** the user creates the link +- **THEN** the link has no key, the request carries a wrapped key and a salt but no password +- **AND** the recipient opens the send by entering the password diff --git a/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-vault-sync/spec.md b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-vault-sync/spec.md new file mode 100644 index 000000000..a3b1ba5ba --- /dev/null +++ b/openspec/changes/archive/2026-10-04-clients-extension-complete/specs/extension-vault-sync/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Keep a snapshot of the vault +The extension MUST keep a snapshot of each account's vault in persistent extension storage: the active suite, every secret row as the server stores it (ciphertext and plaintext metadata), the folders and the types, and when it was synced. The snapshot MUST be replaced in one write, MUST be discarded when the account is removed, and MUST hold nothing decrypted. It comes from the offline manifest, or page by page when an administrator switched offline caching off. + +#### Scenario: One write per sync +@e2e exclude Browser-extension worker. Covered by tests/extension/vaultSync.spec.js ("stores the snapshot in one write"). +- **GIVEN** an unlocked account +- **WHEN** a sync completes +- **THEN** the snapshot is stored with one write, with its time and change check + +### Requirement: Sync when it matters and cheaply +The extension MUST sync an account when it unlocks, when the popup opens with a snapshot older than 15 minutes, every 15 minutes while unlocked, after every change made in the extension and on "Sync now", and never while locked. A sync that is not forced MUST first ask only for the most recently updated secret and the total, and stop there when nothing changed. At most one sync per account MUST run at a time. + +#### Scenario: Nothing changed +@e2e exclude Browser-extension worker. Covered by tests/extension/vaultSync.spec.js ("costs one cheap request when nothing changed"). +- **GIVEN** a fresh snapshot and an unchanged vault +- **WHEN** a scheduled sync runs +- **THEN** only the latest-secret request is made + +#### Scenario: Unlock and lock +@e2e exclude Browser-extension worker. Covered by tests/extension/vaultSync.spec.js ("syncs on unlock, schedules a sync while unlocked and stops on lock"). +- **GIVEN** a paired account +- **WHEN** it unlocks and then locks +- **THEN** a sync runs and a 15-minute alarm is set, and the alarm is cleared on lock + +### Requirement: Work offline from the snapshot +When the server cannot be reached the extension MUST keep serving the snapshot: the Vault tab lists and opens items from it, autofill offers logins from it, and the tab shows "Last synced