Skip to content
Merged
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
8 changes: 5 additions & 3 deletions PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

## Mission

Build an industry-grade portfolio v1: a browser-first, zero-knowledge password manager with encrypted offline storage, user-controlled recovery, deterministic Google Drive synchronization, safe browser autofill, TOTP, encrypted backup/restore, and local password-health analysis. The application server must never possess keys that decrypt vault content.
Build an industry-grade portfolio v1: a browser-first, zero-knowledge password manager with encrypted offline storage, user-controlled recovery, deterministic Google Drive synchronization, safe browser autofill, TOTP, private-email aliases, encrypted backup/restore, and local password-health analysis. The application server must never possess keys that decrypt vault content.

## Non-negotiable invariants

Expand Down Expand Up @@ -46,19 +46,21 @@ Build an industry-grade portfolio v1: a browser-first, zero-knowledge password m
- Generic CSV and one Bitwarden-compatible importer.
- Provider-independent encrypted archive backup and restore.
- Local weak/reused/old password analysis and HIBP k-anonymous compromise checks.
- Signup-only private-email generation through plus addressing or a user-configured SimpleLogin/Addy.io account. Provider secrets remain encrypted vault content.
- Public references to passkeys held by platform authenticators, security keys, or external providers. TOTP seeds remain encrypted login data; passkey private keys are never imported into the WebExtension.
- Whole-system hardening, accessibility, reproducible deployment, and a polished portfolio demonstration.

## Explicit non-goals for v1

- WebDAV, multiple production providers, cards, identities, addresses, attachments, payment autofill, software passkeys, SSH keys, Secure Send, document intelligence, digital credentials, DigiLocker, iCloud, Safari, native desktop/mobile apps, native SSH agent, browser SSH terminal, persistent live shared vaults, emergency access, continuous paid breach monitoring, email aliases, arbitrary-PDF selective disclosure, and paid eSign generation.
- WebDAV, multiple production providers, attachments, software passkey private-key custody/signing, SSH keys, Secure Send, document intelligence, digital credentials, DigiLocker, iCloud, Safari, native desktop/mobile apps, native SSH agent, browser SSH terminal, persistent live shared vaults, emergency access, continuous paid breach monitoring, a project-operated email relay/domain, arbitrary-PDF selective disclosure, and paid eSign generation.

## Security model summary

Task 3 persists independent random root, document, and credential keys, each wrapped separately by the master-password, Recovery Kit, and active WebAuthn PRF device slots. Task 4 adds independently keyed immutable login/note revisions. A root-derived V2 security tag authenticates mutable bootstrap state. Privileged mutations use atomic compare-and-replace; rollback or a stale writer locks rather than continuing with stale keys. Google Drive can observe traffic metadata and ciphertext sizes but cannot derive vault keys. Zero knowledge does not protect an unlocked compromised endpoint, guarantee JavaScript erasure, or prove freshness when a provider presents a self-consistent old history to a fresh client.

## Deferred expansion

The former document-wallet, digital-credential, DigiLocker, Secure Send, SSH, software-passkey, WebDAV, and broader item plans are preserved as future work, not v1 commitments. See [`docs/32-future-work.md`](docs/32-future-work.md).
The former document-wallet, digital-credential, DigiLocker, Secure Send, SSH, native passkey-provider, WebDAV, and broader item plans are preserved as future work, not v1 commitments. See [`docs/32-future-work.md`](docs/32-future-work.md). The browser alias and passkey-reference boundary is specified in [`docs/39-private-email-and-passkey-boundary.md`](docs/39-private-email-and-passkey-boundary.md).

## Working rules for future agents

Expand Down
879 changes: 182 additions & 697 deletions README.md

Large diffs are not rendered by default.

33 changes: 29 additions & 4 deletions apps/extension/entrypoints/autofill.content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ import {
generateAdaptiveRegistrationPassword,
isCredentialField,
isLoginAction,
isRegistrationEmailField,
isRegistrationPasswordField,
isUsernameField,
OPEN_VAULT_MANAGER_TYPE,
PRIVATE_EMAIL_REQUEST_TYPE,
PROFILE_AUTOFILL_REQUEST_TYPE,
PROFILE_AUTOFILL_SELECT_TYPE,
type PrivateEmailResponse,
type ProfileAutofillResponse,
parseBiometricFillRequest,
parseShowAutofillRequest,
Expand Down Expand Up @@ -495,6 +498,28 @@ export default defineContentScript({
});
};

