Improved security (#291)

- Notes are fully encrypted using Waku Message version 1 (no metadata leak)
- Notes are always encrypted, key is part of the URL
- Using standard uuid v4 from browser API to generate ID
- upgraded deps to fix a bug
This commit is contained in:
fryorcraken 2024-01-03 10:45:40 +11:00 committed by GitHub
parent ce693cfdf1
commit 3c7795e121
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 581 additions and 5394 deletions

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,6 @@ import { notes } from "@/services/notes";
export default function Create() { export default function Create() {
const router = useRouter(); const router = useRouter();
const { note, onNoteChange } = useEditNote(); const { note, onNoteChange } = useEditNote();
const { toEncrypt, onEncryptChange } = useEncryptedState();
const onSave = async () => { const onSave = async () => {
if (!note) { if (!note) {
@ -15,8 +14,8 @@ export default function Create() {
} }
try { try {
const { id, password } = await notes.createNote(note, toEncrypt); const { id, key } = await notes.createNote(note);
const passwordParam = password ? `?password=${password}` : ""; const passwordParam = `?key=${key}`;
router.push(`/view/${id}${passwordParam}`); router.push(`/view/${id}${passwordParam}`);
} catch (error) { } catch (error) {
@ -30,17 +29,7 @@ export default function Create() {
Your record will be stored for couple of days. Markdown is supported. Your record will be stored for couple of days. Markdown is supported.
</p> </p>
<div className="create-header"> <div className="create-header">
<div> <div></div>
<input
type="checkbox"
id="isEncrypted"
name="isEncrypted"
onChange={onEncryptChange}
/>
<label htmlFor="isEncrypted" className="to-encrypt">
Private (only those that have link will read the note)
</label>
</div>
<button onClick={onSave} className="save-note"> <button onClick={onSave} className="save-note">
Save note Save note
</button> </button>
@ -66,16 +55,3 @@ const useEditNote = () => {
onNoteChange, onNoteChange,
}; };
}; };
const useEncryptedState = () => {
const [toEncrypt, setToEncrypt] = React.useState<string>();
const onEncryptChange = (event: React.FormEvent<HTMLSelectElement>) => {
setToEncrypt(event?.currentTarget?.value);
};
return {
toEncrypt,
onEncryptChange,
};
};

View File

@ -9,7 +9,7 @@ import { Loading } from "@/components/Loading";
const View = () => { const View = () => {
const router = useRouter(); const router = useRouter();
const { id, password } = useNoteURL(); const { id, key } = useNoteURL();
const [note, setNote] = React.useState<string>(""); const [note, setNote] = React.useState<string>("");
React.useEffect(() => { React.useEffect(() => {
@ -18,8 +18,8 @@ const View = () => {
return; return;
} }
notes.readNote(id, password).then((note) => setNote(note || "")); notes.readNote(id, key).then((note) => setNote(note || ""));
}, [id, password, setNote]); }, [id, key, setNote]);
if (!note) { if (!note) {
return <Loading />; return <Loading />;

View File

@ -1,16 +1,15 @@
"use client"; "use client";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
export const useNoteURL = (): undefined | string => { export const useNoteURL = (): { id: string; key: string } => {
const pathname = usePathname(); const pathname = usePathname();
const params = useSearchParams(); const params = useSearchParams();
const segments = pathname.split("/"); const segments = pathname.split("/");
const viewIndex = segments.indexOf("view"); const viewIndex = segments.indexOf("view");
const password = params.get("password"); const key = params.get("key");
return { const id = segments[viewIndex + 1];
password,
id: segments[viewIndex + 1] || undefined, return { key, id };
};
}; };

View File

@ -3,73 +3,64 @@
import { waku } from "@/services/waku"; import { waku } from "@/services/waku";
import { CONTENT_TOPIC } from "@/const"; import { CONTENT_TOPIC } from "@/const";
import { import {
symmetric,
generateSymmetricKey,
} from "@waku/message-encryption/crypto";
import {
createDecoder,
createEncoder, createEncoder,
Decoder, createDecoder,
Encoder, DecodedMessage,
IDecodedMessage, generateSymmetricKey,
Unsubscribe, } from "@waku/message-encryption/symmetric";
utf8ToBytes, import { Unsubscribe, utf8ToBytes, bytesToUtf8 } from "@waku/sdk";
bytesToUtf8,
} from "@waku/sdk";
import { generateRandomString } from "@/utils";
import { bytesToHex, hexToBytes } from "@waku/utils/bytes"; import { bytesToHex, hexToBytes } from "@waku/utils/bytes";
const UUID_V4_STR_LEN = 8 + 1 + 4 + 1 + 4 + 1 + 4 + 1 + 12; // 8-4-4-4-12 format
type Note = { type Note = {
id: string; id: string;
content: string; content: string;
iv: string;
}; };
type NoteResult = { type NoteResult = {
id: string; id: string;
password?: string; key: string;
}; };
export class Notes { export class Notes {
private decoder: Decoder; private messages: DecodedMessage[] = [];
private encoder: Encoder;
private messages: IDecodedMessage[] = [];
private subscription: undefined | Unsubscribe; private subscription: undefined | Unsubscribe;
constructor() { constructor() {}
this.decoder = createDecoder(CONTENT_TOPIC);
this.encoder = createEncoder({ contentTopic: CONTENT_TOPIC });
}
public async createNote( public async createNote(content: string): Promise<NoteResult> {
content: string, const symKey = generateSymmetricKey();
toEncrypt?: boolean
): Promise<NoteResult> {
const symmetricKey = toEncrypt ? generateSymmetricKey() : undefined;
const note = toEncrypt
? await this.encryptNote(content, symmetricKey)
: { id: generateRandomString(), content, iv: undefined };
await waku.send(this.encoder, { const encoder = createEncoder({ contentTopic: CONTENT_TOPIC, symKey });
payload: utf8ToBytes(JSON.stringify(note)), const id = self.crypto.randomUUID();
if (id.length !== UUID_V4_STR_LEN) {
throw "Unexpected uuid length";
}
await waku.send(encoder, {
payload: utf8ToBytes(id + content),
}); });
return { return {
id: note.id, id,
password: symmetricKey ? bytesToHex(symmetricKey) : undefined, key: bytesToHex(symKey),
}; };
} }
public async readNote( public async readNote(id: string, key: string): Promise<string | undefined> {
id: string, await this.initMessages(hexToBytes(key));
password?: string
): Promise<string | undefined> {
await this.initMessages();
const message = this.messages const message = this.messages
.map((m) => { .map((m) => {
try { try {
return JSON.parse(bytesToUtf8(m.payload)) as Note; const str = bytesToUtf8(m.payload);
const id = str.substring(0, UUID_V4_STR_LEN);
const content = str.substring(UUID_V4_STR_LEN);
return { id, content } as Note;
} catch (error) { } catch (error) {
console.log("Failed to read message:", error); console.log("Failed to read message:", error);
} }
@ -80,65 +71,21 @@ export class Notes {
} }
}); });
if (!message?.iv) { return message?.content;
return message?.content;
}
const passwordReceived =
password || window.prompt("This note is encrypted, need password:");
if (!passwordReceived) {
console.log("No password was provided, stopping reading a note.");
return;
}
return this.decryptNote(message, passwordReceived);
} }
private async initMessages() { private async initMessages(key: Uint8Array) {
if (this.subscription) { if (this.subscription) {
return; return;
} }
this.messages = await waku.getHistory(this.decoder); const decoder = createDecoder(CONTENT_TOPIC, key);
this.subscription = await waku.subscribe(this.decoder, (message) => {
this.messages = await waku.getHistory(decoder);
this.subscription = await waku.subscribe(decoder, (message) => {
this.messages.push(message); this.messages.push(message);
}); });
} }
private async encryptNote(
content: string,
symmetricKey: Uint8Array
): Promise<Note> {
const iv = symmetric.generateIv();
const encryptedContent = await symmetric.encrypt(
iv,
symmetricKey,
utf8ToBytes(content)
);
return {
id: generateRandomString(),
content: bytesToHex(encryptedContent),
iv: bytesToHex(iv),
};
}
private async decryptNote(note: Note, password: string): Promise<string> {
if (!note?.iv) {
throw Error("Failed to decrypt a note, no IV params found.");
}
const iv = hexToBytes(note.iv);
const symmetricKey = hexToBytes(password);
const decryptedContent = await symmetric.decrypt(
iv,
symmetricKey,
hexToBytes(note.content)
);
return bytesToUtf8(decryptedContent);
}
} }
export const notes = new Notes(); export const notes = new Notes();

View File

@ -1,10 +0,0 @@
export function generateRandomString(): string {
let result = "";
let characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let charactersLength = characters.length;
for (let i = 0; i < 16; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}