Skip to content

Commit 6a12848

Browse files
committed
web: use pgp keys for inbox
Signed-off-by: 01zulfi <[email protected]>
1 parent cc58446 commit 6a12848

File tree

18 files changed

+1751
-229
lines changed

18 files changed

+1751
-229
lines changed

apps/web/package-lock.json

Lines changed: 1394 additions & 31 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
"happy-dom": "16.0.1",
114114
"ip": "^2.0.1",
115115
"lorem-ipsum": "^2.0.4",
116+
"openpgp": "^6.2.2",
116117
"otplib": "^12.0.1",
117118
"rollup-plugin-visualizer": "^5.13.1",
118119
"vite": "5.4.11",
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
This file is part of the Notesnook project (https://notesnook.com/)
3+
4+
Copyright (C) 2023 Streetwriters (Private) Limited
5+
6+
This program is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
This program is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { useState } from "react";
21+
import { Button, Flex, Text } from "@theme-ui/components";
22+
import Dialog from "../components/dialog";
23+
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
24+
import { db } from "../common/db";
25+
import Field from "../components/field";
26+
import { showToast } from "../utils/toast";
27+
import { SerializedKeyPair } from "@notesnook/crypto";
28+
import { ConfirmDialog } from "./confirm";
29+
30+
type InboxPGPKeysDialogProps = BaseDialogProps<boolean> & {
31+
keys?: SerializedKeyPair | null;
32+
};
33+
34+
export const InboxPGPKeysDialog = DialogManager.register(
35+
function InboxPGPKeysDialog(props: InboxPGPKeysDialogProps) {
36+
const { keys: initialKeys, onClose } = props;
37+
const [mode, setMode] = useState<"choose" | "edit">(
38+
initialKeys ? "edit" : "choose"
39+
);
40+
const [publicKey, setPublicKey] = useState(initialKeys?.publicKey || "");
41+
const [privateKey, setPrivateKey] = useState(initialKeys?.privateKey || "");
42+
const [isLoading, setIsLoading] = useState(false);
43+
44+
const hasChanges =
45+
publicKey !== (initialKeys?.publicKey || "") ||
46+
privateKey !== (initialKeys?.privateKey || "");
47+
48+
async function handleAutoGenerate() {
49+
try {
50+
setIsLoading(true);
51+
await db.user.getInboxKeys();
52+
showToast("success", "Inbox keys generated");
53+
onClose(true);
54+
} catch (error) {
55+
showToast("error", "Failed to generate inbox keys");
56+
console.error(error);
57+
} finally {
58+
setIsLoading(false);
59+
}
60+
}
61+
62+
async function handleSave() {
63+
const trimmedPublicKey = publicKey.trim();
64+
const trimmedPrivateKey = privateKey.trim();
65+
if (!trimmedPublicKey || !trimmedPrivateKey) {
66+
showToast("error", "Both public and private keys are required");
67+
return;
68+
}
69+
70+
try {
71+
setIsLoading(true);
72+
const isValid = await db.storage().validatePGPKeyPair({
73+
publicKey: trimmedPublicKey,
74+
privateKey: trimmedPrivateKey
75+
});
76+
if (!isValid) {
77+
showToast(
78+
"error",
79+
"Invalid PGP key pair. Please check your keys and try again."
80+
);
81+
return;
82+
}
83+
84+
if (initialKeys) {
85+
const ok = await ConfirmDialog.show({
86+
title: "Change Inbox PGP Keys",
87+
message:
88+
"Changing Inbox PGP keys will delete all your unsynced inbox items. Are you sure?",
89+
positiveButtonText: "Yes",
90+
negativeButtonText: "No"
91+
});
92+
if (!ok) return;
93+
}
94+
95+
await db.user.saveInboxKeys({
96+
publicKey: trimmedPublicKey,
97+
privateKey: trimmedPrivateKey
98+
});
99+
showToast("success", "Inbox keys saved");
100+
onClose(true);
101+
} catch (error) {
102+
showToast("error", "Failed to save inbox keys");
103+
console.error(error);
104+
} finally {
105+
setIsLoading(false);
106+
}
107+
}
108+
109+
if (mode === "choose") {
110+
return (
111+
<Dialog
112+
isOpen={true}
113+
title="Setup Inbox PGP Keys"
114+
width={500}
115+
negativeButton={{
116+
text: "Cancel",
117+
onClick: () => onClose(false)
118+
}}
119+
>
120+
<Flex sx={{ flexDirection: "column", gap: 3 }}>
121+
<Text sx={{ fontSize: "body", color: "paragraph" }}>
122+
Choose how you want to set up your Inbox PGP keys:
123+
</Text>
124+
<Flex sx={{ flexDirection: "column", gap: 2 }}>
125+
<Button
126+
variant="secondary"
127+
onClick={handleAutoGenerate}
128+
disabled={isLoading}
129+
sx={{ width: "100%" }}
130+
>
131+
{isLoading ? "Generating..." : "Auto-generate keys"}
132+
</Button>
133+
<Text
134+
sx={{
135+
fontSize: "body",
136+
color: "paragraph",
137+
textAlign: "center"
138+
}}
139+
>
140+
Or
141+
</Text>
142+
<Button
143+
variant="secondary"
144+
onClick={() => setMode("edit")}
145+
disabled={isLoading}
146+
sx={{ width: "100%" }}
147+
>
148+
Provide your own keys
149+
</Button>
150+
</Flex>
151+
</Flex>
152+
</Dialog>
153+
);
154+
}
155+
156+
return (
157+
<Dialog
158+
isOpen={true}
159+
title="Inbox PGP Keys"
160+
width={600}
161+
positiveButton={{
162+
text: isLoading ? "Saving..." : "Save",
163+
onClick: handleSave,
164+
disabled: isLoading || !hasChanges
165+
}}
166+
negativeButton={{
167+
text: "Cancel",
168+
onClick: () => onClose(false)
169+
}}
170+
>
171+
<Flex sx={{ flexDirection: "column", gap: 3 }}>
172+
<Field
173+
label="Public Key"
174+
id="publicKey"
175+
name="publicKey"
176+
as="textarea"
177+
required
178+
value={publicKey}
179+
onChange={(e) => setPublicKey(e.target.value)}
180+
sx={{
181+
fontFamily: "monospace",
182+
fontSize: "body",
183+
minHeight: 150,
184+
resize: "vertical"
185+
}}
186+
placeholder="Enter your PGP public key..."
187+
disabled={isLoading}
188+
/>
189+
<Field
190+
label="Private Key"
191+
id="privateKey"
192+
name="privateKey"
193+
as="textarea"
194+
required
195+
value={privateKey}
196+
onChange={(e) => setPrivateKey(e.target.value)}
197+
sx={{
198+
fontFamily: "monospace",
199+
fontSize: "body",
200+
minHeight: 150,
201+
resize: "vertical"
202+
}}
203+
placeholder="Enter your PGP private key..."
204+
disabled={isLoading}
205+
/>
206+
</Flex>
207+
</Dialog>
208+
);
209+
}
210+
);

apps/web/src/dialogs/settings/inbox-settings.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
import { SettingsGroup } from "./types";
2121
import { useStore as useSettingStore } from "../../stores/setting-store";
2222
import { InboxApiKeys } from "./components/inbox-api-keys";
23+
import { InboxPGPKeysDialog } from "../inbox-pgp-keys-dialog";
24+
import { db } from "../../common/db";
25+
import { showPasswordDialog } from "../password-dialog";
26+
import { strings } from "@notesnook/intl";
2327

2428
export const InboxSettings: SettingsGroup[] = [
2529
{
@@ -42,6 +46,41 @@ export const InboxSettings: SettingsGroup[] = [
4246
}
4347
]
4448
},
49+
{
50+
key: "show-inbox-pgp-keys",
51+
title: "Inbox PGP Keys",
52+
description: "View/edit your Inbox PGP keys",
53+
keywords: ["inbox", "pgp", "keys"],
54+
onStateChange: (listener) =>
55+
useSettingStore.subscribe((s) => s.isInboxEnabled, listener),
56+
isHidden: () => !useSettingStore.getState().isInboxEnabled,
57+
components: [
58+
{
59+
type: "button",
60+
title: "Show",
61+
variant: "secondary",
62+
action: async () => {
63+
const ok = await showPasswordDialog({
64+
title: "Authenticate to view/edit Inbox PGP keys",
65+
inputs: {
66+
password: {
67+
label: strings.accountPassword(),
68+
autoComplete: "current-password"
69+
}
70+
},
71+
validate: ({ password }) => {
72+
return db.user.verifyPassword(password);
73+
}
74+
});
75+
if (!ok) return;
76+
77+
InboxPGPKeysDialog.show({
78+
keys: await db.user.getInboxKeys()
79+
});
80+
}
81+
}
82+
]
83+
},
4584
{
4685
key: "inbox-api-keys",
4786
title: "",

apps/web/src/interfaces/storage.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ import {
2626
} from "./key-value";
2727
import { NNCrypto } from "./nncrypto";
2828
import type {
29-
AsymmetricCipher,
3029
Cipher,
3130
SerializedKey,
3231
SerializedKeyPair
3332
} from "@notesnook/crypto";
3433
import { isFeatureSupported } from "../utils/feature-check";
3534
import { IKeyStore } from "./key-store";
3635
import { User } from "@notesnook/core";
36+
import * as openpgp from "openpgp";
3737

3838
type EncryptedKey = { iv: Uint8Array; cipher: BufferSource };
3939
export type DatabasePersistence = "memory" | "db";
@@ -133,8 +133,44 @@ export class NNStorage implements IStorage {
133133
return await NNCrypto.exportKey(password, salt);
134134
}
135135

136-
async generateCryptoKeyPair() {
137-
return await NNCrypto.exportKeyPair();
136+
async generatePGPKeyPair(): Promise<SerializedKeyPair> {
137+
const keys = await openpgp.generateKey({
138+
userIDs: [{ name: "NN", email: "[email protected]" }]
139+
});
140+
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
141+
}
142+
143+
async validatePGPKeyPair(keys: SerializedKeyPair): Promise<boolean> {
144+
try {
145+
const dummyData = JSON.stringify({
146+
favorite: true,
147+
title: "Hello world"
148+
});
149+
150+
const publicKey = await openpgp.readKey({ armoredKey: keys.publicKey });
151+
const encrypted = await openpgp.encrypt({
152+
message: await openpgp.createMessage({
153+
text: dummyData
154+
}),
155+
encryptionKeys: publicKey
156+
});
157+
158+
const message = await openpgp.readMessage({
159+
armoredMessage: encrypted
160+
});
161+
const privateKey = await openpgp.readPrivateKey({
162+
armoredKey: keys.privateKey
163+
});
164+
const decrypted = await openpgp.decrypt({
165+
message,
166+
decryptionKeys: privateKey
167+
});
168+
169+
return decrypted.data === dummyData;
170+
} catch (e) {
171+
console.error("PGP key pair validation error:", e);
172+
return false;
173+
}
138174
}
139175

140176
async hash(password: string, email: string): Promise<string> {
@@ -165,12 +201,21 @@ export class NNStorage implements IStorage {
165201
return NNCrypto.decryptMulti(key, items, "text");
166202
}
167203

168-
decryptAsymmetric(
169-
keyPair: SerializedKeyPair,
170-
cipherData: AsymmetricCipher<"base64">
204+
async decryptPGPMessage(
205+
privateKeyArmored: string,
206+
encryptedMessage: string
171207
): Promise<string> {
172-
cipherData.format = "base64";
173-
return NNCrypto.decryptAsymmetric(keyPair, cipherData, "base64");
208+
const message = await openpgp.readMessage({
209+
armoredMessage: encryptedMessage
210+
});
211+
const privateKey = await openpgp.readPrivateKey({
212+
armoredKey: privateKeyArmored
213+
});
214+
const decrypted = await openpgp.decrypt({
215+
message,
216+
decryptionKeys: privateKey
217+
});
218+
return decrypted.data;
174219
}
175220

176221
/**

0 commit comments

Comments
 (0)