From f481a97a8cc78f511278e72fe9ca3a1cbc4e726b Mon Sep 17 00:00:00 2001 From: Max G Date: Fri, 13 Mar 2020 23:34:32 +0000 Subject: [PATCH] Encrypted Notes Alpha! fixes #28 --- client/src/assets/semantic-helper.css | 10 +- .../GlobalNotificationComponent.vue | 9 +- client/src/components/NoteInputPanel.vue | 292 ++++++++++++++++-- .../src/components/NoteTitleDisplayCard.vue | 11 +- client/src/pages/NotesPage.vue | 6 + client/src/stores/mainStore.js | 6 +- server/helpers/CryptoString.js | 109 +++++++ server/helpers/ProcessText.js | 4 +- server/models/Note.js | 147 +++++++-- server/models/QuickNote.js | 2 +- server/models/User.js | 3 +- server/routes/noteController.js | 6 +- syncDown.sh | 2 +- 13 files changed, 547 insertions(+), 60 deletions(-) create mode 100644 server/helpers/CryptoString.js diff --git a/client/src/assets/semantic-helper.css b/client/src/assets/semantic-helper.css index 6eddc84..65f15a5 100644 --- a/client/src/assets/semantic-helper.css +++ b/client/src/assets/semantic-helper.css @@ -42,6 +42,12 @@ body { font-family: 'Roboto', 'Helvetica Neue', Arial, Helvetica, sans-serif; } +.ui.segment { + color: var(--text_color); + background-color: var(--background_color); + border-color: var(--border_color); +} + .ui.form input:not([type]), .ui.form input:not([type]):focus, .ui.form textarea:not([type]), @@ -216,10 +222,6 @@ a:hover { scrollbar-width: none; } /*Makes the first line real big */ - .squire-box > p:first-child { - font-size: 1.4em; - line-height: 1.7em; - } .squire-box:focus { outline: none; } diff --git a/client/src/components/GlobalNotificationComponent.vue b/client/src/components/GlobalNotificationComponent.vue index e7d5bc1..b63d22e 100644 --- a/client/src/components/GlobalNotificationComponent.vue +++ b/client/src/components/GlobalNotificationComponent.vue @@ -13,8 +13,8 @@ border-top-right-radius: 4px; border-top-left-radius: 4px; - color: var(--text_color); - background-color: var(--background_color); + color: white; + background-color: #21ba45; } .popup-row { padding: 1em 5px; @@ -30,7 +30,7 @@ font-size: 1.25em; } .popup-row + .popup-row { - border-top: 1px solid #000; + border-top: 1px solid #FFF; } @@ -64,6 +64,9 @@ }, mounted(){ + // this.$bus.$emit('notification', 'Password Protection Removed') + // this.$bus.$emit('notification', 'Password Protection Removed') + // this.$bus.$emit('notification', 'Password Protection Removed') }, methods: { diff --git a/client/src/components/NoteInputPanel.vue b/client/src/components/NoteInputPanel.vue index 0b26081..d552ccd 100644 --- a/client/src/components/NoteInputPanel.vue +++ b/client/src/components/NoteInputPanel.vue @@ -41,12 +41,69 @@
-
+ + +
+ + +
+
+

+ + + + This note is encrypted and requires a password to be opened. + + + + + To many unlock attempts. Note is locked for 5 minutes. + +

+ +
+
+ Hint: {{ passwordHint }} +
+
+ +
+
+
+ Unlock Note +
+
+ Unlock Note +
+
+
+ +
+
+
+ +
+ + Password Protect +
+
+ + Remove Password +
+
Status: {{ statusText }}
@@ -56,6 +113,8 @@
+ +
@@ -202,7 +261,56 @@ -
+ +
+

Note Decrypted

+
Lock Note
+
+ +
+ +
+ +

Password protect this Note

+

Password protection will prevent anyone from reading the text of this note, unless they enter the correct password.

+

Only the note text is protected. Title, tags, and files are not encrypted and remain visible without a password.

+

