diff --git a/package.json b/package.json index faebf6d9918d88c76836b7903b0324e148cd68ab..37c5d1badcb1f933558b3a7d035402525bc72da3 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "type": "module", "packageManager": "pnpm@9.7.0+sha512.dc09430156b427f5ecfc79888899e1c39d2d690f004be70e05230b72cb173d96839587545d09429b55ac3c429c801b4dc3c0e002f653830a420fa2dd4e3cf9cf", "dependencies": { - "cryptr": "^6.3.0", "formsnap": "^1.0.1", + "msgpackr": "^1.11.0", "sveltekit-superforms": "^2.17.0", "zod": "^3.23.8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf87dd6be214b5c697d0be425d664369390e9308..5e7542c01cb3c332322bf58d95a5b8cbfa068e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,12 @@ importers: .: dependencies: - cryptr: - specifier: ^6.3.0 - version: 6.3.0 formsnap: specifier: ^1.0.1 version: 1.0.1(svelte@4.2.18)(sveltekit-superforms@2.17.0(@sveltejs/kit@2.5.24(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.4.2(@types/node@22.5.0)))(svelte@4.2.18)(vite@5.4.2(@types/node@22.5.0)))(svelte@4.2.18)) + msgpackr: + specifier: ^1.11.0 + version: 1.11.0 sveltekit-superforms: specifier: ^2.17.0 version: 2.17.0(@sveltejs/kit@2.5.24(@sveltejs/vite-plugin-svelte@3.1.1(svelte@4.2.18)(vite@5.4.2(@types/node@22.5.0)))(svelte@4.2.18)(vite@5.4.2(@types/node@22.5.0)))(svelte@4.2.18) @@ -541,6 +541,36 @@ packages: resolution: {integrity: sha512-uDBFBvCYUT4UaZZKv7gJejQvbrOp4YyI1S0Z92DPiMbyLq0DPDXz3Lt2ZqUZKlQrinBX+W1TO6w0RudEX6Q6WA==} engines: {node: ^16.14 || >=18} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -994,9 +1024,6 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} - cryptr@6.3.0: - resolution: {integrity: sha512-TA4byAuorT8qooU9H8YJhBwnqD151i1rcauHfJ3Divg6HmukHB2AYMp0hmjv2873J2alr4t15QqC7zAnWFrtfQ==} - css-tree@2.3.1: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1037,6 +1064,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + devalue@4.3.3: resolution: {integrity: sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==} @@ -1545,6 +1576,13 @@ packages: ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.0: + resolution: {integrity: sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1569,6 +1607,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -2644,6 +2686,24 @@ snapshots: dependencies: esm-env: 1.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3130,8 +3190,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cryptr@6.3.0: {} - css-tree@2.3.1: dependencies: mdn-data: 2.0.30 @@ -3156,6 +3214,9 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.0.3: + optional: true + devalue@4.3.3: {} devalue@5.0.0: {} @@ -3756,6 +3817,22 @@ snapshots: ms@2.1.2: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.0: + optionalDependencies: + msgpackr-extract: 3.0.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -3776,6 +3853,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.3 + optional: true + node-releases@2.0.18: {} normalize-path@3.0.0: {} diff --git a/src/lib/cookie.ts b/src/lib/cookie.ts new file mode 100644 index 0000000000000000000000000000000000000000..c26031cfcd620ea034d251333a813860f5c79db3 --- /dev/null +++ b/src/lib/cookie.ts @@ -0,0 +1,31 @@ +import type { Cookies } from '@sveltejs/kit'; +import Cryptr from './crypto'; +import env from '$lib/env'; +import type { Schema } from 'zod'; +import { encode, decode } from 'msgpackr'; + +const cryptr = new Cryptr(env.COOKIE_SECRET); + +export function secureCookie<T>(cookies: Cookies, schema: Schema<T>, name: string) { + function read(): T | null { + const cookie = cookies.get(name); + if (!cookie) return null; + + try { + const decryptedBuffer = cryptr.decrypt(cookie); + const json = decode(decryptedBuffer); + const data = schema.parse(json); + return data; + } catch { + return null; + } + } + + function write(data: T) { + const compactJson = encode(data); + const encryptedBuffer = cryptr.encrypt(compactJson); + cookies.set(name, encryptedBuffer, { path: '/' }); + } + + return { read, write }; +} diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000000000000000000000000000000000000..537200c2df1b0723df22a99326ecaa047493dab1 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,50 @@ +import crypto from 'crypto'; + +// Inspiré de https://github.com/MauriceButler/cryptr/blob/master/index.js +export default class Cryptr { + algorithm = 'aes-256-gcm' as const; + encoding = 'base64' as const; + pbkdf2Iterations = 100000; + saltLength = 64; + ivLength = 16; + tagLength = 16; + tagPosition = this.saltLength + this.ivLength; + encryptedPosition = this.tagPosition + this.tagLength; + + constructor(public secret: string) {} + + getKey(salt: crypto.BinaryLike) { + return crypto.pbkdf2Sync(this.secret, salt, this.pbkdf2Iterations, 32, 'sha512'); + } + + encrypt(value: crypto.BinaryLike) { + const iv = crypto.randomBytes(this.ivLength); + const salt = crypto.randomBytes(this.saltLength); + + const key = this.getKey(salt); + + const cipher = crypto.createCipheriv(this.algorithm, key, iv); + const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); + + const tag = cipher.getAuthTag(); + + return Buffer.concat([salt, iv, tag, encrypted]).toString(this.encoding); + } + + decrypt(value: string) { + const stringValue = Buffer.from(String(value), this.encoding); + + const salt = stringValue.subarray(0, this.saltLength); + const iv = stringValue.subarray(this.saltLength, this.tagPosition); + const tag = stringValue.subarray(this.tagPosition, this.encryptedPosition); + const encrypted = stringValue.subarray(this.encryptedPosition); + + const key = this.getKey(salt); + + const decipher = crypto.createDecipheriv(this.algorithm, key, iv); + + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(encrypted), decipher.final()]); + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ae984638a80a387df668c6f4e0b2fb4ceff194c6..53723ea231b71ec29d86cea8fb4cf6d4113f5d62 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -17,4 +17,4 @@ function ensureEnv<K extends readonly string[]>(keys: K): RecordFromKeys<K> { return cleanEnv; } -export default ensureEnv(['API_ORIGIN', 'API_TOKEN'] as const); +export default ensureEnv(['API_ORIGIN', 'API_TOKEN', 'COOKIE_SECRET'] as const); diff --git a/src/lib/game.ts b/src/lib/game.ts new file mode 100644 index 0000000000000000000000000000000000000000..f93d0bb0943c56b2a136a6ef180eacbd618bd420 --- /dev/null +++ b/src/lib/game.ts @@ -0,0 +1,36 @@ +import type { Cookies } from '@sveltejs/kit'; +import { secureCookie } from './cookie'; +import { z } from 'zod'; + +export const gameStateSchema = z.object({ + history: z.array(z.string()), + step: z.number(), + points: z.number(), + options: z.array(z.string()), + state: z.enum(['playing', 'solution', 'next']), + solution: z.string() +}); + +export type GameState = z.infer<typeof gameStateSchema>; + +const defaultGameState: GameState = { + state: 'next', + history: [], + step: 0, + points: 0, + options: [], + solution: '' +}; + +export function loadGameState(cookies: Cookies) { + const cookie = secureCookie(cookies, gameStateSchema, 'qui-est-ce'); + + const gameState = cookie.read() ?? defaultGameState; + + return { + gameState, + saveGame() { + cookie.write(gameState); + } + }; +} diff --git a/src/routes/wip/+page.server.ts b/src/routes/wip/+page.server.ts index 588151c905c0636630e7513be7e03724bd3e5473..7bfea6d7d486f74a09d2f09524856a4eef2ce160 100644 --- a/src/routes/wip/+page.server.ts +++ b/src/routes/wip/+page.server.ts @@ -1,49 +1,19 @@ import { superValidate } from 'sveltekit-superforms'; import { zod } from 'sveltekit-superforms/adapters'; -import { gameStateSchema, schema } from './schema'; -import type { GameState } from './schema'; -import { fail, redirect, type Cookies } from '@sveltejs/kit'; +import { schema } from './schema'; +import { fail } from '@sveltejs/kit'; import { GetPromotionStore, UserDetailsStore } from '$houdini'; import { pageIterator } from '$lib/graphql/query'; import { getRandomItems } from '$lib/utils'; -import Cryptr from 'cryptr'; +import { loadGameState } from '$lib/game'; type Option = { value: string; label: string; }; -const cryptr = new Cryptr('myTotallySecretKey'); - -function loadGameState(cookies: Cookies): GameState | null { - const cookie = cookies.get('qui-est-ce'); - if (!cookie) return null; - - const decryptedString = cryptr.decrypt(cookie); - const gameState = gameStateSchema.safeParse(JSON.parse(decryptedString)); - - if (!gameState.success) return null; - return gameState.data; -} - -function saveGameState(gameState: GameState, cookies: Cookies) { - const encryptedString = cryptr.encrypt(JSON.stringify(gameState)); - cookies.set('qui-est-ce', encryptedString, { path: '/' }); -} - export async function load(event) { - let gameState = loadGameState(event.cookies); - - if (gameState === null) { - gameState = { - state: 'next', - history: [], - step: 0, - points: 0, - options: [], - solution: '' - }; - } + let { gameState, saveGame } = loadGameState(event.cookies); if (gameState.state === 'next') { const pagination = pageIterator(event, GetPromotionStore, { promotion: 2023 }); @@ -70,7 +40,7 @@ export async function load(event) { console.log(gameState); - saveGameState(gameState, event.cookies); + saveGame(); const photo = users.find((node) => node.id === gameState.solution)?.photo!; @@ -88,18 +58,13 @@ export const actions = { return fail(400, { form }); } - let gameState = loadGameState(event.cookies); - - if (gameState === null) { - return redirect(307, '/wip'); - } + let { gameState, saveGame } = loadGameState(event.cookies); if (form.data.choice === gameState.solution) { gameState.points += 10; } gameState.history.push(gameState.solution); - - saveGameState(gameState, event.cookies); + saveGame(); return { form, @@ -110,14 +75,10 @@ export const actions = { async next(event) { const form = await superValidate(zod(schema)); - let gameState = loadGameState(event.cookies); - - if (gameState === null) { - return redirect(307, '/wip'); - } + let { gameState, saveGame } = loadGameState(event.cookies); gameState.state = 'next'; - saveGameState(gameState, event.cookies); + saveGame(); return { form }; } diff --git a/src/routes/wip/schema.ts b/src/routes/wip/schema.ts index 8f04be14e2b62c9d9993694b67063b1c28a0eb9c..6e7360b7c2a672d7ecc169ad556f6e8973623096 100644 --- a/src/routes/wip/schema.ts +++ b/src/routes/wip/schema.ts @@ -3,14 +3,3 @@ import { z } from 'zod'; export const schema = z.object({ choice: z.string() }); - -export const gameStateSchema = z.object({ - history: z.array(z.string()), - step: z.number(), - points: z.number(), - options: z.array(z.string()), - state: z.enum(['playing', 'solution', 'next']), - solution: z.string() -}); - -export type GameState = z.infer<typeof gameStateSchema>;