Compare commits

..
9 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 f5cc02b93f feat: remove passkey login research docs to simplify the codebase 2026-06-19 01:41:37 +08:00
14 changed files with 493 additions and 43 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
+3 -3
View File
@@ -3904,9 +3904,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
+2 -1
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,7 @@
}
},
"overrides": {
"nanoid": "3.3.17",
"nanoid": "3.3.18",
"undici": "8.9.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
+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;