mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 13:20:13 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76d766d5d6 | |||
| cdbe87aac2 | |||
| d1a43f2e95 | |||
| 8d6bcc327d | |||
| d1e6ec8b8d | |||
| 3e56d05283 | |||
| 870149c771 | |||
| 9771df8777 | |||
| 0be3b91dd7 | |||
| 645a2f8e95 | |||
| f63b5d6cf4 | |||
| 081dc64093 |
@@ -6,6 +6,7 @@ node_modules/
|
||||
.dev.vars
|
||||
wrangler.my.toml
|
||||
RELEASE_NOTES.md
|
||||
tests/selfcheck.ts
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
@@ -10,7 +10,7 @@ English:[`README_EN.md`](./README_EN.md)
|
||||
---
|
||||
## 与 Bitwarden 官方服务端能力对比
|
||||
|
||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
||||
|---|---|---|---|
|
||||
| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 |
|
||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
||||
@@ -18,9 +18,10 @@ English:[`README_EN.md`](./README_EN.md)
|
||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||
| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
|
||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||
| 完整 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 没必要实现 |
|
||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||
| Send | ✅ | ❌ | 基本没人用 |
|
||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||
@@ -57,6 +58,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 可选:登录 TOTP(2FA)
|
||||
|
||||
- 在 Workers 的 Variables and Secrets 里新增 Secret:`TOTP_SECRET`(Base32)。
|
||||
- 配置了 `TOTP_SECRET` 就启用登录 TOTP;删除该变量即关闭。
|
||||
- 客户端流程:密码 -> TOTP 验证码。
|
||||
- 支持“记住此设备”30 天。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
@@ -83,4 +91,7 @@ LGPL-3.0 License
|
||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||
---
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
|
||||
+13
-1
@@ -20,9 +20,10 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||
| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
|
||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||
| Full 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not necessary to implement |
|
||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||
| Send | ✅ | ❌ | Not necessary to implement |
|
||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
||||
@@ -60,6 +61,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Optional Login TOTP (2FA)
|
||||
|
||||
- Add Workers Secret `TOTP_SECRET` (Base32) to enable login TOTP.
|
||||
- Remove `TOTP_SECRET` to disable login TOTP.
|
||||
- Client flow: password -> TOTP code.
|
||||
- "Remember this device" is supported for 30 days.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
@@ -89,3 +97,7 @@ LGPL-3.0 License
|
||||
|
||||
|
||||
|
||||
---
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- IMPORTANT:
|
||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||
-- Any new table/column/index must be added to both places together.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -77,6 +81,28 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
user_id TEXT NOT NULL,
|
||||
device_identifier TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_identifier),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_identifier TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
ip TEXT PRIMARY KEY,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodewarden",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"license": "LGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260131.0",
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodewarden",
|
||||
"version": "0.2.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||
"author": "shuaiplus",
|
||||
"license": "LGPL-3.0",
|
||||
@@ -9,8 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||
"deploy": "wrangler deploy",
|
||||
"selfcheck": "npx tsx tests/selfcheck.ts"
|
||||
"deploy": "wrangler deploy"
|
||||
},
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
@@ -22,7 +21,7 @@
|
||||
"cloudflare": {
|
||||
"bindings": {
|
||||
"JWT_SECRET": {
|
||||
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
|
||||
"description": "Use a strong random string (32+ characters recommended)"
|
||||
},
|
||||
"DB": {
|
||||
"description": "D1 database for storing vault data"
|
||||
|
||||
@@ -99,6 +99,6 @@
|
||||
compatibility: {
|
||||
// Single source of truth for /config.version and /api/version.
|
||||
// /config.version 与 /api/version 的统一版本号来源。
|
||||
bitwardenServerVersion: '2025.12.0',
|
||||
bitwardenServerVersion: '2026.1.0',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthService } from '../services/auth';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
if (!value) return false;
|
||||
@@ -128,7 +129,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
}
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
// - X-Device-Identifier: client device identifier
|
||||
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||
|
||||
if (!email || !deviceIdentifier) {
|
||||
return jsonResponse(false);
|
||||
}
|
||||
|
||||
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||
return jsonResponse(known);
|
||||
}
|
||||
|
||||
// GET /api/devices
|
||||
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const devices = await storage.getDevicesByUserId(userId);
|
||||
|
||||
return jsonResponse({
|
||||
data: devices.map(device => ({
|
||||
id: device.deviceIdentifier,
|
||||
name: device.name,
|
||||
identifier: device.deviceIdentifier,
|
||||
type: device.type,
|
||||
creationDate: device.createdAt,
|
||||
revisionDate: device.updatedAt,
|
||||
object: 'device',
|
||||
})),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,48 @@ import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
TwoFactorProviders: [0],
|
||||
TwoFactorProviders2: {
|
||||
'0': {
|
||||
Priority: 1,
|
||||
},
|
||||
},
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
async function recordFailedLoginAndBuildResponse(
|
||||
rateLimit: RateLimitService,
|
||||
loginIdentifier: string,
|
||||
message: string
|
||||
): Promise<Response> {
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// POST /identity/connect/token
|
||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||
@@ -30,7 +72,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
const passwordHash = body.password;
|
||||
const twoFactorToken = body.twoFactorToken;
|
||||
const twoFactorProvider = body.twoFactorProvider;
|
||||
const twoFactorRemember = body.twoFactorRemember;
|
||||
const loginIdentifier = getClientIdentifier(request);
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
// Bitwarden clients expect OAuth-style error fields.
|
||||
@@ -55,16 +101,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||
if (!valid) {
|
||||
// Record failed login attempt
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
loginIdentifier,
|
||||
'Username or password is incorrect. Try again'
|
||||
);
|
||||
}
|
||||
|
||||
if (deviceInfo.deviceIdentifier) {
|
||||
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
|
||||
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
||||
let passedByRememberToken = false;
|
||||
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) {
|
||||
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||
twoFactorToken,
|
||||
deviceInfo.deviceIdentifier
|
||||
);
|
||||
passedByRememberToken = trustedUserId === user.id;
|
||||
}
|
||||
|
||||
if (!passedByRememberToken && !twoFactorToken) {
|
||||
return twoFactorRequiredResponse();
|
||||
}
|
||||
|
||||
if (!passedByRememberToken) {
|
||||
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken);
|
||||
if (!totpOk) {
|
||||
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (failed.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (rememberRequested && deviceInfo.deviceIdentifier) {
|
||||
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||
await storage.saveTrustedTwoFactorDeviceToken(
|
||||
trustedTwoFactorTokenToReturn,
|
||||
user.id,
|
||||
deviceInfo.deviceIdentifier,
|
||||
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
@@ -78,6 +168,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
Kdf: user.kdfType,
|
||||
|
||||
+71
-8
@@ -2,6 +2,7 @@ import { Env, Cipher, Folder, CipherType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
// Bitwarden client import request format
|
||||
interface CiphersImportRequest {
|
||||
@@ -16,6 +17,11 @@ interface CiphersImportRequest {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
autofillOnPageLoad?: boolean | null;
|
||||
fido2Credentials?: any[] | null;
|
||||
uri?: string | null;
|
||||
passwordRevisionDate?: string | null;
|
||||
[key: string]: any;
|
||||
} | null;
|
||||
card?: {
|
||||
cardholderName?: string | null;
|
||||
@@ -66,6 +72,17 @@ interface CiphersImportRequest {
|
||||
}>;
|
||||
}
|
||||
|
||||
function bindNull(v: any): any {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||
const chunk = statements.slice(i, i + chunkSize);
|
||||
await db.batch(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
@@ -82,9 +99,11 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
const folderRelationships = importData.folderRelationships || [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||
|
||||
// Create folders and build index -> id mapping
|
||||
const folderIdMap = new Map<number, string>();
|
||||
const folderRows: Folder[] = [];
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folderId = generateUUID();
|
||||
@@ -98,7 +117,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
folderRows.push(folder);
|
||||
}
|
||||
|
||||
if (folderRows.length > 0) {
|
||||
const folderStatements = folderRows.map(folder =>
|
||||
env.DB
|
||||
.prepare(
|
||||
'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'
|
||||
)
|
||||
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||
);
|
||||
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
|
||||
}
|
||||
|
||||
// Build cipher index -> folder id mapping from relationships
|
||||
@@ -111,11 +142,13 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
}
|
||||
|
||||
// Create ciphers
|
||||
const cipherRows: Cipher[] = [];
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || null;
|
||||
|
||||
const cipher: Cipher = {
|
||||
...c,
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
type: c.type as CipherType,
|
||||
@@ -124,6 +157,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
notes: c.notes || null,
|
||||
favorite: c.favorite || false,
|
||||
login: c.login ? {
|
||||
...c.login,
|
||||
username: c.login.username || null,
|
||||
password: c.login.password || null,
|
||||
uris: c.login.uris?.map(u => ({
|
||||
@@ -132,10 +166,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: c.login.totp || null,
|
||||
autofillOnPageLoad: null,
|
||||
fido2Credentials: null,
|
||||
uri: null,
|
||||
passwordRevisionDate: null,
|
||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||
fido2Credentials: c.login.fido2Credentials ?? null,
|
||||
uri: c.login.uri ?? null,
|
||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||
} : null,
|
||||
card: c.card ? {
|
||||
cardholderName: c.card.cardholderName || null,
|
||||
@@ -174,14 +208,43 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
})) || null,
|
||||
passwordHistory: c.passwordHistory || null,
|
||||
reprompt: c.reprompt || 0,
|
||||
sshKey: null,
|
||||
key: null,
|
||||
sshKey: (c as any).sshKey ?? null,
|
||||
key: (c as any).key ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
cipherRows.push(cipher);
|
||||
}
|
||||
|
||||
if (cipherRows.length > 0) {
|
||||
const cipherStatements = cipherRows.map(cipher => {
|
||||
const data = JSON.stringify(cipher);
|
||||
return env.DB
|
||||
.prepare(
|
||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, deleted_at=excluded.deleted_at'
|
||||
)
|
||||
.bind(
|
||||
cipher.id,
|
||||
cipher.userId,
|
||||
Number(cipher.type) || 1,
|
||||
bindNull(cipher.folderId),
|
||||
bindNull(cipher.name),
|
||||
bindNull(cipher.notes),
|
||||
cipher.favorite ? 1 : 0,
|
||||
data,
|
||||
bindNull(cipher.reprompt ?? 0),
|
||||
bindNull(cipher.key),
|
||||
cipher.createdAt,
|
||||
cipher.updatedAt,
|
||||
bindNull(cipher.deletedAt)
|
||||
);
|
||||
});
|
||||
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
|
||||
}
|
||||
|
||||
// Update revision date
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
body: string;
|
||||
@@ -60,7 +61,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
@@ -73,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
+55
-26
@@ -3,39 +3,68 @@ import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
|
||||
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
|
||||
// initializeDatabase() only validates schema presence, so retries are cheap.
|
||||
let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
let dbInitPromise: Promise<void> | null = null;
|
||||
|
||||
function shouldSkipDatabaseInit(request: Request): boolean {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
if (method === 'OPTIONS') return true;
|
||||
if (method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) return true;
|
||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||
if (method === 'GET' && path.startsWith('/icons/')) return true;
|
||||
if (path.startsWith('/notifications/')) return true;
|
||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
if (dbInitialized) return;
|
||||
|
||||
if (!dbInitPromise) {
|
||||
dbInitPromise = (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
})
|
||||
.finally(() => {
|
||||
dbInitPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await dbInitPromise;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Auto-initialize database on first request
|
||||
if (!dbInitialized) {
|
||||
try {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
}
|
||||
}
|
||||
void ctx;
|
||||
const requiresDatabase = !shouldSkipDatabaseInit(request);
|
||||
|
||||
if (dbInitError) {
|
||||
const resp = jsonResponse(
|
||||
{
|
||||
error: 'Database not initialized',
|
||||
error_description: dbInitError,
|
||||
ErrorModel: {
|
||||
Message: dbInitError,
|
||||
Object: 'error',
|
||||
if (requiresDatabase) {
|
||||
await ensureDatabaseInitialized(env);
|
||||
if (dbInitError) {
|
||||
const resp = jsonResponse(
|
||||
{
|
||||
error: 'Database not initialized',
|
||||
error_description: dbInitError,
|
||||
ErrorModel: {
|
||||
Message: dbInitError,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await handleRequest(request, env);
|
||||
|
||||
+6
-9
@@ -37,6 +37,7 @@ import { handleSync } from './handlers/sync';
|
||||
|
||||
// Setup handlers
|
||||
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
|
||||
import { handleKnownDevice, handleGetDevices } from './handlers/devices';
|
||||
|
||||
// Import handler
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
@@ -218,13 +219,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// Known device check (no auth required) - returns plain string "true" or "false"
|
||||
if (path.startsWith('/api/devices/knowndevice')) {
|
||||
return new Response('true', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
// Known device check (no auth required)
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
// Identity endpoints (no auth required)
|
||||
@@ -540,9 +537,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Devices endpoint (stub) - for authenticated requests
|
||||
// Devices endpoint
|
||||
if (path === '/api/devices' && method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
return handleGetDevices(request, env, userId);
|
||||
}
|
||||
|
||||
// Not found
|
||||
|
||||
+226
-56
@@ -1,6 +1,71 @@
|
||||
import { User, Cipher, Folder, Attachment } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
|
||||
|
||||
// IMPORTANT:
|
||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||
// Any new table/column/index must be added to both places together.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
// - All methods are scoped by userId where applicable.
|
||||
@@ -56,69 +121,49 @@ export class StorageService {
|
||||
}
|
||||
|
||||
// --- Database initialization ---
|
||||
// One-click deploy requires zero manual migration steps.
|
||||
// This method idempotently creates required schema objects on first request.
|
||||
// Strategy:
|
||||
// - Run only once per isolate.
|
||||
// - Persist schema hash in DB config; if unchanged, skip all schema SQL.
|
||||
// - Keep statements idempotent so updates are safe.
|
||||
async initializeDatabase(): Promise<void> {
|
||||
if (StorageService.schemaVerified) return;
|
||||
|
||||
const schemaStatements = [
|
||||
'PRAGMA foreign_keys = ON',
|
||||
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
|
||||
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
|
||||
.bind(SCHEMA_HASH_CONFIG_KEY)
|
||||
.first<{ value: string }>();
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
if (current?.value !== schemaHash) {
|
||||
for (const stmt of SCHEMA_STATEMENTS) {
|
||||
await this.executeSchemaStatement(stmt);
|
||||
}
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
for (const stmt of schemaStatements) {
|
||||
await this.db.prepare(stmt).run();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
|
||||
.run();
|
||||
}
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
|
||||
private async executeSchemaStatement(statement: string): Promise<void> {
|
||||
try {
|
||||
await this.db.prepare(statement).run();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
// Keep migration resilient if a future non-idempotent DDL is retried.
|
||||
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config / setup ---
|
||||
|
||||
async isRegistered(): Promise<boolean> {
|
||||
@@ -471,10 +516,48 @@ export class StorageService {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
if (cipherIds.length === 0) return grouped;
|
||||
|
||||
const placeholders = cipherIds.map(() => '?').join(',');
|
||||
const uniqueCipherIds = [...new Set(cipherIds)];
|
||||
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||
|
||||
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
|
||||
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await this.db
|
||||
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||
.bind(...chunk)
|
||||
.all<any>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
const item: Attachment = {
|
||||
id: row.id,
|
||||
cipherId: row.cipher_id,
|
||||
fileName: row.file_name,
|
||||
size: row.size,
|
||||
sizeName: row.size_name,
|
||||
key: row.key,
|
||||
};
|
||||
const list = grouped.get(item.cipherId);
|
||||
if (list) {
|
||||
list.push(item);
|
||||
} else {
|
||||
grouped.set(item.cipherId, [item]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
const res = await this.db
|
||||
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||
.bind(...cipherIds)
|
||||
.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 c.user_id = ?`
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
@@ -575,6 +658,93 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
}
|
||||
|
||||
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
||||
const digest = await this.sha256Hex(token);
|
||||
return `sha256:${digest}`;
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(userId, deviceIdentifier, name, type, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const user = await this.getUser(email);
|
||||
if (!user) return false;
|
||||
return this.isKnownDevice(user.id, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(row => ({
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||
|
||||
async saveTrustedTwoFactorDeviceToken(
|
||||
token: string,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs?: number
|
||||
): Promise<void> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
|
||||
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||
|
||||
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||
)
|
||||
.bind(tokenKey, userId, deviceIdentifier, expiresAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
'SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(tokenKey, deviceIdentifier)
|
||||
.first<{ user_id: string; expires_at: number }>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
+144
-22
@@ -15,7 +15,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f3f4f6;
|
||||
--grid-line: rgba(170, 170, 170, 0.34);
|
||||
--grid-size: 30px;
|
||||
--card: #ffffff;
|
||||
--border: #d0d5dd;
|
||||
--text: #101828;
|
||||
@@ -33,7 +34,12 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
background-color: var(--bg);
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
background-position: -1px -1px;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -48,7 +54,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
box-shadow: 0px 0px 20px 10px rgba(16, 24, 40, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
@@ -242,9 +248,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
height: 46px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #d5dae1;
|
||||
background: #ffffff;
|
||||
color: #111418;
|
||||
border: 1px solid #c6ccd5;
|
||||
background: #f6f7f9;
|
||||
color: #1d2939;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
display: inline-flex;
|
||||
@@ -253,13 +259,39 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #edf0f4;
|
||||
border-color: #b8c0cc;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
.btn.primary {
|
||||
border-color: #111418;
|
||||
background: #111418;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: #1f242b;
|
||||
border-color: #1f242b;
|
||||
box-shadow: 0 10px 22px rgba(16, 24, 40, 0.22);
|
||||
}
|
||||
.btn.primary:active {
|
||||
background: #151a20;
|
||||
border-color: #151a20;
|
||||
}
|
||||
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
background: inherit;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
.mode-tabs { display: inline-flex; border: 1px solid #d5dae1; border-radius: 12px; overflow: hidden; }
|
||||
.mode-tab {
|
||||
@@ -299,6 +331,19 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
.flow-actions { display: flex; align-items: center; gap: 8px; width: 132px; }
|
||||
.flow-actions .btn { width: 120px; padding: 0; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.flow-bottom {
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.flow-actions {
|
||||
width: calc(50% - 10px);
|
||||
}
|
||||
.flow-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -483,7 +528,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
</section>
|
||||
|
||||
<section id="step4" class="step">
|
||||
<h2 id="t_s4_title">Final step</h2>
|
||||
<h2 id="t_s4_title">Create account</h2>
|
||||
<p class="lead" id="t_s4_desc"></p>
|
||||
|
||||
<div id="setup-form">
|
||||
@@ -516,6 +561,35 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
<button type="submit" id="submitBtn" class="btn primary" style="width:100%;height:52px;">Create account</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="step5" class="step">
|
||||
<h2 id="t_s5_title">Optional: login TOTP (2FA)</h2>
|
||||
<p class="lead" id="t_s5_desc"></p>
|
||||
|
||||
<div class="kv" style="margin-top:14px;">
|
||||
<h3 id="t_s5_enable_title">Enable on server (Cloudflare Workers)</h3>
|
||||
<ol>
|
||||
<li id="t_s5_enable_1"></li>
|
||||
<li id="t_s5_enable_2"></li>
|
||||
<li id="t_s5_enable_3"></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="kv">
|
||||
<h3 id="t_s5_client_title">Use in Bitwarden client</h3>
|
||||
<ol>
|
||||
<li id="t_s5_client_1"></li>
|
||||
<li id="t_s5_client_2"></li>
|
||||
<li id="t_s5_client_3"></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section id="step6" class="step">
|
||||
<h2 id="t_s6_title">Final step</h2>
|
||||
<p class="lead" id="t_s6_desc"></p>
|
||||
|
||||
<div id="registered-view" style="display:none;">
|
||||
<div class="kv">
|
||||
@@ -549,6 +623,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
<span class="dot" data-step="2"></span>
|
||||
<span class="dot" data-step="3"></span>
|
||||
<span class="dot" data-step="4"></span>
|
||||
<span class="dot" data-step="5"></span>
|
||||
<span class="dot" data-step="6"></span>
|
||||
</div>
|
||||
<div class="flow-actions" style="justify-content:flex-end;">
|
||||
<button id="nextBtn" class="btn primary" type="button">Next</button>
|
||||
@@ -622,7 +698,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
|
||||
s3Title: '同步策略(可跳过)',
|
||||
s3Title: '更新策略(可跳过)',
|
||||
s3CommonTitle: '共同前置步骤',
|
||||
s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。',
|
||||
s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。',
|
||||
@@ -637,8 +713,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
s3AutoStep2: '点击 “I understand my workflows, go ahead and enable them”。',
|
||||
s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。',
|
||||
|
||||
s4Title: '最终:创建账号',
|
||||
s4Desc: '填写信息并创建你的唯一账号,完成初始化。',
|
||||
s4Title: '创建账号',
|
||||
s4Desc: '填写信息并创建你的唯一账号。创建成功后会进入登录 TOTP 教程。',
|
||||
s5Title: '开启登录 TOTP(2FA,可跳过)',
|
||||
s5Desc: '这一页可跳过。如果你想开启登录二次验证,按下面步骤设置。',
|
||||
s5EnableTitle: '服务端开启(Cloudflare Workers)',
|
||||
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
|
||||
s5Enable2: '新增 Secret:TOTP_SECRET,值填写 Base32 密钥 。',
|
||||
s5Enable3: '保存并等待重新部署。删除 TOTP_SECRET 即关闭登录 2FA。',
|
||||
s5ClientTitle: '客户端使用',
|
||||
s5Client1: '下次登录时先输入主密码。',
|
||||
s5Client2: '客户端会弹出 2FA 验证码输入框,输入验证器里的 6 位码。',
|
||||
s5Client3: '如需可勾选记住此设备(30 天)。',
|
||||
s6Title: '最终页面',
|
||||
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
|
||||
nameLabel: '昵称',
|
||||
emailLabel: '邮箱',
|
||||
pwLabel: '主密码',
|
||||
@@ -724,8 +812,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
s3AutoStep2: 'Click “I understand my workflows, go ahead and enable them”.',
|
||||
s3AutoStep3: 'It runs daily at 03:00 by default; you can also click “Run workflow”.',
|
||||
|
||||
s4Title: 'Final: create account',
|
||||
s4Desc: 'Create your single user account to finish setup.',
|
||||
s4Title: 'Create account',
|
||||
s4Desc: 'Create your single user account. After success, you will see the optional login TOTP guide.',
|
||||
s5Title: 'Optional: login TOTP (2FA)',
|
||||
s5Desc: 'You can skip this step. If you want login 2FA, follow the steps below.',
|
||||
s5EnableTitle: 'Enable on server (Cloudflare Workers)',
|
||||
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
|
||||
s5Enable2: 'Add Secret: TOTP_SECRET, value is a Base32 secret (example: JBSWY3DPEHPK3PXP).',
|
||||
s5Enable3: 'Save and wait for redeploy. Remove TOTP_SECRET to disable login 2FA.',
|
||||
s5ClientTitle: 'Use in Bitwarden client',
|
||||
s5Client1: 'On next login, enter your master password first.',
|
||||
s5Client2: 'Client will prompt 2FA code. Enter the 6-digit code from your authenticator app.',
|
||||
s5Client3: 'Optionally choose remember this device (30 days).',
|
||||
s6Title: 'Final step',
|
||||
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
|
||||
nameLabel: 'Name',
|
||||
emailLabel: 'Email',
|
||||
pwLabel: 'Master password',
|
||||
@@ -841,6 +941,18 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
setText('t_hide_title', t('hideTitle'));
|
||||
setText('t_hide_desc', t('hideDesc'));
|
||||
setText('hideBtn', t('hideBtn'));
|
||||
setText('t_s5_title', t('s5Title'));
|
||||
setText('t_s5_desc', t('s5Desc'));
|
||||
setText('t_s5_enable_title', t('s5EnableTitle'));
|
||||
setText('t_s5_enable_1', t('s5Enable1'));
|
||||
setText('t_s5_enable_2', t('s5Enable2'));
|
||||
setText('t_s5_enable_3', t('s5Enable3'));
|
||||
setText('t_s5_client_title', t('s5ClientTitle'));
|
||||
setText('t_s5_client_1', t('s5Client1'));
|
||||
setText('t_s5_client_2', t('s5Client2'));
|
||||
setText('t_s5_client_3', t('s5Client3'));
|
||||
setText('t_s6_title', t('s6Title'));
|
||||
setText('t_s6_desc', t('s6Desc'));
|
||||
setText('hideModalTitle', t('hideModalTitle'));
|
||||
setText('hideModalDesc', t('hideModalDesc'));
|
||||
setText('hideModalWarn', t('hideModalWarn'));
|
||||
@@ -907,10 +1019,11 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
function goToStep(targetStep) {
|
||||
// 安全限制:JWT_SECRET 不合规时,只允许访问第 1/2 步。
|
||||
const maxStep = JWT_STATE ? 2 : 4;
|
||||
const maxStep = JWT_STATE ? 2 : (isRegistered ? 6 : 4);
|
||||
currentStep = Math.max(1, Math.min(maxStep, targetStep));
|
||||
if (isRegistered && currentStep === 4) currentStep = 5;
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const el = document.getElementById('step' + i);
|
||||
if (el) el.classList.toggle('active', i === currentStep);
|
||||
}
|
||||
@@ -926,7 +1039,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
if (nextBtn) {
|
||||
nextBtn.style.display = currentStep >= 4 ? 'none' : 'inline-flex';
|
||||
const reachedEnd = isRegistered ? (currentStep >= 6) : (currentStep >= 4);
|
||||
nextBtn.style.display = reachedEnd ? 'none' : 'inline-flex';
|
||||
if (currentStep === 2 && !!JWT_STATE) {
|
||||
nextBtn.disabled = true;
|
||||
nextBtn.textContent = t('keyWaitRefresh');
|
||||
@@ -954,8 +1068,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
// 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。
|
||||
goToStep(2);
|
||||
} else {
|
||||
goToStep(4);
|
||||
showRegisteredView();
|
||||
goToStep(6);
|
||||
showFinalView();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -963,8 +1077,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function showRegisteredView() {
|
||||
isRegistered = true;
|
||||
function showFinalView() {
|
||||
const setupForm = document.getElementById('setup-form');
|
||||
const registeredView = document.getElementById('registered-view');
|
||||
const serverUrl = document.getElementById('serverUrl');
|
||||
@@ -1087,7 +1200,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (isRegistered) {
|
||||
showRegisteredView();
|
||||
goToStep(6);
|
||||
showFinalView();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1152,7 +1266,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok && result.success) {
|
||||
showRegisteredView();
|
||||
isRegistered = true;
|
||||
goToStep(5);
|
||||
showFinalView();
|
||||
} else {
|
||||
showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error');
|
||||
if (btn) {
|
||||
@@ -1181,6 +1297,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (currentStep <= 1) return;
|
||||
if (isRegistered && currentStep === 5) {
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
goToStep(currentStep - 1);
|
||||
});
|
||||
}
|
||||
@@ -1190,7 +1310,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (currentStep === 1) goToStep(2);
|
||||
else if (currentStep === 2) goToStep(3);
|
||||
else if (currentStep === 3) goToStep(4);
|
||||
else if (currentStep === 3) goToStep(isRegistered ? 5 : 4);
|
||||
else if (currentStep === 4) goToStep(5);
|
||||
else if (currentStep === 5) goToStep(6);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface Env {
|
||||
DB: D1Database;
|
||||
ATTACHMENTS: R2Bucket;
|
||||
JWT_SECRET: string;
|
||||
TOTP_SECRET?: string;
|
||||
}
|
||||
|
||||
// Sample JWT secret used by `.dev.vars.example`.
|
||||
@@ -147,6 +148,15 @@ export interface Folder {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
name: string;
|
||||
type: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// JWT Payload
|
||||
export interface JWTPayload {
|
||||
sub: string; // user id
|
||||
@@ -190,6 +200,7 @@ export interface TokenResponse {
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
Kdf: number;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||
const DEFAULT_DEVICE_TYPE = 14;
|
||||
|
||||
function decodeBase64UrlUtf8(value: string): string | null {
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value: string | undefined | null): string {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function parseDeviceType(value: string | number | undefined | null): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||
return DEFAULT_DEVICE_TYPE;
|
||||
}
|
||||
|
||||
export interface AuthRequestDeviceInfo {
|
||||
deviceIdentifier: string | null;
|
||||
deviceName: string;
|
||||
deviceType: number;
|
||||
}
|
||||
|
||||
export function readAuthRequestDeviceInfo(
|
||||
body: Record<string, string | undefined>,
|
||||
request: Request
|
||||
): AuthRequestDeviceInfo {
|
||||
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||
const bodyName = body.deviceName || body.device_name;
|
||||
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||
const bodyType = body.deviceType || body.device_type;
|
||||
const headerType = request.headers.get('Device-Type') || undefined;
|
||||
|
||||
return {
|
||||
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||
deviceType: parseDeviceType(bodyType || headerType),
|
||||
};
|
||||
}
|
||||
|
||||
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||
return { email, deviceIdentifier };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
||||
|
||||
function isTrustedClientOrigin(origin: string): boolean {
|
||||
// Official browser extension / desktop-webview common origins.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const TOTP_STEP_SECONDS = 30;
|
||||
const TOTP_DIGITS = 6;
|
||||
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||
|
||||
function normalizeBase32(input: string): string {
|
||||
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Uint8Array | null {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const normalized = normalizeBase32(input);
|
||||
if (!normalized) return null;
|
||||
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output: number[] = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const idx = alphabet.indexOf(char);
|
||||
if (idx === -1) return null;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return output.length > 0 ? new Uint8Array(output) : null;
|
||||
}
|
||||
|
||||
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||
const counterBytes = new Uint8Array(8);
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = c & 0xff;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
secret,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||
const offset = signature[signature.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((signature[offset] & 0x7f) << 24) |
|
||||
((signature[offset + 1] & 0xff) << 16) |
|
||||
((signature[offset + 2] & 0xff) << 8) |
|
||||
(signature[offset + 3] & 0xff);
|
||||
|
||||
const otp = binary % (10 ** TOTP_DIGITS);
|
||||
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||
}
|
||||
|
||||
function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
if (expected === token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user