Compare commits

...
16 Commits
Author SHA1 Message Date
shuaiplus 94b5f3e975 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-05 15:17:00 +08:00
shuaiplus 062c966e14 feat: update styles for two-step providers and responsive layout adjustments 2026-07-05 15:16:56 +08:00
shuaiplus e73ae3d5ea feat: enhance two-factor authentication handling and UI improvements 2026-07-05 15:05:53 +08:00
shuaiplus c019c93726 feat: add passkey-based two-factor authentication 2026-07-05 14:51:11 +08:00
shuaiplus f63b745d05 feat: Add YubiKey OTP support and management features
- Implemented YubiKey OTP settings management in useAccountSecurityActions hook.
- Added API functions for retrieving, saving, and bootstrapping YubiKey OTP credentials.
- Enhanced authentication flow to support multiple two-factor providers, including YubiKey.
- Updated localization files to include new YubiKey-related strings in English, Spanish, Russian, and Chinese.
- Introduced new styles for YubiKey management UI components.
- Created utility functions for YubiKey OTP validation and credential handling.
2026-07-04 02:49:46 +08:00
shuaiplus c7eb6c663d feat(i18n): add new localization strings for settings and two-step login across multiple languages
style: adjust grid layout for app main and add responsive styles for settings category

style: enhance management styles with new settings category layout and tabs

style: improve responsive design for settings modules and submodules
2026-07-03 19:19:24 +08:00
rootphantomerandShuai 1ec6ed44a1 fix: reject plaintext FIDO2, SSH keys, and password history on import
Validate encrypted-string fields in validateCipherEncryptedFieldsForCompatibility
before they reach storage:

- FIDO2 credentials (12 fields: 8 required + 4 optional)
- SSH key (privateKey, publicKey, keyFingerprint/fingerprint)
- Password history (password per entry)

This closes a defense gap where plaintext in these positions was silently
accepted on import and later discarded at response time.
2026-07-02 17:36:06 +08:00
shuaiplus 6284c632de Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-02 17:23:47 +08:00
shuaiplus 60dd298dee fix(security): harden jwt config and password rotation 2026-07-02 17:20:51 +08:00
shuaiplus 439683d350 fix(identity): add security stamp and invalidate user cache on token handling 2026-07-02 16:57:24 +08:00
shuaiplus 1545881eae fix(auth): hash stored api keys 2026-07-02 16:27:20 +08:00
shuaiplus 680e287c8d fix(ci): validate global domains sync ref 2026-07-02 16:11:19 +08:00
shuaiplus baf569983d fix(security): scope storage reads by user 2026-07-02 16:03:49 +08:00
Matt Van HornandShuai 73bbe8b268 perf: throttle jsQR camera fallback to a few decodes per second 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai d024798548 fix: composite transparent QR uploads over white before jsQR decode 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai b0a679b1c2 fix: decode uploaded TOTP QR images when BarcodeDetector is unavailable
The TOTP QR reader relied solely on window.BarcodeDetector. On desktop
Chrome/Edge (Windows/Linux) that interface exists but has no working
backend, so detect() returns an empty array: uploading a valid QR image
fell through to "no QR code found" and the camera path bailed to
"unsupported" with an empty preview.

Add a dependency-free jsQR canvas fallback. decodeTotpQrImage now tries
BarcodeDetector first when present, then decodes the image via jsQR
before reporting not-found. The camera reader no longer hard-returns
"unsupported" when only BarcodeDetector is missing: it starts the camera
whenever getUserMedia is available and decodes frames with jsQR, which
also lets the preview render.

