Compare commits
3 Commits
c2c2f656db
...
d6dc384e9f
| Author | SHA1 | Date | |
|---|---|---|---|
| d6dc384e9f | |||
| c0231fcd26 | |||
| 87aed17092 |
72
AGENTS.md
Normal file
72
AGENTS.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Password Vault — Agent Context
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
An offline-first, single-file password manager built with **Svelte 5** (runes-based reactivity). All data is encrypted in-browser with AES-256-GCM and stored in IndexedDB. The production build is a single `dist/index.html` with zero network dependencies — it works from `file://`, USB, or any static server.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── App.svelte # Root component — routes between LockScreen, MainLayout
|
||||||
|
├── main.js # Entry point
|
||||||
|
├── components/
|
||||||
|
│ ├── LockScreen.svelte # Master password setup + unlock UI
|
||||||
|
│ ├── MainLayout.svelte # Shell: sidebar + content area
|
||||||
|
│ ├── Sidebar.svelte # Group list + search bar
|
||||||
|
│ ├── EntryList.svelte # Credential entries grid
|
||||||
|
│ ├── EntryForm.svelte # Create/edit credential form
|
||||||
|
│ ├── EntryDetail.svelte # View single entry (copy password/username)
|
||||||
|
│ ├── PasswordGenerator.svelte# Configurable password generator
|
||||||
|
│ └── ImportExport.svelte # JSON import/export with merge/replace
|
||||||
|
├── lib/
|
||||||
|
│ ├── crypto/crypto.js # Web Crypto API: PBKDF2 key derivation, AES-GCM encrypt/decrypt, password generator
|
||||||
|
│ ├── storage/db.js # IndexedDB layer (idb wrapper): entries, groups, meta stores
|
||||||
|
│ ├── models/schema.js # Data models: CredentialEntry, Group; validation; ID generation
|
||||||
|
│ └── stores/
|
||||||
|
│ ├── app.svelte.js # AppStore: $state(isUnlocked, encryptionKey, salt)
|
||||||
|
│ ├── security.svelte.js # Auto-lock timer, visibility change, beforeunload cleanup
|
||||||
|
│ └── search.svelte.js # Reactive search state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Svelte 5 runes** — Use `$state`, `$derived`, `$effect`. Props-based event passing (no Svelte events).
|
||||||
|
- **No external crypto libraries** — Uses the browser's native Web Crypto API exclusively.
|
||||||
|
- **Key never persisted** — Encryption key lives only in `$state` memory; cleared on lock, tab switch, or page close.
|
||||||
|
- **Single-file build** — `vite-plugin-singlefile` inlines all JS/CSS; post-build script inlines favicon.
|
||||||
|
- **IndexedDB via `idb`** — Three stores: `entries`, `groups`, `meta`. Only `encryptedPassword` is encrypted at rest; titles, usernames, URLs, and notes are plaintext for searchability.
|
||||||
|
- **PBKDF2 key derivation** — 600,000 iterations, SHA-256, 256-bit AES-GCM key.
|
||||||
|
|
||||||
|
## Encryption Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Master Password → PBKDF2 (600k iters, 16-byte salt) → AES-256-GCM Key → encrypt/decrypt credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
Password verification uses a test payload (random string encrypted at vault creation). On unlock, the entered password derives a key that must successfully decrypt the test payload.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server with HMR on `:5173` |
|
||||||
|
| `npm run build` | Production build → `dist/index.html` (single self-contained file) |
|
||||||
|
| `npm run preview` | Preview production build |
|
||||||
|
| `npm run test` | Vitest (watch mode) |
|
||||||
|
| `npm run test:run` | Vitest (single run) |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Framework: **Vitest** with **jsdom** environment
|
||||||
|
- Test setup: `tests/setup.js` (fake-indexeddb polyfill)
|
||||||
|
- Test files: `tests/lib/stores/*.test.js`
|
||||||
|
- Run with `npm run test` or `npm run test:run`
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Only `encryptedPassword` is encrypted at rest; other fields (title, username, URL, notes) are plaintext in IndexedDB.
|
||||||
|
- `testPlaintext` for password verification is stored unencrypted in the `meta` store.
|
||||||
|
- Auto-lock triggers on tab visibility change and 5-minute inactivity.
|
||||||
|
- Clipboard auto-clears after 15 seconds.
|
||||||
|
- No browser fingerprinting or anti-keylogger protections.
|
||||||
@ -95,6 +95,7 @@
|
|||||||
bind:value={masterPassword}
|
bind:value={masterPassword}
|
||||||
placeholder="Enter master password"
|
placeholder="Enter master password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
autofocus
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { generateId } from '../models/schema.js'
|
import { generateId } from '../models/schema.js'
|
||||||
|
|
||||||
const PBKDF2_ITERATIONS = 100_000
|
const PBKDF2_ITERATIONS = 600_000
|
||||||
const SALT_LENGTH = 16 // bytes
|
const SALT_LENGTH = 16 // bytes
|
||||||
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
|
const IV_LENGTH = 12 // bytes (recommended for AES-GCM)
|
||||||
|
|
||||||
@ -135,7 +135,7 @@ export async function createTestPayload(masterPassword) {
|
|||||||
|
|
||||||
// --- Utility: Uint8Array ↔ Base64 ---
|
// --- Utility: Uint8Array ↔ Base64 ---
|
||||||
|
|
||||||
function uint8ArrayToBase64(buffer) {
|
export function uint8ArrayToBase64(buffer) {
|
||||||
const bytes = new Uint8Array(buffer)
|
const bytes = new Uint8Array(buffer)
|
||||||
let binary = ''
|
let binary = ''
|
||||||
for (let i = 0; i < bytes.byteLength; i++) {
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
@ -144,7 +144,7 @@ function uint8ArrayToBase64(buffer) {
|
|||||||
return btoa(binary)
|
return btoa(binary)
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64ToUint8Array(base64) {
|
export function base64ToUint8Array(base64) {
|
||||||
const binary = atob(base64)
|
const binary = atob(base64)
|
||||||
const bytes = new Uint8Array(binary.length)
|
const bytes = new Uint8Array(binary.length)
|
||||||
for (let i = 0; i < binary.length; i++) {
|
for (let i = 0; i < binary.length; i++) {
|
||||||
@ -189,10 +189,21 @@ export function generatePassword({
|
|||||||
throw new Error('Password charset is empty — enable at least one character type')
|
throw new Error('Password charset is empty — enable at least one character type')
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomValues = crypto.getRandomValues(new Uint8Array(length))
|
const charsetLength = charset.length
|
||||||
|
const maxValid = 256 - (256 % charsetLength)
|
||||||
|
const randomBytes = new Uint8Array(length * 2)
|
||||||
let password = ''
|
let password = ''
|
||||||
for (let i = 0; i < length; i++) {
|
let byteIdx = 0
|
||||||
password += charset[randomValues[i] % charset.length]
|
|
||||||
|
while (password.length < length) {
|
||||||
|
if (byteIdx >= randomBytes.length) {
|
||||||
|
crypto.getRandomValues(randomBytes)
|
||||||
|
byteIdx = 0
|
||||||
|
}
|
||||||
|
const byte = randomBytes[byteIdx++]
|
||||||
|
if (byte < maxValid) {
|
||||||
|
password += charset[byte % charsetLength]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return password
|
return password
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
export function generateId() {
|
export function generateId() {
|
||||||
const timestamp = Date.now().toString(36)
|
const timestamp = Date.now().toString(36)
|
||||||
const random = Math.random().toString(36).slice(2, 10)
|
const randomBytes = new Uint8Array(4)
|
||||||
|
crypto.getRandomValues(randomBytes)
|
||||||
|
const random = Array.from(randomBytes).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 8)
|
||||||
return `${timestamp}_${random}`
|
return `${timestamp}_${random}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -310,19 +310,6 @@ export async function exportAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a base64 string back to Uint8Array.
|
|
||||||
* @param {string} base64
|
|
||||||
* @returns {Uint8Array}
|
|
||||||
*/
|
|
||||||
function base64ToUint8Array(base64) {
|
|
||||||
const binary = atob(base64)
|
|
||||||
const bytes = new Uint8Array(binary.length)
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import data from a previously exported JSON object.
|
* Import data from a previously exported JSON object.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user