Skip to content

Commit 3502cf9

Browse files
author
djinni-hppro
committed
Encrypted Note
1 parent fd1cd01 commit 3502cf9

8 files changed

Lines changed: 412 additions & 13 deletions

File tree

src/git.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const createMarkdownContent = (note) => {
118118
if (note.priority) lines.push(`priority: ${note.priority}`);
119119
if (note.reminder) lines.push(`reminder: ${note.reminder}`);
120120
if (note.bodyEncoded !== undefined) lines.push(`body_encoded: ${note.bodyEncoded}`);
121+
if (note.isEncrypted !== undefined) lines.push(`is_encrypted: ${note.isEncrypted}`);
121122

122123
if (note.tags && note.tags.length > 0) {
123124
lines.push(`tags: [${note.tags.join(', ')}]`);
@@ -263,6 +264,7 @@ _GLOBAL.listNotesInGit = async (creds) => {
263264
reminder: metadata.reminder || null,
264265
priority: metadata.priority || 0,
265266
bodyEncoded: metadata.body_encoded !== 'false',
267+
isEncrypted: metadata.is_encrypted === 'true',
266268
source: 'git'
267269
});
268270
} catch (readErr) {

src/helpers.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,3 +1291,74 @@ async function authenticateBiometric() {
12911291
const assertion = await navigator.credentials.get(options);
12921292
return !!assertion;
12931293
}
1294+
1295+
// --- Universal Text Encryption Helpers ---
1296+
1297+
const textEncoder = new TextEncoder();
1298+
const textDecoder = new TextDecoder();
1299+
1300+
async function deriveKey(password, salt) {
1301+
const passwordBytes = textEncoder.encode(password);
1302+
const baseKey = await crypto.subtle.importKey(
1303+
'raw', passwordBytes, 'PBKDF2', false, ['deriveKey']
1304+
);
1305+
return crypto.subtle.deriveKey(
1306+
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
1307+
baseKey,
1308+
{ name: 'AES-GCM', length: 256 },
1309+
false,
1310+
['encrypt', 'decrypt']
1311+
);
1312+
}
1313+
1314+
async function encrypt(plaintext, password) {
1315+
const salt = crypto.getRandomValues(new Uint8Array(16)); // 16 bytes salt
1316+
const iv = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes IV for GCM
1317+
1318+
const key = await deriveKey(password, salt);
1319+
const plaintextBytes = textEncoder.encode(plaintext);
1320+
1321+
const ciphertextBuffer = await crypto.subtle.encrypt(
1322+
{ name: 'AES-GCM', iv }, key, plaintextBytes
1323+
);
1324+
const ciphertextBytes = new Uint8Array(ciphertextBuffer);
1325+
1326+
// Combine everything into a single byte array for easy transport
1327+
const combinedBytes = new Uint8Array(salt.length + iv.length + ciphertextBytes.length);
1328+
combinedBytes.set(salt, 0);
1329+
combinedBytes.set(iv, salt.length);
1330+
combinedBytes.set(ciphertextBytes, salt.length + iv.length);
1331+
1332+
// Convert binary to a clean, transferable string (stack-safe for large notes)
1333+
let binary = '';
1334+
const len = combinedBytes.byteLength;
1335+
for (let i = 0; i < len; i++) {
1336+
binary += String.fromCharCode(combinedBytes[i]);
1337+
}
1338+
return btoa(binary);
1339+
}
1340+
1341+
async function decrypt(combinedBase64, password) {
1342+
const binString = atob(combinedBase64);
1343+
const len = binString.length;
1344+
const combinedBytes = new Uint8Array(len);
1345+
for (let i = 0; i < len; i++) {
1346+
combinedBytes[i] = binString.charCodeAt(i);
1347+
}
1348+
1349+
// Slice out the structural components exactly as they were packed
1350+
const salt = combinedBytes.slice(0, 16);
1351+
const iv = combinedBytes.slice(16, 28);
1352+
const ciphertextBytes = combinedBytes.slice(28);
1353+
1354+
const key = await deriveKey(password, salt);
1355+
1356+
const decryptedBuffer = await crypto.subtle.decrypt(
1357+
{ name: 'AES-GCM', iv }, key, ciphertextBytes
1358+
);
1359+
1360+
return textDecoder.decode(decryptedBuffer);
1361+
}
1362+
1363+
_GLOBAL.encrypt = encrypt;
1364+
_GLOBAL.decrypt = decrypt;

src/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@
147147
color: var(--color-blue-400);
148148
}
149149

