Skip to content

Commit

Permalink
add invitation handling
Browse files Browse the repository at this point in the history
  • Loading branch information
nikgraf committed Jun 6, 2024
1 parent 34f1fa6 commit ecde814
Show file tree
Hide file tree
Showing 17 changed files with 187 additions and 24 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
Expand All @@ -115,13 +113,16 @@ 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)

- 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
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const Lists: React.FC = () => {

<View className="flex flex-col gap-2 pt-4">
{documentsQuery.data?.map((doc) => {
if (!locker.content[`document:${doc.id}`]) {
return null;
}
const documentKey = sodium.from_base64(
locker.content[`document:${doc.id}`]
);
Expand Down
48 changes: 46 additions & 2 deletions apps/app/src/app/list-invitation/[token].tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,55 @@
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 = () => {
const acceptDocumentInvitationMutation =
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 },
{
Expand All @@ -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}` });
}
},
Expand All @@ -31,7 +75,7 @@ const Invitation: React.FC = () => {
<p className="mb-4">Accept the invitation to this list.</p>
<Button
disabled={acceptDocumentInvitationMutation.isPending}
onPress={acceptInvitation}
onPress={acceptInvitationAndSend}
>
<Text>Accept Invitation</Text>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/app/list/[listId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const List: React.FC<Props> = () => {
documentKey={documentKey}
/>

<DocumentInvitation documentId={documentId} />
<DocumentInvitation documentId={documentId} documentKey={documentKey} />

<View>
<View>
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -31,6 +32,7 @@ const Login = () => {
key: loginResult.exportKey,
subkeyId: "1D4xb6ADE6j67ZttH7cj7Q",
});
setSessionKey(loginResult.sessionKey);
await addItem({ type: "lockerKey", value: lockerKey.key });

if (redirect) {
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/app/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -31,6 +32,7 @@ const Register = () => {
key: result.exportKey,
subkeyId: "1D4xb6ADE6j67ZttH7cj7Q",
});
setSessionKey(result.sessionKey);
await addItem({ type: "lockerKey", value: lockerKey.key });

if (redirect) {
Expand Down
48 changes: 36 additions & 12 deletions apps/app/src/components/documentInvitation.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,65 @@
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<Props> = ({ documentId }) => {
export const DocumentInvitation: React.FC<Props> = ({
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 (
<div>
<Text>Invitation link</Text>
<div className="flex gap-2 pt-2">
<Input
id={id}
value={`${window.location.origin}/list-invitation/${documentInvitationQuery.data?.token}`}
value={`${window.location.origin}/list-invitation/${documentInvitationQuery.data?.token}#key=${seed}`}
readOnly
className="w-72"
/>
<Button
disabled={createOrRefreshDocumentInvitationMutation.isPending}
onPress={() =>
createOrRefreshDocumentInvitationMutation.mutate(
{ documentId },
{
onSuccess: () => {
documentInvitationQuery.refetch();
},
}
)
}
onPress={createAndSendInvitation}
>
<Text>Refresh</Text>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/utils/acceptInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
3 changes: 3 additions & 0 deletions apps/app/src/utils/getHashParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const getHashParameter = (param: string) => {
return null;
};
6 changes: 6 additions & 0 deletions apps/app/src/utils/getHashParam.web.ts
Original file line number Diff line number Diff line change
@@ -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);
};
14 changes: 14 additions & 0 deletions apps/app/src/utils/sessionKeyStorage.ts
Original file line number Diff line number Diff line change
@@ -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");
};
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ model DocumentInvitation {
documentId String
token String @unique
createdAt DateTime @default(now())
ciphertext String
}

model Document {
Expand Down
6 changes: 0 additions & 6 deletions apps/server/src/db/createDocument.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { generateId } from "../utils/generateId/generateId.js";
import { prisma } from "./prisma.js";

type Params = {
Expand Down Expand Up @@ -28,11 +27,6 @@ export const createDocument = async ({
isAdmin: true,
},
},
documentInvitations: {
create: {
token: generateId(16),
},
},
},
});
};
3 changes: 3 additions & 0 deletions apps/server/src/db/createOrRefreshDocumentInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -30,6 +32,7 @@ export const createOrRefreshDocumentInvitation = async ({
data: {
token,
documentId,
ciphertext,
},
});
};
19 changes: 19 additions & 0 deletions apps/server/src/db/getDocumentInvitationByToken.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit ecde814

Please sign in to comment.