Skip to content

Commit

Permalink
Merge pull request #3 from nikgraf/storage
Browse files Browse the repository at this point in the history
Storage
  • Loading branch information
nikgraf committed Jun 2, 2024
2 parents 3ae2567 + 6dd8f02 commit 19662b7
Show file tree
Hide file tree
Showing 14 changed files with 146 additions and 56 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ Users use OPAQUE to authenticate with the server. After Login the server creates

## Todos

- setup CI (ts:check)
- generate keys and store them locally
- store keys on lockbox
- store data locally (api in secsync)
- encrypt list name
- add invitation scheme
- allow to delete list
- add retry for locker in case write fails (invalid clock)
- encrypt MMKV storage on iOS and Android

- store data locally (api in secsync)
- fix websocket session auth in secsync
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"react-native": "0.74.1",
"react-native-gesture-handler": "~2.16.1",
"react-native-libsodium": "^1.3.1",
"react-native-mmkv": "^2.12.2",
"react-native-opaque": "^0.3.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1",
Expand Down
30 changes: 4 additions & 26 deletions apps/app/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
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 = () => {
Expand All @@ -25,16 +23,6 @@ const Lists: React.FC = () => {
refetchInterval: 5000,
});

// const lockerKey = sodium.from_base64(
// "MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98"
// );

const { content, addDocumentKey } = useLocker(
"MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98"
);

console.log("lockerContent", content);

return (
<View>
<Link href="/login">
Expand All @@ -50,25 +38,15 @@ const Lists: React.FC = () => {

<CreateListForm />

<Button
onPress={() => {
// random number for now
const id = Math.floor(Math.random() * 10000000).toString();
addDocumentKey(id, "123");
}}
>
<Text>GENERATE</Text>
</Button>

<div className="flex flex-col gap-2 pt-4">
<View className="flex flex-col gap-2 pt-4">
{documentsQuery.data?.map((doc) => (
<Link href={`/list/${doc.id}`} key={doc.id}>
<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">
{doc.name}
<Text>{doc.name}</Text>
</Card>
</Link>
))}
</div>
</View>
</View>
);
};
Expand Down
10 changes: 6 additions & 4 deletions apps/app/src/app/list/[listId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as Yjs from "yjs";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Text } from "~/components/ui/text";
import { useLocker } from "../../hooks/useLocker";
import { useYArray } from "../../hooks/useYArray";