Fixes #276
2026-07-01 17:07:30 +08:00
63 changed files with 4186 additions and 739 deletions
-5
View File
@@ -1,5 +0,0 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+10 -1
View File
@@ -26,7 +26,16 @@ jobs:
node-version: 22 node-version: 22
- name: Sync generated Bitwarden domains - name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}" env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched - name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json run: git diff --exit-code -- src/static/global_domains.custom.json
-151
View File
@@ -1,151 +0,0 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
{
echo "mode=auto"
echo "latest_tag=$LATEST_TAG"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
{
echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
{
echo "mode=manual"
echo "target_sha=$TARGET_SHA"
} >> "$GITHUB_OUTPUT"
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> "$GITHUB_OUTPUT"
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> "$GITHUB_OUTPUT"
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout 'HEAD@{1}' -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
{
echo "### Synced successfully"
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}"
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}"
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`"
} >> "$GITHUB_STEP_SUMMARY"
else
echo "### Nothing to update" >> "$GITHUB_STEP_SUMMARY"
fi
+1
View File
@@ -60,6 +60,7 @@ NodeWarden-compat/
# Compatibility analysis documents # Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json .mcp.json
opencode.jsonc opencode.jsonc
.cursor/ .cursor/
+1
View File
@@ -241,6 +241,7 @@ CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
purpose TEXT NOT NULL DEFAULT 'login',
name TEXT NOT NULL, name TEXT NOT NULL,
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
credential_id TEXT NOT NULL, credential_id TEXT NOT NULL,
+7
View File
@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.22.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
@@ -3442,6 +3443,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+1
View File
@@ -67,6 +67,7 @@
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.22.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
+306 -1
View File
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey'; import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
import { import {
accountPasskeyCredentialToResponse, accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus, accountPasskeyPrfStatus,
@@ -29,8 +29,10 @@ import {
verifyAccountPasskeyToken, verifyAccountPasskeyToken,
} from '../utils/account-passkeys'; } from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { createRecoveryCode } from '../utils/recovery-code';
const MAX_ACCOUNT_PASSKEYS = 5; const MAX_ACCOUNT_PASSKEYS = 5;
const MAX_TWO_FACTOR_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> { function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {}; return body && typeof body === 'object' ? body as Record<string, any> : {};
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey); return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
} }
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
return {
Enabled: credentials.length > 0,
enabled: credentials.length > 0,
Keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
Object: 'twoFactorWebAuthn',
object: 'twoFactorWebAuthn',
};
}
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readPrfKeySet(body: Record<string, any>): { function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null; encryptedUserKey: string | null;
encryptedPublicKey: string | null; encryptedPublicKey: string | null;
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
if (payload.userId && credential.userId !== payload.userId) { if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user'); throw new Error('Passkey does not belong to this user');
} }
if (credential.purpose !== 'login') {
throw new Error('Passkey is not registered for login');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle); const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId; const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
}); });
} }
export async function buildTwoFactorPasskeyAssertionOptions(
request: Request,
env: Env,
storage: StorageService,
user: User
): Promise<Record<string, unknown> | null> {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (!credentials.length) return null;
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'discouraged',
timeout: 60000,
});
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
return options as unknown as Record<string, unknown>;
}
export async function assertTwoFactorPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
user: User,
deviceResponse: unknown
): Promise<AccountPasskeyCredential> {
const response = normalizeAuthenticationResponse(deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
throw new Error('Passkey is not registered for two-step login');
}
const challenge = readAuthenticationChallenge(response);
if (!challenge) {
throw new Error('Passkey assertion challenge is missing');
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorAuthentication',
user.id,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: false,
});
if (!verification.verified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return credential;
}
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'discouraged',
requireResidentKey: false,
userVerification: 'discouraged',
},
});
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
return jsonResponse(options);
}
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const challenge = readRegistrationChallenge(registrationResponse);
if (!challenge) {
return errorResponse('Passkey challenge is missing', 400);
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorCreate',
userId,
Date.now()
);
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserPresence: true,
requireUserVerification: false,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const transports = normalizeTransports(registrationResponse.response.transports);
await storage.saveAccountPasskeyCredential({
id: generateUUID(),
userId,
purpose: 'twoFactor',
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
supportsPrf: false,
createdAt: now,
updatedAt: now,
});
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = now;
await storage.saveUser(user);
}
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.enable',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: null,
metadata: auditRequestMetadata(request),
});
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const requestedId = Number(body.id ?? body.Id);
if (!Number.isInteger(requestedId) || requestedId <= 0) {
return errorResponse('Invalid key id', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length < 2) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const credential = credentials[requestedId - 1];
if (!credential) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.delete',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> { export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400); if (!body) return errorResponse('Invalid request payload', 400);
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
const credential: AccountPasskeyCredential = { const credential: AccountPasskeyCredential = {
id: generateUUID(), id: generateUUID(),
userId, userId,
purpose: 'login',
name: normalizeAccountPasskeyName(body.name), name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey), publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id, credentialId: verification.registrationInfo.credential.id,
+265 -20
View File
@@ -1,4 +1,4 @@
import { Env, User, DEFAULT_DEV_SECRET } from '../types'; import { Env, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -6,14 +6,20 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { hashApiKey } from '../utils/api-key';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response'; import { buildProfileResponse } from '../utils/profile-response';
import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// CONTRACT: // CONTRACT:
// users.master_password_hash is server-side login verification only. It does // users.master_password_hash is server-side login verification only. It does
@@ -149,10 +155,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -193,6 +198,31 @@ function readNestedNumber(source: unknown, path: string[]): number | undefined {
return typeof current === 'number' ? current : undefined; return typeof current === 'number' ? current : undefined;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
}
async function ensureStoredYubicoCredentials(
storage: StorageService,
env: Env,
email: string,
otp: string
): Promise<YubicoApiCredentials | null> {
const existing = await getStoredYubicoCredentials(storage, env);
if (existing) return existing;
const credentials = await requestYubicoApiCredentials(email, otp);
if (!credentials) return null;
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
return credentials;
}
async function readRequestBody(request: Request): Promise<Record<string, unknown>> { async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
@@ -241,8 +271,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
? 'JWT_SECRET is not set' ? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters'; : 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400); return errorResponse(message, 400);
} }
@@ -324,6 +352,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
verifyDevices: true, verifyDevices: true,
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null,
yubikeyKey2: null,
yubikeyKey3: null,
yubikeyKey4: null,
yubikeyKey5: null,
yubikeyNfc: false,
apiKey: null, apiKey: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -764,6 +798,29 @@ function twoFactorAuthenticatorResponse(
}; };
} }
function yubiKeyResponse(user: User): Record<string, unknown> {
return {
Enabled: isYubiKeyEnabled(user),
Key1: user.yubikeyKey1,
Key2: user.yubikeyKey2,
Key3: user.yubikeyKey3,
Key4: user.yubikeyKey4,
Key5: user.yubikeyKey5,
Nfc: !!user.yubikeyNfc,
Object: 'twoFactorYubiKey',
};
}
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env);
return {
...yubiKeyResponse(user),
YubicoConfigured: !!credentials?.clientId,
YubicoClientId: credentials?.clientId ?? '',
YubicoSecretKey: credentials?.secretKey ?? '',
};
}
// GET /api/two-factor // GET /api/two-factor
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
@@ -771,9 +828,11 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
const data = user.totpSecret const data = [];
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)] if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true));
: []; if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true));
return jsonResponse({ return jsonResponse({
Data: data, Data: data,
@@ -805,6 +864,27 @@ export async function handleGetTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken)); return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
} }
// POST /api/two-factor/get-yubikey
export async function handleGetTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// PUT/POST /api/two-factor/authenticator // PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -828,7 +908,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400); if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400); const matchedCounter = await findMatchingTotpCounter(key, token);
if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid token.', 400);
}
user.totpSecret = key; user.totpSecret = key;
if (!user.totpRecoveryCode) { if (!user.totpRecoveryCode) {
@@ -851,6 +934,141 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env
return jsonResponse(twoFactorAuthenticatorResponse(true, key)); return jsonResponse(twoFactorAuthenticatorResponse(true, key));
} }
// PUT/POST /api/two-factor/yubikey
export async function handlePutTwoFactorYubiKey(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const keys = [
readBodyString(body, ['key1', 'Key1']),
readBodyString(body, ['key2', 'Key2']),
readBodyString(body, ['key3', 'Key3']),
readBodyString(body, ['key4', 'Key4']),
readBodyString(body, ['key5', 'Key5']),
];
const publicIds: Array<string | null> = [];
let credentials = await getStoredYubicoCredentials(storage, env);
let apiKeyBootstrapOtpIndex: number | null = null;
for (const key of keys) {
const trimmed = key.trim();
if (!trimmed) {
publicIds.push(null);
continue;
}
const publicId = yubiKeyPublicIdFromOtp(trimmed);
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
if (isYubiKeyPublicId(trimmed)) {
publicIds.push(publicId);
continue;
}
if (!credentials) {
credentials = await ensureStoredYubicoCredentials(storage, env, user.email, trimmed);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
apiKeyBootstrapOtpIndex = publicIds.length;
}
if (apiKeyBootstrapOtpIndex !== publicIds.length && !await verifyYubicoOtp(env, trimmed, credentials)) {
return errorResponse('Invalid YubiKey OTP.', 400);
}
publicIds.push(publicId);
}
if (!publicIds.some(Boolean)) return errorResponse('At least one YubiKey OTP is required.', 400);
user.yubikeyKey1 = publicIds[0] ?? null;
user.yubikeyKey2 = publicIds[1] ?? null;
user.yubikeyKey3 = publicIds[2] ?? null;
user.yubikeyKey4 = publicIds[3] ?? null;
user.yubikeyKey5 = publicIds[4] ?? null;
user.yubikeyNfc = !!(body.nfc ?? body.Nfc);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.yubikey.enable',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// PUT/POST /api/two-factor/yubikey/config
export async function handlePutTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const clientId = readBodyString(body, ['yubicoClientId', 'YubicoClientId', 'clientId', 'ClientId']).trim();
const secretKey = readBodyString(body, ['yubicoSecretKey', 'YubicoSecretKey', 'secretKey', 'SecretKey']).trim();
if (!clientId) return errorResponse('Yubico Client ID is required.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, secretKey);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/yubikey/bootstrap
export async function handleBootstrapTwoFactorYubiKeyConfig(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
const otp = readBodyString(body, ['otp', 'OTP', 'token', 'Token']).trim();
if (!yubiKeyPublicIdFromOtp(otp)) return errorResponse('Invalid YubiKey OTP.', 400);
const credentials = await requestYubicoApiCredentials(user.email, otp);
if (!credentials) return errorResponse('Unable to initialize Yubico validation credentials.', 400);
await storage.setConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY, credentials.clientId);
await storage.setConfigValue(YUBICO_KEY_CONFIG_KEY, credentials.secretKey);
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable // DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -867,7 +1085,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR; const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10); const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) { if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
return errorResponse('Two-factor provider is not supported by this server.', 400); return errorResponse('Two-factor provider is not supported by this server.', 400);
} }
@@ -883,14 +1101,32 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
} }
if (!verified) return errorResponse('User verification failed.', 400); if (!verified) return errorResponse('User verification failed.', 400);
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
user.totpSecret = null; user.totpSecret = null;
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
} else {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of credentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
}
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id); AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.totp.disable', action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
? 'account.totp.disable'
: type === TWO_FACTOR_PROVIDER_YUBIKEY
? 'account.yubikey.disable'
: 'account.webauthn_2fa.disable',
category: 'security', category: 'security',
level: 'security', level: 'security',
targetType: 'user', targetType: 'user',
@@ -898,7 +1134,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false)); return jsonResponse(twoFactorProviderResponse(type, false));
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
@@ -943,8 +1179,8 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (!verifiedUser) { if (!verifiedUser) {
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
const verified = await verifyTotpToken(normalizedSecret, body.token); const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
if (!verified) { if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
} }
user.totpSecret = normalizedSecret; user.totpSecret = normalizedSecret;
@@ -1092,6 +1328,16 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
} }
user.totpSecret = null; user.totpSecret = null;
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of webAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
@@ -1194,9 +1440,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) { // Only the fresh secret is returned once; the database stores a hash.
// Upstream apikeys are 30-character random alphanumeric strings const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = await hashApiKey(plainApiKey);
if (rotate) { if (rotate) {
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
@@ -1213,10 +1459,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
targetId: user.id, targetId: user.id,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
}
return jsonResponse({ return jsonResponse({
apiKey: user.apiKey, apiKey: plainApiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+1 -1
View File
@@ -76,7 +76,7 @@ export async function handleAdminListUsers(
name: user.name, name: user.name,
role: user.role, role: user.role,
status: user.status, status: user.status,
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5),
creationDate: user.createdAt, creationDate: user.createdAt,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'user', object: 'user',
+19 -21
View File
@@ -1,4 +1,4 @@
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher } from '../types';
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
@@ -167,7 +167,7 @@ export async function handleCreateAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
@@ -205,7 +205,7 @@ export async function handleCreateAttachment(
await storage.saveAttachment(attachment); await storage.saveAttachment(attachment);
// Add attachment to cipher // Add attachment to cipher
await storage.addAttachmentToCipher(cipherId, attachmentId); await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -215,7 +215,7 @@ export async function handleCreateAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
@@ -244,13 +244,13 @@ export async function handleUploadAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -283,12 +283,12 @@ export async function handlePublicUploadAttachment(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, claims.userId);
if (!cipher || cipher.userId !== claims.userId) { if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -308,13 +308,13 @@ export async function handleGetAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -349,12 +349,12 @@ export async function handleUpdateAttachmentMetadata(
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -405,10 +405,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const secret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret) return errorResponse('Server configuration error', 500);
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -418,7 +416,7 @@ export async function handlePublicDownloadAttachment(
} }
// Verify token // Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); const claims = await verifyFileDownloadToken(token, secret);
if (!claims) { if (!claims) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
@@ -471,13 +469,13 @@ export async function handleDeleteAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -486,7 +484,7 @@ export async function handleDeleteAttachment(
await deleteBlobObject(env, path); await deleteBlobObject(env, path);
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachmentForUser(attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -501,7 +499,7 @@ export async function handleDeleteAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments); const cipherResponse = cipherToResponse(updatedCipher!, attachments);
+3 -3
View File
@@ -201,7 +201,7 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404); if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest)); return jsonResponse(toAuthRequestResponse(request, authRequest));
} }
@@ -239,7 +239,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400); if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) { if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404); return errorResponse('Not found', 404);
} }
@@ -275,7 +275,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
masterPasswordHash, masterPasswordHash,
}); });
if (!updated) return errorResponse('Auth request has already been answered.', 409); if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id); const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous // Match Bitwarden upstream behavior: only approval wakes the originating anonymous
// client. Denials are not pushed to avoid leaking that a login attempt was rejected. // client. Denials are not pushed to avoid leaking that a login attempt was rejected.
if (approved) { if (approved) {
+39 -11
View File
@@ -354,6 +354,34 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.'; if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
} }
} }
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
if (Array.isArray(login.fido2Credentials)) {
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
for (const cred of login.fido2Credentials) {
if (!cred || typeof cred !== 'object') continue;
for (const key of fido2EncryptedKeys) {
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
}
}
}
}
// Validate SSH key fields — all three must be encrypted strings.
const sshKey = cipher.sshKey as any;
if (sshKey && typeof sshKey === 'object') {
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
if (!entry || typeof entry !== 'object') continue;
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
}
} }
return null; return null;
@@ -812,7 +840,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// GET /api/ciphers/:id // GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -827,8 +855,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> { async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true; if (!folderId) return true;
const folder = await storage.getFolder(folderId); const folder = await storage.getFolderForUser(folderId, userId);
return !!(folder && folder.userId === userId); return !!folder;
} }
// POST /api/ciphers // POST /api/ciphers
@@ -909,7 +937,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id // PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id); const existingCipher = await storage.getCipherForUser(id, userId);
if (!existingCipher || existingCipher.userId !== userId) { if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1020,7 +1048,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id // DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1052,7 +1080,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// - If item is already soft-deleted -> hard delete. // - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1079,7 +1107,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1104,7 +1132,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore // PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1126,7 +1154,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// PUT /api/ciphers/:id/partial - Update only favorite/folderId // PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1218,7 +1246,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
// PUT/POST /api/ciphers/:id/archive // PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1244,7 +1272,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
// PUT/POST /api/ciphers/:id/unarchive // PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
+5 -5
View File
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id // GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id // PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id // DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const folders = ( const folders = (
await Promise.all(ids.map(async (id) => { await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
return folder && folder.userId === userId ? folder : null; return folder;
})) }))
).filter((folder): folder is Folder => !!folder); ).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
+87 -23
View File
@@ -1,4 +1,4 @@
import { Env, TokenResponse } from '../types'; import { Env, TokenResponse, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -18,16 +18,24 @@ import {
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { import {
assertAccountPasskeyCredential, assertAccountPasskeyCredential,
assertTwoFactorPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
buildTwoFactorPasskeyAssertionOptions,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
import { isYubiKeyEnabled, userYubiKeyPublicIds, verifyYubicoOtp, yubicoCredentialsFromEnv, yubiKeyPublicIdFromOtp, type YubicoApiCredentials } from '../utils/yubico-otp';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8; const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh'; const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
const YUBICO_KEY_CONFIG_KEY = 'globalSettings__yubico__key';
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows // Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains // the official Identity provider enum (RecoveryCode = 8), while request parsing remains
// compatible with older/local provider values. // compatible with older/local provider values.
@@ -106,18 +114,6 @@ function parseCookieValue(request: Request, name: string): string | null {
return null; return null;
} }
function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined { function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const value = body[name]; const value = body[name];
@@ -126,6 +122,15 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
return undefined; return undefined;
} }
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
const clientId = String(await storage.getConfigValue(YUBICO_CLIENT_ID_CONFIG_KEY) || '').trim();
if (!clientId) return null;
const secretKey = String(await storage.getConfigValue(YUBICO_KEY_CONFIG_KEY) || '').trim();
return { clientId, secretKey };
}
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string { function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
const isHttps = new URL(request.url).protocol === 'https:'; const isHttps = new URL(request.url).protocol === 'https:';
const parts = [ const parts = [
@@ -194,13 +199,32 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
}; };
} }
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { async function twoFactorRequiredResponse(
request: Request,
env: Env,
storage: StorageService,
user?: User,
message: string = 'Two factor required.'
): Promise<Response> {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to // Clients expose recovery-code entry points themselves; Android 2026.4 fails to
// parse the challenge if an unknown recovery provider key such as "8" is included. // parse the challenge if an unknown recovery provider key such as "8" is included.
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]; const providers: string[] = [];
const providers2: Record<string, { Email: null }> = {}; let webAuthnOptions: Record<string, unknown> | null = null;
for (const provider of providers) providers2[provider] = { Email: null }; if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
if (user) {
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
}
const providers2: Record<string, Record<string, unknown> | null> = {};
for (const provider of providers) {
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
? { Nfc: user?.yubikeyNfc ?? false }
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
? webAuthnOptions
: null;
}
const customResponse = { const customResponse = {
TwoFactorProviders: providers, TwoFactorProviders: providers,
TwoFactorProviders2: providers2, TwoFactorProviders2: providers2,
@@ -341,7 +365,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId); const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
valid = !!( valid = !!(
authRequest && authRequest &&
authRequest.userId === user.id && authRequest.userId === user.id &&
@@ -381,10 +405,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
// Optional 2FA: enabled only by per-user secret. // Optional 2FA: enabled by any supported per-user provider.
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) { const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -394,7 +420,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload. // respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) { if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.'); return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -409,9 +435,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse('Two factor required.'); return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
if (!effectiveTotpSecret) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (matchedCounter == null) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
@@ -420,6 +449,30 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!consumed) { if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_YUBIKEY)) {
const publicId = yubiKeyPublicIdFromOtp(normalizedTwoFactorToken);
if (!publicId || !effectiveYubiKeyPublicIds.includes(publicId)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const credentials = await getStoredYubicoCredentials(storage, env);
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
if (!effectiveWebAuthnCredentials.length) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
let deviceResponse: unknown;
try {
deviceResponse = JSON.parse(normalizedTwoFactorToken);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
try {
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if ( } else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE || normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) || normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
@@ -429,10 +482,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
user.totpSecret = null; user.totpSecret = null;
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
for (const credential of effectiveWebAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
rememberRequested = false; rememberRequested = false;
} else { } else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt. // Unsupported provider for this server profile behaves as an invalid 2FA attempt.
@@ -688,7 +752,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
+8 -8
View File
@@ -134,7 +134,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
@@ -401,7 +401,7 @@ export async function handleGetSendFileUpload(
): Promise<Response> { ): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -436,7 +436,7 @@ export async function handleUploadSendFile(
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -472,7 +472,7 @@ export async function handlePublicUploadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, claims.userId);
if (!send || send.userId !== claims.userId) { if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -485,7 +485,7 @@ export async function handlePublicUploadSendFile(
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -632,7 +632,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -698,7 +698,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -719,7 +719,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
+3 -5
View File
@@ -3,7 +3,6 @@ import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type'; import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
createSendFileDownloadToken, createSendFileDownloadToken,
@@ -113,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string, idOrAccessId: string,
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const safeSecret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { if (!safeSecret.ok) return safeSecret.response;
return errorResponse('Server configuration error', 500); const { secret } = safeSecret;
}
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
+2 -2
View File
@@ -1,4 +1,4 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import { import {
notifyUserSendCreate, notifyUserSendCreate,
notifyUserSendDelete, notifyUserSendDelete,
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return { ok: false, response: errorResponse('Server configuration error', 500) }; return { ok: false, response: errorResponse('Server configuration error', 500) };
} }
return { ok: true, secret }; return { ok: true, secret };
+40
View File
@@ -15,6 +15,10 @@ import {
handleGetTwoFactorProviders, handleGetTwoFactorProviders,
handleGetTwoFactorAuthenticator, handleGetTwoFactorAuthenticator,
handlePutTwoFactorAuthenticator, handlePutTwoFactorAuthenticator,
handleGetTwoFactorYubiKey,
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleDisableTwoFactorProvider, handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
@@ -74,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import { import {
handleCreateAccountPasskeyCredential, handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential, handleDeleteAccountPasskeyCredential,
handleDeleteTwoFactorWebAuthn,
handleGetAccountPasskeyAttestationOptions, handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials, handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions, handleGetAccountPasskeyUpdateAssertionOptions,
handleGetTwoFactorWebAuthn,
handleGetTwoFactorWebAuthnChallenge,
handlePutTwoFactorWebAuthn,
handleUpdateAccountPasskeyEncryption, handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys'; } from './handlers/account-passkeys';
import { import {
@@ -141,12 +149,44 @@ export async function handleAuthenticatedRoute(
return handleGetTwoFactorAuthenticator(request, env, userId); return handleGetTwoFactorAuthenticator(request, env, userId);
} }
if ((path === '/api/two-factor/get-yubikey' || path === '/api/two-factor/get-yubi-key') && method === 'POST') {
return handleGetTwoFactorYubiKey(request, env, userId);
}
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
}
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
}
if (path === '/api/two-factor/authenticator') { if (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId); if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId); if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405); return errorResponse('Method not allowed', 405);
} }
if ((path === '/api/two-factor/yubikey' || path === '/api/two-factor/yubi-key')) {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorYubiKey(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/webauthn') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
}
if ((path === '/api/two-factor/yubikey/bootstrap' || path === '/api/two-factor/yubi-key/bootstrap') && method === 'POST') {
return handleBootstrapTwoFactorYubiKeyConfig(request, env, userId);
}
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) { if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
return handleDisableTwoFactorProvider(request, env, userId); return handleDisableTwoFactorProvider(request, env, userId);
} }
+1 -4
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import { import {
handleAccessSend, handleAccessSend,
handleAccessSendFile, handleAccessSendFile,
@@ -34,7 +33,7 @@ import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -308,8 +307,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
? 'missing' ? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength : secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short' ? 'too_short'
: null; : null;
+17 -7
View File
@@ -1,4 +1,4 @@
import { DEFAULT_DEV_SECRET, Env } from './types'; import { Env } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response'; import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated'; import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public'; import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean { function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false; if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -85,14 +95,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env); const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) { if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const auth = new AuthService(env); const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader); const verified = await auth.verifyAccessTokenWithUser(authHeader);
+1 -1
View File
@@ -427,7 +427,7 @@ export async function buildBackupArchive(
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([ const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, created_at, updated_at FROM users ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
+2 -1
View File
@@ -297,6 +297,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 1,
yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []), user_revisions: cloneRows(payload.user_revisions || []),
@@ -619,7 +620,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements( buildInsertStatements(
db, db,
tableName('users'), tableName('users'),
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'yubikey_key1', 'yubikey_key2', 'yubikey_key3', 'yubikey_key4', 'yubikey_key5', 'yubikey_nfc', 'created_at', 'updated_at'],
payload.users || [] payload.users || []
) )
); );
+20 -13
View File
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [ const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
{ name: 'id', sql: 'id TEXT' }, { name: 'id', sql: 'id TEXT' },
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" }, { name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" }, { name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" }, { name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" }, { name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
await db await db
.prepare( .prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + "id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)' 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
function mapCredentialRow(row: { function mapCredentialRow(row: {
id: string; id: string;
user_id: string; user_id: string;
purpose?: string | null;
name: string; name: string;
public_key: string; public_key: string;
credential_id: string; credential_id: string;
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
return { return {
id: row.id, id: row.id,
userId: row.user_id, userId: row.user_id,
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
name: row.name, name: row.name,
publicKey: row.public_key, publicKey: row.public_key,
credentialId: row.credential_id, credentialId: row.credential_id,
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
await safeBind( await safeBind(
db.prepare( db.prepare(
'INSERT INTO webauthn_credentials(' + 'INSERT INTO webauthn_credentials(' +
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' + 'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' + 'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' + 'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' + 'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at' 'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
), ),
credential.id, credential.id,
credential.userId, credential.userId,
credential.purpose,
credential.name, credential.name,
credential.publicKey, credential.publicKey,
credential.credentialId, credential.credentialId,
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
export async function listAccountPasskeyCredentialsByUserId( export async function listAccountPasskeyCredentialsByUserId(
db: D1Database, db: D1Database,
userId: string userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> { ): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const rows = await db const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC') .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
.bind(userId) .bind(userId, purpose)
.all<any>(); .all<any>();
return (rows.results || []).map(mapCredentialRow); return (rows.results || []).map(mapCredentialRow);
} }
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
export async function countAccountPasskeyCredentialsByUserId( export async function countAccountPasskeyCredentialsByUserId(
db: D1Database, db: D1Database,
userId: string userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> { ): Promise<number> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const row = await db const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?') .prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
.bind(userId) .bind(userId, purpose)
.first<{ count: number }>(); .first<{ count: number }>();
return Number(row?.count || 0); return Number(row?.count || 0);
} }
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
export async function deleteAccountPasskeyCredential( export async function deleteAccountPasskeyCredential(
db: D1Database, db: D1Database,
userId: string, userId: string,
id: string id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> { ): Promise<boolean> {
await ensureAccountPasskeySchema(db); await ensureAccountPasskeySchema(db);
const result = await db const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?') .prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
.bind(userId, id) .bind(userId, id, purpose)
.run(); .run();
return Number(result.meta.changes || 0) > 0; return Number(result.meta.changes || 0) > 0;
} }
+64 -1
View File
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
}; };
} }
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
const row = await db
.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 a.id = ? AND c.user_id = ?`
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
}
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> { export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key ' +
'WHERE EXISTS (' +
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
')'
); );
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
} }
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
} }
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
await db
.prepare(
`DELETE FROM attachments
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers c
WHERE c.id = attachments.cipher_id AND c.user_id = ?
)`
)
.bind(id, userId)
.run();
}
export async function bulkDeleteAttachmentsByIds( export async function bulkDeleteAttachmentsByIds(
db: D1Database, db: D1Database,
sqlChunkSize: SqlChunkSize, sqlChunkSize: SqlChunkSize,
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
} }
export async function addAttachmentToCipherForUser(
db: D1Database,
cipherId: string,
attachmentId: string,
userId: string
): Promise<void> {
await db
.prepare(
`UPDATE attachments
SET cipher_id = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers target_cipher
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
)
AND EXISTS (
SELECT 1 FROM ciphers current_cipher
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
)`
)
.bind(cipherId, attachmentId, cipherId, userId, userId)
.run();
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
} }
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
return row ? mapAuthRequestRow(row) : null; return row ? mapAuthRequestRow(row) : null;
} }
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> { export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>(); const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
return (res.results || []).map(mapAuthRequestRow); return (res.results || []).map(mapAuthRequestRow);
+10 -1
View File
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
return parseCipherRow(row); return parseCipherRow(row);
} }
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
.bind(id, userId)
.first<CipherRow>();
return parseCipherRow(row);
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId); const folderId = normalizeOptionalId(cipher.folderId);
const data = buildCipherData(cipher, folderId); const data = buildCipherData(cipher, folderId);
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at' '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, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
stmt, stmt,
+10 -1
View File
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
return mapFolderRow(row); return mapFolderRow(row);
} }
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapFolderRow(row);
}
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> { export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + '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' 'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
) )
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run(); .run();
+9 -2
View File
@@ -14,13 +14,19 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER 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, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key2 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key3 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key4 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key5 TEXT',
'ALTER TABLE users ADD COLUMN yubikey_nfc INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN api_key TEXT', 'ALTER TABLE users ADD COLUMN api_key TEXT',
'CREATE TABLE IF NOT EXISTS domain_settings (' + 'CREATE TABLE IF NOT EXISTS domain_settings (' +
@@ -134,10 +140,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)', 'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)', 'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
+14 -2
View File
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
return mapSendRow(row); return mapSendRow(row);
} }
export async function getSendForUser(db: D1Database, id: string, userId: string): Promise<Send | null> {
const row = await db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ? AND user_id = ?'
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapSendRow(row);
}
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> { export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' + 'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' + 'type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' + 'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' + 'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date' 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
+24 -6
View File
@@ -4,7 +4,7 @@ type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedState
const USER_SELECT_COLUMNS = const USER_SELECT_COLUMNS =
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' + 'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' + 'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
'totp_secret, totp_recovery_code, api_key, created_at, updated_at'; 'totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at';
function mapUserRow(row: any): User { function mapUserRow(row: any): User {
return { return {
@@ -26,6 +26,12 @@ function mapUserRow(row: any): User {
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null,
yubikeyKey2: row.yubikey_key2 ?? null,
yubikeyKey3: row.yubikey_key3 ?? null,
yubikeyKey4: row.yubikey_key4 ?? null,
yubikeyKey5: row.yubikey_key5 ?? null,
yubikeyNfc: !!row.yubikey_nfc,
apiKey: row.api_key ?? null, apiKey: row.api_key ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
@@ -65,11 +71,11 @@ export async function getAllUsers(db: D1Database): Promise<User[]> {
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, api_key=excluded.api_key, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, yubikey_key1=excluded.yubikey_key1, yubikey_key2=excluded.yubikey_key2, yubikey_key3=excluded.yubikey_key3, yubikey_key4=excluded.yubikey_key4, yubikey_key5=excluded.yubikey_key5, yubikey_nfc=excluded.yubikey_nfc, api_key=excluded.api_key, updated_at=excluded.updated_at'
); );
await safeBind( await safeBind(
stmt, stmt,
@@ -91,6 +97,12 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User):
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
@@ -104,8 +116,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User)
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> { export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, api_key, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, yubikey_key1, yubikey_key2, yubikey_key3, yubikey_key4, yubikey_key5, yubikey_nfc, api_key, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await safeBind( const result = await safeBind(
@@ -128,6 +140,12 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user:
user.verifyDevices ? 1 : 0, user.verifyDevices ? 1 : 0,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode, user.totpRecoveryCode,
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
user.yubikeyNfc ? 1 : 0,
user.apiKey, user.apiKey,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
+52 -7
View File
@@ -41,6 +41,7 @@ import {
deleteFolder as deleteStoredFolder, deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders, getAllFolders as listStoredFolders,
getFolder as findStoredFolder, getFolder as findStoredFolder,
getFolderForUser as findStoredFolderForUser,
getFoldersPage as listStoredFoldersPage, getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
@@ -53,6 +54,7 @@ import {
bulkUnarchiveCiphers as unarchiveStoredCiphers, bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCipherForUser as findStoredCipherForUser,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage, getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher, saveCipher as saveStoredCipher,
@@ -60,10 +62,13 @@ import {
} from './storage-cipher-repo'; } from './storage-cipher-repo';
import { import {
addAttachmentToCipher as attachStoredAttachmentToCipher, addAttachmentToCipher as attachStoredAttachmentToCipher,
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment, deleteAttachment as deleteStoredAttachment,
deleteAttachmentForUser as deleteStoredAttachmentForUser,
getAttachment as findStoredAttachment, getAttachment as findStoredAttachment,
getAttachmentForUser as findStoredAttachmentForUser,
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
@@ -75,6 +80,7 @@ import {
deleteSend as deleteStoredSend, deleteSend as deleteStoredSend,
getAllSends as listStoredSends, getAllSends as listStoredSends,
getSend as findStoredSend, getSend as findStoredSend,
getSendForUser as findStoredSendForUser,
getSendsByIds as listStoredSendsByIds, getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage, getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount, incrementSendAccessCount as incrementStoredSendAccessCount,
@@ -114,6 +120,7 @@ import {
import { import {
createAuthRequest as createStoredAuthRequest, createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById, getAuthRequestById as findStoredAuthRequestById,
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId, listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId, listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated, markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
@@ -154,7 +161,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay'; const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
@@ -391,8 +398,11 @@ export class StorageService {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential); await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
} }
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> { async getAccountPasskeyCredentialsByUserId(
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId); userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
} }
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> { async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
@@ -403,8 +413,11 @@ export class StorageService {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId); return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
} }
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> { async countAccountPasskeyCredentialsByUserId(
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId); userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
} }
async updateAccountPasskeyCounter( async updateAccountPasskeyCounter(
@@ -435,8 +448,12 @@ export class StorageService {
); );
} }
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> { async deleteAccountPasskeyCredential(
return deleteStoredAccountPasskeyCredential(this.db, userId, id); userId: string,
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
} }
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> { async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
@@ -458,6 +475,10 @@ export class StorageService {
return findStoredCipher(this.db, id); return findStoredCipher(this.db, id);
} }
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
return findStoredCipherForUser(this.db, id, userId);
}
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
} }
@@ -508,6 +529,10 @@ export class StorageService {
return findStoredFolder(this.db, id); return findStoredFolder(this.db, id);
} }
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
return findStoredFolderForUser(this.db, id, userId);
}
async saveFolder(folder: Folder): Promise<void> { async saveFolder(folder: Folder): Promise<void> {
await saveStoredFolder(this.db, folder); await saveStoredFolder(this.db, folder);
} }
@@ -546,6 +571,10 @@ export class StorageService {
return findStoredAttachment(this.db, id); return findStoredAttachment(this.db, id);
} }
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
return findStoredAttachmentForUser(this.db, id, userId);
}
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
} }
@@ -554,6 +583,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id); await deleteStoredAttachment(this.db, id);
} }
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
await deleteStoredAttachmentForUser(this.db, id, userId);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> { async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
} }
@@ -574,6 +607,10 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
@@ -634,6 +671,10 @@ export class StorageService {
return findStoredSend(this.db, id); return findStoredSend(this.db, id);
} }
async getSendForUser(id: string, userId: string): Promise<Send | null> {
return findStoredSendForUser(this.db, id, userId);
}
async saveSend(send: Send): Promise<void> { async saveSend(send: Send): Promise<void> {
await saveStoredSend(this.db, this.safeBind.bind(this), send); await saveStoredSend(this.db, this.safeBind.bind(this), send);
} }
@@ -783,6 +824,10 @@ export class StorageService {
return findStoredAuthRequestById(this.db, id); return findStoredAuthRequestById(this.db, id);
} }
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestByIdForUser(this.db, id, userId);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> { async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId); return listStoredAuthRequestsByUserId(this.db, userId);
} }
+20 -5
View File
@@ -14,15 +14,17 @@ export interface Env {
WEBAUTHN_RP_ID?: string; WEBAUTHN_RP_ID?: string;
WEBAUTHN_RP_NAME?: string; WEBAUTHN_RP_NAME?: string;
WEBAUTHN_ALLOWED_ORIGINS?: string; WEBAUTHN_ALLOWED_ORIGINS?: string;
YUBICO_CLIENT_ID?: string;
YUBICO_SECRET_KEY?: string;
YUBICO_VALIDATION_URLS?: string;
'globalSettings__yubico__clientId'?: string;
'globalSettings__yubico__key'?: string;
'globalSettings__yubico__validationUrls'?: string;
} }
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned'; export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
@@ -53,6 +55,12 @@ export interface User {
verifyDevices?: boolean; verifyDevices?: boolean;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null; totpRecoveryCode: string | null;
yubikeyKey1: string | null;
yubikeyKey2: string | null;
yubikeyKey3: string | null;
yubikeyKey4: string | null;
yubikeyKey5: string | null;
yubikeyNfc: boolean;
apiKey: string | null; apiKey: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -244,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential { export interface AccountPasskeyCredential {
id: string; id: string;
userId: string; userId: string;
purpose: 'login' | 'twoFactor';
name: string; name: string;
publicKey: string; publicKey: string;
credentialId: string; credentialId: string;
@@ -259,7 +268,12 @@ export interface AccountPasskeyCredential {
updatedAt: string; updatedAt: string;
} }
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet'; export type AccountPasskeyChallengeScope =
| 'Authentication'
| 'CreateCredential'
| 'UpdateKeySet'
| 'TwoFactorAuthentication'
| 'TwoFactorCreate';
export interface AccountPasskeyChallenge { export interface AccountPasskeyChallenge {
challengeHash: string; challengeHash: string;
@@ -502,6 +516,7 @@ export interface ProfileResponse {
masterPasswordHint: string | null; masterPasswordHint: string | null;
culture: string; culture: string;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
yubikeyEnabled?: boolean;
key: string; key: string;
privateKey: string | null; privateKey: string | null;
accountKeys: any | null; accountKeys: any | null;
+3 -1
View File
@@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
} }
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number { export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS; return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
} }
export async function createAccountPasskeyToken( export async function createAccountPasskeyToken(
+36
View File
@@ -0,0 +1,36 @@
const API_KEY_HASH_PREFIX = 'sha256:';
export function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
}
export async function hashApiKey(apiKey: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
}
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false;
const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types'; import { Env } from '../types';
import { errorResponse } from './response'; import { errorResponse } from './response';
export interface DirectUploadPayload { export interface DirectUploadPayload {
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null { export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return null; return null;
} }
return secret; return secret;
+3 -1
View File
@@ -1,5 +1,6 @@
import type { Env, ProfileResponse, User } from '../types'; import type { Env, ProfileResponse, User } from '../types';
import { buildAccountKeys } from './user-decryption'; import { buildAccountKeys } from './user-decryption';
import { isYubiKeyEnabled } from './yubico-otp';
export function buildProfileResponse(user: User, env?: Env): ProfileResponse { export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
void env; void env;
@@ -16,7 +17,8 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: user.masterPasswordHint, masterPasswordHint: user.masterPasswordHint,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: !!user.totpSecret, twoFactorEnabled: !!user.totpSecret || isYubiKeyEnabled(user),
yubikeyEnabled: isYubiKeyEnabled(user),
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys, accountKeys,
+190
View File
@@ -0,0 +1,190 @@
import type { Env, User } from '../types';
const YUBIKEY_PUBLIC_ID_LENGTH = 12;
const YUBIKEY_MIN_OTP_LENGTH = 32;
const YUBIKEY_MAX_OTP_LENGTH = 48;
const YUBICO_DEFAULT_VALIDATION_URL = 'https://api.yubico.com/wsapi/2.0/verify';
const YUBICO_GET_API_KEY_URL = 'https://upgrade.yubico.com/getapikey/';
const MODHEX_RE = /^[cbdefghijklnrtuv]+$/;
export interface YubicoApiCredentials {
clientId: string;
secretKey: string;
}
export function normalizeYubiKeyOtp(input: string): string {
return String(input || '').replace(/\s+/g, '').toLowerCase();
}
export function yubiKeyPublicIdFromOtp(input: string): string | null {
const otp = normalizeYubiKeyOtp(input);
if (otp.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(otp)) return otp;
if (otp.length < YUBIKEY_MIN_OTP_LENGTH || otp.length > YUBIKEY_MAX_OTP_LENGTH) return null;
if (!MODHEX_RE.test(otp)) return null;
return otp.slice(0, YUBIKEY_PUBLIC_ID_LENGTH);
}
export function isYubiKeyPublicId(input: string): boolean {
const value = normalizeYubiKeyOtp(input);
return value.length === YUBIKEY_PUBLIC_ID_LENGTH && MODHEX_RE.test(value);
}
function isYubiKeyOtp(input: string): boolean {
const otp = normalizeYubiKeyOtp(input);
return otp.length >= YUBIKEY_MIN_OTP_LENGTH && otp.length <= YUBIKEY_MAX_OTP_LENGTH && MODHEX_RE.test(otp);
}
export function userYubiKeyPublicIds(user: User): string[] {
return [
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean);
}
export function isYubiKeyEnabled(user: User): boolean {
return userYubiKeyPublicIds(user).length > 0;
}
export function yubicoCredentialsFromEnv(env: Env): YubicoApiCredentials | null {
const clientId = String(env['globalSettings__yubico__clientId'] || env.YUBICO_CLIENT_ID || '').trim();
const secretKey = String(env['globalSettings__yubico__key'] || env.YUBICO_SECRET_KEY || '').trim();
return clientId ? { clientId, secretKey } : null;
}
function randomNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
function parseYubicoResponse(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const idx = line.indexOf('=');
if (idx <= 0) continue;
out[line.slice(0, idx)] = line.slice(idx + 1);
}
return out;
}
function base64ToBytes(input: string): Uint8Array {
const binary = atob(input);
const out = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) out[index] = binary.charCodeAt(index);
return out;
}
function bytesToBase64(input: Uint8Array): string {
let binary = '';
for (const byte of input) binary += String.fromCharCode(byte);
return btoa(binary);
}
async function hmacSha1Base64(base64Key: string, message: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
base64ToBytes(base64Key),
{ name: 'HMAC', hash: 'SHA-1' },
false,
['sign']
);
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
}
function constantTimeStringEquals(a: string, b: string): boolean {
const aBytes = new TextEncoder().encode(a);
const bBytes = new TextEncoder().encode(b);
let diff = aBytes.length ^ bBytes.length;
for (let index = 0; index < aBytes.length && index < bBytes.length; index += 1) {
diff |= aBytes[index] ^ bBytes[index];
}
return diff === 0;
}
function canonicalQuery(params: URLSearchParams): string {
return Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('&');
}
function validationUrls(env: Env): string[] {
const configured = String(env['globalSettings__yubico__validationUrls'] || env.YUBICO_VALIDATION_URLS || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
return configured.length > 0 ? configured : [YUBICO_DEFAULT_VALIDATION_URL];
}
export async function requestYubicoApiCredentials(email: string, otpInput: string): Promise<YubicoApiCredentials | null> {
const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return null;
const body = new URLSearchParams();
body.set('email', String(email || '').trim().toLowerCase());
body.set('otp', otp);
body.set('terms_conditions', 'consented');
const response = await fetch(YUBICO_GET_API_KEY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) return null;
const html = await response.text();
const clientId = /Client ID:<\/th>\s*<td><b>(\d+)<\/b>/i.exec(html)?.[1] || '';
const secretKey = /Secret key:<\/th>\s*<td><code>([^<]+)<\/code>/i.exec(html)?.[1] || '';
return clientId ? { clientId, secretKey } : null;
}
export async function verifyYubicoOtp(
env: Env,
otpInput: string,
credentials: YubicoApiCredentials | null = yubicoCredentialsFromEnv(env)
): Promise<boolean> {
const otp = normalizeYubiKeyOtp(otpInput);
if (!isYubiKeyOtp(otp)) return false;
const clientId = String(credentials?.clientId || '').trim();
if (!clientId) return false;
const nonce = randomNonce();
const secretKey = String(credentials?.secretKey || '').trim();
const params = new URLSearchParams({
id: clientId,
nonce,
otp,
});
if (secretKey) {
try {
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
} catch {
return false;
}
}
for (const baseUrl of validationUrls(env)) {
try {
const response = await fetch(`${baseUrl}?${params.toString()}`, { method: 'GET' });
if (!response.ok) continue;
const parsed = parseYubicoResponse(await response.text());
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue;
if (secretKey) {
if (!parsed.h) continue;
const signedParams = new URLSearchParams();
for (const [key, value] of Object.entries(parsed)) {
if (key !== 'h') signedParams.set(key, value);
}
if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue;
}
return true;
} catch {
continue;
}
}
return false;
}
@@ -0,0 +1,346 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NodeWarden WebAuthn Connector</title>
<style>
:root {
color-scheme: light;
--primary: #2563eb;
--primary-strong: #1d4ed8;
--text: #101828;
--muted: #667085;
--line: #d8e0ec;
--panel: #ffffff;
--surface: #f6f8fb;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--surface);
color: var(--text);
}
main {
display: grid;
min-height: 100vh;
place-items: center;
padding: 28px 18px;
}
.connector-card {
width: min(100%, 430px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
box-shadow: 0 18px 44px rgba(16, 24, 40, 0.10);
padding: 28px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
}
.brand img {
width: 44px;
height: 44px;
object-fit: contain;
}
.brand strong {
font-size: 18px;
line-height: 1;
}
h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.55;
}
.form {
display: grid;
gap: 16px;
margin-top: 24px;
}
.remember {
display: flex;
align-items: center;
gap: 9px;
color: #344054;
font-size: 14px;
}
.remember input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
button {
min-height: 48px;
width: 100%;
border: 1px solid var(--primary);
border-radius: 10px;
background: var(--primary);
color: #fff;
cursor: pointer;
font: inherit;
font-weight: 800;
transition: background-color 160ms ease, border-color 160ms ease, transform 120ms ease;
}
button:hover:not(:disabled) {
background: var(--primary-strong);
border-color: var(--primary-strong);
}
button:active:not(:disabled) {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.msg {
display: none;
border-radius: 10px;
padding: 11px 12px;
font-size: 14px;
line-height: 1.45;
}
.msg.show {
display: block;
}
.msg.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
}
.msg.success {
border: 1px solid #bbf7d0;
background: #f0fdf4;
color: #166534;
}
</style>
</head>
<body>
<main>
<section class="connector-card" aria-labelledby="title">
<div class="brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden" />
<strong>NodeWarden</strong>
</div>
<h1 id="title">Verify your identity</h1>
<p id="subtitle">Use your security key to finish two-step verification.</p>
<div class="form">
<div id="msg" class="msg" role="status" aria-live="polite"></div>
<label class="remember">
<input id="remember" type="checkbox" />
<span id="remember-label">Trust this device for 30 days</span>
</label>
<button id="webauthn-button" type="button">Read security key</button>
</div>
</section>
</main>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
var titleEl = document.getElementById("title");
var subtitleEl = document.getElementById("subtitle");
var rememberEl = document.getElementById("remember");
var rememberLabelEl = document.getElementById("remember-label");
var buttonEl = document.getElementById("webauthn-button");
var msgEl = document.getElementById("msg");
titleEl.textContent = text.title;
subtitleEl.textContent = text.subtitle;
rememberLabelEl.textContent = text.remember;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
buttonEl.addEventListener("click", start);
function pickText(locale) {
var normalized = String(locale || "en").toLowerCase();
if (normalized.indexOf("zh") === 0) {
return {
title: "\u9a8c\u8bc1\u8eab\u4efd",
subtitle: "\u4f7f\u7528\u5b89\u5168\u5bc6\u94a5\u5b8c\u6210\u4e24\u6b65\u9a8c\u8bc1\u3002",
remember: "30 \u5929\u5185\u4fe1\u4efb\u6b64\u8bbe\u5907",
button: "\u8bfb\u53d6\u5b89\u5168\u5bc6\u94a5",
awaiting: "\u7b49\u5f85\u5b89\u5168\u5bc6\u94a5\u4ea4\u4e92...",
success: "\u9a8c\u8bc1\u5b8c\u6210",
unsupported: "\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u5b89\u5168\u5bc6\u94a5",
};
}
return {
title: "Verify your identity",
subtitle: "Use your security key to finish two-step verification.",
remember: "Trust this device for 30 days",
button: "Read security key",
awaiting: "Awaiting security key interaction...",
success: "Verification complete",
unsupported: "This browser does not support security keys",
};
}
function decodeRepeated(value) {
if (!value) return "";
var out = String(value);
for (var i = 0; i < 2; i += 1) {
try {
var next = decodeURIComponent(out);
if (next === out) break;
out = next;
} catch (_error) {
break;
}
}
return out;
}
function showMessage(kind, message) {
msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind;
}
function decodeBase64Unicode(value) {
var input = String(value || "").replace(/ /g, "+");
try {
return decodeURIComponent(Array.prototype.map.call(atob(input), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
} catch (_error) {
var normalized = input.replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return decodeURIComponent(Array.prototype.map.call(atob(normalized), function (char) {
return "%" + ("00" + char.charCodeAt(0).toString(16)).slice(-2);
}).join(""));
}
}
function bytesFromBase64Url(value) {
var normalized = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
normalized += "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
var binary = atob(normalized);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function base64UrlFromBuffer(value) {
if (!value) return undefined;
var bytes = value instanceof Uint8Array
? value
: new Uint8Array(value);
var binary = "";
for (var i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function readPublicKeyOptions() {
var data = params.get("data");
if (!data) throw new Error("No data.");
var decoded = decodeBase64Unicode(data);
if (params.get("v") === "1") {
return JSON.parse(decoded);
}
var payload = JSON.parse(decoded);
return typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data;
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = Object.assign({}, options);
copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
return Object.assign({}, credential, {
id: bytesFromBase64Url(credential.id),
});
});
}
return copy;
}
function credentialToDataString(credential) {
var response = credential.response;
var clientDataJSON = base64UrlFromBuffer(response.clientDataJSON);
var data = {
id: credential.id,
rawId: base64UrlFromBuffer(credential.rawId),
type: credential.type,
extensions: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
clientExtensionResults: credential.getClientExtensionResults ? credential.getClientExtensionResults() : {},
response: {
authenticatorData: base64UrlFromBuffer(response.authenticatorData),
clientDataJson: clientDataJSON,
clientDataJSON: clientDataJSON,
signature: base64UrlFromBuffer(response.signature),
userHandle: response.userHandle ? base64UrlFromBuffer(response.userHandle) : undefined,
},
};
return JSON.stringify(data);
}
async function start() {
if (sentSuccess) return;
if (!("credentials" in navigator) || !window.PublicKeyCredential) {
showMessage("error", text.unsupported);
return;
}
try {
msgEl.className = "msg";
buttonEl.disabled = true;
buttonEl.textContent = decodeRepeated(params.get("btnAwaitingInteractionText")) || text.awaiting;
var publicKey = normalizeOptions(readPublicKeyOptions());
var credential = await navigator.credentials.get({ publicKey: publicKey });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
window.postMessage({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
}, "*");
sentSuccess = true;
showMessage("success", text.success);
} catch (error) {
buttonEl.disabled = false;
buttonEl.textContent = decodeRepeated(params.get("btnText")) || text.button;
showMessage("error", error && error.message ? error.message : String(error || "WebAuthn failed."));
}
}
})();
</script>
</body>
</html>
+58 -11
View File
@@ -20,7 +20,7 @@ import {
loadProfileSnapshot, loadProfileSnapshot,
saveProfileSnapshot, saveProfileSnapshot,
revokeCurrentSession, revokeCurrentSession,
getTotpStatus, getTwoFactorProviderStatus,
getVaultRevisionDate, getVaultRevisionDate,
saveSession, saveSession,
stripProfileSecrets, stripProfileSecrets,
@@ -58,6 +58,7 @@ import {
type PendingPasskeyPassword, type PendingPasskeyPassword,
type PendingTotp, type PendingTotp,
} from '@/lib/app-auth'; } from '@/lib/app-auth';
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions'; import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions'; import useBackupActions from '@/hooks/useBackupActions';
@@ -152,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
type ThemePreference = 'system' | 'light' | 'dark'; type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
@@ -654,19 +657,38 @@ export default function App() {
} }
} }
function handleSelectTotpProvider(providerType: number) {
if (totpSubmitting) return;
setPendingTotp((current) => {
if (!current || current.providerType === providerType) return current;
const canUseProvider = current.availableProviders.includes(providerType);
if (!canUseProvider) return current;
return {
...current,
providerType,
providerData: current.providerDataByType[providerType],
};
});
setTotpCode('');
}
async function handleTotpVerify() { async function handleTotpVerify() {
if (totpSubmitting) return; if (totpSubmitting) return;
if (!pendingTotp) return; if (!pendingTotp) return;
if (!totpCode.trim()) { const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
pushToast('error', t('txt_please_input_totp_code')); if (!isPasskeyTwoFactor && !totpCode.trim()) {
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
return; return;
} }
setTotpSubmitting(true); setTotpSubmitting(true);
try { try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); const token = isPasskeyTwoFactor
? await assertTwoFactorPasskey(pendingTotp.providerData)
: totpCode;
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
await finalizeLogin(login); await finalizeLogin(login);
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
} finally { } finally {
setTotpSubmitting(false); setTotpSubmitting(false);
} }
@@ -951,11 +973,14 @@ export default function App() {
confirm={null} confirm={null}
onCancelConfirm={() => {}} onCancelConfirm={() => {}}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}} onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false} totpSubmitting={false}
@@ -1081,9 +1106,9 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
const totpStatusQuery = useQuery({ const twoFactorStatusQuery = useQuery({
queryKey: ['totp-status', vaultCacheKey || session?.email], queryKey: ['two-factor-status', vaultCacheKey || session?.email],
queryFn: () => getTotpStatus(authedFetch), queryFn: () => getTwoFactorProviderStatus(authedFetch),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -1816,7 +1841,7 @@ export default function App() {
onNotify: pushToast, onNotify: pushToast,
onProfileUpdated: setProfile, onProfileUpdated: setProfile,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchTotpStatus: totpStatusQuery.refetch, refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
refetchAuthorizedDevices: authorizedDevicesQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
@@ -1939,6 +1964,7 @@ export default function App() {
session, session,
mobileLayout, mobileLayout,
mobileSidebarToggleKey, mobileSidebarToggleKey,
themePreference,
importRoute: IMPORT_ROUTE, importRoute: IMPORT_ROUTE,
settingsHomeRoute: SETTINGS_HOME_ROUTE, settingsHomeRoute: SETTINGS_HOME_ROUTE,
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
@@ -1953,7 +1979,9 @@ export default function App() {
invites: invitesQuery.data || [], invites: invitesQuery.data || [],
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data), adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '', adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
totpEnabled: !!totpStatusQuery.data?.enabled, totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
lockTimeoutMinutes, lockTimeoutMinutes,
sessionTimeoutAction, sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
@@ -1966,6 +1994,7 @@ export default function App() {
onNavigate: navigate, onNavigate: navigate,
onLogout: handleLogout, onLogout: handleLogout,
onNotify: pushToast, onNotify: pushToast,
onThemePreferenceChange: setThemePreference,
onImport: vaultSendActions.importVault, onImport: vaultSendActions.importVault,
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw, onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
onExport: vaultSendActions.exportVault, onExport: vaultSendActions.exportVault,
@@ -2002,9 +2031,18 @@ export default function App() {
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string, masterPassword: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token, masterPassword); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await twoFactorStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
onDisableYubiKey: accountSecurityActions.disableYubiKey,
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey, onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey,
@@ -2012,6 +2050,9 @@ export default function App() {
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
onRefreshTwoFactorStatus: async () => {
await twoFactorStatusQuery.refetch();
},
pendingAuthRequests, pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading, pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading, pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
@@ -2206,11 +2247,14 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp} pendingTotpOpen={!!pendingTotp}
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
totpCode={totpCode} totpCode={totpCode}
rememberDevice={rememberDevice} rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode} onTotpCodeChange={setTotpCode}
onRememberDeviceChange={setRememberDevice} onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()} onConfirmTotp={() => void handleTotpVerify()}
onSelectTotpProvider={handleSelectTotpProvider}
onCancelTotp={() => { onCancelTotp={() => {
if (totpSubmitting) return; if (totpSubmitting) return;
setPendingTotp(null); setPendingTotp(null);
@@ -2265,11 +2309,14 @@ export default function App() {
confirm={confirm} confirm={confirm}
onCancelConfirm={() => setConfirm(null)} onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false} pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode="" totpCode=""
rememberDevice={false} rememberDevice={false}
onTotpCodeChange={() => {}} onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}} onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}} onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}} onCancelTotp={() => {}}
onUseRecoveryCode={() => {}} onUseRecoveryCode={() => {}}
totpSubmitting={false} totpSubmitting={false}
+86 -6
View File
@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost'; import ToastHost from '@/components/ToastHost';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -21,11 +22,14 @@ interface AppGlobalOverlaysProps {
confirm: AppConfirmState | null; confirm: AppConfirmState | null;
onCancelConfirm: () => void; onCancelConfirm: () => void;
pendingTotpOpen: boolean; pendingTotpOpen: boolean;
pendingTotpProviderType?: number;
pendingTotpAvailableProviders?: number[];
totpCode: string; totpCode: string;
rememberDevice: boolean; rememberDevice: boolean;
onTotpCodeChange: (value: string) => void; onTotpCodeChange: (value: string) => void;
onRememberDeviceChange: (checked: boolean) => void; onRememberDeviceChange: (checked: boolean) => void;
onConfirmTotp: () => void; onConfirmTotp: () => void;
onSelectTotpProvider: (providerType: number) => void;
onCancelTotp: () => void; onCancelTotp: () => void;
onUseRecoveryCode: () => void; onUseRecoveryCode: () => void;
totpSubmitting: boolean; totpSubmitting: boolean;
@@ -37,7 +41,40 @@ interface AppGlobalOverlaysProps {
disableTotpSubmitting: boolean; disableTotpSubmitting: boolean;
} }
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_ORDER = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
const available = new Set(providerTypes || []);
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
}
function twoFactorProviderLabel(providerType: number): string {
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
return t('txt_authenticator_app');
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders]
);
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
useEffect(() => {
setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -55,10 +92,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
title={t('txt_two_step_verification')} title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
message={t('txt_password_is_already_verified')} <span className="dialog-title-stack">
<span>{t('txt_two_step_verification')}</span>
<span>{t('txt_passkey')}</span>
</span>
) : t('txt_two_step_verification')}
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
confirmText={t('txt_verify')} confirmText={t('txt_verify')}
cancelText={t('txt_cancel')} hideCancel
closeButton
showIcon={false} showIcon={false}
confirmDisabled={props.totpSubmitting} confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting} cancelDisabled={props.totpSubmitting}
@@ -67,16 +110,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
afterActions={( afterActions={(
<div className="dialog-extra"> <div className="dialog-extra">
<div className="dialog-divider" /> <div className="dialog-divider" />
{alternateProviders.length > 0 && (
<div className="two-factor-method-switcher">
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.totpSubmitting}
aria-expanded={methodChooserOpen}
onClick={() => setMethodChooserOpen((open) => !open)}
>
{t('txt_select_another_verification_method')}
</button>
{methodChooserOpen && (
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
{alternateProviders.map((providerType) => (
<button
key={providerType}
type="button"
className="btn btn-secondary two-factor-method-option"
disabled={props.totpSubmitting}
onClick={() => {
setMethodChooserOpen(false);
props.onSelectTotpProvider(providerType);
}}
>
{twoFactorProviderLabel(providerType)}
</button>
))}
</div>
)}
</div>
)}
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}> <button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')} {t('txt_use_recovery_code')}
</button> </button>
</div> </div>
)} )}
> >
{isWebAuthn ? (
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
) : (
<label className="field"> <label className="field">
<span>{t('txt_totp_code')}</span> <span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} /> <input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
)}
<label className="check-line check-line-compact"> <label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} /> <input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span> <span>{t('txt_trust_this_device_for_30_days')}</span>
@@ -88,7 +167,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
title={t('txt_disable_totp')} title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')} message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')} confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')} hideCancel
closeButton
danger danger
showIcon={false} showIcon={false}
confirmDisabled={props.disableTotpSubmitting} confirmDisabled={props.disableTotpSubmitting}
+30 -1
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin'; import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats'; import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage')); const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -39,6 +39,7 @@ export interface AppMainRoutesProps {
session: SessionState | null; session: SessionState | null;
mobileLayout: boolean; mobileLayout: boolean;
mobileSidebarToggleKey: number; mobileSidebarToggleKey: number;
themePreference: 'system' | 'light' | 'dark';
importRoute: string; importRoute: string;
settingsHomeRoute: string; settingsHomeRoute: string;
settingsAccountRoute: string; settingsAccountRoute: string;
@@ -54,6 +55,8 @@ export interface AppMainRoutesProps {
adminLoading: boolean; adminLoading: boolean;
adminError: string; adminError: string;
totpEnabled: boolean; totpEnabled: boolean;
yubikeyEnabled: boolean;
passkey2faEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
@@ -66,6 +69,7 @@ export interface AppMainRoutesProps {
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
onLogout: () => void; onLogout: () => void;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
onImport: ( onImport: (
payload: CiphersImportPayload, payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
@@ -110,6 +114,15 @@ export interface AppMainRoutesProps {
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -117,6 +130,7 @@ export interface AppMainRoutesProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onRefreshTwoFactorStatus: () => Promise<void>;
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean; pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean; pendingAuthRequestsRefreshing: boolean;
@@ -266,12 +280,26 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsPage <SettingsPage
profile={props.profile} profile={props.profile}
totpEnabled={props.totpEnabled} totpEnabled={props.totpEnabled}
yubikeyEnabled={props.yubikeyEnabled}
passkey2faEnabled={props.passkey2faEnabled}
themePreference={props.themePreference}
lockTimeoutMinutes={props.lockTimeoutMinutes} lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction} sessionTimeoutAction={props.sessionTimeoutAction}
onThemePreferenceChange={props.onThemePreferenceChange}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onChangePassword={props.onChangePassword} onChangePassword={props.onChangePassword}
onSavePasswordHint={props.onSavePasswordHint} onSavePasswordHint={props.onSavePasswordHint}
onEnableTotp={props.onEnableTotp} onEnableTotp={props.onEnableTotp}
onOpenDisableTotp={props.onOpenDisableTotp} onOpenDisableTotp={props.onOpenDisableTotp}
onGetYubiKeySettings={props.onGetYubiKeySettings}
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
onDisableYubiKey={props.onDisableYubiKey}
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
onGetRecoveryCode={props.onGetRecoveryCode} onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey} onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey} onRotateApiKey={props.onRotateApiKey}
@@ -279,6 +307,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
+24 -5
View File
@@ -1,19 +1,21 @@
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact'; import { TriangleAlert, X } from 'lucide-preact';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean; open: boolean;
title: string; title: ComponentChildren;
message: string; message?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'warning';
showIcon?: boolean; showIcon?: boolean;
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
danger?: boolean; danger?: boolean;
hideCancel?: boolean; hideCancel?: boolean;
hideConfirm?: boolean;
closeButton?: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
cancelDisabled?: boolean; cancelDisabled?: boolean;
onConfirm: () => void; onConfirm: () => void;
@@ -88,6 +90,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`; const messageId = `${dialogId}-message`;
const hasMessage = !!props.message;
const canDismiss = !props.cancelDisabled && !closing; const canDismiss = !props.cancelDisabled && !closing;
useEffect(() => { useEffect(() => {
@@ -191,7 +194,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
aria-describedby={messageId} aria-describedby={hasMessage ? messageId : undefined}
tabIndex={-1} tabIndex={-1}
onKeyDown={handleDialogKeyDown} onKeyDown={handleDialogKeyDown}
onSubmit={(e) => { onSubmit={(e) => {
@@ -211,9 +214,24 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
</div> </div>
</> </>
) : null} ) : null}
{props.closeButton && (
<button
type="button"
className="dialog-close-btn"
aria-label={t('txt_close')}
disabled={props.cancelDisabled}
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
}}
>
<X size={18} />
</button>
)}
<h3 id={titleId} className="dialog-title">{props.title}</h3> <h3 id={titleId} className="dialog-title">{props.title}</h3>
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div> {hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children} {props.children}
{!props.hideConfirm && (
<button <button
type="submit" type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`} className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
@@ -222,6 +240,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
> >
{props.confirmText || t('txt_yes')} {props.confirmText || t('txt_yes')}
</button> </button>
)}
{!props.hideCancel && ( {!props.hideCancel && (
<button <button
type="button" type="button"
+1 -3
View File
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface JwtWarningPageProps { interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short'; reason: 'missing' | 'too_short';
minLength: number; minLength: number;
} }
@@ -21,8 +21,6 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const title = const title =
props.reason === 'missing' props.reason === 'missing'
? t('txt_jwt_title_missing') ? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short'); : t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
-28
View File
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
} }
export default function NotFoundPage(props: NotFoundPageProps) { export default function NotFoundPage(props: NotFoundPageProps) {
const starBoxes = [1, 2, 3, 4];
const stars = [1, 2, 3, 4, 5, 6, 7];
return ( return (
<main className="not-found-page"> <main className="not-found-page">
<div className="not-found-space" aria-hidden="true">
{starBoxes.map((box) => (
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
{stars.map((star) => (
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
))}
</div>
))}
</div>
<section className="not-found-shell" aria-labelledby="not-found-title"> <section className="not-found-shell" aria-labelledby="not-found-title">
<div className="not-found-brand"> <div className="not-found-brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" /> <img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" /> <span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
</div> </div>
<div className="not-found-astro-stage" aria-hidden="true">
<div className="not-found-astronaut">
<div className="not-found-astro-head" />
<div className="not-found-astro-arm not-found-astro-arm-left" />
<div className="not-found-astro-arm not-found-astro-arm-right" />
<div className="not-found-astro-body">
<div className="not-found-astro-panel" />
</div>
<div className="not-found-astro-leg not-found-astro-leg-left" />
<div className="not-found-astro-leg not-found-astro-leg-right" />
<div className="not-found-astro-pack" />
</div>
</div>
<div className="not-found-copy"> <div className="not-found-copy">
<div className="not-found-code">404</div> <div className="not-found-code">404</div>
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1> <h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
File diff suppressed because it is too large Load Diff
+49 -15
View File
@@ -1,6 +1,7 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -171,16 +172,38 @@ export default function VaultEditor(props: VaultEditorProps) {
return new window.BarcodeDetector({ formats: ['qr_code'] }); return new window.BarcodeDetector({ formats: ['qr_code'] });
}; };
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => { const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => {
const width = 'videoWidth' in source ? source.videoWidth : source.width;
const height = 'videoHeight' in source ? source.videoHeight : source.height;
if (!width || !height) return '';
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) return '';
// jsQR ignores alpha and reads RGB directly, so transparent pixels would be
// treated as black. Composite over white first so transparent-background QR
// exports do not become black-on-black and fail to decode.
context.fillStyle = '#ffffff';
context.fillRect(0, 0, width, height);
context.drawImage(source, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
return String(jsQR(imageData.data, width, height)?.data || '').trim();
};
const decodeTotpQrImage = async (source: ImageBitmap): Promise<boolean> => {
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) { if (detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported')); try {
return false;
}
const results = await detector.detect(source); const results = await detector.detect(source);
const value = String(results[0]?.rawValue || '').trim(); const value = String(results[0]?.rawValue || '').trim();
if (!value) return false; if (value && applyTotpQrValue(value)) return true;
return applyTotpQrValue(value); } catch {
// Fall back to jsQR when the native detector is present but not usable.
}
}
const value = decodeTotpQrCanvas(source);
return value ? applyTotpQrValue(value) : false;
}; };
const handleTotpQrFile = async (file: File | null) => { const handleTotpQrFile = async (file: File | null) => {
@@ -206,14 +229,8 @@ export default function VaultEditor(props: VaultEditorProps) {
return; return;
} }
let stopped = false; let stopped = false;
let lastCanvasScan = 0;
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
return () => { return () => {
@@ -229,9 +246,26 @@ export default function VaultEditor(props: VaultEditorProps) {
totpQrFrameRef.current = window.requestAnimationFrame(scan); totpQrFrameRef.current = window.requestAnimationFrame(scan);
return; return;
} }
try {
let value = '';
if (detector) {
try { try {
const results = await detector.detect(video); const results = await detector.detect(video);
const value = String(results[0]?.rawValue || '').trim(); value = String(results[0]?.rawValue || '').trim();
} catch {
// Fall back to jsQR when the native detector is present but not usable.
}
}
// The jsQR fallback runs a synchronous full-frame decode, so throttle
// it to a few times per second instead of every animation frame to
// avoid pegging the CPU while a code is being aligned.
if (!value) {
const now = performance.now();
if (now - lastCanvasScan >= 250) {
lastCanvasScan = now;
value = decodeTotpQrCanvas(video);
}
}
if (value && applyTotpQrValue(value)) return; if (value && applyTotpQrValue(value)) return;
} catch { } catch {
// Keep the camera active; transient frame decode failures are common. // Keep the camera active; transient frame decode failures are common.
+121 -5
View File
@@ -1,22 +1,32 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { import {
changeMasterPassword, changeMasterPassword,
bootstrapYubiKeyOtpApiCredentials,
deleteAllAuthorizedDevices, deleteAllAuthorizedDevices,
deleteAuthorizedDevice, deleteAuthorizedDevice,
deleteAuthorizedDevices, deleteAuthorizedDevices,
deriveLoginHash, deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi, deleteAccountPasskey as deleteAccountPasskeyApi,
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi, enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
disableYubiKeyOtp,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getApiKey, getApiKey,
getAccountPasskeyAttestationOptions, getAccountPasskeyAttestationOptions,
getAccountPasskeyUpdateAssertionOptions, getAccountPasskeyUpdateAssertionOptions,
getTotpRecoveryCode, getTotpRecoveryCode,
getTwoFactorPasskeyChallenge,
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
getYubiKeyOtpSettings,
listAccountPasskeys, listAccountPasskeys,
rotateApiKey, rotateApiKey,
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
saveAccountPasskey, saveAccountPasskey,
saveTwoFactorPasskey,
saveYubiKeyOtpApiCredentials,
saveYubiKeyOtpSettings,
setTotp, setTotp,
trustAuthorizedDevicePermanently, trustAuthorizedDevicePermanently,
updateAuthorizedDeviceName, updateAuthorizedDeviceName,
@@ -28,11 +38,12 @@ import {
buildAccountPasskeyPrfKeySet, buildAccountPasskeyPrfKeySet,
buildAccountPasskeyPrfKeySetFromPrfKey, buildAccountPasskeyPrfKeySetFromPrfKey,
createAccountPasskeyCredential, createAccountPasskeyCredential,
createTwoFactorPasskeyCredential,
} from '@/lib/account-passkeys'; } from '@/lib/account-passkeys';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types'; import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void; type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -47,7 +58,7 @@ interface UseAccountSecurityActionsOptions {
onNotify: Notify; onNotify: Notify;
onProfileUpdated: (profile: Profile) => void; onProfileUpdated: (profile: Profile) => void;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchTotpStatus: () => Promise<unknown>; refetchTwoFactorStatus: () => Promise<unknown>;
refetchAuthorizedDevices: () => Promise<unknown>; refetchAuthorizedDevices: () => Promise<unknown>;
} }
@@ -63,7 +74,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onNotify, onNotify,
onProfileUpdated, onProfileUpdated,
onSetConfirm, onSetConfirm,
refetchTotpStatus, refetchTwoFactorStatus,
refetchAuthorizedDevices, refetchAuthorizedDevices,
} = options; } = options;
@@ -187,13 +198,118 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
clearDisableTotpDialog(); clearDisableTotpDialog();
await refetchTotpStatus(); await refetchTwoFactorStatus();
onNotify('success', t('txt_totp_disabled')); onNotify('success', t('txt_totp_disabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
} }
}, },
async getYubiKeySettings(masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
return getYubiKeyOtpSettings(authedFetch, derived.hash);
},
async saveYubiKeySettings(keys: string[], nfc: boolean, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await saveYubiKeyOtpSettings(authedFetch, { keys, nfc, masterPasswordHash: derived.hash });
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikeys_updated'));
return settings;
},
async saveYubiKeyApiCredentials(clientId: string, secretKey: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await saveYubiKeyOtpApiCredentials(authedFetch, {
masterPasswordHash: derived.hash,
yubicoClientId: clientId,
yubicoSecretKey: secretKey,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_config_updated'));
return settings;
},
async bootstrapYubiKeyApiCredentials(otp: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await bootstrapYubiKeyOtpApiCredentials(authedFetch, {
masterPasswordHash: derived.hash,
otp,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_config_updated'));
return settings;
},
async disableYubiKey(masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
await disableYubiKeyOtp(authedFetch, derived.hash);
await refetchTwoFactorStatus();
onNotify('success', t('txt_yubikey_disabled'));
},
async getTwoFactorPasskeySettings(masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
return getTwoFactorPasskeySettingsApi(authedFetch, derived.hash);
},
async createTwoFactorPasskey(name: string, masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const normalizedName = String(name || '').trim() || t('txt_passkey');
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const challenge = await getTwoFactorPasskeyChallenge(authedFetch, derived.hash);
const deviceResponse = await createTwoFactorPasskeyCredential(challenge);
const settings = await saveTwoFactorPasskey(authedFetch, {
name: normalizedName,
masterPasswordHash: derived.hash,
deviceResponse,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkey_added'));
return settings;
},
async deleteTwoFactorPasskey(id: number, masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await deleteTwoFactorPasskeyApi(authedFetch, { id, masterPasswordHash: derived.hash });
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkey_removed'));
return settings;
},
async disableTwoFactorPasskeys(masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
await disableTwoFactorPasskeysApi(authedFetch, derived.hash);
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkeys_disabled'));
},
async getRecoveryCode(masterPassword: string): Promise<string> { async getRecoveryCode(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable')); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || ''); const normalized = String(masterPassword || '');
@@ -476,7 +592,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
session?.symEncKey, session?.symEncKey,
session?.symMacKey, session?.symMacKey,
refetchAuthorizedDevices, refetchAuthorizedDevices,
refetchTotpStatus, refetchTwoFactorStatus,
] ]
); );
} }
+22
View File
@@ -340,6 +340,28 @@ export async function createAccountPasskeyCredential(
}; };
} }
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return attestationRequest(credential);
}
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_invalid_passkey_assertion_response'));
}
return JSON.stringify(assertionRequest(credential));
}
function parseRsaEncryptedUserKey(value: string): Uint8Array { function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim(); const text = String(value || '').trim();
const [type, payload] = text.split('.'); const [type, payload] = text.split('.');
+220 -7
View File
@@ -7,6 +7,8 @@ import type {
SessionState, SessionState,
TokenError, TokenError,
TokenSuccess, TokenSuccess,
TwoFactorPasskeySettings,
YubiKeyOtpSettings,
} from '../types'; } from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status'; import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
@@ -240,6 +242,7 @@ export async function loginWithPassword(
passwordHash: string, passwordHash: string,
options?: { options?: {
totpCode?: string; totpCode?: string;
twoFactorProvider?: number;
rememberDevice?: boolean; rememberDevice?: boolean;
useRememberToken?: boolean; useRememberToken?: boolean;
signal?: AbortSignal; signal?: AbortSignal;
@@ -259,7 +262,7 @@ export async function loginWithPassword(
body.set('twoFactorProvider', '5'); body.set('twoFactorProvider', '5');
body.set('twoFactorToken', rememberedToken); body.set('twoFactorToken', rememberedToken);
} else if (options?.totpCode) { } else if (options?.totpCode) {
body.set('twoFactorProvider', '0'); body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0));
body.set('twoFactorToken', options.totpCode); body.set('twoFactorToken', options.totpCode);
if (options.rememberDevice) { if (options.rememberDevice) {
body.set('twoFactorRemember', '1'); body.set('twoFactorRemember', '1');
@@ -591,11 +594,14 @@ export async function changeMasterPassword(
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32); const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32); const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac); const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
if (userSym.length !== 64) {
throw new Error('Invalid profile key');
}
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32); const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32); const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym, nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash); const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
@@ -647,6 +653,203 @@ export async function setTotp(
} }
} }
function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
return {
enabled: !!(raw?.enabled ?? raw?.Enabled),
keys: [
String(raw?.key1 ?? raw?.Key1 ?? ''),
String(raw?.key2 ?? raw?.Key2 ?? ''),
String(raw?.key3 ?? raw?.Key3 ?? ''),
String(raw?.key4 ?? raw?.Key4 ?? ''),
String(raw?.key5 ?? raw?.Key5 ?? ''),
],
nfc: !!(raw?.nfc ?? raw?.Nfc),
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
};
}
export async function getYubiKeyOtpSettings(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/get-yubikey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function saveYubiKeyOtpSettings(
authedFetch: AuthedFetch,
payload: { keys: string[]; nfc: boolean; masterPasswordHash: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key1: payload.keys[0] || '',
key2: payload.keys[1] || '',
key3: payload.keys[2] || '',
key4: payload.keys[3] || '',
key5: payload.keys[4] || '',
nfc: payload.nfc,
masterPasswordHash: payload.masterPasswordHash,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function saveYubiKeyOtpApiCredentials(
authedFetch: AuthedFetch,
payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_config_update_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function bootstrapYubiKeyOtpApiCredentials(
authedFetch: AuthedFetch,
payload: { masterPasswordHash: string; otp: string }
): Promise<YubiKeyOtpSettings> {
const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_auto_config_failed')));
}
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
}
export async function disableYubiKeyOtp(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch('/api/two-factor/disable', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 3, masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed')));
}
}
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
return {
enabled: !!(raw?.enabled ?? raw?.Enabled),
keys: keys
.map((item: any) => ({
id: Number(item?.id ?? item?.Id),
name: String(item?.name || item?.Name || ''),
migrated: !!(item?.migrated ?? item?.Migrated),
}))
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
};
}
export async function getTwoFactorPasskeySettings(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/get-webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function getTwoFactorPasskeyChallenge(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<unknown> {
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return parseJson<unknown>(resp);
}
export async function saveTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function deleteTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id: number; masterPasswordHash: string }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function disableTwoFactorPasskeys(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch('/api/two-factor/disable', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 7, masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
}
}
export async function verifyMasterPassword( export async function verifyMasterPassword(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string masterPasswordHash: string
@@ -804,11 +1007,21 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
return stamp; return stamp;
} }
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> { export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
const resp = await authedFetch('/api/accounts/totp'); const resp = await authedFetch('/api/two-factor');
if (!resp.ok) throw new Error('Failed to load TOTP status'); if (!resp.ok) throw new Error('Failed to load two-factor status');
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {}; const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
return { enabled: !!body.enabled }; const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
const enabledTypes = new Set(
providers
.map((provider: any) => Number(provider?.type ?? provider?.Type))
.filter((type) => Number.isFinite(type))
);
return {
totpEnabled: enabledTypes.has(0),
yubikeyEnabled: enabledTypes.has(3),
passkeyEnabled: enabledTypes.has(7),
};
} }
export async function getTotpRecoveryCode( export async function getTotpRecoveryCode(
+119 -7
View File
@@ -34,6 +34,10 @@ export interface PendingTotp {
passwordHash: string; passwordHash: string;
masterKey: Uint8Array; masterKey: Uint8Array;
kdfIterations: number; kdfIterations: number;
providerType: number;
providerData?: unknown;
availableProviders: number[];
providerDataByType: Record<number, unknown>;
} }
export interface PendingPasskeyPassword { export interface PendingPasskeyPassword {
@@ -42,7 +46,7 @@ export interface PendingPasskeyPassword {
kdfIterations: number; kdfIterations: number;
} }
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -70,10 +74,98 @@ export interface CompletedLogin {
freshUserVerificationToken?: string | null; freshUserVerificationToken?: string | null;
} }
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function readTokenUserVerificationToken(token: TokenSuccess): string | null { function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null; return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
type TwoFactorTokenError = {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
CustomResponse?: {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
};
error_description?: string;
error?: string;
};
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
}
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return undefined;
const record = providers2 as Record<string, unknown>;
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
}
function twoFactorProviderTypeFromValue(value: unknown): number | null {
const raw = value && typeof value === 'object'
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
: value;
const text = String(raw ?? '').trim();
if (!text) return null;
const normalized = text.toLowerCase();
const numeric = Number(text);
const provider = Number.isFinite(numeric)
? numeric
: normalized === 'webauthn'
? TWO_FACTOR_PROVIDER_WEBAUTHN
: normalized === 'yubikey' || normalized === 'yubikeyotp'
? TWO_FACTOR_PROVIDER_YUBIKEY
: normalized === 'authenticator' || normalized === 'totp'
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
: Number.NaN;
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
}
function sortTwoFactorProviders(providerTypes: number[]): number[] {
const unique = new Set(providerTypes);
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
}
function readTwoFactorProviderTypes(providers: unknown): number[] {
const providerTypes: number[] = [];
if (Array.isArray(providers)) {
for (const provider of providers) {
const providerType = twoFactorProviderTypeFromValue(provider);
if (providerType != null) providerTypes.push(providerType);
}
} else if (providers && typeof providers === 'object') {
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
if (value === false) continue;
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) providerTypes.push(providerType);
}
}
return sortTwoFactorProviders(providerTypes);
}
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return {};
const out: Record<number, unknown> = {};
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) out[providerType] = value;
}
return out;
}
function resolvePendingTwoFactorProvider(providers: unknown): number {
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
export type PasswordLoginResult = export type PasswordLoginResult =
| { kind: 'success'; login: CompletedLogin } | { kind: 'success'; login: CompletedLogin }
| { kind: 'totp'; pendingTotp: PendingTotp } | { kind: 'totp'; pendingTotp: PendingTotp }
@@ -416,8 +508,12 @@ export async function performPasswordLogin(
}; };
} }
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; const tokenError = token as TwoFactorTokenError;
if (tokenError.TwoFactorProviders) { const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return { return {
kind: 'totp', kind: 'totp',
pendingTotp: { pendingTotp: {
@@ -425,6 +521,10 @@ export async function performPasswordLogin(
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations, kdfIterations: derived.kdfIterations,
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
}, },
}; };
} }
@@ -498,13 +598,17 @@ export async function performTotpLogin(
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, { const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
totpCode: totpCode.trim(), totpCode: totpCode.trim(),
twoFactorProvider: pendingTotp.providerType,
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
? t('txt_passkey_verification_failed')
: t('txt_totp_verify_failed');
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
} }
export async function performRecoverTwoFactorLogin( export async function performRecoverTwoFactorLogin(
@@ -584,7 +688,7 @@ export async function performUnlock(
return unlockOffline(); return unlockOffline();
} }
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string }; let token: TokenSuccess | TwoFactorTokenError;
try { try {
token = await loginWithPassword(normalizedEmail, derived.hash, { token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true, useRememberToken: true,
@@ -606,8 +710,12 @@ export async function performUnlock(
}; };
} }
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; const tokenError = token as TwoFactorTokenError;
if (tokenError.TwoFactorProviders) { const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return { return {
kind: 'totp', kind: 'totp',
pendingTotp: { pendingTotp: {
@@ -615,6 +723,10 @@ export async function performUnlock(
passwordHash: derived.hash, passwordHash: derived.hash,
masterKey: derived.masterKey, masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations, kdfIterations: derived.kdfIterations,
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
}, },
}; };
} }
+11
View File
@@ -907,6 +907,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
adminLoading: false, adminLoading: false,
adminError: '', adminError: '',
totpEnabled: true, totpEnabled: true,
passkey2faEnabled: false,
authorizedDevices: state.authorizedDevices, authorizedDevices: state.authorizedDevices,
authorizedDevicesLoading: false, authorizedDevicesLoading: false,
authorizedDevicesError: '', authorizedDevicesError: '',
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onSavePasswordHint: readonly, onSavePasswordHint: readonly,
onEnableTotp: readonly, onEnableTotp: readonly,
onOpenDisableTotp: readonlyVoid, onOpenDisableTotp: readonlyVoid,
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
onCreateTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDeleteTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDisableTwoFactorPasskeys: readonly,
onGetRecoveryCode: readonlyString, onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString, onGetApiKey: readonlyString,
onRotateApiKey: readonlyString, onRotateApiKey: readonlyString,
+71 -1
View File
@@ -11,6 +11,57 @@ const en: Record<string, string> = {
"nav_import_export": "Import & Export", "nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup", "nav_group_data_backup": "Data & Backup",
"nav_group_management": "Management", "nav_group_management": "Management",
"txt_settings_appearance": "Appearance",
"txt_theme": "Theme",
"txt_use_system_theme": "Use system theme",
"txt_light_theme": "Light",
"txt_dark_theme": "Dark",
"txt_theme_saved_locally": "Choose a theme for your web vault.",
"txt_display_language_help": "Change the web vault language.",
"txt_two_step_login": "Two-step login",
"txt_keys": "Keys",
"txt_manage": "Manage",
"txt_providers": "Providers",
"txt_authenticator_app": "Authenticator app",
"txt_authenticator_app_help": "Enter a code generated by an authenticator app.",
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
"txt_yubico_otp_security_key": "Yubico OTP security key",
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
"txt_yubikey_setup_intro": "Insert your YubiKey into a USB port. Select the first empty YubiKey field below, touch the YubiKey button, then save the form.",
"txt_yubikey_plug_in": "Insert your YubiKey into a USB port.",
"txt_yubikey_select_empty_field": "Select the first empty YubiKey input field below.",
"txt_yubikey_touch_button": "Touch the YubiKey button.",
"txt_yubikey_save_form": "Save the form.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC support",
"txt_yubikey_supports_nfc": "One of my keys supports NFC.",
"txt_yubikey_supports_nfc_desc": "If one of your YubiKeys supports NFC, mobile apps can prompt you when NFC is available.",
"txt_disable_all_keys": "Disable all keys",
"txt_yubikeys_updated": "YubiKeys updated",
"txt_yubikey_update_failed": "Failed to update YubiKeys",
"txt_disable_yubikey_failed": "Failed to disable YubiKeys",
"txt_yubikey_disabled": "YubiKeys disabled",
"txt_yubikey_enabled": "YubiKey is enabled.",
"txt_yubikey_config_required": "Yubico validation is not configured",
"txt_yubikey_config_required_help": "Enter one YubiKey OTP first. NodeWarden will automatically request and save the instance Client ID and Secret key, then open the YubiKey setup form.",
"txt_otp_from_yubikey": "OTP from YubiKey",
"txt_please_input_yubikey_otp": "Please input YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey verification failed",
"txt_press_yubikey_to_authenticate": "Press your YubiKey to authenticate.",
"txt_yubikey_auto_configure": "Get and save automatically",
"txt_yubikey_validation_credentials": "Yubico validation credentials",
"txt_view": "View",
"txt_yubikey_config_updated": "Yubico validation credentials updated",
"txt_yubikey_config_update_failed": "Failed to update Yubico validation credentials",
"txt_yubikey_auto_config_failed": "Failed to get Yubico validation credentials",
"txt_yubikey_reconfigure_help": "Enter a fresh OTP to request and replace these credentials automatically.",
"txt_yubikey_auto_configure_again": "Get again automatically",
"txt_setting_coming_soon": "Coming soon.",
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
"txt_your_two_step_recovery_code": "Your Bitwarden two-step login recovery code:",
"txt_name_account_passkey_after_verification": "Passkey created. Name it to help you recognize it.",
"txt_account_passkey_name_help": "0 / 50 characters",
"txt_page_not_found": "Page Not Found", "txt_page_not_found": "Page Not Found",
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.", "txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
"txt_back_to_home": "Back To Home", "txt_back_to_home": "Back To Home",
@@ -669,7 +720,7 @@ const en: Record<string, string> = {
"txt_jwt_secret_value_label": "Value:", "txt_jwt_secret_value_label": "Value:",
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters", "txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
"txt_jwt_what_is": "What is JWT?", "txt_jwt_what_is": "What is JWT?",
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.", "txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
"txt_how_to_fix": "How to fix", "txt_how_to_fix": "How to fix",
"txt_jwt_fix_step_1": "Open your deployment environment variables.", "txt_jwt_fix_step_1": "Open your deployment environment variables.",
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.", "txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
@@ -755,6 +806,23 @@ const en: Record<string, string> = {
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer", "txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
"txt_passkey": "Passkey", "txt_passkey": "Passkey",
"txt_passkeys": "Passkeys", "txt_passkeys": "Passkeys",
"txt_register": "Register",
"txt_key_list": "Key list",
"txt_select_another_verification_method": "Select another verification method",
"txt_select_two_step_login_method": "Select two-step login method",
"txt_two_step_passkeys": "Passkey two-step login",
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
"txt_two_step_passkey_name_placeholder": "Security key",
"txt_add_two_step_passkey": "Add passkey",
"txt_two_step_passkey_added": "Passkey two-step login updated",
"txt_two_step_passkey_removed": "Passkey removed",
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
"txt_no_two_step_passkeys": "No two-step passkeys",
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
"txt_passkey_setup_failed": "Passkey setup failed",
"txt_passkey_created_at_value": "Created on {value}", "txt_passkey_created_at_value": "Created on {value}",
"txt_account_passkey": "Account passkey", "txt_account_passkey": "Account passkey",
"txt_account_passkeys": "Account passkeys", "txt_account_passkeys": "Account passkeys",
@@ -838,6 +906,8 @@ const en: Record<string, string> = {
"txt_scope": "scope", "txt_scope": "scope",
"txt_grant_type": "grant_type", "txt_grant_type": "grant_type",
"txt_refresh": "Refresh", "txt_refresh": "Refresh",
"txt_refresh_status": "Refresh status",
"txt_load_failed": "Failed to load",
"txt_refresh_in_seconds_s": "Refresh in {seconds}s", "txt_refresh_in_seconds_s": "Refresh in {seconds}s",
"txt_regenerate": "Regenerate", "txt_regenerate": "Regenerate",
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.", "txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
+71 -1
View File
@@ -11,6 +11,57 @@ const es: Record<string, string> = {
"nav_import_export": "Importar y exportar", "nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias", "nav_group_data_backup": "Datos y copias",
"nav_group_management": "Gestión", "nav_group_management": "Gestión",
"txt_settings_appearance": "Apariencia",
"txt_theme": "Tema",
"txt_use_system_theme": "Usar tema del sistema",
"txt_light_theme": "Claro",
"txt_dark_theme": "Oscuro",
"txt_theme_saved_locally": "Elige un tema para tu bóveda web.",
"txt_display_language_help": "Cambia el idioma de la bóveda web.",
"txt_two_step_login": "Inicio de sesión en dos pasos",
"txt_keys": "Claves",
"txt_manage": "Gestionar",
"txt_providers": "Proveedores",
"txt_authenticator_app": "Aplicación autenticadora",
"txt_authenticator_app_help": "Introduce un código generado por una aplicación autenticadora.",
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
"txt_yubikey_setup_intro": "Inserta tu YubiKey en un puerto USB. Selecciona el primer campo YubiKey vacío, toca el botón de la YubiKey y guarda el formulario.",
"txt_yubikey_plug_in": "Inserta tu YubiKey en un puerto USB.",
"txt_yubikey_select_empty_field": "Selecciona el primer campo YubiKey vacío.",
"txt_yubikey_touch_button": "Toca el botón de la YubiKey.",
"txt_yubikey_save_form": "Guarda el formulario.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "Compatibilidad NFC",
"txt_yubikey_supports_nfc": "Una de mis llaves admite NFC.",
"txt_yubikey_supports_nfc_desc": "Si una de tus YubiKeys admite NFC, las apps móviles pueden avisarte cuando NFC esté disponible.",
"txt_disable_all_keys": "Desactivar todas las llaves",
"txt_yubikeys_updated": "YubiKeys actualizadas",
"txt_yubikey_update_failed": "No se pudieron actualizar las YubiKeys",
"txt_disable_yubikey_failed": "No se pudieron desactivar las YubiKeys",
"txt_yubikey_disabled": "YubiKeys desactivadas",
"txt_yubikey_enabled": "YubiKey activada.",
"txt_yubikey_config_required": "La validación de Yubico no está configurada",
"txt_yubikey_config_required_help": "Introduce primero un OTP de YubiKey. NodeWarden solicitará y guardará automáticamente el Client ID y la Secret key de la instancia, y luego abrirá el formulario de YubiKey.",
"txt_otp_from_yubikey": "OTP de YubiKey",
"txt_please_input_yubikey_otp": "Introduce el OTP de YubiKey",
"txt_yubikey_verify_failed": "No se pudo verificar la YubiKey",
"txt_press_yubikey_to_authenticate": "Pulsa tu YubiKey para autenticarte.",
"txt_yubikey_auto_configure": "Obtener y guardar automáticamente",
"txt_yubikey_validation_credentials": "Credenciales de validación de Yubico",
"txt_view": "Ver",
"txt_yubikey_config_updated": "Credenciales de validación de Yubico actualizadas",
"txt_yubikey_config_update_failed": "No se pudieron actualizar las credenciales de validación de Yubico",
"txt_yubikey_auto_config_failed": "No se pudieron obtener las credenciales de validación de Yubico",
"txt_yubikey_reconfigure_help": "Introduce un OTP nuevo para solicitar y reemplazar estas credenciales automáticamente.",
"txt_yubikey_auto_configure_again": "Obtener de nuevo automáticamente",
"txt_setting_coming_soon": "Próximamente.",
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
"txt_your_two_step_recovery_code": "Tu código de recuperación de inicio de sesión en dos pasos de Bitwarden:",
"txt_name_account_passkey_after_verification": "Passkey creada. Ponle un nombre para reconocerla.",
"txt_account_passkey_name_help": "0 / 50 caracteres como máximo",
"txt_page_not_found": "Página no encontrada", "txt_page_not_found": "Página no encontrada",
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.", "txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
"txt_back_to_home": "Volver al inicio", "txt_back_to_home": "Volver al inicio",
@@ -669,7 +720,7 @@ const es: Record<string, string> = {
"txt_jwt_secret_value_label": "Valor:", "txt_jwt_secret_value_label": "Valor:",
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres", "txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
"txt_jwt_what_is": "Qué es JWT", "txt_jwt_what_is": "Qué es JWT",
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.", "txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
"txt_how_to_fix": "Cómo corregirlo", "txt_how_to_fix": "Cómo corregirlo",
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.", "txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.", "txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
@@ -755,6 +806,23 @@ const es: Record<string, string> = {
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos", "txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
"txt_passkey": "Clave de acceso", "txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso", "txt_passkeys": "Claves de acceso",
"txt_register": "Registrar",
"txt_key_list": "Lista de claves",
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
"txt_add_two_step_passkey": "Agregar clave de acceso",
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
"txt_passkey_created_at_value": "Creado el {value}", "txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Clave de acceso de cuenta", "txt_account_passkey": "Clave de acceso de cuenta",
"txt_account_passkeys": "Claves de acceso de cuenta", "txt_account_passkeys": "Claves de acceso de cuenta",
@@ -838,6 +906,8 @@ const es: Record<string, string> = {
"txt_scope": "Ámbito", "txt_scope": "Ámbito",
"txt_grant_type": "Tipo de concesión", "txt_grant_type": "Tipo de concesión",
"txt_refresh": "Actualizar", "txt_refresh": "Actualizar",
"txt_refresh_status": "Actualizar estado",
"txt_load_failed": "No se pudo cargar",
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s", "txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
"txt_regenerate": "Regenerar", "txt_regenerate": "Regenerar",
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.", "txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
+71 -1
View File
@@ -12,6 +12,57 @@ const ru: Record<string, string> = {
"nav_import_export": "Импорт и экспорт", "nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии", "nav_group_data_backup": "Данные и резервные копии",
"nav_group_management": "Управление", "nav_group_management": "Управление",
"txt_settings_appearance": "Внешний вид",
"txt_theme": "Тема",
"txt_use_system_theme": "Использовать системную тему",
"txt_light_theme": "Светлая",
"txt_dark_theme": "Темная",
"txt_theme_saved_locally": "Выберите тему для веб-хранилища.",
"txt_display_language_help": "Изменить язык веб-хранилища.",
"txt_two_step_login": "Двухэтапный вход",
"txt_keys": "Ключи",
"txt_manage": "Управлять",
"txt_providers": "Поставщики",
"txt_authenticator_app": "Приложение-аутентификатор",
"txt_authenticator_app_help": "Введите код, созданный приложением-аутентификатором.",
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
"txt_yubikey_setup_intro": "Вставьте YubiKey в USB-порт. Выберите первое пустое поле YubiKey ниже, коснитесь кнопки YubiKey и сохраните форму.",
"txt_yubikey_plug_in": "Вставьте YubiKey в USB-порт.",
"txt_yubikey_select_empty_field": "Выберите первое пустое поле YubiKey ниже.",
"txt_yubikey_touch_button": "Коснитесь кнопки YubiKey.",
"txt_yubikey_save_form": "Сохраните форму.",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "Поддержка NFC",
"txt_yubikey_supports_nfc": "Один из моих ключей поддерживает NFC.",
"txt_yubikey_supports_nfc_desc": "Если один из ваших YubiKey поддерживает NFC, мобильные приложения смогут подсказать вам, когда NFC доступен.",
"txt_disable_all_keys": "Отключить все ключи",
"txt_yubikeys_updated": "YubiKey обновлены",
"txt_yubikey_update_failed": "Не удалось обновить YubiKey",
"txt_disable_yubikey_failed": "Не удалось отключить YubiKey",
"txt_yubikey_disabled": "YubiKey отключены",
"txt_yubikey_enabled": "YubiKey включен.",
"txt_yubikey_config_required": "Проверка Yubico не настроена",
"txt_yubikey_config_required_help": "Сначала введите один OTP с YubiKey. NodeWarden автоматически запросит и сохранит Client ID и Secret key экземпляра, затем откроет форму настройки YubiKey.",
"txt_otp_from_yubikey": "OTP с YubiKey",
"txt_please_input_yubikey_otp": "Введите OTP с YubiKey",
"txt_yubikey_verify_failed": "Не удалось проверить YubiKey",
"txt_press_yubikey_to_authenticate": "Нажмите YubiKey для проверки.",
"txt_yubikey_auto_configure": "Получить и сохранить автоматически",
"txt_yubikey_validation_credentials": "Учетные данные проверки Yubico",
"txt_view": "Показать",
"txt_yubikey_config_updated": "Учетные данные проверки Yubico обновлены",
"txt_yubikey_config_update_failed": "Не удалось обновить учетные данные проверки Yubico",
"txt_yubikey_auto_config_failed": "Не удалось получить учетные данные проверки Yubico",
"txt_yubikey_reconfigure_help": "Введите новый OTP, чтобы автоматически запросить и заменить эти учетные данные.",
"txt_yubikey_auto_configure_again": "Получить снова автоматически",
"txt_setting_coming_soon": "Скоро появится.",
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
"txt_your_two_step_recovery_code": "Ваш код восстановления двухэтапного входа Bitwarden:",
"txt_name_account_passkey_after_verification": "Ключ доступа создан. Назовите его, чтобы легче узнавать.",
"txt_account_passkey_name_help": "0 / не более 50 символов",
"txt_page_not_found": "Страница не найдена", "txt_page_not_found": "Страница не найдена",
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.", "txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
"txt_back_to_home": "На главную", "txt_back_to_home": "На главную",
@@ -669,7 +720,7 @@ const ru: Record<string, string> = {
"txt_jwt_secret_value_label": "Значение:", "txt_jwt_secret_value_label": "Значение:",
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.", "txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
"txt_jwt_what_is": "Что такое JWT?", "txt_jwt_what_is": "Что такое JWT?",
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.", "txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
"txt_how_to_fix": "Как исправить", "txt_how_to_fix": "Как исправить",
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.", "txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.", "txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
@@ -755,6 +806,23 @@ const ru: Record<string, string> = {
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.", "txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
"txt_passkey": "Ключ доступа", "txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа", "txt_passkeys": "Ключи доступа",
"txt_register": "Зарегистрировать",
"txt_key_list": "Список ключей",
"txt_select_another_verification_method": "Выбрать другой способ проверки",
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
"txt_add_two_step_passkey": "Добавить ключ доступа",
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
"txt_two_step_passkey_removed": "Ключ доступа удален",
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
"txt_passkey_created_at_value": "Создано {value}", "txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Ключ доступа аккаунта", "txt_account_passkey": "Ключ доступа аккаунта",
"txt_account_passkeys": "Ключи доступа аккаунта", "txt_account_passkeys": "Ключи доступа аккаунта",
@@ -838,6 +906,8 @@ const ru: Record<string, string> = {
"txt_scope": "Область доступа", "txt_scope": "Область доступа",
"txt_grant_type": "Тип авторизации", "txt_grant_type": "Тип авторизации",
"txt_refresh": "Обновить", "txt_refresh": "Обновить",
"txt_refresh_status": "Обновить статус",
"txt_load_failed": "Не удалось загрузить",
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.", "txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
"txt_regenerate": "Регенерировать", "txt_regenerate": "Регенерировать",
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.", "txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
+71 -1
View File
@@ -11,6 +11,57 @@ const zhCN: Record<string, string> = {
"nav_import_export": "导入导出", "nav_import_export": "导入导出",
"nav_group_data_backup": "数据与备份", "nav_group_data_backup": "数据与备份",
"nav_group_management": "管理", "nav_group_management": "管理",
"txt_settings_appearance": "外观",
"txt_theme": "主题",
"txt_use_system_theme": "使用系统主题",
"txt_light_theme": "浅色",
"txt_dark_theme": "深色",
"txt_theme_saved_locally": "为您的网页密码库选择一个主题。",
"txt_display_language_help": "更改网页密码库的语言。",
"txt_two_step_login": "两步登录",
"txt_keys": "密钥",
"txt_manage": "管理",
"txt_providers": "提供程序",
"txt_authenticator_app": "验证器 App",
"txt_authenticator_app_help": "输入验证器 App 生成的代码。",
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
"txt_yubikey_setup_intro": "将 YubiKey 插入计算机的 USB 端口。在下面选择第一个空的 YubiKey 输入字段。触摸 YubiKey 的按钮、保存。",
"txt_yubikey_plug_in": "将 YubiKey 插入计算机的 USB 端口",
"txt_yubikey_select_empty_field": "在下面选择第一个空的 YubiKey 输入字段",
"txt_yubikey_touch_button": "触摸 YubiKey 的按钮、保存",
"txt_yubikey_save_form": "保存",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC 支持",
"txt_yubikey_supports_nfc": "我的某个密钥支持 NFC",
"txt_yubikey_supports_nfc_desc": "",
"txt_disable_all_keys": "停用全部密钥",
"txt_yubikeys_updated": "YubiKey 已更新",
"txt_yubikey_update_failed": "更新 YubiKey 失败",
"txt_disable_yubikey_failed": "停用 YubiKey 失败",
"txt_yubikey_disabled": "YubiKey 已停用",
"txt_yubikey_enabled": "YubiKey 已启用。",
"txt_yubikey_config_required": "尚未配置 Yubico 验证",
"txt_yubikey_config_required_help": "请先输入一次 YubiKey OTP。NodeWarden 会自动获取并保存实例级 Client ID 和 Secret key,成功后再进入 YubiKey 设置表单。",
"txt_otp_from_yubikey": "来自 YubiKey 的 OTP",
"txt_please_input_yubikey_otp": "请输入 YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey 验证失败",
"txt_press_yubikey_to_authenticate": "按下 YubiKey 进行验证。",
"txt_yubikey_auto_configure": "自动获取并保存",
"txt_yubikey_validation_credentials": "Yubico 验证凭据",
"txt_view": "查看",
"txt_yubikey_config_updated": "Yubico 验证凭据已更新",
"txt_yubikey_config_update_failed": "更新 Yubico 验证凭据失败",
"txt_yubikey_auto_config_failed": "获取 Yubico 验证凭据失败",
"txt_yubikey_reconfigure_help": "输入一个新的 OTP,可以重新自动获取并替换当前凭据。",
"txt_yubikey_auto_configure_again": "重新自动获取",
"txt_setting_coming_soon": "即将推出。",
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
"txt_your_two_step_recovery_code": "您的 Bitwarden 两步登录恢复代码:",
"txt_name_account_passkey_after_verification": "通行密钥创建成功!为您的通行密钥命名以帮助您识别它。",
"txt_account_passkey_name_help": "0 / 最多 50 个字符",
"txt_page_not_found": "页面不存在", "txt_page_not_found": "页面不存在",
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。", "txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
"txt_back_to_home": "回到首页", "txt_back_to_home": "回到首页",
@@ -669,7 +720,7 @@ const zhCN: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符", "txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
"txt_jwt_what_is": "JWT 是什么", "txt_jwt_what_is": "JWT 是什么",
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,或者仍然使用示例值,实例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,实例就不能安全地正常使用。",
"txt_how_to_fix": "处理步骤(添加 / 更换)", "txt_how_to_fix": "处理步骤(添加 / 更换)",
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。", "txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
@@ -755,6 +806,23 @@ const zhCN: Record<string, string> = {
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符", "txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
"txt_passkey": "通行密钥", "txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥", "txt_passkeys": "通行密钥",
"txt_register": "注册",
"txt_key_list": "密钥列表",
"txt_select_another_verification_method": "选择其他验证方式",
"txt_select_two_step_login_method": "选择验证方式",
"txt_two_step_passkeys": "通行密钥二步登录",
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
"txt_two_step_passkey_name_placeholder": "安全密钥",
"txt_add_two_step_passkey": "添加通行密钥",
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
"txt_two_step_passkey_removed": "通行密钥已移除",
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
"txt_passkey_setup_failed": "通行密钥设置失败",
"txt_passkey_created_at_value": "创建于 {value}", "txt_passkey_created_at_value": "创建于 {value}",
"txt_account_passkey": "账号通行密钥", "txt_account_passkey": "账号通行密钥",
"txt_account_passkeys": "账号通行密钥", "txt_account_passkeys": "账号通行密钥",
@@ -838,6 +906,8 @@ const zhCN: Record<string, string> = {
"txt_scope": "权限范围", "txt_scope": "权限范围",
"txt_grant_type": "授权类型", "txt_grant_type": "授权类型",
"txt_refresh": "刷新", "txt_refresh": "刷新",
"txt_refresh_status": "刷新状态",
"txt_load_failed": "加载失败",
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新", "txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
"txt_regenerate": "重新生成", "txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "注册成功,请登录", "txt_registration_succeeded_please_sign_in": "注册成功,请登录",
+71 -1
View File
@@ -11,6 +11,57 @@ const zhTW: Record<string, string> = {
"nav_import_export": "導入導出", "nav_import_export": "導入導出",
"nav_group_data_backup": "資料與備份", "nav_group_data_backup": "資料與備份",
"nav_group_management": "管理", "nav_group_management": "管理",
"txt_settings_appearance": "外觀",
"txt_theme": "主題",
"txt_use_system_theme": "使用系統主題",
"txt_light_theme": "淺色",
"txt_dark_theme": "深色",
"txt_theme_saved_locally": "為您的網頁密碼庫選擇一個主題。",
"txt_display_language_help": "更改網頁密碼庫的語言。",
"txt_two_step_login": "兩步登入",
"txt_keys": "密鑰",
"txt_manage": "管理",
"txt_providers": "提供程序",
"txt_authenticator_app": "驗證器 App",
"txt_authenticator_app_help": "輸入驗證器 App 生成的代碼。",
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
"txt_yubikey_setup_intro": "將 YubiKey 插入電腦的 USB 連接埠。在下方選擇第一個空的 YubiKey 輸入欄位,觸摸 YubiKey 按鈕,然後保存表單。",
"txt_yubikey_plug_in": "將 YubiKey 插入電腦的 USB 連接埠。",
"txt_yubikey_select_empty_field": "在下方選擇第一個空的 YubiKey 輸入欄位。",
"txt_yubikey_touch_button": "觸摸 YubiKey 按鈕。",
"txt_yubikey_save_form": "保存表單。",
"txt_yubikey_x": "YubiKey {index}",
"txt_nfc_support": "NFC 支援",
"txt_yubikey_supports_nfc": "我的某個密鑰支援 NFC。",
"txt_yubikey_supports_nfc_desc": "如果您的某個 YubiKey 支援 NFC,行動裝置偵測到 NFC 可用時會提示您。",
"txt_disable_all_keys": "停用全部密鑰",
"txt_yubikeys_updated": "YubiKey 已更新",
"txt_yubikey_update_failed": "更新 YubiKey 失敗",
"txt_disable_yubikey_failed": "停用 YubiKey 失敗",
"txt_yubikey_disabled": "YubiKey 已停用",
"txt_yubikey_enabled": "YubiKey 已啟用。",
"txt_yubikey_config_required": "尚未配置 Yubico 驗證",
"txt_yubikey_config_required_help": "請先輸入一次 YubiKey OTP。NodeWarden 會自動取得並保存實例級 Client ID 和 Secret key,成功後再進入 YubiKey 設定表單。",
"txt_otp_from_yubikey": "來自 YubiKey 的 OTP",
"txt_please_input_yubikey_otp": "請輸入 YubiKey OTP",
"txt_yubikey_verify_failed": "YubiKey 驗證失敗",
"txt_press_yubikey_to_authenticate": "按下 YubiKey 進行驗證。",
"txt_yubikey_auto_configure": "自動取得並保存",
"txt_yubikey_validation_credentials": "Yubico 驗證憑據",
"txt_view": "查看",
"txt_yubikey_config_updated": "Yubico 驗證憑據已更新",
"txt_yubikey_config_update_failed": "更新 Yubico 驗證憑據失敗",
"txt_yubikey_auto_config_failed": "取得 Yubico 驗證憑據失敗",
"txt_yubikey_reconfigure_help": "輸入一個新的 OTP,可以重新自動取得並替換目前憑據。",
"txt_yubikey_auto_configure_again": "重新自動取得",
"txt_setting_coming_soon": "即將推出。",
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
"txt_your_two_step_recovery_code": "您的 Bitwarden 兩步登入恢復代碼:",
"txt_name_account_passkey_after_verification": "通行密鑰創建成功!為您的通行密鑰命名以幫助您識別它。",
"txt_account_passkey_name_help": "0 / 最多 50 個字符",
"txt_page_not_found": "頁面不存在", "txt_page_not_found": "頁面不存在",
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。", "txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
"txt_back_to_home": "回到首頁", "txt_back_to_home": "回到首頁",
@@ -669,7 +720,7 @@ const zhTW: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符", "txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
"txt_jwt_what_is": "JWT 是什麼", "txt_jwt_what_is": "JWT 是什麼",
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,或者仍然使用示例值,實例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,實例就不能安全地正常使用。",
"txt_how_to_fix": "處理步驟(添加 / 更換)", "txt_how_to_fix": "處理步驟(添加 / 更換)",
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。", "txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
@@ -755,6 +806,23 @@ const zhTW: Record<string, string> = {
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符", "txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
"txt_passkey": "通行密鑰", "txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰", "txt_passkeys": "通行密鑰",
"txt_register": "註冊",
"txt_key_list": "密鑰列表",
"txt_select_another_verification_method": "選擇其他驗證方式",
"txt_select_two_step_login_method": "選擇驗證方式",
"txt_two_step_passkeys": "通行密鑰兩步登入",
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
"txt_two_step_passkey_name_placeholder": "安全密鑰",
"txt_add_two_step_passkey": "新增通行密鑰",
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
"txt_two_step_passkey_removed": "通行密鑰已移除",
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
"txt_passkey_setup_failed": "通行密鑰設置失敗",
"txt_passkey_created_at_value": "創建於 {value}", "txt_passkey_created_at_value": "創建於 {value}",
"txt_account_passkey": "賬號通行密鑰", "txt_account_passkey": "賬號通行密鑰",
"txt_account_passkeys": "賬號通行密鑰", "txt_account_passkeys": "賬號通行密鑰",
@@ -838,6 +906,8 @@ const zhTW: Record<string, string> = {
"txt_scope": "權限範圍", "txt_scope": "權限範圍",
"txt_grant_type": "授權類型", "txt_grant_type": "授權類型",
"txt_refresh": "刷新", "txt_refresh": "刷新",
"txt_refresh_status": "刷新狀態",
"txt_load_failed": "載入失敗",
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新", "txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
"txt_regenerate": "重新生成", "txt_regenerate": "重新生成",
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄", "txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
+22 -1
View File
@@ -15,6 +15,7 @@ export interface Profile {
name: string; name: string;
key: string; key: string;
masterPasswordHint?: string | null; masterPasswordHint?: string | null;
yubikeyEnabled?: boolean;
privateKey?: string | null; privateKey?: string | null;
publicKey?: string | null; publicKey?: string | null;
role: 'admin' | 'user'; role: 'admin' | 'user';
@@ -290,11 +291,20 @@ export interface ListResponse<T> {
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations?: number; defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
} }
export interface YubiKeyOtpSettings {
enabled: boolean;
keys: [string, string, string, string, string];
nfc: boolean;
yubicoConfigured: boolean;
yubicoClientId: string;
yubicoSecretKey: string;
}
export interface TokenSuccess { export interface TokenSuccess {
access_token: string; access_token: string;
refresh_token?: string; refresh_token?: string;
@@ -340,6 +350,17 @@ export interface AccountPasskeyCredential {
revisionDate?: string; revisionDate?: string;
} }
export interface TwoFactorPasskeyCredential {
id: number;
name: string;
migrated?: boolean;
}
export interface TwoFactorPasskeySettings {
enabled: boolean;
keys: TwoFactorPasskeyCredential[];
}
export interface AuthRequest { export interface AuthRequest {
id: string; id: string;
publicKey: string; publicKey: string;
+1 -1
View File
@@ -297,7 +297,7 @@ h4 {
} }
.app-main { .app-main {
grid-template-columns: 212px minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
background: var(--panel-soft); background: var(--panel-soft);
} }
+6 -13
View File
@@ -30,14 +30,11 @@
.not-found-page { .not-found-page {
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center; @apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
background: background: var(--surface);
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
} }
.not-found-shell { .not-found-shell {
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center; @apply relative z-20 grid w-full max-w-[560px] justify-items-center gap-6 px-4 py-7 text-center;
background: transparent; background: transparent;
border: 0; border: 0;
box-shadow: none; box-shadow: none;
@@ -356,7 +353,7 @@
} }
.not-found-logo { .not-found-logo {
@apply h-14 w-[70px] flex-shrink-0 object-contain; @apply h-14 w-14 flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22)); filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
} }
@@ -377,17 +374,16 @@
.not-found-copy { .not-found-copy {
@apply grid justify-items-center gap-3; @apply grid justify-items-center gap-3;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
} }
.not-found-shell h1 { .not-found-shell h1 {
@apply m-0 text-3xl font-extrabold leading-tight; @apply m-0 text-3xl font-extrabold leading-tight;
color: #f8fbff; color: var(--text);
} }
.not-found-shell p { .not-found-shell p {
@apply m-0 max-w-[420px] text-sm leading-relaxed; @apply m-0 max-w-[420px] text-sm leading-relaxed;
color: rgba(220, 232, 251, 0.82); color: var(--muted);
} }
.not-found-action { .not-found-action {
@@ -396,10 +392,7 @@
@media (max-width: 520px) { @media (max-width: 520px) {
.not-found-page { .not-found-page {
background: background: var(--surface);
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
} }
.not-found-shell { .not-found-shell {
+321 -1
View File
@@ -537,6 +537,275 @@
@apply mb-2; @apply mb-2;
} }
.settings-page-categorized {
@apply min-w-0;
}
.settings-category-layout {
@apply grid min-w-0 gap-5;
}
.settings-category-tabs {
@apply flex min-w-0 items-center gap-1 border;
width: fit-content;
max-width: 100%;
padding: 8px;
border-radius: 14px;
border-color: var(--line);
background: var(--panel);
}
.settings-category-tab {
@apply relative flex h-9 cursor-pointer items-center rounded-lg border-0 px-3 text-sm font-bold;
background: transparent;
color: var(--muted-strong);
transition:
background var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth),
box-shadow var(--dur-fast) var(--ease-smooth);
}
.settings-category-tab:hover {
color: var(--primary-strong);
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.settings-category-tab.active {
color: var(--primary-strong);
background: color-mix(in srgb, var(--primary) 12%, var(--panel));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 22%, var(--line));
}
.settings-category-tab.active::after {
display: none;
}
.settings-category-panel {
@apply min-w-0;
width: 50%;
min-width: 700px;
max-width: 50%;
padding: 24px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
}
.settings-section-stack {
@apply grid min-w-0 gap-0;
}
.settings-submodule {
@apply min-w-0 border-0 border-b px-0 py-5;
border-color: var(--line);
background: transparent;
}
.settings-submodule:first-child {
padding-top: 0;
}
.settings-submodule:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.settings-submodule h3 {
@apply mb-4 mt-0 text-base font-extrabold;
color: var(--text);
}
.settings-placeholder-module {
background: transparent;
}
.settings-vertical-fields {
@apply grid gap-3;
}
.settings-vertical-fields .field {
@apply mb-0;
}
.settings-vertical-fields + .btn {
margin-top: 14px;
}
.settings-category-panel > .settings-section-stack > .settings-submodule:only-child {
border-bottom: 0;
padding-bottom: 0;
}
.two-step-recovery-warning {
@apply rounded-xl border p-4;
border-color: color-mix(in srgb, #f97316 28%, var(--line));
background: color-mix(in srgb, #f97316 7%, var(--panel));
}
.settings-submodule.two-step-recovery-warning {
border-bottom-color: color-mix(in srgb, #f97316 28%, var(--line));
}
.two-step-recovery-warning + .two-step-providers-module {
border-top: 0;
}
.two-step-providers-module .settings-module-head {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.two-step-warning-head {
@apply mb-2 flex items-center gap-2 text-sm font-extrabold;
color: #c2410c;
padding-top: 10px;
}
.two-step-recovery-warning p {
@apply mb-3 mt-0 text-sm leading-6;
color: #c2410c;
}
.two-step-recovery-code-dialog-value {
@apply my-5 text-center text-base font-extrabold tracking-[0.12em];
color: #c2187a;
word-spacing: 0.55em;
overflow-wrap: anywhere;
}
.two-step-provider-list {
@apply grid;
}
.two-step-provider-row {
@apply grid min-w-0 items-center gap-4 border-b py-5;
grid-template-columns: 76px minmax(0, 1fr) auto;
border-color: var(--line);
}
.two-step-provider-row:first-child {
padding-top: 0;
}
.two-step-provider-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.two-step-provider-icon {
@apply flex h-14 w-14 items-center justify-center rounded-xl;
color: var(--primary-strong);
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
}
.two-step-provider-yubico {
@apply text-base font-extrabold lowercase;
color: #54a51c;
background: transparent;
}
.two-step-provider-copy {
@apply grid min-w-0 gap-1;
}
.two-step-provider-title {
@apply flex min-w-0 flex-wrap items-center gap-2;
}
.two-step-provider-title strong {
@apply text-base font-bold;
color: var(--text);
}
.two-step-provider-copy span {
@apply text-sm leading-5;
color: var(--muted-strong);
}
.two-step-enabled-badge {
@apply inline-flex min-h-6 items-center rounded-md border px-2 text-xs font-extrabold leading-none;
border-color: color-mix(in srgb, var(--success) 28%, var(--line));
background: color-mix(in srgb, var(--success) 10%, var(--panel));
color: var(--success);
}
.totp-manage-dialog-body {
@apply mt-3;
}
.totp-manage-dialog-body .totp-grid {
grid-template-columns: minmax(0, 1fr);
}
.totp-manage-dialog-body .totp-qr {
justify-self: center;
width: 220px;
}
.totp-manage-dialog-body .totp-secret-input-wrap {
grid-template-columns: minmax(0, 1fr) auto;
}
.totp-manage-dialog-body .totp-secret-input {
font-size: 13px;
}
.yubikey-manage-dialog-body {
@apply mt-3 space-y-4;
}
.settings-plain-steps {
@apply m-0 space-y-1 border-b pb-3 pl-5;
border-color: var(--line);
color: var(--text);
}
.yubikey-input-row {
@apply grid items-center gap-2;
grid-template-columns: minmax(0, 1fr) auto;
}
.yubikey-stored-key {
@apply block min-h-9 rounded-md border border-transparent px-0 py-2 text-sm text-slate-800 dark:text-slate-100;
overflow-wrap: anywhere;
}
.yubikey-remove-btn {
@apply h-9 w-9 p-0;
}
.settings-checkbox-block {
@apply space-y-2;
}
.checkbox-inline {
@apply flex items-center gap-2 text-sm;
}
.checkbox-inline input {
@apply h-4 w-4;
}
.dialog-close-btn {
@apply absolute right-3 top-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 p-0;
background: transparent;
color: var(--muted-strong);
transition:
background-color var(--dur-fast) var(--ease-smooth),
color var(--dur-fast) var(--ease-smooth);
}
.dialog-close-btn:hover:not(:disabled) {
background: var(--panel-soft);
color: var(--text);
}
.dialog-close-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.settings-modules-grid { .settings-modules-grid {
--settings-grid-gap: 12px; --settings-grid-gap: 12px;
@apply grid; @apply grid;
@@ -1089,7 +1358,7 @@
} }
.settings-module-head { .settings-module-head {
@apply mb-[5px] flex items-center justify-between gap-3; @apply mb-[10px] flex items-center justify-between gap-3;
} }
.settings-module-head h3 { .settings-module-head h3 {
@@ -1119,10 +1388,37 @@
accent-color: var(--primary); accent-color: var(--primary);
} }
.account-passkey-list,
.account-passkeys-list { .account-passkeys-list {
@apply mt-3 grid gap-2; @apply mt-3 grid gap-2;
} }
.two-factor-passkey-register-row {
@apply flex min-w-0 items-center gap-2;
}
.two-factor-passkey-register-row .input {
@apply min-w-0 flex-1;
}
.two-factor-passkey-register-row .btn {
@apply shrink-0;
min-width: 86px;
}
.two-factor-passkey-list-block {
@apply grid gap-2;
}
.settings-list-label {
@apply text-sm font-extrabold;
color: var(--muted);
}
.two-factor-passkey-danger-actions {
@apply justify-end pt-1;
}
.account-passkey-row { .account-passkey-row {
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3; @apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
@@ -1130,6 +1426,17 @@
background: var(--panel); background: var(--panel);
} }
.two-factor-passkey-row {
grid-template-columns: 2rem minmax(0, 1fr) auto;
}
.account-passkey-index {
@apply inline-grid h-8 w-8 place-items-center rounded-lg text-sm font-extrabold;
border: 1px solid var(--line);
color: var(--muted);
background: color-mix(in srgb, var(--panel) 80%, var(--panel-2));
}
.account-passkey-main { .account-passkey-main {
@apply grid min-w-0 gap-1; @apply grid min-w-0 gap-1;
} }
@@ -1196,6 +1503,19 @@
background: var(--panel); background: var(--panel);
} }
.settings-category-panel .sensitive-action {
border-right: 0;
border-left: 0;
border-radius: 0;
padding-right: 0;
padding-left: 0;
background: transparent;
}
.settings-category-panel > .settings-section-stack > .settings-submodule:only-child.sensitive-action {
border-top: 0;
}
.sensitive-action h4 { .sensitive-action h4 {
@apply mb-1 mt-0 text-base font-extrabold; @apply mb-1 mt-0 text-base font-extrabold;
color: var(--text); color: var(--text);
+24 -1
View File
@@ -68,6 +68,10 @@
@apply my-1.5 text-3xl; @apply my-1.5 text-3xl;
} }
.dialog-title-stack {
@apply flex flex-col items-center gap-1;
}
.dialog-message { .dialog-message {
@apply mb-2.5; @apply mb-2.5;
color: #475467; color: #475467;
@@ -91,7 +95,7 @@
} }
.dialog-extra { .dialog-extra {
@apply mt-2; @apply mt-2 grid gap-2;
} }
.dialog-divider { .dialog-divider {
@@ -99,6 +103,25 @@
background: var(--line); background: var(--line);
} }
.two-factor-method-switcher {
@apply grid gap-2;
}
.two-factor-method-list {
@apply grid gap-2 rounded-lg border p-2;
border-color: var(--line);
background: color-mix(in srgb, var(--panel) 92%, var(--surface));
}
.two-factor-method-label {
@apply px-1 text-left text-sm font-extrabold;
color: var(--muted);
}
.two-factor-method-option {
@apply min-h-11 justify-start text-base;
}
.import-summary-dialog { .import-summary-dialog {
@apply relative max-w-[520px] pt-4 text-left; @apply relative max-w-[520px] pt-4 text-left;
} }
+119 -8
View File
@@ -770,11 +770,44 @@
} }
.settings-modules-grid, .settings-modules-grid,
.settings-category-layout,
.domain-rules-grid, .domain-rules-grid,
.password-settings-grid { .password-settings-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-category-tabs {
position: static;
display: flex;
gap: 4px;
overflow-x: auto;
width: 100%;
margin-inline: 0;
padding: 4px;
scrollbar-width: none;
}
.settings-category-tabs::-webkit-scrollbar {
display: none;
}
.settings-category-tab {
flex: 0 0 auto;
min-height: 38px;
padding: 0 12px;
white-space: nowrap;
}
.settings-category-panel {
width: 100%;
min-width: 0;
max-width: none;
}
.settings-category-tab.active {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 22%, var(--line));
}
.import-export-panel .actions .btn, .import-export-panel .actions .btn,
.settings-subcard .actions .btn, .settings-subcard .actions .btn,
.section-head .actions .btn { .section-head .actions .btn {
@@ -961,6 +994,51 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-category-layout {
gap: 12px;
}
.settings-category-panel {
padding: 16px;
border-radius: 14px;
}
.settings-page-categorized {
padding: 0;
}
.settings-section-stack {
gap: 0;
}
.settings-submodule {
padding: 14px 0;
border-radius: 0;
}
.settings-submodule.two-step-recovery-warning {
padding: 14px;
}
.two-step-provider-row {
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 10px;
}
.two-step-provider-icon {
width: 42px;
height: 42px;
}
.two-step-provider-row .btn {
grid-column: 3;
justify-self: end;
}
.totp-manage-dialog-body .totp-grid {
grid-template-columns: 1fr;
}
.settings-modules-grid { .settings-modules-grid {
gap: 8px; gap: 8px;
} }
@@ -974,7 +1052,8 @@
padding: 0; padding: 0;
} }
.settings-module h3 { .settings-module h3,
.settings-submodule h3 {
margin-bottom: 8px; margin-bottom: 8px;
font-size: 15px; font-size: 15px;
line-height: 1.25; line-height: 1.25;
@@ -1001,11 +1080,13 @@
} }
.settings-module .field, .settings-module .field,
.settings-submodule .field,
.auth-card .field { .auth-card .field {
margin-bottom: 8px; margin-bottom: 8px;
} }
.settings-module .field > span, .settings-module .field > span,
.settings-submodule .field > span,
.auth-card .field > span { .auth-card .field > span {
margin-top: 0; margin-top: 0;
margin-bottom: 4px; margin-bottom: 4px;
@@ -1014,19 +1095,22 @@
} }
.settings-module .field-grid, .settings-module .field-grid,
.settings-submodule .field-grid,
.auth-card .field-grid, .auth-card .field-grid,
.session-timeout-fields { .session-timeout-fields {
gap: 8px; gap: 8px;
} }
.settings-module .input { .settings-module .input,
.settings-submodule .input {
height: 42px; height: 42px;
border-radius: 12px; border-radius: 12px;
padding: 8px 11px; padding: 8px 11px;
font-size: 15px; font-size: 15px;
} }
.settings-module select.input { .settings-module select.input,
.settings-submodule select.input {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
padding-right: 30px; padding-right: 30px;
@@ -1035,18 +1119,21 @@
calc(100% - 9px) calc(50% - 3px); calc(100% - 9px) calc(50% - 3px);
} }
.settings-module .field-help { .settings-module .field-help,
.settings-submodule .field-help {
margin-top: 5px; margin-top: 5px;
font-size: 12px; font-size: 12px;
line-height: 1.35; line-height: 1.35;
} }
.settings-module .btn, .settings-module .btn,
.settings-submodule .btn,
.auth-card .btn:not(.full) { .auth-card .btn:not(.full) {
margin-top: 2px; margin-top: 2px;
} }
.settings-module .actions { .settings-module .actions,
.settings-submodule .actions {
gap: 7px; gap: 7px;
} }
@@ -1056,6 +1143,17 @@
padding: 10px; padding: 10px;
} }
.two-factor-passkey-row {
grid-template-columns: 2rem minmax(0, 1fr) auto;
gap: 8px;
}
.two-factor-passkey-row .btn {
grid-column: 3;
justify-self: end;
white-space: nowrap;
}
.account-passkey-status { .account-passkey-status {
justify-self: flex-start; justify-self: flex-start;
} }
@@ -1065,18 +1163,31 @@
width: 100%; width: 100%;
} }
.settings-module .totp-grid { .two-factor-passkey-register-row {
flex-direction: column;
align-items: stretch;
}
.two-factor-passkey-register-row .btn {
width: 100%;
}
.settings-module .totp-grid,
.settings-submodule .totp-grid {
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.settings-module .totp-qr { .settings-module .totp-qr,
.settings-submodule .totp-qr {
min-height: 132px; min-height: 132px;
padding: 8px; padding: 8px;
} }
.settings-module .totp-qr svg, .settings-module .totp-qr svg,
.settings-module .totp-qr img { .settings-module .totp-qr img,
.settings-submodule .totp-qr svg,
.settings-submodule .totp-qr img {
width: 118px; width: 118px;
height: 118px; height: 118px;
} }