From 4b8864bb5740e4e579a5219d08add9d4039eab40 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Sat, 14 Feb 2026 02:13:50 +1300 Subject: [PATCH] Introduced master key system --- materialious/src/lib/backend.ts | 86 +++++++++++++++---- materialious/src/lib/server/database.ts | 28 ++++-- materialious/src/lib/server/user.ts | 58 ++++++++++--- .../src/routes/api/user/create/+server.ts | 12 ++- .../src/routes/api/user/login/+server.ts | 7 +- 5 files changed, 153 insertions(+), 38 deletions(-) diff --git a/materialious/src/lib/backend.ts b/materialious/src/lib/backend.ts index bda8e0f0..44a236dc 100644 --- a/materialious/src/lib/backend.ts +++ b/materialious/src/lib/backend.ts @@ -1,11 +1,43 @@ import sodium from 'libsodium-wrappers-sumo'; import { rawSubscriptionKeyStore } from './store'; +import { get } from 'svelte/store'; + +export async function postSubscribeBackend(authorId: string) { + const rawSubscriptionKey = get(rawSubscriptionKeyStore); + + if (!rawSubscriptionKey) return; + + await sodium.ready; + + const rawKey = sodium.from_base64(rawSubscriptionKeyStore); + + const internalAuthorId = sodium.crypto_generichash( + sodium.crypto_generichash_BYTES, + authorId, + rawKey + ); + + const textEncoder = new TextEncoder(); + + const channelIdNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const channelIdCipher = sodium.crypto_secretbox_easy( + textEncoder.encode(authorId), + channelIdNonce, + rawKey + ); + + const channelNameNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const channelNameCipher = sodium.crypto_secretbox_easy( + textEncoder.encode(authorId), + channelNameNonce, + rawKey + ); +} export async function createUserBackend(username: string, rawPassword: string): Promise { await sodium.ready; const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); - const loginHash = sodium.crypto_pwhash( 32, rawPassword, @@ -15,7 +47,24 @@ export async function createUserBackend(username: string, rawPassword: string): sodium.crypto_pwhash_ALG_DEFAULT ); - const subscriptionPasswordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const decryptionKeySalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const rawDecryptionKey = sodium.crypto_pwhash( + 32, + rawPassword, + decryptionKeySalt, + sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, + sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, + sodium.crypto_pwhash_ALG_DEFAULT + ); + + const rawDecryptionMasterKey = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES); + const decryptionMasterKeyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + + const masterKeyCipher = sodium.crypto_secretbox_easy( + rawDecryptionMasterKey, + decryptionMasterKeyNonce, + rawDecryptionKey + ); const userCreateResp = await fetch('/api/user/create', { method: 'POST', @@ -25,22 +74,17 @@ export async function createUserBackend(username: string, rawPassword: string): hash: sodium.to_base64(loginHash), salt: sodium.to_base64(passwordSalt) }, - subscriptionPasswordSalt: sodium.to_base64(subscriptionPasswordSalt) + decryptionKeySalt: sodium.to_base64(decryptionKeySalt), + masterKey: { + cipher: sodium.to_base64(masterKeyCipher), + nonce: sodium.to_base64(decryptionMasterKeyNonce) + } }) }); if (!userCreateResp.ok) return false; - const subscriptionRawKey = sodium.crypto_pwhash( - 32, - rawPassword, - passwordSalt, - sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, - sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, - sodium.crypto_pwhash_ALG_DEFAULT - ); - - rawSubscriptionKeyStore.set(sodium.to_base64(subscriptionRawKey)); + rawSubscriptionKeyStore.set(sodium.to_base64(rawDecryptionMasterKey)); return true; } @@ -72,16 +116,24 @@ export async function loginUserBackend(username: string, rawPassword: string): P if (!loginResp.ok) return false; - const subscriptionRawKey = sodium.crypto_pwhash( - 32, + const loginJson = await loginResp.json(); + + const rawDecryptionKey = sodium.crypto_pwhash( + sodium.crypto_secretbox_KEYBYTES, rawPassword, - sodium.from_base64(passwordSalts.subscriptionPasswordSalt), + sodium.from_base64(passwordSalts.decryptionKeySalt), sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, sodium.crypto_pwhash_ALG_DEFAULT ); - rawSubscriptionKeyStore.set(sodium.to_base64(subscriptionRawKey)); + const rawDecryptionMasterKey = sodium.crypto_secretbox_open_easy( + sodium.from_base64(loginJson.masterKeyCipher), + sodium.from_base64(loginJson.masterKeyNonce), + rawDecryptionKey + ); + + rawSubscriptionKeyStore.set(sodium.to_base64(rawDecryptionMasterKey)); return true; } diff --git a/materialious/src/lib/server/database.ts b/materialious/src/lib/server/database.ts index 4b12218d..6d18510d 100644 --- a/materialious/src/lib/server/database.ts +++ b/materialious/src/lib/server/database.ts @@ -12,7 +12,9 @@ export interface UserTableModel extends Model { passwordHash: string; passwordSalt: string; created: Date; - subscriptionPasswordSalt: string; + decryptionKeySalt: string; + masterKeyCipher: string; + masterKeyNonce: string; } export const UserTable = sequelize.define('User', { @@ -38,27 +40,41 @@ export const UserTable = sequelize.define('User', { type: DataTypes.DATE, allowNull: false }, - subscriptionPasswordSalt: { + decryptionKeySalt: { + type: DataTypes.STRING, + allowNull: false + }, + masterKeyCipher: { + type: DataTypes.STRING, + allowNull: false + }, + masterKeyNonce: { type: DataTypes.STRING, allowNull: false } }); export interface ChannelSubscriptionModel { + id: string; // Hashed authId with subscription key on client. channelIdCipher: string; - channelIdSalt: string; + channelIdNonce: string; channelNameCipher: string; - channelNameSalt: string; + channelNameNonce: string; lastRSSFetch: Date; userId: string; } export const ChannelSubscriptionTable = sequelize.define('Subscriptions', { + id: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, channelIdCipher: { type: DataTypes.STRING, allowNull: false }, - channelIdSalt: { + channelIdNonce: { type: DataTypes.STRING, allowNull: false }, @@ -66,7 +82,7 @@ export const ChannelSubscriptionTable = sequelize.define('Subscriptions', { type: DataTypes.STRING, allowNull: false }, - channelNameSalt: { + channelNameNonce: { type: DataTypes.STRING, allowNull: false }, diff --git a/materialious/src/lib/server/user.ts b/materialious/src/lib/server/user.ts index fdbd8fee..be41f2db 100644 --- a/materialious/src/lib/server/user.ts +++ b/materialious/src/lib/server/user.ts @@ -1,30 +1,35 @@ -import { UserTable, type ChannelSubscriptionModel, type UserTableModel } from './database'; +import { + ChannelSubscriptionTable, + UserTable, + type ChannelSubscriptionModel, + type UserTableModel +} from './database'; import { Op } from 'sequelize'; import crypto from 'crypto'; import { error } from '@sveltejs/kit'; export class User { - private user: UserTableModel; + data: UserTableModel; - constructor(user: UserTableModel) { - this.user = user; + constructor(data: UserTableModel) { + this.data = data; } public get id() { - return this.user.id; + return this.data.id; } public get publicPasswordSalts() { return { - subscriptionPasswordSalt: this.user.subscriptionPasswordSalt, - passwordSalt: this.user.passwordSalt + decryptionKeySalt: this.data.decryptionKeySalt, + passwordSalt: this.data.passwordSalt }; } private get userWhere() { return { where: { - [Op.or]: [{ id: this.user.id }, { username: this.user.username }] + [Op.or]: [{ id: this.data.id }, { username: this.data.username }] } }; } @@ -33,10 +38,35 @@ export class User { await UserTable.destroy(this.userWhere); } + async addSubscription(subscription: Omit) { + await ChannelSubscriptionTable.create({ + ...subscription, + userId: this.id + }); + } + + async removeSubscription(id: string) { + await ChannelSubscriptionTable.destroy({ + where: { + id + } + }); + } + + async amSubscribed(id: string): Promise { + return ( + (await ChannelSubscriptionTable.count({ + where: { + id + } + })) > 0 + ); + } + async subscriptions(): Promise { const subscriptions = await UserTable.findAll({ where: { - userId: this.user.id + userId: this.data.id } }); @@ -52,7 +82,11 @@ export type CreateUser = { hash: string; salt: string; }; - subscriptionPasswordSalt: string; + decryptionKeySalt: string; + masterKey: { + cipher: string; + nonce: string; + }; }; export async function createUser(user: CreateUser): Promise { @@ -64,7 +98,9 @@ export async function createUser(user: CreateUser): Promise { passwordHash: user.password.hash, passwordSalt: user.password.salt, created: new Date(), - subscriptionPasswordSalt: user.subscriptionPasswordSalt + decryptionKeySalt: user.decryptionKeySalt, + masterKeyCipher: user.masterKey.cipher, + masterKeyNonce: user.masterKey.nonce }; let userCreated = false; diff --git a/materialious/src/routes/api/user/create/+server.ts b/materialious/src/routes/api/user/create/+server.ts index 93ae86bd..9436b274 100644 --- a/materialious/src/routes/api/user/create/+server.ts +++ b/materialious/src/routes/api/user/create/+server.ts @@ -9,7 +9,11 @@ const zUserCreate = z.object({ hash: z.string().max(255), salt: z.string().max(255) }), - subscriptionPasswordSalt: z.string().max(255) + decryptionKeySalt: z.string().max(255), + masterKey: z.object({ + cipher: z.string().max(255), + nonce: z.string().max(255) + }) }); export async function POST({ request }) { @@ -27,7 +31,11 @@ export async function POST({ request }) { hash: userToCreate.data.password.hash, salt: userToCreate.data.password.salt }, - subscriptionPasswordSalt: userToCreate.data.subscriptionPasswordSalt + decryptionKeySalt: userToCreate.data.decryptionKeySalt, + masterKey: { + cipher: userToCreate.data.masterKey.cipher, + nonce: userToCreate.data.masterKey.nonce + } }); return new Response(''); diff --git a/materialious/src/routes/api/user/login/+server.ts b/materialious/src/routes/api/user/login/+server.ts index c1dab167..1900c0e9 100644 --- a/materialious/src/routes/api/user/login/+server.ts +++ b/materialious/src/routes/api/user/login/+server.ts @@ -1,5 +1,5 @@ import { authenticateUser } from '$lib/server/user'; -import { error } from '@sveltejs/kit'; +import { error, json } from '@sveltejs/kit'; import z from 'zod'; import { env } from '$env/dynamic/private'; import { sign } from 'cookie-signature'; @@ -27,5 +27,8 @@ export async function POST({ request, cookies }) { maxAge: 60 * 60 * 24 * 60 // 60 days }); - return new Response(); + return json({ + masterKeyCipher: userModel.data.masterKeyCipher, + masterKeyNonce: userModel.data.masterKeyNonce + }); }