const websocketEndpoint =
Expand All @@ -21,9 +22,6 @@ type Props = {
const List: React.FC<Props> = () => {
const { listId } = useLocalSearchParams();
const documentId = typeof listId === "string" ? listId : "";
const documentKey = sodium.from_base64(
"MTcyipWZ6Kiibd5fATw55i9wyEU7KbdDoTE_MRgDR98"
);

const [authorKeyPair] = useState<KeyPair>(() => {
return sodium.crypto_sign_keypair();
Expand All @@ -33,6 +31,8 @@ const List: React.FC<Props> = () => {
const yTodos: Yjs.Array<string> = yDocRef.current.getArray("todos");
const todos = useYArray(yTodos);
const [newTodoText, setNewTodoText] = useState("");
const { content } = useLocker();
const documentKey = sodium.from_base64(content[`document:${documentId}`]);

const [state, send] = useYjsSync({
yDoc: yDocRef.current,
Expand Down Expand Up @@ -94,7 +94,9 @@ const List: React.FC<Props> = () => {
onPress={() => {
yTodos.delete(index, 1);
}}
/>
>
<Text>x</Text>
</Button>
</View>
);
})}
Expand Down
14 changes: 12 additions & 2 deletions apps/app/src/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,35 @@ import { View } from "react-native";
import { Text } from "~/components/ui/text";
import { AlertCircle } from "~/lib/icons/AlertCircle";
import { AuthForm } from "../components/authForm";
import { useLocker } from "../hooks/useLocker";
import { useLogin } from "../hooks/useLogin";
import { deriveKey } from "../utils/deriveKey";

const Login = () => {
const { login, isPending } = useLogin();
const [error, setError] = useState<string | null>(null);
const { redirect } = useLocalSearchParams<{ redirect?: string }>();
const { addItem } = useLocker();

return (
<View className="max-w-md mr-auto ml-auto">
<AuthForm
onSubmit={async ({ password, username }) => {
const sessionKey = await login({
const loginResult = await login({
userIdentifier: username,
password,
});
if (!sessionKey) {
if (loginResult === null) {
setError("Failed to login");
return;
}
const lockerKey = deriveKey({
context: "userLocker",
key: loginResult.exportKey,
subkeyId: "1D4xb6ADE6j67ZttH7cj7Q",
});
await addItem({ type: "lockerKey", value: lockerKey.key });

if (redirect) {
router.navigate(redirect);
return;
Expand Down
14 changes: 12 additions & 2 deletions apps/app/src/app/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,35 @@ import { View } from "react-native";
import { Text } from "~/components/ui/text";
import { AlertCircle } from "~/lib/icons/AlertCircle";
import { AuthForm } from "../components/authForm";
import { useLocker } from "../hooks/useLocker";
import { useRegisterAndLogin } from "../hooks/useRegisterAndLogin";
import { deriveKey } from "../utils/deriveKey";

const Register = () => {
const { registerAndLogin, isPending } = useRegisterAndLogin();
const { redirect } = useLocalSearchParams<{ redirect?: string }>();
const [error, setError] = useState<string | null>(null);
const { addItem } = useLocker();

return (
<View className="max-w-md mr-auto ml-auto">
<AuthForm
onSubmit={async ({ password, username }) => {
const sessionKey = await registerAndLogin({
const result = await registerAndLogin({
userIdentifier: username,
password,
});
if (!sessionKey) {
if (!result) {
setError("Failed to register");
return;
}
const lockerKey = deriveKey({
context: "userLocker",
key: result.exportKey,
subkeyId: "1D4xb6ADE6j67ZttH7cj7Q",
});
await addItem({ type: "lockerKey", value: lockerKey.key });

if (redirect) {
router.navigate(redirect);
return;
Expand Down
13 changes: 12 additions & 1 deletion apps/app/src/components/createListForm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { router } from "expo-router";
import { useState } from "react";
import { Alert, View } 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 { useLocker } from "../hooks/useLocker";
import { trpc } from "../utils/trpc";

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

return (
<View className="flex justify-center items-center gap-4 py-4">
Expand All @@ -26,7 +29,15 @@ export const CreateListForm: React.FC = () => {
createDocumentMutation.mutate(
{ name },
{
onSuccess: ({ document }) => {
onSuccess: async ({ document }) => {
const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();

addItem({
type: "document",
documentId: document.id,
value: sodium.to_base64(key),
});

router.navigate({
pathname: `/list/[documentId]`,
params: { documentId: document.id },
Expand Down
5 changes: 3 additions & 2 deletions apps/app/src/components/logout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { router } from "expo-router";
import { Alert } from "react-native";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import { lockerStorage } from "../hooks/useLocker";
import { trpc } from "../utils/trpc";

export const Logout: React.FC = () => {
Expand All @@ -14,11 +15,11 @@ export const Logout: React.FC = () => {
// not perfect but good enough since the local changes are fast
disabled={logoutMutation.isPending}
onPress={async () => {
// removeLocalDb(); // TODO
lockerStorage.clearAll();
logoutMutation.mutate(undefined, {
onSuccess: () => {
// delete again to verify in case new info came in during the logout request
// removeLocalDb(); // TODO
lockerStorage.clearAll();
queryClient.invalidateQueries();
router.navigate("/login");
},
Expand Down
56 changes: 46 additions & 10 deletions apps/app/src/hooks/useLocker.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,80 @@
import { useMemo } from "react";
import sodium from "react-native-libsodium";
import { MMKV } from "react-native-mmkv";
import { decryptLocker } from "../utils/decryptLocker";
import { encryptLocker } from "../utils/encryptLocker";
import { trpc } from "../utils/trpc";

export const useLocker = (key: string) => {
export const lockerStorage = new MMKV({
id: `locker-storage`,
});

const getCompleteLocalLocker = () => {
const allKeys = lockerStorage.getAllKeys();
return allKeys.reduce<Record<string, string | undefined>>((acc, key) => {
acc[key] = lockerStorage.getString(key);
return acc;
}, {});
};

export const useLocker = () => {
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 localLocker = getCompleteLocalLocker();

if (!latestUserLockerQuery.data || localLocker.lockerKey === undefined) {
return localLocker;
}

const keyUint8Array = sodium.from_base64(localLocker.lockerKey);
const contentString = decryptLocker(
latestUserLockerQuery.data,
keyUint8Array
);
return JSON.parse(contentString) || {}; // TODO validate schema
return {
// TODO validate schema
...JSON.parse(contentString),
...localLocker,
};
}, [
latestUserLockerQuery.data?.ciphertext,
latestUserLockerQuery.data?.nonce,
latestUserLockerQuery.data?.commitment,
latestUserLockerQuery.data?.clock,
]);

// RETRY in case it fails
const addDocumentKey = async (documentId: string, value: string) => {
// TODO RETRY in case it fails
const addItem = async (
params:
| { type: "document"; documentId: string; value: string }
| { type: "lockerKey"; value: string }
) => {
const newKey =
params.type === "document"
? `document:${params.documentId}`
: "lockerKey";

lockerStorage.set(newKey, params.value);

const localLocker = getCompleteLocalLocker();
const newContent = {
...localLocker,
...content,
[documentId]: value,
[newKey]: params.value,
};
const newContentString = JSON.stringify(newContent);
const newClock =
latestUserLockerQuery.data?.clock !== undefined
? latestUserLockerQuery.data.clock + 1
: 0;

if (!localLocker.lockerKey) {
throw new Error("Locker key not found");
}

const keyUint8Array = sodium.from_base64(localLocker.lockerKey);
const { ciphertext, nonce, commitment } = encryptLocker(
newContentString,
newClock,
Expand All @@ -56,6 +92,6 @@ export const useLocker = (key: string) => {

return {
content,
addDocumentKey,
addItem,
};
};
4 changes: 2 additions & 2 deletions apps/app/src/hooks/useLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const useLogin = () => {
if (!loginResult) {
return null;
}
const { sessionKey, finishLoginRequest } = loginResult;
const { sessionKey, finishLoginRequest, exportKey } = loginResult;

const { success } = await loginFinishMutation.mutateAsync({
finishLoginRequest,
Expand All @@ -44,7 +44,7 @@ export const useLogin = () => {

queryClient.invalidateQueries();

return success ? sessionKey : null;
return success ? { sessionKey, exportKey } : null;
} catch (error) {
return null;
} finally {
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/hooks/useRegisterAndLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const useRegisterAndLogin = () => {

const result = await login({ userIdentifier, password });
return result;
} catch (error) {
} catch (_error) {
return null;
} finally {
setIsPending(false);
Expand Down
Loading

0 comments on commit 19662b7

Please sign in to comment.