150+
.EasyMDEContainer .editor-toolbar button[aria-label*="crypt"] {
151+
color: var(--color-yellow-400);
152+
}
153+
150154
.EasyMDEContainer .editor-toolbar button[aria-label*="AI"] {
151155
color: var(--color-yellow-400);
152156
}

src/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ <h3 class="text-lg font-semibold" x-text="noteEditorNoteId ? ('Editing: #' + not
197197
<div class="flex items-center gap-2">
198198
<h4 class="text-base font-bold truncate" :style="{ 'color': getNoteColor(note) }" x-text="note.title"></h4>
199199
</div>
200-
<p class="text-sm line-clamp-5 break-all" x-text="getNotePreview(note.content)"></p>
200+
<p class="text-sm line-clamp-5 break-all" x-text="getNotePreview(note)"></p>
201201
</div>
202202

203203
<div class="mt-4 pt-2 border-t flex flex-col gap-2">

src/index.js

Lines changed: 157 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
6060
noteEditorTags: '',
6161
noteEditorReminder: '',
6262
noteEditorBodyEncoded: true,
63+
noteEditorIsEncrypted: false,
64+
currentNotePassword: null,
6365

6466
// --- Notification Data ---
6567
notificationsEnabled: false,
@@ -384,7 +386,11 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
384386
minute: '2-digit',
385387
})
386388
},
387-
getNotePreview(content) {
389+
getNotePreview(note) {
390+
if (note?.isEncrypted) {
391+
return '**********';
392+
}
393+
const content = note?.content;
388394
return content ? `${content.trim().replace(/(\r?\n)+/g, '\n').substring(0, 300)}...` : '<!-- EMPTY -->';
389395
},
390396

@@ -1024,7 +1030,7 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
10241030
}
10251031
},
10261032

