feat(vault-key-proof): require a master-password proof for destructive vault operations (#673) - #677
Conversation
OpenSpec change harden-vault-key-material-guards: proposal, design, tasks, and spec deltas for a verified master-password proof (VaultKeyProof) gating the irreversible key-material operations, plus a migration abort route. Reproduced end-to-end against development; findings 1 and 2 documented in the proposal. Spec only — implementation lands in a separate commit. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
The "any completed rotation silently costs emergency access" open question is resolved by #674 (migrate-emergency-access-on-rotation), which re-envelopes reachable contacts under the new key. The lost-password route's destructive mechanics (the revocation warning and the refuse-while-a-usable-contact-exists gate) are likewise folded into #674; this note records where each piece now lives. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
…673) Correcting the abort spec discovered during implementation: revoking the unused successor suite would run EncryptionSuiteRevokedListener, which for a user suite sweeps the owner's incoming ShareTargets and promotes their delegations — destroying real state over a migration the abort exists to undo. The successor is brand-new and empty, so it is deleted outright. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
The abort route the compromiseRecovery refusal already promises but that did not exist — the remedy named in the error message. It is the non-destructive terminal: completion carries the vault forward to the new suite and marks the old one compromised; abort carries it back to the old suite, which stays active and readable. Abort is permitted only while no record has been committed to the new suite (MigrationWorkService::countCommitted). Once a record has moved, both outcomes lose data, so the migration stays in_progress and the caller is pointed at resuming — a 409 carrying the committed count. This restriction is also exactly what makes abort safe against the session-only lockout: producing a valid re-encrypted record needs the master password, so a hostile session that never held it can never have committed one and can always be aborted away. On success: status -> aborted, the successor suite is deleted (not revoked, which would cascade the user-suite lost-identity teardown), failure accounting is cleared, the write lock is released, and SuiteMigrationAbortedEvent fires — NOT SuiteMigrationCompletedEvent, so the terminal cascade (compromise-flagging, link-share revocation, emergency-access invalidation) never runs. Its one listener unlocks the SecretRequests locked at start, keeping them on the old suite. Frontend: an "Abort and keep my old key" control on the resume banner plus the abortMigration store action. Backend + store fully unit-tested (abort restores/deletes; refused-after-commit with count; idempotent; aborted-not- completed event); phpmd clean; prettier clean. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
The core of the fix for the session-only lockout (#395). A destructive operation on vault key material now requires a VaultKeyProof: a signature, made with the caller's suite private key, over a server-issued challenge bound to the operation's own parameters. The private key is obtainable only by decrypting its envelope with the master password, so a verified proof is a server-verifiable proof of the master password — a stolen session, a leaked app password, or XSS in an unlocked tab no longer suffices, because the session key is non-extractable and decrypt-only and so cannot sign. - `#[VaultKeyProofRequired(binds, subject, purpose)]` declares the guard on a method; the binding lives on the attribute because the middleware cannot read the request body (the framework decodes JSON and drops the raw bytes), so the proof commits to NAMED parameters, hashed individually in order. - `VaultKeyProofMiddleware` enforces it: reads the attribute by reflection, resolves the subject suite, collects the bound params, delegates to the service, and maps a failure to 403 `key_proof_required`. It consults no auth backend and honours no token scope, so it is not waived for SSO/app-password sessions — its authority is key material, not the login method. - `VaultKeyProofService` issues a STATELESS, expiring, HMAC-authenticated nonce (no ICacheFactory — a null cache on a default install would break the flow) and verifies an RSASSA-PKCS1-v1_5 SHA-256 signature. Replay is a non-issue because the signature commits to the operation's parameters. - Challenge endpoint `GET /api/v1/suites/{id}/proof-challenge` (ungated). - Guard applied to compromiseRecovery, updatePrivateKey, complete, and the emergency-contact destroy. `VaultKeyProofAttributesTest` enumerates them and fails the build if one drops the attribute (a declarative guard fails open by omission), with a documented exclusion list (challenge, abort). Service crypto and middleware dispatch fully unit-tested (valid verifies; wrong key / altered value / tampered nonce / expired / wrong purpose / wrong user / missing all refused; attribute dispatch, subject resolution, foreign suite, 403 mapping). phpmd clean. The client half (proveMasterPassword + wiring the four flows) lands next — until then the guarded routes 403 by design. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
…#673) The client half of the guard. `proveMasterPassword` (reauth.js) decrypts the suite envelope with the freshly entered master password, re-imports the PKCS#8 bytes for SIGNING (RSASSA-PKCS1-v1_5 SHA-256 — a distinct capability from the session key, which is non-extractable and decrypt-only and so cannot sign), signs the challenge bound to the operation's parameters, and discards every derived key. `keyProof.js` fetches a challenge and returns the two proof headers, so the four flows do not each re-implement it. Wired the flows that already hold the master password, so they keep working against the now-guarded routes: - compromise-recovery START — proof over the OLD key (old password) bound to the new key material; - migration COMPLETE on the initiate path — proof over the NEW key (new password) bound to the migration id; - routine password change (updatePrivateKey) — proof over the current key (old password) bound to the new envelope; the old key is already materialised there, so no extra prompt. Tested: proveMasterPassword round-trips under RSASSA-PKCS1-v1_5 (verifies over the exact server-rebuilt message; wrong password throws before signing; a changed bound value fails verification), and the session key is pinned non-extractable / decrypt-only. Store tests still green. prettier + eslint clean (0 errors). REMAINING (tracked in tasks §4.7-4.8): the emergency-contact delete and the resume-path completion both need a master-password prompt at the point of action (no password in hand there), plus the 403 re-enter-and-retry UX. Until those land, those two paths return 403 by design. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
… §4.7-4.8) Finishes the client wiring so all four guarded flows work end to end. Emergency-contact delete (§4.7): `emergencyAccess.revoke(id, masterPassword)` builds a proof (subject active, bound to the contact id) and the delete carries it. `EmergencyAccessView` gained a master-password confirm dialog — deleting a contact destroys its recovery envelope, so it must prove the master password, which is why a session alone can no longer do it. Completion (§4.8): completion's proof is now over the OLD (retiring) key rather than the new one, via a new middleware subject `migrationOldSuite` that resolves the migration's old suite. Both suites are active at completion so 'active' was ambiguous, and — the point — the old key is the one BOTH the initiate and resume paths already hold the password for, so a resumed run finalises with no extra prompt. The "Finish anyway" acknowledgement path builds the proof from the retained (or re-entered) old password, and the form re-shows the password field on a `key_proof_required` refusal — the re-enter-and-retry UX. Coverage and middleware tests updated for the new subject; the middleware gains SuiteMigrationMapper to resolve the old suite. Backend + frontend tests green (79 PHP incl. the new migrationOldSuite resolution test; 22 frontend incl. proveMasterPassword). phpmd clean; prettier + eslint 0 errors. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
… §7) §6.3: VaultKeyProofCrossImplTest verifies a signature produced by the browser's scheme (WebCrypto RSASSA-PKCS1-v1_5 SHA-256, the one proveMasterPassword uses) with PHP openssl_verify over VaultKeyProofService::signedMessage — proving the two implementations agree on both the signature scheme and the message construction, the one interop risk a same-language test cannot catch. A tampered bound value breaks it. Fixture at tests/fixtures/vault-key-proof.json, regenerated by generate-vault-key-proof-fixture.mjs. §7: documented the guard in docs/ARCHITECTURE.md §4.2 — the guarded-route table, the attribute contract, the load-bearing design points (sign-not- decrypt; stateless nonce; not waived for any session type; complete proves the old key; abort deliberately unguarded), and the rule that a new destructive route MUST be added to VaultKeyProofAttributesTest. Confirmed gate-110 does not apply (no migration, info.xml version unchanged). Change now at 42/47. Remaining: 6.5/6.6 (a full request-pipeline / live without-proof assertion — belongs with the §7.6 live reproduction and a Newman e2e), and the human submission steps (§7.1 CI gates, §7.5 PR disclosure, §7.6 independent verification). Refs #673 Assisted-by: ClaudeCode:claude-opus-5
Adds a component test for the master-password gate on emergency-contact revocation: clicking Revoke opens the confirmation without calling the store; confirming passes the entered password through to store.revoke (which builds the proof); a key_proof_required refusal is surfaced and the dialog stays open to retry; a successful revoke closes it. There was no prior EmergencyAccessView test, so this is a new file rather than an extension. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ❌ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-09-11 08:36 UTC
Download the full PDF report from the workflow artifacts.
CI quality checks flagged three things the local per-file runs missed: - phpmd: `VaultKeyProofMiddleware::afterException` has unused `$controller`/ `$methodName` (mandated by the Middleware override) — suppressed with the same annotation MigrationController uses. Adding VaultKeyProofService pushed `EncryptionSuiteController` to coupling 13 — suppressed with justification, as two sibling controllers already do. - phpcs: `VaultKeyProofService` called its own `b64url()`/`mac()` with positional args (the codebase requires named params for internal calls), and the `EncryptionSuiteController` constructor docblock was missing the `$proofService` @PARAM. Full `lib/` is back to 0 errors. - test:l10n / l10n-parity: the 8 new UI strings (abort control, emergency revoke dialog, re-auth field) were added to `l10n/en.json` and seeded into all 36 required locales. Non-English values are English placeholders pending Transifex, consistent with how new source strings enter the pipeline. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
…stener (#673) The coverage-baseline guard failed because new code in MODIFIED files was untested, dropping their coverage against the merge base: - EncryptionSuiteController::proofChallenge had no test — added three (issue on a valid purpose; 400 on an unknown purpose; 404 on a foreign suite); - MigrationWorkService::countCommitted was only ever mocked (in MigrationServiceTest), so its body was uncovered — added a direct test summing the new-suite rows across the three stores, plus the zero case; - SuiteMigrationAbortedListener (a new file) gained a test: it unlocks the SecretRequests keeping the old suite, and ignores other events. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ❌ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-09-11 09:03 UTC
Download the full PDF report from the workflow artifacts.
The l10n/*.js browser catalogues are compiled from l10n/*.json, so the 8 new UI strings left them stale (check:l10n-js failed). Ran `npm run l10n:build` to regenerate all 37; the diff is purely additive and prettier-clean. Refs #673 Assisted-by: ClaudeCode:claude-opus-5
…ds (#673) Three mechanical gates were red on the guard-hardening change: - gate-46 (spec-anchor-existence): the abort @SPEC anchors pointed at openspec/specs/encryption-suites, but that requirement lives in the not-yet-archived change delta. Repoint the six abort anchors to openspec/changes/harden-vault-key-material-guards/specs/... so they resolve. - gate-16 (spec-coverage): add the missing @SPEC tags on VaultKeyProofService::issueChallenge/verify/signedMessage and on EmergencyAccessView's cancelRevoke. - gate-13 (modal-isolation): the revoke-confirmation NcDialog was written inline in EmergencyAccessView. Extract it to src/dialogs/EmergencyRevokeDialog.vue per ADR-004. The guard state (target id, busy flag, refusal message) stays with the view; the dialog is presentational and passes the entered master password back through its confirm event. Assisted-by: ClaudeCode:claude-opus-5
…ke dialog (#673) vue/attributes-order requires the two-way binding to precede plain prop bindings; the extracted EmergencyRevokeDialog had :open first, failing the lint-check and Vue Quality (eslint) CI jobs. Reorder only — no behaviour change. Assisted-by: ClaudeCode:claude-opus-5
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ❌ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-11 11:56 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-11 12:20 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Inline findings from a Strict-mode review. The protocol construction is sound — hash_equals on the MAC, openssl_verify !== 1, enforced 300s expiry, server-resolved verifying key, and a test that genuinely pins the session key as non-extractable/decrypt-only. The two blockers below are about coverage and binding completeness, not construction.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES (Strict mode)
The protocol construction is genuinely good. I checked the things that usually go wrong in a scheme like this, and they're all right:
hash_equals()on the MAC — no timing leak, no==/===anywhere on itopenssl_verifychecked as!== 1, so the-1error case refuses instead of passing (the classic truthiness bug — avoided)- expiry genuinely enforced on verify (300s), with an injected
ITimeFactoryand a real test - the verifying public key comes from the server-resolved subject suite, never from request input
purposeis inside the MAC'd payload and the nonce is line 1 of the signed message, so cross-purpose replay is closed- the middleware is registered (
PlatformIntegrationRegistrar.php:70) — not inert — and fails closed on every path I traced: missing/empty header, malformed base64, missing certificate, suite-resolutionThrowablere-wrapped rather than swallowed - sign-not-decrypt is load-bearing and actually pinned:
proveMasterPassword.spec.js:134-135assertsextractable === falseandusages === ['decrypt']— a real assertion, not a comment - no master password or raw PEM in
localStorage/sessionStorageanywhere insrc/crypto/ - the cross-impl test uses a fixed fixture and a tampered-bound-value negative case
On item 17 of my own checklist — the "primary control is untested" worry — that's largely unfounded: VaultKeyProofMiddlewareTest exercises the refusal paths directly. The missing live 403 is a genuine but narrow gap, and your rationale for it (same as RateLimitAttributesTest) holds.
Where the implementation doesn't match the design is coverage and binding completeness, not construction.
Two blockers
-
completebinds onlyid—acceptUnrecoverableandhasErrorsare unbound and re-aimable inside the 300s window, and the 409 body hands the caller the exact count needed. A replayed proof can finalise the migration with unacknowledged permanent loss.docs/ARCHITECTURE.md:630's "a replay only ever re-authorises the byte-identical operation" is not true for this route. Your owndesign.md:160already argues the parameter is security-relevant. -
revokeis left entirely outside the guard — and outside the attributes test. Session-only, hard-deletes ShareTargets, promotes delegations irreversibly, blocks every secret read, and recovery is admin-only so the victim can't undo it. That is the #395 lockout shape this PR exists to close, still reachable with nothing but a stolen cookie.
There are exactly four #[VaultKeyProofRequired] sites in lib/; revoke at :286 isn't one of them.
Guarded-route audit
| Route | Attribute | binds |
Params | Gap |
|---|---|---|---|---|
compromise-recovery |
✅ | publicKey, encryptedPrivateKey |
same | complete |
suites/{id}/private-key |
✅ | encryptedPrivateKey |
id, encryptedPrivateKey |
id selects the verifying key, so re-aiming breaks the signature — safe |
migrations/{id}/complete |
✅ | id |
id, hasErrors, acceptUnrecoverable |
🔴 two unbound |
emergency-access/contacts/{id} |
✅ | id |
id |
complete |
suites/{id}/revoke |
❌ | — | id, reason |
🔴 unguarded |
migrations/{id}/abort |
❌ deliberate | — | id |
accepted — gate verified |
suites/{id}/proof-challenge |
❌ deliberate | — | id, purpose |
accepted |
Two 🟡s
- The attributes test's route list is hardcoded — a good regression pin, but it can't catch a new destructive route.
revokeis the live proof it already failed once. validateOwnershippasses for any non-user-owned suite, so the newproofChallengeaccepts an application suite id. Nothing is granted — the middleware's ownassertOwned()is the real control — but the check the endpoint advertises in its docblock isn't the check the code performs. (PR #676 fixes this same helper; worth coordinating.)
🟢 Notes
- The challenge HMAC key defaults to
''whensecretis unset (VaultKeyProofService.php:241). Not exploitable on its own — a forged challenge still can't produce a signature — but a key-material control shouldn't silently degrade. Throw on an empty secret. - Abort's gate is correct. I verified your claim: it refuses whenever
countCommitted() > 0, is idempotent on non-in_progress, deletes rather than revokes the successor, and dispatches the aborted event so the teardown cascades don't fire. The reasoning that a hostile session can never have committed a record holds. The only residual is a rotation-restart nuisance, which is the right trade against a wedged vault.
On the disclosure note — filing publicly was the contributor's call to make and the reasoning is sound for a pre-production app; no objection from me.
Wilco's Strict-review blocker #2: suites/{id}/revoke was the one destructive route outside the guard — session-only, hard-deletes ShareTargets, promotes delegations, blocks every secret read, and reinstate is admin-only, so a stolen cookie could inflict the exact #395 lockout this change exists to close. - New purpose VaultKeyProofService::PURPOSE_REVOKE_SUITE + PROOF_PURPOSE.REVOKE_SUITE. - EncryptionSuiteController::revoke gains #[VaultKeyProofRequired(binds: ['reason'], subject: 'routeParam:id', purpose: PURPOSE_REVOKE_SUITE)] — the verifying key is resolved from the suite being revoked, so a re-aimed id breaks the signature, and the reason is bound so a captured proof can't be replayed against another request. Added to VaultKeyProofAttributesTest so a future drop of the attribute fails CI. - Frontend: revokeSuite(reason, masterPassword) signs the proof and attaches the headers; the revoke confirmation now asks for the master password (which signs and is never sent). A stolen session, lacking the master password, can no longer revoke. An owner who has LOST the password uses the separate admin recovery path (to be designed in its own PR), never this one. Both @SPEC tags kept on the touched methods (retrofit + the new vault-key-proof requirement): a deleted @SPEC would trip gate-16's whole-file re-evaluation, which mis-reads the multi-line #[VaultKeyProofRequired] attribute on the other guarded methods (the checker bug noted on #678) — kept additive to avoid it. Assisted-by: ClaudeCode:claude-opus-5
…#673) Wilco's Strict-review blocker #1: MigrationController::complete bound only `id`, leaving `hasErrors` and `acceptUnrecoverable` unbound and re-aimable within the 300s window — and the 409 body hands the caller the exact required count. A proof captured on a clean completion could be replayed to finalise the migration with an unacknowledged permanent loss, contradicting ARCHITECTURE.md's "a replay only ever re-authorises the byte-identical operation". - complete now binds ['id', 'hasErrors', 'acceptUnrecoverable']; attributes test updated so a future narrowing fails CI. - Client: a boundParam() helper serialises each value exactly as the middleware's (string) cast does — true → '1', false/null/absent → '', else String(value) — and the three completion-proof sites (initiate, resume, acceptMigrationLosses) bind [id, hasErrors, acceptUnrecoverable] accordingly. acceptMigrationLosses, the acknowledgement retry, now commits to the accepted count, so each distinct acknowledgement needs its own proof — closing the replay. - Store test pins acceptMigrationLosses' bound values; ARCHITECTURE.md's guard table updated (complete's full binds, plus the revoke row). Live 403/replay verification remains the same deferred gap as the rest of the guard (no running-pipeline test), noted on #677. Assisted-by: ClaudeCode:claude-opus-5
…rden-vault-key-material-guards # Conflicts: # lib/Controller/EncryptionSuiteController.php
Replace the `{*}` jsdoc type on the completion-proof serialization helper with
the concrete union it actually takes, clearing the one new
jsdoc/reject-any-type warning the blocker-1 change introduced.
Assisted-by: ClaudeCode:claude-opus-5
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test. |
||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-14 08:25 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Inline findings from a Strict-mode re-review of the three fix commits. Both prior blockers were taken seriously and complete's binding is properly closed; the revoke guard is the right shape but is inert as landed — one missing allow-list entry. Verdict follows separately.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES (Strict mode) — re-review of 96470c1…cc63def
Both blockers were taken on directly, and the reasoning in the new commit messages and docblocks is good. One of them is properly closed; the other landed a correct guard that can't fire.
Blocker 1 — complete binding: ✅ closed
binds: ['id', 'hasErrors', 'acceptUnrecoverable'], matching boundValues at all three client call sites, provider row and docs table updated. I checked the part that could have failed silently: the middleware's (string)getParam($name, '') and the new boundParam() helper agree on every case that matters (true→'1', false/null/absent→''), and completeMigration posts hasErrors as a real boolean. A proof captured on a clean completion no longer verifies once acceptUnrecoverable is set. Thread resolved.
Blocker 2 — revoke: ⚠️ guarded on paper, inert in practice
The attribute is the right shape — dedicated purpose, subject: 'routeParam:id', binds: ['reason'], provider row, client wiring, a master-password field in the confirm dialog, and store tests asserting the headers ride the request. But PURPOSE_REVOKE_SUITE was never added to ALLOWED_PURPOSES, so proofChallenge answers 400 Unknown or missing proof purpose, no proof can be built, and the middleware refuses every revoke.
revoke therefore went from reachable by any session to reachable by nobody — and it's the self-service "my laptop was stolen, lock my vault" control, with revokeSuite()'s single caller being this method and reinstate being admin-only. One line fixes it.
Guarded-route audit (updated)
| Route | Attribute | binds |
Params | Status |
|---|---|---|---|---|
compromise-recovery |
✅ | publicKey, encryptedPrivateKey |
same | complete |
suites/{id}/private-key |
✅ | encryptedPrivateKey |
id, encryptedPrivateKey |
complete (id selects the verifying key) |
migrations/{id}/complete |
✅ | id, hasErrors, acceptUnrecoverable |
same | ✅ fixed this round |
emergency-access/contacts/{id} |
✅ | id |
id |
complete |
suites/{id}/revoke |
✅ | reason |
id, reason |
🔴 purpose not issuable → route dead |
migrations/{id}/abort |
❌ deliberate | — | id |
accepted |
suites/{id}/proof-challenge |
❌ deliberate | — | id, purpose |
accepted |
Also this round
- 🟡 Nothing pins "a guarded purpose is issuable" — the root cause of the above, and why 30 green checks didn't catch it.
VaultKeyProofAttributesTestasserts the attribute's purpose constant;EncryptionSuiteControllerTestonly exercisesproofChallengewith two purposes that happen to be in the list. A one-lineassertContains($purpose, ALLOWED_PURPOSES)over the existing provider closes it. My earlier note about the provider being hardcoded stays open — it still derives nothing fromappinfo/routes.php. - 🟡 The master password is now the only key to a revoke — a design consequence worth recording rather than a defect, and it's where @rjzondervan's Slack point lands. Short version: admin revocation rights are a fair constraint and my finding never argued against them (the concern was
#[NoAdminRequired]admitting any session); but on this branch no admin revoke exists to preserve, andreinstateSuite()restores only the status flag — the ShareTargetDELETEand the delegation promotion are not undone, so the cascade isn't reversible even though the status is. An admin-only proof-exemptforceRevokewould close both halves; good follow-up issue, not this PR. - 🟢
validateOwnership— fixed, via thedevelopmentmerge bringing in theownerType !== 'user' || ownerId !== $userIdform. My earlier note about coordinating with #676 is moot; it landed. - 🟢 Still open from last time: the challenge HMAC key defaults to
''whensecretis unset (VaultKeyProofService.php:242). Not exploitable on its own, but a key-material control shouldn't silently degrade — throw on an empty secret.
Blocking set
Just the one line in ALLOWED_PURPOSES. I'd take the test cross-check with it, since it is what turns that class of mistake from green to red, but only the first is blocking.
CI is green across all 30 checks on cc63def, which is worth stating plainly: this failure mode is invisible to every gate the repo has.
Wilco's re-review blocker: PURPOSE_REVOKE_SUITE carried the guard attribute but was never added to ALLOWED_PURPOSES, so proofChallenge answered 400 for it, no proof could be built, and the middleware refused every revoke — the guard was present but inert, turning revoke from "reachable by any session" into "reachable by nobody" (the self-service "my laptop was stolen, lock my vault" control). Also close the class of mistake that let 30 green checks miss it: VaultKeyProof- AttributesTest now cross-checks every guarded method's purpose against ALLOWED_PURPOSES. And the standing 🟢: VaultKeyProofService::mac throws when the instance secret is unset instead of degrading to an empty HMAC key. Assisted-by: ClaudeCode:claude-opus-5
Quality Report — ConductionNL/keepiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-l10n-js | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| composer | ✅ | ✅ 111/111 | |||
| npm | ✅ | ✅ 543/543 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ✅ | ||||
| PHPUnit | ✅ | ||||
| Newman | ✅ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it on any branch from the Actions tab, or locally with npx playwright test. |
||||
| Hydra gates | ✅ |
Quality workflow — 2026-09-14 09:26 UTC
Download the full PDF report from the workflow artifacts.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: APPROVE (Standard mode) — re-review of cc63def…716b8b6
Both things I blocked on last round are fixed, and I verified each at the head SHA rather than from the diff:
- The
revokeguard is now live.PURPOSE_REVOKE_SUITEis inALLOWED_PURPOSES, soproofChallengeissues for it and the attribute added inaef04a7actually takes effect instead of refusing every revoke. - The gap that let an inert guard ship green is closed. The attribute test now cross-checks each guarded purpose against the issuable set — the same suite that went green on a dead guard would now turn red.
One change in 716b8b6 I didn't ask for and think is a good catch: mac() throws when the instance secret is empty instead of computing an HMAC with an empty key. I traced the failure path before treating it as harmless — verify() isn't wrapped in a catch, and afterException re-throws anything that isn't a KeyProofRequiredException, so an unset secret produces a 500 rather than a forgeable challenge. That's fail-closed in the right direction, and it removes a real (if unlikely) fail-open: with an empty key, anyone could have minted a valid nonce.
CI is green across all 30 checks on 716b8b6.
Two 🟡s stay open, neither blocking:
guardedMethodsProvider()is still a literal array — the routes that exist are pinned, but one added tomorrow without the attribute still passes silently. Follow-up issue, not this PR.- The master password is now the only key to a revoke — a decision I'd like recorded in
docs/ARCHITECTURE.md §4.2rather than inherited. Either answer is fine; I'd just like it written down.
The two items your description lists as not yet done — the live without-proof 403 and the independent human reproduction — are still outstanding, and they're the ones that matter most for a security fix. Approving on the protocol and the guard coverage; the end-to-end confirmation is yours to sign off before merge.
What this changes
Closes the session-only vault-lockout reported in #395 (and the two further vectors found while tracing it). Under ADR-003 the server holds only ciphertext, so reading the vault needs the master password — but destroying key material needed only a Nextcloud session. This makes the destructive operations require a vault-key proof: a signature, made with the owner's suite private key, over a server-issued challenge bound to the operation's 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. A stolen cookie, leaked app password, or XSS in an unlocked tab no longer suffices.
Backend
#[VaultKeyProofRequired(binds, subject, purpose)]+VaultKeyProofMiddleware+VaultKeyProofService(stateless HMAC challenge; RSASSA-PKCS1-v1_5 SHA-256 signature; noICacheFactory).GET /api/v1/suites/{id}/proof-challenge(ungated).compromiseRecovery,updatePrivateKey,complete, and the emergency-contactdestroy.VaultKeyProofAttributesTestfails the build if a destructive route drops the attribute.POST /api/v1/migrations/{id}/abort) — the remedycompromiseRecoveryalready promised. Non-destructive; refuses once any record has moved.Frontend
proveMasterPassword+ a shared challenge/header helper; all four flows send the proof. Emergency delete gained a master-password confirm dialog; the recovery form re-prompts on akey_proof_requiredrefusal; the resume banner gained an abort control.Design points worth reviewing (documented in
docs/ARCHITECTURE.md §4.2): sign-not-decrypt (the session key is decrypt-only and can't sign); the attribute carries the binding because the middleware can't read the body;completeproves the old key; abort is deliberately unguarded.Testing
proveMasterPasswordround-trip, the session-key decrypt-only pin, the emergency confirm dialog, store and component tests.Not yet done (tracked in the OpenSpec change)
A live without-proof 403 / finding-2 end-to-end regression needs the running request pipeline (the guard is middleware-enforced, so isolated PHPUnit can't produce a real 403 — same rationale as
RateLimitAttributesTest), and the independent human reproduction (pre-fix → refused post-fix). These are the remaining§6.5/6.6/7.6items.Tracking
Closes #673 (OpenSpec change
harden-vault-key-material-guards) and closes #395 (the reported vulnerability). The§3abort route is independently mergeable if you'd prefer to split it out.Security disclosure
This fixes an exploitable vulnerability. #395 was filed publicly by the contributor deliberately (the app is pre-production); this PR and its linked issue therefore discuss it in the open. If that is not intended, per the project security policy the report path is HackerOne rather than a public PR — the contributor's call.
AI assistance disclosure
Assisted by Claude Code (claude-opus-5): drafted the guard middleware/service/attribute, the abort route, the client wiring, the OpenSpec change and tests, and reproduced #395 on a local instance. All changes to be reviewed and independently verified by the human contributor before merge.