Skip to content

Review critical security risks#13

Merged
pantharshit007 merged 8 commits intomasterfrom
refactor/security-upgrade
Feb 8, 2026
Merged

Review critical security risks#13
pantharshit007 merged 8 commits intomasterfrom
refactor/security-upgrade

Conversation

@pantharshit007
Copy link
Owner

@pantharshit007 pantharshit007 commented Feb 7, 2026

Improve the security side of things and added one time and view based restriction on password sharing

Summary

Summary by CodeRabbit

  • New Features

    • Optional per-link view limits (including one-time links); set, update, or remove limits in the UI; lists and detail views show views/maxViews.
    • Stronger alphanumeric passcodes with in-app generation and improved passcode validation, plus better reveal/copy UX and clearer expiry/max-views displays.
  • Documentation

    • SECURITY and docs updated to reflect stronger passcodes, server-side passcode verification, and view-limit behavior.
  • Chores

    • Version bumped to 0.0.14.

Checklist

  • All tests pass
  • Code is linted/formatting is correct
  • Documentation updated (if needed)

Additional Information


Open with Devin

@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

Adds optional numeric maxViews to the sharedSecrets schema and threads view-limit handling through backend APIs, mutations, and UI; introduces share passcode utilities and server-side passcode verification, key-store TTL/subscription, docs updates, and a package version bump.

Changes

Cohort / File(s) Summary
Schema & Types
convex/schema.ts, src/lib/types.ts
Add optional maxViews?: number to sharedSecrets schema and SharedSecret type.
Backend — shared secrets
convex/sharedSecrets.ts
Accept/validate maxViews on create; include maxViews in get/list/paginated responses; add updateMaxViews; rename recordViewaccessSecret and enforce disabled/expired/exhausted checks and view increment logic.
Backend — projects & passcodes
convex/projects.ts, TODO.md
Add server-side passcode hashing utilities and verifyPasscode mutation; sanitize passcodeHash from project responses; update batchUpdatePasscodes args; add TODO for rate-limiting verifyPasscode.
Frontend — share creation UI
src/components/share-dialog.tsx
UI controls for one-time/limited links, passcode generation/validation; createShare now accepts optional maxViews; input validation and state resets added.
Frontend — shared link admin & view pages
src/routes/d/shared.tsx, src/routes/share/$shareId.tsx, src/routes/d/project/$projectId.tsx, src/routes/d/settings.tsx, src/routes/d.tsx
Add Set/Remove View Limit dialog/actions, display views/maxViews, integrate updateMaxViews and accessSecret, switch to server-side verifyPasscode, add isSharePayload guard, update passcode UI and copy/reveal behaviors, clear keyStore on sign-out.
Lib — constants, utils & key-store
src/lib/constants.ts, src/lib/utils.ts, src/lib/key-store.ts
New exports: SHARE_PASSCODE_MIN_LENGTH, SHARE_PASSCODE_MAX_LENGTH, SHARE_PASSCODE_REGEX, SHARE_MAX_VIEWS_LIMIT, META_DATA; add getSharePasscodeError and generateSharePasscode; keyStore gets TTL, expiry, subscribe, getSnapshot, and global singleton reuse.
Docs & metadata
docs/SECURITY.md, docs/learning.md, src/routes/docs/security.tsx, progress-log/progress-08.md, package.json
Document passcode hardening, view-limit design and UX changes; update threat-model wording; bump package version to 0.0.14.
Misc UI types
src/components/passcode-recovery.tsx
Remove passcodeHash from PasscodeRecoveryProps project prop.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as Share Dialog
    participant Client as Client
    participant Server as Server
    participant DB as Database

    User->>UI: Configure share (passcode, maxViews)
    UI->>Client: (optional) generateSharePasscode()
    User->>UI: Create share
    UI->>Client: encrypt payload + create(maxViews,...)
    Client->>Server: create(sharedSecret)
    Server->>Server: validate maxViews ≤ SHARE_MAX_VIEWS_LIMIT
    Server->>DB: insert sharedSecret (includes maxViews)
    DB-->>Server: inserted
    Server-->>Client: return shareId
    Client-->>UI: display link
Loading
sequenceDiagram
    participant User as User
    participant UI as Share View Page
    participant Client as Client
    participant Server as Server
    participant DB as Database

    User->>UI: Enter passcode to unlock
    UI->>Client: accessSecret(shareId, passcode)
    Client->>Server: accessSecret request
    Server->>DB: fetch sharedSecret (views, maxViews)
    DB-->>Server: sharedSecret
    alt maxViews defined and views >= maxViews
        Server-->>Client: return exhausted state
        Client-->>UI: show "Link Used Up"
    else Not exhausted
        Server->>DB: increment views
        Server-->>Client: return encrypted payload + metadata
        Client-->>UI: decrypt & display content
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I stitched a gate for each shared little key,
Longer passcodes, counts of views — safe as can be,
One hop, one view, or many if you please,
Encrypted carrots tucked tight, no plaintext to seize,
Hooray — I hopped back, leaving limits and ease!

