Skip to content

Commit 49751a0

Browse files
Fix Zero-Knowledge vaults: set the passphrase at creation + verify on unlock
You could create a ZK space but were never asked to *set* a passphrase: the first thing typed on entry silently became the key, with no confirmation and no wrong- passphrase check — a typo encrypted files under a passphrase you couldn't reproduce ('Déchiffrement impossible'). The crypto was correct; the flow wasn't. - Creating a secured space now prompts for the passphrase twice (confirmation). - A per-vault 'verifier' (a constant encrypted with the vault key) is stored so unlocking can detect a wrong passphrase immediately, and a stale cached key is rejected instead of trusted. Legacy vaults (no verifier) behave as before. - Client now generates the per-vault salt so it can derive the key before the round-trip; server falls back to its own salt if absent. - Schema: Folder.zkVerifier (+ migration); shared create schema accepts zkSalt / zkVerifier; serializer/types expose zkVerifier. - Also bump the GitHub update-check timeout to 12s. 41 tests pass.
1 parent 6e6c170 commit 49751a0

9 files changed

Lines changed: 119 additions & 12 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Folder" ADD COLUMN "zkVerifier" TEXT;

apps/api/prisma/schema.prisma

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ model Folder {
113113
// Per-vault random salt for the client's PBKDF2 key derivation (ZK folders only).
114114
// Not secret; storing it per vault defeats cross-vault precomputation. null = legacy.
115115
zkSalt String?
116+
// Opaque "key check value": a constant encrypted client-side with the vault key when the
117+
// vault is created. The client decrypts it on unlock to confirm the passphrase is correct
118+
// (so a typo is caught immediately instead of corrupting uploads). null = legacy vault.
119+
zkVerifier String?
116120
// Soft-delete: when set, the folder is in the Trash (hidden from the Drive) until it is
117121
// restored or purged. Trashing a folder cascades this timestamp to its whole subtree.
118122
deletedAt DateTime?

apps/api/src/lib/serialize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function toPublicFolder(f: Folder): PublicFolder {
3939
parentId: f.parentId,
4040
isZeroKnowledge: f.isZeroKnowledge,
4141
zkSalt: f.zkSalt,
42+
zkVerifier: f.zkVerifier,
4243
createdAt: f.createdAt.toISOString(),
4344
};
4445
}

apps/api/src/routes/folders.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ export const folderRoutes: FastifyPluginAsync = async (app) => {
5151
parentId: body.parentId ?? null,
5252
name: body.name,
5353
isZeroKnowledge: isZk,
54-
// Fresh per-vault salt so each vault's passphrase derivation is independent.
55-
zkSalt: isZk ? randomToken(16) : null,
54+
// Per-vault salt: prefer the client's (so it can derive the key before the round-trip),
55+
// else a fresh server one. Independent per vault either way.
56+
zkSalt: isZk ? (body.zkSalt ?? randomToken(16)) : null,
57+
// Passphrase verifier supplied by the client (so unlock can detect a wrong passphrase).
58+
zkVerifier: isZk ? (body.zkVerifier ?? null) : null,
5659
},
5760
});
5861
await audit(req, 'folder.create', { target: folder.id });

