diff --git a/README.md b/README.md index 17295c0..c3a848a 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,7 @@ const seed = generateSeed(); const (privKey, pubKey) = generateKeyPair(seed); const listKeyLockbox = encrypt(pubKey, listKey); const invitation = { - listId, listKeyLockbox, - pubKey, }; const encryptedInvitation = encrypt(invitation, sessionKey); ``` @@ -115,6 +113,8 @@ const listKey = decrypt(invitation.listKeyLockbox, privKey) acceptInvitation(listId, listKey) ``` +TODO better version where the token is also never exposed to the network so not even the ciphertext can be retrieved by a network attacker + ## Todos - allow to delete list (needs a tombstone) @@ -122,6 +122,7 @@ acceptInvitation(listId, listKey) - figure out how author keys are managed (tabs in serenity and possible change in secsync) - add retry for locker in case write fails (invalid clock) - store the list name locally (also with unsynced changes) +- use expo-secure-store for the sessionKey - encrypt MMKV storage on iOS and Android - allow to sync lists in the background diff --git a/apps/app/src/app/index.tsx b/apps/app/src/app/index.tsx index aa6ecf9..60092dd 100644 --- a/apps/app/src/app/index.tsx +++ b/apps/app/src/app/index.tsx @@ -45,6 +45,9 @@ const Lists: React.FC = () => { {documentsQuery.data?.map((doc) => { + if (!locker.content[`document:${doc.id}`]) { + return null; + } const documentKey = sodium.from_base64( locker.content[`document:${doc.id}`] ); diff --git a/apps/app/src/app/list-invitation/[token].tsx b/apps/app/src/app/list-invitation/[token].tsx index 49a5a98..e470b12 100644 --- a/apps/app/src/app/list-invitation/[token].tsx +++ b/apps/app/src/app/list-invitation/[token].tsx @@ -1,7 +1,13 @@ import { router, useLocalSearchParams } from "expo-router"; +import { Alert } from "react-native"; +import * as sodium from "react-native-libsodium"; import { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; import { Text } from "~/components/ui/text"; +import { useLocker } from "../../hooks/useLocker"; +import { acceptInvitation } from "../../utils/acceptInvitation"; +import { getHashParameter } from "../../utils/getHashParam"; +import { getSessionKey } from "../../utils/sessionKeyStorage"; import { trpc } from "../../utils/trpc"; const Invitation: React.FC = () => { @@ -9,8 +15,41 @@ const Invitation: React.FC = () => { trpc.acceptDocumentInvitation.useMutation(); const { token: rawToken } = useLocalSearchParams(); const token = typeof rawToken === "string" ? rawToken : ""; + const key = getHashParameter("key"); + const locker = useLocker(); + + const documentInvitationByTokenQuery = + trpc.documentInvitationByToken.useQuery(token); + + const acceptInvitationAndSend = () => { + if (!documentInvitationByTokenQuery.data) { + Alert.alert("Invitation not found."); + return; + } + + const sessionKey = getSessionKey(); + if (!sessionKey) { + Alert.alert("Session key not found."); + return; + } + + if (!key) { + Alert.alert("Key is not available in the Invitation URL."); + return; + } + + console.log( + "documentInvitationByTokenQuery", + documentInvitationByTokenQuery + ); + + const { listKey } = acceptInvitation({ + ciphertext: documentInvitationByTokenQuery.data.ciphertext, + nonce: documentInvitationByTokenQuery.data.nonce, + seed: key, + sessionKey: sodium.from_base64(sessionKey).slice(0, 32), + }); - const acceptInvitation = () => { acceptDocumentInvitationMutation.mutate( { token }, { @@ -19,6 +58,11 @@ const Invitation: React.FC = () => { }, onSuccess: (data) => { if (data?.documentId) { + locker.addItem({ + type: "document", + documentId: data.documentId, + value: sodium.to_base64(listKey), + }); router.navigate({ pathname: `/list/${data.documentId}` }); } }, @@ -31,7 +75,7 @@ const Invitation: React.FC = () => {