🚥 Pre-merge checks | ❌ 3
❌ Failed checks (1 warning, 2 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Review critical security risks' is vague and generic, failing to convey the specific changes in the changeset. Use a more specific title like 'Harden shared link security and add view limits' or 'Implement view-based restrictions and alphanumeric passcodes for shared links'.
Description check ❓ Inconclusive The description covers main objectives but lacks detail on implementation scope, affected areas, and testing approach required by the template. Expand the summary with implementation details (schema changes, passcode requirements, view limits), affected files/components, and clarify linting status.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/security-upgrade

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
docs/SECURITY.md (1)

127-127: ⚠️ Potential issue | 🟡 Minor

Stale client-side validation docs — still references 6-digit numeric passcodes.

Line 127 still documents Regex /^\d{6}$/ + maxLength={6} for passcodes, which conflicts with the new 8+ character alphanumeric share passcode requirements introduced in this PR. This section should distinguish between project passcodes (if still 6-digit) and share passcodes (now 8+ alphanumeric), or be updated accordingly.

src/routes/share/$shareId.tsx (1)

112-116: ⚠️ Potential issue | 🟠 Major

View limit is enforced client-side only — trivially bypassable.

recordView is called after the client has already received and decrypted the data. A caller can simply skip the recordView call (or the request can fail) and the view counter is never incremented, effectively bypassing the view limit. This is inherent to the zero-knowledge design (as noted in the progress log), but it may be worth documenting this trade-off in the UI or security docs — users should understand that view limits are best-effort, not cryptographically enforced.

One improvement: move the view recording to a mutation that returns the encrypted data (replacing the current get query), so the counter increments atomically with data retrieval. This would at least prevent repeated fetches without counting.

convex/sharedSecrets.ts (1)

285-307: ⚠️ Potential issue | 🟠 Major

recordView is unauthenticated — enables DoS on view-limited links.

Any unauthenticated client can call recordView with a known share ID to inflate the view counter and exhaust a view-limited link without knowing the passcode. This is a denial-of-service vector against the new maxViews feature.

Consider at minimum adding rate limiting or requiring proof of successful decryption (e.g., a HMAC of the decrypted payload). Alternatively, combining the get and recordView into a single mutation that atomically returns the encrypted data and increments the counter would prevent the current client-side-only enforcement gap noted in $shareId.tsx as well.

🤖 Fix all issues with AI agents
In `@src/lib/utils.ts`:
- Around line 100-118: The generateSharePasscode function has modulo bias and an
insecure Math.random fallback; replace the fallback with a hard failure and
implement rejection sampling: require globalThis.crypto?.getRandomValues and
throw an error if missing, allocate a Uint8Array for the target length, then
fill the passcode by repeatedly reading bytes and only accepting bytes < 256 -
(256 % chars.length) so you can map acceptedByte % chars.length without bias;
update code paths that reference buffer/length/chars in generateSharePasscode to
use this rejection loop until the result reaches the desired length.

In `@src/routes/share/`$shareId.tsx:
- Line 215: Remove the debug console.log call that prints expiresAt — delete the
line containing console.log("****** expires At", { d: data?.expiresAt }); (the
temporary debug statement referencing data?.expiresAt) so no debug logging
remains in the $shareId route/component.
- Around line 29-32: The local interface SharedVariable is a duplicate of the
exported type in src/lib/types.ts; remove this local definition and import
SharedVariable from the shared module instead (update any references in this
file to use the imported type). Specifically, delete the local "interface
SharedVariable { name: string; value: string; }" and add an import for
SharedVariable from src/lib/types (so functions/components in this file use the
shared type rather than redefining it).
🧹 Nitpick comments (5)
src/lib/constants.ts (1)

48-51: META_DATA — consider whether this belongs in constants.ts.

This object contains social/repository links rather than application configuration or validation constants. It might be a better fit alongside SITE_CONFIG (referenced in src/utilities/site-config), which already holds similar metadata like siteUrl and links.githubSecurity. Keeping all external URLs in one place avoids fragmentation.

src/routes/share/$shareId.tsx (2)

34-54: Type guard only checks 2 of 11 asserted fields.

isSharePayload asserts fields like passcodeSalt, iv, authTag, payloadIv, payloadAuthTag, etc., but only verifies the presence of encryptedPayload and encryptedShareKey. If the server response shape changes or a field is missing, accesses at lines 84–108 would silently fail at runtime. Consider checking all fields that the decryption flow depends on.


305-315: maxViews display is hidden when maxViews is falsy (including 0).

Line 311 uses data?.maxViews && (...) which is falsy for maxViews === 0. While 0 shouldn't be a valid value based on backend validation (maxViews >= 1), using a stricter check is safer.

Suggested tweak
-            {data?.maxViews && (
+            {data?.maxViews != null && (
src/routes/d/shared.tsx (2)

261-266: Missing error handling for clipboard write.

navigator.clipboard.writeText can throw (e.g., when the page lacks focus or the Permissions API denies access). Other similar handlers in the file also lack this, but since this is new code, consider wrapping it.

Proposed fix
  async function handleCopyPasscode(id: string, passcode: string) {
-   await navigator.clipboard.writeText(passcode);
-   setCopiedPasscodeId(id);
-   setTimeout(() => setCopiedPasscodeId(null), 2000);
-   toast.success("Passcode copied");
+   try {
+     await navigator.clipboard.writeText(passcode);
+     setCopiedPasscodeId(id);
+     setTimeout(() => setCopiedPasscodeId(null), 2000);
+     toast.success("Passcode copied");
+   } catch {
+     toast.error("Failed to copy passcode");
+   }
  }

690-708: "Set view limit" and "Remove view limit" dropdown items are clean.

State initialization before opening the dialog and conditional rendering of "Remove" only when maxViews is set are both correct.

Note: share.maxViews on line 701 uses a truthy check, which would also hide the option if maxViews were 0. Backend validates maxViews >= 1, so this is safe in practice, but share.maxViews != null would be more semantically precise.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
convex/sharedSecrets.ts (1)

276-316: ⚠️ Potential issue | 🔴 Critical

View is consumed before passcode verification — allows unauthenticated view-limit exhaustion.

accessSecret increments views and returns the encrypted payload in a single unauthenticated call. Because passcode verification happens client-side after this mutation, any caller who knows (or brute-forces) the share ID can repeatedly invoke accessSecret to exhaust maxViews without ever supplying the passcode. For one-time links (maxViews === 1) a single spurious call permanently locks out the legitimate recipient.

Consider one of:

  1. Server-side passcode verification — accept the passcode (or its hash) as an argument, verify it before incrementing views and releasing the payload.
  2. Split into two steps — keep the current unauthenticated metadata query (get) and only release the payload + increment the view in a second mutation that requires passcode proof.

Option 1 is the stronger fix and aligns with the PR goal of hardened security.

🤖 Fix all issues with AI agents
In `@src/routes/share/`$shareId.tsx:
- Around line 76-93: The current code calls accessSecret (which increments
views) before verifying the passcode, causing views to be consumed on wrong
passcodes; change the flow so the client performs a local "trial decrypt" of the
encrypted payload with the user-provided passcode and only calls accessSecret if
that trial decrypt succeeds. Specifically, locate the passcode submission
handler where accessSecret is awaited (and where isSharePayload and
setUnlockError are used), add a pre-check that attempts to decrypt the encrypted
data (using the same client-side decryption routine used after successful
access) and if decryption fails setUnlockError("Incorrect passcode.") and return
without calling accessSecret; only call accessSecret when the trial decrypt
passes so server-side views aren’t consumed by obvious wrong passcodes.
- Around line 309-323: The UI reads metadata from sharedSecret reactively and
after calling accessSecret the query may replace sharedSecret with an exhausted
shape lacking fields like isIndefinite, causing the expiry text to render
incorrectly; fix this by copying the metadata you need (e.g., isIndefinite,
expiresAt, maxViews) into a local ref or component state before invoking
accessSecret (or before the decryption/get query updates) and use those
preserved values when rendering the expiry text (the code paths around
sharedSecret, accessSecret, and formatRelativeTime should read from the saved
ref/state instead of directly from the reactive sharedSecret).
🧹 Nitpick comments (3)
convex/sharedSecrets.ts (1)

571-606: maxViews === 0 accepted via the -1 sentinel bypass.

args.maxViews === 0 falls through to the < 1 check and is correctly rejected. Good. However, consider the edge case at Line 596: setting maxViews equal to current views immediately exhausts the link. If that's intentional (as an alternative to disabling), it's fine — but a brief doc comment would prevent future confusion.

Also, the comment says "Pass maxViews = -1 to remove limit," but v.number() admits any float. Values like -0.5 or NaN (from JSON) would bypass the -1 check and then fail Number.isInteger. This is harmless (it throws), but explicitly documenting that only -1 removes the limit and all other non-positive values are errors would strengthen the contract.

Proposed tightening
   handler: async (ctx, args) => {
     const { sharedSecret } = await checkSecretManagementAccess(ctx, args.id);
 
-    if (args.maxViews === -1) {
+    // Sentinel: -1 means "remove the view limit"
+    if (args.maxViews === -1) {
       await ctx.db.patch(args.id, {
         maxViews: undefined,
       });
       return { success: true };
     }
 
     if (!Number.isInteger(args.maxViews) || args.maxViews < 1) {
       throwError("maxViews must be a positive integer", "BAD_REQUEST", 400);
     }
     if (args.maxViews > SHARE_MAX_VIEWS_LIMIT) {
       throwError(`maxViews cannot exceed ${SHARE_MAX_VIEWS_LIMIT}`, "BAD_REQUEST", 400);
     }
-    if (args.maxViews < sharedSecret.views) {
+    if (args.maxViews < sharedSecret.views) {
+      // Note: maxViews === views is allowed and immediately exhausts the link.
       throwError("maxViews cannot be lower than current views", "BAD_REQUEST", 400);
     }
src/routes/share/$shareId.tsx (2)

30-48: Type guard only checks two fields but asserts a much richer shape.

isSharePayload narrows to a type with 10 properties but only verifies the presence of encryptedPayload and encryptedShareKey. If the server ever returns a partial object containing those two keys (e.g., a future code path or schema change), the downstream decrypt calls will throw on missing passcodeSalt, iv, authTag, etc.

Consider checking all required fields:

Proposed fix
 function isSharePayload(sharedSecret: unknown): sharedSecret is {
   encryptedPayload: string;
   encryptedShareKey: string;
   passcodeSalt: string;
   iv: string;
   authTag: string;
   payloadIv: string;
   payloadAuthTag: string;
   isIndefinite: boolean;
   expiresAt?: number;
   maxViews?: number;
 } {
   return Boolean(
     sharedSecret &&
     typeof sharedSecret === "object" &&
     "encryptedPayload" in sharedSecret &&
-    "encryptedShareKey" in sharedSecret
+    "encryptedShareKey" in sharedSecret &&
+    "passcodeSalt" in sharedSecret &&
+    "iv" in sharedSecret &&
+    "authTag" in sharedSecret &&
+    "payloadIv" in sharedSecret &&
+    "payloadAuthTag" in sharedSecret
   );
 }

65-93: Missing "exhausted" guard at the top of handleUnlock.

Line 66 checks for expired and disabled but not exhausted. If the reactive get query updates to an exhausted state between render and the user clicking "Unlock", handleUnlock proceeds to call accessSecret — which harmlessly returns { exhausted: true } and is caught on Line 87. So this isn't a bug, but adding "exhausted" to the early-return guard is more defensive and avoids the unnecessary mutation call.

Proposed fix
-  if (!sharedSecret || "expired" in sharedSecret || "disabled" in sharedSecret) return;
+  if (
+    !sharedSecret ||
+    "expired" in sharedSecret ||
+    "disabled" in sharedSecret ||
+    "exhausted" in sharedSecret
+  ) return;

Comment on lines 76 to +93
try {
const secret = await accessSecret({ id: shareId as Id<"sharedSecrets"> });
if (!isSharePayload(secret)) {
if ("expired" in secret) {
setUnlockError("This link has expired.");
return;
}
if ("disabled" in secret) {
setUnlockError("This link has been disabled.");
return;
}
if ("exhausted" in secret) {
setUnlockError("This link has reached its view limit.");
return;
}
setUnlockError("Unable to access this link.");
return;
}
Copy link

@coderabbitai coderabbitai bot Feb 7, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

A view is consumed even when the passcode is wrong.

accessSecret atomically increments views before the client attempts decryption. If the user mistypes the passcode, the view is already spent. For maxViews === 1 links this is especially punishing — a single typo permanently locks out the recipient.

This is the client-side manifestation of the server-side issue noted in convex/sharedSecrets.ts (the accessSecret mutation). The root fix belongs server-side (verify passcode before counting a view), but on the client side you could mitigate by running a local "trial decrypt" of the encrypted passcode before calling accessSecret, so obviously wrong passcodes never reach the mutation.

🤖 Prompt for AI Agents
In `@src/routes/share/`$shareId.tsx around lines 76 - 93, The current code calls
accessSecret (which increments views) before verifying the passcode, causing
views to be consumed on wrong passcodes; change the flow so the client performs
a local "trial decrypt" of the encrypted payload with the user-provided passcode
and only calls accessSecret if that trial decrypt succeeds. Specifically, locate
the passcode submission handler where accessSecret is awaited (and where
isSharePayload and setUnlockError are used), add a pre-check that attempts to
decrypt the encrypted data (using the same client-side decryption routine used
after successful access) and if decryption fails setUnlockError("Incorrect
passcode.") and return without calling accessSecret; only call accessSecret when
the trial decrypt passes so server-side views aren’t consumed by obvious wrong
passcodes.

Copy link
Owner Author

Choose a reason for hiding this comment

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

@coderabbitai but we are only calling this mutation once the code verifies on the client side

Copy link

Choose a reason for hiding this comment

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

@pantharshit007, I understand the confusion. You're right that there's client-side validation with getSharePasscodeError(passcode) before calling accessSecret — however, that validation only checks the format of the passcode (minimum/maximum length, alphanumeric pattern), not whether it's the correct passcode.

Here's the current flow in handleUnlock():

  1. Format validation: getSharePasscodeError(passcode) checks if the passcode meets length/regex requirements
  2. View consumed: accessSecret() is called and increments the views count
  3. Correctness verification: Decryption with deriveKey() and decrypt()this is where we discover if the passcode is actually correct
  4. If decryption fails, the catch block sets "Invalid passcode" error

The problem is that step 2 happens before step 3. So if a user enters a well-formed but incorrect passcode (e.g., "WrongPass" instead of "CorrectPass"), the view count is already incremented before the decryption fails.

For a one-time link (maxViews === 1), this means one wrong guess locks out the legitimate recipient permanently.

The fix would be to verify the passcode is actually correct (by attempting decryption) before calling accessSecret. Would you like me to plan that change?


🧠 Learnings used
Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to convex/**/*.ts : Never store plaintext secrets - only store encrypted values with IV and authTag

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 10 additional findings in Devin Review.

Open in Devin Review

…ion and removing passcode hashes from client-side exposure
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment on lines +846 to +877
args: {
projectId: v.id("projects"),
passcode: v.string(),
},
handler: async (ctx, args) => {
const userId = await getUserId(ctx);

const membership = await ctx.db
.query("projectMembers")
.withIndex("by_project_user", (q) =>
q.eq("projectId", args.projectId).eq("userId", userId)
)
.unique();

if (!membership) {
throwError("Access denied: Not a member of this project", "FORBIDDEN", 403, {
user_id: userId,
project_id: args.projectId,
});
}

const project = await ctx.db.get(args.projectId);
if (!project) {
throwError("Project not found", "NOT_FOUND", 404, {
user_id: userId,
project_id: args.projectId,
});
}

const enteredHash = await hashPasscode(args.passcode, project.passcodeSalt);
const ok = enteredHash === project.passcodeHash;
return { ok };

Choose a reason for hiding this comment

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

🔴 verifyPasscode sends plaintext passcode over the network, contradicting zero-knowledge claims

The verifyPasscode mutation accepts the raw plaintext passcode as an argument (passcode: v.string()) and hashes it server-side. This means the plaintext project passcode is transmitted to the Convex server on every unlock attempt.

Security Architecture Concern

At convex/projects.ts:846-848, the mutation accepts passcode: v.string() and at line 875 hashes it server-side: const enteredHash = await hashPasscode(args.passcode, project.passcodeSalt). The passcode is sent in plaintext over the WebSocket connection to Convex.

Previously (before this PR), passcode verification was done entirely client-side: the client hashed the passcode locally and compared against the stored hash. The old approach at src/routes/d/project/$projectId.tsx (LEFT side, lines 509-511) was:

const enteredHash = await cryptoHash(passcode, project.passcodeSalt);
if (enteredHash !== project.passcodeHash) { ... }

The new approach sends the plaintext passcode to the server, which contradicts the project's stated "zero-knowledge architecture" (from SECURITY.md). While the docs have been updated to acknowledge this ("Project passcodes are verified server-side (the passcode is sent for verification but never stored)"), this is a regression in the security model. The Convex server (and its logs, if any request logging is enabled) can now see plaintext project passcodes.

The passcode is also the input to PBKDF2 key derivation for encrypting all environment variables. If the server is compromised or logs are leaked, the attacker could derive the project encryption key.

Impact: Reduces the security guarantee from zero-knowledge to trust-the-server for project passcodes. This is a deliberate design choice acknowledged in docs, but worth flagging as it weakens the security model.

Prompt for agents
Consider reverting to client-side passcode verification to maintain the zero-knowledge architecture. The previous approach was: the server returns the passcodeHash to the client, the client hashes the entered passcode locally, and compares. The PR removed passcodeHash from client responses to prevent exposure, but the hash is already a one-way function — exposing the hash is far less risky than exposing the plaintext passcode.

Alternative: If server-side verification is desired, have the client hash the passcode first and send the hash to the server for comparison, rather than sending the plaintext. This preserves zero-knowledge while still moving verification to the server.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/SECURITY.md (1)

3-3: ⚠️ Potential issue | 🟡 Minor

Stale "Last Updated" date.

The document is being updated in this PR (February 2026), but the header still reads "Last Updated: January 26, 2026."

Proposed fix
-> Last Updated: January 26, 2026
+> Last Updated: February 7, 2026
🤖 Fix all issues with AI agents
In `@convex/projects.ts`:
- Around line 40-45: hashPasscode currently does a single SHA-256 which is too
fast for 6-digit passcodes; replace the one-shot digest with a slower KDF
(PBKDF2) or at minimum an HMAC-SHA256 to add computational cost. Update
hashPasscode to import the passcode as a raw key and call
crypto.subtle.deriveBits with PBKDF2 using a configurable iteration count (e.g.
CRYPTO.PBKDF2_ITERATIONS) and CRYPTO.HASH_ALGORITHM, then convert the derived
ArrayBuffer to base64 via arrayBufferToBase64; if you choose HMAC instead, use
crypto.subtle.importKey with "HMAC" and crypto.subtle.sign to compute
HMAC-SHA256(salt, passcode). Also ensure you add or reference constants
(CRYPTO.PBKDF2_ITERATIONS, CRYPTO.PBKDF2_DERIVED_KEY_LENGTH) and consider
compatibility with client-side verification (either update client to match the
stronger scheme or document the deliberate trade-off).
- Around line 845-878: Add per-user/per-project rate-limiting to verifyPasscode:
before hashing, look up a failure tracker (e.g., add fields to projectMembers
like failedAttempts:number and lockoutUntil:timestamp or create a
passcodeAttempts table keyed by projectId+userId) and if lockoutUntil > now
return { ok: false } or throw with FORBIDDEN; after each failed check increment
failedAttempts and set lockoutUntil to now+15min when failedAttempts >= 5, and
apply exponential backoff by awaiting a delay proportional to
2^(failedAttempts-1) seconds before returning; on successful verification reset
failedAttempts and clear lockoutUntil. Update logic around verifyPasscode,
projectMembers, hashPasscode and getUserId to read/update the tracker atomically
(use ctx.db.update/withIndex/unique patterns already used) so concurrent
attempts are handled correctly.
🧹 Nitpick comments (5)
docs/learning.md (1)

837-851: Potentially confusing juxtaposition of "no server-side verification" vs "server-side verification".

Lines 837–838 state shared links were hardened "without introducing server-side passcode verification," while lines 849–850 state "Project passcodes are now verified on the backend." Although these refer to different passcode types (share vs. project), a reader skimming this section could easily misread the intent. Consider adding a brief qualifier, e.g., "without introducing server-side share passcode verification" to disambiguate.

src/lib/key-store.ts (1)

15-23: Expired-key eviction doesn't notify listeners.

When getKey lazily evicts an expired entry (Line 19), it doesn't call this.notify(). Subscribers (e.g., keyStoreVersion in shared.tsx) won't be informed that a key has disappeared, so UI elements gated on keyStore.getKey(...) won't reactively update until the next unrelated re-render.

Proposed fix
   getKey(projectId: string): CryptoKey | null {
     const entry = this.keys.get(projectId);
     if (!entry) return null;
     if (Date.now() > entry.expiresAt) {
       this.keys.delete(projectId);
+      this.notify();
       return null;
     }
     return entry.key;
   }
convex/projects.ts (1)

233-235: Inconsistent sanitization approach and imprecise type cast.

The get query uses Object.fromEntries(Object.entries(...).filter(...)) with as typeof project, while list and listOwned use destructuring (const { passcodeHash: _, ...safeProject } = project). The destructuring approach is both more readable and more type-safe. Additionally, the as typeof project cast is inaccurate — the resulting object no longer contains passcodeHash, but TypeScript will believe it does.

Proposed fix: use destructuring consistently
-    return {
-      ...(Object.fromEntries(
-        Object.entries(project).filter(([key]) => key !== "passcodeHash")
-      ) as typeof project),
-      role: membership.role,
-      ownerTier: owner?.tier ?? "free",
-    };
+    const { passcodeHash: _passcodeHash, ...safeProject } = project;
+    return {
+      ...safeProject,
+      role: membership.role,
+      ownerTier: owner?.tier ?? "free",
+    };
src/routes/d/shared.tsx (2)

110-118: State declarations scattered between hooks.

The view-limit state variables (Lines 115–118) are declared after the useEffect subscription hook, breaking the grouping with the other state declarations (Lines 101–108). Move them up to sit with the rest of the state for readability.

Suggested grouping
   const [searchQuery, setSearchQuery] = useState("");
+  const [keyStoreVersion, setKeyStoreVersion] = useState(0);
   const [projectFilter, setProjectFilter] = useState("all");
   const [statusFilter, setStatusFilter] = useState("all");
   const [revealedPasscodes, setRevealedPasscodes] = useState<Set<string>>(new Set());
   const [decryptedPasscodes, setDecryptedPasscodes] = useState<Record<string, string>>({});
   const [copiedPasscodeId, setCopiedPasscodeId] = useState<string | null>(null);
   const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
-  const [keyStoreVersion, setKeyStoreVersion] = useState(0);
+  const [viewLimitTarget, setViewLimitTarget] = useState<SharedSecret | null>(null);
+  const [viewLimitValue, setViewLimitValue] = useState("");
+  const [viewLimitError, setViewLimitError] = useState<string | null>(null);
+  const [isViewLimitOpen, setIsViewLimitOpen] = useState(false);

   useEffect(() => {
     return keyStore.subscribe(() => {
       setKeyStoreVersion((prev) => prev + 1);
     });
   }, []);
-  const [viewLimitTarget, setViewLimitTarget] = useState<SharedSecret | null>(null);
-  const [viewLimitValue, setViewLimitValue] = useState("");
-  const [viewLimitError, setViewLimitError] = useState<string | null>(null);
-  const [isViewLimitOpen, setIsViewLimitOpen] = useState(false);

169-169: void keyStoreVersion is a workaround to force re-renders — consider a brief comment.

The void keyStoreVersion; silences the unused-variable lint, but the actual purpose (triggering re-renders so keyStore.getKey() calls in JSX reflect current state) isn't obvious. A one-line comment would help future readers understand why this variable exists.

Suggested comment
-  void keyStoreVersion;
+  // Read keyStoreVersion so React re-renders when keys change (used by inline keyStore.getKey() calls)
+  void keyStoreVersion;

Comment on lines +40 to +45
async function hashPasscode(passcode: string, salt: string): Promise<string> {
const encoder = new TextEncoder();
const combined = encoder.encode(salt + passcode);
const digest = await crypto.subtle.digest(CRYPTO.HASH_ALGORITHM, combined);
return arrayBufferToBase64(digest);
}
Copy link

@coderabbitai coderabbitai bot Feb 8, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

hashPasscode uses a single SHA-256 round — fast to brute-force for 6-digit codes.

This function hashes with one pass of SHA-256, which is extremely fast on modern hardware (~billions/sec on GPU). For a 6-digit numeric passcode, the entire keyspace (10⁶) can be exhausted in milliseconds. Since PBKDF2 is already used elsewhere in the codebase for key derivation, consider using it here too (or at minimum HMAC-SHA256 instead of plain SHA-256(salt ‖ passcode)) to add computational cost.

That said, this hash is also stored and used for client-side verification elsewhere, so changing it here alone would create a mismatch. If this is a deliberate trade-off (matching the existing client-side hash scheme), document it and consider strengthening the overall passcode hashing scheme in a follow-up.

🤖 Prompt for AI Agents
In `@convex/projects.ts` around lines 40 - 45, hashPasscode currently does a
single SHA-256 which is too fast for 6-digit passcodes; replace the one-shot
digest with a slower KDF (PBKDF2) or at minimum an HMAC-SHA256 to add
computational cost. Update hashPasscode to import the passcode as a raw key and
call crypto.subtle.deriveBits with PBKDF2 using a configurable iteration count
(e.g. CRYPTO.PBKDF2_ITERATIONS) and CRYPTO.HASH_ALGORITHM, then convert the
derived ArrayBuffer to base64 via arrayBufferToBase64; if you choose HMAC
instead, use crypto.subtle.importKey with "HMAC" and crypto.subtle.sign to
compute HMAC-SHA256(salt, passcode). Also ensure you add or reference constants
(CRYPTO.PBKDF2_ITERATIONS, CRYPTO.PBKDF2_DERIVED_KEY_LENGTH) and consider
compatibility with client-side verification (either update client to match the
stronger scheme or document the deliberate trade-off).

Copy link
Owner Author

Choose a reason for hiding this comment

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

ignoring sicne its now server side

Copy link

Choose a reason for hiding this comment

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

@pantharshit007, I understand your decision. Just to clarify: moving verification server-side does prevent clients from seeing the hash (which is great!), but it doesn't strengthen the hash itself against offline attacks if the database is compromised — single SHA-256 remains fast to brute-force in that scenario.

That said, the switch from 6-digit numeric to 8+ character alphanumeric passcodes is a significant security improvement (keyspace increases from ~1 million to ~218 trillion combinations), which does make brute-force attacks far less practical. That change addresses the core weakness much more effectively than the hashing algorithm alone would.

Happy to move forward with the current approach!


🧠 Learnings used
Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to convex/**/*.ts : Always authenticate by checking `ctx.auth.getUserIdentity()` in Convex mutations and queries

Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to convex/**/*.ts : Never log or expose decrypted secrets in Convex functions

Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to convex/**/*.ts : Never store plaintext secrets - only store encrypted values with IV and authTag

Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to convex/**/*.ts : Use compound indexes for permission checks like `by_project_user` in Convex queries

Comment on lines +845 to +878
export const verifyPasscode = mutation({
args: {
projectId: v.id("projects"),
passcode: v.string(),
},
handler: async (ctx, args) => {
const userId = await getUserId(ctx);

const membership = await ctx.db
.query("projectMembers")
.withIndex("by_project_user", (q) =>
q.eq("projectId", args.projectId).eq("userId", userId)
)
.unique();

if (!membership) {
throwError("Access denied: Not a member of this project", "FORBIDDEN", 403, {
user_id: userId,
project_id: args.projectId,
});
}

const project = await ctx.db.get(args.projectId);
if (!project) {
throwError("Project not found", "NOT_FOUND", 404, {
user_id: userId,
project_id: args.projectId,
});
}

const enteredHash = await hashPasscode(args.passcode, project.passcodeSalt);
const ok = enteredHash === project.passcodeHash;
return { ok };
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for rate-limiting, attempt tracking, and lockout mechanisms
rg -n "rateLimi|rateLimit|rate_limit|throttl|cooldown|lockout|attempt" --type=ts -g '!node_modules' | head -50

Repository: pantharshit007/tijori

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Check if verifyPasscode or related authentication functions have any delays/backoff
rg -A 10 "verifyPasscode\|checkAttempt\|trackAttempt" convex/ --type=ts | head -100

Repository: pantharshit007/tijori

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Verify passcode format constraint
rg -B 5 -A 5 "passcode.*6.*digit\|6.*digit.*passcode\|maxLength.*6\|length.*6" --type=ts | head -100

Repository: pantharshit007/tijori

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Check the verifyPasscode mutation details and surrounding code
cat -n convex/projects.ts | sed -n '840,880p'

Repository: pantharshit007/tijori

Length of output: 1396


🏁 Script executed:

#!/bin/bash
# Look for passcode input field definition in client code
fd -e tsx -e ts | xargs rg -l "passcode" | grep -v node_modules | head -10

Repository: pantharshit007/tijori

Length of output: 321


🏁 Script executed:

#!/bin/bash
# Check the projects schema definition
rg -A 20 "passcodeHash\|passcodeSalt" convex/schema.ts --type=ts

Repository: pantharshit007/tijori

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Find the schema file
fd schema --type=ts convex/

Repository: pantharshit007/tijori

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Check project settings page for passcode input constraints
cat -n src/routes/d/project/\$projectId.tsx | grep -B 10 -A 10 "passcode"

Repository: pantharshit007/tijori

Length of output: 4759


🏁 Script executed:

#!/bin/bash
# Check the security documentation
cat -n src/routes/docs/security.tsx | head -150

Repository: pantharshit007/tijori

Length of output: 8183


Add rate-limiting to verifyPasscode to prevent brute-force attacks.

The 6-digit passcode space (10⁶ combinations) can be exhaustively searched by an authenticated project member calling this mutation in a loop. Implement per-project/per-user attempt tracking with lockout:

  • Track failed verification attempts (e.g., 5 failures → 15-minute cooldown)
  • Add exponential backoff or delay on consecutive failures

Though the attacker must be an authenticated member, this is a security-critical app where insider threat mitigation is important.

🤖 Prompt for AI Agents
In `@convex/projects.ts` around lines 845 - 878, Add per-user/per-project
rate-limiting to verifyPasscode: before hashing, look up a failure tracker
(e.g., add fields to projectMembers like failedAttempts:number and
lockoutUntil:timestamp or create a passcodeAttempts table keyed by
projectId+userId) and if lockoutUntil > now return { ok: false } or throw with
FORBIDDEN; after each failed check increment failedAttempts and set lockoutUntil
to now+15min when failedAttempts >= 5, and apply exponential backoff by awaiting
a delay proportional to 2^(failedAttempts-1) seconds before returning; on
successful verification reset failedAttempts and clear lockoutUntil. Update
logic around verifyPasscode, projectMembers, hashPasscode and getUserId to
read/update the tracker atomically (use ctx.db.update/withIndex/unique patterns
already used) so concurrent attempts are handled correctly.

…shboard to use useSyncExternalStore for key store updates
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/lib/key-store.ts`:
- Around line 15-23: The getKey method currently deletes expired entries but
doesn’t inform subscribers, causing stale snapshots; modify getKey in the
key-store class so that when it detects an expired entry (the branch that calls
this.keys.delete(projectId)), it also calls this.notify() before returning null
so subscribers using getSnapshot() via useSyncExternalStore see the updated
keys.size and re-render; ensure notify() is only invoked when an actual deletion
occurs to avoid spurious notifications.
- Around line 42-44: getSnapshot() currently returns this.keys.size which
doesn't change when an existing projectId's key is replaced, so React's
useSyncExternalStore may not re-render; change snapshot to use a monotonic
version counter updated in notify() (e.g., add a private version number field,
increment it inside notify(), and have getSnapshot() return that version) and
ensure setKey/setKeys call notify() after mutating this.keys so subscribers see
the updated snapshot; reference getSnapshot, notify, setKey, setKeys, and
this.keys when making the changes.

In `@src/routes/d/shared.tsx`:
- Around line 267-272: The navigator.clipboard.writeText call in
handleCopyPasscode is unguarded and can reject; wrap the clipboard call in a
try/catch and guard availability (check navigator.clipboard && typeof
navigator.clipboard.writeText === "function") before calling it, only call
setCopiedPasscodeId and toast.success on successful write, and call toast.error
(or an appropriate fallback notification) inside the catch to surface permission
or availability failures; mirror the same defensive pattern used for
handleCopyLink so failures don't produce unhandled promise rejections.
- Around line 300-303: The parsing logic using Number.parseInt allows trailing
non-numeric characters (e.g., "10abc") to succeed; update the validation around
the variables parsed and trimmed in the view limit flow to only accept purely
numeric input: first ensure trimmed matches a stricter numeric pattern (e.g.,
/^\d+$/) or confirm that parsing round-trips (the parsed integer string equals
trimmed after normalizing), then convert to a number and check parsed is >= 1;
on failure call setViewLimitError("Max views must be a positive number") and
return.
🧹 Nitpick comments (5)
src/lib/key-store.ts (1)

35-40: Add explicit return types on the new public methods.

The coding guidelines prefer explicit return types for exported functions. Since keyStore is exported and these methods are part of its public API (consumed by useSyncExternalStore), annotating them improves clarity and catches accidental signature changes.

Proposed fix
-  subscribe(listener: () => void) {
+  subscribe(listener: () => void): () => void {
     this.listeners.add(listener);
     return () => {
       this.listeners.delete(listener);
     };
   }

-  getSnapshot() {
+  getSnapshot(): number {
     return this.keys.size;
   }

As per coding guidelines: "Prefer explicit return types for exported functions".

src/routes/d/shared.tsx (4)

109-112: Subscribe callback recreated every render causes unnecessary resubscriptions.

useSyncExternalStore will re-subscribe whenever the subscribe argument changes identity. The inline arrow (listener) => keyStore.subscribe(listener) is a new function each render, so React unsubscribes and resubscribes on every render cycle.

Stabilize by binding the method once outside the component or using useCallback:

Proposed fix (bind outside component)
 import { keyStore } from "@/lib/key-store";
+
+const subscribeKeyStore = (listener: () => void) => keyStore.subscribe(listener);
+const getKeyStoreSnapshot = () => keyStore.getSnapshot();

Then inside the component:

-  const keyStoreVersion = useSyncExternalStore(
-    (listener) => keyStore.subscribe(listener),
-    () => keyStore.getSnapshot()
-  );
+  const keyStoreVersion = useSyncExternalStore(subscribeKeyStore, getKeyStoreSnapshot);

167-167: void keyStoreVersion — consider a brief comment for maintainability.

This suppresses the "unused variable" warning, but the intent (force re-render on key-store changes) isn't immediately obvious. A one-line comment would help future readers:

+  // Reading keyStoreVersion forces re-render when key store changes
   void keyStoreVersion;

320-323: Prefer catch (err: unknown) over catch (err: any).

Strict TypeScript mode discourages any. Using unknown ensures you go through getErrorMessage (which already handles unknown) without losing type safety. This applies to both new catch blocks.

Proposed fix
-    } catch (err: any) {
+    } catch (err: unknown) {
       console.error("Failed to update view limit:", err);
       toast.error(getErrorMessage(err, "Failed to update view limit"));
     }
-    } catch (err: any) {
+    } catch (err: unknown) {
       console.error("Failed to remove view limit:", err);
       toast.error(getErrorMessage(err, "Failed to remove view limit"));
     }

As per coding guidelines: "Strict TypeScript mode enabled - No implicit any, strict null checks".

Also applies to: 330-333


583-612: Button title and tooltip content are duplicated.

The title attribute (lines 588–594) and the <TooltipContent> (lines 605–610) render the exact same text. The tooltip already provides the accessible hover hint; the title attr will produce a double-tooltip in many browsers (native + custom). Consider removing one — typically the native title if you rely on the custom <Tooltip> component for consistent styling.

Comment on lines 42 to 44
getSnapshot() {
return this.keys.size;
}
Copy link

@coderabbitai coderabbitai bot Feb 8, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

getSnapshot() based on keys.size won't detect key replacements.

If setKey is called for a projectId that already exists (e.g., re-deriving a key after re-entering a password), the map size stays the same. useSyncExternalStore compares snapshots with Object.is, so React will skip the re-render even though notify() fires — the snapshot value is identical.

A simple monotonic counter bumped in notify() is the standard pattern for external-store snapshots:

Proposed fix
 class ProjectKeyStore {
   private keys: Map<string, { key: CryptoKey; expiresAt: number }> = new Map();
   private readonly ttlMs = 60 * 60 * 1000;
   private listeners: Set<() => void> = new Set();
+  private version = 0;

   // ...

   getSnapshot(): number {
-    return this.keys.size;
+    return this.version;
   }

   private notify(): void {
+    this.version++;
     for (const listener of this.listeners) {
       listener();
     }
   }
 }
🤖 Prompt for AI Agents
In `@src/lib/key-store.ts` around lines 42 - 44, getSnapshot() currently returns
this.keys.size which doesn't change when an existing projectId's key is
replaced, so React's useSyncExternalStore may not re-render; change snapshot to
use a monotonic version counter updated in notify() (e.g., add a private version
number field, increment it inside notify(), and have getSnapshot() return that
version) and ensure setKey/setKeys call notify() after mutating this.keys so
subscribers see the updated snapshot; reference getSnapshot, notify, setKey,
setKeys, and this.keys when making the changes.

Copy link
Owner Author

Choose a reason for hiding this comment

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

@coderabbitai but there won't be any situation where the user re-enters the password, since once unlocked there's only the option of locking it again.

Copy link

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for all usages of setKey to understand the call patterns
rg -n 'keyStore\.setKey' --type ts --type tsx -A 3 -B 3

# Also search for the unlock and lock patterns
rg -n '(unlock|lock).*project' --type ts --type tsx -C 2 -g '!**/*.test.*' | head -100

Length of output: 55


🏁 Script executed:

#!/bin/bash
# Search for all usages of setKey to understand the call patterns
rg -n 'keyStore\.setKey' -g '*.ts' -g '*.tsx' -A 3 -B 3

# Also search for the unlock and lock patterns in the project routes
rg -n 'setKey|removeKey' -g '*.ts' -g '*.tsx' -C 3

# Look specifically at the project unlock flow
rg -n 'const handleUnlock' -g '*.tsx' -A 20

Length of output: 2056


🏁 Script executed:

#!/bin/bash
# Look for the complete unlock flow
rg -n 'handleUnlock' -g '*.tsx' -A 30

# Also check if derivedKey is set multiple times
rg -n 'setDerivedKey' -g '*.tsx' -C 3

# Check the SharedDashboard component that uses useSyncExternalStore
rg -n 'useSyncExternalStore' -g '*.tsx' -C 5

Length of output: 14030


@pantharshit007 You're absolutely right about the single-project flow! Once a project is unlocked, the only option is to lock it—you won't unlock it again without locking first.

However, the issue is more subtle. The getSnapshot() method serves all subscribers across the entire app, not just one project. Looking at line 109-112 in src/routes/d/shared.tsx, the SharedDashboard uses useSyncExternalStore to track which project keys are available (so it knows which passcodes can be decrypted).

Consider this scenario with multiple projects:

  1. User unlocks Project A → map size goes from 0 to 1
  2. User unlocks Project B → map size goes from 1 to 2
  3. User locks Project A → map size goes from 2 to 1
  4. User unlocks Project A again → map size stays at 1 (no change!)

Even though the set of available keys changed in step 4, the size-based snapshot returns the same value, so React won't re-render the SharedDashboard. The UI would show stale information about which passcodes can be decrypted.

A version counter handles this cleanly—every mutation (add/remove/replace) increments the version, guaranteeing useSyncExternalStore detects the change. It's also the standard pattern for external stores in React's documentation.

Would you like to keep the size-based approach since it works for your current single-project unlock flow, or adopt the version counter for robustness across the multi-project scenario?


🧠 Learnings used
Learnt from: CR
Repo: pantharshit007/tijori PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-01T14:33:50.174Z
Learning: Applies to src/**/*.{ts,tsx} : Clear encryption keys from memory on logout/navigation

Comment on lines +300 to +303
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
setViewLimitError("Max views must be a positive number");
return;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Number.parseInt silently accepts trailing non-numeric characters.

Number.parseInt("10abc", 10) returns 10 and passes the Number.isFinite check, so a user typing "10abc" would successfully set a view limit of 10. While the <input type="number"> mitigates this in browsers (it won't allow non-numeric input in most UAs), programmatic or paste scenarios could still hit this path.

A stricter parse avoids surprises:

Proposed fix
-        const parsed = Number.parseInt(trimmed, 10);
-        if (!Number.isFinite(parsed) || parsed < 1) {
+        const parsed = Number(trimmed);
+        if (!Number.isInteger(parsed) || parsed < 1) {
🤖 Prompt for AI Agents
In `@src/routes/d/shared.tsx` around lines 300 - 303, The parsing logic using
Number.parseInt allows trailing non-numeric characters (e.g., "10abc") to
succeed; update the validation around the variables parsed and trimmed in the
view limit flow to only accept purely numeric input: first ensure trimmed
matches a stricter numeric pattern (e.g., /^\d+$/) or confirm that parsing
round-trips (the parsed integer string equals trimmed after normalizing), then
convert to a number and check parsed is >= 1; on failure call
setViewLimitError("Max views must be a positive number") and return.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 17 additional findings in Devin Review.

Open in Devin Review

}

const enteredHash = await hashPasscode(args.passcode, project.passcodeSalt);
const ok = enteredHash === project.passcodeHash;

Choose a reason for hiding this comment

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

🟡 Non-constant-time hash comparison in verifyPasscode enables timing attacks

The verifyPasscode mutation uses JavaScript's === operator to compare passcode hashes, which is not constant-time and can leak information about the stored hash through timing side-channels.

Root Cause

At convex/projects.ts:876:

const ok = enteredHash === project.passcodeHash;

The === operator short-circuits on the first differing character, meaning comparisons that match more prefix characters take slightly longer. Over many requests, an attacker could statistically determine the correct hash byte-by-byte.

While the practical exploitability depends on network jitter and the Convex runtime environment, this is a well-known security anti-pattern for a security-focused application. The fix is to use a constant-time comparison function that always compares all bytes regardless of where they differ.

Impact: An attacker with project membership could potentially recover the passcode hash through repeated timing measurements, though practical exploitation is difficult in a serverless environment.

Suggested change
const ok = enteredHash === project.passcodeHash;
const enteredHash = await hashPasscode(args.passcode, project.passcodeSalt);
// Constant-time comparison to prevent timing attacks
const a = new TextEncoder().encode(enteredHash);
const b = new TextEncoder().encode(project.passcodeHash);
let ok = a.length === b.length;
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i++) {
ok = ok && (a[i] === b[i]);
}
return { ok };
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/d/shared.tsx (1)

461-463: ⚠️ Potential issue | 🟡 Minor

Missing title attribute on several Button components.

As per coding guidelines, Button components from @/components/ui/button should have a title attribute. The following buttons are missing one:

  • Line 461: "Bulk Actions" button
  • Line 653: Actions dropdown trigger (MoreHorizontal icon button)
  • Line 771: "Load More" button
Proposed fix
-                <Button variant="outline" size="sm" className="gap-2">
+                <Button variant="outline" size="sm" className="gap-2" title="Bulk actions">
                   Bulk Actions
-                          <Button variant="ghost" size="icon" className="h-8 w-8">
+                          <Button variant="ghost" size="icon" className="h-8 w-8" title="Share actions">
-            <Button variant="outline" onClick={() => loadMore(10)}>
+            <Button variant="outline" onClick={() => loadMore(10)} title="Load more shared secrets">

As per coding guidelines: src/**/*.tsx: "Add title attribute to Button components from @/components/ui/button"

Also applies to: 653-655, 771-773

🤖 Fix all issues with AI agents
In `@src/lib/key-store.ts`:
- Around line 56-62: The sign-out click handler for user logout fails to clear
in-memory encryption keys; update the signOut onClick handler that calls
signOut(...) so it invokes keyStore.clear() before redirecting (e.g., call
keyStore.clear() inside the signOut callback or immediately before calling
signOut), ensuring keyStore.clear() is executed prior to setting
window.location.href or completing the signOut flow; reference the existing
keyStore and signOut call in the sign-out click handler and add the clear call
accordingly.
🧹 Nitpick comments (1)
src/routes/d/shared.tsx (1)

167-167: Add a brief comment explaining the void usage.

void keyStoreVersion silently consumes the variable to trigger re-renders via useSyncExternalStore. This pattern isn't immediately obvious to other developers.

Suggested improvement
+  // Read keyStoreVersion to trigger re-renders when project keys change
   void keyStoreVersion;

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/d.tsx (1)

76-88: ⚠️ Potential issue | 🟠 Major

Add title attribute to the Button component and configure UserButton's sign-out flow to clear encryption keys.

The Button at line 76 is missing the title attribute required by the coding guidelines (other Buttons in the file like the Tutorial button use this). Additionally, the UserButton component at line 121 has no onSignOut callback or afterSignOutUrl configured, meaning when users sign out via that widget, keyStore.clear() won't be called—leaving encryption keys in memory. The pattern is already established in app-sidebar.tsx where handleLogout properly calls keyStore.clear() before sign-out. Wire the same key-clearing logic to the UserButton's sign-out flow.

🧹 Nitpick comments (2)
src/routes/d.tsx (2)

89-95: Missing title attribute on Button.

Per coding guidelines, Button components should have a title attribute. This applies to the "Return to Landing Page" button. Though this line isn't part of the current diff, it's adjacent to the changed code.

Proposed fix
                <Button
                  variant="link"
                  className="w-full text-muted-foreground"
+                 title="Return to Landing Page"
                  onClick={() => (window.location.href = "/")}
                >

As per coding guidelines: src/**/*.tsx: Add title attribute to Button components from @/components/ui/button.


146-146: Missing title attribute on "Go back to Home" Button.

Proposed fix
-            <Button onClick={() => (window.location.href = "/")}>Go back to Home</Button>
+            <Button title="Go back to Home" onClick={() => (window.location.href = "/")}>Go back to Home</Button>

As per coding guidelines: src/**/*.tsx: Add title attribute to Button components from @/components/ui/button.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 19 additional findings in Devin Review.

Open in Devin Review

Comment on lines 299 to 301
await ctx.db.patch(args.id, {
views: sharedSecret.views + 1,
});

Choose a reason for hiding this comment

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

🔴 accessSecret consumes a view before client-side passcode verification, exhausting view-limited links on wrong passcode

The accessSecret mutation increments the view counter and returns the encrypted payload before the client attempts to decrypt with the user-entered passcode. If the user enters a wrong passcode, decryption fails on the client (line 127-129 of src/routes/share/$shareId.tsx), but the view has already been permanently consumed server-side.

Root Cause and Impact

The flow in src/routes/share/$shareId.tsx:77 calls accessSecret which at convex/sharedSecrets.ts:299-301 does:

await ctx.db.patch(args.id, {
  views: sharedSecret.views + 1,
});

…and returns the encrypted payload. Only after that does the client try decryption with the user's passcode at src/routes/share/$shareId.tsx:95-121. If decryption fails (wrong passcode), the catch block at line 128-129 shows an error, but the view was already consumed.

Impact: For a one-time link (maxViews: 1), a single wrong passcode attempt permanently exhausts the link. The next attempt — even with the correct passcode — will receive { exhausted: true } from the server, making the shared secret permanently inaccessible. This fundamentally breaks the one-time link security feature introduced in this PR.

Prompt for agents
The view count increment in the accessSecret mutation (convex/sharedSecrets.ts lines 299-301) needs to be deferred until after the client has successfully decrypted the payload with the correct passcode. There are two main approaches:

1. Split into two steps: Have accessSecret return the encrypted payload WITHOUT incrementing the view count. Add a separate confirmView mutation that the client calls AFTER successful decryption (in src/routes/share/$shareId.tsx, after line 124 where setDecryptedVariables is called). The confirmView mutation would then atomically check limits and increment the counter. This approach has a minor weakness: a malicious client could skip calling confirmView.

2. Server-side passcode verification for shared links: Accept the share passcode in the accessSecret mutation args, derive the key server-side, attempt to decrypt the ShareKey to verify correctness, and only increment the view count and return the payload if the passcode is valid. This is more secure but requires moving some decryption logic to the server.

Approach 1 is simpler and matches the existing zero-knowledge architecture. In convex/sharedSecrets.ts, remove the view increment from accessSecret (lines 299-301) and create a new confirmView mutation that takes the secret ID, checks limits, and increments the view count. In src/routes/share/$shareId.tsx, call confirmView after successful decryption at line 124.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Owner Author

Choose a reason for hiding this comment

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

this is only called once the client decrypts and verifes the passcode

@pantharshit007 pantharshit007 merged commit 72cb584 into master Feb 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant