Skip to content

Commit

Permalink
Merge pull request #5 from nikgraf/encryption-and-invitations
Browse files Browse the repository at this point in the history
Encryption and invitations
  • Loading branch information
nikgraf committed Jun 6, 2024
2 parents cc27742 + 9adcda2 commit abd2222
Show file tree
Hide file tree
Showing 31 changed files with 724 additions and 47 deletions.
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,47 @@ SELECT * FROM "Document";

Users use OPAQUE to authenticate with the server. After Login the server creates a session and stores it as HTTP-Only Cookie. The session is used to authenticate the user for authenticated requests and also to connect to the Websocket.

### Invitation

Users can invite other users to a list via an invitation link.

Creating an invitation link:

```ts
const seed = generateSeed();
const (privKey, pubKey) = generateKeyPair(seed);
const listKeyLockbox = encrypt(pubKey, listKey);
const invitation = {
listKeyLockbox,
};
const encryptedInvitation = encrypt(invitation, sessionKey);
```

InvitationLink: `${token}/#accessKey=${seed}`

Accepting an invitation:

```ts
const (privKey, pubKey) = generateKeyPair(seed);
const encryptedInvitation getInvitationByToken(token);
const invitation = decrypt(encryptedInvitation, sessionKey)
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

- store data locally (api in secsync)
- encrypt list name
- add invitation scheme
- allow to delete list
- allow to delete list (needs a tombstone)
- invitation links should expire after 2 days
- UI for invitations links

- 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
- fix websocket session auth in secsync
9 changes: 7 additions & 2 deletions apps/app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "app.lini.app"
Expand All @@ -27,7 +29,10 @@
"web": {
"favicon": "./assets/images/favicon.png"
},
"plugins": ["expo-router"],
"plugins": [
"expo-router",
"expo-secure-store"
],
"extra": {
"router": {
"origin": false
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"expo-font": "~12.0.6",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.14",
"expo-secure-store": "~13.0.1",
"expo-splash-screen": "~0.27.4",
"expo-standard-web-crypto": "^1.8.1",
"expo-status-bar": "~1.12.1",
Expand Down
35 changes: 28 additions & 7 deletions apps/app/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Link } from "expo-router";
import * as React from "react";
import { View } from "react-native";
import sodium from "react-native-libsodium";
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 { decryptString } from "../utils/decryptString";
import { trpc } from "../utils/trpc";

const Lists: React.FC = () => {
Expand All @@ -19,6 +22,8 @@ const Lists: React.FC = () => {
},
});

const locker = useLocker();

const documentsQuery = trpc.documents.useQuery(undefined, {
refetchInterval: 5000,
});
Expand All @@ -39,13 +44,29 @@ const Lists: React.FC = () => {
<CreateListForm />

<View className="flex flex-col gap-2 pt-4">
{documentsQuery.data?.map((doc) => (
<Link href={`/list/${doc.id}`} key={doc.id} asChild>
<Card className="flex flex-col items-start gap-2 rounded-lg border p-5 text-left text-xl transition-all hover:bg-accent">
<Text>{doc.name}</Text>
</Card>
</Link>
))}
{documentsQuery.data?.map((doc) => {
if (!locker.content[`document:${doc.id}`]) {
return null;
}
const documentKey = sodium.from_base64(
locker.content[`document:${doc.id}`]
);

const name = decryptString({
ciphertext: doc.nameCiphertext,
commitment: doc.nameCommitment,
nonce: doc.nameNonce,
key: documentKey,
});

return (
<Link href={`/list/${doc.id}`} key={doc.id} asChild>
<Card className="flex flex-col items-start gap-2 rounded-lg border p-5 text-left text-xl transition-all hover:bg-accent">
<Text>{name}</Text>
</Card>
</Link>
);
})}
</View>
</View>
);
Expand Down
86 changes: 86 additions & 0 deletions apps/app/src/app/list-invitation/[token].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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),
});

acceptDocumentInvitationMutation.mutate(
{ token },
{
onError: () => {
alert("Failed to accept invitation. Please try again.");
},
onSuccess: (data) => {
if (data?.documentId) {
locker.addItem({
type: "document",
documentId: data.documentId,
value: sodium.to_base64(listKey),
});
router.navigate({ pathname: `/list/${data.documentId}` });
}
},
}
);
};

return (
<Card className="p-4">
<p className="mb-4">Accept the invitation to this list.</p>
<Button
disabled={acceptDocumentInvitationMutation.isPending}
onPress={acceptInvitationAndSend}
>
<Text>Accept Invitation</Text>
</Button>
</Card>
);
};

export default Invitation;
12 changes: 12 additions & 0 deletions apps/app/src/app/list/[listId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import * as Yjs from "yjs";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Text } from "~/components/ui/text";
import { DocumentInvitation } from "../../components/documentInvitation";
import { UpdateDocumentNameForm } from "../../components/updateDocumentNameForm";
import { useLocker } from "../../hooks/useLocker";
import { useYArray } from "../../hooks/useYArray";
import { deserialize } from "../../utils/deserialize";
Expand Down Expand Up @@ -107,12 +109,22 @@ const List: React.FC<Props> = () => {

return (
<>
<UpdateDocumentNameForm
documentId={documentId}
documentKey={documentKey}
/>

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

<View>
<View>
<Input
placeholder="What needs to be done?"
onChangeText={(value) => setNewTodoText(value)}
value={newTodoText}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
/>
<Button
className="add"
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
4 changes: 4 additions & 0 deletions apps/app/src/components/authForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const AuthForm = ({ onSubmit, isPending, children }: Props) => {
className="border border-slate-300 p-2 rounded"
placeholder="Username"
autoComplete="off"
autoCorrect={false}
autoCapitalize="none"
value={username}
onChangeText={(value) => {
setUsername(value);
Expand All @@ -37,6 +39,8 @@ export const AuthForm = ({ onSubmit, isPending, children }: Props) => {
className="border border-slate-300 p-2 rounded"
placeholder="Password"
autoComplete="off"
autoCorrect={false}
autoCapitalize="none"
value={password}
onChangeText={(value) => {
setPassword(value);
Expand Down
45 changes: 36 additions & 9 deletions apps/app/src/components/createListForm.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,57 @@
import { useQueryClient } from "@tanstack/react-query";
import { getQueryKey } from "@trpc/react-query";
import { router } from "expo-router";
import { useState } from "react";
import { Alert, View } from "react-native";
import * as sodium from "react-native-libsodium";
import { generateId } from "secsync";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Text } from "~/components/ui/text";
import { useLocker } from "../hooks/useLocker";
import { encryptString } from "../utils/encryptString";
import { trpc } from "../utils/trpc";

export const CreateListForm: React.FC = () => {
const [name, setName] = useState("");
// const [name, setName] = useState("");
const createDocumentMutation = trpc.createDocument.useMutation();
const { addItem } = useLocker();
const queryClient = useQueryClient();

return (
<View className="flex justify-center items-center gap-4 py-4">
<Input
{/* <Input
placeholder="List name"
className="max-w-48"
value={name}
autoCorrect={false}
onChangeText={(value) => {
setName(value);
}}
/>
/> */}
<Button
disabled={createDocumentMutation.isPending}
onPress={() => {
const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
// @ts-expect-error not matching libsodium types
const documentId = generateId(sodium);

const {
ciphertext: nameCiphertext,
nonce: nameNonce,
commitment: nameCommitment,
} = encryptString({
value: "",
key,
});

createDocumentMutation.mutate(
{ name },
{
id: documentId,
nameCiphertext,
nameNonce,
nameCommitment,
},
{
onSuccess: async ({ document }) => {
const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();

addItem({
type: "document",
documentId: document.id,
Expand All @@ -42,7 +62,14 @@ export const CreateListForm: React.FC = () => {
pathname: `/list/[documentId]`,
params: { documentId: document.id },
});
// documentsQuery.refetch(); // TODO
const documentsQueryKey = getQueryKey(
trpc.documents,
undefined,
"query"
);
queryClient.invalidateQueries({
queryKey: [documentsQueryKey],
});
},
onError: () => {
Alert.alert("Failed to create the list");
Expand Down
Loading

0 comments on commit abd2222

Please sign in to comment.