Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
# sdk/testdata/ holds a throwaway RSA key and values encrypted to it. They are
# conformance vectors that every client library and the server decrypt in
# tests; the key protects nothing and is labelled TEST ONLY in each file.
# The browser extension's CA-constraints fixture holds throwaway keys of the same kind.
title = "keepiq"

[extend]
useDefault = true

[allowlist]
description = "Test-only conformance vectors for the client libraries"
paths = ['''^sdk/testdata/''']
description = "Test-only conformance vectors and certificate fixtures"
paths = ['''^sdk/testdata/''', '''^browser-extension/src/testing/ca-constraints-chain\.json$''']
30 changes: 26 additions & 4 deletions browser-extension/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ How the extension is put together. Decisions live in the ADRs under [openspec/ar
## Modules

- `src/api/`: the Keepiq client, the only code that calls `fetch`. Basic auth on the app password, `credentials: 'omit'`, a 401 that logs the account out ([ADR-003](openspec/architecture/adr-003-keepiq-api-contract.md)).
- `src/crypto/`: PBKDF2 key derivation, AES-GCM envelope and RSA-OAEP, byte compatible with the web app (tested against `../tests/vectors/crypto/`).
- `src/crypto/`: PBKDF2 key derivation, AES-GCM envelope and RSA-OAEP, byte compatible with the web app (tested against `../tests/vectors/crypto/`). `federated-certificate.ts` verifies a partner recipient's certificate chain, ported from the web app.
- `src/accounts/`:
- `store.ts`: the account list, behind one write queue.
- `settings.ts`: the per-account timeout settings and the policy clamp.
- `verify.ts`: checks credentials before an account is stored.
- `normalize-server-url.ts`: turns whatever was typed into a server URL.
- `src/settings/global.ts`: settings that apply before an account is chosen, today the theme.
- `src/vault/`:
- `key-store.ts`: where the unlocked key lives ([ADR-002](openspec/architecture/adr-002-key-lifetime-and-vault-cache.md)).
- `unlock.ts`: unlock, lock and the suite checks.
Expand All @@ -28,6 +29,11 @@ How the extension is put together. Decisions live in the ADRs under [openspec/ar
- `match.ts`: base-domain matching for suggestions.
- `list.ts`: sorting, filtering and the folder tree.
- `payloads.ts`: parses card, identity and passkey payloads.
- `fields.ts`: the item form's model, limits and validation; pure, so the popup may use it.
- `write.ts`: item and folder writes, the only code that encrypts to the user's own key.
- `share-save.ts`: saves a value change with every other copy of a shared item in one share-save.
- `verified-partners.ts`: partner certificates a failed save verified, kept in memory for 5 minutes.
- `write-error.ts`: maps a failed write to the code the popup words.
- `url.ts`: the one rule for turning an item URL into a link.
- `icons.ts`: type icons.
- `src/background/`:
Expand All @@ -47,7 +53,8 @@ All types are in `src/messages.ts`. The background only answers extension pages;
| Direction | Kinds | Reply |
| --- | --- | --- |
| Popup → background, account actions | `accounts.*`, `vault.unlock`, `vault.lock`, `vault.lockAll`, `vault.status` | `Result`: `{ ok: true, state }` or `{ ok: false, code }` |
| Popup → background, vault requests | `vault.snapshot`, `vault.sync`, `item.decrypt`, `clipboard.copied`, `popup.popout`, `popup.lastTab.set` | The typed reply in `PopupReplies`; `undefined` when the background failed |
| Popup → background, vault requests | `vault.snapshot`, `vault.sync`, `item.decrypt`, `clipboard.copied`, `popup.popout`, `popup.lastTab.set`, `settings.*` | The typed reply in `PopupReplies`; `undefined` when the background failed |
| Popup → background, writes | `item.fetch`, `item.editable`, `item.create`, `item.update`, `item.delete`, `folder.create`, `folder.rename`, `folder.children`, `folder.delete`, `folder.dismissNotice` | `WriteResult`: `{ ok: true, value }` or `{ ok: false, error }`, after the sync that follows a write; `item.fetch`, `item.editable` and `folder.children` reply without one |
| Background → popup | `vault.changed`, `vault.locked` | None; the popup re-reads |
| Background → offscreen | `offscreen.clearClipboard` | Whether it cleared |
| Content → background | `page_ready` | None |
Expand All @@ -63,14 +70,18 @@ All types are in `src/messages.ts`. The background only answers extension pages;
| `accounts` | local | Account records, app passwords included |
| `activeAccountId` | local | The active account; heals itself when it points nowhere |
| `settings.<id>` | local | Vault timeout and action |
| `settings.global` | local | The theme; survives logout |
| `keepiq.theme` | popup `localStorage` | A mirror of the theme for the first frame, never read by the background |
| `suite.<id>` | local | The active suite row, private key still wrapped |
| `vaultCache.<id>` | local | The snapshot: secret rows, folders, types, suite id and epoch, counts |
| `vaultCache.<id>` | local | The snapshot: secret rows, folders, types, suite id and epoch, counts, the default item type |
| `folderNotice.<id>` | local | The folder manager's plaintext-names notice was dismissed |
| `vaultCheckedAt.<id>` | local | When the server was last checked |
| `neverLockKey.<id>` | local | The unwrapped key, under the "Never" timeout only |
| `privateKeyPkcs8.<id>` | session | The unwrapped key while unlocked |
| `unlockedAt.<id>`, `lastInteractionAt` | session | Timeout bookkeeping |
| `popup:lastTab` | session | The popup tab to reopen |
| `addAccountDraft` | session | Server URL and username while adding an account |
| `editOpened.<id>` | session | Per edit form, for the 10 newest, keyed by the random `recordId` of the form's fetch: the ciphertext it showed in full, the row's `updatedAt` and the source's `sourceUpdatedAt`; a form's entry goes when its save is accepted, stays after a failed one, and all go when the key is cleared |

Where `storage.session` is missing (Firefox below 115), session values live in background memory instead, and the draft is not kept.

Expand All @@ -88,12 +99,23 @@ Where `storage.session` is missing (Firefox below 115), session values live in b
- **Triggers:** unlock, opening the popup with a missing or stale snapshot, the `vault-sync` alarm every 15 minutes, and "Sync now". One sync runs per account; a later trigger joins the one running.
- **Probe:** the newest row and the total, plus the suite list. When nothing changed and the folder and type lists are under 60 minutes old, the sync only records the check time.
- **Full:**
1. Fetch the manifest. On 403 or 404, at 1000 rows, or when the cached total is 1000 or more, page through the secret list instead.
1. Fetch the manifest, and the user settings beside it for the default item type. On a refusal (403 or 428) or 404, at 1000 rows, or when the cached total is 1000 or more, page through the secret list instead.
2. Handle a two-factor block, then a suite change.
3. Mark rows on blocked suites.
4. Write the snapshot in one `set`.
- **Logout during a sync:** checked after every write, and the writes are undone.

### Writes

- **The popup sends plaintext,** the background encrypts with the cached suite's certificate, once it proved that certificate matches the unlocked key and the server's active suite is still that one.
- **Each write names its account,** and is refused when that is no longer the active one.
- **Shared copies keep their limits** (use-only, read-only, expiring, view-only) in the popup and again in the background. A copy is view-only when `write-context` gives it no `owner`, `write` or `manage` grade; the detail asks with `item.editable` unless Edit is already unavailable, and the edit fetch and every save but a move refuse it.
- **Edit and Clone fetch the item fresh** from the server and open it only when its ciphertext matches the vault cache, after one sync on a mismatch (ADR-002); Edit saves the changed fields with `baseUpdatedAt`, so a newer version is not overwritten, and with its fetch's `recordId`, so the save vouches only for what its own form showed.
- **A changed `key`, `login` or `additionalFields` saves through share-save**, the editor's row and every other copy in one transaction: `write-context` picks the owner path (local copies and partner shares), or the copy-editor path (the source and the other copies). The first call carries empty lists; a 409 `incomplete` names what to encrypt for, at most three rounds. Partners get the typed fields plus what the form's `editOpened` record showed, never a fresh row. Any other edit is a plain `PUT`, after a `write-context` read unless it only moves the item.
- **A refused save writes nothing** and keeps the form open; when the reply to the final call is lost, the server wrote everything or nothing. Nothing about a save outlives the request but verified partner certificates, kept in background memory for 5 minutes and forgotten on lock, logout and account switch, and the server's version check orders concurrent saves. The `item.update` reply carries `shareSave` when the item's detail has a note to show.
- **Every write records** whether the server answered, which drives the same offline flag as sync.
- **A successful write runs a full sync** and replies after it, waiting for a sync that started earlier.

### Timeout

- `enforce()` runs on the `vault-timeout` alarm and before every popup request, and locks or logs out each account past its timeout.
Expand Down
2 changes: 1 addition & 1 deletion browser-extension/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ empty. Each entry: the choice, the options, and what it blocks. -->
- Whether the "Custom" vault timeout is capped (for example at 24 hours) or
unbounded. Blocks the timeout options in `ext-settings` (ADR-002).
- Whether the paginated fallback may persist the vault when the admin turned off
offline caching (the manifest answers 403). It does today, as `ext-vault-browse`
offline caching (the manifest refuses with 428). It does today, as `ext-vault-browse`
specifies; the alternative keeps it in `storage.session` for the browser session only.

## Additional rules
Expand Down
23 changes: 22 additions & 1 deletion browser-extension/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ The extension uses the same end-to-end encryption as the Keepiq web app and is b
| --- | --- | --- |
| Master password | Nowhere | — |
| Private key | `storage.session` (memory, cleared on browser restart). Firefox below 115 uses background page memory instead. | Lock, timeout, logout, browser restart, extension reload |
| Decrypted values | Popup memory while shown, never the value of a use-only copy | Closing the item or the popup |
| Decrypted values, unsaved forms | Popup memory while shown, never the value of a use-only copy | Closing the item, the form or the popup |
| Ciphertext of the 10 newest edit forms, with the item's and its source's version | `storage.session`, like the private key | That form's accepted save, or 10 newer entries; lock, timeout, logout, account removal, browser restart, extension reload. Closing the form or popup leaves it. |
| Ciphertext, names, URLs, folders, types | `storage.local` | Logout, account removal, a key change on the server |
| App password | `storage.local` | Logout, account removal, the server answering 401 |
| Account list and settings | `storage.local` | Account removal |
Expand All @@ -39,6 +40,7 @@ The extension uses the same end-to-end encryption as the Keepiq web app and is b
## Server and network

- The extension only talks to your own Nextcloud server. It sends no telemetry and doesn't fetch favicons from websites or icon services, and the Firefox manifest declares no data collection.
- Every popup field turns off the browser's spell check, so cloud spelling services, such as Chrome's Enhanced spell check or Edge's Microsoft Editor, never receive vault text.
- Requests go from the background only, with Basic auth on an app password and `credentials: 'omit'`, so browser cookies are never sent.
- Server URLs must be `https`. Plain `http` is allowed only for `localhost`, `127.0.0.1` and `.test` hosts, for development.
- Host access is requested per server, when you add an account. The extension holds no other host permissions.
Expand All @@ -61,6 +63,23 @@ There is no remote wipe, so these checks stand in for it:
- The popup gets item metadata and ids. It gets decrypted fields only on request, and ciphertext never. Of a use-only copy it gets only the login; the background refuses its value and additional fields.
- No remote code is loaded, and the popup renders no HTML from vault data.

## Saving

- The popup sends what you typed to the background, which does all the encryption.
- Before it encrypts, the extension checks that the server's active key is still the one it holds, and proves the server's certificate belongs to your unlocked key. Otherwise nothing is written.
- A save goes to the account the popup showed, and is refused when another window switched accounts.
- Edit fetches the item fresh, sends only what changed, and is refused when a newer version was saved elsewhere. Edit and Clone only open what your vault already holds for that item, and a save gives partners the fields you didn't touch only as the form showed them in full, under the type the fetch checked. A swap while the form is open changes nothing partners get; a swap before the form opens is not caught (see Known limits, and [ADR-002](openspec/architecture/adr-002-key-lifetime-and-vault-cache.md#sharing)).
- Shared copies keep their limits: a use-only copy is never shown, copied or changed, a read-only one can only be filed in a folder, one shared to view only can't be changed but can be cloned or filed, and a use-only, read-only or expiring copy can't be cloned.
- Delete moves an item to Keepiq's trash and ends its shares. A folder delete is immediate, and "Delete items too" deletes them for good.

## Shared items

- A changed password, username or field of a shared item is encrypted again, in the background, for every copy the server lists, and saved in one request: the server writes your row, every copy in your organisation and every partner share together, or nothing. It refuses a save over a newer version of the item, or, for a team editor, of the original.
- Recipients in your own organisation are trusted as the server lists them, with the certificate it serves, as in the Keepiq web app. A compromised server could list someone you never shared with, or make up a partner share, and get the password, username and fields of any item whose password, username or fields you edit, shared or not. When the form didn't show every part of an item, a partner share stops the save, so such a server gets only what you changed ([ADR-002](openspec/architecture/adr-002-key-lifetime-and-vault-cache.md#sharing)).
- A recipient at a partner organisation gets nothing until their certificate chains to the partner root your server pinned and names them. Otherwise their share is paused. When their server can't be reached, nothing is saved until you choose to save without them, which pauses their share too.
- Both your server and the partner's are still trusted for partner recipients: the check catches a certificate for the wrong person, an expired one, or one from another partner, not a server that lies.
- The decrypted value lives in background memory for that one request. The popup only learns codes and the partner shares paused or not reached, and forgets them when it closes.

## Clipboard

- Copy happens in the popup, under your click.
Expand All @@ -73,6 +92,8 @@ There is no remote wipe, so these checks stand in for it:
- **The app password** is long-lived. It can't decrypt anything, but it does give read and write access to the ciphertext. You can revoke it from Nextcloud's security settings.
- **Names, URLs and folders** are stored in plain text on disk, as the server returns them. They reveal which sites you have accounts on, though not the secrets.
- **While the vault is unlocked,** anyone at the machine can use it, up to the timeout.
- **The recipient list of a shared item** comes from the server, so editing a shared item trusts it (see Shared items).
- **A server that swaps another item's value into this one before you open it for editing** is not caught. The form shows that value as this item's, masked if it is a password or another secret, and saving shares it with whoever the server lists. Fields the form doesn't show are never shared this way.
- **Revocation takes effect on the next contact with the server:** at most the 15-minute sync interval, or sooner when the popup opens.
- **A share that becomes use-only** shows as use-only after the next full sync: within an hour and a quarter, or at once after unlock or Sync now. The server doesn't change the copy's `updatedAt`, so the quick probe misses it.
- **Offline caching turned off by an admin:** the extension still caches the vault on disk. Whether it should keep it in session storage instead is still open.
Expand Down
2 changes: 2 additions & 0 deletions browser-extension/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Header } from './components/Header'
import { Loading } from './components/Loading'
import { useBackgroundMessage } from './hooks/useBackgroundMessage'
import { usePopupState } from './hooks/usePopupState'
import { useTheme } from './hooks/useTheme'
import { AccountSwitcher } from './views/AccountSwitcher'
import { AddAccount } from './views/AddAccount'
import { LogInAgain } from './views/LogInAgain'
Expand All @@ -16,6 +17,7 @@ type Panel = 'switcher' | 'add_account' | null

export default function App() {
const { state, error, dispatch } = usePopupState()
useTheme()
const [panel, setPanel] = useState<Panel>(null)
const refreshState = useCallback(() => void dispatch({ kind: 'vault.status', passive: true }), [dispatch])
// A lock from the timeout or another window swaps this popup to the unlock view.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useState } from 'react'
import { i18n } from '#i18n'
import { customField, hasLineBreak, keepLineBreaks, type CustomField, type FormErrors } from '@/src/vault/fields'
import { fieldErrorText } from '../errors'
import { Button, IconButton } from './Button'
import { Icon } from './Icon'
import { TextArea } from './TextArea'
import { TextField } from './TextField'

interface Props {
fields: CustomField[]
/** The errors to show now, keyed as `validate` keys them. */
errors: FormErrors
onChange: (fields: CustomField[]) => void
}

/** Only an opened value can hold a line break, as the single-line input takes none. */
function FieldValue({ label, value, error, onChange }: { label: string; value: string; error: string | null | undefined; onChange: (value: string) => void }) {
const [original] = useState(value)
return hasLineBreak(original)
? <TextArea label={label} value={value} onChange={(typed) => onChange(keepLineBreaks(typed, original))} error={error} mono concealed />
: <TextField label={label} type="password" value={value} onChange={onChange} error={error} autoComplete="off" />
}

/** Values are masked until revealed; the blob stores no field type, so that is view state only. */
export function AdditionalFieldsEditor({ fields, errors, onChange }: Props) {
const update = (id: number, patch: Partial<CustomField>) => onChange(fields.map((field) => field.id === id ? { ...field, ...patch } : field))
const error = (path: string) => errors[path] && fieldErrorText(errors[path])
return (
<div className="custom-fields">
{fields.map((field) => (
<div key={field.id} className="custom-field">
<div className="custom-field__inputs">
<TextField label={i18n.t('form.fieldName')} value={field.name} onChange={(name) => update(field.id, { name })} error={error(`field.${field.id}.name`)} autoComplete="off" verbatim />
<FieldValue label={field.name.trim() || i18n.t('form.fieldValue')} value={field.value} onChange={(value) => update(field.id, { value })} error={error(`field.${field.id}.value`)} />
</div>
<IconButton
icon="trash"
label={field.name.trim() ? i18n.t('form.removeField', { name: field.name.trim() }) : i18n.t('form.removeUnnamed')}
onClick={() => onChange(fields.filter((other) => other.id !== field.id))}
/>
</div>
))}
<Button variant="link" onClick={() => onChange([...fields, customField()])}><Icon name="plus" />{i18n.t('form.addField')}</Button>
</div>
)
}
Loading
Loading