Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ end Playwright smoke + TOTP scenarios live in `packages/e2e/`.
recovery widgets), `dirk/` (the canonical Button / Input / Select /
Textarea / Field set used by composer and forms), `feedback/`
(InlineAlert, ErrorBoundary), `layout/` (Surface, Modal), and
`specifics/` (KeyMissingModal, SurfaceCard) for the rare components
`specifics/` (KeyMissingModal) for the rare components
that don't fit elsewhere.
- Per-module pages live at `src/app/flow/<Module>/`. Each module is
lazy-loaded behind an `ErrorBoundary` so a crash stays confined to
Expand Down
20 changes: 18 additions & 2 deletions docs/Modules/Mood.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,24 @@

Daily module for tracking mood and recording three positive things.
- Ideal cadence: one entry per day (optional).
- UI: mood score (−2 to +2), emoji, three positives, optional comment,
optionally an introspection question / answer.
- UI: mood score (−2 to +2), a free note (« mot du jour »), three positives,
and an introspection question / answer.

### Composer layout

The score (« note du jour ») and the free « mot du jour » (payload `comment`)
always sit in the main questionnaire. The three positives and the « question du
jour » are each placed independently — in the main questionnaire, in an
expandable drawer, or not offered at all — via the encrypted preferences
`moodPositivesPlacement` / `moodQuestionPlacement` (both default to the drawer;
the question falls back to the legacy `moodOfferDailyQuestion` boolean for blobs
written before this setting). Resolved in `Mood/lib/placements.ts`, configured
from the module settings panel.

In the entries **list**, `moodEntryLead` (absent ⇒ `positives`) picks which of
the three body blocks — the positives, the « mot du jour », or the « question
du jour » — leads each row; the other two follow in the canonical order. This
is a display concern only (`moodEntryOrder`); the composer order is fixed.

## Expected cleartext payload

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nodea",
"private": true,
"version": "2.19.0",
"version": "2.20.0",
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@11.9.0",
"scripts": {
Expand Down
8 changes: 1 addition & 7 deletions packages/api/src/auth/session.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto';
import { and, eq, gt, lt } from 'drizzle-orm';
import { and, eq, gt } from 'drizzle-orm';
import { db } from '../db/client.ts';
import { sessions, users, type User } from '../db/schema.ts';
import { getConfig } from '../config.ts';
Expand Down Expand Up @@ -274,9 +274,3 @@ export async function finalizeMfaSession(
return { id: row.id, userId: row.userId, expiresAt: row.expiresAt };
});
}

/** Housekeeping — remove expired rows. Safe to call on an interval. */
export async function pruneExpiredSessions(): Promise<number> {
const result = await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
return result.length;
}
3 changes: 1 addition & 2 deletions packages/api/src/auth/totp-backup-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
/** Base32 alphabet (RFC 4648, no padding) — same as TOTP secrets. */
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export const BACKUP_CODE_BIT_LENGTH = 120;
/** 120 / 5 = 24 base32 chars. */
/** 120 bits / 5 = 24 base32 chars. */
export const BACKUP_CODE_LENGTH_CHARS = 24;
export const BACKUP_CODES_PER_USER = 10;

Expand Down
22 changes: 22 additions & 0 deletions packages/api/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ export interface RateLimitOptions {
* `null` the limiter falls back to the trusted client IP.
*/
keyFn?: (c: Context) => string | null;
/**
* When `true`, a request that ends in an error response (status
* ≥ 400) does NOT consume the caller's budget — the increment made
* for it is rolled back after the handler runs. Use it where the
* budget should meter *successful* actions, so a rejected attempt
* (bad input, already-taken target, a downstream 4xx) can't block a
* legitimate retry. Off by default: a login limiter, say, wants
* failures to count.
*/
skipFailedRequests?: boolean;
}

// Stashed on globalThis so Vitest 4's per-test-file module
Expand Down Expand Up @@ -111,7 +121,19 @@ export function rateLimit(opts: RateLimitOptions): MiddlewareHandler {
return c.json({ error: 'rate_limited' }, 429);
}
}