Accept the invitation to this list.

diff --git a/apps/app/src/app/list/[listId].tsx b/apps/app/src/app/list/[listId].tsx index a10db76..d5f8354 100644 --- a/apps/app/src/app/list/[listId].tsx +++ b/apps/app/src/app/list/[listId].tsx @@ -114,7 +114,7 @@ const List: React.FC = () => { documentKey={documentKey} /> - + diff --git a/apps/app/src/app/login.tsx b/apps/app/src/app/login.tsx index 80820f0..d2b37ed 100644 --- a/apps/app/src/app/login.tsx +++ b/apps/app/src/app/login.tsx @@ -7,6 +7,7 @@ import { AuthForm } from "../components/authForm"; import { useLocker } from "../hooks/useLocker"; import { useLogin } from "../hooks/useLogin"; import { deriveKey } from "../utils/deriveKey"; +import { setSessionKey } from "../utils/sessionKeyStorage"; const Login = () => { const { login, isPending } = useLogin(); @@ -31,6 +32,7 @@ const Login = () => { key: loginResult.exportKey, subkeyId: "1D4xb6ADE6j67ZttH7cj7Q", }); + setSessionKey(loginResult.sessionKey); await addItem({ type: "lockerKey", value: lockerKey.key }); if (redirect) { diff --git a/apps/app/src/app/register.tsx b/apps/app/src/app/register.tsx index 64e7a5a..da73dab 100644 --- a/apps/app/src/app/register.tsx +++ b/apps/app/src/app/register.tsx @@ -7,6 +7,7 @@ import { AuthForm } from "../components/authForm"; import { useLocker } from "../hooks/useLocker"; import { useRegisterAndLogin } from "../hooks/useRegisterAndLogin"; import { deriveKey } from "../utils/deriveKey"; +import { setSessionKey } from "../utils/sessionKeyStorage"; const Register = () => { const { registerAndLogin, isPending } = useRegisterAndLogin(); @@ -31,6 +32,7 @@ const Register = () => { key: result.exportKey, subkeyId: "1D4xb6ADE6j67ZttH7cj7Q", }); + setSessionKey(result.sessionKey); await addItem({ type: "lockerKey", value: lockerKey.key }); if (redirect) { diff --git a/apps/app/src/components/documentInvitation.tsx b/apps/app/src/components/documentInvitation.tsx index 0b7b859..b130b7b 100644 --- a/apps/app/src/components/documentInvitation.tsx +++ b/apps/app/src/components/documentInvitation.tsx @@ -1,18 +1,51 @@ import React, { useId } from "react"; +import { Alert } from "react-native"; +import * as sodium from "react-native-libsodium"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; +import { createInvitation } from "../utils/createInvitation"; +import { getSessionKey } from "../utils/sessionKeyStorage"; import { trpc } from "../utils/trpc"; type Props = { documentId: string; + documentKey: Uint8Array; }; -export const DocumentInvitation: React.FC = ({ documentId }) => { +export const DocumentInvitation: React.FC = ({ + documentId, + documentKey, +}) => { const documentInvitationQuery = trpc.documentInvitation.useQuery(documentId); const createOrRefreshDocumentInvitationMutation = trpc.createOrRefreshDocumentInvitation.useMutation(); const id = useId(); + const [seed, setSeed] = React.useState(""); + + const createAndSendInvitation = () => { + const sessionKey = getSessionKey(); + + if (!sessionKey) { + Alert.alert("Session key not found"); + return; + } + + const { ciphertext, nonce, seed } = createInvitation({ + listKey: documentKey, + // the key is much longer and we only need the first 32 bytes + sessionKey: sodium.from_base64(sessionKey).slice(0, 32), + }); + createOrRefreshDocumentInvitationMutation.mutate( + { documentId, ciphertext, nonce }, + { + onSuccess: () => { + documentInvitationQuery.refetch(); + setSeed(seed); + }, + } + ); + }; return (
@@ -20,22 +53,13 @@ export const DocumentInvitation: React.FC = ({ documentId }) => {
diff --git a/apps/app/src/utils/acceptInvitation.ts b/apps/app/src/utils/acceptInvitation.ts index f5ff512..22a5770 100644 --- a/apps/app/src/utils/acceptInvitation.ts +++ b/apps/app/src/utils/acceptInvitation.ts @@ -19,12 +19,12 @@ export const acceptInvitation = ({ sodium.from_base64(nonce), sessionKey ); + const { boxCiphertext } = JSON.parse(sodium.to_string(invitation)); const listKey = sodium.crypto_box_seal_open( sodium.from_base64(boxCiphertext), publicKey, privateKey ); - return { listKey }; }; diff --git a/apps/app/src/utils/getHashParam.ts b/apps/app/src/utils/getHashParam.ts new file mode 100644 index 0000000..aeb4993 --- /dev/null +++ b/apps/app/src/utils/getHashParam.ts @@ -0,0 +1,3 @@ +export const getHashParameter = (param: string) => { + return null; +}; diff --git a/apps/app/src/utils/getHashParam.web.ts b/apps/app/src/utils/getHashParam.web.ts new file mode 100644 index 0000000..b841006 --- /dev/null +++ b/apps/app/src/utils/getHashParam.web.ts @@ -0,0 +1,6 @@ +export const getHashParameter = (param: string) => { + const hash = window.location.hash; + const hashWithoutHash = hash.startsWith("#") ? hash.substring(1) : hash; + const params = new URLSearchParams(hashWithoutHash); + return params.get(param); +}; diff --git a/apps/app/src/utils/sessionKeyStorage.ts b/apps/app/src/utils/sessionKeyStorage.ts new file mode 100644 index 0000000..9ff212c --- /dev/null +++ b/apps/app/src/utils/sessionKeyStorage.ts @@ -0,0 +1,14 @@ +import { MMKV } from "react-native-mmkv"; +// TODO replace with secure storage!!! + +const sessionKeyStorage = new MMKV({ + id: `session-key-storage`, +}); + +export const setSessionKey = (sessionKey: string) => { + sessionKeyStorage.set("sessionKey", sessionKey); +}; + +export const getSessionKey = () => { + return sessionKeyStorage.getString("sessionKey"); +}; diff --git a/apps/server/prisma/migrations/20240606111742_add_ciphertext_to_invitation/migration.sql b/apps/server/prisma/migrations/20240606111742_add_ciphertext_to_invitation/migration.sql new file mode 100644 index 0000000..405cf70 --- /dev/null +++ b/apps/server/prisma/migrations/20240606111742_add_ciphertext_to_invitation/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `ciphertext` to the `DocumentInvitation` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "DocumentInvitation" ADD COLUMN "ciphertext" TEXT NOT NULL; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 1ccd986..4098bab 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -66,6 +66,7 @@ model DocumentInvitation { documentId String token String @unique createdAt DateTime @default(now()) + ciphertext String } model Document { diff --git a/apps/server/src/db/createDocument.ts b/apps/server/src/db/createDocument.ts index c233d6a..6da53bb 100644 --- a/apps/server/src/db/createDocument.ts +++ b/apps/server/src/db/createDocument.ts @@ -1,4 +1,3 @@ -import { generateId } from "../utils/generateId/generateId.js"; import { prisma } from "./prisma.js"; type Params = { @@ -28,11 +27,6 @@ export const createDocument = async ({ isAdmin: true, }, }, - documentInvitations: { - create: { - token: generateId(16), - }, - }, }, }); }; diff --git a/apps/server/src/db/createOrRefreshDocumentInvitation.ts b/apps/server/src/db/createOrRefreshDocumentInvitation.ts index 6a383e2..c70143a 100644 --- a/apps/server/src/db/createOrRefreshDocumentInvitation.ts +++ b/apps/server/src/db/createOrRefreshDocumentInvitation.ts @@ -4,11 +4,13 @@ import { prisma } from "./prisma.js"; type Params = { userId: string; documentId: string; + ciphertext: string; }; export const createOrRefreshDocumentInvitation = async ({ userId, documentId, + ciphertext, }: Params) => { const document = await prisma.document.findUnique({ where: { @@ -30,6 +32,7 @@ export const createOrRefreshDocumentInvitation = async ({ data: { token, documentId, + ciphertext, }, }); }; diff --git a/apps/server/src/db/getDocumentInvitationByToken.ts b/apps/server/src/db/getDocumentInvitationByToken.ts new file mode 100644 index 0000000..f8e54ac --- /dev/null +++ b/apps/server/src/db/getDocumentInvitationByToken.ts @@ -0,0 +1,19 @@ +import { prisma } from "./prisma.js"; + +type Params = { + token: string; + userId: string; +}; + +export const getDocumentInvitationByToken = async ({ + token, + userId, +}: Params) => { + const documentInvitation = await prisma.documentInvitation.findUnique({ + where: { + token, + }, + }); + + return documentInvitation; +}; diff --git a/apps/server/src/trpc/appRouter.ts b/apps/server/src/trpc/appRouter.ts index 26d7f85..8d0e2a9 100644 --- a/apps/server/src/trpc/appRouter.ts +++ b/apps/server/src/trpc/appRouter.ts @@ -1,6 +1,7 @@ import * as opaque from "@serenity-kit/opaque"; import { TRPCError } from "@trpc/server"; import "dotenv/config"; +import sodium from "libsodium-wrappers"; import { z } from "zod"; import { addUserToDocument } from "../db/addUserToDocument.js"; import { createDocument } from "../db/createDocument.js"; @@ -13,6 +14,7 @@ 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 { getDocumentInvitationByToken } from "../db/getDocumentInvitationByToken.js"; import { getDocumentMembers } from "../db/getDocumentMembers.js"; import { getDocumentsByUserId } from "../db/getDocumentsByUserId.js"; import { getLatestUserLocker } from "../db/getLatestUserLocker.js"; @@ -106,12 +108,22 @@ export const appRouter = router({ .input( z.object({ documentId: z.string(), + ciphertext: z.string(), + nonce: z.string(), }) ) .mutation(async (opts) => { + const invitation = sodium.crypto_secretbox_open_easy( + sodium.from_base64(opts.input.ciphertext), + sodium.from_base64(opts.input.nonce), + sodium.from_base64(opts.ctx.session.sessionKey).slice(0, 32) + ); + const { boxCiphertext } = JSON.parse(sodium.to_string(invitation)); + const documentInvitation = await createOrRefreshDocumentInvitation({ userId: opts.ctx.session.userId, documentId: opts.input.documentId, + ciphertext: boxCiphertext, }); return documentInvitation ? { token: documentInvitation.token } : null; }), @@ -127,6 +139,33 @@ export const appRouter = router({ return { token: documentInvitation.token }; }), + documentInvitationByToken: protectedProcedure + .input(z.string()) + .query(async (opts) => { + const documentInvitation = await getDocumentInvitationByToken({ + token: opts.input, + userId: opts.ctx.session.userId, + }); + if (!documentInvitation) return null; + + const invitation = JSON.stringify({ + boxCiphertext: documentInvitation.ciphertext, + }); + + const sessionNonce = sodium.randombytes_buf( + sodium.crypto_secretbox_NONCEBYTES + ); + const sessionCiphertext = sodium.crypto_secretbox_easy( + invitation, + sessionNonce, + sodium.from_base64(opts.ctx.session.sessionKey).slice(0, 32) + ); + return { + ciphertext: sodium.to_base64(sessionCiphertext), + nonce: sodium.to_base64(sessionNonce), + }; + }), + acceptDocumentInvitation: protectedProcedure .input( z.object({