I'm using the minified version of SJCL to encrypt the backup files requested by the users. It is in a Google Apps Script add-on for Google Sheets.
I'm mostly concerned with correct use, exception handling and best practices of adding the encryption part in the script. It is currently working as expected in backup and in restore process.
The Backup (and Encryption)
User enter passphrase
It starts by asking the user to enter and re-enter the passphrase in a prompt. 1) One concern here is that the text field of the prompt doesn't mask the passphrase. To fix that I would show a dialog with HTML and form (password input). But then I would need to work client-server call and, while that is not an issue, the prompt is much simpler, less complicated and it won't be necessary to put a HTML - that could be questionable by the user?
const passphrase1 = ui.prompt(
'Backup',
'Enter passphrase:',
ui.ButtonSet.OK_CANCEL);
if (passphrase1.getSelectedButton() === ui.Button.CANCEL) return 0;
const passphrase2 = ui.prompt(
'Backup',
'Please re-enter this passphrase:',
ui.ButtonSet.OK_CANCEL);
if (passphrase2.getSelectedButton() === ui.Button.CANCEL) return 0;
Test passphrase
Here, the script tests if the passphrases match, and it also tests passphrase length and if it has at least upper, lower, numbers and special characters. It is a very simple test - so pA$$w0rd00
pass the test - and I actually didn't want to test anything like GnuPG does. In this case, is nothing (no test, like GnuPG) better than this simple test?
const passphrase = passphrase1.getResponseText();
if (passphrase !== passphrase2.getResponseText() || passphrase.length < 10 || testPassphrasePolicy(passphrase)) {
ui.alert(
'Backup',
'Invalid passphrase.',
ui.ButtonSet.OK);
return 1;
}
function testPassphrasePolicy (passphrase) {
if (!/[a-z]+/.test(passphrase)) return 1;
if (!/[A-Z]+/.test(passphrase)) return 1;
if (!/[0-9]+/.test(passphrase)) return 1;
if (!/[~!@#$%^*-_=+[{\]}/;:,.?]+/.test(passphrase)) return 1;
return 0;
}
Encrypt data
The encryption part is pretty straight forward. The backup
is a object like backup = { foe: 'abc', bar: 123 }
. The steps are as follow:
- Stringify
backup
- Encode to base64
- Compute SHA256
- Concatenate the base64 with
:
and the SHA256 - Encrypt with AES-128 in GCM mode and using the SHA256 as authentication data. If there is an error, simply return and show a generic message error.
- Test decryption. If there is an error, simply return and show a generic message error.
- Return a blob of the encrypted backup
I suspect steps 3 and 4 are overdoing, but before the addition of encryption that is how the script was doing an simple integrity test (with SHA1).
function encryptBackup_ (backup, passphrase) {
const string = JSON.stringify(backup);
const webSafeCode = Utilities.base64EncodeWebSafe(string, Utilities.Charset.UTF_8);
const sha = computeDigest('SHA_256', webSafeCode, 'UTF_8');
const data = webSafeCode + ':' + sha;
let encrypted = '';
try {
encrypted = sjcl.encrypt(passphrase, data, { mode: "gcm", adata: sha });
} catch (err) {
ConsoleLog.error(err);
return 0;
}
try {
const decrypted = sjcl.decrypt(passphrase, encrypted);
const parts = decrypted.split(':');
const test_sha = computeDigest('SHA_256', parts[0], 'UTF_8');
if (test_sha !== parts[1]) throw new Error('digestBackup_(): Bad decryption.');
} catch (err) {
ConsoleLog.error(err);
return 0;
}
const date = Utilities.formatDate(DATE_NOW, 'GMT', 'yyyy-MM-dd-HH-mm-ss');
const name = 'data' + date + '.backup';
const blob = Utilities.newBlob(encrypted, 'application/octet-stream', name);
return blob;
}
The Restore (and Decryption)
Once the user select a Drive file
, the script requests the passphrase and tries to decrypt the file. This part is testing if it is possible to decrypt the file selected by the user.
The passphrase is being cached in a cache instance scoped to the current user and script so the script can retrieve it later to actually decrypt the file and restore the data. The cache expires in 120 seconds (session), and once it is retrieved later, it is immediately removed.
const ui = SpreadsheetApp.getUi();
const passphrase = ui.prompt(
'Restore',
'Enter passphrase:',
ui.ButtonSet.OK_CANCEL);
if (passphrase.getSelectedButton() === ui.Button.CANCEL) return 0;
let decrypted = null;
try {
decrypted = sjcl.decrypt(passphrase.getResponseText(), data);
} catch (err) {
ConsoleLog.error(err);
return 4;
}
const address = computeDigest(
'SHA_1',
file.getId() + SpreadsheetApp2.getActiveSpreadsheet().getId(),
'UTF_8');
CacheService2.put('user', address, 'string', passphrase.getResponseText(), 120);
const parts = decrypted.split(':');
const test_sha = computeDigest('SHA_256', parts[0], 'UTF_8');
if (test_sha !== parts[1]) return 4;
const string = base64DecodeWebSafe(parts[0], 'UTF_8');
return JSON.parse(string);