Skip to content

Commit c826c39

Browse files
committed
feat(new-deepnotes): e2ee package and page collaboration crypto
1 parent 7936bdb commit c826c39

31 files changed

Lines changed: 1789 additions & 49 deletions

new-deepnotes/PLAN_PROGRESS.md

Lines changed: 36 additions & 9 deletions
Large diffs are not rendered by default.

new-deepnotes/apps/api-worker/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,19 @@ app.get("/api/pages/:pageId/collab-updates", async (c) => {
16201620
index: u.index,
16211621
encryptedData: u.encryptedData.toString("base64"),
16221622
})),
1623+
groupId: out.groupId,
1624+
pageEncryptedSymmetricKeyring:
1625+
out.pageEncryptedSymmetricKeyring.toString("base64"),
1626+
groupEncryptedContentKeyring:
1627+
out.groupEncryptedContentKeyring.toString("base64"),
1628+
groupAccessKeyring:
1629+
out.groupAccessKeyring != null
1630+
? out.groupAccessKeyring.toString("base64")
1631+
: null,
1632+
memberEncryptedAccessKeyring:
1633+
out.memberEncryptedAccessKeyring != null
1634+
? out.memberEncryptedAccessKeyring.toString("base64")
1635+
: null,
16231636
},
16241637
200,
16251638
);

