Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
2c5c409
docs(encryption-suites): spec — harden vault key-material guards (#673)
rjzondervan Sep 10, 2026
455fb54
docs(encryption-suites): spec — migrate emergency access on rotation …
rjzondervan Sep 10, 2026
ea787dc
docs(encryption-suites): reconcile 673 open questions with #674
rjzondervan Sep 10, 2026
8965d7b
docs(emergency-access): fold the destructive-revocation warning into …
rjzondervan Sep 10, 2026
3bc32e9
docs(encryption-suites): abort deletes the successor, not revokes it …
rjzondervan Sep 10, 2026
49ff8c2
feat(encryption-suites): add the migration abort route (#673)
rjzondervan Sep 10, 2026
bb0e61c
feat(vault-key-proof): server-verified master-password guard (#673)
rjzondervan Sep 10, 2026
2d445d4
feat(vault-key-proof): client proof + wire the password-in-hand flows…
rjzondervan Sep 10, 2026
0c65767
feat(vault-key-proof): wire the emergency and completion flows (#673,…
rjzondervan Sep 10, 2026
9c257c2
test(vault-key-proof): cross-impl round-trip + guard docs (#673, §6.3…
rjzondervan Sep 10, 2026
7e65e05
test(emergency-access): cover the guarded revoke confirm dialog (#673)
rjzondervan Sep 11, 2026
0976e54
fix(quality): satisfy phpcs, phpmd and l10n gates on the guard (#673)
rjzondervan Sep 11, 2026
82af293
test(vault-key-proof): cover proofChallenge, countCommitted, abort li…
rjzondervan Sep 11, 2026
f0931d3
fix(l10n): rebuild the browser catalogues for the new keys (#673)
rjzondervan Sep 11, 2026
7dae235
fix(keepiq): satisfy hydra gates 13/16/46 on the vault-key-proof guar…
rjzondervan Sep 11, 2026
d9072e3
chore(674): stack on harden-vault-key-material-guards (#673)
rjzondervan Sep 11, 2026
96470c1
fix(keepiq): order v-model:password before other bindings on the revo…
rjzondervan Sep 11, 2026
e34b171
chore(674): pull the revoke-dialog attribute-order lint fix from #673
rjzondervan Sep 11, 2026
664993e
feat(keepiq): re-point emergency-access envelopes during suite rotati…
rjzondervan Sep 11, 2026
a009f11
feat(keepiq): refuse silent emergency-access loss on suite revocation…
rjzondervan Sep 11, 2026
c5564c7
feat(keepiq): migrate emergency access in the browser and warn on rev…
rjzondervan Sep 11, 2026
11de95c
docs(674): mark migrate-emergency-access-on-rotation tasks complete
rjzondervan Sep 11, 2026
63dc7df
fix(674): restore handleRevoke's @spec docblock and prettier-format t…
rjzondervan Sep 11, 2026
96b7dab
chore(release): sync main back into development
github-actions[bot] Sep 12, 2026
7493b37
chore(release): 0.3.4-unstable.20260912202807
github-actions[bot] Sep 12, 2026
cf2012e
Merge pull request #690 from ConductionNL/release/v0.3.4-unstable.202…
rubenvdlinde Sep 13, 2026
a5e1297
Merge pull request #683 from ConductionNL/sync/main-to-development-0.3.2
rubenvdlinde Sep 13, 2026
c818ee2
chore(sync): carry beta back into development
github-actions[bot] Sep 13, 2026
0309fcc
Merge pull request #695 from ConductionNL/sync/beta-to-development-20…
rubenvdlinde Sep 13, 2026
aef04a7
fix(keepiq): guard suite revocation with a vault-key proof (#673)
rjzondervan Sep 14, 2026
74b633c
fix(keepiq): bind complete's loss-acknowledgement params to the proof…
rjzondervan Sep 14, 2026
fc0005c
Merge remote-tracking branch 'origin/development' into feature/673/ha…
rjzondervan Sep 14, 2026
cc63def
style(keepiq): tighten boundParam's @param type (#673)
rjzondervan Sep 14, 2026
752277c
fix(keepiq): count re-enveloped contacts in the abort gate (#674)
rjzondervan Sep 14, 2026
7ae3f99
Merge remote-tracking branch 'origin/feature/673/harden-vault-key-mat…
rjzondervan Sep 14, 2026
716b8b6
fix(keepiq): make the revoke-suite proof purpose issuable (#673)
rjzondervan Sep 14, 2026
04d3d14
Merge remote-tracking branch 'origin/feature/673/harden-vault-key-mat…
rjzondervan Sep 14, 2026
225eada
fix(keepiq): address Wilco's three #678 should-fixes (#674)
rjzondervan Sep 14, 2026
e097a21
fix(encryption): single-line revoke vault-key-proof attribute for gat…
rjzondervan Sep 14, 2026
d46a2e4
Merge pull request #677 from ConductionNL/feature/673/harden-vault-ke…
rjzondervan Sep 14, 2026
8504024
Merge remote-tracking branch 'origin/development' into feature/674/mi…
rjzondervan Sep 14, 2026
3b63d30
Merge pull request #678 from ConductionNL/feature/674/migrate-emergen…
rjzondervan Sep 14, 2026
7d1f0a7
docs(encryption-suites): scaffold admin-suite-revocation change + ADR…
rjzondervan Sep 14, 2026
8fc72f8
feat(encryption-suites): admin force-revoke endpoint + compromise cas…
rjzondervan Sep 14, 2026
cc3b2a0
feat(encryption-suites): admin Encryption-suites settings section
rjzondervan Sep 14, 2026
df89d9a
docs(encryption-suites): mark admin-suite-revocation tasks 1-19 done
rjzondervan Sep 14, 2026
d24c0c5
fix(encryption-suites): null-check the acting admin in forceRevoke
rjzondervan Sep 14, 2026
b9367ba
fix(waivers): drop the dead deployment excuse from nine emergency-acc…
rubenvdlinde Sep 14, 2026
0ca3273
i18n(encryption-suites): translate admin force-revoke strings into al…
rjzondervan Sep 15, 2026
d3ae62a
feat(connections): show keepiq's breach check and SIEM export through…
rubenvdlinde Sep 15, 2026
67f8e03
Merge development into feature/702 — resolve l10n conflicts
rjzondervan Sep 15, 2026
73b3288
style(tests): prettier-format integrations-page e2e spec
rjzondervan Sep 15, 2026
0cb6ba6
style(e2e): format the integrations page spec so the format check pas…
rubenvdlinde Sep 15, 2026
8422d4c
fix(tests): two #706 tests that could not pass in CI (#717)
rubenvdlinde Sep 15, 2026
b21e44c
fix(tests): declare the classes the connection report tests use (#719)
rubenvdlinde Sep 16, 2026
0cc8f41
feat(connections): the breach check reads Switched off, and the forma…
rubenvdlinde Sep 16, 2026
74b5733
style(tests): format the two connection specs #721 left unformatted (…
rubenvdlinde Sep 16, 2026
f83d592
Merge remote-tracking branch 'origin/development' into feature/702/ad…
rjzondervan Sep 16, 2026
a99364f
Merge pull request #703 from ConductionNL/feature/702/admin-suite-rev…
rjzondervan Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,14 @@ jobs:
# which reads as an auth problem rather than a typo. Six fleet repos hit
# this; in pipelinq it killed all four PHPUnit legs and the E2E job at the
# clone step, so those gates had never executed a single test.
additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]'
#
# integriq is here because the Integrations page reads integriq's
# `app_connection` rows (adopt-connection-registry). Without it the page
# shows the missing-dependency screen and
# `tests/e2e/workflows/integrations-page.spec.ts` fails on every run.
# `app` is `integriq`, verified in its appinfo/info.xml on `development`
# on 2026-09-15.
additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]'
enable-sbom: true

# ── Licensing ────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Vrij en open source onder de EUPL-1.2-licentie.

**Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl.
]]></description>
<version>0.3.2-unstable.20260910105221</version>
<version>0.3.4-unstable.20260912202807</version>
<licence>EUPL-1.2</licence>
<author mail="info@conduction.nl" homepage="https://www.conduction.nl/">Conduction</author>
<namespace>Keepiq</namespace>
Expand Down
7 changes: 7 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
['name' => 'encryptionSuite#create', 'url' => '/api/v1/suites', 'verb' => 'POST'],
['name' => 'encryptionSuite#updatePrivateKey', 'url' => '/api/v1/suites/{id}/private-key', 'verb' => 'PUT'],
['name' => 'encryptionSuite#revoke', 'url' => '/api/v1/suites/{id}/revoke', 'verb' => 'POST'],
['name' => 'encryptionSuite#forceRevoke', 'url' => '/api/v1/suites/{id}/force-revoke', 'verb' => 'POST'],
['name' => 'encryptionSuite#reinstate', 'url' => '/api/v1/suites/{id}/reinstate', 'verb' => 'POST'],
['name' => 'encryptionSuite#compromiseRecovery','url' => '/api/v1/suites/compromise-recovery', 'verb' => 'POST'],
['name' => 'encryptionSuite#proofChallenge', 'url' => '/api/v1/suites/{id}/proof-challenge', 'verb' => 'GET'],

// CA management (admin-only).
['name' => 'cACertificate#getStatus', 'url' => '/api/v1/ca/status', 'verb' => 'GET'],
Expand All @@ -49,6 +51,7 @@
// Migration tracking.
['name' => 'migration#getStatus', 'url' => '/api/v1/migrations/status', 'verb' => 'GET'],
['name' => 'migration#complete', 'url' => '/api/v1/migrations/{id}/complete', 'verb' => 'POST'],
['name' => 'migration#abort', 'url' => '/api/v1/migrations/{id}/abort', 'verb' => 'POST'],

// Compromise-recovery migration work loop. One record per request: the
// browser decrypts with the old private key, re-encrypts under the new one,
Expand All @@ -59,6 +62,10 @@
['name' => 'migration#reEncryptSecret', 'url' => '/api/v1/migrations/{id}/secrets/{secretId}', 'verb' => 'POST'],
['name' => 'migration#reEncryptVersion', 'url' => '/api/v1/migrations/{id}/versions/{versionId}', 'verb' => 'POST'],
['name' => 'migration#reEncryptAttachmentGrant', 'url' => '/api/v1/migrations/{id}/attachment-grants/{grantId}', 'verb' => 'POST'],
// Emergency contacts migrate too, but off the gate: the browser mints a fresh
// envelope escrowing the new key and re-points the contact here. A contact it
// cannot carry is left for the completion sweep to invalidate.
['name' => 'migration#reEnvelopeEmergencyContact', 'url' => '/api/v1/migrations/{id}/emergency-contacts/{contactId}', 'verb' => 'POST'],

// Key generator endpoint (stateless, authenticated).
['name' => 'keyGenerator#generate', 'url' => '/api/v1/generate-key', 'verb' => 'POST'],
Expand Down
87 changes: 87 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,32 @@ Tracks compromise recovery migrations.
| `started_at` | datetime | — |
| `completed_at` | datetime | Null while in progress |

**Emergency-access recovery envelopes are a migrated store, not a casualty.**
A compromise-recovery rotation re-encrypts every suite-bound store under the new
key; emergency contacts are the one store not produced by decrypt-then-re-encrypt.
The rotating owner holds the new private key and can fetch each grantee's current
certificate, so the browser mints a *fresh* recovery envelope escrowing the new
key (`buildRecoveryEnvelope` — a build, never a re-wrap of the old envelope) and
re-points the contact to the new suite, keeping it `granted`
(`MigrationController::reEnvelopeEmergencyContact` →
`EmergencyEnvelopeInvalidationService::reEnvelopeForRotation`). The server cannot
open the envelope (only the grantee can), so it shape-checks it and asserts the
declared grantee suite is the grantee's *current* active suite rather than
round-tripping it. Emergency contacts are deliberately **outside** the completion
gate: a grantee with no reachable certificate can never be re-enveloped, and
gating on one would wedge the vault, so such a contact is left on the old suite.

`EmergencyEnvelopeInvalidationService::invalidateForGrantorRotation`, fired by
`EmergencyAccessSuiteRotationListener` on `SuiteMigrationCompletedEvent`, is now a
**residual sweep**, not a blanket invalidation: the re-enveloped contacts have
already left the old suite, so the sweep finds only the residual (unreachable
grantees), invalidates exactly those, and the client surfaces them for the owner
to re-establish. Revocation still clears the envelopes outright — it produces no
new key to migrate to — but `EncryptionSuiteController::revoke` now refuses while
a usable emergency contact exists unless `acceptEmergencyLoss` is given, and the
refusal surfaces the count (never the identities) so the destruction is a knowing
choice.

### 3.3 Encryption Flow Summary

```
Expand Down Expand Up @@ -585,6 +611,67 @@ All limits are keyed anonymously (per-IP) by Nextcloud's rate-limiter
middleware, which is available since NC 24; Keepiq's `info.xml` floor
(NC 31) already satisfies this.

### 4.2 Vault-key proofs on destructive operations

The always-E2E model (ADR-003) makes *reading* the vault cryptographically
gated on the master password, but leaves *writing* gated only by the Nextcloud
session, because writing a secret needs only the owner's public key. For the
operations that can render vault contents or key material **permanently
unreadable**, a session is not enough: those carry a **vault-key proof** — a
signature, made with the owner's suite private key, over a server-issued
challenge bound to the operation's own parameters. Because that private key is
recoverable only by decrypting its envelope with the master password, a verified
proof is a server-verifiable proof of the master password. This closes the
session-only lockout (issue #395): a stolen cookie, leaked app password, or XSS
in an unlocked tab can no longer destroy a vault.

Enforced declaratively by `#[VaultKeyProofRequired(binds, subject, purpose)]`
(`lib/Attribute/`) + `VaultKeyProofMiddleware`, with the crypto in
`VaultKeyProofService`. The guarded routes:

| Controller::method | `subject` | `binds` | `purpose` |
|---|---|---|---|
| `EncryptionSuiteController::compromiseRecovery` | `active` (old suite) | `publicKey`, `encryptedPrivateKey` | `compromise-recovery` |
| `EncryptionSuiteController::updatePrivateKey` | `routeParam:id` | `encryptedPrivateKey` | `update-private-key` |
| `EncryptionSuiteController::revoke` | `routeParam:id` | `reason` | `revoke-suite` |
| `MigrationController::complete` | `migrationOldSuite` | `id`, `hasErrors`, `acceptUnrecoverable` | `complete-migration` |
| `EmergencyAccessController::destroy` | `active` | `id` | `emergency-access-destroy` |

Load-bearing design points — change these only deliberately:

- **Sign, never decrypt.** The proof is a *signature*. A decrypt challenge would
be satisfiable by the session `CryptoKey`, which is imported non-extractable
and `['decrypt']`-only — so an unlocked tab (and thus injected script) could
answer it. Signing needs the raw private key, re-imported with `['sign']` from
bytes that exist only while the freshly entered master password is in hand.
- **The attribute carries the binding.** The middleware cannot read the request
body (the framework decodes JSON and drops the raw bytes), so the proof
commits to *named* request parameters, each hashed and concatenated in
declared order. No JSON-canonicalisation agreement between JS and PHP is
needed; cross-language interop is pinned by `VaultKeyProofCrossImplTest`.
- **Stateless, expiring challenges.** The nonce is HMAC-authenticated with the
instance secret over its random part, the caller, the purpose and an expiry —
no server-side store. Deliberately **not** `ICacheFactory`: a null cache on a
default install would break the flow. Single-use enforcement is unnecessary
because the signature commits to the operation's parameters, so a replay only
ever re-authorises the byte-identical operation.
- **Not waived for any session type.** The middleware consults no auth backend
and no token scope, so it behaves identically on SSO, app-password and
ordinary sessions — its authority is key material, not the login method.
- **`complete` proves the OLD key** (`migrationOldSuite`), not the new one: at
completion both suites are active so `active` is ambiguous, and the old key is
the one both the initiate and resume clients already hold the password for.
- **Abort is deliberately unguarded.** `MigrationController::abort` is
restorative (it returns the vault to the still-active old suite), so requiring
a proof would leave a vault wedged by an unauthorised rotation wedged.

**A new route that can irreversibly destroy vault data MUST be added to
`tests/Unit/Controller/VaultKeyProofAttributesTest.php`.** A declarative guard
fails *open* when it is omitted — nothing errors, the attribute is just absent —
so that reflection test enumerates the guarded routes and fails the build if one
loses its attribute or has its binding/subject/purpose loosened. The test also
carries a documented exclusion list (`proofChallenge`, `abort`).

## 5. Open Research Questions

1. **Application API authentication** — RFC 7523 (JWT Bearer / Private Key JWT) is the lean for how approved applications authenticate to retrieve secrets. Uses existing RSA key infrastructure, short-lived tokens, no new credential. Needs team discussion before finalizing. See [application-mgmt spec](../openspec/specs/application-mgmt/spec.md).
Expand Down
37 changes: 36 additions & 1 deletion l10n/be.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
OC.L10N.register(
"keepiq",
{
"Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.": "Emergency access for %n contact could not be carried across and was removed. Re-establish it so they can still recover your vault.",
"Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.": "Emergency access for %n contacts could not be carried across and was removed. Re-establish them so they can still recover your vault.",
"Revoke and delete emergency access": "Revoke and delete emergency access",
"This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contact. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.",
"This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.": "This also permanently deletes emergency access for %n contacts. If an emergency accessor exists, they must retrieve the secrets first, while this suite is still active.",
"Abort and keep my old key": "Abort and keep my old key",
"Aborting…": "Aborting…",
"Could not abort the rotation.": "Could not abort the rotation.",
"Could not revoke. Check your master password.": "Could not revoke. Check your master password.",
"Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.": "Discards the new key and unlocks your vault under the old one. Only possible while nothing has been re-encrypted yet.",
"Re-enter your previous master password to finish": "Re-enter your previous master password to finish",
"Revoke emergency access": "Revoke emergency access",
"This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.": "This deletes the recovery envelope for this contact. They will no longer be able to break glass unless you re-establish them.",
"Take over as vault administrator": "Перабраць як адміністратар сховішча",
"Select {name}": "Выбраць {name}",
"Could not load the password policy.": "Не ўдалося загрузіць палітыку пароляў.",
Expand Down Expand Up @@ -1133,7 +1146,29 @@ OC.L10N.register(
"Added {ok} of {total} secrets to the team folder": "Дадана {ok} з {total} сакрэтаў у камандную папку",
"The Keepiq browser extension autofills your logins, provides passkeys, and shows TOTP codes — without your secrets ever leaving your device.": "Пашырэнне Keepiq для браўзера аўтаматычна запаўняе вашы лагіны, дае ключы доступу і паказвае коды TOTP — і вашы сакрэты ніколі не пакідаюць вашу прыладу.",
"A placeholder is created and stays empty until the recipient fills it in — you never have to invent a value.": "Ствараецца запаўняльнік, які застаецца пустым, пакуль атрымальнік яго не запоўніць — вам ніколі не трэба выдумляць значэнне.",
"Could not reach the directory": "Не ўдалося звязацца з каталогам"
"Could not reach the directory": "Не ўдалося звязацца з каталогам",
"Integrations": "Інтэграцыі",
"Connection": "Злучэнне",
"Status message": "Паведамленне пра стан",
"Last checked": "Апошняя праверка",
"All connections": "Усе злучэнні",
"Add integration": "Дадаць інтэграцыю",
"Open settings": "Адкрыць налады",
"Configured": "Наладжана",
"Limited": "Абмежавана",
"Simulated": "Імітавана",
"Not available": "Недаступна",
"Error": "Памылка",
"e.g. Offboarding, device lost, key compromised": "напр. звальненне, страта прылады, кампраметацыя ключа",
"Encryption suites": "Наборы шыфравання",
"Failed to force-revoke suite": "Не ўдалося прымусова адклікаць набор",
"Failed to reinstate suite": "Не ўдалося аднавіць набор",
"Force-revoke a user- or application-owned encryption suite by id when its owner cannot (a forgotten master password, a de-authorised departure, or a compromise), and reinstate a revoked one. Force-revocation asks you to re-confirm your own password and permanently clears the suite's emergency access.": "Прымусова адклікаць набор шыфравання, які належыць карыстальніку або дадатку, па id, калі яго ўладальнік не можа (забыты галоўны пароль, адкліканы доступ або кампраметацыя), і аднавіць адкліканы. Прымусовае адкліканне просіць паўторна пацвердзіць ваш уласны пароль і назаўжды выдаляе аварыйны доступ набору.",
"Force-revoke suite": "Прымусова адклікаць набор",
"Reinstate suite": "Аднавіць набор",
"Revoking this suite deleted %n emergency-access contact.": "Адкліканне гэтага набору выдаліла %n кантакт аварыйнага доступу.",
"Revoking this suite deleted %n emergency-access contacts.": "Адкліканне гэтага набору выдаліла %n кантактаў аварыйнага доступу.",
"Treat the suite's secrets as compromised (flag for rotation and notify owners)": "Лічыць сакрэты набору скампраметаванымі (пазначыць для ратацыі і апавясціць уладальнікаў)"
},
"nplurals=2; plural=(n != 1);"
)
Loading
Loading