mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Basic success
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { Env, User, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
// POST /api/accounts/register (only used from setup page, not client)
|
||||
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Check if already registered
|
||||
const isRegistered = await storage.isRegistered();
|
||||
if (isRegistered) {
|
||||
return errorResponse('Registration is closed', 403);
|
||||
}
|
||||
|
||||
let body: {
|
||||
email?: string;
|
||||
name?: string;
|
||||
masterPasswordHash?: string;
|
||||
masterPasswordHint?: string;
|
||||
key?: string;
|
||||
kdf?: number;
|
||||
kdfIterations?: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
keys?: {
|
||||
publicKey?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const email = body.email?.toLowerCase();
|
||||
const name = body.name || email;
|
||||
const masterPasswordHash = body.masterPasswordHash;
|
||||
const key = body.key;
|
||||
const privateKey = body.keys?.encryptedPrivateKey;
|
||||
const publicKey = body.keys?.publicKey;
|
||||
|
||||
if (!email || !masterPasswordHash || !key) {
|
||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||
}
|
||||
|
||||
if (!privateKey || !publicKey) {
|
||||
return errorResponse('Private key and public key are required', 400);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user: User = {
|
||||
id: generateUUID(),
|
||||
email: email,
|
||||
name: name || email,
|
||||
masterPasswordHash: masterPasswordHash,
|
||||
key: key,
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
kdfType: body.kdf ?? 0,
|
||||
kdfIterations: body.kdfIterations ?? 600000,
|
||||
kdfMemory: body.kdfMemory,
|
||||
kdfParallelism: body.kdfParallelism,
|
||||
securityStamp: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await storage.saveUser(user);
|
||||
await storage.setRegistered();
|
||||
|
||||
return jsonResponse({ success: true }, 200);
|
||||
}
|
||||
|
||||
// GET /api/accounts/profile
|
||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: true,
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
providerOrganizations: [],
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
return jsonResponse(profile);
|
||||
}
|
||||
|
||||
// PUT /api/accounts/profile
|
||||
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
let body: { name?: string; masterPasswordHint?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (body.name) {
|
||||
user.name = body.name;
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveUser(user);
|
||||
|
||||
return handleGetProfile(request, env, userId);
|
||||
}
|
||||
|
||||
// POST /api/accounts/keys
|
||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
let body: {
|
||||
key?: string;
|
||||
encryptedPrivateKey?: string;
|
||||
publicKey?: string;
|
||||
};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (body.key) user.key = body.key;
|
||||
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||
if (body.publicKey) user.publicKey = body.publicKey;
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveUser(user);
|
||||
|
||||
return handleGetProfile(request, env, userId);
|
||||
}
|
||||
|
||||
// GET /api/accounts/revision-date
|
||||
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
|
||||
// Return as milliseconds timestamp (Bitwarden format)
|
||||
const timestamp = new Date(revisionDate).getTime();
|
||||
return jsonResponse(timestamp);
|
||||
}
|
||||
|
||||
// POST /api/accounts/verify-password
|
||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const user = await storage.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
let body: { masterPasswordHash?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.masterPasswordHash) {
|
||||
return errorResponse('masterPasswordHash is required', 400);
|
||||
}
|
||||
|
||||
if (body.masterPasswordHash !== user.masterPasswordHash) {
|
||||
return errorResponse('Invalid password', 400);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Env, Attachment, Cipher } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// Get R2 object path for attachment
|
||||
function getAttachmentPath(cipherId: string, attachmentId: string): string {
|
||||
return `${cipherId}/${attachmentId}`;
|
||||
}
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||
// Creates attachment metadata and returns upload URL
|
||||
export async function handleCreateAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
let body: {
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
fileSize?: number;
|
||||
};
|
||||
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.fileName || !body.key) {
|
||||
return errorResponse('fileName and key are required', 400);
|
||||
}
|
||||
|
||||
const fileSize = body.fileSize || 0;
|
||||
const attachmentId = generateUUID();
|
||||
|
||||
// Create attachment metadata
|
||||
const attachment: Attachment = {
|
||||
id: attachmentId,
|
||||
cipherId: cipherId,
|
||||
fileName: body.fileName,
|
||||
size: fileSize,
|
||||
sizeName: formatSize(fileSize),
|
||||
key: body.key,
|
||||
};
|
||||
|
||||
// Save attachment metadata
|
||||
await storage.saveAttachment(attachment);
|
||||
|
||||
// Add attachment to cipher
|
||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment-fileUpload',
|
||||
attachmentId: attachmentId,
|
||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||
fileUploadType: 0, // Direct upload
|
||||
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Maximum file size: 100MB
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Upload attachment file content
|
||||
export async function handleUploadAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Check content-length header for size limit
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||
}
|
||||
|
||||
// Get the file from multipart form data
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('data') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return errorResponse('No file uploaded', 400);
|
||||
}
|
||||
|
||||
// Check actual file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||
}
|
||||
|
||||
// Store file in R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
||||
httpMetadata: {
|
||||
contentType: 'application/octet-stream',
|
||||
},
|
||||
customMetadata: {
|
||||
cipherId: cipherId,
|
||||
attachmentId: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
// Update attachment size if different
|
||||
const actualSize = file.size;
|
||||
if (actualSize !== attachment.size) {
|
||||
attachment.size = actualSize;
|
||||
attachment.sizeName = formatSize(actualSize);
|
||||
await storage.saveAttachment(attachment);
|
||||
}
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Get attachment download info
|
||||
export async function handleGetAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Generate short-lived download token
|
||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||
|
||||
// Generate download URL with token
|
||||
const url = new URL(request.url);
|
||||
const downloadUrl = `${url.origin}/api/attachments/${cipherId}/${attachmentId}?token=${token}`;
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
url: downloadUrl,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(attachment.size),
|
||||
sizeName: attachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||
// Public download endpoint (uses token for auth instead of header)
|
||||
export async function handlePublicDownloadAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return errorResponse('Token required', 401);
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||
if (!claims) {
|
||||
return errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
// Verify token matches request
|
||||
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||
return errorResponse('Token mismatch', 401);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Get file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
const object = await env.ATTACHMENTS.get(path);
|
||||
|
||||
if (!object) {
|
||||
return errorResponse('Attachment file not found', 404);
|
||||
}
|
||||
|
||||
return new Response(object.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(object.size),
|
||||
'Cache-Control': 'private, no-cache',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Delete attachment
|
||||
export async function handleDeleteAttachment(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
// Verify cipher exists and belongs to user
|
||||
const cipher = await storage.getCipher(cipherId);
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Verify attachment exists
|
||||
const attachment = await storage.getAttachment(attachmentId);
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
|
||||
// Delete file from R2
|
||||
const path = getAttachmentPath(cipherId, attachmentId);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
|
||||
// Delete attachment metadata
|
||||
await storage.deleteAttachment(attachmentId);
|
||||
|
||||
// Remove attachment from cipher
|
||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||
|
||||
// Update cipher revision date
|
||||
await storage.updateCipherRevisionDate(cipherId);
|
||||
|
||||
// Get updated cipher for response
|
||||
const updatedCipher = await storage.getCipher(cipherId);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: formatCipherResponse(updatedCipher!, attachments),
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format cipher response with attachments
|
||||
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
|
||||
return {
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: cipher.type,
|
||||
name: cipher.name,
|
||||
notes: cipher.notes,
|
||||
favorite: cipher.favorite,
|
||||
login: cipher.login,
|
||||
card: cipher.card,
|
||||
identity: cipher.identity,
|
||||
secureNote: cipher.secureNote,
|
||||
fields: cipher.fields,
|
||||
passwordHistory: cipher.passwordHistory,
|
||||
reprompt: cipher.reprompt,
|
||||
organizationUseTotp: false,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: null,
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: String(a.size),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
object: 'attachment',
|
||||
})) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all attachments for a cipher (used when deleting cipher)
|
||||
export async function deleteAllAttachmentsForCipher(
|
||||
env: Env,
|
||||
cipherId: string
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const path = getAttachmentPath(cipherId, attachment.id);
|
||||
await env.ATTACHMENTS.delete(path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||
|
||||
// Format attachments for API response
|
||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: String(a.size),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
object: 'attachment',
|
||||
}));
|
||||
}
|
||||
|
||||
// Convert internal cipher to API response format
|
||||
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||
return {
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: cipher.type,
|
||||
name: cipher.name,
|
||||
notes: cipher.notes,
|
||||
favorite: cipher.favorite,
|
||||
login: cipher.login,
|
||||
card: cipher.card,
|
||||
identity: cipher.identity,
|
||||
secureNote: cipher.secureNote,
|
||||
fields: cipher.fields,
|
||||
passwordHistory: cipher.passwordHistory,
|
||||
reprompt: cipher.reprompt,
|
||||
organizationUseTotp: false,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
edit: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: formatAttachments(attachments),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/ciphers
|
||||
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
|
||||
// Filter out soft-deleted ciphers unless specifically requested
|
||||
const url = new URL(request.url);
|
||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||
|
||||
const filteredCiphers = includeDeleted
|
||||
? ciphers
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
data: cipherResponses,
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/ciphers/:id
|
||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(cipherToResponse(cipher, attachments));
|
||||
}
|
||||
|
||||
// POST /api/ciphers
|
||||
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
// Handle nested cipher object (from some clients)
|
||||
const cipherData = body.cipher || body;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const cipher: Cipher = {
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
type: cipherData.type,
|
||||
folderId: cipherData.folderId || null,
|
||||
name: cipherData.name,
|
||||
notes: cipherData.notes || null,
|
||||
favorite: cipherData.favorite || false,
|
||||
login: cipherData.login || null,
|
||||
card: cipherData.card || null,
|
||||
identity: cipherData.identity || null,
|
||||
secureNote: cipherData.secureNote || null,
|
||||
fields: cipherData.fields || null,
|
||||
passwordHistory: cipherData.passwordHistory || null,
|
||||
reprompt: cipherData.reprompt || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher), 200);
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id
|
||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const existingCipher = await storage.getCipher(id);
|
||||
|
||||
if (!existingCipher || existingCipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
// Handle nested cipher object
|
||||
const cipherData = body.cipher || body;
|
||||
|
||||
const cipher: Cipher = {
|
||||
...existingCipher,
|
||||
type: cipherData.type ?? existingCipher.type,
|
||||
folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId,
|
||||
name: cipherData.name ?? existingCipher.name,
|
||||
notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes,
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
login: cipherData.login !== undefined ? cipherData.login : existingCipher.login,
|
||||
card: cipherData.card !== undefined ? cipherData.card : existingCipher.card,
|
||||
identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity,
|
||||
secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote,
|
||||
fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields,
|
||||
passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id
|
||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
cipher.deletedAt = new Date().toISOString();
|
||||
cipher.updatedAt = cipher.deletedAt;
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id (permanent)
|
||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
// Delete all attachments first
|
||||
await deleteAllAttachmentsForCipher(env, id);
|
||||
|
||||
await storage.deleteCipher(id, userId);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id/restore
|
||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
cipher.deletedAt = null;
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const cipher = await storage.getCipher(id);
|
||||
|
||||
if (!cipher || cipher.userId !== userId) {
|
||||
return errorResponse('Cipher not found', 404);
|
||||
}
|
||||
|
||||
let body: { folderId?: string | null; favorite?: boolean };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (body.folderId !== undefined) {
|
||||
cipher.folderId = body.folderId;
|
||||
}
|
||||
if (body.favorite !== undefined) {
|
||||
cipher.favorite = body.favorite;
|
||||
}
|
||||
cipher.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
}
|
||||
|
||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
let body: { ids?: string[]; folderId?: string | null };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.ids || !Array.isArray(body.ids)) {
|
||||
return errorResponse('ids array is required', 400);
|
||||
}
|
||||
|
||||
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Env, Folder, FolderResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
// Convert internal folder to API response format
|
||||
function folderToResponse(folder: Folder): FolderResponse {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/folders
|
||||
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
|
||||
return jsonResponse({
|
||||
data: folders.map(folderToResponse),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/folders/:id
|
||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
|
||||
// POST /api/folders
|
||||
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
let body: { name?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (!body.name) {
|
||||
return errorResponse('Name is required', 400);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const folder: Folder = {
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
name: body.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
|
||||
return jsonResponse(folderToResponse(folder), 200);
|
||||
}
|
||||
|
||||
// PUT /api/folders/:id
|
||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
let body: { name?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
if (body.name) {
|
||||
folder.name = body.name;
|
||||
}
|
||||
folder.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
|
||||
return jsonResponse(folderToResponse(folder));
|
||||
}
|
||||
|
||||
// DELETE /api/folders/:id
|
||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const folder = await storage.getFolder(id);
|
||||
|
||||
if (!folder || folder.userId !== userId) {
|
||||
return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
await storage.deleteFolder(id, userId);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Env, TokenResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
|
||||
// POST /identity/connect/token
|
||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const auth = new AuthService(env);
|
||||
const rateLimit = new RateLimitService(env.VAULT);
|
||||
|
||||
let body: Record<string, string>;
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||
} else {
|
||||
body = await request.json();
|
||||
}
|
||||
|
||||
const grantType = body.grant_type;
|
||||
|
||||
if (grantType === 'password') {
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
const passwordHash = body.password;
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
return errorResponse('Email and password are required', 400);
|
||||
}
|
||||
|
||||
// Check if login is rate limited
|
||||
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
||||
if (!loginCheck.allowed) {
|
||||
return errorResponse(
|
||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
if (!user) {
|
||||
// Record failed attempt even for non-existent user (prevent enumeration)
|
||||
await rateLimit.recordFailedLogin(email);
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||
if (!valid) {
|
||||
// Record failed login attempt
|
||||
const result = await rateLimit.recordFailedLogin(email);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
await rateLimit.clearLoginAttempts(email);
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: 7200,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
},
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
|
||||
} else if (grantType === 'refresh_token') {
|
||||
// Refresh token
|
||||
const refreshToken = body.refresh_token;
|
||||
if (!refreshToken) {
|
||||
return errorResponse('Refresh token is required', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
if (!result) {
|
||||
return errorResponse('Invalid refresh token', 401);
|
||||
}
|
||||
|
||||
// Revoke old refresh token (prevent reuse)
|
||||
await storage.deleteRefreshToken(refreshToken);
|
||||
|
||||
const { accessToken, user } = result;
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
expires_in: 7200,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: newRefreshToken,
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
},
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
}
|
||||
|
||||
return errorResponse('Unsupported grant type', 400);
|
||||
}
|
||||
|
||||
// POST /identity/accounts/prelogin
|
||||
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
let body: { email?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const email = body.email?.toLowerCase();
|
||||
if (!email) {
|
||||
return errorResponse('Email is required', 400);
|
||||
}
|
||||
|
||||
const user = await storage.getUser(email);
|
||||
|
||||
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
||||
const kdfType = user?.kdfType ?? 0;
|
||||
const kdfIterations = user?.kdfIterations ?? 600000;
|
||||
const kdfMemory = user?.kdfMemory;
|
||||
const kdfParallelism = user?.kdfParallelism;
|
||||
|
||||
return jsonResponse({
|
||||
kdf: kdfType,
|
||||
kdfIterations: kdfIterations,
|
||||
kdfMemory: kdfMemory,
|
||||
kdfParallelism: kdfParallelism,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
// Bitwarden client import request format
|
||||
interface CiphersImportRequest {
|
||||
ciphers: Array<{
|
||||
type: number;
|
||||
name: string;
|
||||
notes?: string | null;
|
||||
favorite?: boolean;
|
||||
reprompt?: number;
|
||||
login?: {
|
||||
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
} | null;
|
||||
card?: {
|
||||
cardholderName?: string | null;
|
||||
brand?: string | null;
|
||||
number?: string | null;
|
||||
expMonth?: string | null;
|
||||
expYear?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
identity?: {
|
||||
title?: string | null;
|
||||
firstName?: string | null;
|
||||
middleName?: string | null;
|
||||
lastName?: string | null;
|
||||
address1?: string | null;
|
||||
address2?: string | null;
|
||||
address3?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: string | null;
|
||||
company?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
ssn?: string | null;
|
||||
username?: string | null;
|
||||
passportNumber?: string | null;
|
||||
licenseNumber?: string | null;
|
||||
} | null;
|
||||
secureNote?: { type: number } | null;
|
||||
fields?: Array<{
|
||||
name?: string | null;
|
||||
value?: string | null;
|
||||
type: number;
|
||||
linkedId?: number | null;
|
||||
}> | null;
|
||||
passwordHistory?: Array<{
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
}> | null;
|
||||
}>;
|
||||
folders: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
folderRelationships: Array<{
|
||||
key: number; // cipher index
|
||||
value: number; // folder index
|
||||
}>;
|
||||
}
|
||||
|
||||
// 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.VAULT);
|
||||
|
||||
let importData: CiphersImportRequest;
|
||||
try {
|
||||
importData = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const folders = importData.folders || [];
|
||||
const ciphers = importData.ciphers || [];
|
||||
const folderRelationships = importData.folderRelationships || [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Create folders and build index -> id mapping
|
||||
const folderIdMap = new Map<number, string>();
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folderId = generateUUID();
|
||||
folderIdMap.set(i, folderId);
|
||||
|
||||
const folder: Folder = {
|
||||
id: folderId,
|
||||
userId: userId,
|
||||
name: folders[i].name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await storage.saveFolder(folder);
|
||||
}
|
||||
|
||||
// Build cipher index -> folder id mapping from relationships
|
||||
const cipherFolderMap = new Map<number, string>();
|
||||
for (const rel of folderRelationships) {
|
||||
const folderId = folderIdMap.get(rel.value);
|
||||
if (folderId) {
|
||||
cipherFolderMap.set(rel.key, folderId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create ciphers
|
||||
for (let i = 0; i < ciphers.length; i++) {
|
||||
const c = ciphers[i];
|
||||
const folderId = cipherFolderMap.get(i) || null;
|
||||
|
||||
const cipher: Cipher = {
|
||||
id: generateUUID(),
|
||||
userId: userId,
|
||||
type: c.type as CipherType,
|
||||
folderId: folderId,
|
||||
name: c.name || 'Untitled',
|
||||
notes: c.notes || null,
|
||||
favorite: c.favorite || false,
|
||||
login: c.login ? {
|
||||
username: c.login.username || null,
|
||||
password: c.login.password || null,
|
||||
uris: c.login.uris?.map(u => ({
|
||||
uri: u.uri || null,
|
||||
uriChecksum: null,
|
||||
match: u.match ?? null,
|
||||
})) || null,
|
||||
totp: c.login.totp || null,
|
||||
autofillOnPageLoad: null,
|
||||
fido2Credentials: null,
|
||||
} : null,
|
||||
card: c.card ? {
|
||||
cardholderName: c.card.cardholderName || null,
|
||||
brand: c.card.brand || null,
|
||||
number: c.card.number || null,
|
||||
expMonth: c.card.expMonth || null,
|
||||
expYear: c.card.expYear || null,
|
||||
code: c.card.code || null,
|
||||
} : null,
|
||||
identity: c.identity ? {
|
||||
title: c.identity.title || null,
|
||||
firstName: c.identity.firstName || null,
|
||||
middleName: c.identity.middleName || null,
|
||||
lastName: c.identity.lastName || null,
|
||||
address1: c.identity.address1 || null,
|
||||
address2: c.identity.address2 || null,
|
||||
address3: c.identity.address3 || null,
|
||||
city: c.identity.city || null,
|
||||
state: c.identity.state || null,
|
||||
postalCode: c.identity.postalCode || null,
|
||||
country: c.identity.country || null,
|
||||
company: c.identity.company || null,
|
||||
email: c.identity.email || null,
|
||||
phone: c.identity.phone || null,
|
||||
ssn: c.identity.ssn || null,
|
||||
username: c.identity.username || null,
|
||||
passportNumber: c.identity.passportNumber || null,
|
||||
licenseNumber: c.identity.licenseNumber || null,
|
||||
} : null,
|
||||
secureNote: c.secureNote || null,
|
||||
fields: c.fields?.map(f => ({
|
||||
name: f.name || null,
|
||||
value: f.value || null,
|
||||
type: f.type,
|
||||
linkedId: f.linkedId ?? null,
|
||||
})) || null,
|
||||
passwordHistory: c.passwordHistory || null,
|
||||
reprompt: c.reprompt || 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
await storage.saveCipher(cipher);
|
||||
}
|
||||
|
||||
// Update revision date
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
||||
|
||||
// Setup page HTML (single-file, no external assets)
|
||||
const setupPageHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg0: #0b0b0f;
|
||||
--bg1: #0f1020;
|
||||
--card: rgba(255, 255, 255, 0.08);
|
||||
--card2: rgba(255, 255, 255, 0.06);
|
||||
--border: rgba(255, 255, 255, 0.14);
|
||||
--text: rgba(255, 255, 255, 0.92);
|
||||
--muted: rgba(255, 255, 255, 0.62);
|
||||
--muted2: rgba(255, 255, 255, 0.52);
|
||||
--accent: #0a84ff;
|
||||
--accent2: #64d2ff;
|
||||
--danger: #ff453a;
|
||||
--ok: #32d74b;
|
||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
||||
--radius: 18px;
|
||||
--radius2: 14px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background:
|
||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.shell {
|
||||
|
||||
width: max(500px);
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
}
|
||||
.hero {
|
||||
padding: 26px;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.top {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.mark {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
||||
border: 1px solid rgba(255,255,255,0.20);
|
||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255,255,255,0.96);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.title h1 {
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.title p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.panel {
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 14px 0;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
.message {
|
||||
display: none;
|
||||
border-radius: 14px;
|
||||
padding: 12px 12px;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.message.error {
|
||||
display: block;
|
||||
border-color: rgba(255, 69, 58, 0.40);
|
||||
background: rgba(255, 69, 58, 0.10);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.message.success {
|
||||
display: block;
|
||||
border-color: rgba(50, 215, 75, 0.35);
|
||||
background: rgba(50, 215, 75, 0.10);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
input {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(0,0,0,0.18);
|
||||
color: rgba(255,255,255,0.92);
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
input::placeholder { color: rgba(255,255,255,0.35); }
|
||||
input:focus {
|
||||
border-color: rgba(10, 132, 255, 0.55);
|
||||
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted2);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.primary {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
||||
color: rgba(255,255,255,0.96);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, filter 120ms ease;
|
||||
}
|
||||
.primary:hover { filter: brightness(1.03); }
|
||||
.primary:active { transform: translateY(1px) scale(0.99); }
|
||||
.primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.sideCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.kv {
|
||||
border-radius: var(--radius2);
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.kv h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
color: rgba(255,255,255,0.86);
|
||||
}
|
||||
.kv p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
}
|
||||
.server {
|
||||
margin-top: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
word-break: break-all;
|
||||
color: rgba(255,255,255,0.90);
|
||||
}
|
||||
a {
|
||||
color: rgba(100, 210, 255, 0.92);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover { text-decoration: underline; }
|
||||
.footer {
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255,255,255,0.10);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="panel">
|
||||
<div class="top">
|
||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
||||
<div class="title">
|
||||
<h1 id="t_app">NodeWarden</h1>
|
||||
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 12px"></div>
|
||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
||||
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
||||
</div>
|
||||
|
||||
<div style="height: 14px"></div>
|
||||
<h2 id="t_setup">初始化</h2>
|
||||
|
||||
<div id="message" class="message"></div>
|
||||
|
||||
<div id="setup-form">
|
||||
<form id="form" onsubmit="handleSubmit(event)">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="name" id="t_name_label">Name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Your name">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email" id="t_email_label">Email</label>
|
||||
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 10px"></div>
|
||||
<div class="field">
|
||||
<label for="password" id="t_pw_label">Master password</label>
|
||||
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
||||
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
||||
</div>
|
||||
|
||||
<div style="height: 10px"></div>
|
||||
<div class="field">
|
||||
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
||||
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="registered-view" class="sideCard" style="display: none;">
|
||||
<div class="kv">
|
||||
<h3 id="t_done_title">Setup complete</h3>
|
||||
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
||||
<div class="server" id="serverUrl"></div>
|
||||
</div>
|
||||
|
||||
<div class="kv">
|
||||
<h3 id="t_important">Important</h3>
|
||||
<p id="t_limitations">
|
||||
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
||||
If you forget it, you must redeploy and register again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div>
|
||||
<span class="muted" id="t_by">By</span>
|
||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
|
||||
|
||||
function isChinese() {
|
||||
const lang = (navigator.language || '').toLowerCase();
|
||||
return lang.startsWith('zh');
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
const zh = {
|
||||
app: 'NodeWarden',
|
||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
|
||||
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
||||
by: '作者',
|
||||
setup: '初始化',
|
||||
nameLabel: '昵称',
|
||||
emailLabel: '邮箱',
|
||||
pwLabel: '主密码',
|
||||
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
||||
pw2Label: '确认主密码',
|
||||
create: '创建账号',
|
||||
creating: '正在创建…',
|
||||
doneTitle: '初始化完成',
|
||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
||||
important: '重要提示',
|
||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
||||
errPwNotMatch: '两次输入的密码不一致',
|
||||
errPwTooShort: '密码长度至少 12 位',
|
||||
errGeneric: '发生错误:',
|
||||
errRegisterFailed: '注册失败',
|
||||
};
|
||||
const en = {
|
||||
app: 'NodeWarden',
|
||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
|
||||
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
||||
by: 'By',
|
||||
setup: 'Setup',
|
||||
nameLabel: 'Name',
|
||||
emailLabel: 'Email',
|
||||
pwLabel: 'Master password',
|
||||
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
||||
pw2Label: 'Confirm password',
|
||||
create: 'Create account',
|
||||
creating: 'Creating…',
|
||||
doneTitle: 'Setup complete',
|
||||
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
||||
important: 'Important',
|
||||
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
||||
errPwNotMatch: 'Passwords do not match',
|
||||
errPwTooShort: 'Password must be at least 12 characters',
|
||||
errGeneric: 'An error occurred: ',
|
||||
errRegisterFailed: 'Registration failed',
|
||||
};
|
||||
return (isChinese() ? zh : en)[key];
|
||||
}
|
||||
|
||||
function applyI18n() {
|
||||
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
||||
|
||||
document.getElementById('t_app').textContent = t('app');
|
||||
document.getElementById('t_tag').textContent = t('tag');
|
||||
document.getElementById('t_intro').textContent = t('intro');
|
||||
document.getElementById('t_by').textContent = t('by');
|
||||
document.getElementById('t_setup').textContent = t('setup');
|
||||
|
||||
document.getElementById('t_name_label').textContent = t('nameLabel');
|
||||
document.getElementById('t_email_label').textContent = t('emailLabel');
|
||||
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
||||
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
||||
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
||||
document.getElementById('submitBtn').textContent = t('create');
|
||||
|
||||
document.getElementById('t_done_title').textContent = t('doneTitle');
|
||||
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
||||
document.getElementById('t_important').textContent = t('important');
|
||||
document.getElementById('t_limitations').textContent = t('limitations');
|
||||
}
|
||||
|
||||
// Check if already registered
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const res = await fetch('/setup/status');
|
||||
const data = await res.json();
|
||||
if (data.registered) {
|
||||
showRegisteredView();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showRegisteredView() {
|
||||
document.getElementById('setup-form').style.display = 'none';
|
||||
document.getElementById('registered-view').style.display = 'block';
|
||||
document.getElementById('serverUrl').textContent = window.location.origin;
|
||||
showMessage(t('doneTitle'), 'success');
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const msg = document.getElementById('message');
|
||||
msg.textContent = text;
|
||||
msg.className = 'message ' + type;
|
||||
}
|
||||
|
||||
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
|
||||
// password can be string or Uint8Array
|
||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Handle password as string or Uint8Array
|
||||
const passwordBytes = (password instanceof Uint8Array)
|
||||
? password
|
||||
: encoder.encode(password);
|
||||
|
||||
// Handle salt as string or Uint8Array
|
||||
const saltBytes = (salt instanceof Uint8Array)
|
||||
? salt
|
||||
: encoder.encode(salt);
|
||||
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordBytes,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
|
||||
const derivedBits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
iterations: iterations,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
keyLen * 8
|
||||
);
|
||||
|
||||
return new Uint8Array(derivedBits);
|
||||
}
|
||||
|
||||
// HKDF expand
|
||||
async function hkdfExpand(prk, info, length) {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
prk,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const infoBytes = encoder.encode(info);
|
||||
const result = new Uint8Array(length);
|
||||
let prev = new Uint8Array(0);
|
||||
let offset = 0;
|
||||
let counter = 1;
|
||||
|
||||
while (offset < length) {
|
||||
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
||||
input.set(prev);
|
||||
input.set(infoBytes, prev.length);
|
||||
input[input.length - 1] = counter;
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, input);
|
||||
prev = new Uint8Array(signature);
|
||||
|
||||
const toCopy = Math.min(prev.length, length - offset);
|
||||
result.set(prev.slice(0, toCopy), offset);
|
||||
offset += toCopy;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate symmetric key
|
||||
function generateSymmetricKey() {
|
||||
return crypto.getRandomValues(new Uint8Array(64));
|
||||
}
|
||||
|
||||
// Encrypt with AES-256-CBC
|
||||
async function encryptAesCbc(data, key, iv) {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'AES-CBC' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-CBC', iv: iv },
|
||||
cryptoKey,
|
||||
data
|
||||
);
|
||||
|
||||
return new Uint8Array(encrypted);
|
||||
}
|
||||
|
||||
// HMAC-SHA256
|
||||
async function hmacSha256(key, data) {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
// Base64 encode
|
||||
function base64Encode(bytes) {
|
||||
return btoa(String.fromCharCode.apply(null, bytes));
|
||||
}
|
||||
|
||||
// Create encrypted string in Bitwarden format
|
||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
||||
|
||||
// Calculate MAC over IV + encrypted data
|
||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
||||
macData.set(iv);
|
||||
macData.set(encrypted, iv.length);
|
||||
const mac = await hmacSha256(macKey, macData);
|
||||
|
||||
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
|
||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
||||
}
|
||||
|
||||
// Generate RSA key pair
|
||||
async function generateRsaKeyPair() {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-1'
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export public key
|
||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
||||
|
||||
// Export private key
|
||||
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyB64,
|
||||
privateKey: privateKeyBytes
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('name').value;
|
||||
const email = document.getElementById('email').value.toLowerCase();
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showMessage(t('errPwNotMatch'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
showMessage(t('errPwTooShort'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = t('creating');
|
||||
|
||||
try {
|
||||
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
|
||||
const iterations = 600000;
|
||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
||||
|
||||
// Generate master password hash (for authentication)
|
||||
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
|
||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
||||
|
||||
// Stretch master key using HKDF
|
||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
|
||||
// Generate symmetric key (will be encrypted with stretched master key)
|
||||
const symmetricKey = generateSymmetricKey();
|
||||
|
||||
// Encrypt symmetric key with stretched master key
|
||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
||||
|
||||
// Generate RSA key pair
|
||||
const rsaKeys = await generateRsaKeyPair();
|
||||
|
||||
// Encrypt private key with symmetric key
|
||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
||||
|
||||
// Register with server
|
||||
const response = await fetch('/api/accounts/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
name: name,
|
||||
masterPasswordHash: masterPasswordHashB64,
|
||||
key: encryptedKey,
|
||||
kdf: 0,
|
||||
kdfIterations: iterations,
|
||||
keys: {
|
||||
publicKey: rsaKeys.publicKey,
|
||||
encryptedPrivateKey: encryptedPrivateKey
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showRegisteredView();
|
||||
} else {
|
||||
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('create');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = t('create');
|
||||
}
|
||||
}
|
||||
|
||||
// Check status on page load
|
||||
applyI18n();
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// GET / - Setup page
|
||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
||||
return htmlResponse(setupPageHTML);
|
||||
}
|
||||
|
||||
// GET /setup/status
|
||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
const registered = await storage.isRegistered();
|
||||
return jsonResponse({ registered });
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
|
||||
// Format attachments for API response
|
||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
if (attachments.length === 0) return null;
|
||||
return attachments.map(a => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
size: String(a.size),
|
||||
sizeName: a.sizeName,
|
||||
key: a.key,
|
||||
object: 'attachment',
|
||||
}));
|
||||
}
|
||||
|
||||
// GET /api/sync
|
||||
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
const storage = new StorageService(env.VAULT);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: true,
|
||||
premium: true,
|
||||
premiumFromOrganization: false,
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
providerOrganizations: [],
|
||||
forcePasswordReset: false,
|
||||
avatarColor: null,
|
||||
creationDate: user.createdAt,
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
cipherResponses.push({
|
||||
id: cipher.id,
|
||||
organizationId: null,
|
||||
folderId: cipher.folderId,
|
||||
type: cipher.type,
|
||||
name: cipher.name,
|
||||
notes: cipher.notes,
|
||||
favorite: cipher.favorite,
|
||||
login: cipher.login,
|
||||
card: cipher.card,
|
||||
identity: cipher.identity,
|
||||
secureNote: cipher.secureNote,
|
||||
fields: cipher.fields,
|
||||
passwordHistory: cipher.passwordHistory,
|
||||
reprompt: cipher.reprompt,
|
||||
organizationUseTotp: false,
|
||||
creationDate: cipher.createdAt,
|
||||
revisionDate: cipher.updatedAt,
|
||||
deletedDate: cipher.deletedAt,
|
||||
edit: true,
|
||||
viewPassword: true,
|
||||
permissions: {
|
||||
delete: true,
|
||||
restore: true,
|
||||
edit: true,
|
||||
},
|
||||
object: 'cipher',
|
||||
collectionIds: [],
|
||||
attachments: formatAttachments(attachments),
|
||||
});
|
||||
};
|
||||
|
||||
// Build folder responses
|
||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
}));
|
||||
|
||||
const syncResponse: SyncResponse = {
|
||||
profile: profile,
|
||||
folders: folderResponses,
|
||||
collections: [],
|
||||
ciphers: cipherResponses,
|
||||
domains: {
|
||||
equivalentDomains: [],
|
||||
globalEquivalentDomains: [],
|
||||
object: 'domains',
|
||||
},
|
||||
policies: [],
|
||||
sends: [],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
return jsonResponse(syncResponse);
|
||||
}
|
||||
Reference in New Issue
Block a user