auth: DAV app passwords (ADR 0064 piece 5, D3) #782
Closed
kayg
wants to merge 0 commits from
feat/dav-app-passwords into dev
pull from: feat/dav-app-passwords
merge into: kayg:dev
kayg:main
kayg:feat/style-sheet-fixes
kayg:storage/wal-checkpoint-off-save-1051
kayg:feat/contrast
kayg:t3/paged-search-reconcile-final
kayg:feat/pass8
kayg:review/calendar-2
kayg:fix/shared-row-swipes
kayg:dev
kayg:feat/collections-db
kayg:fix/instant-keys-1056
kayg:feat/money-round
kayg:perf/stalls-1054
kayg:wip/tiering-v3-cold4
kayg:wip/tiering-v3-cold5
kayg:port/rows-actions
kayg:port/rows-drop
kayg:port/hidden-blocks
kayg:port/rows
kayg:port/port-tables
kayg:port/port-tables-2
kayg:t3/mail-1011-liveness
kayg:port/devices
kayg:port/port-attachments
kayg:port/port-attachments-2
kayg:port/port-attachments-3
kayg:docs/design-consistency
kayg:docs/engineering-standards
kayg:fix/rusqlite-0.41-rustsec-2026-0459
kayg:feat/iphone-fixes
kayg:shared/restore-and-stale-918-919
kayg:fix/phone-top-band-contrast
kayg:feat/notes-approve
kayg:perf/soak-max-gate
kayg:feat/files-s1-identity
kayg:fix/lifecycle-event-id
kayg:feat/quickfix
kayg:wip/tiering-v3-cold
kayg:perf/web-mockup-frame-fixes
kayg:port/contacts-merge
kayg:port/contacts-edit
kayg:test/shared-room-no-deleted-text
kayg:perf/first-paint-credits
kayg:fix/shared-history-after-grant
kayg:fix/phone-swipe-regressions
kayg:feat/contrast-r1
kayg:review/sharing-pr3-review
kayg:feat/tiering-v3-s2-tags
kayg:feat/folder-drop
kayg:fix/deskpills-lazy-share
kayg:feat/sharing-s1-pr3
kayg:tmp/money-round-before
kayg:fix/phone-pane-backdrop-roots
kayg:fix/palette-preload-chips
kayg:fix/invite-defaults
kayg:port/tasks-matrix
kayg:fix/restore-green-gates
kayg:fix/sharing-fast-path-hardening
kayg:t3/photos-s1
kayg:feat/tiering-v3-s1-namespace
kayg:notes-archive-hide
kayg:feat/tasks-actions
kayg:t3/photos-plan-slice-one
kayg:feat/whiteboard-s1
kayg:t3/paged-search-reconcile
kayg:t3/smooth-pane-transitions
kayg:t3/index-position-startup-scan
kayg:fix/private-staging-759b-r2
kayg:feat/dav-transport
kayg:feat/imap-notes-fetch
kayg:feat/imap-notes-projection
kayg:feat/imap-notes-readonly
kayg:feat/imap-notes-auth
kayg:feat/imap-notes-server-foundation
kayg:docs/undo-block-ids-plan
kayg:fix/undo-768
kayg:proto/block-identity-v7d
kayg:issue/carddav-read-perf-fix-r2
kayg:issue/carddav-read-r2
kayg:docs/imap-766-plan-v2
kayg:docs/imap-notes-plan
kayg:docs/quota-plan-2
kayg:fix/parallel-test-flakes
kayg:feat/contacts-companion-pure
kayg:docs/calendar-backend-v2-plan
kayg:feat/calendar-ical-converter
kayg:docs/tasks-writes-v2-plan
kayg:docs/carddav-writes-v2-plan
kayg:docs/contacts-import-v2-plan
kayg:issue/610-pm-midnight
kayg:feat/calendar-v2-s1-containment
kayg:docs/dav-recorder-v2-plan
kayg:plan/previews-865
kayg:plan/save-log-durable-entry
kayg:fix/e2e-data-on-ram
kayg:feat/appwindow3
kayg:feat/appwindow2
kayg:feat/appwindow
kayg:feat/money-p1
kayg:feat/keys-sheet
kayg:review/calendar-2-themes
kayg:feat/smooth-cursor
kayg:fix/float-style
kayg:review/calendar-2-float
kayg:release/2026-11-06-hotfix
kayg:archive/port/rows-drop-code
kayg:archive/port/rows-actions
kayg:archive/port/rows
kayg:scratch/tasks-894-ui-gates
kayg:review/calendar-2-night-wip
kayg:release/2026-11-05
kayg:archive/feat/tiering-v3-s1-namespace
kayg:archive/flaky/rows-drop-fixed
kayg:archive/flaky/rows-actions
kayg:archive/feat/tiering-v3-reader
kayg:archive/feat/tiering-v3-roots
kayg:archive/flaky/rows
kayg:sim/bm15-final
kayg:sim/bm15-fix
kayg:sim/bm15-bis-932
kayg:sim/bm15-bis-909
kayg:sim/bm15
kayg:sim/bm15-bis-887
kayg:perf/tiering-option-b
kayg:port/style-3
kayg:port/style-2
kayg:port/style
kayg:fix/sw-lazy-precache
kayg:fix/vim-w-waits-for-save
kayg:fix/signout-test-races
kayg:fp-scratch/all
kayg:sim/bm15-first-paint
kayg:archive/bm15/rows-drop
kayg:archive/bm15/rows-actions
kayg:fix/palette-snippet-contrast
kayg:archive/bm15/rows
kayg:archive/bm15/hidden
kayg:archive/plan/save-log-durable-entry
kayg:mock/money-fix2
kayg:archive/fix/e2e-data-on-ram
kayg:mock/appearance-all
kayg:mock/pill-press
kayg:archive/docs/design-consistency
kayg:mock/auto-swipes
kayg:mock/mail-actions
kayg:archive/feat/mail-p1
kayg:mock/money-static
kayg:archive/review/standards-917
kayg:archive/docs/engineering-standards
kayg:perf/tiering-761-fix
kayg:mock/type-audit
kayg:mock/third-pane
kayg:feat/contacts-import-v2-s1
kayg:perf/executor-hot-path
kayg:archive/port/hidden-blocks
kayg:archive/port/devices
kayg:scratch/executor-on-894
kayg:scratch/executor-spans2
kayg:scratch/executor-spans
kayg:train/2026-11-03-bm14c
kayg:archive/port/rows-drop
kayg:feat/tiering-761-d-scale
kayg:feat/tiering-761-c-index
kayg:feat/tiering-761-b-reader
kayg:archive/review/sharing-pr3-review-9fec3d1f
kayg:train/2026-11-03-bm14b
kayg:perf/ports-lazy-budgets
kayg:feat/tiering-761-a-migration
kayg:train/2026-11-03-bm14
kayg:train/2026-11-03-bm12
kayg:archive/review/sharing-pr3-review
kayg:archive/feat/sharing-s1-pr3
kayg:train/2026-11-03-bm11
kayg:archive/feat/imap-notes-fetch
kayg:train/2026-11-03-bm10
kayg:archive/review/sharing-pr3-review-code
kayg:archive/plan/previews-865
kayg:train/2026-11-02-bm9
kayg:archive/port/port-attachments-3
kayg:archive/port/contacts-merge
kayg:fix/motion-audit-859
kayg:train/2026-11-02-bm8
kayg:archive/scratch/bm8-prep
kayg:archive/port/port-pickers
kayg:archive/port/port-tables-2
kayg:archive/port/port-tables
kayg:archive/feat/sharing-s1-pr3-code
kayg:archive/scratch/bm7-fixed
kayg:train/2026-11-02-bm7
kayg:archive/fix/parallel-test-flakes-code-minipc01
kayg:train/2026-11-02-bm6
kayg:docs/imap-766-readonly-slice
kayg:feat/dav-recorder-v3
kayg:research/788-diagnosis
kayg:archive/fix/parallel-test-flakes
kayg:archive/perf/pr792-cold-index-edit
kayg:docs/sharing-slice-1
kayg:archive/docs/imap-766-plan-v2
kayg:feat/dav-recorder-v2
kayg:archive/docs/dav-recorder-v2-plan
kayg:spike/imap-766-s0-cases
kayg:archive/fix/undo-768
kayg:feat/dav-recorder
kayg:archive/docs/contacts-import-v2-plan
kayg:archive/feat/calendar-ical-converter
kayg:feat/contacts-import
kayg:perf/cold-growth-on-764
kayg:perf/cold-growth-on-tiering
kayg:archive/docs/tasks-writes-v2-plan
kayg:archive/docs/calendar-backend-v2-plan
kayg:archive/feat/contacts-companion-pure
kayg:feat/tasks-set-status
kayg:feat/tasks-move-reorder
kayg:archive/docs/carddav-writes-v2-plan
kayg:archive/feat/calendar-v2-s1-containment
kayg:feat/tasks-delete-promote-duplicate
kayg:archive/feat/dav-transport
kayg:archive/fix/carddav-rebuild-r1
kayg:archive/fix/carddav-retention-r1
kayg:feat/carddav-writes
kayg:perf/quota-on-tiering
kayg:perf/quota-simple
kayg:perf/cold-growth-on-quota-stack
kayg:feat/calendar-identity
kayg:feat/calendar-event-parser
kayg:feat/calendar-props-maps
kayg:port/glass-when-ring
kayg:feat/tiering-761-s1
kayg:wip/10by32-01-impl-759b-scratch-20261030
kayg:wip/6by32-01-bm3-webkit-pause-20261030
kayg:wip/10by32-01-bm3-chromium-pause-20261030
kayg:wip/6by32-01-tier-761-s1-pause-20261030
kayg:spike/imap-766-s0
kayg:wip/6by32-02-calternal-pause-20261030
kayg:wip/8by64-01-review-688-pause-20261030
kayg:wip/6by32-01-verify-744-pause-20261030
kayg:wip/6by32-01-verify-742-pause-20261030
kayg:fix/private-staging-759b
kayg:wip/6by32-01-review-748-pause-20261030
kayg:wip/6by32-01-review-733c-pause-20261030
kayg:wip/6by32-01-review-733b-pause-20261030
kayg:wip/6by32-01-perf-idle-8-pause-20261030
kayg:wip/6by32-01-perf767-base-pause-20261030
kayg:wip/6by32-01-perf767b-767-pause-20261030
kayg:wip/6by32-01-perf767b-764-pause-20261030
kayg:wip/6by32-01-perf767-767-pause-20261030
kayg:wip/6by32-01-perf767-764-pause-20261030
kayg:wip/6by32-01-lists-b-pause-20261030
kayg:wip/6by32-01-ids-verify5-pause-20261030
kayg:wip/6by32-01-ids-verify4-pause-20261030
kayg:review/calendar-month
kayg:wip/10by32-02-undo-768-20261030
kayg:test/ids-verify3
kayg:wip/10by32-02-bm3-chromium-20261030
kayg:wip/10by32-01-review-753-20261030
kayg:wip/10by32-01-perf-idle-2-20261030
kayg:wip/10by32-01-bm2-webkit2-20261030
kayg:plan/759b-staging
kayg:train/2026-10-30-bm2
kayg:review/758b-tests
kayg:review/758-tests
kayg:review/764d-tests
kayg:review/767b-tests
kayg:review/preview-2026-10-29
kayg:review/767-tests
kayg:docs/tiering-731-plan
kayg:wip/c6b02-when2
kayg:wip/c6b02-tasks-create-fix5
kayg:wip/c6b02-settings-frost-mock
kayg:wip/c6b02-settings-frost-base
kayg:wip/c6b02-settings-frost
kayg:wip/c6b02-ring
kayg:wip/c6b02-review-733d
kayg:wip/c6b02-bm1-full-a
kayg:wip/compute02-when-field
kayg:wip/compute02-webkit-lists-repro
kayg:wip/compute02-train-bl2-dev
kayg:wip/compute02-dev-green-pw164
kayg:docs/deploy-plan
kayg:review/764c-tests
kayg:review/764b-tests
kayg:review/748g-tests
kayg:review/764-tests
kayg:review/759b-tests
kayg:fix/storage-owner-liveness-642
kayg:fix/storage-fix-7
kayg:wip/compute02-storage-fix-7
kayg:review/759-tests
kayg:feat/tiering-731
kayg:wip/tiering-731-first-attempt
kayg:wip/compute02-storage-fix-1
kayg:review/754g-tests
kayg:perf/idle-honest-2
kayg:review/754f-tests
kayg:review/754e-tests
kayg:review/755-tests
kayg:review/754d-tests
kayg:review/754c-tests
kayg:review/754b-tests
kayg:review/754-tests
kayg:fix/storage-private-tmp-642-dev
kayg:wip/compute02-verify-750
kayg:test/ids-verify5
kayg:test/ids-verify4
kayg:fix/storage-exact-file-quarantine-642
kayg:wip/compute02-verify-740
kayg:perf/one-flush-save
kayg:review/calendar-static
kayg:wip/compute02-verify-737
kayg:review/indexing-ring
kayg:review/when-field-2
kayg:wip/compute02-review-733
kayg:proto/block-identity-2-review
kayg:proto/block-identity-review
kayg:wip/c6b02-tc-design
kayg:review/when-field
kayg:issue/tasks-create-ids
kayg:issue/tasks-create
kayg:review/combined
kayg:wip/compute02-review-combined
kayg:review/task-polish
kayg:review/top-buttons
kayg:review/q1-q5
kayg:train/2026-10-26-bl2
kayg:archive/wt-398h
kayg:archive/train-bm-proptest-seed
kayg:archive/integ325-293
kayg:archive/integ325-ci
kayg:archive/client-auth-review-baseline
kayg:archive/wt-sim-4853
kayg:archive/wt-sim-4811
kayg:archive/wt-448m
kayg:archive/wt-398a-allocator
kayg:issue/calendar-adr
kayg:archive/old/train/2026-10-23bk2
kayg:archive/old/train/2026-10-23bk
kayg:issue/469-mockup-9a
kayg:issue/smart-tasks-mockup
kayg:issue/469-menus
kayg:issue/652-signout-flake
kayg:issue/652-history-fix7
kayg:issue/652-history-fix6
kayg:archive/old/issue/spring-check-perf
kayg:issue/spring-check-perf
kayg:issue/659-rename-fast
kayg:archive/old/train/2026-10-21ar
kayg:issue/search-hardening-3
kayg:issue/652-history-fix5
kayg:issue/660-port-sheets-glass
kayg:issue/652-history-fix4
kayg:issue/652-history-fix3
kayg:issue/652-history-fix2
kayg:issue/652-history-flake
kayg:train/2026-10-21an
kayg:issue/settings-admin-port
kayg:issue/search-port
kayg:train/2026-10-20am
kayg:issue/storage-split-fix2
kayg:issue/storage-split
kayg:archive/old/issue/folder-ids-fix
kayg:train/2026-10-20al
kayg:archive/old/issue/folder-ids
kayg:train/2026-10-20ak
kayg:issue/tables-mockup
kayg:train/2026-10-19aj
kayg:train/2026-10-19ai
kayg:issue/469-merge-fixes
kayg:issue/sidebar-band
kayg:archive/old/issue/608-settings-spec
kayg:train/2026-10-19ah
kayg:issue/swipe-offscreen
kayg:train/2026-10-19ag
kayg:archive/old/issue/590-nlp-adr
kayg:archive/old/issue/587-event-after-write-2
kayg:train/2026-10-19af
kayg:archive/old/issue/587-event-after-write
kayg:issue/469-pill-room-c
kayg:issue/469-pill-room
kayg:train/2026-10-19ae
kayg:issue/469-narrow-handle
kayg:issue/397-ios-swipe-2
kayg:archive/old/issue/adrs-photos-files-tasks
kayg:issue/469-desk-fixes
kayg:archive/old/issue/auto-update-adr
kayg:issue/469-filter-gap
kayg:issue/469-port-1b
kayg:issue/442-opacity-flake
kayg:archive/397-ios-swipe-2
kayg:issue/327-web-fast-3
kayg:archive/old/issue/469-port-1
kayg:issue/469-port-1
kayg:issue/327-web-fast-2
kayg:archive/old/issue/327-web-fast
kayg:archive/old/issue/425-stt-bench-vq1
kayg:archive/old/issue/445-no-bleed
kayg:archive/old/issue/400-swipe-mockup-2
kayg:issue/426-client-review
kayg:issue/426-client-2
kayg:issue/426-client
kayg:archive/old/issue/476-safe-area-lab
kayg:archive/445-top-light-2
kayg:archive/old/issue/445-top-light
kayg:issue/471-blocks-mockup
kayg:archive/old/issue/448-links-app-2
kayg:archive/old/issue/448-links-app
kayg:archive/old/issue/398-cold-links
kayg:issue/434-442-small-2
kayg:issue/434-442-small
kayg:archive/old/issue/398-unload
kayg:archive/old/issue/445-bottom-band
kayg:issue/445-bottom-band
kayg:issue/351-rem-3
kayg:archive/312-views-mockup
kayg:issue/351-rem-2
kayg:issue/351-rem
kayg:archive/old/issue/327-first-note
kayg:issue/395-width-phone
kayg:archive/old/issue/327-typing
kayg:issue/396-ios-edges
kayg:issue/397-ios-swipe
kayg:archive/398-allocator
kayg:archive/old/issue/327-notes-index
kayg:issue/345-347-contrast-empty-2
kayg:archive/old/issue/44-arenas
kayg:issue/44-arenas
kayg:archive/integ325
kayg:archive/old/issue/327-perf-harness
kayg:archive/old/issue/44-thp
kayg:issue/44-thp
kayg:archive/old/issue/285-sticky-pills
kayg:archive/old/issue/44-trim-3
kayg:issue/44-trim-3
kayg:archive/old/issue/165-cards
kayg:archive/old/issue/165-picker-2
kayg:archive/old/issue/212-multi-select
No reviewers
Labels
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
kayg/calternal!782
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/dav-app-passwords"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Final round implementation plan (resume from
40cfc587)Keep the existing assertion timestamp, v9 migration and fair 250 ms cold queue. Add a minimum 8 ms lifetime to each of the eight global cold permits (including cancelled requests): eight-read burst, at most 1,000 cold DB reads/s sustained across all sources. Warm entries and TTL rechecks remain separate. Finite queued bursts admit valid cold passwords; sustained overload can exceed the bounded wait and return 429. Add a deterministic test with all eight permits held by junk behind a blocked DB worker, plus concurrent HTTP flood coverage.
Limiter lookup stays O(1); recording/eviction uses an ordered LRU index in O(log n), no capacity scan. Expiry sweeps stay at most once per five seconds. Memory remains bounded at 10,000 buckets per limiter. Make failure deduplication atomic. Preserve the existing monotonic checked_at and touch-only-if-present behavior.
Extend Authenticator's return type with an explicit HTTP rejection; all extractors propagate it. DAV middleware and trait implementation share response construction, preserving 401/404/429/503 and retry headers. Strengthen passkey-add tests with a real ceremony, and explicitly test setup/recovery/invite sessions and all Bidi_Control characters.
Gates on dev-01 via Nix and slot: auth/server suites, contract tests, clippy (also attachments/backdrops affected by the trait), fmt. This finishes round 2; no additional review round or merge.
Plan (written first): DAV app passwords (ADR 0064 piece 5, plan step D3)
What this adds
auth.dbv8:installations.scopes(comma list; non-empty only for kinddav). Triggers keep that pairing true. Olddavrows get an empty scope and so reach nothing.dav+ scopes. Secretca1_+ 52 base32 chars (32 random bytes). Only the BLAKE3 hash is stored (same choice as session tokens: the secret is 256 bits of randomness, so a slow password hash buys nothing and would make each DAV request expensive).POST /api/v1/auth/app-passwords {name, scopes?}: signed-in only, returns the secret once. A plain route, not an action, because action results are kept in the idempotency table and the secret must never be stored.account.app-passwords(list, never secrets) andaccount.app-password-revoke.account.installationsalso shows them (withscopes).Auth::dav_gate(scope)->DavGate: HTTP Basic check for DAV routes only (/dav/**,/.well-known/{carddav,caldav}). Works as an axum middleware (addsDavIdentity) and as anAuthenticator. D4 mounts it; nothing is mounted here.Auth as Authenticator) refuses DAV paths; the session lookup skips kinddav; the app-password lookup wants kinddav; prefixes differ (ci1_vsca1_).Sizes and big-O
HashMaplookup, one revoked-set lookup, one 32-byte constant-time compare. O(1). First use per process: one indexed SQLite read (token_hashis UNIQUE). Last-seen write at most hourly, in the background.Limiter).Authorizationheader.Failure table
a_wrong_password_is_refused_with_a_challenge,near_misses_of_every_length_are_refusedblake3::Hashequality (constant time) on a fixed 32-byte hash; the map is keyed by the hash, never by the secrethash_compare_catches_a_change_anywhere(unit); timing itself is not asserted (flaky)revoking_stops_the_next_request_and_a_replay_failsrevoked_atinauth.dba_revoked_password_stays_revoked_after_a_restartthe_wrong_scope_is_refuseddav; prefix check;Authrefuses DAV pathsapp_passwords_do_not_reach_anything_outside_davdavsessions_do_not_reach_dava_password_for_someone_else_s_url_is_not_founddav_failures_per_minute)Retry-After: 60, even with a good password from that source; other sources unaffected; a missing header is free (every client starts that way)guessing_is_throttled_per_source_and_does_not_spill_overthe_database_never_holds_a_secretthere_is_a_limit_on_app_passwords_per_personrevoke_only_touches_your_own_app_passwordsversion_7_gets_scopes_and_scope_rules_are_enforcedDecisions (no questions asked)
token.rs/session.rs).revoked_at).Calendarexists as a scope so scope isolation is testable now, but creating one over HTTP is refused until CalDAV exists. Tests mint it throughAuth::mint_app_password.contracts/actions.jsonandapps/webgenerated types updated for the two new actions. No UI here.Gates (run on calternal-dev-01-compute02, Nix dev shell, via the slot helper)
cargo test -j4 -p calternal-auth --no-fail-fast: all green, including 14 new tests intests/app_passwords.rsand 3 new unit tests.cargo test -j4 -p calternal contract(contracts/actions.json in sync),cargo test -j4 -p calternal-server --no-fail-fast: green.cargo clippy -j4 -p calternal-auth --all-targets -- -D warningsandcargo fmt --all -- --check: clean.user_versionnow also drop the v8 column/triggers first; their version asserts went 7 to 8.Open risks
DavGate::middleware.trusted_proxiesis set; otherwise all clients share the proxy's bucket (same as login).Co-Authored-By: Claude Sonnet 5.5 noreply@anthropic.com
🤖 Generated with Claude Code
https://claude.ai/code/session_01Xi6YxsuZ7m2ESGC7aS91AN
Independent security review, round 1
Reviewed
70ccd57c(diff vsorigin/dev, ADR 0064, plan in the PR body). Read-only; nothing built or run. Line numbers refer to the PR head.Counts: P0 0, P1 2, P2 3, P3 8.
Checked and fine
getrandom, base32, 52 chars,ca1_prefix. 256 bits; BLAKE3 is fine for a uniformly random 256-bit secret (nothing to brute force; same as sessions). The secret is only inAppPasswordand the mint response (no-storevia the router layer). It is not in the action pipeline or idempotency table (plain route), not in the DB (hash only), and there is no TraceLayer or header logging. Thedebug!(?why)log carries only the enum.same_hashis constant time and redundant but harmless.dav(session.rs:229), theci1_/ca1_prefixes differ,Auth::authenticatereturns None on DAV paths (http.rs,is_dav_path), the gate reads Basic only, andDavGate::checkdenies any non-DAV path. Bearer or cookie carryingca1_failsplausible_session_token.verifyinserts into the cache, then checks the revoked set.forget_installationmarks the set first, then purges. Every interleaving I walked through ends in 401;touchre-inserting a purged entry is also caught by the revoked check. A restart is covered byrevoked_atin the DB.davrows get scope''and reach nothing; the old session lookup no longer honours them. The triggers only fire onUPDATE OF kind, scopes, so revoke is unaffected. An old binary on a v8 DB works (it only acceptsci1_).t[4..]is safe after the ASCII prefix check).P1
P1-1. The URL-user check fails open when the path does not parse as a user id.
dav.rs:135,dav.rs:57-59.url_userreturns None for/dav,/dav/,/dav/<non-uuid>/..,/dav//<uuid>/.., and percent-encoded ids (/dav/%31...).is_some_and(...)is then false and the request isAllowedwith a validDavIdentityfor user A. The check compares the raw, still-encoded path. If D4's router or handlers decode the segment (or take the user from the URL, which ADR 0064 says they do),/dav/%<encoded uuid of B>/contacts/passes the gate as A and a handler reads B's book. It is latent today because nothing is mounted, but this is the one place the cross-user rule is enforced.Fix: for any path under
/dav/(not the bare/davor the two well-known paths), require segment 1 to be a canonical user id with no%,\,..or empty segments, and return NotFound otherwise. Document and test that handlers must useDavIdentity.user, never re-parse the URL. Add tests:/dav/%<uuid>/x,/dav//<uuid>/x,/dav/./<uuid>, uppercase uuid,/dav/<uuid>%2f...P1-2. The per-source throttle also blocks valid credentials, so one bad device or one attacker locks out everyone sharing the source.
dav.rs:123,limit.rs has_room; the testguessing_is_throttled_per_source...asserts this behaviour.has_roomis checked before the password is verified, so past 20 failures a source gets 429 even with a correct password. Scenarios:(a) A household or office NAT: a phone with a revoked or stale password retries on every sync (Apple retries aggressively). 20 failures per minute is easy, and every other device on that IP, including the same user's working ones, gets 429 and keeps getting it. The outage is permanent while the stale device stays configured.
(b)
trusted_proxiesunset (the default) behind a reverse proxy, or noConnectInfo:source_keyis the proxy address or"unknown". One anonymous attacker sending 20 bad Basic headers per minute locks every user out of DAV indefinitely. The plan accepts this as "same as login", but login lockout does not take down an already-configured background sync.Fix: the secret is 256 bits, so the throttle only protects the database and CPU, not guessing. Verify first: a secret found in the cache or DB (valid, unrevoked, right scope) always passes, and the limiter only gates the uncached/unknown lookup path (check
has_roomonly when the DB has to be consulted, and count only unknown secrets). Also log a startup warning when the origin looks proxied andtrusted_proxiesis empty. Change the test to assert a valid password still works while the source is throttled.P2
P2-1. Any session, including agent and CLI bearer tokens, can mint a permanent credential; no step-up.
http.rs:408-412.app_password_createaccepts whateverauthenticateaccepts: browser, native, cli or agent installations. A stolen cookie, or an AI agent token, can mint a non-expiringca1_secret that outlives revocation of the session that created it (no link between them) and survives "sign out" of that session. Fix: require kind Browser/Native, preferably a fresh passkey assertion (as for adding a passkey), reject Cli/Agent, and emit a visible event or notification (announceis in the background only).P2-2. The app-password cache never expires and never re-reads the DB; DB errors are swallowed.
app_password.rs:175-200.Once cached, a password is valid until process restart or an in-process revoke. A revoke from another process (
calternal user ...CLI, which the code explicitly supports openingauth.dbnext to a live server, or a second instance during a rolling deploy) never reaches it. Sessions at least haveexpires_at; app passwords have no bound at all. The PR lists this as an open risk, but with non-expiring credentials it should be bounded. Also.ok().flatten().ok_or(Unknown)turns a DB error (busy, I/O) into a 401 that counts as a failure and feeds the throttle, so a DB hiccup throttles legitimate devices.Fix: add a TTL to cache entries (about 60 s) and re-check
revoked_aton expiry (one indexed read); map DB errors to 503 and do not count them.P2-3. DAV credentials get ambient-credential CSRF exposure once D6 writes exist.
dav.rs:170(WWW-Authenticate: Basic).Sending a Basic challenge on the app's own origin lets browsers prompt and then cache the app password for the origin, attached automatically to cross-site requests (
<form>POST,fetchwithno-cors) to/dav/**. Mostly mitigated by a DAV write needing a vCard body and a method that browsers rarely send cross-site, but PUT/DELETE through simple methods are not all safe. Fix: in the gate, refuse requests that carry a browserOriginorSec-Fetch-Site: cross-site(DAV clients send neither), and consider a challenge only on DAV paths (already the case). Add a test.P3
app_password.rs:63AppPasswordderivesDebugwith the secret in the struct; a stray{:?}ordbg!logs it. Use a redacting manualDebugforsecret.dav.rs:51The whole credential must be UTF-8, including the ignored user name. Clients that send a Latin-1 user name with a valid ASCII password get 401 and a counted failure. Decode lossy for the user part, or split on the first:in bytes and require only the password to be UTF-8.dav.rs:32Path matching is exact and case-sensitive, which is right, but nothing guards the D4 mount: noNormalizePath/trailing-slash layer, no percent-decoding before the gate, and/.well-known/carddav/or//dav/xare classed non-DAV (so session auth applies to them, which is only a problem if they route). Add an end-to-end test through the real router for//dav/x,/%64av/x,/dav/../api/v1/auth/me,/DAV/x,/.well-known/carddav/, plus HEAD and OPTIONS on every DAV route.app_password.rs:105The cap of 50 can be filled by anyone holding a session (see P2-1), which blocks the owner from adding a device; mint is not rate limited. Add a small per-user mint rate limit.retainis O(n) under a mutex on every failure (10,000 cap); an attacker spreading failures across many sources adds contention, and each unknown plausible secret costs one SQLite read.dav.rsserves Basic with no scheme check.AuthConfigrequires an https origin (localhost excepted), so it is fine by default, but a plain-http listener behind a proxy would accept app passwords; consider refusing whenX-Forwarded-Proto: httpfrom a trusted proxy.db.rstriggers only test non-empty scopes, so adavrow withscopes='bogus'is accepted (it decodes to no scope and grants nothing; harmless, but aGLOBcheck would make the DB rule match the code).session.rs:347 clean_namestrips control characters but not bidi overrides or zero-width characters, so a device name can spoof another in the list. Names also show no creator or creation source.Review round 1: findings and status (head
e358f3fa)dav_targetis strict and never decodes. Under/dav/the first segment must be exactly the owner's canonical (lowercase, hyphenated) id;%,\,;, empty,.,..segments and non-ASCII are refused (404 after sign-in).is_dav_pathnow also catches//dav,/%64av,/DAV, trailing-slash well-known. Handlers must useDavIdentity.user, never the URL. Tests:odd_paths_never_reach_a_handler_as_someone_else(catch-all and real-style routes), unitdav_targets_fail_closeda_stale_device_behind_a_shared_address_locks_nobody_out,an_anonymous_flood_does_not_block_a_valid_user, rewrittenguessing_is_throttled_per_source.... Startup warning for unsettrusted_proxies: not done (follow-up)app_password_fresh(10 min). A session is only created by passkey sign-in, so its age is the age of the last passkey assertion; older sessions sign in again. A separate "re-verify with passkey" endpoint is a UI follow-up. Test:minting_needs_a_fresh_interactive_session. Visible notification of a new device: not done (follow-up)app_password_recheck): one indexed read per active password per interval, cheaper than a generation counter that needs a read on every request. DB errors are 503 withRetry-After: 5and are not counted. Tests:a_revoke_from_another_process_is_seen_after_the_recheck,the_default_recheck_keeps_the_cache_warm,a_database_error_is_503_and_is_not_countedOriginfrom another site orSec-Fetch-Site: cross-site, and must not rely on a Basic challenge being cached by browsers; decide the UA/header rule in D6 when writes exist. Nothing is mounted for writes yetDebugDebughides it. Testthe_secret_stays_out_of_debug_output//dav/x,/%64av/x,/DAV/x,/dav/../api/v1/auth/me, well-known trailing slash). HEAD/OPTIONS per route: D4dav_mints_per_minute(10) per person. Testminting_is_rate_limited_per_person'',contacts,calendar,calendar,contacts(v8 is unmerged, edited in place). Test indb.rsclean_namestrips them. Unit testGates (compute02, Nix shell,
~/bin/slot,-j4):cargo fmt --all --check;cargo test -p calternal-auth --no-fail-fast(all green, 23 tests inapp_passwords);cargo test -p calternal contract;cargo test -p calternal-server --no-fail-fast;cargo clippy -p calternal-auth -p calternal-server --all-targets -- -D warnings.Independent security review, round 2
Reviewed head
e358f3fa(diff 70ccd57c..HEAD and the whole PR vsorigin/dev). Read-only; nothing built or run. Line numbers are for the PR head.Verdict: ready to merge as a pre-D4 building block, with the P2s below filed as D4 blockers. No P0, no P1. All round-1 P1s are fixed and their tests prove them. The new findings are about the fixes' edges (the step-up is weaker than described, the cold-lookup path can still be starved).
Round-1 items
dav_target(dav.rs:110) never decodes; refuses%,\,;,?,#, non-ASCII, empty/./..segments, non-canonical ids.check(dav.rs:217) only letsRootorUser(u)==ownerthrough; everything else is 404. I walked/dav/%<uuid>,//dav, uppercase, hyphenless,<uuid>%2f.., trailing-slash well-known: all refused.is_dav_pathis wide on purpose (decode + lowercase + trim slashes), so session auth refuses every spelling. The tests (odd_paths_never_reach_a_handler_as_someone_elseagainst a catch-all and real-style routes, plus unitdav_targets_fail_closed) assert the right things, including that user B's id never appears in the body.app_password.rs:223-273). Per-credential bucket plus "charge the source once per credential per minute" stops a stale device starving its NAT neighbours (test shows a 30-iteration retry loop and a fresh device still passing). Memory: credential and source limiters are each capped at 10,000 entries with LRU eviction (limit.rs:43), a junk flood from one source stops creating credential buckets after 20 becauseoverreturns beforefail(). Bounded. See N-1 for the concurrency hole.Browser; cli/agent get 403).created_atis never changed bytouch(it only moveslast_seen_at/expires_at) and there is no rotation, so session age really is time since sign-in. But see N-2.checked_atrecheck refreshes scopes and revocation from the DB; DB error gives 503 withRetry-After: 5and is not charged (app_password.rs:245-251). Revoke from another process test uses a real second connection. Not covered: error on a warm-expired entry (it returns 503, which is fine fail-closed).Debughidessecret; no other type holding the secret derives Debug;debug!(?why)is enum only.:in bytes, only the password must be UTF-8; unit test covers it.dav_mint_limitper user (http.rs:455), test passes. Counts failed JSON/scope attempts too; harmless.'',contacts,calendar,calendar,contacts;encode_scopessorts and dedups so it always writes one of those. Test covers bogus values and stripping.New findings
P2
N-1. A flood still starves a valid user whose password is not cached.
app_password.rs:228-244,dav_overflowin lib.rs.try_acquireon the 8-permit semaphore fails immediately when the permits are busy, and the permit holders are the attacker's own lookups queued behind the singleauth-dbworker (db.rs, one thread, queue of 64). With more than 8 concurrent junk requests from the shared source (the default whentrusted_proxiesis empty: everyone is"unknown"), all 8 permits are held nearly all the time, so a cold valid password from that source gets 429. Cold means: first request after a restart or deploy, a new device, or after any entry is evicted. Warm clients are fine (cached entries skip the whole branch), so the failure shows up exactly after restarts, when every device is cold at once. Apple backs off on 429, so it can last a while. The testan_anonymous_flood_does_not_block_a_valid_useris sequential; it never holds a permit, so it does not prove the semaphore.Separately: lookups for sources that are not yet over their limit are not capped globally. N sources times 20 distinct junk secrets per minute all go to the one DB worker, which also serves logins, session misses, action revokes and the 30 s rechecks of every warm DAV password. A botnet or an IPv6 /64 can fill that queue (64 slots,
send().awaitblocks) and add latency to all of auth.Fix: make the cold-lookup permit apply to every uncached lookup, and use
acquirewith a short timeout (e.g. 250 ms) for sources that are not over,try_acquireonly for over sources, so a valid password queues behind junk rather than losing to it. Add a global per-second cap on cold lookups. Add a concurrent test: hold all 8 permits with slow junk lookups, then assert a valid cold password gets 200 within a bound.N-2. The step-up is bypassable through passkey-add, and its description overstates what
created_atproves.http.rs:410-441,account.rs(account.passkey-add-*),setup.rs:387.Session
created_atis the time of a passkey sign-in, a setup/recovery/invite link redemption (setup.rs:387also makes aBrowsersession from a bearer link plus a new passkey, no assertion), or nothing else. That is fine as far as it goes. Butaccount.passkey-add-start/finishneeds only an ordinary session of any age. An attacker holding a stolen old cookie adds their own passkey, signs in with it (fresh session, 0 s old), and mints. The 10-minute window then buys nothing against cookie theft; it only stops a cookie that is both stolen and used within the window by someone who does not know about passkey-add. The same goes forpasskey-removeof the victim's key.Fix: apply the same freshness rule (
fresh_interactive_session) topasskey-add-finishandpasskey-remove, or better add the real step-up (a new assertion whose ceremony id is consumed by the mint request). Until then, say in the PR and ADR that the step-up defends against a cli/agent token and a very old stolen session only. Also emit a visible event on mint and on passkey-add (already a documented follow-up for new devices, but it is now what makes this bypass detectable).P3
N-3. Malformed passwords are charged to the source every time, not once per credential.
app_password.rs:208-212. Only well-formed unknown secrets are deduplicated byfail(). A device with a typo'd, truncated or padded password (or the person's login password typed into Apple Contacts) sends junk that is notca1_+ 52, and every retry costs the source a full count, so 20 retries a minute push the NAT's sourceover. Effect is mild now (cold valid lookups then need a permit, see N-1), but it brings back a small version of round-1 P1-2. Fix: dedupe those by the hash of the presented string in the credential bucket too, or charge them once per source per 10 s.N-4.
Limiter::attemptis O(n) under a global mutex on every call, and DAV now calls it on the hottest unauthenticated path.limit.rs:42(retainover up to 10,000 entries).dav_failure_limit.attemptruns for every junk header with no DB cost,has_room/counttake the same lock. At tens of thousands of requests per second this burns a core and serializes the async threads on a std mutex. Fix: sweep expired entries at most once per few seconds (or when len is near capacity), not on each call.N-5.
touchreinserts the cloned entry with its oldchecked_at.app_password.rs:293-295, 333-341. It can overwrite a fresher entry or resurrect one that a recheck just removed because a revoke happened in another process, extending exposure by at most one more 30 s window. Alsochecked_atuses wall-clocknow_ms, so a clock step backwards makesnow - checked_at < recheckstay true for as long as the step. Fix: intouchupdate onlytouched_atif the key is still present; use a monotonicInstantfor the recheck.N-6. Name sanitizing misses some invisible/bidi characters.
session.rs:347. Not covered: U+061C (Arabic letter mark, a bidi control), U+00AD, U+034F, U+180E, U+3164, variation selectors, tag characters U+E0000-E007F. Usechar::is_controlplus aDefault_Ignorable_Code_Pointcheck, and reuse it for passkey labels (passkeys::clean_labeldoes not strip bidi at all).N-7. DB migration edited in place.
db.rsSCHEMA_V8. Any dev database already atuser_version = 8from the first commit keeps the old, looser triggers and never gets the tightened ones. Only matters for machines that ran the earlier branch head; note it, or bump to a v9 that drops and recreates the triggers.N-8. D4 mounting notes (not bugs today). (a)
checkreadsparts.uri.path(); if D4 mounts the gate on anested router the path is stripped,is_dav_pathis false and everything returns 401 (fail closed, but confusing); useOriginalUri. (b) TheAuthenticatorimpl forDavGateturns 429/503 intoNone, so callers using it get 401 instead; use the middleware for DAV routes. (c) The gate treats/davand/dav/asRoot, so handlers on those paths must never list or touch data of anyone butDavIdentity.user.Things I checked and found fine
verifyre-checks the revoked set after every cache load or insert;forget_installationmarks before purging; an expired entry that finds the row gone is removed.guard(JSON content type, Origin match) applies to the mint route.Counts: P0 0, P1 0, P2 2, P3 5. Verdict: ready for merge as scaffolding (nothing is mounted); fix N-1 and N-2 before D4 mounts the gate.
Review round 2: resumed fixes and final status
Head
551b62c4da, pushed tofeat/dav-app-passwords. Resumed from40cfc587; kept the preceding agent's assertion timestamp, v9 migration, monotonic recheck and safe touch changes. This completes the requested fixes without another review round. Never merged.cold_lookups_queue_globally_behind_slow_junkblocks the DB worker, fills all eight permits with junk, polls a cold valid lookup while they are held, then proves it succeeds within 250 ms after release. Independent-source junk also exercises global pacing.a_cold_valid_password_gets_through_a_concurrent_floodruns 200 junk HTTP requests plus a valid uncached password. Both pass.assertion_at, set only by passkey login. Mint, add-finish and remove require a recent browser/native assertion.an_old_session_cannot_add_or_remove_passkeysnow supplies a real registration ceremony and credential. Setup, recovery and invite sessions explicitly fail minting; recovery/invite also fail add-finish. Existing fresh add/remove tests pass. ADR 0064 states the protection and the remaining fresh-cookie risk.the_same_malformed_password_is_charged_onceandconcurrent_retries_have_only_one_first_attemptpass.touch_preserves_rechecks_and_never_resurrects_a_removed_entrypasses.Result<Option<Identity>, Response>; server, attachment and backdrop extractors propagate rejections. DAV middleware and trait share response construction.authenticator_keeps_dav_rejection_status_and_headersproves 429/503, Retry-After and no-store. OriginalUri handling from the preceding commits is retained.Gates
All passed on calternal-dev-01-compute02, Nix dev shell, inside one
~/bin/slot bash ~/dav782-run-gates.shjob (~/dav782-all-gates.log, endingDAV782_GATES_PASS):cargo test -j4 -p calternal-auth -p calternal-server --no-fail-fast— green, including all 29 app-password integration tests and 40 auth unit tests. Existing perf-only tests remain ignored as intended.cargo test -j4 -p calternal contract— green (contract_matches_the_server).cargo clippy -j4 -p calternal-auth -p calternal-server -p calternal-attachments -p calternal-backdrops --all-targets -- -D warnings— clean.cargo fmt --all -- --check— clean; rechecked after comment/doc clarifications, alsogit diff --check.The Nix flake files on the VM were excluded from the commit.
Decisions and remaining limits
Chose the existing short FIFO queue plus a small global cold-read rate bound, preserving the token format and hash-only storage. A finite concurrent burst now queues correctly; sustained overload or a sufficiently slow DB can still exceed 250 ms and return 429. The bound is deliberate and documented, rather than promising cold-client availability under arbitrary load.
Freshness protects against CLI/agent tokens and old stolen sessions; a stolen session within the ten-minute assertion window can still mint/add keys. Setup/recovery/invite sessions require a real passkey sign-in. Passkey-add emits its existing account event and mint emits the Installation event; a prominent device notification and separate step-up prompt remain UI work.
D4 still has to mount the gate and keep handlers scoped to DavIdentity.user, including root paths. D6 browser-origin/CSRF policy, IPv6 /64 bucketing, trusted-proxy warning and plaintext-proxy checks remain the already documented follow-ups. No DAV routes were mounted here.
Independent security review, round 3 (final)
Reviewed
551b62c4da. Read the PR body and all five preceding review/status comments, including #70764; reviewede358f3fa..40cfc587,40cfc587..551b62c4, and the whole PR againstorigin/dev(2f304154). Defensive review of our own code. No code changed or pushed.Verdict: small fix. New findings: P0 0, P1 0, P2 1, P3 0. Fix R3-1 below before mounting this gate. This is the final review round; no fourth round requested. The rule requiring “stop patching, simplify the design” for new P0/P1 findings is not triggered.
Round-2 items
ColdPermit::dropretains the permit through the minimum lifetime even on cancellation. Warm entries and TTL rechecks bypass this queue. Finite-burst tests exercise actual occupied permits. Sustained overload can still deny a valid uncached device indefinitely, and pending-waiter memory lacks a hard cap (R3-1).passkey.rs:195sets assertion time only after successful passkey login;setup.rs:387passes false for setup/recovery/invite redemption. v9 leaves existing sessions with NULL assertion time.session.rs:353checks browser/native kind and assertion freshness; mint, add-finish and remove all call it. The old-session add test now presents a real registration response. Setup/recovery/invite rejection tests cover the intended distinction. A stolen fresh cookie retains the explicitly documented ten-minute authority.app_password.rs:229-234hashes malformed passwords and uses the same deduplication;failuses atomicfirst_attempt. Concurrent retries cannot all charge the source.limit.rs:63sweeps at most every five seconds. HashMap reads are O(1); the ordered LRU index makes ordinary recording/eviction O(log n). Both indexes stay at 10,000 entries per limiter.DavLive.checked_atis monotonic;touchonly updates an existing entry's touched time. It cannot overwrite checked_at or resurrect a removed entry.hidden_charsanitizer covers the reported characters and all Bidi_Control characters, and is used for installation names and passkey labels.New finding
R3-1 — P2: the new FIFO wait bounds time, but not the number of pending cold requests.
Location: crates/auth/src/app_password.rs:262, with the eight-permit setup at
crates/auth/src/lib.rs:133.timeout(COLD_WAIT, semaphore.acquire_owned())permits any number of request futures to join the semaphore queue. Eight permits bound active cold work, not waiters. Each waiter retains its request state and timer until admission, cancellation or timeout. No outer request-concurrency/admission cap is present in the server wiring. Fresh plausible junk credentials can therefore create arbitrarily many pending cold requests in a burst, including after their source is over its failure budget: source exhaustion changes the response after the lookup, not admission.The limiter maps are bounded; queue memory instead scales with concurrent arrivals (roughly arrivals during the preceding 250 ms under overload). This is an allocation/availability amplification introduced by replacing immediate refusal with waiting. It is a static finding, not a measured OOM claim; no DAV routes are mounted yet, so it is P2.
Fix: add a small, separate pending-cold admission budget acquired with
try_acquire_ownedbefore joining the FIFO wait. Hold that admission permit through the cold operation; return the existing 429/no-store/Retry-After response immediately when full. Keep the existing eight worker permits and cancellation-safe pacing; warm reads must bypass admission. Test with a blocked DB worker and more requests than the pending cap: excess requests must fail immediately, retained admissions must never exceed the cap, and cancellation/timeout must return admission permits. A timeout alone does not establish a memory bound.Explicit availability and memory limits
Yes, a sustained flood can keep a valid uncached device at 429 indefinitely. FIFO fairness only helps requests whose position becomes serviceable within 250 ms; every timed-out retry loses its place. Roughly a few hundred queued requests at the maximum service rate already consume that wait budget. The 32-junk blocked-worker and 200-request HTTP tests prove finite-burst behavior, not sustained-load availability. #70764 and the ADR acknowledge this residual policy; I am not reporting it again as a new finding.
The eight cold worker permits and 10,000-entry limiter indexes have hard bounds. Pending cold waiters do not. The DAV cache also has no global cardinality cap: it stores successfully looked-up credentials, with TTL rechecking on use, rather than being an anonymous negative cache. The 50-live-password cap is per user, not a global process-memory limit.
Verification and scope
Read the existing dev-01 gate script and
~/dav782-all-gates.log; the VM worktree is at551b62c4. The log confirms the blocked-worker, concurrent-flood, real passkey-add, setup/recovery/invite and Authenticator tests passed, with 40 auth unit tests and 29 app-password integration tests, and endsDAV782_GATES_PASS. Recorded commands:cargo test -j4 -p calternal-auth -p calternal-server --no-fail-fastcargo test -j4 -p calternal contractcargo clippy -j4 -p calternal-auth -p calternal-server -p calternal-attachments -p calternal-backdrops --all-targets -- -D warningscargo fmt --all -- --checkI did not rerun builds/tests. My local
git diff --check origin/dev...551b62c4passed. No new secret disclosure, cross-user authorization bypass or scope widening found. Existing D6 CSRF, IPv6 /64, plaintext HTTP, trusted-proxy warning and new-device-notification follow-ups were not re-raised.Required small fix: R3-1 only. New P0/P1: none.
R3-1 fixed: cap pending cold DAV lookups
Pushed
a59f782e43tofeat/dav-app-passwords. Only three auth files changed; never merged.AuthConfig::dav_max_pending_colddefaults to 256, counting both active and waiting cold lookups. A separate semaphore is acquired withtry_acquire_ownedbefore allocating a FIFO waiter or timeout. Full admission returns the existing 429 + Retry-After: 60 + Cache-Control: no-store immediately. Admission is held through the database read and returned on completion, cancellation or timeout. Cached passwords, including TTL rechecks, bypass admission. The existing eight workers, 8 ms pacing and 250 ms fair wait are unchanged.Added
cold_admission_is_bounded_and_cached_passwords_bypass_it: with a custom cap of 16 and the DB worker blocked, fill all 16 admissions, then concurrently flood 64 excess requests. Each excess gate check must finish on its first poll with 429 and the retry/no-store headers; retained admissions remain at the cap. A cached valid password succeeds while full. Aborting active/queued requests and timing out a queued request both restore admission capacity. The regression failed before wiring admission into verification and passed after the fix.All gates passed on calternal-dev-01-compute02, through the Nix dev shell and
~/bin/slot, log~/dav782-r3-gates.log, endingDAV782_R3_GATES_PASS:cargo test -j4 -p calternal-auth -p calternal-server --no-fail-fastcargo test -j4 -p calternal contractcargo clippy -j4 -p calternal-auth --all-targets -- -D warnings(the only touched crate)cargo fmt --all -- --checkAlso ran the new regression separately, checked
git diff --check, and verified the three tested VM files match the pushed files by SHA-256. Existing perf-only tests remain ignored. VM Nix flake files are excluded from the commit.Decision: keep the admission mechanism separate and non-waiting, with a default cap large enough for the existing finite-burst tests. Sustained overload can still refuse an uncached valid device; cached valid passwords bypass the cap. No additional review round or unrelated changes.
kayg referenced this pull request2026-10-31 17:36:11 +00:00
BM4 passkey regression implementation plan
Setup currently verifies registration but creates a session without assertion_at. Record that proof after registration. Add an additive v10 registration_session flag (one integer per installation, default false for existing rows) so app-password minting keeps its existing sign-in-only rule. Passkey management accepts recent registration or sign-in proof, still restricted to browser/native sessions. Check add-start and add-finish; return passkey_reverification_required for stale/missing proof. The UI offers a passkey check and retries the requested operation once; cancellation leaves a visible message. Re-check uses login without signing out first and verifies that the resulting user is unchanged.
All freshness checks remain one indexed installation lookup, O(1), with no new cache.
Failure → effect → detection → recovery → test:
Gates: Rust auth/server and core error catalogue tests, contract check, format check; targeted passkeys ceremony in Chromium and passkeys UI in Chromium/WebKit on dev-01. WebKit has no virtual passkey authenticator, so the real ceremony stays Chromium-only. Rebuild BM4 by merging the updated feature branch, push and report. No full train run and no merge into dev.
BM4 passkey regression fixed and targeted gates passed
Feature #782:
850e604774(feat/dav-app-passwords). Rebuilt train #810:29175e3782(train/2026-11-01-bm4), including the updated feature branch. Both pushed. No merge into dev and no full train run started.Cause and behavior
Confirmed by reading setup redemption and reproducing
e2e/passkeys.spec.tson the original BM4 build: after Add a passkey, the list stayed at one row. Setup verified a registration but created its session with no assertion_at. The new Rust setup-add regression also failed before the fix.A completed registration now records assertion_at in the redemption transaction. Add-start, add-finish and remove require recent browser/native passkey proof. Stale/missing proof returns a distinct 403
passkey_reverification_requiredwith “Verify with your passkey to continue.” Settings offers a passkey check, then retries the operation. Cancellation keeps the session and offers another try. Re-check requires the current account's passkey; another account's key cannot replace the session.App-password minting keeps its sign-in-only rule. Additive auth.db v10 records registration_session so registration proof alone cannot mint. Existing v9 proof timestamps are preserved and old sessions are not assigned new proof. Recovery/invite redemption also performs registration in this implementation, so its completed ceremony counts for passkey management; sessions with no ceremony do not. Rust tests cover setup, recovery, invite, missing/stale proof, sign-in, mint restrictions, current-account re-check, and migration.
Verification
On calternal-dev-01-compute02, Nix dev shell, through
~/bin/slot bash ~/bm4-passkey-train-gates.sh, worktree~/Developer/Worktrees/bm4-passkey-web. Log: ~/bm4-passkey-train.log, ending BM4_PASSKEY_TRAIN_EXIT=0. VM worktree is clean at the train head above. Rust sources were unchanged by the last merge, which only switched the e2e timestamp helper to Nix's sqlite3.All passed:
Browser result: 5 passed, zero retries. Chromium exercised real setup/add/rename/remove, stale removal with cancellation and retry, and stale add with verification followed by successful registration. Chromium/WebKit UI tests cover the stale-session prompt and a failed re-check with another attempt. WebKit has no virtual WebAuthn authenticator, so the real ceremony remains in the Chromium
passkeysproject. Svelte check: 0 errors, 14 warnings in 8 existing files; clippy clean.One earlier feature-suite run failed
counter_concurrent_mutations_drift_and_restart(16577 vs 16440). The final rebuilt-train suite passed it. No quota code changed. Existing ignored performance tests remain ignored. The full train rerun, including the previously reported WebKit swipe case, is left to the orchestrator.kayg referenced this pull request2026-11-01 06:27:45 +00:00
Closing: head
850e6047is already in dev (reached via29175e37); nothing left to merge. Branch kept.Pull request closed