diff --git a/README.md b/README.md index c999d72..c1faf8c 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Users use OPAQUE to authenticate with the server. After Login the server creates - generate keys and store them locally - store keys on lockbox - add invitation scheme +- add retry for locker in case write fails (invalid clock) - store data locally (api in secsync) - fix websocket session auth in secsync diff --git a/apps/app/package.json b/apps/app/package.json index ce7b18b..ef21632 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -9,7 +9,7 @@ "lint": "echo \"Lint not setup\" # expo lint" }, "jest": { - "preset": "jest-expo" + "preset": "jest-expo/web" }, "dependencies": { "@expo/vector-icons": "^14.0.0", @@ -18,6 +18,7 @@ "@trpc/client": "^11.0.0-rc.382", "@trpc/react-query": "^11.0.0-rc.382", "babel-preset-expo": "~11.0.0", + "canonicalize": "^2.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "expo": "~51.0.9", diff --git a/apps/app/src/app/index.tsx b/apps/app/src/app/index.tsx index e686f8b..433182e 100644 --- a/apps/app/src/app/index.tsx +++ b/apps/app/src/app/index.tsx @@ -1,10 +1,12 @@ import { Link } from "expo-router"; import * as React from "react"; import { View } from "react-native"; +import { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; import { Text } from "~/components/ui/text"; import { CreateListForm } from "../components/createListForm"; import { Logout } from "../components/logout"; +import { useLocker } from "../hooks/useLocker"; import { trpc } from "../utils/trpc"; const Lists: React.FC = () => { @@ -23,6 +25,16 @@ const Lists: React.FC = () => { refetchInterval: 5000, }); + // const lockerKey = sodium.from_base64( + // "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98" + // ); + + const { content, addDocumentKey } = useLocker( + "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98" + ); + + console.log("lockerContent", content); + return ( @@ -38,6 +50,16 @@ const Lists: React.FC = () => { + +
{documentsQuery.data?.map((doc) => ( diff --git a/apps/app/src/hooks/useLocker.ts b/apps/app/src/hooks/useLocker.ts new file mode 100644 index 0000000..db689a9 --- /dev/null +++ b/apps/app/src/hooks/useLocker.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import sodium from "react-native-libsodium"; +import { decryptLocker } from "../utils/decryptLocker"; +import { encryptLocker } from "../utils/encryptLocker"; +import { trpc } from "../utils/trpc"; + +export const useLocker = (key: string) => { + const createUserLockerMutation = trpc.createUserLocker.useMutation(); + const latestUserLockerQuery = trpc.getLatestUserLocker.useQuery(); + + const keyUint8Array = sodium.from_base64(key); + + const content = useMemo(() => { + if (!latestUserLockerQuery.data) { + return null; + } + + const contentString = decryptLocker( + latestUserLockerQuery.data, + keyUint8Array + ); + return JSON.parse(contentString) || {}; // TODO validate schema + }, [ + latestUserLockerQuery.data?.ciphertext, + latestUserLockerQuery.data?.nonce, + latestUserLockerQuery.data?.commitment, + latestUserLockerQuery.data?.clock, + ]); + + // RETRY in case it fails + const addDocumentKey = async (documentId: string, value: string) => { + const newContent = { + ...content, + [documentId]: value, + }; + const newContentString = JSON.stringify(newContent); + const newClock = + latestUserLockerQuery.data?.clock !== undefined + ? latestUserLockerQuery.data.clock + 1 + : 0; + + const { ciphertext, nonce, commitment } = encryptLocker( + newContentString, + newClock, + keyUint8Array + ); + + await createUserLockerMutation.mutateAsync({ + ciphertext, + nonce, + commitment, + clock: newClock, + }); + await latestUserLockerQuery.refetch(); + }; + + return { + content, + addDocumentKey, + }; +}; diff --git a/apps/app/src/utils/decryptLocker.test.ts b/apps/app/src/utils/decryptLocker.test.ts new file mode 100644 index 0000000..1ed1032 --- /dev/null +++ b/apps/app/src/utils/decryptLocker.test.ts @@ -0,0 +1,18 @@ +import { decryptLocker } from "./decryptLocker"; +import { encryptLocker } from "./encryptLocker"; + +import * as sodium from "react-native-libsodium"; + +beforeAll(async () => { + await sodium.ready; +}); + +// test encrypting and decrypting a locker +test("encryptLocker and decryptLocker", () => { + const key = new Uint8Array(32); + const content = "hello world"; + const clock = 1234; + const encryptedLocker = encryptLocker(content, clock, key); + const decryptedContent = decryptLocker(encryptedLocker, key); + expect(decryptedContent).toEqual(content); +}); diff --git a/apps/app/src/utils/decryptLocker.ts b/apps/app/src/utils/decryptLocker.ts new file mode 100644 index 0000000..ca305fc --- /dev/null +++ b/apps/app/src/utils/decryptLocker.ts @@ -0,0 +1,51 @@ +import canonicalize from "canonicalize"; +import * as sodium from "react-native-libsodium"; + +type EncryptedLocker = { + ciphertext: string; + nonce: string; + commitment: string; + clock: number; +}; + +export const decryptLocker = ( + encryptedLocker: EncryptedLocker, + key: Uint8Array +) => { + const { ciphertext, nonce, commitment, clock } = encryptedLocker; + + const additionalData = canonicalize({ clock }); + if (!additionalData) { + throw new Error("Failed to canonicalize additional data"); + } + const commitmentContent = canonicalize({ + nonce, + ciphertext, + additionalData, + }); + if (!commitmentContent) { + throw new Error("Failed to canonicalize commitment data"); + } + + const isValidCommitment = sodium.crypto_auth_verify( + sodium.from_base64(commitment), + commitmentContent, + key + ); + if (!isValidCommitment) { + throw new Error("Invalid commitment"); + } + + const decryptedContent = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + null, + sodium.from_base64(ciphertext), + additionalData, + sodium.from_base64(nonce), + key + ); + if (!decryptedContent) { + throw new Error("Failed to decrypt locker"); + } + + return sodium.to_string(decryptedContent); +}; diff --git a/apps/app/src/utils/encryptLocker.ts b/apps/app/src/utils/encryptLocker.ts new file mode 100644 index 0000000..10ceb0d --- /dev/null +++ b/apps/app/src/utils/encryptLocker.ts @@ -0,0 +1,43 @@ +import canonicalize from "canonicalize"; +import * as sodium from "react-native-libsodium"; + +export const encryptLocker = ( + content: string, + clock: number, + key: Uint8Array +) => { + const nonce = sodium.randombytes_buf( + sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES + ); + const nonceString = sodium.to_base64(nonce); + const additionalData = canonicalize({ clock }); + if (!additionalData) { + throw new Error("Failed to canonicalize additional data"); + } + const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + content, + additionalData, + null, + nonce, + key + ); + const ciphertextString = sodium.to_base64(ciphertext); + + const commitmentContent = canonicalize({ + nonce: nonceString, + ciphertext: ciphertextString, + additionalData, + }); + if (!commitmentContent) { + throw new Error("Failed to canonicalize commitment data"); + } + + const commitment = sodium.crypto_auth(commitmentContent, key); + + return { + nonce: nonceString, + ciphertext: ciphertextString, + commitment: sodium.to_base64(commitment), + clock, + }; +}; diff --git a/apps/server/prisma/migrations/20240602072616_add_locker/migration.sql b/apps/server/prisma/migrations/20240602072616_add_locker/migration.sql new file mode 100644 index 0000000..78be350 --- /dev/null +++ b/apps/server/prisma/migrations/20240602072616_add_locker/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - You are about to drop the column `lala` on the `User` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "User" DROP COLUMN "lala"; + +-- CreateTable +CREATE TABLE "UserLocker" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "clock" INTEGER NOT NULL, + "ciphertext" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "commitment" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserLocker_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserLocker_userId_clock_key" ON "UserLocker"("userId", "clock"); + +-- AddForeignKey +ALTER TABLE "UserLocker" ADD CONSTRAINT "UserLocker_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 5f7041f..7f1baea 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -18,7 +18,20 @@ model User { loginAttempt LoginAttempt? createdAt DateTime @default(now()) documents UsersOnDocuments[] - lala String? + userLocker UserLocker[] +} + +model UserLocker { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + clock Int + ciphertext String + nonce String + commitment String + createdAt DateTime @default(now()) + + @@unique([userId, clock]) } model Session { diff --git a/apps/server/src/db/createUserLocker.ts b/apps/server/src/db/createUserLocker.ts new file mode 100644 index 0000000..0187743 --- /dev/null +++ b/apps/server/src/db/createUserLocker.ts @@ -0,0 +1,53 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma.js"; + +type Params = { + userId: string; + ciphertext: string; + nonce: string; + commitment: string; + clock: number; +}; + +export const createUserLocker = async ({ + userId, + ciphertext, + clock, + commitment, + nonce, +}: Params) => { + return await prisma.$transaction( + async (prisma) => { + const userLocker = await prisma.userLocker.findFirst({ + where: { + userId, + }, + orderBy: { + clock: "desc", + }, + }); + + console.log("userLocker", userLocker, clock); + + if (userLocker === null) { + if (clock !== 0) { + throw new Error("Invalid clock, expected 0"); + } + } else if (userLocker.clock + 1 !== clock) { + throw new Error("Invalid clock"); + } + + const locker = await prisma.userLocker.create({ + data: { + userId, + ciphertext, + nonce, + commitment, + clock, + }, + }); + return { id: locker.id }; + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); +}; diff --git a/apps/server/src/db/getLatestUserLocker.ts b/apps/server/src/db/getLatestUserLocker.ts new file mode 100644 index 0000000..c922347 --- /dev/null +++ b/apps/server/src/db/getLatestUserLocker.ts @@ -0,0 +1,13 @@ +import { prisma } from "./prisma.js"; + +export const getLatestUserLocker = async (userId: string) => { + const locker = await prisma.userLocker.findFirst({ + where: { + userId, + }, + orderBy: { + clock: "desc", + }, + }); + return locker; +}; diff --git a/apps/server/src/trpc/appRouter.ts b/apps/server/src/trpc/appRouter.ts index c47e9eb..1d050fd 100644 --- a/apps/server/src/trpc/appRouter.ts +++ b/apps/server/src/trpc/appRouter.ts @@ -8,12 +8,14 @@ import { createLoginAttempt } from "../db/createLoginAttempt.js"; import { createOrRefreshDocumentInvitation } from "../db/createOrRefreshDocumentInvitation.js"; import { createSession } from "../db/createSession.js"; import { createUser } from "../db/createUser.js"; +import { createUserLocker } from "../db/createUserLocker.js"; import { deleteLoginAttempt } from "../db/deleteLoginAttempt.js"; import { deleteSession } from "../db/deleteSession.js"; import { getDocument } from "../db/getDocument.js"; import { getDocumentInvitation } from "../db/getDocumentInvitation.js"; import { getDocumentMembers } from "../db/getDocumentMembers.js"; import { getDocumentsByUserId } from "../db/getDocumentsByUserId.js"; +import { getLatestUserLocker } from "../db/getLatestUserLocker.js"; import { getLoginAttempt } from "../db/getLoginAttempt.js"; import { getUser } from "../db/getUser.js"; import { getUserByUsername } from "../db/getUserByUsername.js"; @@ -137,6 +139,33 @@ export const appRouter = router({ opts.ctx.clearCookie(); }), + getLatestUserLocker: protectedProcedure.query(async (opts) => { + const locker = await getLatestUserLocker(opts.ctx.session.userId); + return locker; + }), + + createUserLocker: protectedProcedure + .input( + z.object({ + ciphertext: z.string(), + commitment: z.string(), + nonce: z.string(), + clock: z.number(), + }) + ) + .mutation(async (opts) => { + const { ciphertext, commitment, nonce, clock } = opts.input; + + const locker = await createUserLocker({ + userId: opts.ctx.session.userId, + ciphertext, + commitment, + nonce, + clock, + }); + return { id: locker.id }; + }), + registerStart: publicProcedure .input(RegisterStartParams) .mutation(async (opts) => {