Skip to content

Commit

Permalink
implement locker
Browse files Browse the repository at this point in the history
  • Loading branch information
nikgraf committed Jun 2, 2024
1 parent 529d225 commit 432902a
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 41 deletions.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ Users use OPAQUE to authenticate with the server. After Login the server creates

## Todos

- 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
21 changes: 2 additions & 19 deletions apps/app/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
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";
Expand All @@ -25,13 +24,7 @@ const Lists: React.FC = () => {
refetchInterval: 5000,
});

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

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

console.log("lockerContent", content);

Expand All @@ -50,21 +43,11 @@ 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">
{documentsQuery.data?.map((doc) => (
<Link href={`/list/${doc.id}`} key={doc.id}>
<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>
))}
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
10 changes: 10 additions & 0 deletions apps/app/src/app/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ 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">
Expand All @@ -23,6 +26,13 @@ const Login = () => {
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
10 changes: 10 additions & 0 deletions apps/app/src/app/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ 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">
Expand All @@ -23,6 +26,13 @@ const Register = () => {
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,
};
};
38 changes: 38 additions & 0 deletions apps/app/src/utils/deriveKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
_unstable_crypto_kdf_hkdf_sha256_expand,
_unstable_crypto_kdf_hkdf_sha256_extract,
crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
from_base64,
randombytes_buf,
to_base64,
} from "react-native-libsodium";

type Params = {
key: string;
context: "userLocker";
subkeyId?: string;
};

export const createSubkeyId = () => {
return to_base64(randombytes_buf(16));
};

export const deriveKey = (params: Params) => {
const context = params.context;
const subkeyId = params.subkeyId || createSubkeyId();

const prk = _unstable_crypto_kdf_hkdf_sha256_extract(
from_base64(params.key),
from_base64(subkeyId)
);
const derivedKey = _unstable_crypto_kdf_hkdf_sha256_expand(
prk,
context,
crypto_aead_xchacha20poly1305_ietf_KEYBYTES
);

return {
subkeyId,
key: to_base64(derivedKey),
};
};
2 changes: 0 additions & 2 deletions apps/server/src/db/createUserLocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export const createUserLocker = async ({
},
});

console.log("userLocker", userLocker, clock);

if (userLocker === null) {
if (clock !== 0) {
throw new Error("Invalid clock, expected 0");
Expand Down

0 comments on commit 432902a

Please sign in to comment.