Skip to content

Commit 18726fd

Browse files
committed
fix: use noble in api-worker
1 parent 3a2924a commit 18726fd

19 files changed

Lines changed: 192 additions & 128 deletions

new-deepnotes/apps/api-worker/src/routes/users.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,16 @@ app.post("/api/users", async (c) => {
5050

5151
const parsed = userRegisterRequestSchema.safeParse(bodyJson);
5252
if (!parsed.success) {
53+
const flattened = parsed.error.flatten();
54+
const formErrors = flattened.formErrors.join("; ");
55+
const fieldErrors = Object.entries(flattened.fieldErrors)
56+
.map(([field, errors]) => `${field}: ${errors.join(", ")}`)
57+
.join("; ");
58+
const message = [formErrors, fieldErrors].filter(Boolean).join("; ") || "Invalid request data";
5359
return c.json(
5460
{
5561
code: "VALIDATION_ERROR",
56-
message: parsed.error.flatten().formErrors.join("; "),
62+
message,
5763
},
5864
400,
5965
);

new-deepnotes/packages/api/src/schemas/sessions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const nanoidId = z
4040
/** Base64 JSON field decoded to `Uint8Array` (legacy tRPC used raw bytes). */
4141
export const byteB64 = z
4242
.string()
43-
.min(1)
43+
.min(0)
4444
.openapi({
4545
format: "byte",
4646
description: "Standard base64-encoded binary (legacy tRPC used raw bytes).",

new-deepnotes/packages/session-core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@
1616
},
1717
"dependencies": {
1818
"@deepnotes/db": "workspace:*",
19+
"@noble/ciphers": "^1.3.0",
20+
"@noble/curves": "^1.9.7",
21+
"@noble/hashes": "^1.8.0",
1922
"crypto-js": "^4.2.0",
2023
"drizzle-orm": "^0.41.0",
2124
"jose": "^5.10.0",
22-
"libsodium-wrappers-sumo": "^0.8.0",
2325
"msgpackr": "^1.10.2",
2426
"nanoid": "^5.1.5",
2527
"otplib": "^12.0.1"

new-deepnotes/packages/session-core/src/change-user-email.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
decryptUserRehashedLoginHash,
1515
derivePasswordValues,
1616
encryptUserRehashedLoginHash,
17-
ensureSodiumReady,
1817
} from "./crypto/session-crypto.js";
1918
import { buildClearSessionCookies, cookieOptionsFromEnv } from "./cookies.js";
2019
import { decryptUserEmail, encryptUserEmail } from "./encrypt-user-email.js";
@@ -24,6 +23,15 @@ import { SessionError } from "./errors.js";
2423
import { verifyAccessToken } from "./jwt.js";
2524
import { sendEmailChangeVerificationEmail } from "./send-email-change-code.js";
2625

26+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
27+
if (a.length !== b.length) return false;
28+
let result = 0;
29+
for (let i = 0; i < a.length; i++) {
30+
result |= a[i]! ^ b[i]!;
31+
}
32+
return result === 0;
33+
}
34+
2735
function toBuf(u: Uint8Array): Buffer {
2836
return Buffer.from(u);
2937
}
@@ -39,7 +47,6 @@ export async function performUserEmailChangeRequest(input: {
3947
oldLoginHash: Uint8Array;
4048
newEmail: string;
4149
}): Promise<{ devEmailVerificationCode?: string }> {
42-
await ensureSodiumReady();
4350

4451
if (input.accessCookie == null || input.accessCookie === "") {
4552
throw new SessionError(401, "UNAUTHORIZED", "No access token.");
@@ -158,7 +165,6 @@ export async function performUserEmailChangeConfirm(input: {
158165
email: string,
159166
) => Promise<void>;
160167
}): Promise<{ cookieLines: string[] }> {
161-
await ensureSodiumReady();
162168
const cookieOpts = cookieOptionsFromEnv(input.env);
163169

164170
if (input.accessCookie == null || input.accessCookie === "") {
@@ -214,7 +220,7 @@ export async function performUserEmailChangeConfirm(input: {
214220
password: input.oldLoginHash,
215221
salt: passwordHashValues.saltBytes,
216222
});
217-
if (!sodium.memcmp(passwordCheck.hash, passwordHashValues.hashBytes)) {
223+
if (!bytesEqual(passwordCheck.hash, passwordHashValues.hashBytes)) {
218224
throw new SessionError(400, "BAD_REQUEST", "Password is incorrect.");
219225
}
220226

new-deepnotes/packages/session-core/src/change-user-password.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { DeepnotesDb } from "@deepnotes/db/client";
22
import { eq } from "drizzle-orm";
3-
import sodium from "libsodium-wrappers-sumo";
43

54
import { sessions, users } from "@deepnotes/db/schema";
65

@@ -14,13 +13,21 @@ import {
1413
decryptUserRehashedLoginHash,
1514
derivePasswordValues,
1615
encryptUserRehashedLoginHash,
17-
ensureSodiumReady,
1816
} from "./crypto/session-crypto.js";
1917
import { buildClearSessionCookies, cookieOptionsFromEnv } from "./cookies.js";
2018
import type { SessionEnv } from "./env.js";
2119
import { SessionError } from "./errors.js";
2220
import { verifyAccessToken } from "./jwt.js";
2321

22+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
23+
if (a.length !== b.length) return false;
24+
let result = 0;
25+
for (let i = 0; i < a.length; i++) {
26+
result |= a[i]! ^ b[i]!;
27+
}
28+
return result === 0;
29+
}
30+
2431
function toBuf(u: Uint8Array): Buffer {
2532
return Buffer.from(u);
2633
}
@@ -39,7 +46,6 @@ export async function performUserPasswordChange(input: {
3946
newEncryptedPrivateKeyring: Uint8Array;
4047
newEncryptedSymmetricKeyring: Uint8Array;
4148
}): Promise<{ cookieLines: string[] }> {
42-
await ensureSodiumReady();
4349
const cookieOpts = cookieOptionsFromEnv(input.env);
4450

4551
if (input.accessCookie == null || input.accessCookie === "") {
@@ -79,7 +85,7 @@ export async function performUserPasswordChange(input: {
7985
password: input.oldLoginHash,
8086
salt: passwordHashValues.saltBytes,
8187
});
82-
if (!sodium.memcmp(passwordValues.hash, passwordHashValues.hashBytes)) {
88+
if (!bytesEqual(passwordValues.hash, passwordHashValues.hashBytes)) {
8389
throw new SessionError(400, "BAD_REQUEST", "Password is incorrect.");
8490
}
8591

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import sodium from "libsodium-wrappers-sumo";
1+
import { x25519 } from "@noble/curves/ed25519";
2+
import { randomBytes } from "@noble/hashes/utils";
3+
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
24

35
import { concatUint8Arrays } from "./bytes.js";
46

57
import type { PublicKey } from "./public-key.js";
68

9+
const NONCE_LENGTH = 24;
10+
const PUBLIC_KEY_LENGTH = 32;
11+
712
export function wrapPrivateKey(value: Uint8Array) {
813
return new (class PrivateKey {
914
get value() {
@@ -17,41 +22,35 @@ export function wrapPrivateKey(value: Uint8Array) {
1722
params?: { padding?: boolean },
1823
): Uint8Array {
1924
if (params?.padding) {
20-
plaintext = sodium.pad(plaintext, 8);
25+
plaintext = pad(plaintext, 8);
2126
}
2227

23-
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
28+
const nonce = randomBytes(NONCE_LENGTH);
29+
30+
// X25519 key exchange
31+
const sharedSecret = x25519.getSharedSecret(value, recipientsPublicKey.value);
2432

25-
const ciphertext = sodium.crypto_box_easy(
26-
plaintext,
27-
nonce,
28-
recipientsPublicKey.value,
29-
value,
30-
);
33+
// Use XChaCha20-Poly1305 with the shared secret
34+
const cipher = xchacha20poly1305(sharedSecret, nonce);
35+
const ciphertext = cipher.encrypt(plaintext);
3136

3237
return concatUint8Arrays(sendersPublicKey.value, nonce, ciphertext);
3338
}
3439

3540
decrypt(message: Uint8Array, params?: { padding?: boolean }): Uint8Array {
36-
const sendersPublicKey = message.slice(
37-
0,
38-
sodium.crypto_box_PUBLICKEYBYTES,
39-
);
40-
const nonce = message.slice(
41-
sendersPublicKey.length,
42-
sendersPublicKey.length + sodium.crypto_box_NONCEBYTES,
43-
);
44-
const ciphertext = message.slice(sendersPublicKey.length + nonce.length);
45-
46-
let plaintext = sodium.crypto_box_open_easy(
47-
ciphertext,
48-
nonce,
49-
sendersPublicKey,
50-
value,
51-
);
41+
const sendersPublicKey = message.slice(0, PUBLIC_KEY_LENGTH);
42+
const nonce = message.slice(PUBLIC_KEY_LENGTH, PUBLIC_KEY_LENGTH + NONCE_LENGTH);
43+
const ciphertext = message.slice(PUBLIC_KEY_LENGTH + nonce.length);
44+
45+
// X25519 key exchange
46+
const sharedSecret = x25519.getSharedSecret(value, sendersPublicKey);
47+
48+
// Use XChaCha20-Poly1305 with the shared secret
49+
const cipher = xchacha20poly1305(sharedSecret, nonce);
50+
let plaintext = cipher.decrypt(ciphertext);
5251

5352
if (params?.padding) {
54-
plaintext = sodium.unpad(plaintext, 8);
53+
plaintext = unpad(plaintext, 8);
5554
}
5655

5756
return plaintext;
@@ -60,3 +59,24 @@ export function wrapPrivateKey(value: Uint8Array) {
6059
}
6160

6261
export type PrivateKey = ReturnType<typeof wrapPrivateKey>;
62+
63+
function pad(data: Uint8Array, blockSize: number): Uint8Array {
64+
const padLength = blockSize - (data.length % blockSize);
65+
const padded = new Uint8Array(data.length + padLength);
66+
padded.set(data);
67+
for (let i = data.length; i < padded.length; i++) {
68+
padded[i] = padLength;
69+
}
70+
return padded;
71+
}
72+
73+
function unpad(data: Uint8Array, blockSize: number): Uint8Array {
74+
if (data.length === 0) {
75+
throw new Error("Cannot unpad empty data");
76+
}
77+
const padLength = data[data.length - 1]!;
78+
if (padLength > blockSize || padLength > data.length) {
79+
throw new Error("Invalid padding");
80+
}
81+
return data.slice(0, data.length - padLength);
82+
}

new-deepnotes/packages/session-core/src/crypto/session-crypto.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* so stored Postgres blobs remain compatible.
55
*/
66
import CryptoJS from "crypto-js";
7-
import sodium from "libsodium-wrappers-sumo";
7+
import { argon2id } from "@noble/hashes/argon2";
8+
import { randomBytes, bytesToHex } from "@noble/hashes/utils";
89
import { pack, unpack } from "msgpackr";
910

1011
import {
@@ -16,24 +17,31 @@ import {
1617
import { cryptoJsWordArrayToUint8Array } from "./crypto-js-wordarray.js";
1718
import { wrapSymmetricKey } from "./symmetric-key.js";
1819

20+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
21+
if (a.length !== b.length) return false;
22+
let result = 0;
23+
for (let i = 0; i < a.length; i++) {
24+
result |= a[i]! ^ b[i]!;
25+
}
26+
return result === 0;
27+
}
28+
1929
export async function ensureSodiumReady(): Promise<void> {
20-
await sodium.ready;
30+
// No-op: noble libraries don't require initialization
2131
}
2232

2333
export function derivePasswordValues(input: {
2434
password: Uint8Array;
2535
salt?: Uint8Array;
2636
}) {
27-
input.salt ??= sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
28-
29-
const derivedKey = sodium.crypto_pwhash(
30-
32 + 64,
31-
input.password,
32-
input.salt,
33-
2,
34-
32 * 1048576,
35-
sodium.crypto_pwhash_ALG_ARGON2ID13,
36-
);
37+
input.salt ??= randomBytes(16);
38+
39+
const derivedKey = argon2id(input.password, input.salt, {
40+
t: 2,
41+
m: 32768,
42+
p: 1,
43+
dkLen: 96,
44+
});
3745

3846
return {
3947
key: wrapSymmetricKey(derivedKey.slice(0, 32)),
@@ -114,12 +122,12 @@ export function hashRecoveryCode(
114122
recoveryCode: string,
115123
salt?: Uint8Array,
116124
): Uint8Array {
117-
salt ??= sodium.randombytes_buf(16);
125+
salt ??= randomBytes(16);
118126

119127
return concatUint8Arrays(
120128
salt,
121129
cryptoJsWordArrayToUint8Array(
122-
CryptoJS.SHA256(sodium.to_hex(salt) + recoveryCode),
130+
CryptoJS.SHA256(bytesToHex(salt) + recoveryCode),
123131
),
124132
);
125133
}
@@ -130,19 +138,15 @@ export function verifyRecoveryCode(
130138
): boolean {
131139
const salt = hashedRecoveryCode.slice(0, 16);
132140

133-
return sodium.memcmp(
141+
return bytesEqual(
134142
hashedRecoveryCode.slice(16),
135143
hashRecoveryCode(recoveryCode, salt).slice(16),
136144
);
137145
}
138146

139-
/** PHC string for a group password (Argon2id, libsodium). Call after `ensureSodiumReady()`. */
147+
/** PHC string for a group password (Argon2id). Group passwords not currently supported. */
140148
export function computeGroupPasswordPhc(groupPasswordPrehash: Uint8Array): string {
141-
return sodium.crypto_pwhash_str(
142-
groupPasswordPrehash,
143-
2,
144-
32 * 1024 * 1024,
145-
) as string;
149+
throw new Error("Group passwords are not currently supported");
146150
}
147151

148152
export function encryptGroupRehashedPasswordHash(

0 commit comments

Comments
 (0)