1027-
async addNote(title, content, reminder, tags, bodyEncoded) {
1033+
async addNote(title, content, reminder, tags, bodyEncoded, isEncrypted = false) {
10281034
try {
10291035
const now = new Date().toISOString();
10301036
const newNote = {
@@ -1037,6 +1043,7 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
10371043
tags: tags || [],
10381044
priority: 0,
10391045
bodyEncoded: bodyEncoded !== false,
1046+
isEncrypted: !!isEncrypted,
10401047
};
10411048
await addNoteDB(newNote);
10421049
this.notes.unshift(newNote);
@@ -1104,11 +1111,20 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
11041111
const content = window.easyMDEInstance?.value();
11051112
if (content === null || content === undefined) return;
11061113

1114+
let finalContent = content;
1115+
if (this.noteEditorIsEncrypted) {
1116+
if (!this.currentNotePassword) {
1117+
return; // Cannot safely encrypt without password, skip autosave
1118+
}
1119+
finalContent = await _GLOBAL.encrypt(finalContent, this.currentNotePassword);
1120+
}
1121+
11071122
const noteData = {
11081123
title: this.noteEditorTitle.trim() || ('Note at ' + new Date().toString().substr(0, 21)),
1109-
content: content,
1124+
content: finalContent,
11101125
reminder: this.noteEditorReminder.trim() !== '' ? this.noteEditorReminder : undefined,
11111126
tags: this.noteEditorTags.split(',').map(tag => tag.trim()).filter(tag => tag),
1127+
isEncrypted: this.noteEditorIsEncrypted,
11121128
};
11131129

11141130
if (noteInDb.title === noteData.title &&
@@ -1754,6 +1770,13 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
17541770
},
17551771
className: "fa fa-share-square-o",
17561772
title: "Share",
1773+
},{
1774+
name: "Encrypt",
1775+
action: function(editor){
1776+
Alpine.$data(document.querySelector('body'))?.toggleNoteEncryption();
1777+
},
1778+
className: "fa fa-unlock-alt",
1779+
title: "Encrypt / Decrypt Note",
17571780
},
17581781
"|",
17591782
"bold", "italic", "heading",
@@ -2038,6 +2061,10 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
20382061
&& window.easyMDEInstance?.togglePreview?.()
20392062
);
20402063

2064+
this.$nextTick(() => {
2065+
this.updatePadlockIcon();
2066+
});
2067+
20412068
this.noteEditorVisible = false;
20422069
this.easyMDEIniting = false;
20432070
} catch (ex) {
@@ -2224,9 +2251,31 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
22242251
const note = isId ? await this.getNote(id) : noteOrId;
22252252

22262253
if (note) {
2254+
let finalContent = note.content;
2255+
this.noteEditorIsEncrypted = !!note.isEncrypted;
2256+
this.currentNotePassword = null;
2257+
2258+
if (note.isEncrypted) {
2259+
const pwd = prompt("This note is encrypted. Please enter the password to view/edit:");
2260+
if (!pwd) {
2261+
this.showToast({ variant: 'error', title: 'Cancelled', description: 'Cannot view note without password.' });
2262+
this.cancelEdit();
2263+
return;
2264+
}
2265+
try {
2266+
finalContent = await _GLOBAL.decrypt(note.content, pwd);
2267+
this.currentNotePassword = pwd;
2268+
} catch (e) {
2269+
console.error("Decryption failed:", e);
2270+
this.showToast({ variant: 'error', title: 'Decryption Failed', description: 'Incorrect password or corrupted content.' });
2271+
this.cancelEdit();
2272+
return;
2273+
}
2274+
}
2275+
22272276
this.noteEditorTitle = note.title;
2228-
this.noteEditorContent = note.content;
2229-
this.noteEditorBaseContent = note.content; // Capture base content for merge
2277+
this.noteEditorContent = finalContent;
2278+
this.noteEditorBaseContent = finalContent; // Capture base content for merge
22302279
this.noteEditorReminder = note.reminder || '';
22312280
this.noteEditorTags = note.tags ? note.tags.join(', ') : '';
22322281
this.noteEditorBodyEncoded = note.bodyEncoded !== false;
@@ -2238,6 +2287,49 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
22382287
this.$nextTick(() => this.prepareEasyMDE(id, options));
22392288
},
22402289

2290+
toggleNoteEncryption() {
2291+
const app = this;
2292+
if (app.noteEditorIsEncrypted) {
2293+
// Ask if they want to decrypt the note (remove encryption)
2294+
const confirmDecrypt = confirm("Do you want to decrypt this note (remove password protection permanently)?");
2295+
if (confirmDecrypt) {
2296+
app.noteEditorIsEncrypted = false;
2297+
app.currentNotePassword = null;
2298+
app.showToast({ title: 'Decryption Pending', description: 'Save the note to remove password protection.' });
2299+
app.updatePadlockIcon();
2300+
}
2301+
} else {
2302+
// Prompt for a password to encrypt
2303+
const pwd = prompt("Enter a password to encrypt this note (this password is NOT stored anywhere):");
2304+
if (!pwd) {
2305+
app.showToast({ variant: 'error', title: 'Cancelled', description: 'Encryption was cancelled.' });
2306+
return;
2307+
}
2308+
const pwdConfirm = prompt("Please confirm your password:");
2309+
if (pwd !== pwdConfirm) {
2310+
app.showToast({ variant: 'error', title: 'Error', description: 'Passwords do not match.' });
2311+
return;
2312+
}
2313+
app.noteEditorIsEncrypted = true;
2314+
app.currentNotePassword = pwd;
2315+
app.showToast({ title: 'Encryption Pending', description: 'Save the note to finalize password protection.' });
2316+
app.updatePadlockIcon();
2317+
}
2318+
},
2319+
2320+
updatePadlockIcon() {
2321+
const lockBtn = document.querySelector('.EasyMDEContainer .editor-toolbar button.fa-lock, .EasyMDEContainer .editor-toolbar button.fa-unlock-alt');
2322+
if (lockBtn) {
2323+
if (this.noteEditorIsEncrypted) {
2324+
lockBtn.className = 'fa fa-lock active text-yellow-500';
2325+
lockBtn.title = 'Note is Encrypted (Click to Decrypt)';
2326+
} else {
2327+
lockBtn.className = 'fa fa-unlock-alt';
2328+
lockBtn.title = 'Note is Unencrypted (Click to Encrypt)';
2329+
}
2330+
}
2331+
},
2332+
22412333
async saveNote(isAuto) {
22422334
if (this.isSaving) return;
22432335
this.isSaving = true;
@@ -2250,18 +2342,34 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
22502342

22512343
const tags = this.noteEditorTags.split(',').map(tag => tag.trim()).filter(tag => tag);
22522344

2345+
let rawContent = window.easyMDEInstance?.value() || this.noteEditorContent;
2346+
let isEncrypted = this.noteEditorIsEncrypted;
2347+
2348+
if (isEncrypted) {
2349+
if (!this.currentNotePassword) {
2350+
const pwd = prompt("Enter a password to encrypt this note:");
2351+
if (!pwd) {
2352+
this.showToast({ variant: 'error', title: 'Error', description: 'A password is required to save an encrypted note.' });
2353+
return;
2354+
}
2355+
this.currentNotePassword = pwd;
2356+
}
2357+
rawContent = await _GLOBAL.encrypt(rawContent, this.currentNotePassword);
2358+
}
2359+
22532360
const noteData = {
22542361
title: finalTitle,
2255-
content: window.easyMDEInstance?.value() || this.noteEditorContent,
2362+
content: rawContent,
22562363
reminder: this.noteEditorReminder.trim() !== '' ? this.noteEditorReminder : undefined,
22572364
tags: tags,
22582365
bodyEncoded: this.noteEditorBodyEncoded,
2366+
isEncrypted: isEncrypted,
22592367
};
22602368

22612369
if (this.noteEditorNoteId && this.noteEditorNoteId !== 'new') {
22622370
await this.updateNote(this.noteEditorNoteId, noteData);
22632371
} else {
2264-
const newNote = await this.addNote(noteData.title, noteData.content, noteData.reminder, noteData.tags, noteData.bodyEncoded);
2372+
const newNote = await this.addNote(noteData.title, noteData.content, noteData.reminder, noteData.tags, noteData.bodyEncoded, isEncrypted);
22652373
if (newNote) {
22662374
this.noteEditorNoteId = newNote.id;
22672375
}
@@ -2332,13 +2440,37 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
23322440
const credentials = await decryptSettings(encryptedSettings, this.userId);
23332441
if (credentials && credentials.s3Bucket) {
23342442
try {
2335-
this.showToast({ title: 'Publishing to S3...', description: 'Creating a shareable link via S3.' });
2443+
let shareHtml = marked.parse(content);
2444+
const encryptShare = confirm("Do you want to encrypt this shared S3 link with a password?");
2445+
if (encryptShare) {
2446+
const sharePassword = prompt("Enter a password for the viewer of this link (do not forget it!):");
2447+
if (!sharePassword) {
2448+
this.showToast({ variant: 'error', title: 'Cancelled', description: 'Sharing cancelled.' });
2449+
return;
2450+
}
2451+
2452+
const wrappedHtml = `<h1 style="font-size: 2.25rem; font-weight: 800; margin-bottom: 1.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e5e7eb;">${this.noteEditorTitle}</h1><div>${shareHtml}</div>`;
2453+
const encryptedPayload = await _GLOBAL.encrypt(wrappedHtml, sharePassword);
2454+
2455+
try {
2456+
const template = await fetch('/self-decrypting.html').then(r => r.text());
2457+
shareHtml = template
2458+
.replace('{{TITLE}}', this.noteEditorTitle)
2459+
.replace('{{ENCRYPTED_PAYLOAD}}', encryptedPayload);
2460+
} catch (e) {
2461+
console.error('Failed to load self-decrypting template:', e);
2462+
this.showToast({ variant: 'error', title: 'Error', description: 'Could not load the encryption template.' });
2463+
return;
2464+
}
2465+
}
2466+
2467+
this.showToast({ title: 'Publishing to S3...', description: 'Creating a shareable link via S3.' });
23362468
const note = {
23372469
id: this.noteEditorNoteId || generateUniqueId(this.noteEditorTitle),
23382470
title: this.noteEditorTitle,
23392471
content: content,
23402472
updatedAt: new Date().toISOString(),
2341-
html: marked.parse(content),
2473+
html: encryptShare ? shareHtml : marked.parse(content),
23422474
};
23432475
await uploadNoteToS3(note, credentials);
23442476
const url = await getPresignedUrl(note, credentials);
@@ -2369,14 +2501,29 @@ document.addEventListener('alpine:init', () => { Alpine.data('mainApp', () => ({
23692501
// Fallback using server storage
23702502
try {
23712503
this.showToast({ title: 'Publishing...', description: 'Creating a shareable link via the server.' });
2504+
2505+
let finalContent = content;
2506+
let isEncrypted = false;
2507+
const encryptShare = confirm("Do you want to encrypt this shared server link with a password?");
2508+
if (encryptShare) {
2509+
const sharePassword = prompt("Enter a password for the viewer of this link (do not forget it!):");
2510+
if (!sharePassword) {
2511+
this.showToast({ variant: 'error', title: 'Cancelled', description: 'Sharing cancelled.' });
2512+
return;
2513+
}
2514+
finalContent = await _GLOBAL.encrypt(finalContent, sharePassword);
2515+
isEncrypted = true;
2516+
}
2517+
23722518
const response = await fetch('/api/publish', {
23732519
method: 'POST',
23742520
headers: {
23752521
'Content-Type': 'application/json',
23762522
},
23772523
body: JSON.stringify({
23782524
title: this.noteEditorTitle,
2379-
content: content
2525+
content: finalContent,
2526+
isEncrypted: isEncrypted
23802527
}),
23812528
});
23822529

0 commit comments

Comments
 (0)