new-deepnotes/apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"preview": "vite preview"
1414
},
1515
"dependencies": {
16+
"@deepnotes/e2ee": "workspace:^",
1617
"@tailwindcss/vite": "^4.2.4",
1718
"@vueuse/core": "^14.2.1",
1819
"class-variance-authority": "^0.7.1",

new-deepnotes/apps/web/src/api/api-types.generated.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5307,6 +5307,11 @@ export interface components {
53075307
encryptedPrivateKeyring: string;
53085308
/** Format: byte */
53095309
encryptedSymmetricKeyring: string;
5310+
/**
5311+
* Format: byte
5312+
* @description Argon2 salt (base64).
5313+
*/
5314+
passwordSalt?: string;
53105315
};
53115316
SessionErrorResponse: {
53125317
code: string;
@@ -5840,6 +5845,28 @@ export interface components {
58405845
*/
58415846
encryptedData: string;
58425847
}[];
5848+
/** @description Owning group (`pages.group_id`) for access-key + content-key unwrap. */
5849+
groupId: string;
5850+
/**
5851+
* Format: byte
5852+
* @description Standard base64-encoded binary (legacy tRPC used raw bytes).
5853+
*/
5854+
pageEncryptedSymmetricKeyring: string;
5855+
/**
5856+
* Format: byte
5857+
* @description Standard base64-encoded binary (legacy tRPC used raw bytes).
5858+
*/
5859+
groupEncryptedContentKeyring: string;
5860+
/**
5861+
* Format: byte
5862+
* @description Public group `access_keyring` bytes when set; otherwise null (use member blob).
5863+
*/
5864+
groupAccessKeyring: string | null;
5865+
/**
5866+
* Format: byte
5867+
* @description `group_members.encrypted_access_keyring` for this user when present.
5868+
*/
5869+
memberEncryptedAccessKeyring: string | null;
58435870
};
58445871
PageCollabUpdateItemInput: {
58455872
/** @description Monotonic index (legacy collab / `page_updates.index`, often Yjs clock). */

new-deepnotes/apps/web/src/api/openapi.json

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@
5858
"encryptedSymmetricKeyring": {
5959
"type": "string",
6060
"format": "byte"
61+
},
62+
"passwordSalt": {
63+
"type": "string",
64+
"format": "byte",
65+
"description": "Argon2 salt (base64)."
6166
}
6267
},
6368
"required": [
@@ -1183,11 +1188,46 @@
11831188
"encryptedData"
11841189
]
11851190
}
1191+
},
1192+
"groupId": {
1193+
"type": "string",
1194+
"description": "Owning group (`pages.group_id`) for access-key + content-key unwrap."
1195+
},
1196+
"pageEncryptedSymmetricKeyring": {
1197+
"type": "string",
1198+
"minLength": 1,
1199+
"format": "byte",
1200+
"description": "Standard base64-encoded binary (legacy tRPC used raw bytes)."
1201+
},
1202+
"groupEncryptedContentKeyring": {
1203+
"type": "string",
1204+
"minLength": 1,
1205+
"format": "byte",
1206+
"description": "Standard base64-encoded binary (legacy tRPC used raw bytes)."
1207+
},
1208+
"groupAccessKeyring": {
1209+
"type": "string",
1210+
"nullable": true,
1211+
"minLength": 1,
1212+
"format": "byte",
1213+
"description": "Public group `access_keyring` bytes when set; otherwise null (use member blob)."
1214+
},
1215+
"memberEncryptedAccessKeyring": {
1216+
"type": "string",
1217+
"nullable": true,
1218+
"minLength": 1,
1219+
"format": "byte",
1220+
"description": "`group_members.encrypted_access_keyring` for this user when present."
11861221
}
11871222
},
11881223
"required": [
11891224
"lastIndex",
1190-
"updates"
1225+
"updates",
1226+
"groupId",
1227+
"pageEncryptedSymmetricKeyring",
1228+
"groupEncryptedContentKeyring",
1229+
"groupAccessKeyring",
1230+
"memberEncryptedAccessKeyring"
11911231
]
11921232
},
11931233
"PageCollabUpdateItemInput": {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/** Session-scoped crypto material for E2EE (mirrors legacy `sessionStorage` key names). */
2+
3+
const P = "dn.";
4+
5+
export const CRYPTO_KEYS = {
6+
sessionKey: `${P}sessionKey`,
7+
encryptedPrivateKeyring: `${P}encryptedPrivateKeyring`,
8+
encryptedSymmetricKeyring: `${P}encryptedSymmetricKeyring`,
9+
sessionId: `${P}sessionId`,
10+
userId: `${P}userId`,
11+
publicKeyring: `${P}publicKeyring`,
12+
personalGroupId: `${P}personalGroupId`,
13+
} as const;
14+
15+
export type StoredSessionCrypto = {
16+
sessionKeyB64: string;
17+
encryptedPrivateKeyringB64: string;
18+
encryptedSymmetricKeyringB64: string;
19+
sessionId: string;
20+
userId: string;
21+
publicKeyringB64: string;
22+
personalGroupId: string;
23+
};
24+
25+
export function writeSessionCrypto(data: StoredSessionCrypto): void {
26+
sessionStorage.setItem(CRYPTO_KEYS.sessionKey, data.sessionKeyB64);
27+
sessionStorage.setItem(
28+
CRYPTO_KEYS.encryptedPrivateKeyring,
29+
data.encryptedPrivateKeyringB64,
30+
);
31+
sessionStorage.setItem(
32+
CRYPTO_KEYS.encryptedSymmetricKeyring,
33+
data.encryptedSymmetricKeyringB64,
34+
);
35+
sessionStorage.setItem(CRYPTO_KEYS.sessionId, data.sessionId);
36+
sessionStorage.setItem(CRYPTO_KEYS.userId, data.userId);
37+
sessionStorage.setItem(CRYPTO_KEYS.publicKeyring, data.publicKeyringB64);
38+
sessionStorage.setItem(
39+
CRYPTO_KEYS.personalGroupId,
40+
data.personalGroupId,
41+
);
42+
}
43+
44+
export function readSessionCrypto(): StoredSessionCrypto | null {
45+
const sessionKeyB64 = sessionStorage.getItem(CRYPTO_KEYS.sessionKey);
46+
const encryptedPrivateKeyringB64 = sessionStorage.getItem(
47+
CRYPTO_KEYS.encryptedPrivateKeyring,
48+
);
49+
const encryptedSymmetricKeyringB64 = sessionStorage.getItem(
50+
CRYPTO_KEYS.encryptedSymmetricKeyring,
51+
);
52+
const sessionId = sessionStorage.getItem(CRYPTO_KEYS.sessionId);
53+
const userId = sessionStorage.getItem(CRYPTO_KEYS.userId);
54+
const publicKeyringB64 = sessionStorage.getItem(CRYPTO_KEYS.publicKeyring);
55+
const personalGroupId = sessionStorage.getItem(CRYPTO_KEYS.personalGroupId);
56+
if (
57+
sessionKeyB64 == null ||
58+
encryptedPrivateKeyringB64 == null ||
59+
encryptedSymmetricKeyringB64 == null ||
60+
sessionId == null ||
61+
userId == null ||
62+
publicKeyringB64 == null ||
63+
personalGroupId == null
64+
) {
65+
return null;
66+
}
67+
return {
68+
sessionKeyB64,
69+
encryptedPrivateKeyringB64,
70+
encryptedSymmetricKeyringB64,
71+
sessionId,
72+
userId,
73+
publicKeyringB64,
74+
personalGroupId,
75+
};
76+
}
77+
78+
export function clearSessionCrypto(): void {
79+
for (const k of Object.values(CRYPTO_KEYS)) {
80+
sessionStorage.removeItem(k);
81+
}
82+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import {
2+
base64ToBytes,
3+
createPrivateKeyring,
4+
createSymmetricKeyring,
5+
derivePasswordValues,
6+
ensureSodiumReady,
7+
wrapSymmetricKey,
8+
} from "@deepnotes/e2ee";
9+
10+
import { loginPreimageFromPassword, uint8ToBase64 } from "./bytes";
11+
import {
12+
clearSessionCrypto,
13+
readSessionCrypto,
14+
writeSessionCrypto,
15+
type StoredSessionCrypto,
16+
} from "./crypto-storage";
17+
18+
export type LoginSuccessPayload = {
19+
userId: string;
20+
sessionId: string;
21+
sessionKey: string;
22+
personalGroupId: string;
23+
publicKeyring: string;
24+
encryptedPrivateKeyring: string;
25+
encryptedSymmetricKeyring: string;
26+
passwordSalt?: string;
27+
};
28+
29+
/**
30+
* After password login: unwrap `User*Keyring` with the password-derived key,
31+
* re-wrap with `SessionUser*Keyring`, and persist (plus raw session key for unwrap).
32+
*/
33+
export async function persistSessionKeyringsFromLogin(input: {
34+
login: LoginSuccessPayload;
35+
password: string;
36+
}): Promise<void> {
37+
const { login, password } = input;
38+
if (login.passwordSalt == null || login.passwordSalt === "") {
39+
return;
40+
}
41+
await ensureSodiumReady();
42+
const preimage = loginPreimageFromPassword(password);
43+
const masterKey = derivePasswordValues({
44+
password: preimage,
45+
salt: base64ToBytes(login.passwordSalt),
46+
}).key;
47+
const sessionKey = wrapSymmetricKey(base64ToBytes(login.sessionKey));
48+
49+
const encPrivB64 = uint8ToBase64(
50+
createPrivateKeyring(base64ToBytes(login.encryptedPrivateKeyring))
51+
.unwrapSymmetric(masterKey, {
52+
associatedData: {
53+
context: "UserPrivateKeyring",
54+
userId: login.userId,
55+
},
56+
})
57+
.wrapSymmetric(sessionKey, {
58+
associatedData: {
59+
context: "SessionUserPrivateKeyring",
60+
userId: login.userId,
61+
},
62+
}).wrappedValue,
63+
);
64+
65+
const encSymB64 = uint8ToBase64(
66+
createSymmetricKeyring(base64ToBytes(login.encryptedSymmetricKeyring))
67+
.unwrapSymmetric(masterKey, {
68+
associatedData: {
69+
context: "UserSymmetricKeyring",
70+
userId: login.userId,
71+
},
72+
})
73+
.wrapSymmetric(sessionKey, {
74+
associatedData: {
75+
context: "SessionUserSymmetricKeyring",
76+
userId: login.userId,
77+
},
78+
}).wrappedValue,
79+
);
80+
81+
writeSessionCrypto({
82+
sessionKeyB64: login.sessionKey,
83+
encryptedPrivateKeyringB64: encPrivB64,
84+
encryptedSymmetricKeyringB64: encSymB64,
85+
sessionId: login.sessionId,
86+
userId: login.userId,
87+
publicKeyringB64: login.publicKeyring,
88+
personalGroupId: login.personalGroupId,
89+
});
90+
}
91+
92+
/** Rotate session-wrapped keyrings after `POST /api/sessions/refresh` (cookie rotation). */
93+
export async function applyRefreshToStoredKeyrings(input: {
94+
oldSessionKey: string;
95+
newSessionKey: string;
96+
}): Promise<void> {
97+
const stored = readSessionCrypto();
98+
if (stored == null) {
99+
return;
100+
}
101+
await ensureSodiumReady();
102+
const oldSk = wrapSymmetricKey(base64ToBytes(input.oldSessionKey));
103+
const newSk = wrapSymmetricKey(base64ToBytes(input.newSessionKey));
104+
const { userId } = stored;
105+
106+
const privateKeyring = createPrivateKeyring(
107+
base64ToBytes(stored.encryptedPrivateKeyringB64),
108+
).unwrapSymmetric(oldSk, {
109+
associatedData: {
110+
context: "SessionUserPrivateKeyring",
111+
userId,
112+
},
113+
});
114+
115+
const symmetricKeyring = createSymmetricKeyring(
116+
base64ToBytes(stored.encryptedSymmetricKeyringB64),
117+
).unwrapSymmetric(oldSk, {
118+
associatedData: {
119+
context: "SessionUserSymmetricKeyring",
120+
userId,
121+
},
122+
});
123+
124+
const next: StoredSessionCrypto = {
125+
...stored,
126+
sessionKeyB64: input.newSessionKey,
127+
encryptedPrivateKeyringB64: uint8ToBase64(
128+
privateKeyring.wrapSymmetric(newSk, {
129+
associatedData: {
130+
context: "SessionUserPrivateKeyring",
131+
userId,
132+
},
133+
}).wrappedValue,
134+
),
135+
encryptedSymmetricKeyringB64: uint8ToBase64(
136+
symmetricKeyring.wrapSymmetric(newSk, {
137+
associatedData: {
138+
context: "SessionUserSymmetricKeyring",
139+
userId,
140+
},
141+
}).wrappedValue,
142+
),
143+
};
144+
writeSessionCrypto(next);
145+
}
146+
147+
export { clearSessionCrypto, readSessionCrypto };
148+
export type { StoredSessionCrypto };

0 commit comments

Comments
 (0)