Compare commits

...

16 Commits

Author SHA1 Message Date
179bc83bfe Add PWA support with a network-sandbox for the installed app
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 55s
Test, Build & Deploy / deploy (push) Successful in 26s
Makes the offline-first vault installable as a standalone PWA, and enforces
"no network access aside from the app's own resources" once installed.

PWA:
- public/manifest.webmanifest: scope ./ (confined to the app's own dir), three
  icons (192/512 + transparent maskable), standalone display, brand theme. No
  push/shortcuts (deliberate: offline-first vault, nothing to push).
- public/sw.js: a NETWORK SANDBOX service worker. Allows only same-origin
  requests inside the worker's own directory (index, manifest, icons); returns
  403 for every cross-origin request and for same-origin paths outside the app
  dir. This blocks in-app <img>/<script>/fetch exfiltration at the source.
- src/lib/pwa.js + main.js: register the worker (relative path, works at / in
  dev and under /password_manager/ in prod).
- index.html: dev CSP (permits Vite HMR WebSocket) + PWA meta/link tags.

Security:
- scripts/inline-assets.js swaps the dev CSP for the STRICT production CSP in
  the shipped dist: connect-src 'none' (no fetch/XHR/WebSockets anywhere),
  form-action 'none', object-src/base-uri 'none', img/font/media 'self' data:.
  The browser-native connect-src closes the WebSocket gap the SW cannot see.
- Known boundary documented in-file: frame-ancestors / X-Frame-Options is
  HTTP-header-only (ignored in meta) and not set by the static host, so
  clickjacking is permissive; all exfiltration channels are closed regardless.

Build/deploy:
- inline-assets.js now PRESERVES manifest/sw.js/icons in dist (was deleting).
- deploy.yml uploads + byte-verifies index.html, manifest, sw.js, and all three
  icons to WebDAV.

Tests:
- tests/lib/sw-policy.test.js: 5 tests for the sandbox allow/deny logic
  (in-scope allowed; cross-origin, out-of-scope, scheme/port mismatch denied).
- Verified in headless Chromium against the built dist: SW registers + is
  active, and both a cross-origin fetch and a same-origin-out-of-scope fetch
  are refused by the CSP before leaving the device.

Fixup: dropped 'frame-ancestors' from the meta CSP after confirming the browser
ignores it there (it is a header-only directive).
2026-09-08 13:44:24 +00:00
a722c66e5a Trigger CI after runner re-registration
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 51s
Test, Build & Deploy / deploy (push) Successful in 25s
2026-08-30 20:59:28 +00:00
be756d5fc4 Trigger CI after runner URL fix
Some checks failed
Test, Build & Deploy / test-and-build (push) Failing after 38s
Test, Build & Deploy / deploy (push) Has been skipped
2026-08-30 20:51:58 +00:00
abc13df7f9 Drop npm cache in CI (Docker-mode runner cache server causes failures)
Some checks failed
Test, Build & Deploy / test-and-build (push) Failing after 34s
Test, Build & Deploy / deploy (push) Has been skipped
2026-08-30 20:43:12 +00:00
b8f7283eb3 Switch CI runner to Ubuntu (containerized act_runner)
Some checks failed
Test, Build & Deploy / test-and-build (push) Failing after 48s
Test, Build & Deploy / deploy (push) Has been skipped
2026-08-30 20:33:39 +00:00
8652dab880 Bump vitest testTimeout to 30s for slow PBKDF2 (600k iters) derivations
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 1m36s
Test, Build & Deploy / deploy (push) Successful in 20s
The crypto suite's deriveKey tests exceeded vitest's default 5s per-test
timeout in CI (passed locally only because the host is faster). Set a 30s
per-test timeout so the PBKDF2-heavy tests don't flake the pipeline.
2026-08-30 19:15:06 +00:00
aa651568af Add CI workflow + baked commit hash
Some checks failed
Test, Build & Deploy / test-and-build (push) Failing after 1m15s
Test, Build & Deploy / deploy (push) Has been skipped
- .gitea/workflows/deploy.yml: test+build on every push; auto-deploy
  dist/index.html to /password_manager via WebDAV on push to main (DELETE-
  then-PUT to bypass stale-file cache), verifying deployed bytes match.
- Build injects __VAULT_COMMIT__ (from VITE_COMMIT_HASH=github.sha in CI,
  git HEAD locally) and main.js logs console.info({ commit_hash }) on
  startup so a deploy is verifiable against its source commit.
2026-08-30 19:12:19 +00:00
d72f41418c Make the lock-screen warning persist based on the stored setting
- Visibility is now driven entirely by the persisted dismissedLocalWarning
  setting, loaded directly on mount (via getSetting) so the banner reflects the
  stored choice immediately when the lock screen renders — before any unlock.
- Removed the ephemeral default-true local flag that caused the banner to
  reappear on reload until the user unlocked. A warningReady guard prevents a
  one-frame flash while the value loads.
2026-08-27 01:33:45 +00:00
ae23b47a92 Validate TOTP secret in the edit dialog
- totp.js: add validateTotpSecret(input, { minBytes = 10 }). Secret is
  optional (blank ok); when provided it must decode to a base32 key of at
  least 80 bits, rejecting bad characters, typos, wrong format, and
  too-short secrets.
- EntryForm: validate the TOTP secret on submit; if invalid, show an inline
  error (⚠) and a red input border and block saving. Error clears as the
  user types.
- Tests: blank ok, valid bare/grouped/otpauth URIs, invalid chars, too-short,
  URI without a secret. 163 total pass.
2026-08-27 01:29:33 +00:00
982567acf9 Add setting to re-enable the lock-screen web-server warning
- Settings dialog gains a 'Show web-server warning' toggle that maps to the
  persisted dismissedLocalWarning flag, letting the user re-show the banner
  after dismissing it (or hide it proactively).
2026-08-27 01:22:06 +00:00
59d903fbd7 Make the lock-screen web-server warning dismissible
- The security banner (shown when opened over http(s), not file://) now has a
  close button.
- Dismissal persists via the settings store (dismissedLocalWarning), so it
  stays hidden across reloads on this browser, while still appearing fresh by
  default.
- Rephrased text kept; banner made flex with a hoverable, accessible close
  button.
2026-08-27 01:18:28 +00:00
c9a42c4670 Document TOTP support in AGENTS.md 2026-08-27 01:12:45 +00:00
9758e80a02 Add TOTP (2FA) support to entries
- New src/lib/crypto/totp.js: native RFC 6238 TOTP using Web Crypto HMAC-SHA1
  (no external crypto). base32Decode, extractSecret (bare base32 or otpauth://
  URI), generateTotp, totpRemainingSeconds.
- Entries gain an optional encryptedTotpSecret, stored AES-GCM-encrypted like
  passwords. schema.js createEntry/updateEntry/docs updated.
- Export/import re-key the TOTP secret alongside passwords when sealing with a
  separate password, and decrypt/re-encrypt on import so TOTP survives moves.
- EntryForm: optional 'TOTP Secret (2FA)' field (base32 or otpauth:// URI).
- EntryDetail: live 6-digit TOTP display updating every second with a countdown
  and urgency indicator, plus copy; guarded cleanup timer on unmount.
- Tests: RFC 6238 SHA-1 vectors (6 & 8 digit), base32/extractSecret, remaining
  seconds, schema round-trip. 157 total pass.
2026-08-27 01:11:48 +00:00
b8e7ce75f7 Make passwords optional for entries
- validateEntry no longer requires encryptedPassword; only title is required
  (schema.js). encryptedPassword documented as optional.
- EntryForm: decrypt-guards empty password on edit; stores empty string (no
  encryption) when the password field is blank; label is 'Password' (no *).
- EntryDetail: only renders the Password field when the entry has one;
  decrypt guards empty.
- Tests: schema validation updated (password optional); 143 total pass.
2026-08-27 01:07:18 +00:00
a89c7811e1 Password export: explicit choice (new password vs reuse existing vault password)
- exportSelected(groupIds, { vaultKey, password, useExistingPassword }) replaces the
  positional (groupIds, vaultKey, exportPassword) form. Protection is now an explicit
  choice, never an ambiguous optional field.
  - password mode: re-key entries under a fresh export-derived key (unchanged semantics).
  - reuseExistingPassword mode: seal with the vault's own key, keep vault salt embedded,
    so import derives the key from the vault master password. No second password required.
- ImportExport.svelte: replaces the long-placeholder free-text field with two radio
  options (Use a new password / Reuse my vault password); short placeholder, with
  client-side validation that a new password isn't empty.
- Cryptography importAll unchanged: derives the envelope key from the supplied password +
  embedded salt, which covers both sealed modes; wrong password still rejects import.
- Tests: updated call sites to options object; added round-trip + wrong-password tests for
  reuseExistingPassword.
2026-08-27 00:01:59 +00:00
800feb1d37 Export can be sealed with a separate password; import accepts file with a different password
- exportSelected(groupIds, vaultKey, exportPassword=''): plain JSON export unchanged when
  no export password; when one is set, re-keys each entry's password to a key derived from
  the export password and AES-256-GCM-seals the entire payload (titles/usernames/notes
  protected too). Returns a { format: 'encrypted-export', salt, data } envelope.
- importAll() detects sealed exports and treats the supplied password as the EXPORT password,
  so it may differ from any vault's master password. Wrong password rejects import instead of
  silently skipping entries.
- ImportExport.svelte: optional 'separate password' field in the export dialog; import dialog's
  field reworded as a generic file password covering both plain and sealed files.
- Tests for sealed export/import round-trip incl. wrong-password & missing-password rejects.
2026-08-26 23:38:25 +00:00
32 changed files with 2171 additions and 343 deletions

117
.gitea/workflows/deploy.yml Normal file
View 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"

View File

@ -75,16 +75,27 @@ Password verification uses a test payload (random string encrypted at vault crea
## Security Notes ## 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. - `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). - Auto-lock triggers on tab visibility change and configurable inactivity timer (default 5 min).
- Clipboard auto-clears after 15 seconds. - Clipboard auto-clears after 15 seconds.
- No browser fingerprinting or anti-keylogger protections. - 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. - `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`.
- `ImportExport.svelte` fetches groups/entries on modal open and shows a checkbox list for group selection with live entry count. - 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 ## Known Bug Fixes

BIN
dist/icons/icon-192.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
dist/icons/icon-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
dist/icons/icon-maskable-512.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

1140
dist/index.html vendored

File diff suppressed because one or more lines are too long

19
dist/manifest.webmanifest vendored Normal file
View 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
View 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`)

View File

@ -2,8 +2,33 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <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> <title>Password Vault</title>
</head> </head>
<body> <body>

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View 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
View 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
View 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`)

View File

@ -30,11 +30,14 @@ if (existsSync(faviconPath)) {
console.log('[inline-assets] Inlined favicon.svg into index.html') console.log('[inline-assets] Inlined favicon.svg into index.html')
} }
// Remove any other leftover asset files (e.g. icons.svg from Svelte compiler) // Remove any other leftover asset files (e.g. icons.svg from Svelte compiler,
const iconsPath = join(distDir, 'icons.svg') // and the source pwa-icon.svg which is only needed to regenerate the PNGs).
if (existsSync(iconsPath)) { for (const leftover of ['icons.svg', 'pwa-icon.svg']) {
rmSync(iconsPath) const p = join(distDir, leftover)
console.log('[inline-assets] Removed icons.svg') if (existsSync(p)) {
rmSync(p)
console.log(`[inline-assets] Removed ${leftover}`)
}
} }
// Remove assets directory if it exists // Remove assets directory if it exists
@ -44,4 +47,43 @@ if (existsSync(assetsDir)) {
console.log('[inline-assets] Removed assets/ directory') 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')

View File

@ -1,6 +1,8 @@
<script> <script>
import { onDestroy } from 'svelte'
import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js' import { getEntryById, moveToTrash, deleteEntry } from '../lib/storage/db.js'
import { decrypt } from '../lib/crypto/crypto.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 { app } from '../lib/stores/app.svelte.js'
import { isTrashGroup } from '../lib/models/schema.js' import { isTrashGroup } from '../lib/models/schema.js'
@ -9,6 +11,9 @@
let entry = $state(null) let entry = $state(null)
let passwordVisible = $state(false) let passwordVisible = $state(false)
let decryptedPassword = $state('') let decryptedPassword = $state('')
let totpCode = $state('')
let totpRemaining = $state(30)
let totalRemaining = $state(false)
let loading = $state(true) let loading = $state(true)
let error = $state('') let error = $state('')
let showDeleteConfirm = $state(false) let showDeleteConfirm = $state(false)
@ -24,7 +29,12 @@
try { try {
entry = await getEntryById(entryId) entry = await getEntryById(entryId)
if (entry && app.encryptionKey) { 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) { } catch (e) {
error = 'Failed to load entry: ' + e.message error = 'Failed to load entry: ' + e.message
@ -34,6 +44,34 @@
loadEntry() 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) { function showToast(message) {
toast = message toast = message
if (toastTimer) clearTimeout(toastTimer) if (toastTimer) clearTimeout(toastTimer)
@ -130,6 +168,7 @@
</div> </div>
{/if} {/if}
{#if entry.encryptedPassword}
<div class="detail-field"> <div class="detail-field">
<span class="field-label">Password</span> <span class="field-label">Password</span>
<div class="field-value"> <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> <button class="btn btn-ghost btn-sm copy-btn" onclick={() => copyToClipboard(decryptedPassword, 'Password')} title="Copy password">📋</button>
</div> </div>
</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} {#if entry.url}
<div class="detail-field"> <div class="detail-field">
@ -309,6 +365,34 @@
flex-shrink: 0; 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 { .detail-meta {
display: flex; display: flex;
gap: 16px; gap: 16px;

View File

@ -3,6 +3,7 @@
import { encrypt, decrypt } from '../lib/crypto/crypto.js' import { encrypt, decrypt } from '../lib/crypto/crypto.js'
import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js' import { createEntry, updateEntry as updateEntryModel, validateEntry, isTrashGroup } from '../lib/models/schema.js'
import { generatePassword } from '../lib/crypto/crypto.js' import { generatePassword } from '../lib/crypto/crypto.js'
import { validateTotpSecret } from '../lib/crypto/totp.js'
import { app } from '../lib/stores/app.svelte.js' import { app } from '../lib/stores/app.svelte.js'
import { search as searchStore } from '../lib/stores/search.svelte.js' import { search as searchStore } from '../lib/stores/search.svelte.js'
import { autofocus } from '../lib/autofocus.js' import { autofocus } from '../lib/autofocus.js'
@ -15,6 +16,7 @@
let url = $state('') let url = $state('')
let notes = $state('') let notes = $state('')
let groupId = $state('') let groupId = $state('')
let totpSecret = $state('')
let passwordVisible = $state(false) let passwordVisible = $state(false)
let groups = $state([]) let groups = $state([])
let loading = $state(true) let loading = $state(true)
@ -22,6 +24,7 @@
let saving = $state(false) let saving = $state(false)
let isEdit = $state(false) let isEdit = $state(false)
let formErrors = $state([]) let formErrors = $state([])
let totpError = $state('')
async function loadForm() { async function loadForm() {
loading = true loading = true
@ -33,7 +36,8 @@
if (entry) { if (entry) {
title = entry.title title = entry.title
username = entry.username 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 || '' url = entry.url || ''
notes = entry.notes || '' notes = entry.notes || ''
groupId = entry.groupId || '' groupId = entry.groupId || ''
@ -66,7 +70,20 @@
return 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) { if (isEdit) {
const existing = await getEntryById(entryId) const existing = await getEntryById(entryId)
@ -74,6 +91,7 @@
title, title,
username, username,
encryptedPassword, encryptedPassword,
encryptedTotpSecret,
url, url,
notes, notes,
groupId, groupId,
@ -84,6 +102,7 @@
title, title,
username, username,
encryptedPassword, encryptedPassword,
encryptedTotpSecret,
url, url,
notes, notes,
groupId, groupId,
@ -128,7 +147,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">Password *</label> <label for="password">Password</label>
<div class="password-input-group"> <div class="password-input-group">
<input <input
id="password" id="password"
@ -145,6 +164,23 @@
</div> </div>
</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"> <div class="form-group">
<label for="url">URL</label> <label for="url">URL</label>
<input id="url" type="url" bind:value={url} placeholder="https://example.com" /> <input id="url" type="url" bind:value={url} placeholder="https://example.com" />
@ -215,6 +251,16 @@
color: var(--color-warning); 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 { .password-input-group {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -22,6 +22,8 @@
let exporting = $state(false) let exporting = $state(false)
let sourcePassword = $state('') let sourcePassword = $state('')
let parsedFileData = $state(null) let parsedFileData = $state(null)
let exportPassword = $state('')
let exportUseExistingPassword = $state(false)
// Group selection for export // Group selection for export
let allGroups = $state([]) let allGroups = $state([])
@ -35,9 +37,22 @@
) )
async function handleExport() { async function handleExport() {
if (!exportUseExistingPassword && !exportPassword.trim()) {
importError = 'Choose a new password to encrypt the export'
return
}
exporting = true exporting = true
try { 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 json = JSON.stringify(exportData, null, 2)
const blob = new Blob([json], { type: 'application/json' }) const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@ -137,7 +152,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- 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()}> <div class="modal" role="dialog" aria-modal="true" aria-label="Export vault" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3>Export Vault</h3> <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"> <div class="group-select-header">
<label class="checkbox-label"> <label class="checkbox-label">
@ -161,6 +176,29 @@
{/each} {/each}
</div> </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"> <div class="modal-actions">
<button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}> <button class="btn btn-primary" onclick={handleExport} disabled={exporting || selectedGroupIds.length === 0}>
{exporting ? 'Exporting...' : '📤 Export JSON'} {exporting ? 'Exporting...' : '📤 Export JSON'}
@ -190,15 +228,15 @@
{/if} {/if}
</div> </div>
{:else if parsedFileData} {: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 &mdash; 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"> <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 <input
id="source-password" id="source-password"
type="password" type="password"
bind:value={sourcePassword} bind:value={sourcePassword}
placeholder="Enter source vault password" placeholder="Enter the export or source vault password"
autocomplete="current-password" autocomplete="current-password"
/> />
</div> </div>
@ -355,6 +393,21 @@
margin-bottom: 4px; 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"] { input[type="file"] {
font-size: 0.85rem; font-size: 0.85rem;
padding: 8px; padding: 8px;

View File

@ -1,7 +1,7 @@
<script> <script>
import { app } from '../lib/stores/app.svelte.js' import { app } from '../lib/stores/app.svelte.js'
import { deriveKey, createTestPayload, verifyPassword } from '../lib/crypto/crypto.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 { startAutoLock } from '../lib/stores/security.svelte.js'
import { settings } from '../lib/stores/settings.svelte.js' import { settings } from '../lib/stores/settings.svelte.js'
import { autofocus } from '../lib/autofocus.js' import { autofocus } from '../lib/autofocus.js'
@ -19,6 +19,29 @@
} }
checkVault() 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() { async function handleSubmit() {
error = '' error = ''
loading = true loading = true
@ -88,8 +111,11 @@
<h1>Password Vault</h1> <h1>Password Vault</h1>
<p class="subtitle">{isSetup ? 'Create your vault' : 'Unlock your vault'}</p> <p class="subtitle">{isSetup ? 'Create your vault' : 'Unlock your vault'}</p>
{#if notLocal} {#if notLocal && warningReady && !settings.dismissedLocalWarning}
<div class="warning-banner" role="alert">This HTML file is intended for offline use.</div> <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}
{#if error} {#if error}
@ -196,13 +222,35 @@
.warning-banner { .warning-banner {
width: 100%; 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); background: rgba(255, 193, 7, 0.15);
border: 1px solid rgba(230, 168, 0, 0.5); border: 1px solid rgba(230, 168, 0, 0.5);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: #b8860b; color: #b8860b;
font-size: 0.85rem; 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 { .hint {

View File

@ -7,6 +7,8 @@
// Local copies so the user can cancel without losing values // Local copies so the user can cancel without losing values
let minutes = $state(settings.autoLockMinutes) let minutes = $state(settings.autoLockMinutes)
let lockOnTabSwitch = $state(settings.lockOnTabSwitch) let lockOnTabSwitch = $state(settings.lockOnTabSwitch)
// Inverse of dismissedLocalWarning: on = show the web-server security banner.
let showLocalWarning = $state(!settings.dismissedLocalWarning)
let saving = $state(false) let saving = $state(false)
const minuteOptions = [1, 5, 10, 15, 30, 60] const minuteOptions = [1, 5, 10, 15, 30, 60]
@ -16,6 +18,7 @@
try { try {
settings.autoLockMinutes = minutes settings.autoLockMinutes = minutes
settings.lockOnTabSwitch = lockOnTabSwitch settings.lockOnTabSwitch = lockOnTabSwitch
settings.dismissedLocalWarning = !showLocalWarning
await settings.save() await settings.save()
startAutoLock() startAutoLock()
} catch (e) { } catch (e) {
@ -29,6 +32,7 @@
$effect(() => { $effect(() => {
minutes = settings.autoLockMinutes minutes = settings.autoLockMinutes
lockOnTabSwitch = settings.lockOnTabSwitch lockOnTabSwitch = settings.lockOnTabSwitch
showLocalWarning = !settings.dismissedLocalWarning
}) })
</script> </script>
@ -67,6 +71,25 @@
</p> </p>
</div> </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"> <div class="form-actions">
<button type="submit" class="btn btn-primary" disabled={saving}> <button type="submit" class="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'} {saving ? 'Saving...' : 'Save'}

159
src/lib/crypto/totp.js Normal file
View 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)
}

View File

@ -30,7 +30,8 @@ export function generateId() {
* @property {string} id - Unique identifier * @property {string} id - Unique identifier
* @property {string} title - Display name (e.g. "GitHub", "Gmail") * @property {string} title - Display name (e.g. "GitHub", "Gmail")
* @property {string} [username] - Login username or email (optional) * @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} [url] - Website URL
* @property {string} [notes] - Free-form notes * @property {string} [notes] - Free-form notes
* @property {string} [groupId] - Reference to a Group id (empty string = no group) * @property {string} [groupId] - Reference to a Group id (empty string = no group)
@ -45,7 +46,8 @@ export function generateId() {
* @param {Object} data * @param {Object} data
* @param {string} data.title * @param {string} data.title
* @param {string} [data.username] * @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.url]
* @param {string} [data.notes] * @param {string} [data.notes]
* @param {string} [data.groupId] * @param {string} [data.groupId]
@ -59,6 +61,7 @@ export function createEntry(data) {
title: data.title.trim(), title: data.title.trim(),
username: data.username?.trim() || '', username: data.username?.trim() || '',
encryptedPassword: data.encryptedPassword, encryptedPassword: data.encryptedPassword,
encryptedTotpSecret: data.encryptedTotpSecret,
url: data.url?.trim() || '', url: data.url?.trim() || '',
notes: data.notes?.trim() || '', notes: data.notes?.trim() || '',
groupId: data.groupId || '', groupId: data.groupId || '',
@ -81,6 +84,7 @@ export function updateEntry(existing, data) {
title: data.title !== undefined ? data.title.trim() : existing.title, title: data.title !== undefined ? data.title.trim() : existing.title,
username: data.username !== undefined ? (data.username?.trim() || '') : existing.username, username: data.username !== undefined ? (data.username?.trim() || '') : existing.username,
encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword, 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, url: data.url !== undefined ? data.url.trim() : existing.url,
notes: data.notes !== undefined ? data.notes.trim() : existing.notes, notes: data.notes !== undefined ? data.notes.trim() : existing.notes,
groupId: data.groupId !== undefined ? data.groupId : existing.groupId, groupId: data.groupId !== undefined ? data.groupId : existing.groupId,
@ -127,8 +131,7 @@ export function createGroup(name, color) {
export function validateEntry(data) { export function validateEntry(data) {
const errors = [] const errors = []
if (!data.title || !data.title.trim()) errors.push('Title is required') if (!data.title || !data.title.trim()) errors.push('Title is required')
// Password is optional — an entry may store no password at all.
if (!data.encryptedPassword) errors.push('Password is required')
return { valid: errors.length === 0, errors } return { valid: errors.length === 0, errors }
} }

26
src/lib/pwa.js Normal file
View 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
}
}

View File

@ -12,7 +12,14 @@
*/ */
import { openDB } from 'idb' 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' import { TRASH_GROUP_ID, createTrashGroup, isTrashGroup } from '../models/schema.js'
// Re-export for convenience // Re-export for convenience
@ -376,16 +383,42 @@ export async function moveEntryToGroup(entryId, groupId) {
// ======================== // ========================
/** /**
* Export data (entries + groups + meta) as a JSON object. * Export data (entries + groups + meta).
* 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.
* *
* @param {string[]} [groupIds] - Array of group IDs to export. If null/empty, exports everything. * The caller must choose how the file is protected - an explicit choice, never
* Include '' to export ungrouped entries. * 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>} * @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 db = await getDb()
const allEntries = await db.getAll('entries') const allEntries = await db.getAll('entries')
const allGroups = await db.getAll('groups') const allGroups = await db.getAll('groups')
@ -395,24 +428,11 @@ export async function exportSelected(groupIds) {
const testPlaintextRow = await db.get('meta', 'testPlaintext') const testPlaintextRow = await db.get('meta', 'testPlaintext')
// If no groups selected, export everything // If no groups selected, export everything
if (!groupIds || groupIds.length === 0) { const full = !groupIds || groupIds.length === 0
return { const pickEntry = full ? () => true : e => groupIds.includes(e.groupId)
version: DB_VERSION, const pickGroup = full ? () => true : g => groupIds.includes(g.id)
exportedAt: new Date().toISOString(),
meta: {
salt: saltRow?.value || null,
testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null,
},
groups: allGroups,
entries: allEntries,
}
}
const entries = allEntries.filter(e => groupIds.includes(e.groupId)) const payload = {
const groups = allGroups.filter(g => groupIds.includes(g.id))
return {
version: DB_VERSION, version: DB_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
meta: { meta: {
@ -420,12 +440,69 @@ export async function exportSelected(groupIds) {
testEncrypted: testEncryptedRow?.value || null, testEncrypted: testEncryptedRow?.value || null,
testPlaintext: testPlaintextRow?.value || null, testPlaintext: testPlaintextRow?.value || null,
}, },
groups, groups: allGroups.filter(pickGroup),
entries, entries: allEntries.filter(pickEntry),
}
// 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: 1,
exportedAt: new Date().toISOString(),
format: 'encrypted-export',
kdfIterations: 600_000,
salt: envelopeSalt,
data: sealed, // encrypt() output string: { iv, ciphertext }
} }
} }
/** /**
* Import data from a previously exported JSON object. * 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 }>} * @returns {Promise<{ imported: { entries: number, groups: number }, skipped: number }>}
*/ */
export async function importAll(data, mode = 'merge', sourcePassword = '', targetKey = null) { 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)) { if (!data || !Array.isArray(data.entries) || !Array.isArray(data.groups)) {
throw new Error('Invalid import data format') throw new Error('Invalid import data format')
} }
@ -478,16 +573,26 @@ export async function importAll(data, mode = 'merge', sourcePassword = '', targe
try { try {
let reencryptedEntry = { ...entry } let reencryptedEntry = { ...entry }
if (sourceKey && targetKey && entry.encryptedPassword) { // An entry is only skippable if it actually needs re-keying (has an
// Decrypt password with source key // encrypted password or TOTP secret) but we lack the keys to do so.
const plaintext = await decrypt(entry.encryptedPassword, sourceKey) const hasEncrypted = !!(entry.encryptedPassword || entry.encryptedTotpSecret)
// Re-encrypt under target vault's key
reencryptedEntry.encryptedPassword = await encrypt(plaintext, targetKey) if (sourceKey && targetKey && hasEncrypted) {
if (entry.encryptedPassword) {
const plaintext = await decrypt(entry.encryptedPassword, sourceKey)
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) { } else if (!sourceKey || !targetKey) {
// Can't re-encrypt — skip this entry with a warning // Can't re-encrypt — require a password/secret, else nothing to do.
console.warn('Skipping entry (missing source password or target key):', entry.title) if (hasEncrypted) {
skipped++ console.warn('Skipping entry (missing source password or target):', entry.title)
continue skipped++
continue
}
} }
await db.put('entries', reencryptedEntry) await db.put('entries', reencryptedEntry)

View File

@ -10,6 +10,8 @@ import { getSetting, saveSetting } from '../storage/db.js'
export class SettingsStore { export class SettingsStore {
autoLockMinutes = $state(5) autoLockMinutes = $state(5)
lockOnTabSwitch = $state(true) 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. * Load persisted settings from IndexedDB.
@ -18,9 +20,11 @@ export class SettingsStore {
async load() { async load() {
const minutes = await getSetting('autoLockMinutes') const minutes = await getSetting('autoLockMinutes')
const tabSwitch = await getSetting('lockOnTabSwitch') const tabSwitch = await getSetting('lockOnTabSwitch')
const dismissedWarning = await getSetting('dismissedLocalWarning')
this.autoLockMinutes = minutes != null ? Number(minutes) : 5 this.autoLockMinutes = minutes != null ? Number(minutes) : 5
this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true this.lockOnTabSwitch = tabSwitch != null ? Boolean(tabSwitch) : true
this.dismissedLocalWarning = dismissedWarning != null ? Boolean(dismissedWarning) : false
} }
/** /**
@ -29,6 +33,7 @@ export class SettingsStore {
async save() { async save() {
await saveSetting('autoLockMinutes', this.autoLockMinutes) await saveSetting('autoLockMinutes', this.autoLockMinutes)
await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch) await saveSetting('lockOnTabSwitch', this.lockOnTabSwitch)
await saveSetting('dismissedLocalWarning', this.dismissedLocalWarning)
} }
} }

View File

@ -1,6 +1,13 @@
import { mount } from 'svelte' import { mount } from 'svelte'
import './styles/main.css' import './styles/main.css'
import App from './App.svelte' 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, { const app = mount(App, {
target: document.getElementById('app'), target: document.getElementById('app'),

View 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)
})
})

View File

@ -50,6 +50,20 @@ describe('createEntry', () => {
expect(entry.updatedAt).toBe(entry.createdAt) 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', () => { it('should trim title and optional fields', () => {
const entry = createEntry({ const entry = createEntry({
title: ' GitHub ', title: ' GitHub ',
@ -176,16 +190,22 @@ describe('validateEntry', () => {
expect(result.errors).toContain('Title is required') 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' }) const result = validateEntry({ title: 'GitHub' })
expect(result.valid).toBe(false) expect(result.valid).toBe(true)
expect(result.errors).toContain('Password is required') 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: '' }) const result = validateEntry({ title: '' })
expect(result.valid).toBe(false) expect(result.valid).toBe(false)
expect(result.errors.length).toBe(2) expect(result.errors.length).toBe(1)
expect(result.errors).toContain('Title is required')
}) })
}) })

View File

@ -25,7 +25,7 @@ import {
importAll, importAll,
TRASH_GROUP_ID, TRASH_GROUP_ID,
} from '../../../src/lib/storage/db.js' } 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' import { createEntry, createGroup, createTrashGroup } from '../../../src/lib/models/schema.js'
const DB_NAME = 'password-vault' const DB_NAME = 'password-vault'
@ -601,4 +601,103 @@ describe('Export / Import', () => {
expect(result.skipped).toBe(1) expect(result.skipped).toBe(1)
expect(result.imported.entries).toBe(0) 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')
})
})
}) })

View 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)
})
})

View File

@ -1,9 +1,25 @@
import { execSync } from 'node:child_process'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
import { viteSingleFile } from 'vite-plugin-singlefile' import { viteSingleFile } from 'vite-plugin-singlefile'
// https://vite.dev/config/ // 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 }) => ({ export default defineConfig(({ command }) => ({
define: {
__VAULT_COMMIT__: JSON.stringify(getCommitHash()),
},
plugins: [ plugins: [
svelte(), svelte(),
...(command === 'build' ...(command === 'build'
@ -27,5 +43,8 @@ export default defineConfig(({ command }) => ({
globals: true, globals: true,
setupFiles: ['./tests/setup.js'], setupFiles: ['./tests/setup.js'],
include: ['src/**/*.test.js', 'tests/**/*.test.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,
}, },
})) }))