const requestPrivateEmail = (anchor: HTMLInputElement) => {
void sendMessage<PrivateEmailResponse>({
topUrl: location.href,
type: PRIVATE_EMAIL_REQUEST_TYPE,
userInitiated: true,
version: 1,
}).then((response) => {
if (response?.status === "value") {
fillProfileField(anchor, response.address);
return;
}
if (response?.status === "not-configured" || response?.status === "disabled") {
requestProfileSuggestions(anchor);
}
});
};

const requestProfileOrPrivateEmail = (anchor: HTMLInputElement) => {
if (isRegistrationEmailField(anchor)) requestPrivateEmail(anchor);
else requestProfileSuggestions(anchor);
};

const requestCardSuggestions = (anchor: HTMLInputElement) => {
const field = cardFieldKind(anchor);
if (field === null) return;
Expand Down Expand Up @@ -693,7 +718,7 @@ export default defineContentScript({
target instanceof HTMLInputElement &&
profileFieldKind(target) !== null
) {
requestProfileSuggestions(target);
requestProfileOrPrivateEmail(target);
} else if (event.isTrusted && window.top === window && isCredentialField(target)) {
requestSuggestions(target);
}
Expand Down Expand Up @@ -733,7 +758,7 @@ export default defineContentScript({
event.target instanceof HTMLInputElement &&
profileFieldKind(event.target) !== null
) {
requestProfileSuggestions(event.target);
requestProfileOrPrivateEmail(event.target);
} else if (isCredentialField(event.target)) {
requestSuggestions(event.target);
}
Expand Down Expand Up @@ -803,7 +828,7 @@ export default defineContentScript({
} else if (cardFieldKind(anchor) !== null) {
requestCardSuggestions(anchor);
} else if (profileFieldKind(anchor) !== null) {
requestProfileSuggestions(anchor);
requestProfileOrPrivateEmail(anchor);
} else {
requestSuggestions(anchor);
}
Expand Down Expand Up @@ -861,7 +886,7 @@ export default defineContentScript({
} else if (cardFieldKind(active) !== null) {
requestCardSuggestions(active);
} else if (profileFieldKind(active) !== null) {
requestProfileSuggestions(active);
requestProfileOrPrivateEmail(active);
} else {
requestSuggestions(active);
}
Expand Down
99 changes: 99 additions & 0 deletions apps/extension/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type IdentityProfileItem,
type LoginItem,
type PaymentCardItem,
type SecureNoteItem,
type VaultItem,
} from "@zk-wallet/vault";
import {
Expand All @@ -21,6 +22,7 @@ import {
type CaptureResponse,
type CardAutofillResponse,
credentialFingerprint,
type PrivateEmailResponse,
type ProfileAutofillResponse,
parseAuthenticatedAutofillSelectRequest,
parseAutofillFilledRequest,
Expand All @@ -33,13 +35,20 @@ import {
parseCardAutofillSelectRequest,
parseManualAutofillRequest,
parseOpenVaultManagerRequest,
parsePrivateEmailRequest,
parseProfileAutofillRequest,
parseProfileAutofillSelectRequest,
parseUsernameObservedRequest,
preferNamedCredentials,
} from "../src/autofill";
import { readAutofillMetadataIndex, writeAutofillMetadataIndex } from "../src/autofillIndex";
import { createExtensionDevicePrfProvider } from "../src/devicePrf";
import {
type CreatedPrivateEmailAlias,
createPrivateEmailAlias,
PRIVATE_EMAIL_SETTINGS_TAG,
parsePrivateEmailSettingsNote,
} from "../src/privateEmail";
import { ExtensionSessionCoordinator } from "../src/session";

export default defineBackground(() => {
Expand Down Expand Up @@ -88,6 +97,30 @@ export default defineBackground(() => {
};
const pendingKey = (tabId: number) => `zk-wallet.pending-capture.v1.${tabId}`;
const recentFillKey = (tabId: number) => `zk-wallet.recent-fill.v1.${tabId}`;
const privateEmailKey = (tabId: number) => `zk-wallet.private-email.v1.${tabId}`;
type PendingPrivateEmail = CreatedPrivateEmailAlias & { readonly expiresAt: number };
const loadPendingPrivateEmail = async (
tabId: number,
topUrl: string,
): Promise<PendingPrivateEmail | null> => {
const key = privateEmailKey(tabId);
const value = (await browser.storage.session.get(key))[key];
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const alias = value as Partial<PendingPrivateEmail>;
if (
typeof alias.address !== "string" ||
typeof alias.createdAt !== "string" ||
typeof alias.createdForOrigin !== "string" ||
typeof alias.expiresAt !== "number" ||
!["addy", "plus", "simplelogin"].includes(alias.provider ?? "") ||
alias.expiresAt <= Date.now() ||
alias.createdForOrigin !== new URL(topUrl).origin
) {
await browser.storage.session.remove(key);
return null;
}
return alias as PendingPrivateEmail;
};
type RecentFill = { readonly expiresAt: number; readonly fingerprint: string };
const rememberRecentFill = async (
tabId: number,
Expand Down Expand Up @@ -221,8 +254,26 @@ export default defineBackground(() => {
}
if (decision.action === "save") {
if (service.createLogin === undefined) return { status: "unavailable", version: 1 };
const pendingAlias = await loadPendingPrivateEmail(tabId, pending.capture.topUrl);
await service.createLogin({
breachCheck: await breachCheckFor(pending.capture.password),
...(pendingAlias !== null &&
pendingAlias.address.toLocaleLowerCase() === pending.username.trim().toLocaleLowerCase()
? {
emailAlias: {
address: pendingAlias.address,
createdAt: pendingAlias.createdAt,
createdForOrigin: pendingAlias.createdForOrigin,
provider: pendingAlias.provider,
...(pendingAlias.providerAliasId === undefined
? {}
: { providerAliasId: pendingAlias.providerAliasId }),
...(pendingAlias.sourceEmail === undefined
? {}
: { sourceEmail: pendingAlias.sourceEmail }),
},
}
: {}),
notes: "",
password: pending.capture.password,
title: decision.displayHost,
Expand All @@ -238,15 +289,19 @@ export default defineBackground(() => {
...(existing.favorite === undefined ? {} : { favorite: existing.favorite }),
...(existing.folder === undefined ? {} : { folder: existing.folder }),
breachCheck: await breachCheckFor(pending.capture.password),
...(existing.emailAlias === undefined ? {} : { emailAlias: existing.emailAlias }),
notes: existing.notes,
password: pending.capture.password,
...(existing.passkeys === undefined ? {} : { passkeys: existing.passkeys }),
...(existing.tags === undefined ? {} : { tags: existing.tags }),
title: existing.title,
...(existing.totpUri === undefined ? {} : { totpUri: existing.totpUri }),
uris: existing.uris,
username: pending.username,
});
}
await deletePending(tabId);
await browser.storage.session.remove(privateEmailKey(tabId));
await browser.action.setPopup({ popup: "popup.html", tabId });
await unlockedLogins();
return { action: decision.action, status: "saved", version: 1 };
Expand Down Expand Up @@ -329,6 +384,7 @@ export default defineBackground(() => {
| AutofillResponse
| CardAutofillResponse
| CaptureResponse
| PrivateEmailResponse
| ProfileAutofillResponse
| undefined
> => {
Expand Down Expand Up @@ -357,6 +413,49 @@ export default defineBackground(() => {
await rememberRecentFill(sender.tab.id, filledReceipt);
return;
}
const privateEmailRequest = parsePrivateEmailRequest(message);
if (privateEmailRequest !== null) {
if (!trustedOrigin(privateEmailRequest.topUrl, sender) || sender.tab?.id === undefined) {
return { status: "unavailable", version: 1 };
}
const cached = await loadPendingPrivateEmail(sender.tab.id, privateEmailRequest.topUrl);
if (cached !== null) {
return {
address: cached.address,
provider: cached.provider,
status: "value",
version: 1,
};
}
const items = await unlockedItems();
if (items === null) return { status: "locked", version: 1 };
const settingsNote = items.find(
(item): item is SecureNoteItem =>
item.type === "secure-note" && item.tags?.includes(PRIVATE_EMAIL_SETTINGS_TAG) === true,
);
if (settingsNote === undefined) return { status: "not-configured", version: 1 };
const settings = parsePrivateEmailSettingsNote(settingsNote.note);
if (settings === null) return { status: "not-configured", version: 1 };
if (!settings.autoFill) return { status: "disabled", version: 1 };
try {
const alias = await createPrivateEmailAlias(settings, privateEmailRequest.topUrl, {
randomBytes(length) {
const output = new Uint8Array(length);
crypto.getRandomValues(output);
return output;
},
});
await browser.storage.session.set({
[privateEmailKey(sender.tab.id)]: {
...alias,
expiresAt: Date.now() + 30 * 60 * 1_000,
} satisfies PendingPrivateEmail,
});
return { address: alias.address, provider: alias.provider, status: "value", version: 1 };
} catch {
return { status: "unavailable", version: 1 };
}
}
const profileSelection = parseProfileAutofillSelectRequest(message);
if (profileSelection !== null) {
if (!trustedOrigin(profileSelection.topUrl, sender)) {
Expand Down
61 changes: 61 additions & 0 deletions apps/extension/src/autofill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import {
generateStrongRegistrationPassword,
isCredentialField,
isLoginAction,
isRegistrationEmailField,
isRegistrationPasswordField,
isUsernameField,
MANUAL_AUTOFILL_REQUEST_TYPE,
PRIVATE_EMAIL_REQUEST_TYPE,
PROFILE_AUTOFILL_REQUEST_TYPE,
PROFILE_AUTOFILL_SELECT_TYPE,
parseAuthenticatedAutofillSelectRequest,
Expand All @@ -40,6 +42,7 @@ import {
parseCardAutofillRequest,
parseCardAutofillSelectRequest,
parseManualAutofillRequest,
parsePrivateEmailRequest,
parseProfileAutofillRequest,
parseProfileAutofillSelectRequest,
parseUsernameObservedRequest,
Expand Down Expand Up @@ -794,6 +797,49 @@ describe("extension automatic autofill", () => {
expect(age === null ? null : profileFieldKind(age)).toBe("age");
});

it("offers private email only with strong registration evidence", () => {
document.body.innerHTML = `
<form id="create-account">
<h2>Create account</h2>
<input id="signup-email" autocomplete="email" type="email">
<input type="password" autocomplete="new-password">
<button type="submit">Sign up</button>
</form>
<form id="newsletter">
<input id="newsletter-email" autocomplete="email" type="email">
<button type="submit">Subscribe</button>
</form>
<form id="login">
<input id="login-email" autocomplete="email" type="email">
<input type="password" autocomplete="current-password">
<button type="submit">Sign in</button>
</form>
`;
expect(isRegistrationEmailField(document.querySelector("#signup-email"))).toBe(true);
expect(isRegistrationEmailField(document.querySelector("#newsletter-email"))).toBe(false);
expect(isRegistrationEmailField(document.querySelector("#login-email"))).toBe(false);
});

it("supports passwordless multi-step signup but rejects ambiguous standalone email fields", () => {
document.body.innerHTML = `
<form id="registration-step-one" action="/accounts/register">
<h1>Join Acme</h1>
<input id="step-email" autocomplete="email" type="email">
<button type="submit">Continue</button>
</form>
<form id="profile">
<input id="plain-email" autocomplete="email" type="email">
</form>
<form id="recovery" action="/recover">
<input id="recovery-email" autocomplete="email" type="email">
<button type="submit">Recover account</button>
</form>
`;
expect(isRegistrationEmailField(document.querySelector("#step-email"))).toBe(true);
expect(isRegistrationEmailField(document.querySelector("#plain-email"))).toBe(false);
expect(isRegistrationEmailField(document.querySelector("#recovery-email"))).toBe(false);
});

it("strictly validates profile lookup and selection messages", () => {
const request = {
field: "city",
Expand All @@ -816,6 +862,21 @@ describe("extension automatic autofill", () => {
expect(parseProfileAutofillSelectRequest({ ...selection, extra: true })).toBeNull();
});

it("accepts only user-initiated HTTPS private-email requests", () => {
const request = {
topUrl: "https://signup.example.test/register",
type: PRIVATE_EMAIL_REQUEST_TYPE,
userInitiated: true,
version: 1,
};
expect(parsePrivateEmailRequest(request)).toEqual(request);
expect(
parsePrivateEmailRequest({ ...request, topUrl: "http://signup.example.test" }),
).toBeNull();
expect(parsePrivateEmailRequest({ ...request, userInitiated: false })).toBeNull();
expect(parsePrivateEmailRequest({ ...request, extra: true })).toBeNull();
});

it("captures a completed login and rejects malformed capture messages", () => {
document.body.innerHTML = `
<form>
Expand Down
Loading
Loading