await next();

// `skipFailedRequests`: an error response didn't perform the metered
// action, so refund the increment we made above (only successes count).
// The limiter's own 429 returns before `next()`, so it's never refunded.
if (opts.skipFailedRequests && c.res.status >= 400) {
const b = buckets.get(key);
if (b) {
b.count -= 1;
if (b.count <= 0) buckets.delete(key);
}
}
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/api/src/routes/auth-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,19 @@ export const authAccountRoutes = makeAuthedRouter();
* for every user behind the same NAT. Now keyed on the
* authenticated user id and mounted last in the chain, so only a
* fully re-authenticated call consumes the quota.
*
* `skipFailedRequests` so ONLY a successful change spends the budget :
* a mistyped / already-taken address (409), a stale re-auth (401) or a
* cooldown rejection (429) rolls the increment back, so an honest user
* who fat-fingers the target isn't locked out of the corrected retry
* for 24 h. (A no-op change to one's own current address still returns
* 200 and counts — it's a completed request, not a failure.)
*/
const changeEmailLimiter = rateLimit({
max: 1,
windowMs: 24 * 60 * 60 * 1000,
keyPrefix: 'rl:change-email',
skipFailedRequests: true,
keyFn: (c) => {
const user = c.get('user') as { id?: string } | undefined;
return user?.id ?? null;
Expand Down
56 changes: 48 additions & 8 deletions packages/api/src/routes/auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { eq } from 'drizzle-orm';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
RecoverKekFinishBodySchema,
RecoverKekStartBodySchema,
Expand Down Expand Up @@ -354,16 +354,23 @@ interface FakeBlobs {
}

/**
* Generate fresh random base64-shaped blobs of the right
* lengths so the response for an unknown email is byte-shape-
* indistinguishable from a real one. The client will fail to
* unwrap them with whatever recovery code they typed — no
* server log of the mismatch.
* Derive base64-shaped decoy blobs of the right lengths so the
* response for an unknown email is byte-shape-indistinguishable
* from a real one. The client fails to unwrap them with whatever
* recovery code they typed — no server log of the mismatch.
*
* Real `wrappedKekRecovery` is AES-GCM(KEK, …) → 32-byte KEK +
* 16-byte tag = 48 bytes ciphertext → 64 chars base64.
* IV is 12 bytes → 16 chars base64.
*
* **Decoys MUST be deterministic per email**, for the exact reason
* the userId below is (audit v2.8.0): a real `wrappedKekRecovery`
* is a stable DB column, so a `randomBytes` decoy that differed
* between two calls for the same email would distinguish unknown
* (varies) from known (stable) — re-opening the enumeration oracle
* the userId fix closed. Both blobs are therefore HMAC-derived
* under COOKIE_SECRET (same shield label), stable per email.
*
* **userId determinism (audit v2.8.0).** Before, the fake userId
* used `randomUUID()` and was therefore different on every call —
* an attacker who hit `/recover-kek/start` twice with the same
Expand Down Expand Up @@ -406,10 +413,43 @@ function deriveFakeUserId(email: string, secret: string): string {
);
}

/**
* `length` deterministic bytes for (secret, email, label), via
* HMAC-SHA-256 expanded across a counter when one 32-byte block isn't
* enough. Same inputs → same bytes, so the decoy blobs stay stable
* across repeated calls for a given email (see the anti-enum note above).
*/
function deriveFakeBytes(
email: string,
secret: string,
label: string,
length: number,
): Buffer {
const blocks: Buffer[] = [];
for (let counter = 0; blocks.length * 32 < length; counter++) {
blocks.push(
createHmac('sha256', secret)
.update(RECOVER_ENUM_SHIELD_LABEL)
.update('\x1f')
.update(label)
.update('\x1f')
.update(email)
.update('\x1f')
.update(String(counter))
.digest(),
);
}
return Buffer.concat(blocks).subarray(0, length);
}

function fakeRecoveryBlobs(email: string, secret: string): FakeBlobs {
return {
wrappedKekRecovery: randomBytes(48).toString('base64'),
wrappedKekRecoveryIv: randomBytes(12).toString('base64'),
wrappedKekRecovery: deriveFakeBytes(email, secret, 'wrappedKekRecovery', 48).toString(
'base64',
),
wrappedKekRecoveryIv: deriveFakeBytes(email, secret, 'wrappedKekRecoveryIv', 12).toString(
'base64',
),
userId: deriveFakeUserId(email, secret),
};
}
Expand Down
16 changes: 16 additions & 0 deletions packages/api/src/test/auth-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,22 @@ describe('POST /auth/recover-kek/start (anti-enum)', () => {
expect(start.recoverSessionId).toBeTypeOf('string');
});

it('returns STABLE decoy blobs across calls for one unknown email (anti-enum)', async () => {
// A real user's wrap blobs are a stable DB column. If the decoy
// blobs varied between two calls for the same unknown email, that
// instability alone would distinguish unknown (varies) from known
// (stable) — the enumeration oracle. So the decoys are derived
// deterministically per email and MUST be identical across calls.
const a = await callRecoverStart('ghost-stable@example.com', NEW_PASSWORD);
const b = await callRecoverStart('ghost-stable@example.com', NEW_PASSWORD);
expect(a.wrappedKekRecovery).toBe(b.wrappedKekRecovery);
expect(a.wrappedKekRecoveryIv).toBe(b.wrappedKekRecoveryIv);
expect(a.userId).toBe(b.userId);
// …but distinct per email, so two unknown emails don't collide.
const c = await callRecoverStart('ghost-other@example.com', NEW_PASSWORD);
expect(c.wrappedKekRecovery).not.toBe(a.wrappedKekRecovery);
});

it('returns the same shape for a known user without a recovery code (anti-enum)', async () => {
// Seeded user but no recovery code set up — should be
// indistinguishable from the unknown-email branch.
Expand Down
26 changes: 26 additions & 0 deletions packages/api/src/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,32 @@ describe('PATCH /auth/email', () => {
});
expect(res.status).toBe(409);
});

it('a failed change (409) does not burn the 24h quota — a corrected retry still succeeds', async () => {
await seedUser('taken-target@example.com');
await seedUser('retry@example.com');
const cookie = await loginAs(app, 'retry@example.com', TEST_PASSWORD);

// First attempt targets an already-taken address → 409.
const proof1 = await passwordProofFor(app, 'retry@example.com', TEST_PASSWORD);
const clash = await app.request('/auth/email', {
method: 'PATCH',
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ ...proof1, newEmail: 'taken-target@example.com' }),
});
expect(clash.status).toBe(409);

// Corrected retry to a free address, same day: the failed attempt
// must not have consumed the 1/24h budget (skipFailedRequests), so
// this succeeds instead of hitting the limiter's 429.
const proof2 = await passwordProofFor(app, 'retry@example.com', TEST_PASSWORD);
const fixed = await app.request('/auth/email', {
method: 'PATCH',
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ ...proof2, newEmail: 'retry-fixed@example.com' }),
});
expect(fixed.status).toBe(200);
});
});

