Compare commits

...
7 Commits
Author SHA1 Message Date
rootphantomerandShuai 1ec6ed44a1 fix: reject plaintext FIDO2, SSH keys, and password history on import
Validate encrypted-string fields in validateCipherEncryptedFieldsForCompatibility
before they reach storage:

- FIDO2 credentials (12 fields: 8 required + 4 optional)
- SSH key (privateKey, publicKey, keyFingerprint/fingerprint)
- Password history (password per entry)

This closes a defense gap where plaintext in these positions was silently
accepted on import and later discarded at response time.
2026-07-02 17:36:06 +08:00
shuaiplus 6284c632de Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-02 17:23:47 +08:00
shuaiplus 60dd298dee fix(security): harden jwt config and password rotation 2026-07-02 17:20:51 +08:00
shuaiplus 439683d350 fix(identity): add security stamp and invalidate user cache on token handling 2026-07-02 16:57:24 +08:00
shuaiplus 1545881eae fix(auth): hash stored api keys 2026-07-02 16:27:20 +08:00
shuaiplus 680e287c8d fix(ci): validate global domains sync ref 2026-07-02 16:11:19 +08:00
shuaiplus baf569983d fix(security): scope storage reads by user 2026-07-02 16:03:49 +08:00
33 changed files with 326 additions and 287 deletions
-5
View File
@@ -1,5 +0,0 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+10 -1
View File
@@ -26,7 +26,16 @@ jobs:
node-version: 22 node-version: 22
- name: Sync generated Bitwarden domains - name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}" env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched - name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json run: git diff --exit-code -- src/static/global_domains.custom.json
-151
View File
@@ -1,151 +0,0 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
{
echo "mode=auto"
echo "latest_tag=$LATEST_TAG"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
{
echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
{
echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout 'HEAD@{1}' -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
{
echo "### Synced successfully"
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}"
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}"
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "### Nothing to update" >> "$GITHUB_STEP_SUMMARY"
fi
+1
View File
@@ -60,6 +60,7 @@ NodeWarden-compat/
# Compatibility analysis documents # Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json .mcp.json
opencode.jsonc opencode.jsonc
.cursor/ .cursor/
+7 -10
View File
@@ -1,4 +1,4 @@
import { Env, User, DEFAULT_DEV_SECRET } from '../types'; import { Env, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -6,6 +6,7 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { hashApiKey } from '../utils/api-key';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
@@ -149,10 +150,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -241,8 +241,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
? 'JWT_SECRET is not set' ? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters'; : 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400); return errorResponse(message, 400);
} }
@@ -1194,9 +1192,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) { // Only the fresh secret is returned once; the database stores a hash.
// Upstream apikeys are 30-character random alphanumeric strings const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = await hashApiKey(plainApiKey);
if (rotate) { if (rotate) {
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
@@ -1213,10 +1211,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
targetId: user.id, targetId: user.id,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
}
return jsonResponse({ return jsonResponse({
apiKey: user.apiKey, apiKey: plainApiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+19 -21
View File
@@ -1,4 +1,4 @@
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher } from '../types';
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -167,7 +167,7 @@ export async function handleCreateAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
@@ -205,7 +205,7 @@ export async function handleCreateAttachment(
await storage.saveAttachment(attachment); await storage.saveAttachment(attachment);
// Add attachment to cipher // Add attachment to cipher
await storage.addAttachmentToCipher(cipherId, attachmentId); await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -215,7 +215,7 @@ export async function handleCreateAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
@@ -244,13 +244,13 @@ export async function handleUploadAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -283,12 +283,12 @@ export async function handlePublicUploadAttachment(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, claims.userId);
if (!cipher || cipher.userId !== claims.userId) { if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -308,13 +308,13 @@ export async function handleGetAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -349,12 +349,12 @@ export async function handleUpdateAttachmentMetadata(
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -405,10 +405,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const secret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret) return errorResponse('Server configuration error', 500);
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -418,7 +416,7 @@ export async function handlePublicDownloadAttachment(
} }
// Verify token // Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); const claims = await verifyFileDownloadToken(token, secret);
if (!claims) { if (!claims) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
@@ -471,13 +469,13 @@ export async function handleDeleteAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -486,7 +484,7 @@ export async function handleDeleteAttachment(
await deleteBlobObject(env, path); await deleteBlobObject(env, path);
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachmentForUser(attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -501,7 +499,7 @@ export async function handleDeleteAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments); const cipherResponse = cipherToResponse(updatedCipher!, attachments);
+3 -3
View File
@@ -201,7 +201,7 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404); if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest)); return jsonResponse(toAuthRequestResponse(request, authRequest));
} }
@@ -239,7 +239,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400); if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) { if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404); return errorResponse('Not found', 404);
} }
@@ -275,7 +275,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
masterPasswordHash, masterPasswordHash,
}); });
if (!updated) return errorResponse('Auth request has already been answered.', 409); if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id); const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous // Match Bitwarden upstream behavior: only approval wakes the originating anonymous
// client. Denials are not pushed to avoid leaking that a login attempt was rejected. // client. Denials are not pushed to avoid leaking that a login attempt was rejected.
if (approved) { if (approved) {
+39 -11
View File
@@ -354,6 +354,34 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.'; if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
} }
} }
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
if (Array.isArray(login.fido2Credentials)) {
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
for (const cred of login.fido2Credentials) {
if (!cred || typeof cred !== 'object') continue;
for (const key of fido2EncryptedKeys) {
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
}
}
}
}
// Validate SSH key fields — all three must be encrypted strings.
const sshKey = cipher.sshKey as any;
if (sshKey && typeof sshKey === 'object') {
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
if (!entry || typeof entry !== 'object') continue;
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
}
} }
return null; return null;
@@ -812,7 +840,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// GET /api/ciphers/:id // GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -827,8 +855,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> { async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true; if (!folderId) return true;
const folder = await storage.getFolder(folderId); const folder = await storage.getFolderForUser(folderId, userId);
return !!(folder && folder.userId === userId); return !!folder;
} }
// POST /api/ciphers // POST /api/ciphers
@@ -909,7 +937,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id // PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id); const existingCipher = await storage.getCipherForUser(id, userId);
if (!existingCipher || existingCipher.userId !== userId) { if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1020,7 +1048,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id // DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1052,7 +1080,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// - If item is already soft-deleted -> hard delete. // - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1079,7 +1107,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1104,7 +1132,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore // PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1126,7 +1154,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// PUT /api/ciphers/:id/partial - Update only favorite/folderId // PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1218,7 +1246,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
// PUT/POST /api/ciphers/:id/archive // PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1244,7 +1272,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
// PUT/POST /api/ciphers/:id/unarchive // PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
+5 -5
View File
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id // GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id // PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id // DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const folders = ( const folders = (
await Promise.all(ids.map(async (id) => { await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
return folder && folder.userId === userId ? folder : null; return folder;
})) }))
).filter((folder): folder is Folder => !!folder); ).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
+5 -14
View File
@@ -22,6 +22,7 @@ import {
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -106,18 +107,6 @@ function parseCookieValue(request: Request, name: string): string | null {
return null; return null;
} }
function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined { function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const value = body[name]; const value = body[name];
@@ -341,7 +330,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId); const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
valid = !!( valid = !!(
authRequest && authRequest &&
authRequest.userId === user.id && authRequest.userId === user.id &&
@@ -430,9 +419,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
user.totpSecret = null; user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
rememberRequested = false; rememberRequested = false;
} else { } else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt. // Unsupported provider for this server profile behaves as an invalid 2FA attempt.
@@ -688,7 +679,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
+8 -8
View File
@@ -134,7 +134,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
@@ -401,7 +401,7 @@ export async function handleGetSendFileUpload(
): Promise<Response> { ): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -436,7 +436,7 @@ export async function handleUploadSendFile(
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -472,7 +472,7 @@ export async function handlePublicUploadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, claims.userId);
if (!send || send.userId !== claims.userId) { if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -485,7 +485,7 @@ export async function handlePublicUploadSendFile(
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -632,7 +632,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -698,7 +698,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -719,7 +719,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
+3 -5
View File
@@ -3,7 +3,6 @@ import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type'; import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
createSendFileDownloadToken, createSendFileDownloadToken,
@@ -113,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string, idOrAccessId: string,
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const safeSecret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { if (!safeSecret.ok) return safeSecret.response;
return errorResponse('Server configuration error', 500); const { secret } = safeSecret;
}
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
+2 -2
View File
@@ -1,4 +1,4 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import { import {
notifyUserSendCreate, notifyUserSendCreate,
notifyUserSendDelete, notifyUserSendDelete,
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return { ok: false, response: errorResponse('Server configuration error', 500) }; return { ok: false, response: errorResponse('Server configuration error', 500) };
} }
return { ok: true, secret }; return { ok: true, secret };
+1 -4
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import { import {
handleAccessSend, handleAccessSend,
handleAccessSendFile, handleAccessSendFile,
@@ -34,7 +33,7 @@ import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -308,8 +307,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
? 'missing' ? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength : secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short' ? 'too_short'
: null; : null;
+17 -7
View File
@@ -1,4 +1,4 @@
import { DEFAULT_DEV_SECRET, Env } from './types'; import { Env } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response'; import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated'; import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public'; import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean { function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false; if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -85,14 +95,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env); const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) { if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const auth = new AuthService(env); const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader); const verified = await auth.verifyAccessTokenWithUser(authHeader);
+64 -1
View File
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
}; };
} }
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
const row = await db
.prepare(
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id
WHERE a.id = ? AND c.user_id = ?`
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
}
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> { export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key ' +
'WHERE EXISTS (' +
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
')'
); );
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
} }
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
} }
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
await db
.prepare(
`DELETE FROM attachments
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers c
WHERE c.id = attachments.cipher_id AND c.user_id = ?
)`
)
.bind(id, userId)
.run();
}
export async function bulkDeleteAttachmentsByIds( export async function bulkDeleteAttachmentsByIds(
db: D1Database, db: D1Database,
sqlChunkSize: SqlChunkSize, sqlChunkSize: SqlChunkSize,
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
} }
export async function addAttachmentToCipherForUser(
db: D1Database,
cipherId: string,
attachmentId: string,
userId: string
): Promise<void> {
await db
.prepare(
`UPDATE attachments
SET cipher_id = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers target_cipher
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
)
AND EXISTS (
SELECT 1 FROM ciphers current_cipher
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
)`
)
.bind(cipherId, attachmentId, cipherId, userId, userId)
.run();
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
} }
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
return row ? mapAuthRequestRow(row) : null; return row ? mapAuthRequestRow(row) : null;
} }
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> { export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>(); const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
return (res.results || []).map(mapAuthRequestRow); return (res.results || []).map(mapAuthRequestRow);
+10 -1
View File
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
return parseCipherRow(row); return parseCipherRow(row);
} }
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
.bind(id, userId)
.first<CipherRow>();
return parseCipherRow(row);
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId); const folderId = normalizeOptionalId(cipher.folderId);
const data = buildCipherData(cipher, folderId); const data = buildCipherData(cipher, folderId);
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at' 'type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
stmt, stmt,
+10 -1
View File
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
return mapFolderRow(row); return mapFolderRow(row);
} }
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapFolderRow(row);
}
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> { export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' 'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
) )
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run(); .run();
+14 -2
View File
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
return mapSendRow(row); return mapSendRow(row);
} }
export async function getSendForUser(db: D1Database, id: string, userId: string): Promise<Send | null> {
const row = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ? AND user_id = ?'
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapSendRow(row);
}
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> { export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' + 'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' + 'type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' + 'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' + 'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date' 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
+35
View File
@@ -41,6 +41,7 @@ import {
deleteFolder as deleteStoredFolder, deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders, getAllFolders as listStoredFolders,
getFolder as findStoredFolder, getFolder as findStoredFolder,
getFolderForUser as findStoredFolderForUser,
getFoldersPage as listStoredFoldersPage, getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
@@ -53,6 +54,7 @@ import {
bulkUnarchiveCiphers as unarchiveStoredCiphers, bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCipherForUser as findStoredCipherForUser,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage, getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher, saveCipher as saveStoredCipher,
@@ -60,10 +62,13 @@ import {
} from './storage-cipher-repo'; } from './storage-cipher-repo';
import { import {
addAttachmentToCipher as attachStoredAttachmentToCipher, addAttachmentToCipher as attachStoredAttachmentToCipher,
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment, deleteAttachment as deleteStoredAttachment,
deleteAttachmentForUser as deleteStoredAttachmentForUser,
getAttachment as findStoredAttachment, getAttachment as findStoredAttachment,
getAttachmentForUser as findStoredAttachmentForUser,
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
@@ -75,6 +80,7 @@ import {
deleteSend as deleteStoredSend, deleteSend as deleteStoredSend,
getAllSends as listStoredSends, getAllSends as listStoredSends,
getSend as findStoredSend, getSend as findStoredSend,
getSendForUser as findStoredSendForUser,
getSendsByIds as listStoredSendsByIds, getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage, getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount, incrementSendAccessCount as incrementStoredSendAccessCount,
@@ -114,6 +120,7 @@ import {
import { import {
createAuthRequest as createStoredAuthRequest, createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById, getAuthRequestById as findStoredAuthRequestById,
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId, listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId, listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated, markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
@@ -458,6 +465,10 @@ export class StorageService {
return findStoredCipher(this.db, id); return findStoredCipher(this.db, id);
} }
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
return findStoredCipherForUser(this.db, id, userId);
}
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
} }
@@ -508,6 +519,10 @@ export class StorageService {
return findStoredFolder(this.db, id); return findStoredFolder(this.db, id);
} }
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
return findStoredFolderForUser(this.db, id, userId);
}
async saveFolder(folder: Folder): Promise<void> { async saveFolder(folder: Folder): Promise<void> {
await saveStoredFolder(this.db, folder); await saveStoredFolder(this.db, folder);
} }
@@ -546,6 +561,10 @@ export class StorageService {
return findStoredAttachment(this.db, id); return findStoredAttachment(this.db, id);
} }
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
return findStoredAttachmentForUser(this.db, id, userId);
}
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
} }
@@ -554,6 +573,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id); await deleteStoredAttachment(this.db, id);
} }
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
await deleteStoredAttachmentForUser(this.db, id, userId);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> { async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
} }
@@ -574,6 +597,10 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
@@ -634,6 +661,10 @@ export class StorageService {
return findStoredSend(this.db, id); return findStoredSend(this.db, id);
} }
async getSendForUser(id: string, userId: string): Promise<Send | null> {
return findStoredSendForUser(this.db, id, userId);
}
async saveSend(send: Send): Promise<void> { async saveSend(send: Send): Promise<void> {
await saveStoredSend(this.db, this.safeBind.bind(this), send); await saveStoredSend(this.db, this.safeBind.bind(this), send);
} }
@@ -783,6 +814,10 @@ export class StorageService {
return findStoredAuthRequestById(this.db, id); return findStoredAuthRequestById(this.db, id);
} }
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestByIdForUser(this.db, id, userId);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> { async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId); return listStoredAuthRequestsByUserId(this.db, userId);
} }
-4
View File
@@ -19,10 +19,6 @@ export interface Env {
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned'; export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
+36
View File
@@ -0,0 +1,36 @@
const API_KEY_HASH_PREFIX = 'sha256:';
export function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
}
export async function hashApiKey(apiKey: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
}
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false;
const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types'; import { Env } from '../types';
import { errorResponse } from './response'; import { errorResponse } from './response';
export interface DirectUploadPayload { export interface DirectUploadPayload {
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null { export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return null; return null;
} }
return secret; return secret;
+1 -3
View File
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface JwtWarningPageProps { interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short'; reason: 'missing' | 'too_short';
minLength: number; minLength: number;
} }
@@ -21,8 +21,6 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const title = const title =
props.reason === 'missing' props.reason === 'missing'
? t('txt_jwt_title_missing') ? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short'); : t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
+4 -1
View File
@@ -591,11 +591,14 @@ export async function changeMasterPassword(
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32); const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32); const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac); const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
if (userSym.length !== 64) {
throw new Error('Invalid profile key');
}
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32); const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32); const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym, nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash); const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
+1 -1
View File
@@ -42,7 +42,7 @@ export interface PendingPasskeyPassword {
kdfIterations: number; kdfIterations: number;
} }
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
defaultKdfIterations: number; defaultKdfIterations: number;
+1 -1
View File
@@ -669,7 +669,7 @@ const en: Record<string, string> = {
"txt_jwt_secret_value_label": "Value:", "txt_jwt_secret_value_label": "Value:",
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters", "txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
"txt_jwt_what_is": "What is JWT?", "txt_jwt_what_is": "What is JWT?",
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.", "txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
"txt_how_to_fix": "How to fix", "txt_how_to_fix": "How to fix",
"txt_jwt_fix_step_1": "Open your deployment environment variables.", "txt_jwt_fix_step_1": "Open your deployment environment variables.",
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.", "txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
+1 -1
View File
@@ -669,7 +669,7 @@ const es: Record<string, string> = {
"txt_jwt_secret_value_label": "Valor:", "txt_jwt_secret_value_label": "Valor:",
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres", "txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
"txt_jwt_what_is": "Qué es JWT", "txt_jwt_what_is": "Qué es JWT",
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.", "txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
"txt_how_to_fix": "Cómo corregirlo", "txt_how_to_fix": "Cómo corregirlo",
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.", "txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.", "txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
+1 -1
View File
@@ -669,7 +669,7 @@ const ru: Record<string, string> = {
"txt_jwt_secret_value_label": "Значение:", "txt_jwt_secret_value_label": "Значение:",
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.", "txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
"txt_jwt_what_is": "Что такое JWT?", "txt_jwt_what_is": "Что такое JWT?",
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.", "txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
"txt_how_to_fix": "Как исправить", "txt_how_to_fix": "Как исправить",
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.", "txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.", "txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
+1 -1
View File
@@ -669,7 +669,7 @@ const zhCN: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符", "txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
"txt_jwt_what_is": "JWT 是什么", "txt_jwt_what_is": "JWT 是什么",
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,或者仍然使用示例值,实例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,实例就不能安全地正常使用。",
"txt_how_to_fix": "处理步骤(添加 / 更换)", "txt_how_to_fix": "处理步骤(添加 / 更换)",
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。", "txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
+1 -1
View File
@@ -669,7 +669,7 @@ const zhTW: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符", "txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
"txt_jwt_what_is": "JWT 是什麼", "txt_jwt_what_is": "JWT 是什麼",
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,或者仍然使用示例值,實例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,實例就不能安全地正常使用。",
"txt_how_to_fix": "處理步驟(添加 / 更換)", "txt_how_to_fix": "處理步驟(添加 / 更換)",
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。", "txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
+1 -1
View File
@@ -290,7 +290,7 @@ export interface ListResponse<T> {
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations?: number; defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
} }