mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
fix(auth): align API keys and exclude device trust backups
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { handleGetApiKey, handleRotateApiKey } from '../src/handlers/accounts.ts';
|
||||
import { hashApiKey, verifyApiKey } from '../src/utils/api-key.ts';
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function createUserRow(apiKey) {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'user@example.com',
|
||||
name: 'User',
|
||||
master_password_hint: null,
|
||||
master_password_hash: 'master-proof',
|
||||
key: 'wrapped-user-key',
|
||||
private_key: null,
|
||||
public_key: null,
|
||||
kdf_type: 0,
|
||||
kdf_iterations: 600000,
|
||||
kdf_memory: null,
|
||||
kdf_parallelism: null,
|
||||
security_stamp: 'security-stamp-original',
|
||||
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: apiKey,
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createDb(apiKey) {
|
||||
const state = {
|
||||
user: createUserRow(apiKey),
|
||||
userWrites: 0,
|
||||
refreshDeletes: 0,
|
||||
auditActions: [],
|
||||
};
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
let bindings = [];
|
||||
const statement = {
|
||||
bind(...values) {
|
||||
bindings = values;
|
||||
return statement;
|
||||
},
|
||||
async first() {
|
||||
if (/FROM users WHERE id = \?/i.test(sql)) return { ...state.user };
|
||||
return null;
|
||||
},
|
||||
async all() {
|
||||
return { results: [] };
|
||||
},
|
||||
async run() {
|
||||
if (/INSERT INTO users\(/i.test(sql)) {
|
||||
state.userWrites += 1;
|
||||
state.user.security_stamp = bindings[12];
|
||||
state.user.api_key = bindings[24];
|
||||
state.user.updated_at = bindings[26];
|
||||
}
|
||||
if (/DELETE FROM refresh_tokens/i.test(sql)) state.refreshDeletes += 1;
|
||||
if (/INSERT INTO audit_logs/i.test(sql)) state.auditActions.push(bindings[2]);
|
||||
return { meta: { changes: 1 } };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
async batch(statements) {
|
||||
return statements.map(() => ({ success: true, meta: { changes: 1 } }));
|
||||
},
|
||||
};
|
||||
return { db, state };
|
||||
}
|
||||
|
||||
function request() {
|
||||
return new Request('https://nodewarden.example/api/accounts/api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash: 'master-proof' }),
|
||||
});
|
||||
}
|
||||
|
||||
function env(db) {
|
||||
return { DB: db, JWT_SECRET: 'test-secret-at-least-thirty-two-characters' };
|
||||
}
|
||||
|
||||
const view = createDb('ExistingReadableApiKey1234567');
|
||||
const viewResponse = await handleGetApiKey(request(), env(view.db), 'user-1');
|
||||
const viewBody = await viewResponse.json();
|
||||
assert(viewResponse.status === 200, 'Viewing an existing readable API key failed');
|
||||
assert(viewBody.apiKey === 'ExistingReadableApiKey1234567', 'View did not return the existing API key');
|
||||
assert(view.state.userWrites === 0, 'View unexpectedly rewrote the user');
|
||||
assert(view.state.refreshDeletes === 0, 'View unexpectedly revoked refresh tokens');
|
||||
assert(view.state.auditActions.includes('account.api_key.view'), 'View audit action is missing');
|
||||
|
||||
const rotate = createDb('ExistingReadableApiKey1234567');
|
||||
const rotateResponse = await handleRotateApiKey(request(), env(rotate.db), 'user-1');
|
||||
const rotateBody = await rotateResponse.json();
|
||||
assert(rotateResponse.status === 200, 'API key rotation failed');
|
||||
assert(rotateBody.apiKey !== 'ExistingReadableApiKey1234567', 'Rotation returned the old API key');
|
||||
assert(rotate.state.user.api_key === rotateBody.apiKey, 'Rotation did not persist the returned API key');
|
||||
assert(rotate.state.user.security_stamp === 'security-stamp-original', 'Rotation changed securityStamp');
|
||||
assert(rotate.state.refreshDeletes === 0, 'Rotation revoked unrelated refresh tokens');
|
||||
assert(!(await verifyApiKey('ExistingReadableApiKey1234567', rotate.state.user.api_key)), 'Old API key still authenticates');
|
||||
assert(await verifyApiKey(rotateBody.apiKey, rotate.state.user.api_key), 'Rotated API key does not authenticate');
|
||||
|
||||
const legacyPlain = 'LegacyHashedApiKey123456789';
|
||||
const legacy = createDb(await hashApiKey(legacyPlain));
|
||||
const legacyResponse = await handleGetApiKey(request(), env(legacy.db), 'user-1');
|
||||
assert(legacyResponse.status === 409, 'Legacy hashed key view should require explicit rotation');
|
||||
assert(legacy.state.userWrites === 0, 'Legacy hashed key was silently rotated');
|
||||
assert(await verifyApiKey(legacyPlain, legacy.state.user.api_key), 'Legacy hashed API key stopped authenticating');
|
||||
|
||||
const missing = createDb(null);
|
||||
const missingResponse = await handleGetApiKey(request(), env(missing.db), 'user-1');
|
||||
const missingBody = await missingResponse.json();
|
||||
assert(missingResponse.status === 200 && !!missingBody.apiKey, 'Missing legacy API key was not initialized');
|
||||
assert(missing.state.userWrites === 1, 'Missing legacy API key initialization was not persisted');
|
||||
|
||||
console.log('Bitwarden-compatible API key view and rotation semantics: PASS');
|
||||
@@ -0,0 +1,138 @@
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
import {
|
||||
buildBackupArchive,
|
||||
parseBackupArchive,
|
||||
validateBackupPayloadContents,
|
||||
} from '../src/services/backup-archive.ts';
|
||||
import { importBackupArchiveBytes } from '../src/services/backup-import.ts';
|
||||
|
||||
const forbiddenRuntimeTables = [
|
||||
'devices',
|
||||
'refresh_tokens',
|
||||
'auth_requests',
|
||||
'trusted_two_factor_device_tokens',
|
||||
'account_passkey_challenges',
|
||||
'used_attachment_download_tokens',
|
||||
];
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function sqlTouchesTable(sql, table) {
|
||||
return new RegExp(`\\b(?:from|into|table)\\s+[\"']?${table}\\b`, 'i').test(sql);
|
||||
}
|
||||
|
||||
function emptyBackupDb(extra = {}) {
|
||||
return {
|
||||
config: [],
|
||||
users: [],
|
||||
domain_settings: [],
|
||||
user_revisions: [],
|
||||
folders: [],
|
||||
ciphers: [],
|
||||
attachments: [],
|
||||
webauthn_credentials: [],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function archiveBytes(db, tableCounts = {}) {
|
||||
const encoder = new TextEncoder();
|
||||
return zipSync({
|
||||
'manifest.json': encoder.encode(JSON.stringify({
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date(0).toISOString(),
|
||||
appVersion: 'test',
|
||||
storageKind: null,
|
||||
tableCounts,
|
||||
includes: { attachments: false },
|
||||
blobSummary: { attachmentFiles: 0, totalBytes: 0, largestObjectBytes: 0 },
|
||||
attachmentBlobs: [],
|
||||
})),
|
||||
'db.json': encoder.encode(JSON.stringify(db)),
|
||||
}, { level: 0 });
|
||||
}
|
||||
|
||||
function createD1Mock({ exportMode = false } = {}) {
|
||||
const preparedSql = [];
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
preparedSql.push(sql);
|
||||
let bindings = [];
|
||||
const statement = {
|
||||
sql,
|
||||
bind(...values) {
|
||||
bindings = values;
|
||||
return statement;
|
||||
},
|
||||
async all() {
|
||||
if (exportMode) return { results: [] };
|
||||
return { results: [] };
|
||||
},
|
||||
async first() {
|
||||
if (/SELECT sql FROM sqlite_master/i.test(sql)) {
|
||||
const table = String(bindings[0] || '').trim();
|
||||
return { sql: `CREATE TABLE ${table} (id TEXT)` };
|
||||
}
|
||||
if (/SELECT COUNT\(\*\).*FROM config__restore/i.test(sql)) return { count: 1 };
|
||||
if (/SELECT COUNT\(\*\)/i.test(sql)) return { count: 0 };
|
||||
return null;
|
||||
},
|
||||
async run() {
|
||||
return { meta: { changes: 0 } };
|
||||
},
|
||||
};
|
||||
return statement;
|
||||
},
|
||||
async batch(statements) {
|
||||
return statements.map(() => ({ success: true, meta: { changes: 0 } }));
|
||||
},
|
||||
};
|
||||
return { db, preparedSql };
|
||||
}
|
||||
|
||||
const exportMock = createD1Mock({ exportMode: true });
|
||||
const exported = await buildBackupArchive({ DB: exportMock.db }, new Date(0), { includeAttachments: false });
|
||||
const exportedZip = unzipSync(exported.bytes);
|
||||
const exportedManifest = JSON.parse(new TextDecoder().decode(exportedZip['manifest.json']));
|
||||
const exportedDb = JSON.parse(new TextDecoder().decode(exportedZip['db.json']));
|
||||
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(!(table in exportedDb), `Export contains forbidden runtime table: ${table}`);
|
||||
assert(!(table in exportedManifest.tableCounts), `Manifest counts forbidden runtime table: ${table}`);
|
||||
assert(!exportMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)), `Export queried forbidden runtime table: ${table}`);
|
||||
}
|
||||
|
||||
const legacyDb = emptyBackupDb({
|
||||
devices: [{ device_identifier: 'device-secret' }],
|
||||
refresh_tokens: [{ token: 'refresh-secret' }],
|
||||
auth_requests: [{ access_code: 'approval-secret' }],
|
||||
trusted_two_factor_device_tokens: [{ token: 'remember-secret' }],
|
||||
account_passkey_challenges: [{ challenge_hash: 'challenge-secret' }],
|
||||
used_attachment_download_tokens: [{ token_hash: 'download-secret' }],
|
||||
});
|
||||
const legacyArchive = archiveBytes(legacyDb, {
|
||||
devices: 1,
|
||||
refresh_tokens: 1,
|
||||
auth_requests: 1,
|
||||
trusted_two_factor_device_tokens: 1,
|
||||
account_passkey_challenges: 1,
|
||||
used_attachment_download_tokens: 1,
|
||||
});
|
||||
const parsedLegacy = parseBackupArchive(legacyArchive);
|
||||
validateBackupPayloadContents(parsedLegacy.payload, parsedLegacy.files);
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(!(table in parsedLegacy.payload.db), `Legacy runtime table was not ignored: ${table}`);
|
||||
}
|
||||
|
||||
const restoreMock = createD1Mock();
|
||||
await importBackupArchiveBytes(legacyArchive, { DB: restoreMock.db }, 'actor', false);
|
||||
for (const table of forbiddenRuntimeTables) {
|
||||
assert(
|
||||
!restoreMock.preparedSql.some((sql) => sqlTouchesTable(sql, table)),
|
||||
`Restore touched forbidden runtime table: ${table}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('backup runtime authentication state exclusion: PASS');
|
||||
Reference in New Issue
Block a user