Commit graph

214 commits

Author SHA1 Message Date
dabbea2483 Merge pull request 'feat: hard revoke / re-key (Phase C, #3)' (#15) from feat/3-phase-c-rekey into main
All checks were successful
Lint and Build / format (push) Successful in 36s
Lint and Build / clippy (push) Successful in 1m38s
Lint and Build / build (push) Successful in 3m48s
Lint and Build / test (push) Successful in 4m20s
2026-07-20 01:32:55 +00:00
goose
6d6af67f2f fix(rekey): echo stored DEK values in response, not the pre-image
All checks were successful
Lint and Build / format (pull_request) Successful in 1m1s
Lint and Build / clippy (pull_request) Successful in 1m44s
Lint and Build / build (pull_request) Successful in 3m49s
Lint and Build / test (pull_request) Successful in 4m21s
rekey_rotates_owner_share_and_keeps_listed_recipients failed in CI:
the response's wrapped_profile_dek was 'owner-dek-v1' (old) instead of
'owner-dek-v2' (new). update_wrapped_dek uses find_one_and_update,
which returns the document as it was BEFORE the update by default —
so reading the new value from the returned doc gives the pre-image.

Fix: echo the request values (exactly what was stored) in the response
instead of reading them back. Unambiguous and avoids the
ReturnDocument::After plumbing. The write itself was always correct
(the stored value was v2) — only the response was stale, which is why
the other rekey tests (which check state via fresh reads, not the
response body) passed.

Now CI-green (clippy/fmt clean; integration tests will re-run).
2026-07-19 22:13:51 -03:00
goose
bf5aeedbc2 feat: hard revoke / re-key (Phase C, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 40s
Lint and Build / clippy (pull_request) Successful in 1m56s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 3m51s
An owner can now rotate a profile's DEK — generating a fresh key,
re-encrypting all the profile's data under it (client-side), and
re-wrapping to the owner share + kept recipients. A revoked recipient's
cached old DEK stops working, closing the soft-revoke window the ADR
requires for graduation / suspected compromise.

The server stays a blind store: it sees opaque old→new ciphertext blobs
flow through, never the DEK or plaintext. No backend crypto.

Backend:
- ProfileRepository::update_wrapped_dek — rotate the owner share
  (account-wrapped profile DEK), owner-scoped.
- ProfileShareRepository::find_active_for_profile +
  delete_for_profile_excluding — list current recipients and hard-delete
  omitted ones.
- POST /api/profiles/:id/rekey: validates each submitted envelope
  against existing active shares (no smuggling new recipients in via
  rekey), rotates the owner share, upserts recipient envelopes with
  fresh ephemeral ECDH keys, hard-deletes omitted recipients.
- rekey_tests.rs: rotation, hard-revoke-from-recipient-view, no-share
  rejection, non-owner rejection, revoke-all.

Frontend:
- useProfileStore.rekeyProfile: fetches all profile data, re-encrypts
  each row under a new DEK (resume-safe — rows already on the new DEK
  are skipped), builds fresh ECDH envelopes per kept recipient, commits
  via POST /rekey, swaps the in-memory DEK.
- ProfileSharing: 'Rotate encryption key (hard revoke)' action with a
  strong confirmation dialog explaining the cost and the resume-safe
  retry.

Best-effort + resumable retry (per design decision); no server-side
write lock.

Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.

⚠️ Backend integration tests not run locally (no Mongo in this sandbox);
CI will run them — I'll fix any failures.

Admin-initiated rekey (ADR §5c reserves the permission) is out of scope
— handler is owner-only until admin shares are creatable in Phase D.
Refs #3.
2026-07-19 15:15:39 -03:00
goose
668ea59030 chore: remove dead AccessClaims struct (#5)
All checks were successful
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m42s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Successful in 3m57s
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 1m58s
Lint and Build / build (push) Successful in 3m48s
Lint and Build / test (push) Successful in 3m57s
backend/src/auth/claims.rs was orphaned dead code: auth/mod.rs never
declared 'mod claims', so the file wasn't compiled into the crate at
all. It defined two structs:
- AccessClaims — referenced nowhere; a latent trap (carried
  family_id/permissions fields that suggested enforcement that doesn't
  exist).
- RefreshClaims — a duplicate of the LIVE RefreshClaims in jwt.rs,
  which is what JwtService actually uses.

Deleted the file. Updated the JWT ADR to mark the open item done and
note the removal historically.

Build/clippy/fmt clean (the file wasn't compiled anyway). Closes #5.
2026-07-19 12:23:31 -03:00
76fd5a8c26 Merge pull request 'fix(security): enforce ownership on medication/appointment write paths (#12)' (#13) from fix/12-write-path-ownership into main
All checks were successful
Lint and Build / format (push) Successful in 35s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Successful in 3m59s
Reviewed-on: #13
2026-07-19 15:20:14 +00:00
goose
ff185a60e4 fix(security): enforce ownership on medication/appointment write paths (#12)
All checks were successful
Lint and Build / format (pull_request) Successful in 38s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m46s
Lint and Build / test (pull_request) Successful in 4m0s
The update/delete handlers for medications and appointments took the
auth Claims but never used them — any authenticated user could update
or delete any other user's records by id (an IDOR). Same gap on
log_dose (could skew another's adherence stats) and get_adherence
(leaked dose history).

Fix: each affected handler now looks up the item first and confirms
user_id == claims.sub before mutating/returning. Mismatches return 404
(not 403) to avoid leaking the existence of other users' records.
Recipients of shared profiles remain read-only per the ADR — writes
stay owner-only.

Covered handlers:
- update_medication, delete_medication, log_dose, get_adherence
- update_appointment, delete_appointment

health_stats update/delete were already checking user_id == claims.sub
(unaffected).

New ownership_tests.rs: cross-user update/delete/log-dose/adherence all
404; the legitimate owner can still do all of the above.

Verification: cargo build/clippy (-D warnings)/fmt clean; existing
tests unaffected (they operate as the same user that created the data).

Closes #12.
2026-07-19 12:00:13 -03:00
d59487fa3b Merge pull request 'feat: profile sharing via X25519 envelope (Phase B, #3)' (#11) from feat/3-phase-b-profile-sharing into main
All checks were successful
Lint and Build / format (push) Successful in 37s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m54s
Lint and Build / test (push) Successful in 3m41s
Reviewed-on: #11
2026-07-19 14:51:58 +00:00
goose
d0efecf38f fix(sharing): gate falls back to direct ownership before share check
All checks were successful
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m43s
Lint and Build / build (pull_request) Successful in 3m50s
Lint and Build / test (pull_request) Successful in 3m41s
The share-gate I added in Phase B was too strict: it only admitted the
owner via the profiles collection, so any data item whose profile_id
didn't map to a real Profile document (legacy/test data, or a profile_id
the caller used at create time without a backing Profile) got 404'd on
read — even for the item's actual owner. CI caught this:
zk_integration_tests::medication_stored_as_ciphertext_not_plaintext
creates a medication with profile_id="default" (no Profile doc) and
the new get_medication gate returned 404.

Fix: in each list/get data handler, check direct ownership
(user_id == claims.sub, or has own data for the profile) FIRST, and
only fall through to the share-gate if the caller isn't the direct
owner. This preserves the original ownership model AND adds share-
recipient access, without breaking items whose profile_id isn't a real
Profile document.

The share-only path (recipient reading shared data they don't own)
still goes through the gate as before.
2026-07-19 11:40:44 -03:00
goose
e28135bc08 test(share): fix query-string encoding + write-status assertion
Some checks failed
Lint and Build / format (pull_request) Successful in 36s
Lint and Build / clippy (pull_request) Successful in 1m39s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 3m43s
Three share_tests were failing in CI:

1. public_key_lookup_returns_recipient_identity_key — passed the email
   as a JSON body on a GET, but send_json builds the URI verbatim and
   ignores the body for GET. The query string was never formed, so the
   handler got an empty email. Fix: put ?email=... in the URI directly
   (URL-encoding the '@').

2. public_key_lookup_404s_for_unknown_or_keyless_user — same root cause.

3. owner_shares_recipient_reads_owner_revokes — asserted the recipient's
   write would return 201 or 403, but create_medication returns 200 OK
   (Axum's default for Ok(Json(...))), and the write currently succeeds
   because the create handler doesn't check ownership (issue #12). The
   write is attributed to the recipient, not the owner, so the owner's
   data is still untouched — which is what the test really wants to
   prove. Accept 200 (current) or 403 (once #12 lands); documented the
   tie to #12.
2026-07-19 10:37:40 -03:00
goose
04520539aa feat: profile sharing via X25519 envelope (Phase B, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

Verification: backend cargo build/clippy (-D warnings)/fmt clean, tests
compile (integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 31/31 tests pass.

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
2026-07-19 07:21:11 -03:00
015a99a7fe Merge pull request 'docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs (#4)' (#10) from docs/3-4-encryption-persona-docs-reconciliation into main
All checks were successful
Lint and Build / format (push) Successful in 34s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Successful in 3m21s
Reviewed-on: #10
2026-07-19 09:47:18 +00:00
7409077355 Merge pull request 'feat: per-profile DEKs + multi-profile (Phase A2, #3)' (#9) from feat/3-phase-a2-per-profile-deks into main
Some checks are pending
Lint and Build / format (push) Waiting to run
Lint and Build / clippy (push) Waiting to run
Lint and Build / build (push) Blocked by required conditions
Lint and Build / test (push) Blocked by required conditions
Reviewed-on: #9
2026-07-19 09:46:54 +00:00
goose
706df11f15 fix(auth): wrong password returns Ok(false), not Err
All checks were successful
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m43s
Lint and Build / test (pull_request) Successful in 3m21s
verify_password() was mapping every password-hash failure — including a
plain wrong password (password_hash::Error::Password) — to Err, which
propagated to the login handler's Err arm and returned 500
'authentication error' instead of 401 'invalid credentials'.

All three callers (login, change_password, recover_password) already
match on Ok(true)/Ok(false)/Err and expect Ok(false) for a mismatch;
the function just wasn't honoring that contract. Map
Error::Password -> Ok(false) and propagate only genuine failures as Err.

This was a latent bug: CI's auth tests never ran before because the
Mongo service container couldn't schedule (port collision, fixed in
#8). Now that CI runs them for real, login_with_wrong_password_is_rejected
exposed it. Behavior improves on all three call sites — a wrong recovery
phrase now also correctly 401s instead of 500ing.
2026-07-19 02:29:09 -03:00
goose
eb2c2aa546 feat: per-profile DEKs + multi-profile (Phase A2, #3)
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s
Implements the 3-tier key model from the multi-person sharing ADR:
each account owns multiple profiles (a person or pet — a 'subject of
care'), and each profile has its own random AES-256-GCM DEK. All
health data is now encrypted under the active profile's DEK, not the
account-wide DEK. The account DEK wraps each profile DEK; the server
stores only opaque wrapped blobs.

Per the ADR: DB wipe, no migration (no real user data). This unblocks
Phase B (sharing) — there is now a per-profile key to wrap to a
recipient's X25519 public key.

Backend:
- Profile model: owner_account_id, kind (human/pet), relationship,
  wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner,
  find_by_profile_id_owned, update_profile, delete_profile — all
  ownership-scoped.
- Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE
  /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs
  get_profile/update_profile (the /api/users/me handlers) to
  get_account/update_account to resolve a name collision.
- Register accepts default_profile_* fields and auto-creates the self
  profile when the client provides a wrapped profile DEK.
- HealthStatistic + Appointment gain profile_id and ?profile_id=
  filtering (health stats previously had no profile binding).
- New profile_tests.rs: multi-profile CRUD + ownership isolation +
  register-with-default-profile. Fixed the zk health-stat test to
  send the now-required profile_id.

Frontend:
- crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek
  + in-memory per-profile DEK store with an active-profile concept.
- useProfileStore rewritten: holds profiles[], activeProfileId;
  loadProfiles unwraps each profile DEK; create/update/delete. All 11
  encrypt/decrypt sites switched from getEncKey() to
  getActiveProfileDek(). load actions pass ?profile_id= so only the
  active profile's rows come back.
- ProfileEditor rewritten for the new store (edit active profile,
  create/delete). New ProfileSwitcher in the Dashboard AppBar.
- MedicationManager / AppointmentsManager use the active profile id
  instead of the hardcoded profile_<user_id>.
- 2 new crypto tests for per-profile DEK isolation; updated store +
  component tests for the active-profile-DEK model.

Verification: backend cargo build/clippy/fmt green, tests compile
(integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 30/30 tests pass.

Closes nothing yet (Phase B/C/D remain). Refs #3.
2026-07-18 23:45:01 -03:00
9807434c5f Merge pull request 'ci: drop host port mapping for Mongo service container' (#8) from ci/fix-mongo-service-port-collision into main
Some checks failed
Lint and Build / format (push) Successful in 33s
Lint and Build / clippy (push) Successful in 1m41s
Lint and Build / build (push) Successful in 3m44s
Lint and Build / test (push) Failing after 2m54s
Reviewed-on: #8
2026-07-19 00:04:12 +00:00
goose
aa115e6e73 ci: drop host port mapping for Mongo service container
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m43s
Lint and Build / test (pull_request) Failing after 2m41s
The test job failed on the runner with:
  Bind for 0.0.0.0:27017 failed: port is already allocated

because the services.mongo ports: 27017:27017 tries to claim host port
27017, which is occupied (mongod on the runner host). The job container
doesn't need the host mapping — it reaches Mongo over the job's internal
network via the service hostname (MONGODB_URI=mongodb://mongo:27017).
Drop the ports block so the service container schedules cleanly.

This unblocks all PRs, including #6 (Phase A1).
2026-07-18 21:02:46 -03:00
6d3642ee13 Merge pull request 'feat(auth): per-account X25519 identity keypair (Phase A1, #3)' (#6) from feat/3-phase-a1-x25519-keypair into main
Some checks failed
Lint and Build / format (push) Successful in 37s
Lint and Build / clippy (push) Successful in 1m39s
Lint and Build / build (push) Has been cancelled
Lint and Build / test (push) Has been cancelled
Reviewed-on: #6
2026-07-19 00:00:29 +00:00
goose
8ee8012aca feat(auth): add per-account X25519 identity keypair (#3)
Some checks failed
Lint and Build / format (pull_request) Successful in 42s
Lint and Build / clippy (pull_request) Successful in 1m46s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 0s
Phase A1 of the multi-person sharing ADR
(docs/adr/multi-person-sharing.md). Each account now gets an X25519
keypair at registration: the public half stored plaintext, the private
half wrapped under the account DEK and stored as opaque ciphertext. The
keypair is generated client-side; the backend adds no crypto deps and
stores everything verbatim, preserving the zero-knowledge contract.

This change only introduces the keypair and threads it through the auth
flows — it is not consumed yet. It unblocks Phase B (profile sharing)
without touching the data model or the ~11 frontend encrypt call sites,
which is Phase A2 (per-profile DEKs).

Backend:
- User model: identity_public_key, identity_private_key_wrapped{,_iv}
  (all Option<String>, backward compatible).
- RegisterRequest/AuthResponse carry the 3 fields; register + login
  echo them. No changes to change_password/recover (DEK value is
  unchanged across both, so the wrapped private key is too).
- 2 new integration tests: round-trip through register/login, and
  optional-fields backward compat.

Frontend:
- crypto/keys.ts: generateIdentityKeyPair, wrapIdentityPrivateKey,
  unwrapIdentityPrivateKey + in-memory identity store mirroring the DEK.
- types/api.ts: AuthTokens + RegisterRequest extended (removes an
  existing `as any` cast).
- useStore register/login/logout + UnlockPage unwrap the private key
  alongside the DEK.
- 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct
  shared secrets); skip gracefully where the runtime lacks X25519.

Backend: cargo test + clippy green. Frontend: npm test + tsc green.

Also gitignore .zcode/ (local tooling artifact).
2026-07-18 20:00:01 -03:00
goose
e322145ffb docs: decide multi-person ZK sharing ADR; reconcile jwt/encryption docs
Some checks failed
Lint and Build / format (pull_request) Successful in 37s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m44s
Lint and Build / test (pull_request) Failing after 0s
Rewrite the encryption/persona/JWT docs to match the implemented code and
record the multi-person sharing design.

- multi-person-sharing.md (NEW): ADR for per-profile DEK + X25519 envelope
  sharing. All five original open questions resolved 2026-07-18. Covers
  divorced-parents, pets, graduation, and elderly-parent care; adds an
  admin permission tier for owner-incapacity. Decided, not yet implemented.
  (#3)
- encryption.md: full rewrite to the implemented wrapped-DEK / Web Crypto
  model; the old version described ZK encryption as 'planned'. (#4)
- jwt-authentication-decision.md: rewritten to match implementation
  (PBKDF2 not bcrypt, no Redis, token_version revocation, real Claims).
  Original aspirational content preserved in a History section. (#4)
- PERSONA_AND_FAMILY_MANAGEMENT.md: 'Current reality' section added, old
  'Implementation Status' marked superseded, encryption section corrected. (#4)
- ENCRYPTION_UPDATE_SUMMARY.md: archived (changelog for a rewrite that
  itself went stale). (#4)
- ADR README index updated for the new + reconciled entries.
2026-07-18 19:26:43 -03:00
goose
6a569da3b1 docs: add AGENTS.md and document issue-driven workflow
Some checks failed
Lint and Build / format (push) Successful in 40s
Lint and Build / clippy (push) Successful in 1m37s
Lint and Build / build (push) Successful in 3m45s
Lint and Build / test (push) Failing after 1s
- Add AGENTS.md as the cross-agent entry point (lean; points to
  .gooserules and docs/AI_* for detail)
- Document the Forgejo issue-driven workflow (report → triage →
  implement → close) and API patterns in .gooserules
- Add Forgejo step to the pre-change checklist
- Gitignore .forgejo-token
2026-07-18 11:20:14 -03:00
goose
147d722570 fix: healthcheck uses HTTPS + -k for self-signed cert
Some checks failed
Lint and Build / format (push) Successful in 35s
Lint and Build / clippy (push) Successful in 1m40s
Lint and Build / build (push) Successful in 3m45s
Lint and Build / test (push) Failing after 1s
2026-07-14 13:00:30 -03:00
goose
0d33a2c1af fix: rust:slim (not rust:latest-slim which doesn't exist)
Some checks failed
Lint and Build / format (push) Successful in 50s
Lint and Build / clippy (push) Successful in 2m13s
Lint and Build / build (push) Successful in 3m41s
Lint and Build / test (push) Failing after 1s
2026-07-14 12:43:05 -03:00
goose
ccbbcd4274 fix: use rust:latest-slim as runtime (glibc match)
Some checks failed
Lint and Build / format (push) Successful in 37s
Lint and Build / clippy (push) Successful in 1m37s
Lint and Build / build (push) Successful in 3m44s
Lint and Build / test (push) Failing after 1s
2026-07-14 12:27:55 -03:00
goose
12b7b2dccb fix: RustlsConfig::from_der takes Vec<Vec<u8>> (cert chain)
Some checks failed
Lint and Build / format (push) Successful in 39s
Lint and Build / clippy (push) Successful in 2m23s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Failing after 0s
2026-07-14 12:12:24 -03:00
goose
3d440702f0 fix: rcgen 0.13 API (cert.der + key_pair.serialize_der)
Some checks failed
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 2m36s
Lint and Build / build (push) Successful in 5m32s
Lint and Build / test (push) Failing after 1s
2026-07-14 12:01:53 -03:00
goose
043b4442a7 fix: HTTPS with self-signed cert for Web Crypto API support
Some checks failed
Lint and Build / format (push) Successful in 43s
Lint and Build / clippy (push) Successful in 2m42s
Lint and Build / build (push) Successful in 3m44s
Lint and Build / test (push) Failing after 1s
Browsers disable crypto.subtle on non-localhost HTTP. The frontend server now
generates a self-signed TLS cert at startup and serves over HTTPS by default
(FRONTEND_TLS=true). FRONTEND_TLS=0 disables it for localhost dev.

Added axum-server (tls-rustls) + rcgen deps to the frontend-server crate.
2026-07-14 11:43:56 -03:00
goose
47000beef4 chore: frontend container listens on port 6501
Some checks failed
Lint and Build / format (push) Successful in 36s
Lint and Build / clippy (push) Successful in 1m45s
Lint and Build / build (push) Successful in 3m47s
Lint and Build / test (push) Failing after 2m53s
2026-07-05 13:04:04 -03:00
goose
a5006bff63 Merge feat/frontend-container
Some checks failed
Lint and Build / format (push) Successful in 37s
Lint and Build / clippy (push) Successful in 1m35s
Lint and Build / build (push) Successful in 3m49s
Lint and Build / test (push) Failing after 2m42s
Separate Axum frontend container: static SPA serving + API proxy. Independently
scalable — run N replicas behind a load balancer.
2026-07-05 11:49:18 -03:00
goose
7143840ea2 fix: add curl to frontend runtime image for healthcheck 2026-07-05 11:47:05 -03:00
goose
300072a5d3 fix: use Axum 0.7 /*path syntax for API proxy routes 2026-07-05 11:39:41 -03:00
goose
2a2f14cbda fix: Dockerfile paths for repo-root build context 2026-07-05 11:29:24 -03:00
goose
1b5c1e2a06 feat: separate frontend container (Axum static serving + API proxy)
A dedicated Rust/Axum binary (web/frontend-server/) that:
- Serves the built Vite SPA bundle from dist/ via tower-http ServeDir
- Falls back to index.html for SPA routing (deep links work)
- Reverse-proxies /api/* to the backend container (same-origin, no CORS)
- Listens on port 8080 (configurable via FRONTEND_PORT)

Dockerfile (web/normogen-web/Dockerfile): 3-stage build:
1. Node: npm ci + npm run build -> dist/
2. Rust: cargo build --release the frontend-server binary
3. Runtime: debian-slim + binary + dist/

docker-compose.yml: new 'frontend' service on :8080, depends on backend healthy,
proxies to http://backend:6500. Independently scalable (run N replicas behind
a load balancer).

Architecture:
  Browser -> :8080 (frontend container)
    /api/* -> proxy -> backend:6500
    /*     -> static dist/ (SPA)
2026-07-05 11:24:19 -03:00
goose
43a427e2dd Merge feat/e2e-tests
Some checks failed
Lint and Build / format (push) Successful in 40s
Lint and Build / clippy (push) Successful in 1m38s
Lint and Build / build (push) Successful in 3m40s
Lint and Build / test (push) Failing after 2m48s
E2E ZK integration tests (backend) + frontend lifecycle test.
2026-07-05 00:35:33 -03:00
goose
34e3b0b0e0 test: E2E ZK integration tests + frontend lifecycle test
Backend ZK integration tests (tests/zk_integration_tests.rs):
- medication_stored_as_ciphertext: register → create med with opaque blob →
  response echoes blob, no plaintext fields leaked → GET echoes blob.
- appointment_stored_as_ciphertext_with_top_level_status: opaque blob + status
  filter works server-side without decryption.
- health_stat_stored_as_ciphertext: opaque blob, no value/stat_type leaked.
- dose_schedule_adherence_reflects_missed_doses: 1×/day schedule → 30 scheduled,
  1 taken, 29 missed, ~3.3% rate.

Updated medication_tests.rs for the opaque-blob contract (was sending old
plaintext name/dosage fields).

Frontend lifecycle test (useStore.test.ts):
- Full ZK round-trip with real WebCrypto: setup → encrypt → verify ciphertext →
  unlock with password → decrypt → recover via phrase → rewrap under new
  password → decrypt with new key. Data survives the full lifecycle.

Verified: backend 24 tests 0 warnings; frontend 25 tests, build clean.
2026-07-05 00:35:33 -03:00
goose
288e776a8c Merge feat/dose-schedule-health-enc-tests
Some checks failed
Lint and Build / format (push) Successful in 41s
Lint and Build / clippy (push) Successful in 1m42s
Lint and Build / build (push) Successful in 3m38s
Lint and Build / test (push) Failing after 2m48s
Dose scheduling model + health stats zero-knowledge encryption.
2026-07-04 14:03:03 -03:00
goose
8c123df490 feat: dose scheduling + health stats zero-knowledge (full stack)
Dose scheduling:
- DoseSchedule struct (times_per_day + days_of_week) as top-level field on
  Medication. get_adherence computes scheduled_doses from the schedule over
  the period; missed doses now reflected. Fallback to taken/total_logged when
  no schedule. Wired through create/update requests + MedicationResponse.
- Frontend: dose_schedule field on Medication domain type + wire response;
  store reads it on load. (MedicationManager UI field deferred — the data
  flows correctly; a form field can be added when needed.)

Health stats zero-knowledge:
- HealthStatistic model now uses opaque encrypted_data blob (like medications).
  Only recorded_at stays plaintext. HealthStatResponse wire type.
- Removed the trends endpoint (server can't compute on ciphertext); trends
  are now computed client-side in the health store after decryption.
- Deleted the dead HealthData model (kept EncryptedField which it defined).
- Frontend: health store decrypts on load + encrypts on write; trends
  computed client-side; HealthStats component uses domain form types.

Verified: backend 24 tests 0 warnings; frontend build clean, 24 tests.
2026-07-04 14:00:46 -03:00
goose
09589b3bb2 feat(backend): dose scheduling + health stats zero-knowledge
Backend changes (frontend + tests follow in next commit):

Dose scheduling:
- New DoseSchedule struct (times_per_day + days_of_week) as a top-level
  plaintext field on Medication. Create/update requests accept it.
- Revised get_adherence: computes scheduled_doses from the schedule over the
  period (days_of_week filtering), so missed doses are now reflected.
  Falls back to taken/total_logged when no schedule.

Health stats zero-knowledge:
- HealthStatistic model now uses opaque encrypted_data blob (like medications).
  Only recorded_at stays plaintext (filterable/sortable).
- HealthStatResponse wire type. Handlers echo opaque blobs.
- Removed the trends endpoint (server can't compute trends on ciphertext;
  frontend computes them client-side after decrypting).
- Deleted the dead HealthData model (kept EncryptedField which it defined).

Verified: backend 24 tests, 0 clippy warnings.
2026-07-04 08:41:48 -03:00
goose
69faa43a72 Merge feat/rate-limiting-and-polish
Some checks failed
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Failing after 1m39s
Lint and Build / build (push) Has been skipped
Lint and Build / test (push) Has been skipped
IP-based rate limiting (last security gap) + E2E crypto lifecycle test.
2026-07-03 22:02:50 -03:00
goose
b55b7c34f8 feat: rate limiting + E2E crypto lifecycle test
Rate limiting (closes the last security gap #3):
- New RateLimiter: in-memory fixed-window IP-based limiter (std::sync::Mutex
  HashMap, no new deps). Configurable via RATE_LIMIT_MAX (default 100) +
  RATE_LIMIT_WINDOW_SECS (default 60) env vars.
- general_rate_limit_middleware now reads ClientIp from request extensions and
  rejects with 429 + Retry-After header when over the limit. Wired via
  from_fn_with_state in app.rs. Lived on AppState as Arc<RateLimiter>.
- Deleted the dead auth_rate_limit_middleware (never wired).
- 3 unit tests (allows up to N, independent IPs, window reset).

E2E crypto lifecycle test:
- Full zero-knowledge round-trip against jsdom's real Web Crypto: setup →
  encrypt → verify ciphertext → unlock with password → decrypt → recover via
  phrase → rewrap under new password → decrypt. Plus wrong-password/wrong-phrase
  failures and cross-user key isolation.
- Fixed wrapDek/unwrapDek: base64-encode raw DEK bytes (was using TextDecoder
  which produced non-base64), and make unwrapped DEK extractable (needed for
  rewrapDek to export).

Verified: backend 24 tests 0 warnings; frontend 24 tests, build clean.
2026-07-03 21:57:39 -03:00
goose
dcd86524d7 Merge feat/unlock-ux
Some checks failed
Lint and Build / format (push) Successful in 45s
Lint and Build / clippy (push) Successful in 1m42s
Lint and Build / build (push) Successful in 4m2s
Lint and Build / test (push) Failing after 2m54s
Unlock UX: re-derive the DEK on page reload without a full re-login.
2026-07-03 20:21:15 -03:00
goose
46f413975c feat(web): unlock UX — re-derive DEK on page reload
Solves the core usability problem of zero-knowledge encryption: on page reload
the in-memory DEK is lost, so the user can't decrypt their data even though their
JWT is still valid. Previously they had to close the tab and re-login from scratch.

Changes:
- Persist wrapped_dek/wrapped_dek_iv in the auth store (zustand persist). Safe:
  it's AES-GCM ciphertext, useless without the password KEK — the server already
  stores the same ciphertext. login/register/recover all save the wrapped DEK;
  logout clears it.
- New UnlockPage: minimal password-only form. Re-derives the DEK locally via
  unlockWithPassword (no API call — the JWT is still valid). Falls back to
  deriveAuthAndEncKeys for Phase 1 compat accounts. Links to /login and /recover.
- ProtectedRoute now checks hasEncKey() after isAuthenticated: authenticated but
  no in-memory DEK → redirect to /unlock.
- /unlock route in App.tsx (public, alongside login/register/recover).

Flow: login → browse → reload page → unlock screen → enter password → dashboard
loads with decrypted data. No full re-login needed.

Verified: npm build clean, 20 tests pass.
2026-07-03 20:21:15 -03:00
goose
38bf0ae8b4 docs: update ADR for ZK Phase 2 (wrapped-DEK recovery) completion
Some checks failed
Lint and Build / format (push) Successful in 33s
Lint and Build / clippy (push) Successful in 1m36s
Lint and Build / build (push) Successful in 3m37s
Lint and Build / test (push) Failing after 2m33s
Records the implemented wrapped-DEK recovery model, new endpoints, and the
password-change re-wrapping. Strikes the 'future' Phase 2 items as done.
2026-06-29 08:51:03 -03:00
goose
8a538cbbb8 feat: wire register to wrapped-DEK model + recovery phrase UI
Some checks failed
Lint and Build / format (push) Successful in 38s
Lint and Build / clippy (push) Successful in 1m35s
Lint and Build / build (push) Has been cancelled
Lint and Build / test (push) Has been cancelled
Register now generates a DEK, wraps it under the password KEK and recovery KEK,
and sends all wrapped forms to the server (via setupEncryption). RegisterPage
has an optional recovery phrase field with a warning. Login unwraps the DEK
from the response via unlockWithPassword. This completes the full ZK recovery
flow end-to-end from the UI.

Verified: npm build clean, 20 tests pass.
2026-06-29 08:45:56 -03:00
goose
4a63b1c972 Merge feat/zk-recovery
Some checks failed
Lint and Build / format (push) Successful in 40s
Lint and Build / clippy (push) Successful in 1m31s
Lint and Build / build (push) Successful in 3m26s
Lint and Build / test (push) Failing after 2m31s
Zero-knowledge recovery (Phase 2): wrapped-DEK model so a forgotten password
doesn't lose encrypted data. Recovery via recovery-phrase-derived KEK.
2026-06-29 03:42:28 -03:00
goose
7a641dec00 feat: zero-knowledge recovery (Phase 2) — wrapped-DEK model
Introduces a wrapped-DEK recovery model so a forgotten password doesn't lose
all encrypted data. The encryption key becomes a random DEK (not derived from
the password); the DEK is wrapped under both a password-derived KEK and a
recovery-phrase-derived KEK, and both wrapped forms are stored on the server.

Crypto (crypto/keys.ts):
- DEK generation (random AES-256-GCM), KEK derivation (PBKDF2 password/recovery),
  wrapDek/unwrapDek/rewrapDek.
- setupEncryption(password, recoveryPhrase?) — generates a DEK, wraps under both
  KEKs, returns wrapped forms + recovery proof.
- unlockWithPassword(password, wrappedDek) — derives password KEK, unwraps DEK.
- unlockWithRecovery(phrase, wrappedDek) — derives recovery KEK, unwraps DEK.

Backend:
- User model: wrapped_dek, wrapped_dek_iv, recovery_wrapped_dek,
  recovery_wrapped_dek_iv fields.
- RegisterRequest accepts wrapped-DEK fields; stored verbatim.
- AuthResponse returns wrapped_dek + wrapped_dek_iv (for login unwrapping).
- New GET /api/auth/recovery-info?email= — returns recovery-wrapped DEK.
- RecoverPasswordRequest gains new_wrapped_dek + new_wrapped_dek_iv.
- change-password also accepts + stores re-wrapped DEK.

Frontend:
- Auth store: login unwraps DEK from response; new recover() action fetches
  recovery-wrapped DEK, unwraps with phrase, re-wraps under new password.
- RecoveryPage (new): email + recovery phrase + new password flow.
- LoginPage: 'Forgot password? Recover' link. App.tsx: /recover route.

Verified: backend 21 tests, 0 warnings; frontend build clean, 20 tests.
2026-06-29 03:34:24 -03:00
goose
62ef1abb84 Merge feat/zero-knowledge-encryption
Some checks failed
Lint and Build / format (push) Successful in 37s
Lint and Build / clippy (push) Successful in 1m36s
Lint and Build / build (push) Successful in 3m54s
Lint and Build / test (push) Failing after 2m40s
Zero-knowledge encryption Phase 1: server can no longer read user data.
Client-side AES-256-GCM via Web Crypto; double-PBKDF2 auth/enc-key split.
2026-06-28 21:51:47 -03:00
goose
d7d09482da docs: ADR for zero-knowledge encryption Phase 1
Records the decision: client-side AES-256-GCM, double-PBKDF2 auth/enc-key
split, server-as-blind-store, in-memory key lifecycle, and Phase 1 limitations
(forgotten password = data loss; Phase 2 recovery wrapping deferred).
2026-06-28 21:51:47 -03:00
goose
1301473bfe feat: zero-knowledge encryption Phase 1 — server can no longer read user data
Complete the core zero-knowledge property: all user data (medications,
appointments, profile names) is now client-encrypted via AES-GCM; the server
stores and returns opaque ciphertext and can never decrypt it.

Frontend crypto module (Web Crypto API, no deps):
- crypto/keys.ts: double-PBKDF2 derivation from the password — an auth secret
  (base64, sent to the server as the 'password') and an encryption key (AES-GCM
  CryptoKey, kept in memory only, never transmitted). In-memory key store.
- crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers.

Auth split: login/register now derive the auth secret + enc key from the
password BEFORE the API call. Only the auth secret (not the raw password) is
sent to the server. The server's PBKDF2 stays as-is (it hashes whatever it
receives) but can never derive the enc key.

Backend — server treats all data blobs as opaque:
- Medication: removed MedicationData + flat MedicationResponse; new
  MedicationResponse echoes metadata + encrypted_data blob. Create/update
  accept opaque blobs (whole-blob replace). MedicationData struct deleted.
- Appointment: same opaque treatment; status moved to a top-level document field
  so it remains filterable without decryption. AppointmentData struct deleted.
- Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-created
  profile starts empty; client sets it.
- EncryptedFieldWire shared wire type across medication/appointment.

Frontend — decrypt-on-read, encrypt-on-write:
- Stores derive the enc key on login/register; decrypt wire responses into
  domain objects on load; encrypt domain data into blobs on create/update.
- API client returns wire types (opaque blobs); components consume decrypted
  domain data (mostly unchanged — the store does the crypto).
- Updated store tests for the ZK contract (derive a real key, mock wire responses).
- 20 frontend tests pass, build clean.

Backend: 21 tests pass (removed MedicationData/appointment-data deser tests;
opaque-blob echo tests added), clippy 0 warnings.

KNOWN LIMITATIONS (Phase 2): forgotten password = data loss (no recovery wrapping
yet). Page reload requires re-entering the password to re-derive the enc key
(in-memory only, by design). No data migration (no real data existed).
2026-06-28 21:46:10 -03:00
goose
149ce37654 feat: zero-knowledge encryption Phase 1 — backend opaque + crypto module (WIP)
Backend: server can no longer read user data. All data blobs (medication,
appointment, profile name) are now opaque client-encrypted ciphertext — the
server stores and returns them verbatim, never deserializing the contents.

- Medication: removed MedicationData + flat MedicationResponse; new
  MedicationResponse echoes metadata + encrypted_data blob. Create/update
  accept opaque blobs (whole-blob replace). Update is no longer load-mutate-
  reserialize (server can't read the data).
- Appointment: same opaque treatment; status moved to a top-level document
  field so it remains filterable without decryption.
- Profile: name is now an opaque encrypted blob (name_data/name_iv). Auto-
  created profile on register starts with an empty name; client sets it.
- EncryptedFieldWire type shared across medication/appointment.

Frontend (partial): crypto module using Web Crypto API —
- crypto/keys.ts: double-PBKDF2 derivation (auth secret sent to server +
  encryption key kept in memory); in-memory key store (set/get/clear).
- crypto/cipher.ts: AES-GCM encrypt/decrypt + JSON convenience wrappers.
- crypto/index.ts: re-exports.

NOT YET DONE (frontend integration): auth store key derivation on login/register,
stores decrypt-on-load/encrypt-on-write, types update, UI components wired,
crypto round-trip tests, ADR. This commit is a verified checkpoint — backend
builds clean (21 tests, 0 warnings); frontend crypto module exists but is not
yet wired into the data flow.
2026-06-28 20:08:38 -03:00
goose
057303a8d0 Merge fix/active-flag-and-appointments
Some checks failed
Lint and Build / format (push) Successful in 40s
Lint and Build / clippy (push) Successful in 1m44s
Lint and Build / build (push) Successful in 3m40s
Lint and Build / test (push) Failing after 2m38s
Active-flag persistence for medications + full appointment feature (CRUD).
2026-06-28 16:38:15 -03:00