Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Storage #3

Merged
merged 2 commits into from
Jun 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading