Compare commits

..
14 Commits
Author SHA1 Message Date
KiritoXDone a72592e76e fix(cipher): clear omitted notes on full update (#363)
Treat omitted nullable cipher fields as cleared during full updates so stale encrypted notes are not restored by merge fallback.

Fixes #362
2026-08-30 01:57:41 +08:00
Chius e63f9663e8 security: prevent websocket JWT leakage through URL query tokens (#349)
* security: prevent websocket JWT query authentication

* fix: harden websocket connection tickets

* fix: expire unused websocket tickets
2026-08-30 01:56:31 +08:00
ph4nt0mer df6b0b9767 fix: keep duplicate group indices unique when selecting duplicates (#351)
The duplicate group index was capped with '% 64' to limit color slots, but
the same index is used as the group identity in 'select unique items from
duplicates'. With more than 64 duplicate groups the indices wrap around, so
later groups had every item selected (nothing kept). Drop the modulo so each
group keeps a unique index; colors still differ via the golden-angle hue.
2026-08-30 01:54:15 +08:00
shuaiplus 64037695b8 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-08-30 01:47:09 +08:00
shuaiplus d43f21e8e0 fix(deps): update nanoid to version 3.3.18 2026-08-30 01:46:42 +08:00
shuaiplus e4368e6129 fix(deps): update nanoid to version 3.3.18 2026-08-30 01:44:36 +08:00
shuaiplus 00433a7cda Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-08-30 01:35:04 +08:00
Cordero Core 13bb0a0308 fix(webapp): resolve three errors in the webapp typecheck (#356)
`npx tsc -p webapp/tsconfig.json --noEmit`, one of the checks recommended
in CONTRIBUTING.md, currently fails on main with three errors. All three
are declaration defects with correct runtime behavior; none changes
observable behavior.

- api/backup.ts: downloadAdminBackupAttachmentBlob declared a bare
  Uint8Array return. Since TypeScript made typed arrays generic, that
  widens to Uint8Array<ArrayBufferLike> and no longer satisfies fflate's
  Uint8Array<ArrayBuffer>. The body already returns an ArrayBuffer-backed
  value, so this only annotates what it produces.

- backup-center.ts: invalidateRemoteBrowserCacheForDestination declared a
  full PersistedRemoteBrowserState but builds four of its five fields.
  The sole caller reads .cache only, so the return type is narrowed to
  match what the function actually returns.

- password-security-cache.ts: getPasswordSecurityState declared the public
  PasswordSecurityState, but startPasswordSecurityScan needs `controller`,
  which lives on InternalPasswordSecurityState. An internal accessor keeps
  `controller` off the exported type rather than widening the public API.

No change to backup payload shape, archive/import whitelists, or any
persisted format.

Verified with tsc 5.9.3, 6.0.3, and 7.0.2 (all exit 0 for both
webapp/tsconfig.json and tsconfig.json), plus npm run build and
npm run i18n:validate.
2026-08-30 01:31:37 +08:00
shuaiplus b3630bdaf5 fix(deps): update nanoid to 3.3.17 and undici to 8.9.0 2026-08-10 20:01:43 +08:00
Cordero Core 38b0ff6263 fix(vault): let the list toolbar wrap instead of overflowing (#348)
.list-head is a single-line flex row whose buttons are nowrap and
cannot shrink below their labels. The duplicates view adds a detection
mode select and a Select-duplicates button to the standard
search/sort/sync set, exceeding the list column (capped at 540px on
desktop), so the shrinkable controls crush to slivers and the fixed
buttons overlap and spill out of the column. Allow wrapping: views
that fit stay on one line; crowded toolbars flow to a second row.
Mobile is unaffected (it switches .list-head to its own grid).
2026-08-10 19:39:36 +08:00
Cordero Core ecc0d134ac fix(devices): wrap authorized-device action buttons instead of clipping (#347)
.authorized-devices-actions forced its four buttons (Untrust, Trust
permanently, Device note, Delete) onto one non-wrapping, non-shrinking
line inside the fixed 26% actions column. At common desktop widths the
row overflows the column and table-layout: fixed clips it at the panel
edge, cutting off Device note and hiding Delete entirely. Let the
buttons wrap to a second line instead.
2026-08-10 19:39:15 +08:00
Cordero Core f644baaf8d fix(vault): stop detail-row value column collapsing to zero width (#346)
The .kv-row grid sized its actions column with auto, letting it claim
content width before the minmax(0, 1fr) value column. Once the
Check breach button joined Reveal and Copy, label + actions could
exceed the row width, resolving the value column to 0px; combined
with overflow-wrap: anywhere this rendered masked passwords as a
vertical column of one asterisk per line at common desktop widths.

Give the value column a floor of min(35%, 140px) so the actions
column shrinks and wraps its buttons (kv-actions already has
flex-wrap) before the value collapses.
2026-08-10 19:38:52 +08:00
Cordero Core fb627f59f0 fix(styles): use theme tokens for hardcoded brand blues (#345)
Replace hardcoded #1d4ed8 / #2563eb / #bfdbfe values with their exact
design-token equivalents (--primary, --primary-hover, --primary-strong)
in 17 declarations across auth, dark, management, and vault styles.

Light theme is pixel-identical: every replaced hex equals the token's
light value. In dark theme this fixes spots that dark.css never
overrode and that kept light-theme blues on dark backgrounds:
standalone footer links and version badge, JWT warning inline link,
restore-progress active dot, TOTP countdown ring, and the
authorized-device checkbox accent.

Intentionally left alone: .btn-primary gradients (would lighten dark
buttons under white text), card brand colors (Amex/Maestro/RuPay blues
are brand constants, not theme colors), and light-pill pairings whose
backgrounds have no token (.log-mode-option.active, .log-category-auth,
.log-level-info, .folder-edit-btn:hover, #93c5fd borders).
2026-08-10 19:38:27 +08:00
shuaiplus f5cc02b93f feat: remove passkey login research docs to simplify the codebase 2026-06-19 01:41:37 +08:00
18 changed files with 841 additions and 349 deletions
+2
View File
@@ -44,6 +44,7 @@ tmp/
.tmp-bitwarden-clients/
nodewarden-wiki/
nodewarden.wiki/
wiki/
AGENTS.md
settings.json
@@ -57,6 +58,7 @@ NodeWarden-compat/
.reasonix/
.upstream/
bitwarden-upstream/
# Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md
+313 -276
View File
File diff suppressed because it is too large Load Diff
+16 -14
View File
@@ -17,6 +17,7 @@
"i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:notifications-security": "tsx --test scripts/notifications-security.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
"test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
"test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts",
@@ -48,7 +49,8 @@
}
},
"overrides": {
"undici": ">=7.28.0",
"nanoid": "3.3.18",
"undici": "8.9.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0",
@@ -56,26 +58,26 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.6",
"@types/node": "^26.1.2",
"autoprefixer": "^10.5.4",
"opencc-js": "^1.4.1",
"postcss": "^8.5.26",
"@preact/preset-vite": "^2.10.5",
"@types/node": "^26.0.1",
"autoprefixer": "^10.5.2",
"opencc-js": "^1.3.2",
"postcss": "^8.5.23",
"tailwindcss": "^3.4.19",
"tsx": "^4.23.9",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"wrangler": "^4.119.0"
"vite": "^8.1.3",
"wrangler": "^4.105.0"
},
"dependencies": {
"@noble/hashes": "^2.3.0",
"@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.101.4",
"@zip.js/zip.js": "^2.8.34",
"@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.29.0",
"preact": "^10.29.8",
"lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4",
"wouter": "^3.10.0"
}
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleNotificationsHub, handleNotificationsNegotiate } from '../src/handlers/notifications';
import type { Env } from '../src/types';
import { createJWT } from '../src/utils/jwt';
const secret = 'notification-security-test-secret-32-bytes';
const userId = 'd75020e1-2de4-46e8-b8f1-475d127b51f2';
const securityStamp = 'security-stamp';
function createTestEnv() {
const connectionTokens = new Map<string, { userId: string; deviceIdentifier: string | null; expiresAt: number }>();
const forwardedHubUrls: string[] = [];
const durableObjectNames: string[] = [];
const userRow = {
id: userId,
email: 'user@example.test',
name: 'Test User',
master_password_hint: null,
master_password_hash: 'hash',
key: 'key',
private_key: null,
public_key: null,
kdf_type: 0,
kdf_iterations: 600000,
kdf_memory: null,
kdf_parallelism: null,
security_stamp: securityStamp,
role: 'user',
status: 'active',
verify_devices: 0,
totp_secret: null,
totp_recovery_code: null,
yubikey_key1: null,
yubikey_key2: null,
yubikey_key3: null,
yubikey_key4: null,
yubikey_key5: null,
yubikey_nfc: 0,
api_key: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const db = {
prepare() {
return {
bind() {
return this;
},
async first() {
return userRow;
},
};
},
} as unknown as D1Database;
const stub = {
async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = new Request(input, init);
const url = new URL(request.url);
if (url.pathname === '/internal/ws-token') {
const body = await request.json() as {
token: string;
userId: string;
deviceIdentifier: string | null;
expiresAt: number;
};
connectionTokens.set(body.token, body);
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/ws-token/consume') {
const { token } = await request.json() as { token: string };
const connection = connectionTokens.get(token);
connectionTokens.delete(token);
if (!connection || connection.expiresAt <= Date.now()) return new Response(null, { status: 401 });
return Response.json(connection);
}
forwardedHubUrls.push(request.url);
return new Response(null, { status: 204 });
},
};
const env = {
DB: db,
JWT_SECRET: secret,
NOTIFICATIONS_HUB: {
idFromName(name: string) {
durableObjectNames.push(name);
return name;
},
get() {
return stub;
},
},
} as unknown as Env;
return { env, connectionTokens, durableObjectNames, forwardedHubUrls };
}
async function validAccessToken(): Promise<string> {
return createJWT({
sub: userId,
email: 'user@example.test',
name: 'Test User',
sstamp: securityStamp,
}, secret);
}
test('query access_token cannot authenticate a websocket', async () => {
const { env, forwardedHubUrls } = createTestEnv();
const token = await validAccessToken();
const response = await handleNotificationsHub(new Request(
`https://vault.example.test/notifications/hub?access_token=${encodeURIComponent(token)}`,
{ headers: { Upgrade: 'websocket' } }
), env);
assert.equal(response.status, 401);
assert.deepEqual(forwardedHubUrls, []);
});
test('Authorization bearer token still authenticates notifications', async () => {
const { env, forwardedHubUrls } = createTestEnv();
const token = await validAccessToken();
const response = await handleNotificationsHub(new Request(
'https://vault.example.test/notifications/hub',
{ headers: { Authorization: `Bearer ${token}`, Upgrade: 'websocket' } }
), env);
assert.equal(response.status, 204);
assert.equal(new URL(forwardedHubUrls[0]).searchParams.get('nw_uid'), userId);
});
test('negotiate issues a short-lived one-time websocket connection token', async () => {
const { env, connectionTokens, forwardedHubUrls } = createTestEnv();
const accessToken = await validAccessToken();
const negotiate = await handleNotificationsNegotiate(new Request(
'https://vault.example.test/notifications/hub/negotiate',
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
), env);
const body = await negotiate.json() as { connectionToken: string };
const stored = connectionTokens.get(body.connectionToken);
assert.equal(negotiate.status, 200);
assert.ok(stored);
assert.ok(stored.expiresAt > Date.now());
assert.ok(stored.expiresAt <= Date.now() + 60_000);
const request = () => new Request(
`https://vault.example.test/notifications/hub?id=${encodeURIComponent(body.connectionToken)}`,
{ headers: { Upgrade: 'websocket' } }
);
assert.equal((await handleNotificationsHub(request(), env)).status, 204);
assert.equal((await handleNotificationsHub(request(), env)).status, 401);
assert.equal(forwardedHubUrls.length, 1);
});
test('a non-upgrade request does not consume a websocket connection token', async () => {
const { env, connectionTokens } = createTestEnv();
const accessToken = await validAccessToken();
const negotiate = await handleNotificationsNegotiate(new Request(
'https://vault.example.test/notifications/hub/negotiate',
{ method: 'POST', headers: { Authorization: `Bearer ${accessToken}` } }
), env);
const { connectionToken } = await negotiate.json() as { connectionToken: string };
const url = `https://vault.example.test/notifications/hub?id=${encodeURIComponent(connectionToken)}`;
assert.equal((await handleNotificationsHub(new Request(url), env)).status, 426);
assert.ok(connectionTokens.has(connectionToken));
assert.equal((await handleNotificationsHub(new Request(url, { headers: { Upgrade: 'websocket' } }), env)).status, 204);
});
test('a forged ticket cannot select or activate a Durable Object', async () => {
const { env, durableObjectNames } = createTestEnv();
const response = await handleNotificationsHub(new Request(
'https://vault.example.test/notifications/hub?id=attacker-controlled.invalid-signature',
{ headers: { Upgrade: 'websocket' } }
), env);
assert.equal(response.status, 401);
assert.deepEqual(durableObjectNames, []);
});
+75
View File
@@ -19,6 +19,8 @@ const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
const WEBSOCKET_CONNECTION_TOKEN_PREFIX = 'ws-token:';
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
type HubProtocol = 'json' | 'messagepack';
type HubKind = 'user' | 'anonymous-auth-request';
@@ -32,6 +34,12 @@ interface WsAttachment {
deviceIdentifier: string | null;
}
interface WebSocketConnectionToken {
userId: string;
deviceIdentifier: string | null;
expiresAt: number;
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = new Uint8Array(total);
@@ -207,6 +215,52 @@ export class NotificationsHub extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/internal/ws-token' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
token?: string;
userId?: string;
deviceIdentifier?: string | null;
expiresAt?: number;
} | null;
const token = String(body?.token || '').trim();
const userId = String(body?.userId || '').trim();
const expiresAt = Number(body?.expiresAt || 0);
if (!token || !userId || expiresAt <= Date.now() || expiresAt > Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS) {
return new Response('Invalid websocket connection token', { status: 400 });
}
await this.ctx.storage.put(`${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`, {
userId,
deviceIdentifier: String(body?.deviceIdentifier || '').trim() || null,
expiresAt,
} satisfies WebSocketConnectionToken);
const currentAlarm = await this.ctx.storage.getAlarm();
if (currentAlarm === null || expiresAt < currentAlarm) {
await this.ctx.storage.setAlarm(expiresAt);
}
return new Response(null, { status: 204 });
}
if (url.pathname === '/internal/ws-token/consume' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as { token?: string } | null;
const token = String(body?.token || '').trim();
if (!token) return new Response('Invalid websocket connection token', { status: 400 });
// Delete inside a transaction so a connection ticket cannot win two concurrent upgrades.
const connection = await this.ctx.storage.transaction(async (txn) => {
const key = `${WEBSOCKET_CONNECTION_TOKEN_PREFIX}${token}`;
const stored = await txn.get<WebSocketConnectionToken>(key);
if (stored) await txn.delete(key);
return stored || null;
});
if (!connection || connection.expiresAt <= Date.now()) {
return new Response('Unauthorized', { status: 401 });
}
return new Response(JSON.stringify(connection), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.pathname === '/internal/notify' && request.method === 'POST') {
const body = (await request.json().catch(() => null)) as {
revisionDate?: string;
@@ -301,6 +355,27 @@ export class NotificationsHub extends DurableObject<Env> {
});
}
async alarm(): Promise<void> {
const now = Date.now();
const tokens = await this.ctx.storage.list<WebSocketConnectionToken>({
prefix: WEBSOCKET_CONNECTION_TOKEN_PREFIX,
});
const expiredKeys: string[] = [];
let nextExpiration: number | null = null;
for (const [key, token] of tokens) {
if (token.expiresAt <= now) {
expiredKeys.push(key);
} else if (nextExpiration === null || token.expiresAt < nextExpiration) {
nextExpiration = token.expiresAt;
}
}
// Negotiated tickets that never reach an upgrade must not remain in DO storage indefinitely.
if (expiredKeys.length > 0) await this.ctx.storage.delete(expiredKeys);
if (nextExpiration !== null) await this.ctx.storage.setAlarm(nextExpiration);
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer | ArrayBufferView): Promise<void> {
const attachment = ws.deserializeAttachment() as WsAttachment | null;
if (!attachment) return;
+32
View File
@@ -0,0 +1,32 @@
interface AliasedValue<T> {
present: boolean;
value: T | null | undefined;
}
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
if (!source || typeof source !== 'object') {
return { present: false, value: undefined };
}
const record = source as Record<string, unknown>;
for (const alias of aliases) {
if (Object.prototype.hasOwnProperty.call(record, alias)) {
return { present: true, value: record[alias] as T | null | undefined };
}
}
return { present: false, value: undefined };
}
/**
* Full cipher updates use replacement semantics for nullable fields.
* Bitwarden clients may omit a property after its value is cleared, so an
* absent property must become null instead of falling back to stored data.
*/
export function readNullableFullUpdateField<T>(
source: unknown,
aliases: readonly string[]
): T | null {
const incoming = readOwnAliasedValue<T>(source, aliases);
return incoming.present ? incoming.value ?? null : null;
}
+5 -10
View File
@@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
import { readActingDeviceIdentifier } from '../utils/device';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { readNullableFullUpdateField } from './cipher-full-update';
// CONTRACT:
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
@@ -1100,16 +1101,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
// This prevents stale custom fields from being resurrected by merge fallback.
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
if (incomingFields.present) {
cipher.fields = incomingFields.value ?? null;
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
// Nullable fields use replacement semantics on this full-update endpoint.
// Some clients omit cleared values, so merge fallback must not resurrect them.
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);
+82 -20
View File
@@ -4,18 +4,20 @@ import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import {
createWebSocketConnectionToken,
verifyWebSocketConnectionToken,
} from '../utils/websocket-connection-token';
const WEBSOCKET_CONNECTION_TOKEN_TTL_MS = 60 * 1000;
function extractAccessToken(request: Request): string | null {
const url = new URL(request.url);
const queryToken = String(url.searchParams.get('access_token') || '').trim();
if (queryToken) return queryToken;
const authHeader = String(request.headers.get('Authorization') || '').trim();
const match = authHeader.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || null;
}
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
async function authenticateAccessToken(request: Request, env: Env): Promise<JWTPayload | null> {
const accessToken = extractAccessToken(request);
if (!accessToken) return null;
@@ -23,30 +25,90 @@ async function authenticateNotificationsRequest(request: Request, env: Env): Pro
return auth.verifyAccessToken(`Bearer ${accessToken}`);
}
async function issueWebSocketConnectionToken(payload: JWTPayload, env: Env): Promise<string> {
const expiresAt = Date.now() + WEBSOCKET_CONNECTION_TOKEN_TTL_MS;
const token = await createWebSocketConnectionToken(payload.sub, expiresAt, env.JWT_SECRET);
const id = env.NOTIFICATIONS_HUB.idFromName(payload.sub);
const stub = env.NOTIFICATIONS_HUB.get(id);
const response = await stub.fetch('https://notifications/internal/ws-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
userId: payload.sub,
deviceIdentifier: payload.did || null,
expiresAt,
}),
});
if (!response.ok) throw new Error('Failed to issue websocket connection token');
return token;
}
async function consumeWebSocketConnectionToken(request: Request, env: Env): Promise<JWTPayload | null> {
const token = String(new URL(request.url).searchParams.get('id') || '').trim();
const claims = await verifyWebSocketConnectionToken(token, env.JWT_SECRET);
if (!claims) return null;
// Verify the signed routing claim before selecting a Durable Object. Otherwise an
// attacker could activate arbitrary object names with forged token prefixes.
const id = env.NOTIFICATIONS_HUB.idFromName(claims.userId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const response = await stub.fetch('https://notifications/internal/ws-token/consume', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) return null;
const connection = (await response.json().catch(() => null)) as {
userId?: string;
deviceIdentifier?: string | null;
} | null;
if (connection?.userId !== claims.userId) return null;
return {
sub: claims.userId,
did: String(connection.deviceIdentifier || '').trim() || undefined,
} as JWTPayload;
}
async function authenticateNotificationsHub(request: Request, env: Env): Promise<JWTPayload | null> {
// Never accept an access JWT from the URL: URLs are routinely retained by logs,
// browser history, proxies, monitoring, and error tracking systems.
if (request.headers.has('Authorization')) {
return authenticateAccessToken(request, env);
}
return consumeWebSocketConnectionToken(request, env);
}
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
const payload = await authenticateAccessToken(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
const connectionId = generateUUID();
return jsonResponse({
connectionId,
connectionToken: connectionId,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
});
const connectionToken = await issueWebSocketConnectionToken(payload, env);
return jsonResponse(
{
connectionId: generateUUID(),
connectionToken,
negotiateVersion: 1,
availableTransports: [
{
transport: 'WebSockets',
transferFormats: ['Text', 'Binary'],
},
],
},
200,
{ 'Cache-Control': 'no-store' }
);
}
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
const payload = await authenticateNotificationsRequest(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return errorResponse('Expected websocket', 426);
}
const payload = await authenticateNotificationsHub(request, env);
if (!payload?.sub) return errorResponse('Unauthorized', 401);
const userId = payload.sub;
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
+86
View File
@@ -0,0 +1,86 @@
import { generateUUID } from './uuid';
const WEBSOCKET_NOTIFICATION_SCOPE = 'notifications.websocket';
export interface WebSocketConnectionTokenClaims {
userId: string;
expiresAt: number;
nonce: string;
scope: typeof WEBSOCKET_NOTIFICATION_SCOPE;
}
function base64UrlEncode(data: Uint8Array): string {
return btoa(String.fromCharCode(...data))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function base64UrlDecode(value: string): Uint8Array {
let normalized = value.replace(/-/g, '+').replace(/_/g, '/');
while (normalized.length % 4) normalized += '=';
const binary = atob(normalized);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
async function getSigningKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
}
export async function createWebSocketConnectionToken(
userId: string,
expiresAt: number,
secret: string
): Promise<string> {
const claims: WebSocketConnectionTokenClaims = {
userId,
expiresAt,
nonce: generateUUID(),
scope: WEBSOCKET_NOTIFICATION_SCOPE,
};
const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claims)));
const signature = await crypto.subtle.sign(
'HMAC',
await getSigningKey(secret),
new TextEncoder().encode(payload)
);
return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`;
}
export async function verifyWebSocketConnectionToken(
token: string,
secret: string
): Promise<WebSocketConnectionTokenClaims | null> {
try {
if (!token || token.length > 1024) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payload, encodedSignature] = parts;
const valid = await crypto.subtle.verify(
'HMAC',
await getSigningKey(secret),
base64UrlDecode(encodedSignature),
new TextEncoder().encode(payload)
);
if (!valid) return null;
const claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as Partial<WebSocketConnectionTokenClaims>;
if (
claims.scope !== WEBSOCKET_NOTIFICATION_SCOPE
|| !String(claims.userId || '').trim()
|| !Number.isFinite(claims.expiresAt)
|| Number(claims.expiresAt) <= Date.now()
) {
return null;
}
return claims as WebSocketConnectionTokenClaims;
} catch {
return null;
}
}
+12 -4
View File
@@ -1674,17 +1674,25 @@ export default function App() {
reconnectAttempts += 1;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
connect();
void connect();
}, delay);
};
const connect = () => {
const connect = async () => {
if (disposed) return;
const accessToken = session.accessToken;
if (!accessToken) return;
try {
const negotiateResponse = await fetch('/notifications/hub/negotiate?negotiateVersion=1', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!negotiateResponse.ok) throw new Error('Notification negotiation failed');
const negotiation = (await negotiateResponse.json()) as { connectionToken?: string };
if (!negotiation.connectionToken || disposed) throw new Error('Notification connection token missing');
const hubUrl = new URL('/notifications/hub', window.location.origin);
hubUrl.searchParams.set('access_token', accessToken);
hubUrl.searchParams.set('id', negotiation.connectionToken);
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(hubUrl.toString());
} catch {
@@ -1809,7 +1817,7 @@ export default function App() {
});
};
connect();
void connect();
return () => {
disposed = true;
+3 -1
View File
@@ -382,7 +382,9 @@ export default function VaultPage(props: VaultPageProps) {
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
// Keep indices unique (no modulo): they are used both for group colors and
// as the group identity when selecting duplicate items to delete.
groupIndexByKey.set(groupKey, index);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
+1 -1
View File
@@ -197,7 +197,7 @@ export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string,
masterPasswordHash: string
): Promise<Uint8Array> {
): Promise<Uint8Array<ArrayBuffer>> {
const resp = await authedFetch('/api/admin/backup/blob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+1 -1
View File
@@ -186,7 +186,7 @@ export function invalidateRemoteBrowserCacheForDestination(
cache: Record<string, RemoteBackupBrowserResponse>,
pathByDestination: Record<string, string>,
pageByKey: Record<string, number>
): PersistedRemoteBrowserState {
): Omit<PersistedRemoteBrowserState, 'refreshedAt'> {
return {
cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))),
pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)),
+6 -2
View File
@@ -23,7 +23,7 @@ function createState(fingerprint: string): InternalPasswordSecurityState {
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
function ensurePasswordSecurityState(fingerprint: string): InternalPasswordSecurityState {
if (state?.fingerprint !== fingerprint) {
state?.controller?.abort();
state = createState(fingerprint);
@@ -31,6 +31,10 @@ export function getPasswordSecurityState(fingerprint: string): PasswordSecurityS
return state;
}
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
return ensurePasswordSecurityState(fingerprint);
}
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
return state?.fingerprint === fingerprint ? state : null;
}
@@ -41,7 +45,7 @@ export function subscribePasswordSecurityState(listener: () => void): () => void
}
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
const current = getPasswordSecurityState(fingerprint);
const current = ensurePasswordSecurityState(fingerprint);
current.controller?.abort();
const controller = new AbortController();
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
+4 -4
View File
@@ -369,7 +369,7 @@
.not-found-code {
@apply rounded-full px-3 py-1 text-sm font-extrabold;
background: #eef4ff;
color: #1d4ed8;
color: var(--primary-hover);
}
.not-found-copy {
@@ -574,7 +574,7 @@
.jwt-inline-link {
@apply font-bold no-underline;
color: #1d4ed8;
color: var(--primary-hover);
}
.jwt-inline-link:hover {
@@ -613,7 +613,7 @@
.standalone-footer a {
@apply font-bold no-underline;
color: #1d4ed8;
color: var(--primary-hover);
}
.standalone-footer a:hover {
@@ -622,5 +622,5 @@
.standalone-version {
@apply font-bold;
color: #1d4ed8;
color: var(--primary-hover);
}
+1 -1
View File
@@ -220,7 +220,7 @@
}
:root[data-theme='dark'] .card-brand-icon {
color: #bfdbfe;
color: var(--primary-strong);
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
}
+12 -10
View File
@@ -99,7 +99,7 @@
@apply inline-flex h-[22px] w-[22px] shrink-0 cursor-pointer items-center justify-center rounded-full p-0 text-[13px] font-extrabold leading-none;
border: 1px solid #bfd1f3;
background: #eef4ff;
color: #1d4ed8;
color: var(--primary-hover);
}
.backup-help-trigger:hover,
@@ -194,7 +194,7 @@
}
.backup-recommendation-step a {
color: #1d4ed8;
color: var(--primary-hover);
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
@@ -316,14 +316,14 @@
}
.backup-interval-preset:hover:not(:disabled) {
border-color: #2563eb;
color: #2563eb;
border-color: var(--primary);
color: var(--primary);
background: #eff6ff;
}
.backup-interval-preset.active {
border-color: #2563eb;
background: #2563eb;
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
@@ -1786,7 +1786,7 @@
}
.restore-progress-item.active {
color: #1d4ed8;
color: var(--primary-hover);
}
.restore-progress-item.done {
@@ -1799,7 +1799,7 @@
}
.restore-progress-item.active .restore-progress-dot {
background: #1d4ed8;
background: var(--primary-hover);
}
.restore-progress-item.done .restore-progress-dot {
@@ -1871,7 +1871,7 @@
.authorized-device-checkbox {
width: 16px;
height: 16px;
accent-color: #2563eb;
accent-color: var(--primary);
}
.authorized-devices-table td:first-child {
@@ -1879,7 +1879,9 @@
}
.authorized-devices-actions {
flex-wrap: nowrap;
/* Wrap instead of clipping: four non-shrinking buttons overflow the
fixed 26% actions column at common widths, hiding Delete entirely. */
flex-wrap: wrap;
gap: 6px;
}
+7 -5
View File
@@ -47,7 +47,7 @@
}
.folder-add-btn:hover {
color: #1d4ed8;
color: var(--primary-hover);
}
.search-input {
@@ -161,7 +161,7 @@
}
.folder-sort-btn:hover {
color: #1d4ed8;
color: var(--primary-hover);
background: #dbeafe;
transform: scale(1.06);
}
@@ -230,7 +230,7 @@ select.input.duplicate-mode-toolbar-select {
}
.list-head {
@apply mb-1.5 flex items-center gap-2;
@apply mb-1.5 flex flex-wrap items-center gap-2;
min-height: 34px;
}
@@ -860,7 +860,9 @@ select.input.duplicate-mode-toolbar-select {
.kv-row {
@apply grid items-center gap-2.5 py-2.5;
grid-template-columns: minmax(0px, 80px) minmax(0, 1fr) auto;
/* Floor the value column so the auto-sized actions column wraps its
buttons instead of collapsing the value to 0 width. */
grid-template-columns: minmax(0px, 80px) minmax(min(35%, 140px), 1fr) auto;
border-bottom: 1px solid rgba(154, 172, 205, 0.22);
}
@@ -962,7 +964,7 @@ select.input.duplicate-mode-toolbar-select {
}
.totp-ring-progress {
stroke: #2563eb;
stroke: var(--primary);
stroke-linecap: round;
transition: stroke-dashoffset 260ms linear, stroke 200ms ease;
}