describe('PATCH /auth/username', () => {
Expand Down
33 changes: 32 additions & 1 deletion packages/shared/src/schemas/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ export const CycleHormoneProfileSchema = z.enum(['off', 'natal', 'masc']);
export type CycleHormoneProfile = z.infer<typeof CycleHormoneProfileSchema>;
export type GoalsDefaultStatus = z.infer<typeof GoalsDefaultStatusSchema>;

/**
* Mood: where an optional composer block (the « question du jour », the three
* positives) is offered — in the main questionnaire, tucked inside the
* expandable drawer, or not at all. The score (« note du jour ») and the free
* « mot du jour » always sit in the main form and aren't configurable.
* Absent ⇒ 'accordion' (the question also honours the legacy
* `moodOfferDailyQuestion`: false ⇒ 'off'). See `Mood/lib/placements.ts`.
*/
export const MoodSectionPlacementSchema = z.enum(['form', 'accordion', 'off']);
export type MoodSectionPlacement = z.infer<typeof MoodSectionPlacementSchema>;

/**
* Mood: which block leads each row in the entries LIST — the three positives,
* the free « mot du jour » (`comment`), or the « question du jour ». The other
* two follow in the canonical order (positives → comment → question). A display
* concern only (the composer order is fixed); absent ⇒ 'positives'. See
* `Mood/lib/placements.ts` (`moodEntryOrder`).
*/
export const MoodEntryLeadSchema = z.enum(['positives', 'comment', 'question']);
export type MoodEntryLead = z.infer<typeof MoodEntryLeadSchema>;

/**
* Journal: default grouping the module opens on (the sidebar toggle still
* overrides per session). Absent ⇒ 'month'.
Expand Down Expand Up @@ -269,8 +290,18 @@ export const UserPreferencesPayloadSchema = z.looseObject({
goalsPrefillYear: z.boolean().optional(),
/** Mood: open with the frise (heatmap) collapsed. Absent ⇒ false (expanded). */
moodChartCollapsed: z.boolean().optional(),
/** Mood: offer the « question du jour » prompt on new entries. Absent ⇒ true. */
/** Mood: offer the « question du jour » prompt on new entries. Absent ⇒ true.
* @deprecated superseded by `moodQuestionPlacement`; still READ as a fallback
* so blobs written before the placement setting keep their behaviour
* (false ⇒ 'off'). No longer written. */
moodOfferDailyQuestion: z.boolean().optional(),
/** Mood: placement of the « question du jour » block. Absent ⇒ derived from
* `moodOfferDailyQuestion` (false ⇒ 'off', else 'accordion'). */
moodQuestionPlacement: MoodSectionPlacementSchema.optional(),
/** Mood: placement of the three-positives block. Absent ⇒ 'accordion'. */
moodPositivesPlacement: MoodSectionPlacementSchema.optional(),
/** Mood: which block leads each entry row in the list. Absent ⇒ 'positives'. */
moodEntryLead: MoodEntryLeadSchema.optional(),
/** Cycle: hormone-curve reference profile (or « off » to hide the tab). Absent ⇒ 'natal'.
* `.catch` degrades a legacy value dropped from the enum (e.g. the removed
* 'fem') to the default rather than failing the whole prefs parse. */
Expand Down
19 changes: 12 additions & 7 deletions packages/web/src/app/flow/Account/components/SessionsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,21 +133,26 @@ export default function SessionsCard() {
const hint = parseDeviceLabel(navigator.userAgent);
const aad = buildSessionDeviceLabelAAD(user.id);
encryptMetaString(hint.label, mainKey.aesKey, aad)
.then(({ cipher, iv }) =>
apiPatchCurrentSessionDeviceLabel({ cipher, iv }),
)
.then(() => {
.then(async ({ cipher, iv }) => {
await apiPatchCurrentSessionDeviceLabel({ cipher, iv });
return { cipher, iv };
})
.then(({ cipher, iv }) => {
if (cancelled) return;
// Refresh the view : the local label cache for the current
// session now points at the just-encrypted hint, and the
// session row's cipher fields can be marked as set so we
// don't try to PATCH again on rerender.
// session row gets the REAL cipher/iv we just wrote. Writing
// the actual blobs (not a 'set' sentinel) matters — this
// setSessions re-runs the decrypt effect above, which would
// choke on a sentinel and clobber the label with « échec du
// déchiffrement » ; with the real blobs it round-trips back to
// hint.label, and the null-cipher guard stops the re-PATCH.
setLabels((prev) => new Map(prev).set(current.id, hint.label));
setSessions((prev) =>
prev
? prev.map((s) =>
s.id === current.id
? { ...s, deviceLabelCipher: 'set', deviceLabelIv: 'set' }
? { ...s, deviceLabelCipher: cipher, deviceLabelIv: iv }
: s,
)
: prev,
Expand Down
Loading
Loading