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() {
const router = useRouter();
const { note, onNoteChange } = useEditNote();
const { toEncrypt, onEncryptChange } = useEncryptedState();
const onSave = async () => {
if (!note) {
@ -15,8 +14,8 @@ export default function Create() {
}
try {
const { id, password } = await notes.createNote(note, toEncrypt);
const passwordParam = password ? `?password=${password}` : "";
const { id, key } = await notes.createNote(note);
const passwordParam = `?key=${key}`;
router.push(`/view/${id}${passwordParam}`);
} catch (error) {
@ -30,17 +29,7 @@ export default function Create() {
Your record will be stored for couple of days. Markdown is supported.
</p>
<div className="create-header">
<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>
<div></div>
<button onClick={onSave} className="save-note">
Save note
</button>
@ -66,16 +55,3 @@ const useEditNote = () => {
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 router = useRouter();
const { id, password } = useNoteURL();
const { id, key } = useNoteURL();
const [note, setNote] = React.useState<string>("");
React.useEffect(() => {
@ -18,8 +18,8 @@ const View = () => {
return;
}
notes.readNote(id, password).then((note) => setNote(note || ""));
}, [id, password, setNote]);
notes.readNote(id, key).then((note) => setNote(note || ""));
}, [id, key, setNote]);
if (!note) {
return <Loading />;

View File

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

View File

@ -3,73 +3,64 @@
import { waku } from "@/services/waku";
import { CONTENT_TOPIC } from "@/const";
import {
symmetric,
generateSymmetricKey,
} from "@waku/message-encryption/crypto";
import {
createDecoder,
createEncoder,
Decoder,
Encoder,
IDecodedMessage,
Unsubscribe,
utf8ToBytes,
bytesToUtf8,
} from "@waku/sdk";
import { generateRandomString } from "@/utils";
createDecoder,
DecodedMessage,
generateSymmetricKey,
} from "@waku/message-encryption/symmetric";
import { Unsubscribe, utf8ToBytes, bytesToUtf8 } from "@waku/sdk";
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 = {
id: string;
content: string;
iv: string;
};
type NoteResult = {
id: string;
password?: string;
key: string;
};
export class Notes {
private decoder: Decoder;
private encoder: Encoder;
private messages: IDecodedMessage[] = [];
private messages: DecodedMessage[] = [];
private subscription: undefined | Unsubscribe;
constructor() {
this.decoder = createDecoder(CONTENT_TOPIC);
this.encoder = createEncoder({ contentTopic: CONTENT_TOPIC });
}
constructor() {}
public async createNote(
content: string,
toEncrypt?: boolean
): Promise<NoteResult> {
const symmetricKey = toEncrypt ? generateSymmetricKey() : undefined;
const note = toEncrypt
? await this.encryptNote(content, symmetricKey)
: { id: generateRandomString(), content, iv: undefined };
public async createNote(content: string): Promise<NoteResult> {
const symKey = generateSymmetricKey();
await waku.send(this.encoder, {
payload: utf8ToBytes(JSON.stringify(note)),
const encoder = createEncoder({ contentTopic: CONTENT_TOPIC, symKey });
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 {
id: note.id,
password: symmetricKey ? bytesToHex(symmetricKey) : undefined,
id,
key: bytesToHex(symKey),
};
}
public async readNote(
id: string,
password?: string
): Promise<string | undefined> {
await this.initMessages();
public async readNote(id: string, key: string): Promise<string | undefined> {
await this.initMessages(hexToBytes(key));
const message = this.messages
.map((m) => {
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) {
console.log("Failed to read message:", error);
}
@ -80,65 +71,21 @@ export class Notes {
}
});
if (!message?.iv) {
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);
return message?.content;
}
private async initMessages() {
private async initMessages(key: Uint8Array) {
if (this.subscription) {
return;
}
this.messages = await waku.getHistory(this.decoder);
this.subscription = await waku.subscribe(this.decoder, (message) => {
const decoder = createDecoder(CONTENT_TOPIC, key);
this.messages = await waku.getHistory(decoder);
this.subscription = await waku.subscribe(decoder, (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();

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;
}