Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit 3d5d946

Browse files
committed
fix(tokens): Hash tokens in tokens module to resist ND2DB-style timing attack
1 parent 1317b2b commit 3d5d946

File tree

8 files changed

+53
-21
lines changed

8 files changed

+53
-21
lines changed

modules/tokens/db/migrations/20240307005945_init/migration.sql renamed to modules/tokens/db/migrations/20240501200641_init/migration.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
-- CreateTable
22
CREATE TABLE "Token" (
33
"id" UUID NOT NULL,
4-
"token" TEXT NOT NULL,
4+
"tokenHash" TEXT NOT NULL,
55
"type" TEXT NOT NULL,
66
"meta" JSONB NOT NULL,
77
"trace" JSONB NOT NULL,
@@ -13,4 +13,4 @@ CREATE TABLE "Token" (
1313
);
1414

1515
-- CreateIndex
16-
CREATE UNIQUE INDEX "Token_token_key" ON "Token"("token");
16+
CREATE UNIQUE INDEX "Token_tokenHash_key" ON "Token"("tokenHash");

modules/tokens/db/schema.prisma

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ datasource db {
55

66
model Token {
77
id String @id @default(uuid()) @db.Uuid
8-
token String @unique
8+
tokenHash String @unique
99
type String
1010
meta Json
1111
trace Json

modules/tokens/scripts/create.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ScriptContext } from "../_gen/scripts/create.ts";
2-
import { TokenWithSecret } from "../utils/types.ts";
3-
import { tokenFromRow } from "../utils/types.ts";
2+
import { TokenWithSecret, tokenFromRowWithSecret, hash } from "../utils/types.ts";
43

54
export interface Request {
65
type: string;
@@ -17,10 +16,9 @@ export async function run(
1716
req: Request,
1817
): Promise<Response> {
1918
const tokenStr = generateToken(req.type);
20-
2119
const token = await ctx.db.token.create({
2220
data: {
23-
token: tokenStr,
21+
tokenHash: await hash(tokenStr),
2422
type: req.type,
2523
meta: req.meta,
2624
trace: ctx.trace,
@@ -29,7 +27,7 @@ export async function run(
2927
});
3028

3129
return {
32-
token: tokenFromRow(token),
30+
token: tokenFromRowWithSecret(token, tokenStr),
3331
};
3432
}
3533

modules/tokens/scripts/extend.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { ScriptContext } from "../_gen/scripts/extend.ts";
2-
import { TokenWithSecret } from "../types/common.ts";
3-
import { tokenFromRow } from "../types/common.ts";
2+
import { Token, tokenFromRow } from "../utils/types.ts";
43

54
export interface Request {
65
token: string;
76
newExpiration: string | null;
87
}
98

109
export interface Response {
11-
token: TokenWithSecret;
10+
token: Token;
1211
}
1312

1413
export async function run(

modules/tokens/scripts/get.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ScriptContext } from "../_gen/scripts/get.ts";
2-
import { Token } from "../utils/types.ts";
3-
import { tokenFromRow } from "../utils/types.ts";
2+
import { Token, tokenFromRow } from "../utils/types.ts";
43

54
export interface Request {
65
tokenIds: string[];
@@ -23,7 +22,7 @@ export async function run(
2322
},
2423
});
2524

26-
const tokens = rows.map(tokenFromRow);
25+
const tokens = rows.map(row => tokenFromRow(row));
2726

2827
return { tokens };
2928
}

modules/tokens/scripts/get_by_token.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ScriptContext } from "../_gen/scripts/get_by_token.ts";
2-
import { Token, tokenFromRow } from "../utils/types.ts";
2+
import { Token, tokenFromRowWithSecret, hash } from "../utils/types.ts";
33

44
export interface Request {
55
tokens: string[];
@@ -13,18 +13,22 @@ export async function run(
1313
ctx: ScriptContext,
1414
req: Request,
1515
): Promise<Response> {
16+
const hashed = await Promise.all(req.tokens.map(hash));
17+
console.log(hashed);
1618
const rows = await ctx.db.token.findMany({
1719
where: {
18-
token: {
19-
in: req.tokens,
20+
tokenHash: {
21+
in: hashed,
2022
},
2123
},
2224
orderBy: {
2325
createdAt: "desc",
2426
},
2527
});
2628

27-
const tokens = rows.map(tokenFromRow);
29+
// Map from the hashed secrets to the original secrets
30+
const hashMap = Object.fromEntries(req.tokens.map((token, i) => [hashed[i], token]));
31+
const tokens = rows.map(row => tokenFromRowWithSecret(row, hashMap[row.tokenHash]));
2832

2933
return { tokens };
3034
}

modules/tokens/tests/validate.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ test(
2323
meta: { foo: "bar" },
2424
});
2525

26-
await ctx.modules.tokens.revoke({ tokenIds: [token.id] });
26+
const { updates } = await ctx.modules.tokens.revoke({ tokenIds: [token.id] });
27+
assertEquals(updates[token.id], "REVOKED");
2728

2829
const error = await assertRejects(async () => {
2930
await ctx.modules.tokens.validate({ token: token.token });

modules/tokens/utils/types.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,44 @@ export interface TokenWithSecret extends Token {
1313
token: string;
1414
}
1515

16-
export function tokenFromRow(
16+
17+
export function withoutKeys<T extends object, K extends keyof T>(
18+
obj: T,
19+
keys: K[]
20+
): Omit<T, K> {
21+
const copy = { ...obj };
22+
for (const key of keys) {
23+
delete copy[key];
24+
}
25+
return copy;
26+
}
27+
28+
export function tokenFromRowWithSecret(
1729
row: prisma.Prisma.TokenGetPayload<any>,
30+
origToken: string
1831
): TokenWithSecret {
1932
return {
20-
...row,
33+
...tokenFromRow(row),
34+
token: origToken,
35+
}
36+
}
37+
38+
export function tokenFromRow(
39+
row: prisma.Prisma.TokenGetPayload<any>,
40+
): Token {
41+
return {
42+
...withoutKeys(row, ["tokenHash"]),
2143
createdAt: row.createdAt.toISOString(),
2244
expireAt: row.expireAt?.toISOString() ?? null,
2345
revokedAt: row.revokedAt?.toISOString() ?? null,
2446
};
2547
}
48+
49+
export async function hash(token: string): Promise<string> {
50+
const encoder = new TextEncoder();
51+
const data = encoder.encode(token);
52+
const hash = await crypto.subtle.digest("SHA-256", data);
53+
const digest = Array.from(new Uint8Array(hash));
54+
const strDigest = digest.map(b => b.toString(16).padStart(2, "0")).join("");
55+
return strDigest;
56+
}

0 commit comments

Comments
 (0)