The password you select will only be used for this note. You can use the same password on multiple notes. The note will be encrypted using the password entered. A longer password is will be more secure.

+

Warning. There is no way to recover a lost password.

+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ Passwords do not match +
+
+
+
+ Protect! +
+
+
+
+ +
+
+ + @@ -210,6 +318,7 @@ @@ -1090,7 +1331,18 @@ } + .stealth-input { + width: 100%; + padding: 10px 15px 5px; + background-color: rgba(255,255,255,0.1); + border: none; + font-size: 1.7em; + /*line-height: 1.7em;*/ + color: var(--text_color); + resize: none; + overflow: hidden; + } /*Settings manager styles */ .all-settings { diff --git a/client/src/components/NoteTitleDisplayCard.vue b/client/src/components/NoteTitleDisplayCard.vue index 746cd95..a3d601d 100644 --- a/client/src/components/NoteTitleDisplayCard.vue +++ b/client/src/components/NoteTitleDisplayCard.vue @@ -26,7 +26,7 @@ - + Empty Note @@ -37,8 +37,7 @@ + class="big-text">

{{ note.title }}

+ +

+ + Locked +

+ Archived + +
+ Locked + +
@@ -679,6 +684,7 @@ 'withTags', // 'Only Show Notes with Tags' 'onlyArchived', //'Only Show Archived Notes' 'onlyShowSharedNotes', //Only show shared notes + 'onlyShowEncrypted', ] let filter = {} diff --git a/client/src/stores/mainStore.js b/client/src/stores/mainStore.js index a36932a..4c42a15 100644 --- a/client/src/stores/mainStore.js +++ b/client/src/stores/mainStore.js @@ -94,9 +94,9 @@ export default new Vuex.Store({ //Save all the totals for the user state.userTotals = totalsObject - // Object.keys(totalsObject).forEach( key => { - // console.log(key + ' -- ' + totalsObject[key]) - // }) + Object.keys(totalsObject).forEach( key => { + console.log(key + ' -- ' + totalsObject[key]) + }) } }, diff --git a/server/helpers/CryptoString.js b/server/helpers/CryptoString.js new file mode 100644 index 0000000..8b57798 --- /dev/null +++ b/server/helpers/CryptoString.js @@ -0,0 +1,109 @@ +/* + Crypto String + Securely Encrypts and decrypts a string using a password +*/ + + +const IV_BYTE_SIZE = 100 // Size of initialization vector +const SALT_BYTE_SIZE = 900 // Size of salt +const KEY_BYTE_SIZE = 32 // size of cipher key +const AUTH_TAG_SIZE = 16 // Size of authentication tag + + +const crypto = require('crypto') + +let CryptoString = module.exports = {} + +CryptoString.encrypt = (password, salt64, rawText) => { + + const initializationVector = crypto.randomBytes(IV_BYTE_SIZE) + const salt = Buffer.from(salt64, 'base64') + const key = CryptoString.seasonedPassword(password, salt) + + const cipher = crypto.createCipheriv('aes-256-gcm', key, initializationVector, { 'authTagLength': AUTH_TAG_SIZE }) + let encryptedMessage = cipher.update(rawText) + + encryptedMessage = Buffer.concat([encryptedMessage, cipher.final()]) + return Buffer.concat([initializationVector, encryptedMessage, cipher.getAuthTag()]).toString('base64') + +} + +//Decrypt base64 string cipher text, +CryptoString.decrypt = (password, salt64, cipherTextString) => { + + let cipherText = Buffer.from(cipherTextString, 'base64') + const salt = Buffer.from(salt64, 'base64') + + const authTag = cipherText.slice(AUTH_TAG_SIZE*-1) + const initializationVector = cipherText.slice(0, IV_BYTE_SIZE) + const encryptedMessage = cipherText.slice(IV_BYTE_SIZE, AUTH_TAG_SIZE*-1) + + const key = CryptoString.seasonedPassword(password, salt) + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, initializationVector, { 'authTagLength': AUTH_TAG_SIZE }) + + try { + decipher.setAuthTag(authTag) + + let messagetext = decipher.update(encryptedMessage) + messagetext = Buffer.concat([messagetext, decipher.final()]).toString('utf8') + + return messagetext + + } catch(err) { + + // console.log(err) + return null + } + +} + +//Salt the password - return {buffer} +CryptoString.seasonedPassword = (password, salt) => { + + return crypto.scryptSync(password, salt, KEY_BYTE_SIZE) +} + +//Create random salt - return {string} +CryptoString.createSalt = () => { + + return crypto.randomBytes(SALT_BYTE_SIZE).toString('base64') +} + +CryptoString.hash = (hashString) => { + + return crypto.createHash('sha256').update(hashString).digest() +} + +CryptoString.test = () => { + + const pp = (title, output) => { + console.log('----------------'+title+'----------------') + console.log(output) + } + + const password = 'ItsMePasswordio123' + const text = '

The genesis of  a new note. Very magical.

Quite a weonderful thing


Weonderful for sheore

' + + const hashTest = CryptoString.createSalt('password') + + const salt = CryptoString.createSalt() + pp('salt',salt.length) + + const seasonPass = CryptoString.seasonedPassword(password, salt) + + const cipherText = CryptoString.encrypt(password, salt, text) + + const decipheredText = CryptoString.decrypt(password, salt, cipherText) + pp('Success Decrypt', decipheredText == text ? 'Pass 😁':'Fail' ) + + const wrongPass = CryptoString.decrypt('Wrong Password', salt, cipherText) + pp('Wrong Password', wrongPass === null ? 'Pass':'Fail') + + const wrongSalt = CryptoString.decrypt(password, 'Wrong Salt', cipherText) + pp('Wrong Salt', wrongSalt === null ? 'Pass':'Fail') + + const wrongCipher = CryptoString.decrypt(password, salt, Buffer.from('Hello there')) + pp('Wrong Cipher Text', wrongCipher === null ? 'Pass':'Fail') + +} diff --git a/server/helpers/ProcessText.js b/server/helpers/ProcessText.js index e6878b6..3bc4370 100644 --- a/server/helpers/ProcessText.js +++ b/server/helpers/ProcessText.js @@ -56,7 +56,7 @@ ProcessText.deduceNoteTitle = (inString) => { const tagFreeLength = ProcessText.removeHtml(inString).length if(tagFreeLength < 100){ - title = ProcessText.stripBlankHtmlLines(inString) + sub = ProcessText.stripBlankHtmlLines(inString) return {title, sub} } @@ -178,7 +178,7 @@ ProcessText.deduceNoteTitle = (inString) => { //Pull out title if its not an empty string if(ProcessText.removeHtml(finalLines[0]).trim().replace(' ','').length > 0 && !noTitleJustList){ - title = finalLines.shift() + // title = finalLines.shift() } sub = finalLines.join('') diff --git a/server/models/Note.js b/server/models/Note.js index 67c142b..0156818 100644 --- a/server/models/Note.js +++ b/server/models/Note.js @@ -8,7 +8,8 @@ let ProcessText = require('@helpers/ProcessText') const DiffMatchPatch = require('@helpers/DiffMatchPatch') -var rp = require('request-promise'); +const cs = require('@helpers/CryptoString') +const rp = require('request-promise'); const fs = require('fs') let Note = module.exports = {} @@ -98,7 +99,7 @@ Note.stressTest = () => { // -------------- -Note.create = (userId, noteText, quickNote = 0) => { +Note.create = (userId, noteTitle, noteText, quickNote = 0, ) => { return new Promise((resolve, reject) => { if(userId == null || userId < 10){ reject('User Id required to create note') } @@ -106,7 +107,7 @@ Note.create = (userId, noteText, quickNote = 0) => { const created = Math.round((+new Date)/1000) db.promise() - .query(`INSERT INTO note_raw_text (text, updated) VALUE (?, ?)`, [noteText, created]) + .query(`INSERT INTO note_raw_text (text, title, updated) VALUE (?, ?, ?)`, [noteText, noteTitle, created]) .then( (rows, fields) => { const rawTextId = rows[0].insertId @@ -129,7 +130,11 @@ Note.reindex = (userId, noteId) => { Note.get(userId, noteId) .then(note => { - const noteText = note.text + let noteText = note.text + if(note.encrypted == 1){ + noteText = '' //Don't put note text in encrypted notes + } + // // Update Solr index @@ -137,7 +142,7 @@ Note.reindex = (userId, noteId) => { Tags.string(userId, noteId) .then(tagString => { - const fullText = ProcessText.removeHtml(noteText) +' '+ tagString + const fullText = note.title + ' ' + ProcessText.removeHtml(noteText) +' '+ tagString db.promise() .query(` @@ -158,33 +163,59 @@ Note.reindex = (userId, noteId) => { }) } -Note.update = (io, userId, noteId, noteText, color, pinned, archived) => { +Note.update = (io, userId, noteId, noteText, noteTitle, color, pinned, archived, password = '', passwordHint = '') => { return new Promise((resolve, reject) => { //Prevent note loss if it saves with empty text if(ProcessText.removeHtml(noteText) == ''){ - console.log('Not saving empty note') - resolve(false) + // console.log('Not saving empty note') + // resolve(false) } const now = Math.round((+new Date)/1000) db.promise() - .query('SELECT note_raw_text_id FROM note WHERE id = ? AND user_id = ?', [noteId, userId]) + .query(` + SELECT note_raw_text_id, salt FROM note + JOIN note_raw_text ON note_raw_text_id = note_raw_text.id + WHERE note.id = ? AND user_id = ?`, [noteId, userId]) .then((rows, fields) => { const textId = rows[0][0]['note_raw_text_id'] + let salt = rows[0][0]['salt'] + + //If password is removed, remove salt. generate a new one next time its encrypted + if(password.length == 0){ + salt = null + } + + //If a password is set, create a salt + if(password.length > 3 && !salt){ + salt = cs.createSalt() + + //Save password hint on first encryption + if(passwordHint.length > 0){ + db.promise().query('UPDATE note_raw_text SET password_hint = ? WHERE id = ?', [passwordHint, textId]) + } + } + + //Encrypt note text if proper data is setup + if(password.length > 3 && salt.length > 1000){ + noteText = cs.encrypt(password, salt, noteText) + } //Update Note text return db.promise() - .query('UPDATE note_raw_text SET text = ?, updated = ? WHERE id = ?', [noteText, now, textId]) + .query('UPDATE note_raw_text SET text = ?, title = ?, updated = ?, salt = ? WHERE id = ?', [noteText, noteTitle, now, salt, textId]) }) .then( (rows, fields) => { + const encrypted = password.length > 3 ? 1:0 + //Update other note attributes return db.promise() - .query('UPDATE note SET pinned = ?, archived = ?, color = ? WHERE id = ? AND user_id = ? LIMIT 1', - [pinned, archived, color, noteId, userId]) + .query('UPDATE note SET pinned = ?, archived = ?, color = ?, encrypted = ? WHERE id = ? AND user_id = ? LIMIT 1', + [pinned, archived, color, encrypted, noteId, userId]) }) .then((rows, fields) => { @@ -309,6 +340,9 @@ Note.getDiffText = (userId, noteId, usersCurrentText, lastUpdated) => { Note.get(userId, noteId) .then(noteObject => { + if(!noteObject.text || !usersCurrentText){ + resolve(null) + } let oldText = noteObject.text.replace(/(\r\n|\n|\r)/gm,"") let newText = usersCurrentText.replace(/(\r\n|\n|\r)/gm,"") @@ -354,17 +388,23 @@ Note.getDiffText = (userId, noteId, usersCurrentText, lastUpdated) => { } -Note.get = (userId, noteId) => { +Note.get = (userId, noteId, password = '') => { return new Promise((resolve, reject) => { db.promise() .query(` - SELECT + SELECT + note_raw_text.title, note_raw_text.text, + note_raw_text.salt, + note_raw_text.password_hint, note_raw_text.updated as updated, + note_raw_text.decrypt_attempts_count, + note_raw_text.last_decrypted_date, note.created, note.pinned, note.archived, note.color, + note.encrypted, count(distinct attachment.id) as attachment_count, note.note_raw_text_id as rawTextId, shareUser.username as shareUsername @@ -372,15 +412,74 @@ Note.get = (userId, noteId) => { JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id) LEFT JOIN attachment ON (note.id = attachment.note_id) LEFT JOIN user as shareUser ON (note.share_user_id = shareUser.id) - WHERE note.user_id = ? AND note.id = ? LIMIT 1`, [userId,noteId]) + WHERE note.user_id = ? AND note.id = ? LIMIT 1`, [userId, noteId]) .then((rows, fields) => { - const created = Math.round((+new Date)/1000) + const nowTime = Math.round((+new Date)/1000) + let noteLockedOut = false + let noteData = rows[0][0] + const rawTextId = noteData['rawTextId'] + noteData.decrypted = true - db.promise().query(`UPDATE note SET opened = ? WHERE (id = ?)`, [created, noteId]) + + //If this is not and encrypted note, pass decrypted true, skip encryption stuff + if(noteData.encrypted == 1){ + noteData.decrypted = false + } + + // + //Rate Limiting + // + //Check if note is exceeding decrypt attempt limit + if(noteData.encrypted == 1){ + const timeSinceLastUnlock = nowTime - noteData.last_decrypted_date + + //To many attempts in less than 5 minutes, note is locked + if(noteData.decrypt_attempts_count > 3 && timeSinceLastUnlock < 300){ + console.log('Locked Out') + noteLockedOut = true + } + + //its been 5 minutes, reset attempt count + if(noteData.decrypt_attempts_count > 0 && timeSinceLastUnlock > 300){ + noteLockedOut = false + noteData.decrypt_attempts_count = 0 + console.log('Resseting Lockout') + db.promise().query('UPDATE note_raw_text SET last_decrypted_date = ?, decrypt_attempts_count = 0 WHERE id = ?', [nowTime, rawTextId ]) + } + } + + //Note is encrypted, lets try and decipher it with the given password + if(password.length > 3 && noteData.encrypted == 1 && !noteLockedOut){ + + const decipheredText = cs.decrypt(password, noteData.salt, noteData.text) + + //Text was decrypted, return decrypted text + if(decipheredText !== null){ + noteData.decrypted = true + noteData.text = decipheredText + + //Save last decrypted date, reset decrypt atempts + db.promise().query('UPDATE note_raw_text SET last_decrypted_date = ?, decrypt_attempts_count = 0 WHERE id = ?', [nowTime, rawTextId ]) + + } + //Text was not deciphered, delete object, never return cipher text + if(decipheredText === null){ + noteData.text = '' //Never return cipher text + noteData.decryptFail = true + noteData.decrypt_attempts_count++ //Update display for user + + //Update decrypt attempts + db.promise().query('UPDATE note_raw_text SET decrypt_attempts_count = decrypt_attempts_count +1 WHERE id = ?', [rawTextId ]) + } + } + + db.promise().query(`UPDATE note SET opened = ? WHERE (id = ?)`, [nowTime, noteId]) //Return note data - resolve(rows[0][0]) + delete noteData.salt //remove salt from return data + noteData.lockedOut = noteLockedOut + resolve(noteData) }) .catch(console.log) @@ -484,6 +583,7 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => { let noteSearchQuery = ` SELECT note.id, SUBSTRING(note_raw_text.text, 1, 1500) as text, + note_raw_text.title as title, note_raw_text.updated as updated, opened, color, @@ -491,6 +591,7 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => { count(distinct attachment.id) as attachment_count, note.pinned, note.archived, + note.encrypted, GROUP_CONCAT(DISTINCT tag.text) as tags, GROUP_CONCAT(DISTINCT attachment.file_location) as thumbs, shareUser.username as shareUsername, @@ -527,6 +628,10 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => { searchAllNotes = true } + if(fastFilters.onlyShowEncrypted == 1){ + noteSearchQuery += ' AND encrypted = 1' + } + //If tags are passed, use those tags in search if(searchTags.length > 0){ searchParams.push(searchTags) @@ -610,14 +715,18 @@ Note.search = (userId, searchQuery, searchTags, fastFilters) => { noteIds.push(note.id) if(note.text == null){ note.text = '' } + if(note.encrypted == 1){ note.text = '' } //Deduce note title const textData = ProcessText.deduceNoteTitle(note.text) // console.log(textData) // console.log(textData) + + if(note.title == null){ + note.title = '' + } - note.title = textData.title note.subtext = textData.sub note.titleLength = textData.titleLength note.subtextLength = textData.subtextLength diff --git a/server/models/QuickNote.js b/server/models/QuickNote.js index 25be442..a7e7205 100644 --- a/server/models/QuickNote.js +++ b/server/models/QuickNote.js @@ -71,7 +71,7 @@ QuickNote.update = (userId, pushText) => { let newText = broken +''+ d.text //Save that, then return the new text - Note.update(null, userId, d.id, newText, d.color, d.pinned, d.archived) + Note.update(null, userId, d.id, newText, '', d.color, d.pinned, d.archived) .then( saveResults => { resolve({ id:d.id, diff --git a/server/models/User.js b/server/models/User.js index f9dd030..19ab820 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -124,11 +124,12 @@ User.getCounts = (userId) => { `SELECT SUM(pinned = 1 && archived = 0 && share_user_id IS NULL) AS pinnedNotes, SUM(archived = 1 && share_user_id IS NULL) AS archivedNotes, + SUM(encrypted = 1) AS encryptedNotes, SUM(share_user_id IS NULL) AS totalNotes, SUM(share_user_id != ?) AS sharedToNotes, SUM( (share_user_id != ? && opened IS null) || (share_user_id != ? && note_raw_text.updated > opened) ) AS unreadNotes FROM note - JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id) + LEFT JOIN note_raw_text ON (note_raw_text.id = note.note_raw_text_id) WHERE user_id = ?`, [userId, userId, userId, userId]) .then( (rows, fields) => { diff --git a/server/routes/noteController.js b/server/routes/noteController.js index c371200..19e9cbd 100644 --- a/server/routes/noteController.js +++ b/server/routes/noteController.js @@ -24,7 +24,7 @@ router.use(function setUserId (req, res, next) { // router.post('/get', function (req, res) { // req.io.emit('welcome_homie', 'Welcome, dont poop from excitement') - Notes.get(userId, req.body.noteId) + Notes.get(userId, req.body.noteId, req.body.password) .then( data => { //Join room when user opens note // req.io.join('note_room') @@ -38,12 +38,12 @@ router.post('/delete', function (req, res) { }) router.post('/create', function (req, res) { - Notes.create(userId, req.body.title) + Notes.create(userId, req.body.title, req.body.text) .then( id => res.send({id}) ) }) router.post('/update', function (req, res) { - Notes.update(req.io, userId, req.body.noteId, req.body.text, req.body.color, req.body.pinned, req.body.archived) + Notes.update(req.io, userId, req.body.noteId, req.body.text, req.body.title, req.body.color, req.body.pinned, req.body.archived, req.body.password, req.body.hint) .then( id => res.send({id}) ) }) diff --git a/syncDown.sh b/syncDown.sh index 7527046..1008b9f 100755 --- a/syncDown.sh +++ b/syncDown.sh @@ -12,4 +12,4 @@ # z - Compress for speed # h - Human Readable file sizes -rsync -e 'ssh' --exclude-from=dontSync.txt -havzC --update mab@marvin.local:/home/mab/pi/ /Users/maxgialanella/Code/privateInternet +rsync -e 'ssh' --exclude-from=dontSync.txt -havzC --update mab@marvin.local:/home/mab/pi/ .