Skip to content

feat(vault-key-proof): require a master-password proof for destructive vault operations (#673) - #677

Merged
rjzondervan merged 19 commits into
developmentfrom
feature/673/harden-vault-key-material-guards
Sep 14, 2026
Merged

rjzondervan merged 19 commits into
developmentfrom
feature/673/harden-vault-key-material-guards

Conversation

@rjzondervan

@rjzondervan rjzondervan commented Sep 11, 2026

Copy link
Copy Markdown
Member

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; no ICacheFactory).
  • Challenge endpoint GET /api/v1/suites/{id}/proof-challenge (ungated).
  • Guard applied to compromiseRecovery, updatePrivateKey, complete, and the emergency-contact destroy. VaultKeyProofAttributesTest fails the build if a destructive route drops the attribute.
  • New abort route (POST /api/v1/migrations/{id}/abort) — the remedy compromiseRecovery already 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 a key_proof_required refusal; 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; complete proves the old key; abort is deliberately unguarded.

Testing

  • Full PHP unit suite green (1244); dedicated tests for the service crypto, middleware dispatch, attribute coverage, a JS→PHP cross-impl round-trip, and abort.
  • Frontend green: proveMasterPassword round-trip, the session-key decrypt-only pin, the emergency confirm dialog, store and component tests.
  • phpmd clean; prettier + eslint 0 errors. Frontend rebuilt locally and verified to contain the proof wiring.

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.6 items.

Tracking

Closes #673 (OpenSpec change harden-vault-key-material-guards) and closes #395 (the reported vulnerability). The §3 abort 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.

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
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 76695c2

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
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 2af8a0c

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
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 4792535

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.

@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 7f54672

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.

rjzondervan added a commit that referenced this pull request Sep 11, 2026
Bring #677's guard code into this branch so #674 builds on top of it
rather than re-touching the overlapping migration / emergency-access
surfaces independently. Per the issue owner's decision to stack #674 on
#677 rather than branch from development.

Assisted-by: ClaudeCode:claude-opus-5

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/Controller/MigrationController.php Outdated
Comment thread lib/Controller/EncryptionSuiteController.php
Comment thread tests/Unit/Controller/VaultKeyProofAttributesTest.php

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 it
  • openssl_verify checked as !== 1, so the -1 error case refuses instead of passing (the classic truthiness bug — avoided)
  • expiry genuinely enforced on verify (300s), with an injected ITimeFactory and a real test
  • the verifying public key comes from the server-resolved subject suite, never from request input
  • purpose is 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-resolution Throwable re-wrapped rather than swallowed
  • sign-not-decrypt is load-bearing and actually pinned: proveMasterPassword.spec.js:134-135 asserts extractable === false and usages === ['decrypt'] — a real assertion, not a comment
  • no master password or raw PEM in localStorage/sessionStorage anywhere in src/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

  1. complete binds only idacceptUnrecoverable and hasErrors are 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 own design.md:160 already argues the parameter is security-relevant.

  2. revoke is 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

  1. The attributes test's route list is hardcoded — a good regression pin, but it can't catch a new destructive route. revoke is the live proof it already failed once.
  2. validateOwnership passes for any non-user-owned suite, so the new proofChallenge accepts an application suite id. Nothing is granted — the middleware's own assertOwned() 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 '' when secret is 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
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ e4e8f7e

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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/Service/VaultKeyProofService.php
Comment thread tests/Unit/Controller/VaultKeyProofAttributesTest.php
Comment thread lib/Controller/EncryptionSuiteController.php

@WilcoLouwerse WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. VaultKeyProofAttributesTest asserts the attribute's purpose constant; EncryptionSuiteControllerTest only exercises proofChallenge with two purposes that happen to be in the list. A one-line assertContains($purpose, ALLOWED_PURPOSES) over the existing provider closes it. My earlier note about the provider being hardcoded stays open — it still derives nothing from appinfo/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, and reinstateSuite() restores only the status flag — the ShareTarget DELETE and the delegation promotion are not undone, so the cascade isn't reversible even though the status is. An admin-only proof-exempt forceRevoke would close both halves; good follow-up issue, not this PR.
  • 🟢 validateOwnership — fixed, via the development merge bringing in the ownerType !== 'user' || ownerId !== $userId form. My earlier note about coordinating with #676 is moot; it landed.
  • 🟢 Still open from last time: the challenge HMAC key defaults to '' when secret is 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
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/keepiq @ 7c7ce04

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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

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:

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.

@rjzondervan
rjzondervan merged commit d46a2e4 into development Sep 14, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants