Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 179bc83bfe | |||
| a722c66e5a | |||
| be756d5fc4 | |||
| abc13df7f9 | |||
| b8f7283eb3 | |||
| 8652dab880 | |||
| aa651568af | |||
| d72f41418c | |||
| ae23b47a92 | |||
| 982567acf9 | |||
| 59d903fbd7 | |||
| c9a42c4670 | |||
| 9758e80a02 | |||
| b8e7ce75f7 | |||
| a89c7811e1 | |||
| 800feb1d37 |
117
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,117 @@
|
||||
name: Test, Build & Deploy
|
||||
|
||||
# Tests + build run on every push/PR; the deploy (WebDAV publish of the
|
||||
# single-file dist/index.html) runs only on the default branch (main).
|
||||
"on":
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:run
|
||||
|
||||
- name: Build (single-file bundle)
|
||||
env:
|
||||
VITE_COMMIT_HASH: ${{ github.sha }}
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build output
|
||||
run: test -f dist/index.html
|
||||
|
||||
deploy:
|
||||
# Release the built app only when a commit lands on the default branch.
|
||||
needs: test-and-build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build (single-file bundle)
|
||||
env:
|
||||
VITE_COMMIT_HASH: ${{ github.sha }}
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build output
|
||||
run: test -f dist/index.html
|
||||
|
||||
# Publish index.html + PWA companion files (manifest, sandbox service
|
||||
# worker, icons) to the WebDAV mirror. The pretty URL
|
||||
# (…/password_manager/index.html) 401s without auth; files live at
|
||||
# www/static/password_manager/<name> with basic auth.
|
||||
- name: Publish to WebDAV (/password_manager)
|
||||
env:
|
||||
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
||||
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then
|
||||
echo "::error::WEBDAV_USER / WEBDAV_PASS repo secrets are not set."
|
||||
echo "::error::Add them under Settings -> Actions -> Secrets, then re-run this job."
|
||||
exit 1
|
||||
fi
|
||||
publish() {
|
||||
local src="$1" dst="$2" ctype="$3"
|
||||
# DELETE-then-PUT: the serving layer caches uploaded files, so a plain
|
||||
# PUT over an existing file keeps serving the OLD copy.
|
||||
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X DELETE "$dst" || echo "(DELETE non-zero; may be a new file -- continuing)"
|
||||
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" -X PUT -T "$src" -H "Content-Type: $ctype" "$dst"
|
||||
echo "Published $src -> $dst"
|
||||
}
|
||||
# Ensure the icons/ subfolder exists.
|
||||
curl -sS -u "$WEBDAV_USER:$WEBDAV_PASS" -X MKCOL "$WEBDAV_BASE/icons" -o /dev/null || echo "(icons dir exists or MKCOL unsupported -- continuing)"
|
||||
publish dist/index.html "$WEBDAV_BASE/index.html" 'text/html'
|
||||
publish dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest" 'application/manifest+json'
|
||||
publish dist/sw.js "$WEBDAV_BASE/sw.js" 'application/javascript; charset=utf-8'
|
||||
publish dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png" 'image/png'
|
||||
publish dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png" 'image/png'
|
||||
publish dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png" 'image/png'
|
||||
|
||||
# Pull the freshly uploaded files back and compare bytes, so a successful
|
||||
# deploy isn't just "curl returned 0" but the served copy actually matches.
|
||||
- name: Verify deployed files match build
|
||||
env:
|
||||
WEBDAV_USER: ${{ secrets.WEBDAV_USER }}
|
||||
WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }}
|
||||
WEBDAV_BASE: https://files.thecookiejar.me/www/static/password_manager
|
||||
run: |
|
||||
set -e
|
||||
verify() {
|
||||
local src="$1" dst="$2"
|
||||
curl -sS --fail -u "$WEBDAV_USER:$WEBDAV_PASS" "$dst" -o "/tmp/deployed_$(basename "$src")"
|
||||
sha1sum "$src" "/tmp/deployed_$(basename "$src")"
|
||||
test "$(sha1sum "$src" | cut -d' ' -f1)" = \
|
||||
"$(sha1sum "/tmp/deployed_$(basename "$src")" | cut -d' ' -f1)" \
|
||||
|| { echo "::error::Deployed $src does not match build"; exit 1; }
|
||||
echo "Verified $src bytes match"
|
||||
}
|
||||
verify dist/index.html "$WEBDAV_BASE/index.html"
|
||||
verify dist/manifest.webmanifest "$WEBDAV_BASE/manifest.webmanifest"
|
||||
verify dist/sw.js "$WEBDAV_BASE/sw.js"
|
||||
verify dist/icons/icon-192.png "$WEBDAV_BASE/icons/icon-192.png"
|
||||
verify dist/icons/icon-512.png "$WEBDAV_BASE/icons/icon-512.png"
|
||||
verify dist/icons/icon-maskable-512.png "$WEBDAV_BASE/icons/icon-maskable-512.png"
|
||||
echo "All deployed files verified"
|
||||
19
AGENTS.md
@ -75,16 +75,27 @@ Password verification uses a test payload (random string encrypted at vault crea
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB.
|
||||
- Only `encryptedPassword` and `encryptedTotpSecret` are encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB. Passwords and TOTP secrets are optional on an entry.
|
||||
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
|
||||
- Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min).
|
||||
- Clipboard auto-clears after 15 seconds.
|
||||
- No browser fingerprinting or anti-keylogger protections.
|
||||
|
||||
## Export
|
||||
## TOTP (2FA)
|
||||
|
||||
- `exportSelected(groupIds)` replaces the old `exportAll()` — accepts an array of group IDs to export. Pass `null` or `[]` for a full export. Vault meta (salt, test payload) is always included for import decryption.
|
||||
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count.
|
||||
- `src/lib/crypto/totp.js`: native RFC 6238 TOTP via Web Crypto HMAC-SHA1 (no external lib). Exports `generateTotp(secret, {timestamp, period=30, digits=6})`, `base32Decode`, `extractSecret` (accepts bare base32 OR `otpauth://` URI), `totpRemainingSeconds`.
|
||||
- Entries store an optional `encryptedTotpSecret` (AES-GCM, like passwords). EntryForm accepts a base32 secret or otpauth:// URI; EntryDetail shows a live, copyable 6-digit code with a 1s countdown + urgency color, cleaned up via `onDestroy`.
|
||||
- Export/import re-key `encryptedTotpSecret` alongside passwords for sealed exports (and decrypt/re-encrypt on import), so TOTP survives migration between vaults.
|
||||
- Note: `extractSecret` must strip hyphens from hyphen-grouped secrets (authenticator display style) as well as whitespace.
|
||||
|
||||
## Export / Import
|
||||
|
||||
- `exportSelected(groupIds, options)` — group IDs to export; `null`/`[]` = full export (include `''` for ungrouped). `options = { vaultKey, password = '', useExistingPassword = false }`. An explicit protection choice is required; every protected export AES-256-GCM-seals the whole payload (titles/usernames/notes included) into `{ format: 'encrypted-export', salt, data }`.
|
||||
- **New password** (`options.password`): re-keys every exported entry's password to a key derived from that password + a fresh random salt (embedded in the envelope), so import needs only this password — never the vault's.
|
||||
- **Reuse existing password** (`options.useExistingPassword`): seals with the vault's own key and keeps the vault's salt embedded, so import derives the key from the vault master password. No second password to create/remember.
|
||||
- **No option set**: falls back to a plaintext JSON export (entries keep source-vault-encrypted passwords) for backward compatibility; the UI never offers this.
|
||||
- `importAll(data, mode, password, targetKey)` detects a sealed export (`data.format === 'encrypted-export'`): `password` is whichever password opens the envelope — a separate export password OR the source vault's master password (for reuse-existing). A wrong password rejects the import (never silently skips). The entry loop decrypts with the reproduced source key and re-encrypts under `targetKey`.
|
||||
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. The export dialog forces an explicit choice via two radio options: "Use a new password" (shows a short password field, validated non-empty) or "Reuse my vault password". The import dialog's single password field is generic (export password for sealed files, source vault master for plain/old files).
|
||||
|
||||
## Known Bug Fixes
|
||||
|
||||
|
||||
BIN
dist/icons/icon-192.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
dist/icons/icon-512.png
vendored
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
dist/icons/icon-maskable-512.png
vendored
Normal file
|
After Width: | Height: | Size: 26 KiB |
1120
dist/index.html
vendored
19
dist/manifest.webmanifest
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Password Vault",
|
||||
"short_name": "Vault",
|
||||
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"display_override": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0f1117",
|
||||
"theme_color": "#6c63ff",
|
||||
"categories": ["productivity", "utilities", "security"],
|
||||
"lang": "en",
|
||||
"icons": [
|
||||
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
58
dist/sw.js
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Password Vault service worker — a NETWORK SANDBOX.
|
||||
*
|
||||
* The vault is offline-first (single-file, zero external deps). When installed
|
||||
* as a PWA this worker enforces that: it allows requests ONLY for the app's own
|
||||
* same-origin static resources (index.html, manifest, icons, and the worker
|
||||
* itself) and rejects every cross-origin/third-party request.
|
||||
*
|
||||
* What this guards against: if a page within the PWA scope tries to exfiltrate
|
||||
* or inject via <img>, <script src>, fetch(), an external manifest link, etc.,
|
||||
* the worker refuses it before it leaves the device.
|
||||
*
|
||||
* Residual boundaries covered by the CSP in index.html (which a service worker
|
||||
* cannot catch): WebSockets (connect-src 'none') and user-initiated top-level
|
||||
* navigation. Form POSTs are blocked by form-action 'none'. img/media that a
|
||||
* site tries to load are blocked here AND by img-src 'self'.
|
||||
*/
|
||||
|
||||
const VERSION = '1.0.0'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', () => {
|
||||
// Claim control of any pages already open in this scope.
|
||||
self.clients?.claim?.()
|
||||
})
|
||||
|
||||
/** URL/string->URL normalizer (worker self.location is unreliable in type). */
|
||||
const toURL = (u) => (u instanceof URL) ? u : new URL(String(u))
|
||||
|
||||
/** Is this an in-scope resource for the app itself? */
|
||||
function isAppOwnResource(url) {
|
||||
const selfLoc = toURL(self.location)
|
||||
// Cross-origin is always denied.
|
||||
if (url.origin !== selfLoc.origin) return false
|
||||
// Same-origin is only allowed WITHIN the worker's own directory scope (the
|
||||
// index.html, manifest, and icons the app ships). A same-origin request to
|
||||
// another app/dir on the same server is outside the sandbox and denied.
|
||||
const dir = selfLoc.pathname.slice(0, selfLoc.pathname.lastIndexOf('/') + 1)
|
||||
return url.pathname.startsWith(dir)
|
||||
}
|
||||
|
||||
/** Block cross-origin requests; let same-origin app resources through. */
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (!isAppOwnResource(url)) {
|
||||
event.respondWith(Promise.resolve(new Response(null, { status: 403, statusText: 'Blocked by Vault net policy' })))
|
||||
return
|
||||
}
|
||||
// Same-origin: pass through to network (covers reload of index.html and the
|
||||
// manifest/icons). We do not cache, so the app is always the current build.
|
||||
event.respondWith(fetch(event.request).catch(() => {
|
||||
// Network fallback: serve a minimal offline placeholder so a launched app
|
||||
// never hangs open on a dead link. The real HTML is never cached here.
|
||||
return new Response('offline', { status: 200 })
|
||||
}))
|
||||
})
|
||||
|
||||
console.log(`[vault-sw] ${VERSION} active — network sandbox on`)
|
||||
27
index.html
@ -2,8 +2,33 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Dev CSP permits the Vite HMR WebSocket + same-origin dev assets. The
|
||||
production build (scripts/inline-assets.js) swaps this for the strict
|
||||
sandbox CSP (connect-src 'none', form/object denied). Clickjacking
|
||||
(frame-ancestors / X-Frame-Options) is an HTTP-header concern and must
|
||||
be set by the serving layer, not in a meta tag. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
font-src 'self' data:;
|
||||
media-src 'self' data:;
|
||||
connect-src 'self' ws: wss:;
|
||||
form-action 'self';
|
||||
object-src 'none';
|
||||
base-uri 'none';
|
||||
manifest-src 'self'
|
||||
" />
|
||||
<meta name="theme-color" content="#6c63ff" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Vault" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>Password Vault</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
19
public/manifest.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Password Vault",
|
||||
"short_name": "Vault",
|
||||
"description": "Offline-first password manager. Encrypted locally in your browser; never leaves your device.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"display_override": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0f1117",
|
||||
"theme_color": "#6c63ff",
|
||||
"categories": ["productivity", "utilities", "security"],
|
||||
"lang": "en",
|
||||
"icons": [
|
||||
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
19
public/pwa-icon.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#6c63ff"/>
|
||||
<stop offset="1" stop-color="#3b2f8f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)"/>
|
||||
<!-- Padlock body -->
|
||||
<ellipse cx="256" cy="300" rx="150" ry="108" fill="#e4e6f0" opacity="0.95"/>
|
||||
<!-- Padlock shackle -->
|
||||
<path d="M 190 200 L 210 120 Q 222 96 256 92 Q 290 96 302 120 L 322 200"
|
||||
fill="none" stroke="#e4e6f0" stroke-width="40" stroke-linecap="round" opacity="0.95"/>
|
||||
<!-- Keyhole -->
|
||||
<circle cx="256" cy="292" r="30" fill="#0f1117"/>
|
||||
<path d="M 256 322 L 256 352 L 244 352 L 268 352" fill="#0f1117"/>
|
||||
<!-- Satin highlight -->
|
||||
<ellipse cx="256" cy="204" rx="96" ry="14" fill="rgba(255,255,255,0.28)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 910 B |
58
public/sw.js
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Password Vault service worker — a NETWORK SANDBOX.
|
||||
*
|
||||
* The vault is offline-first (single-file, zero external deps). When installed
|
||||
* as a PWA this worker enforces that: it allows requests ONLY for the app's own
|
||||
* same-origin static resources (index.html, manifest, icons, and the worker
|
||||
* itself) and rejects every cross-origin/third-party request.
|
||||
*
|
||||
* What this guards against: if a page within the PWA scope tries to exfiltrate
|
||||
* or inject via <img>, <script src>, fetch(), an external manifest link, etc.,
|
||||
* the worker refuses it before it leaves the device.
|
||||
*
|
||||
* Residual boundaries covered by the CSP in index.html (which a service worker
|
||||
* cannot catch): WebSockets (connect-src 'none') and user-initiated top-level
|
||||
* navigation. Form POSTs are blocked by form-action 'none'. img/media that a
|
||||
* site tries to load are blocked here AND by img-src 'self'.
|
||||
*/
|
||||
|
||||
const VERSION = '1.0.0'
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', () => {
|
||||
// Claim control of any pages already open in this scope.
|
||||
self.clients?.claim?.()
|
||||
})
|
||||
|
||||
/** URL/string->URL normalizer (worker self.location is unreliable in type). */
|
||||
const toURL = (u) => (u instanceof URL) ? u : new URL(String(u))
|
||||
|
||||
/** Is this an in-scope resource for the app itself? */
|
||||
function isAppOwnResource(url) {
|
||||
const selfLoc = toURL(self.location)
|
||||
// Cross-origin is always denied.
|
||||
if (url.origin !== selfLoc.origin) return false
|
||||
// Same-origin is only allowed WITHIN the worker's own directory scope (the
|
||||
// index.html, manifest, and icons the app ships). A same-origin request to
|
||||
// another app/dir on the same server is outside the sandbox and denied.
|
||||
const dir = selfLoc.pathname.slice(0, selfLoc.pathname.lastIndexOf('/') + 1)
|
||||
return url.pathname.startsWith(dir)
|
||||
}
|
||||
|
||||
/** Block cross-origin requests; let same-origin app resources through. */
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (!isAppOwnResource(url)) {
|
||||
event.respondWith(Promise.resolve(new Response(null, { status: 403, statusText: 'Blocked by Vault net policy' })))
|
||||
return
|
||||
}
|
||||
// Same-origin: pass through to network (covers reload of index.html and the
|
||||
// manifest/icons). We do not cache, so the app is always the current build.
|
||||
event.respondWith(fetch(event.request).catch(() => {
|
||||
// Network fallback: serve a minimal offline placeholder so a launched app
|
||||
// never hangs open on a dead link. The real HTML is never cached here.
|
||||
return new Response('offline', { status: 200 })
|
||||
}))
|
||||
})
|
||||
|
||||
console.log(`[vault-sw] ${VERSION} active — network sandbox on`)
|
||||
@ -30,11 +30,14 @@ if (existsSync(faviconPath)) {
|
||||
console.log('[inline-assets] Inlined favicon.svg into index.html')
|
||||
}
|
||||
|
||||
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler)
|
||||
const iconsPath = join(distDir, 'icons.svg')
|
||||
if (existsSync(iconsPath)) {
|
||||
rmSync(iconsPath)
|
||||
console.log('[inline-assets] Removed icons.svg')
|
||||
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
|
||||
// and the source pwa-icon.svg which is only needed to regenerate the PNGs).
|
||||
for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
|
||||
const p = join(distDir, leftover)
|
||||
if (existsSync(p)) {
|
||||
rmSync(p)
|
||||
console.log(`[inline-assets] Removed ${leftover}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove assets directory if it exists
|
||||
@ -44,4 +47,43 @@ if (existsSync(assetsDir)) {
|
||||
console.log('[inline-assets] Removed assets/ directory')
|
||||
}
|
||||
|
||||
console.log('[inline-assets] Done — dist/ contains only index.html')
|
||||
// ---- Production network sandbox CSP -----------------------------
|
||||
// Replace the dev CSP with the strict, shipped one. The app is single-file and
|
||||
// offline-first, so script/style keep 'unsafe-inline' (they are inline), but
|
||||
// every channel that could exfiltrate vault data is denied at the browser level:
|
||||
// connect-src 'none' (no fetch/XHR/WebSocket anywhere), form-action 'none',
|
||||
// object-src 'none', base-uri 'none'. (Clickjacking via frame-ancestors /
|
||||
// X-Frame-Options is a header-only directive and must be set by the serving
|
||||
// layer, so it is intentionally absent here.)
|
||||
const indexPath = join(distDir, 'index.html')
|
||||
let html = readFileSync(indexPath, 'utf8')
|
||||
const PROD_CSP = [
|
||||
"default-src 'self';",
|
||||
"script-src 'self' 'unsafe-inline';",
|
||||
"style-src 'self' 'unsafe-inline';",
|
||||
"img-src 'self' data:;",
|
||||
"font-src 'self' data:;",
|
||||
"media-src 'self' data:;",
|
||||
"connect-src 'none';",
|
||||
"form-action 'none';",
|
||||
"object-src 'none';",
|
||||
"base-uri 'none';",
|
||||
"manifest-src 'self';",
|
||||
'upgrade-insecure-requests',
|
||||
].join('\n ')
|
||||
html = html.replace(
|
||||
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/>/i,
|
||||
`<meta http-equiv="Content-Security-Policy" content="\n ${PROD_CSP}\n " />`
|
||||
)
|
||||
writeFileSync(indexPath, html)
|
||||
console.log('[inline-assets] Replaced dev CSP with strict production sandbox')
|
||||
|
||||
// ---- Keep the PWA companion files -------------------------------
|
||||
// These MUST ship alongside index.html so the app is installable: the web
|
||||
// manifest, the network-sandbox service worker, and the icon set.
|
||||
for (const keep of ['manifest.webmanifest', 'sw.js', 'icons']) {
|
||||
const p = join(distDir, keep)
|
||||
if (existsSync(p)) console.log(`[inline-assets] Preserved PWA ${keep}: ${p}`)
|
||||
}
|
||||
|
||||
console.log('[inline-assets] Done — dist/ contains index.html + PWA files')
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte'
|
||||
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
|
||||
import { decrypt } from '../lib/crypto/crypto.js'
|
||||
import { generateTotp, totpRemainingSeconds } from '../lib/crypto/totp.js'
|
||||
import { app } from '../lib/stores/app.svelte.js'
|
||||
import { isTrashGroup } from '../lib/models/schema.js'
|
||||
|
||||
@ -9,6 +11,9 @@
|
||||
let entry = $state(null)
|
||||
let passwordVisible = $state(false)
|
||||
let decryptedPassword = $state('')
|
||||
let totpCode = $state('')
|
||||
let totpRemaining = $state(30)
|
||||
let totalRemaining = $state(false)
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
let showDeleteConfirm = $state(false)
|
||||
@ -24,7 +29,12 @@
|
||||
try {
|
||||
entry = await getEntryById(entryId)
|
||||
if (entry && app.encryptionKey) {
|
||||
decryptedPassword = await decrypt(entry.encryptedPassword, app.encryptionKey)
|
||||
decryptedPassword = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, app.encryptionKey)
|
||||
await refreshTotp(secret)
|
||||
startTotpTimer(secret)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to load entry: ' + e.message
|
||||
@ -34,6 +44,34 @@
|
||||
|
||||
loadEntry()
|
||||
|
||||
let totpTimer = null
|
||||
|
||||
async function refreshTotp(secret) {
|
||||
try {
|
||||
totpCode = await generateTotp(secret)
|
||||
totpRemaining = totpRemainingSeconds()
|
||||
totalRemaining = totpRemaining <= 5
|
||||
} catch (e) {
|
||||
totpCode = ''
|
||||
totpRemaining = 0
|
||||
}
|
||||
}
|
||||
|
||||
function startTotpTimer(secret) {
|
||||
stopTotpTimer()
|
||||
totpTimer = setInterval(async () => {
|
||||
if (totpRemaining <= 1) await refreshTotp(secret)
|
||||
else totpRemaining -= 1
|
||||
totalRemaining = totpRemaining <= 5
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopTotpTimer() {
|
||||
if (totpTimer) { clearInterval(totpTimer); totpTimer = null }
|
||||
}
|
||||
|
||||
onDestroy(stopTotpTimer)
|
||||
|
||||
function showToast(message) {
|
||||
toast = message
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
@ -130,6 +168,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entry.encryptedPassword}
|
||||
<div class="detail-field">
|
||||
<span class="field-label">Password</span>
|
||||
<div class="field-value">
|
||||
@ -140,6 +179,23 @@
|
||||
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(decryptedPassword, 'Password')} title="Copy password">📋</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entry.encryptedTotpSecret}
|
||||
<div class="detail-field">
|
||||
<span class="field-label">2FA Code (TOTP)</span>
|
||||
<div class="field-value totp-value">
|
||||
<span class:totp-urgent={totalRemaining} class="totp-code">
|
||||
{totpCode ? String(totpCode).replace(/^(.{3})/, '$1 ') : ''}
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(totpCode, '2FA code')} title="Copy 2FA code">📋</button>
|
||||
</div>
|
||||
<div class="totp-remaining" aria-hidden="true">
|
||||
<span class="totp-dot" class:urgent={totalRemaining}></span>
|
||||
<span class="text-xs text-muted">{totpRemaining}s</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if entry.url}
|
||||
<div class="detail-field">
|
||||
@ -309,6 +365,34 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.totp-value .totp-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.totp-value .totp-code.totp-urgent {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.totp-remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.totp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
display: inline-block;
|
||||
}
|
||||
.totp-dot.urgent {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { encrypt, decrypt } from '../lib/crypto/crypto.js'
|
||||
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
|
||||
import { generatePassword } from '../lib/crypto/crypto.js'
|
||||
import { validateTotpSecret } from '../lib/crypto/totp.js'
|
||||
import { app } from '../lib/stores/app.svelte.js'
|
||||
import { search as searchStore } from '../lib/stores/search.svelte.js'
|
||||
import { autofocus } from '../lib/autofocus.js'
|
||||
@ -15,6 +16,7 @@
|
||||
let url = $state('')
|
||||
let notes = $state('')
|
||||
let groupId = $state('')
|
||||
let totpSecret = $state('')
|
||||
let passwordVisible = $state(false)
|
||||
let groups = $state([])
|
||||
let loading = $state(true)
|
||||
@ -22,6 +24,7 @@
|
||||
let saving = $state(false)
|
||||
let isEdit = $state(false)
|
||||
let formErrors = $state([])
|
||||
let totpError = $state('')
|
||||
|
||||
async function loadForm() {
|
||||
loading = true
|
||||
@ -33,7 +36,8 @@
|
||||
if (entry) {
|
||||
title = entry.title
|
||||
username = entry.username
|
||||
password = await decrypt(entry.encryptedPassword, app.encryptionKey)
|
||||
password = entry.encryptedPassword ? await decrypt(entry.encryptedPassword, app.encryptionKey) : ''
|
||||
totpSecret = entry.encryptedTotpSecret ? await decrypt(entry.encryptedTotpSecret, app.encryptionKey) : ''
|
||||
url = entry.url || ''
|
||||
notes = entry.notes || ''
|
||||
groupId = entry.groupId || ''
|
||||
@ -66,7 +70,20 @@
|
||||
return
|
||||
}
|
||||
|
||||
const encryptedPassword = await encrypt(password, app.encryptionKey)
|
||||
// TOTP secret is optional, but if provided it must be valid.
|
||||
const cleanTotp = totpSecret.trim()
|
||||
if (cleanTotp) {
|
||||
const totpCheck = validateTotpSecret(cleanTotp)
|
||||
if (!totpCheck.valid) {
|
||||
totpError = totpCheck.error
|
||||
saving = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const encryptedPassword = password ? await encrypt(password, app.encryptionKey) : ''
|
||||
// Accept raw base32 or an otpauth:// URI; store encrypted only when given.
|
||||
const encryptedTotpSecret = cleanTotp ? await encrypt(cleanTotp, app.encryptionKey) : ''
|
||||
|
||||
if (isEdit) {
|
||||
const existing = await getEntryById(entryId)
|
||||
@ -74,6 +91,7 @@
|
||||
title,
|
||||
username,
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url,
|
||||
notes,
|
||||
groupId,
|
||||
@ -84,6 +102,7 @@
|
||||
title,
|
||||
username,
|
||||
encryptedPassword,
|
||||
encryptedTotpSecret,
|
||||
url,
|
||||
notes,
|
||||
groupId,
|
||||
@ -128,7 +147,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password *</label>
|
||||
<label for="password">Password</label>
|
||||
<div class="password-input-group">
|
||||
<input
|
||||
id="password"
|
||||
@ -145,6 +164,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="totp">TOTP Secret (2FA) — optional</label>
|
||||
<input
|
||||
id="totp"
|
||||
type="text"
|
||||
bind:value={totpSecret}
|
||||
oninput={() => totpError = ''}
|
||||
class:input-error={totpError}
|
||||
placeholder="Base32 secret or otpauth:// URI (e.g. JBSWY3DPEHPK3PXP)"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
{#if totpError}
|
||||
<p class="field-error">⚠ {totpError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="url">URL</label>
|
||||
<input id="url" type="url" bind:value={url} placeholder="https://example.com" />
|
||||
@ -215,6 +251,16 @@
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-danger);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.password-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@ -22,6 +22,8 @@
|
||||
let exporting = $state(false)
|
||||
let sourcePassword = $state('')
|
||||
let parsedFileData = $state(null)
|
||||
let exportPassword = $state('')
|
||||
let exportUseExistingPassword = $state(false)
|
||||
|
||||
// Group selection for export
|
||||
let allGroups = $state([])
|
||||
@ -35,9 +37,22 @@
|
||||
)
|
||||
|
||||
async function handleExport() {
|
||||
if (!exportUseExistingPassword && !exportPassword.trim()) {
|
||||
importError = 'Choose a new password to encrypt the export'
|
||||
return
|
||||
}
|
||||
exporting = true
|
||||
try {
|
||||
exportData = await exportSelected(selectedGroupIds.length === allGroups.length ? null : selectedGroupIds)
|
||||
exportData = await exportSelected(
|
||||
selectedGroupIds.length === allGroups.length ? null : selectedGroupIds,
|
||||
{
|
||||
vaultKey: app.encryptionKey,
|
||||
password: exportUseExistingPassword ? '' : exportPassword.trim(),
|
||||
useExistingPassword: exportUseExistingPassword,
|
||||
}
|
||||
)
|
||||
exportPassword = ''
|
||||
exportUseExistingPassword = false
|
||||
const json = JSON.stringify(exportData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@ -137,7 +152,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<h3>Export Vault</h3>
|
||||
<p>Select which groups to export. You'll need the source vault's master password when importing into another vault.</p>
|
||||
<p>Select which groups to export. Choose how to protect the file - either with a new password, or reuse the password that unlocks this vault.</p>
|
||||
|
||||
<div class="group-select-header">
|
||||
<label class="checkbox-label">
|
||||
@ -161,6 +176,29 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="protection-choice">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="exportProtection" checked={!exportUseExistingPassword} onchange={() => exportUseExistingPassword = false} />
|
||||
<span>Use a new password</span>
|
||||
</label>
|
||||
{#if !exportUseExistingPassword}
|
||||
<div class="form-group">
|
||||
<input
|
||||
id="export-password"
|
||||
type="password"
|
||||
bind:value={exportPassword}
|
||||
placeholder="Enter a new password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="exportProtection" checked={exportUseExistingPassword} onchange={() => exportUseExistingPassword = true} />
|
||||
<span>Reuse my vault password</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
|
||||
{exporting ? 'Exporting...' : '📤 Export JSON'}
|
||||
@ -190,15 +228,15 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if parsedFileData}
|
||||
<p>File loaded. Enter the <strong>source vault's master password</strong> to decrypt and re-encrypt entries under your current vault.</p>
|
||||
<p>File loaded. Enter the password protecting this file — for an encrypted export that is the <strong>export password</strong> you chose; for a plain export it is the <strong>source vault's master password</strong>.</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source-password" class="file-label">Source vault password</label>
|
||||
<label for="source-password" class="file-label">Password for this file</label>
|
||||
<input
|
||||
id="source-password"
|
||||
type="password"
|
||||
bind:value={sourcePassword}
|
||||
placeholder="Enter source vault password"
|
||||
placeholder="Enter the export or source vault password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
@ -355,6 +393,21 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.protection-choice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.protection-choice .radio-label {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.protection-choice .form-group {
|
||||
margin: 2px 0 6px 24px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { app } from '../lib/stores/app.svelte.js'
|
||||
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.js'
|
||||
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup } from '../lib/storage/db.js'
|
||||
import { saveVaultMeta, loadVaultMeta, isVaultInitialized, ensureTrashGroup, getSetting } from '../lib/storage/db.js'
|
||||
import { startAutoLock } from '../lib/stores/security.svelte.js'
|
||||
import { settings } from '../lib/stores/settings.svelte.js'
|
||||
import { autofocus } from '../lib/autofocus.js'
|
||||
@ -19,6 +19,29 @@
|
||||
}
|
||||
checkVault()
|
||||
|
||||
// Visibility of the web-server warning is driven by the persisted setting
|
||||
// (dismissedLocalWarning). It is loaded directly on mount so the banner
|
||||
// reflects the user's stored choice immediately when the lock screen shows,
|
||||
// before any unlock happens. `warningReady` avoids a one-frame flash while
|
||||
// the value is read from IndexedDB.
|
||||
let warningReady = $state(false)
|
||||
|
||||
async function loadWarningSetting() {
|
||||
try {
|
||||
settings.dismissedLocalWarning = Boolean(await getSetting('dismissedLocalWarning'))
|
||||
} catch (e) {
|
||||
console.warn('Failed to load warning setting:', e)
|
||||
}
|
||||
warningReady = true
|
||||
}
|
||||
loadWarningSetting()
|
||||
|
||||
async function dismissLocalWarning() {
|
||||
settings.dismissedLocalWarning = true
|
||||
warningReady = true
|
||||
try { await settings.save() } catch (e) { console.warn('Failed to persist warning dismissal:', e) }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
error = ''
|
||||
loading = true
|
||||
@ -88,8 +111,11 @@
|
||||
<h1>Password Vault</h1>
|
||||
<p class="subtitle">{isSetup ? 'Create your vault' : 'Unlock your vault'}</p>
|
||||
|
||||
{#if notLocal}
|
||||
<div class="warning-banner" role="alert">This HTML file is intended for offline use.</div>
|
||||
{#if notLocal && warningReady && !settings.dismissedLocalWarning}
|
||||
<div class="warning-banner" role="alert">
|
||||
<span class="warning-text">You're viewing this from a web server. For best security, download this HTML file and open it locally on your computer instead.</span>
|
||||
<button class="warning-dismiss" onclick={dismissLocalWarning} aria-label="Dismiss warning">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
@ -196,13 +222,35 @@
|
||||
|
||||
.warning-banner {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 10px 10px 14px;
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border: 1px solid rgba(230, 168, 0, 0.5);
|
||||
border-radius: var(--radius-md);
|
||||
color: #b8860b;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.warning-dismiss {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.warning-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hint {
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
// Local copies so the user can cancel without losing values
|
||||
let minutes = $state(settings.autoLockMinutes)
|
||||
let lockOnTabSwitch = $state(settings.lockOnTabSwitch)
|
||||
// Inverse of dismissedLocalWarning: on = show the web-server security banner.
|
||||
let showLocalWarning = $state(!settings.dismissedLocalWarning)
|
||||
let saving = $state(false)
|
||||
|
||||
const minuteOptions = [1, 5, 10, 15, 30, 60]
|
||||
@ -16,6 +18,7 @@
|
||||
try {
|
||||
settings.autoLockMinutes = minutes
|
||||
settings.lockOnTabSwitch = lockOnTabSwitch
|
||||
settings.dismissedLocalWarning = !showLocalWarning
|
||||
await settings.save()
|
||||
startAutoLock()
|
||||
} catch (e) {
|
||||
@ -29,6 +32,7 @@
|
||||
$effect(() => {
|
||||
minutes = settings.autoLockMinutes
|
||||
lockOnTabSwitch = settings.lockOnTabSwitch
|
||||
showLocalWarning = !settings.dismissedLocalWarning
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -67,6 +71,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="toggle-label" for="show-local-warning">
|
||||
<input
|
||||
id="show-local-warning"
|
||||
type="checkbox"
|
||||
bind:checked={showLocalWarning}
|
||||
/>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-text">Show web-server warning</span>
|
||||
</label>
|
||||
<p class="text-muted text-xs mt-1">
|
||||
{showLocalWarning
|
||||
? 'Shows a reminder on the lock screen to use the vault from a downloaded local file.'
|
||||
: 'The lock-screen web-server warning banner is hidden.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
|
||||
159
src/lib/crypto/totp.js
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* TOTP (Time-based One-Time Password) — RFC 6238 / RFC 4226.
|
||||
*
|
||||
* Implemented with the browser's native Web Crypto API (HMAC-SHA1), so no
|
||||
* external crypto dependency. A base32 secret yields 6-digit codes that change
|
||||
* every 30 seconds, matching common 2FA authenticator apps.
|
||||
*/
|
||||
|
||||
const DEFAULT_PERIOD = 30
|
||||
const DEFAULT_DIGITS = 6
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
/**
|
||||
* Decode a base32 string (RFC 4648) into bytes. Accepts whitespace (from
|
||||
* authenticator-exported secrets). Throws on invalid characters.
|
||||
*
|
||||
* @param {string} base32
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function base32Decode(base32) {
|
||||
const clean = String(base32).toUpperCase().replace(/[\s=]/g, '')
|
||||
if (!clean) return new Uint8Array(0)
|
||||
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const bytes = []
|
||||
for (const ch of clean) {
|
||||
const idx = BASE32_ALPHABET.indexOf(ch)
|
||||
if (idx === -1) throw new Error(`Invalid base32 character: "${ch}"`)
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >>> (bits - 8)) & 0xff)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a raw base32 secret from the user input, accepting either a bare
|
||||
* base32 string or an otpauth:// URI (which embeds `secret=`).
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string} Base32 secret (uppercased, no whitespace)
|
||||
*/
|
||||
export function extractSecret(input) {
|
||||
const text = String(input || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^otpauth:\/\//i.test(text)) {
|
||||
try {
|
||||
const m = text.match(/[?&]secret=([^&]+)/i)
|
||||
if (m) return m[1].toUpperCase().replace(/[^A-Z2-7]/g, '')
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
// Bare base32 (optionally hyphen-grouped as authenticators display it).
|
||||
return text.toUpperCase().replace(/[\s-]/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a TOTP secret entered by the user (bare base32 or otpauth:// URI).
|
||||
*
|
||||
* The secret is optional — an empty string validates as OK. When provided, it
|
||||
* must decode to a valid base32 key of at least `minBytes` bytes so garbage,
|
||||
* typos, or wrong-format input is rejected before it is stored.
|
||||
*
|
||||
* @param {string} input
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.minBytes=10] - Minimum decoded secret length (RFC 6238
|
||||
* recommends >= 80 bits / 10 bytes; most real 2FA secrets exceed this).
|
||||
* @returns {{ valid: boolean, error: string }}
|
||||
*/
|
||||
export function validateTotpSecret(input, { minBytes = 10 } = {}) {
|
||||
const text = String(input || '').trim()
|
||||
if (!text) return { valid: true, error: '' }
|
||||
|
||||
let secret
|
||||
try {
|
||||
secret = extractSecret(text)
|
||||
} catch {
|
||||
return { valid: false, error: 'Could not read the TOTP secret.' }
|
||||
}
|
||||
|
||||
if (!secret) {
|
||||
return { valid: false, error: 'No TOTP secret found. Paste a base32 code or an otpauth:// link.' }
|
||||
}
|
||||
|
||||
let bytes
|
||||
try {
|
||||
bytes = base32Decode(secret)
|
||||
} catch (e) {
|
||||
return { valid: false, error: `Invalid TOTP secret: ${e.message}` }
|
||||
}
|
||||
|
||||
if (bytes.length === 0) {
|
||||
return { valid: false, error: 'The TOTP secret is empty.' }
|
||||
}
|
||||
if (bytes.length < minBytes) {
|
||||
return { valid: false, error: `TOTP secret is too short (needs at least ${minBytes} characters decoded).` }
|
||||
}
|
||||
|
||||
return { valid: true, error: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the 8-byte big-endian counter for a Unix timestamp.
|
||||
* @param {number} counter
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function counterBytes(counter) {
|
||||
const buf = new Uint8Array(8)
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
buf[i] = counter & 0xff
|
||||
counter = Math.floor(counter / 256)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a TOTP code for a secret at a given Unix timestamp.
|
||||
*
|
||||
* @param {string} secret - Base32 secret (SCHEME uri also accepted via extractSecret)
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000] - Unix seconds
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.digits=6]
|
||||
* @returns {Promise<string>} Zero-padded code.
|
||||
*/
|
||||
export async function generateTotp(secret, { timestamp = Math.floor(Date.now() / 1000), period = DEFAULT_PERIOD, digits = DEFAULT_DIGITS } = {}) {
|
||||
const keyBytes = base32Decode(extractSecret(secret))
|
||||
if (keyBytes.length === 0) {
|
||||
throw new Error('TOTP secret is empty or invalid')
|
||||
}
|
||||
|
||||
const counter = Math.floor(timestamp / period)
|
||||
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
|
||||
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes(counter)))
|
||||
|
||||
// Dynamic truncation (RFC 4226 section 5.3)
|
||||
const offset = sig[sig.length - 1] & 0x0f
|
||||
const bin = ((sig[offset] & 0x7f) << 24) | (sig[offset + 1] << 16) | (sig[offset + 2] << 8) | sig[offset + 3]
|
||||
const code = bin % Math.pow(10, digits)
|
||||
|
||||
return code.toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds remaining before the current TOTP code expires.
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.period=30]
|
||||
* @param {number} [opts.timestamp=Date.now()/1000]
|
||||
* @returns {number} 1..period
|
||||
*/
|
||||
export function totpRemainingSeconds({ period = DEFAULT_PERIOD, timestamp = Math.floor(Date.now() / 1000) } = {}) {
|
||||
return period - (timestamp % period)
|
||||
}
|
||||
@ -30,7 +30,8 @@ export function generateId() {
|
||||
* @property {string} id - Unique identifier
|
||||
* @property {string} title - Display name (e.g. "GitHub", "Gmail")
|
||||
* @property {string} [username] - Login username or email (optional)
|
||||
* @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string)
|
||||
* @property {string} [encryptedPassword] - AES-GCM encrypted password blob (JSON string); optional
|
||||
* @property {string} [encryptedTotpSecret] - AES-GCM encrypted TOTP base32 secret (JSON string); optional
|
||||
* @property {string} [url] - Website URL
|
||||
* @property {string} [notes] - Free-form notes
|
||||
* @property {string} [groupId] - Reference to a Group id (empty string = no group)
|
||||
@ -45,7 +46,8 @@ export function generateId() {
|
||||
* @param {Object} data
|
||||
* @param {string} data.title
|
||||
* @param {string} [data.username]
|
||||
* @param {string} data.encryptedPassword - Must already be encrypted
|
||||
* @param {string} [data.encryptedPassword] - Must already be encrypted (optional; empty string = no password)
|
||||
* @param {string} [data.encryptedTotpSecret] - Must already be encrypted (optional; empty = no TOTP)
|
||||
* @param {string} [data.url]
|
||||
* @param {string} [data.notes]
|
||||
* @param {string} [data.groupId]
|
||||
@ -59,6 +61,7 @@ export function createEntry(data) {
|
||||
title: data.title.trim(),
|
||||
username: data.username?.trim() || '',
|
||||
encryptedPassword: data.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret,
|
||||
url: data.url?.trim() || '',
|
||||
notes: data.notes?.trim() || '',
|
||||
groupId: data.groupId || '',
|
||||
@ -81,6 +84,7 @@ export function updateEntry(existing, data) {
|
||||
title: data.title !== undefined ? data.title.trim() : existing.title,
|
||||
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
|
||||
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword,
|
||||
encryptedTotpSecret: data.encryptedTotpSecret !== undefined ? data.encryptedTotpSecret : existing.encryptedTotpSecret,
|
||||
url: data.url !== undefined ? data.url.trim() : existing.url,
|
||||
notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
|
||||
groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
|
||||
@ -127,8 +131,7 @@ export function createGroup(name, color) {
|
||||
export function validateEntry(data) {
|
||||
const errors = []
|
||||
if (!data.title || !data.title.trim()) errors.push('Title is required')
|
||||
|
||||
if (!data.encryptedPassword) errors.push('Password is required')
|
||||
// Password is optional — an entry may store no password at all.
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
|
||||
26
src/lib/pwa.js
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PWA bootstrap for the Password Vault.
|
||||
*
|
||||
* Registers the service worker (relative path, so it works both in dev at /
|
||||
* and in production under /password_manager/). The worker doubles as a network
|
||||
* sandbox (see public/sw.js): once installed, it blocks all cross-origin and
|
||||
* out-of-app requests. CSP in index.html backs it up (WebSockets/form/etc.).
|
||||
*
|
||||
* Registration is best-effort: it never blocks or breaks the app.
|
||||
*/
|
||||
|
||||
export async function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator) || !window.isSecureContext) return false
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('./sw.js')
|
||||
try {
|
||||
await reg.update()
|
||||
} catch {
|
||||
/* first install may have nothing to update */
|
||||
}
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn('[vault] service worker registration failed:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,14 @@
|
||||
*/
|
||||
|
||||
import { openDB } from 'idb'
|
||||
import { deriveKey, decrypt, encrypt, base64ToUint8Array } from '../crypto/crypto.js'
|
||||
import {
|
||||
deriveKey,
|
||||
decrypt,
|
||||
encrypt,
|
||||
generateSalt,
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from '../crypto/crypto.js'
|
||||
import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
|
||||
|
||||
// Re-export for convenience
|
||||
@ -376,16 +383,42 @@ export async function moveEntryToGroup(entryId, groupId) {
|
||||
// ========================
|
||||
|
||||
/**
|
||||
* Export data (entries + groups + meta) as a JSON object.
|
||||
* Entries remain encrypted with the source vault's key. The import function
|
||||
* requires the source vault's master password to decrypt and re-encrypt
|
||||
* entries under the target vault's key.
|
||||
* Export data (entries + groups + meta).
|
||||
*
|
||||
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything.
|
||||
* Include '' to export ungrouped entries.
|
||||
* The caller must choose how the file is protected - an explicit choice, never
|
||||
* an ambiguous "optional" field. Wraps the whole payload (entries + groups +
|
||||
* meta, incl. titles/usernames/notes) in an AES-256-GCM envelope so the file
|
||||
* is unreadable without the unlock key:
|
||||
*
|
||||
* - `password`: a NEW, separate password. The envelope is sealed with a key
|
||||
* derived from this password + a fresh random salt (embedded in the
|
||||
* envelope), so importing needs ONLY this password - never the vault's.
|
||||
* Each entry's password is re-encrypted from the vault key to the export key.
|
||||
*
|
||||
* - `useExistingPassword`: reuse the SAME password that unlocks this vault.
|
||||
* The envelope is sealed with the vault's own key and the vault's salt
|
||||
* stays embedded, so import derives that key from the master password. No
|
||||
* new password needs creating or remembering.
|
||||
*
|
||||
* Supplying neither password nor useExistingPassword falls back to a plaintext
|
||||
* JSON export (entries keep their source-vault-encrypted passwords) - kept for
|
||||
* programmatic/backward compatibility, though the UI always chooses one of the
|
||||
* two protected modes.
|
||||
*
|
||||
* @param {string[]} [groupIds] - Group IDs to export. null/[] = full (include '' for ungrouped).
|
||||
* @param {Object} [options]
|
||||
* @param {CryptoKey|null} [options.vaultKey] - Current vault's in-memory encryption key (required to seal).
|
||||
* @param {string} [options.password] - Optional NEW separate password to seal with.
|
||||
* @param {boolean} [options.useExistingPassword] - Seal with the vault's own key instead of a new password.
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function exportSelected(groupIds) {
|
||||
export async function exportSelected(groupIds = null, options = {}) {
|
||||
const {
|
||||
vaultKey = null,
|
||||
password = '',
|
||||
useExistingPassword = false,
|
||||
} = options
|
||||
|
||||
const db = await getDb()
|
||||
const allEntries = await db.getAll('entries')
|
||||
const allGroups = await db.getAll('groups')
|
||||
@ -395,8 +428,11 @@ export async function exportSelected(groupIds) {
|
||||
const testPlaintextRow = await db.get('meta', 'testPlaintext')
|
||||
|
||||
// If no groups selected, export everything
|
||||
if (!groupIds || groupIds.length === 0) {
|
||||
return {
|
||||
const full = !groupIds || groupIds.length === 0
|
||||
const pickEntry = full ? () => true : e => groupIds.includes(e.groupId)
|
||||
const pickGroup = full ? () => true : g => groupIds.includes(g.id)
|
||||
|
||||
const payload = {
|
||||
version: DB_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
meta: {
|
||||
@ -404,28 +440,69 @@ export async function exportSelected(groupIds) {
|
||||
testEncrypted: testEncryptedRow?.value || null,
|
||||
testPlaintext: testPlaintextRow?.value || null,
|
||||
},
|
||||
groups: allGroups,
|
||||
entries: allEntries,
|
||||
}
|
||||
groups: allGroups.filter(pickGroup),
|
||||
entries: allEntries.filter(pickEntry),
|
||||
}
|
||||
|
||||
const entries = allEntries.filter(e => groupIds.includes(e.groupId))
|
||||
const groups = allGroups.filter(g => groupIds.includes(g.id))
|
||||
// Explicit choice required: pick a way to protect the file.
|
||||
const wantsProtection = !!(password || useExistingPassword)
|
||||
if (!wantsProtection) {
|
||||
// Plaintext fallback (backward compatibility, never offered by the UI).
|
||||
return payload
|
||||
}
|
||||
|
||||
if (!vaultKey) {
|
||||
throw new Error('The vault key is required to create a protected export')
|
||||
}
|
||||
|
||||
let sealKey
|
||||
let envelopeSalt
|
||||
if (useExistingPassword) {
|
||||
// Use the vault's own key; its salt stays embedded so import can
|
||||
// reproduce the same key from the master password. Entries are already
|
||||
// encrypted under vaultKey, so no re-keying is needed.
|
||||
sealKey = vaultKey
|
||||
envelopeSalt = payload.meta.salt
|
||||
} else {
|
||||
// A separate password: fresh random salt + a new key. Re-key the exported
|
||||
// entries from the vault key to this new key so that import needs only
|
||||
// this password to unlock them.
|
||||
if (!password) {
|
||||
throw new Error('Enter a new password to encrypt this export, or choose to reuse the vault password')
|
||||
}
|
||||
const exportSalt = generateSalt()
|
||||
sealKey = await deriveKey(password, exportSalt)
|
||||
envelopeSalt = uint8ArrayToBase64(exportSalt)
|
||||
|
||||
for (const entry of payload.entries) {
|
||||
if (entry.encryptedPassword) {
|
||||
const plaintext = await decrypt(entry.encryptedPassword, vaultKey)
|
||||
entry.encryptedPassword = await encrypt(plaintext, sealKey)
|
||||
}
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, vaultKey)
|
||||
entry.encryptedTotpSecret = await encrypt(secret, sealKey)
|
||||
}
|
||||
}
|
||||
// Point meta.salt at the export salt so import reproduces the export key.
|
||||
payload.meta.salt = envelopeSalt
|
||||
}
|
||||
|
||||
// Seal the entire payload (also protects titles/usernames/notes at rest).
|
||||
const sealed = await encrypt(JSON.stringify(payload), sealKey)
|
||||
|
||||
return {
|
||||
version: DB_VERSION,
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
meta: {
|
||||
salt: saltRow?.value || null,
|
||||
testEncrypted: testEncryptedRow?.value || null,
|
||||
testPlaintext: testPlaintextRow?.value || null,
|
||||
},
|
||||
groups,
|
||||
entries,
|
||||
format: 'encrypted-export',
|
||||
kdfIterations: 600_000,
|
||||
salt: envelopeSalt,
|
||||
data: sealed, // encrypt() output string: { iv, ciphertext }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Import data from a previously exported JSON object.
|
||||
*
|
||||
@ -440,6 +517,24 @@ export async function exportSelected(groupIds) {
|
||||
* @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
|
||||
*/
|
||||
export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) {
|
||||
// Accept a password-protected (sealed) export. `sourcePassword` here is the
|
||||
// EXPORT password - it may differ from any vault's master password. If the
|
||||
// wrong password is entered, decryption fails and import is rejected, rather
|
||||
// than silently skipping entries.
|
||||
if (data && data.format === 'encrypted-export') {
|
||||
if (!sourcePassword) {
|
||||
throw new Error('The export password is required to import this encrypted file')
|
||||
}
|
||||
const exportKey = await deriveKey(sourcePassword, base64ToUint8Array(data.salt))
|
||||
let innerJson
|
||||
try {
|
||||
innerJson = await decrypt(data.data, exportKey)
|
||||
} catch {
|
||||
throw new Error('Incorrect export password - could not decrypt this file')
|
||||
}
|
||||
data = JSON.parse(innerJson)
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
|
||||
throw new Error('Invalid import data format')
|
||||
}
|
||||
@ -478,17 +573,27 @@ export async function importAll(data, mode = 'merge', sourcePassword = '', targe
|
||||
try {
|
||||
let reencryptedEntry = { ...entry }
|
||||
|
||||
if (sourceKey && targetKey && entry.encryptedPassword) {
|
||||
// Decrypt password with source key
|
||||
// An entry is only skippable if it actually needs re-keying (has an
|
||||
// encrypted password or TOTP secret) but we lack the keys to do so.
|
||||
const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret)
|
||||
|
||||
if (sourceKey && targetKey && hasEncrypted) {
|
||||
if (entry.encryptedPassword) {
|
||||
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
|
||||
// Re-encrypt under target vault's key
|
||||
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey)
|
||||
}
|
||||
if (entry.encryptedTotpSecret) {
|
||||
const secret = await decrypt(entry.encryptedTotpSecret, sourceKey)
|
||||
reencryptedEntry.encryptedTotpSecret = await encrypt(secret, targetKey)
|
||||
}
|
||||
} else if (!sourceKey || !targetKey) {
|
||||
// Can't re-encrypt — skip this entry with a warning
|
||||
console.warn('Skipping entry (missing source password or target key):', entry.title)
|
||||
// Can't re-encrypt — require a password/secret, else nothing to do.
|
||||
if (hasEncrypted) {
|
||||
console.warn('Skipping entry (missing source password or target):', entry.title)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await db.put('entries', reencryptedEntry)
|
||||
importedEntries++
|
||||
|
||||
@ -10,6 +10,8 @@ import { getSetting, saveSetting } from '../storage/db.js'
|
||||
export class SettingsStore {
|
||||
autoLockMinutes = $state(5)
|
||||
lockOnTabSwitch = $state(true)
|
||||
// User dismissed the "running from a web server" warning banner on the lock screen.
|
||||
dismissedLocalWarning = $state(false)
|
||||
|
||||
/**
|
||||
* Load persisted settings from IndexedDB.
|
||||
@ -18,9 +20,11 @@ export class SettingsStore {
|
||||
async load() {
|
||||
const minutes = await getSetting('autoLockMinutes')
|
||||
const tabSwitch = await getSetting('lockOnTabSwitch')
|
||||
const dismissedWarning = await getSetting('dismissedLocalWarning')
|
||||
|
||||
this.autoLockMinutes = minutes != null ? Number(minutes) : 5
|
||||
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true
|
||||
this.dismissedLocalWarning = dismissedWarning != null ? Boolean(dismissedWarning) : false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -29,6 +33,7 @@ export class SettingsStore {
|
||||
async save() {
|
||||
await saveSetting('autoLockMinutes', this.autoLockMinutes)
|
||||
await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch)
|
||||
await saveSetting('dismissedLocalWarning', this.dismissedLocalWarning)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { mount } from 'svelte'
|
||||
import './styles/main.css'
|
||||
import App from './App.svelte'
|
||||
import { registerServiceWorker } from './lib/pwa.js'
|
||||
|
||||
// Report the baked-in commit hash on startup (handy for verifying a deploy).
|
||||
console.info({ commit_hash: __VAULT_COMMIT__ })
|
||||
|
||||
// Register the PWA service worker / network sandbox (no-op where unsupported).
|
||||
registerServiceWorker()
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
|
||||
117
tests/lib/crypto/totp.test.js
Normal file
@ -0,0 +1,117 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateTotp, totpRemainingSeconds, base32Decode, extractSecret, validateTotpSecret } from '../../../src/lib/crypto/totp.js'
|
||||
|
||||
// RFC 6238 test vectors (Appendix B, SHA-1) use the ASCII secret
|
||||
// "12345678901234567890" whose base32 is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.
|
||||
const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'
|
||||
|
||||
describe('base32Decode', () => {
|
||||
it('decodes the RFC secret to the expected ASCII bytes', () => {
|
||||
const bytes = base32Decode(RFC_SECRET)
|
||||
expect(String.fromCharCode(...bytes)).toBe('12345678901234567890')
|
||||
})
|
||||
|
||||
it('ignores whitespace and padding', () => {
|
||||
expect(base32Decode('JBSWY3DP EHPK3PXP').length).toBe(10)
|
||||
expect(base32Decode('JBSWY3DPEHPK3PXP==').length).toBe(10)
|
||||
})
|
||||
|
||||
it('throws on invalid characters', () => {
|
||||
// '0','1','8','9' are not valid base32
|
||||
expect(() => base32Decode('ABC0')).toThrow(/Invalid base32/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSecret', () => {
|
||||
it('passes through a bare base32 secret (normalized)', () => {
|
||||
expect(extractSecret('jbs-wy3dpehpk3pxp')).toBe('JBSWY3DPEHPK3PXP')
|
||||
})
|
||||
|
||||
it('pulls the secret out of an otpauth:// URI', () => {
|
||||
expect(extractSecret('otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example'))
|
||||
.toBe('JBSWY3DPEHPK3PXP')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(extractSecret('')).toBe('')
|
||||
expect(extractSecret(' ')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateTotp (RFC 6238 vectors)', () => {
|
||||
it('matches the RFC 6238 SHA-1 vectors at 6 digits', async () => {
|
||||
const vectors = [
|
||||
[59, '287082'],
|
||||
[1111111109, '081804'],
|
||||
[1111111111, '050471'],
|
||||
[1234567890, '005924'],
|
||||
[2000000000, '279037'],
|
||||
[20000000000, '353130'],
|
||||
]
|
||||
for (const [t, expected] of vectors) {
|
||||
await expect(generateTotp(RFC_SECRET, { timestamp: t })).resolves.toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('supports custom digit counts', async () => {
|
||||
// 8-digit vector at t=59 is 94287082
|
||||
await expect(generateTotp(RFC_SECRET, { timestamp: 59, digits: 8 })).resolves.toBe('94287082')
|
||||
})
|
||||
|
||||
it('rejects an empty/invalid secret', async () => {
|
||||
await expect(generateTotp('')).rejects.toThrow(/empty or invalid/)
|
||||
await expect(generateTotp('!!!!')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('changes over time', async () => {
|
||||
const a = await generateTotp(RFC_SECRET, { timestamp: 30 })
|
||||
const b = await generateTotp(RFC_SECRET, { timestamp: 90 })
|
||||
// 30 and 90 map to counters 1 and 3 — codes differ.
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateTotpSecret', () => {
|
||||
const GOOD = 'JBSWY3DPEHPK3PXP' // valid 10-byte base32 secret
|
||||
|
||||
it('accepts a blank secret (optional)', () => {
|
||||
expect(validateTotpSecret('').valid).toBe(true)
|
||||
expect(validateTotpSecret(' ').valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a valid bare base32 secret', () => {
|
||||
expect(validateTotpSecret(GOOD).valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts hyphen-grouped base32 and otpauth:// URIs', () => {
|
||||
expect(validateTotpSecret('JBSW-Y3DP-EHPK-3PXP').valid).toBe(true)
|
||||
expect(validateTotpSecret(`otpauth://totp/Example:alice?secret=${GOOD}&issuer=Example`).valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid base32 characters', () => {
|
||||
const r = validateTotpSecret('ABC9012345678901')
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toMatch(/Invalid TOTP secret/)
|
||||
})
|
||||
|
||||
it('rejects a secret that is too short', () => {
|
||||
const r = validateTotpSecret('ABCDEF') // 4 decoded bytes < minBytes
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toMatch(/too short/)
|
||||
})
|
||||
|
||||
it('rejects an otpauth:// URI with no secret parameter', () => {
|
||||
const r = validateTotpSecret('otpauth://totp/x?issuer=y')
|
||||
expect(r.valid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('totpRemainingSeconds', () => {
|
||||
it('returns period for exact boundary', () => {
|
||||
expect(totpRemainingSeconds({ timestamp: 0, period: 30 })).toBe(30)
|
||||
})
|
||||
it('counts down within a period', () => {
|
||||
expect(totpRemainingSeconds({ timestamp: 5, period: 30 })).toBe(25)
|
||||
expect(totpRemainingSeconds({ timestamp: 29, period: 30 })).toBe(1)
|
||||
})
|
||||
})
|
||||
@ -50,6 +50,20 @@ describe('createEntry', () => {
|
||||
expect(entry.updatedAt).toBe(entry.createdAt)
|
||||
})
|
||||
|
||||
it('should store an encrypted TOTP secret', () => {
|
||||
const entry = createEntry({
|
||||
title: 'GitHub',
|
||||
encryptedPassword: 'encrypted-blob',
|
||||
encryptedTotpSecret: 'encrypted-totp',
|
||||
})
|
||||
expect(entry.encryptedTotpSecret).toBe('encrypted-totp')
|
||||
})
|
||||
|
||||
it('should default encryptedTotpSecret to undefined when not provided', () => {
|
||||
const entry = createEntry({ title: 'GitHub' })
|
||||
expect(entry.encryptedTotpSecret).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should trim title and optional fields', () => {
|
||||
const entry = createEntry({
|
||||
title: ' GitHub ',
|
||||
@ -176,16 +190,22 @@ describe('validateEntry', () => {
|
||||
expect(result.errors).toContain('Title is required')
|
||||
})
|
||||
|
||||
it('should fail with missing encryptedPassword', () => {
|
||||
it('should pass with no encryptedPassword (password optional)', () => {
|
||||
const result = validateEntry({ title: 'GitHub' })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Password is required')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('should report multiple errors', () => {
|
||||
it('should pass with an empty password string', () => {
|
||||
const result = validateEntry({ title: 'GitHub', encryptedPassword: '' })
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('should report only the title error when password is missing (not required)', () => {
|
||||
const result = validateEntry({ title: '' })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.length).toBe(2)
|
||||
expect(result.errors.length).toBe(1)
|
||||
expect(result.errors).toContain('Title is required')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
importAll,
|
||||
TRASH_GROUP_ID,
|
||||
} from '../../../src/lib/storage/db.js'
|
||||
import { generateSalt, deriveKey, encrypt } from '../../../src/lib/crypto/crypto.js'
|
||||
import { generateSalt, deriveKey, encrypt, decrypt } from '../../../src/lib/crypto/crypto.js'
|
||||
import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js'
|
||||
|
||||
const DB_NAME = 'password-vault'
|
||||
@ -601,4 +601,103 @@ describe('Export / Import', () => {
|
||||
expect(result.skipped).toBe(1)
|
||||
expect(result.imported.entries).toBe(0)
|
||||
})
|
||||
|
||||
describe('password-protected encrypted export/import', () => {
|
||||
async function setupSourceVault() {
|
||||
await clearAllData()
|
||||
const salt = generateSalt()
|
||||
const key = await deriveKey('source-key', salt)
|
||||
const testPlaintext = 'vault_test_src'
|
||||
const testEncrypted = await encrypt(testPlaintext, key)
|
||||
await saveVaultMeta(salt, testEncrypted, testPlaintext)
|
||||
const group = createGroup('Work')
|
||||
await addGroup(group)
|
||||
const enc = await encrypt('topsecret', key)
|
||||
await addEntry(createEntry({
|
||||
title: 'GitHub',
|
||||
encryptedPassword: enc,
|
||||
groupId: group.id,
|
||||
}))
|
||||
return key
|
||||
}
|
||||
|
||||
async function setupTargetVault() {
|
||||
await clearAllData()
|
||||
const salt = generateSalt()
|
||||
const key = await deriveKey('target-key', salt)
|
||||
const testPlaintext = 'vault_test_tgt'
|
||||
const testEncrypted = await encrypt(testPlaintext, key)
|
||||
await saveVaultMeta(salt, testEncrypted, testPlaintext)
|
||||
return key
|
||||
}
|
||||
|
||||
it('seals the export envelope with the separate password', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||
expect(sealed.format).toBe('encrypted-export')
|
||||
expect(sealed.salt).toBeDefined()
|
||||
expect(sealed.data).toBeDefined()
|
||||
expect(sealed.groups).toBeUndefined()
|
||||
expect(sealed.entries).toBeUndefined()
|
||||
// The plaintext payload (incl. entry title) must not leak into the envelope
|
||||
expect(JSON.stringify(sealed)).not.toContain('GitHub')
|
||||
})
|
||||
|
||||
it('imports a sealed export into another vault using only the export password', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||
|
||||
const targetKey = await setupTargetVault()
|
||||
const result = await importAll(sealed, 'merge', 'exportpw', targetKey)
|
||||
expect(result.skipped).toBe(0)
|
||||
expect(result.imported.entries).toBe(1)
|
||||
|
||||
const entries = await getEntries()
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].title).toBe('GitHub')
|
||||
const plain = await decrypt(entries[0].encryptedPassword, targetKey)
|
||||
expect(plain).toBe('topsecret')
|
||||
})
|
||||
|
||||
it('rejects a sealed export when the export password is wrong', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||
const targetKey = await setupTargetVault()
|
||||
await expect(importAll(sealed, 'merge', 'wrong-password', targetKey))
|
||||
.rejects.toThrow('Incorrect export password')
|
||||
})
|
||||
|
||||
it('requires an export password to import a sealed export', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, password: 'exportpw' })
|
||||
const targetKey = await setupTargetVault()
|
||||
await expect(importAll(sealed, 'merge', '', targetKey))
|
||||
.rejects.toThrow('The export password is required')
|
||||
})
|
||||
|
||||
it('seals with the vault password when reuseExistingPassword is set', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, useExistingPassword: true })
|
||||
expect(sealed.format).toBe('encrypted-export')
|
||||
expect(JSON.stringify(sealed)).not.toContain('GitHub')
|
||||
|
||||
const targetKey = await setupTargetVault()
|
||||
// Import with the SOURCE vault's master password.
|
||||
const result = await importAll(sealed, 'merge', 'source-key', targetKey)
|
||||
expect(result.skipped).toBe(0)
|
||||
const entries = await getEntries()
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0].title).toBe('GitHub')
|
||||
const plain = await decrypt(entries[0].encryptedPassword, targetKey)
|
||||
expect(plain).toBe('topsecret')
|
||||
})
|
||||
|
||||
it('rejects a reuse-existing export with the wrong vault password', async () => {
|
||||
const sourceKey = await setupSourceVault()
|
||||
const sealed = await exportSelected(null, { vaultKey: sourceKey, useExistingPassword: true })
|
||||
const targetKey = await setupTargetVault()
|
||||
await expect(importAll(sealed, 'merge', 'not-the-password', targetKey))
|
||||
.rejects.toThrow('Incorrect export password')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
50
tests/lib/sw-policy.test.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* Canonical network-sandbox decision logic for the Password Vault PWA.
|
||||
* Mirrors `isAppOwnResource` in public/sw.js. It decides whether a request is
|
||||
* permitted: only SAME-ORIGIN requests within the worker's OWN directory scope
|
||||
* are allowed; everything else (cross-origin, or same-origin but a different
|
||||
* app/dir on the same server) is denied.
|
||||
*/
|
||||
function isAppOwnResource(requestUrl, selfUrl) {
|
||||
const req = new URL(requestUrl)
|
||||
const self = new URL(selfUrl)
|
||||
if (req.origin !== self.origin) return false
|
||||
const dir = self.pathname.slice(0, self.pathname.lastIndexOf('/') + 1)
|
||||
return req.pathname.startsWith(dir)
|
||||
}
|
||||
|
||||
// The deployed worker lives under /password_manager/ -> selfUrl is its own URL.
|
||||
const SELF = 'https://thecookiejar.me/password_manager/sw.js'
|
||||
|
||||
describe('Vault network-sandbox policy', () => {
|
||||
it('allows the app is own in-scope static resources', () => {
|
||||
expect(isAppOwnResource('https://thecookiejar.me/password_manager/index.html', SELF)).toBe(true)
|
||||
expect(isAppOwnResource('https://thecookiejar.me/password_manager/manifest.webmanifest', SELF)).toBe(true)
|
||||
expect(isAppOwnResource('https://thecookiejar.me/password_manager/icons/icon-192.png', SELF)).toBe(true)
|
||||
expect(isAppOwnResource('https://thecookiejar.me/password_manager/sw.js', SELF)).toBe(true)
|
||||
})
|
||||
|
||||
it('denies any cross-origin request (third-party exfiltration)', () => {
|
||||
expect(isAppOwnResource('https://evil.example.com/collect', SELF)).toBe(false)
|
||||
expect(isAppOwnResource('https://api.open-meteo.com/v1/forecast', SELF)).toBe(false)
|
||||
expect(isAppOwnResource('https://google.com/', SELF)).toBe(false)
|
||||
expect(isAppOwnResource('https://thecookiejar.me.evil.com/', SELF)).toBe(false)
|
||||
})
|
||||
|
||||
it('denies same-origin requests outside the app directory scope', () => {
|
||||
// Same server, different app/vault dir -> outside the sandbox.
|
||||
expect(isAppOwnResource('https://thecookiejar.me/weather/index.html', SELF)).toBe(false)
|
||||
expect(isAppOwnResource('https://thecookiejar.me/static/switch.css', SELF)).toBe(false)
|
||||
expect(isAppOwnResource('https://thecookiejar.me/', SELF)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a different scheme (http vs https) as a different origin', () => {
|
||||
expect(isAppOwnResource('http://thecookiejar.me/password_manager/index.html', SELF)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a different port as a different origin', () => {
|
||||
expect(isAppOwnResource('https://thecookiejar.me:8443/password_manager/index.html', SELF)).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -1,9 +1,25 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile'
|
||||
|
||||
// https://vite.dev/config/
|
||||
// Bake the commit hash into the bundle at build time so a deployed app can
|
||||
// report exactly which commit produced it (logged via console.info at startup).
|
||||
// CI sets VITE_COMMIT_HASH=${{ github.sha }}; local builds fall back to git HEAD.
|
||||
function getCommitHash() {
|
||||
if (process.env.VITE_COMMIT_HASH) return process.env.VITE_COMMIT_HASH
|
||||
try {
|
||||
return execSync('git rev-parse HEAD').toString().trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
define: {
|
||||
__VAULT_COMMIT__: JSON.stringify(getCommitHash()),
|
||||
},
|
||||
plugins: [
|
||||
svelte(),
|
||||
...(command === 'build'
|
||||
@ -27,5 +43,8 @@ export default defineConfig(({ command }) => ({
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.js'],
|
||||
include: ['src/**/*.test.js', 'tests/**/*.test.js'],
|
||||
// PBKDF2 (600k iterations) derivations are slow; don't let the default 5s
|
||||
// per-test timeout flake the suite on slower CI hosts.
|
||||
testTimeout: 30000,
|
||||
},
|
||||
}))
|
||||
|
||||