apps/api/src/services/version.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function getRemoteVersion(env: Env): Promise<RemoteVersion | null>
102102
try {
103103
const res = await fetch(
104104
`https://api.github.com/repos/${env.GITHUB_REPO}/commits/${encodeURIComponent(env.UPDATE_BRANCH)}`,
105-
{ headers, signal: AbortSignal.timeout(8000) },
105+
{ headers, signal: AbortSignal.timeout(12000) },
106106
);
107107
if (!res.ok) return null;
108108
const c = (await res.json()) as GithubCommit;
@@ -129,7 +129,7 @@ export async function commitsBehind(env: Env, base: string, head: string): Promi
129129
try {
130130
const res = await fetch(
131131
`https://api.github.com/repos/${env.GITHUB_REPO}/compare/${base}...${head}`,
132-
{ headers, signal: AbortSignal.timeout(8000) },
132+
{ headers, signal: AbortSignal.timeout(12000) },
133133
);
134134
if (!res.ok) return null;
135135
const data = (await res.json()) as { ahead_by?: number };

apps/web/src/app/(app)/drive/page.tsx

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ import type { PublicFile, PublicFolder } from '@opencoperlock/shared/client';
3939
import { formatBytes } from '@opencoperlock/shared/client';
4040
import { api, API_URL, ApiError } from '@/lib/api';
4141
import { useAuth } from '@/lib/auth';
42-
import { decryptBlob, decryptName, deriveVaultKey, encryptFile } from '@/lib/zk';
42+
import {
43+
checkVerifier,
44+
decryptBlob,
45+
decryptName,
46+
deriveVaultKey,
47+
encryptFile,
48+
makeVerifier,
49+
randomSalt,
50+
} from '@/lib/zk';
4351
import { fileVisual } from '@/lib/fileType';
4452
import { FileViewer, type ViewerSource } from '@/components/FileViewer';
4553
import { Menu } from '@/components/ui/Menu';
@@ -78,6 +86,7 @@ export default function EspacesPage() {
7886
// passphrase prompt
7987
const [askPass, setAskPass] = useState<PublicFolder | null>(null);
8088
const [passInput, setPassInput] = useState('');
89+
const [passError, setPassError] = useState<string | null>(null);
8190

8291
const spaces = useMemo(() => allFolders.filter((f) => f.parentId === null), [allFolders]);
8392
const activeSpace = useMemo(
@@ -147,21 +156,33 @@ export default function EspacesPage() {
147156
if (space.isZeroKnowledge) {
148157
const cached = sessionStorage.getItem(passKey(space.id));
149158
if (cached) {
150-
setVaultKey(await deriveVaultKey(cached, space.zkSalt));
151-
} else {
152-
setPassInput('');
153-
setAskPass(space);
159+
const key = await deriveVaultKey(cached, space.zkSalt);
160+
// Trust the cache only if it still matches the vault's verifier (legacy vaults have none).
161+
if (!space.zkVerifier || (await checkVerifier(key, space.zkVerifier))) {
162+
setVaultKey(key);
163+
return;
164+
}
165+
sessionStorage.removeItem(passKey(space.id));
154166
}
167+
setPassInput('');
168+
setPassError(null);
169+
setAskPass(space);
155170
}
156171
}
157172

158173
async function submitPassphrase() {
159174
if (!askPass || !passInput) return;
160175
const key = await deriveVaultKey(passInput, askPass.zkSalt);
176+
// Reject a wrong passphrase up-front instead of silently caching a bad key.
177+
if (askPass.zkVerifier && !(await checkVerifier(key, askPass.zkVerifier))) {
178+
setPassError('Phrase de passe incorrecte.');
179+
return;
180+
}
161181
sessionStorage.setItem(passKey(askPass.id), passInput);
162182
setVaultKey(key);
163183
setAskPass(null);
164184
setPassInput('');
185+
setPassError(null);
165186
}
166187

167188
function leaveSpace() {
@@ -184,8 +205,45 @@ export default function EspacesPage() {
184205
],
185206
});
186207
if (!kind) return;
208+
209+
if (kind === 'secured') {
210+
// Set the vault passphrase now, with confirmation, and store a verifier so a wrong
211+
// passphrase is caught on unlock. The passphrase never leaves the browser.
212+
const pass = await prompt({
213+
title: 'Phrase de passe du coffre',
214+
message:
215+
'Elle chiffre vos fichiers dans le navigateur et n’est jamais envoyée au serveur. Notez-la précieusement : elle est irrécupérable en cas d’oubli.',
216+
label: 'Phrase de passe',
217+
password: true,
218+
});
219+
if (!pass) return;
220+
const again = await prompt({ title: 'Confirmez la phrase de passe', label: 'Retapez-la', password: true });
221+
if (again === null) return;
222+
if (again !== pass) {
223+
toast('Les phrases de passe ne correspondent pas.', 'error');
224+
return;
225+
}
226+
try {
227+
const salt = randomSalt();
228+
const key = await deriveVaultKey(pass, salt);
229+
const verifier = await makeVerifier(key);
230+
const res = await api.post<{ folder: PublicFolder }>('/folders', {
231+
name,
232+
isZeroKnowledge: true,
233+
zkSalt: salt,
234+
zkVerifier: verifier,
235+
});
236+
sessionStorage.setItem(passKey(res.folder.id), pass);
237+
await loadFolders();
238+
toast('Espace sécurisé créé', 'success');
239+
} catch (err) {
240+
setError(err instanceof ApiError ? err.message : 'Création impossible');
241+
}
242+
return;
243+
}
244+
187245
try {
188-
await api.post('/folders', { name, isZeroKnowledge: kind === 'secured' });
246+
await api.post('/folders', { name, isZeroKnowledge: false });
189247
await loadFolders();
190248
toast('Espace créé', 'success');
191249
} catch (err) {
@@ -665,10 +723,14 @@ export default function EspacesPage() {
665723
autoFocus
666724
placeholder="Phrase de passe"
667725
value={passInput}
668-
onChange={(e) => setPassInput(e.target.value)}
726+
onChange={(e) => {
727+
setPassInput(e.target.value);
728+
if (passError) setPassError(null);
729+
}}
669730
/>
731+
{passError && <p className="text-sm text-red-300">{passError}</p>}
670732
<div className="flex justify-end gap-2">
671-
<button type="button" className="btn-ghost" onClick={() => setAskPass(null)}>
733+
<button type="button" className="btn-ghost" onClick={() => { setAskPass(null); setPassError(null); }}>
672734
Annuler
673735
</button>
674736
<button type="submit" className="btn-primary" disabled={!passInput}>

apps/web/src/lib/zk.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,34 @@ export async function deriveVaultKey(passphrase: string, zkSalt: string | null):
5757
);
5858
}
5959

60+
/** A fresh, non-secret per-vault salt (hex) generated client-side at vault creation. */
61+
export function randomSalt(): string {
62+
const bytes = crypto.getRandomValues(new Uint8Array(16));
63+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
64+
}
65+
66+
// A constant encrypted under the vault key to form a "key check value": decrypting it back
67+
// to this exact string proves the passphrase is correct, without revealing anything.
68+
const VERIFIER_TOKEN = 'opencoperlock.zk.verify.v1';
69+
70+
/** Build the passphrase verifier stored with a new vault (iv.ciphertext, base64). */
71+
export async function makeVerifier(vaultKey: CryptoKey): Promise<string> {
72+
const iv = crypto.getRandomValues(new Uint8Array(12));
73+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, vaultKey, enc.encode(VERIFIER_TOKEN));
74+
return `${toB64(iv.buffer)}.${toB64(ct)}`;
75+
}
76+
77+
/** True when `vaultKey` correctly decrypts the stored verifier (i.e. the passphrase matches). */
78+
export async function checkVerifier(vaultKey: CryptoKey, verifier: string): Promise<boolean> {
79+
try {
80+
const [ivB64, ctB64] = verifier.split('.');
81+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64!) }, vaultKey, fromB64(ctB64!));
82+
return dec.decode(plain) === VERIFIER_TOKEN;
83+
} catch {
84+
return false;
85+
}
86+
}
87+
6088
export interface EncryptedUpload {
6189
blob: Blob;
6290
encryptedName: string;

packages/shared/src/schemas.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export const createFolderSchema = z.object({
5757
name: folderNameSchema,
5858
parentId: cuidSchema.nullable().optional(),
5959
isZeroKnowledge: z.boolean().optional().default(false),
60+
// Client-generated per-vault salt (hex) for a new ZK vault; the server falls back to its
61+
// own random salt if omitted. Ignored for normal folders.
62+
zkSalt: z.string().min(8).max(128).optional(),
63+
// Opaque passphrase verifier (iv.ciphertext) for a new ZK vault; ignored for normal folders.
64+
zkVerifier: z.string().max(1024).optional(),
6065
});
6166
export type CreateFolderInput = z.infer<typeof createFolderSchema>;
6267

packages/shared/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export interface PublicFolder {
2828
isZeroKnowledge: boolean;
2929
/** Per-vault PBKDF2 salt for ZK folders (null for normal folders / legacy vaults). */
3030
zkSalt: string | null;
31+
/** Opaque passphrase verifier for ZK vaults (null for normal folders / legacy vaults). */
32+
zkVerifier: string | null;
3133
createdAt: string;
3234
}
3335

0 commit comments

Comments
 (0)