Skip to content

Commit

Permalink
Feature/add secure notes support (#17)
Browse files Browse the repository at this point in the history
* Add Secure Notes support

* Apply format

* Add alias for note retrieving command like for other commands

* Move util function into new dedicated file

* Apply lint
  • Loading branch information
jboillot authored Apr 14, 2022
1 parent d75cfe8 commit cefb67c
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 70 deletions.
54 changes: 54 additions & 0 deletions src/crypto/decrypt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as crypto from 'crypto';
import winston from 'winston';
import { hmacSha256, sha512 } from './hash.js';
import { BackupEditTransaction } from '../types';
import zlib from 'zlib';
import * as xml2json from 'xml2json';
import * as argon2 from 'argon2';

export interface Argon2d {
algo: string;
Expand Down Expand Up @@ -112,3 +116,53 @@ const parseArgon2d = (decodedBase64: string, buffer: Buffer): CipheringMethod =>
cypheredContent,
};
};

export const decryptTransaction = (transaction: BackupEditTransaction, derivate: Buffer): any => {
let cypheredContent;

try {
cypheredContent = getCipheringMethod(transaction.content).cypheredContent;
} catch (error) {
if (error instanceof Error) {
console.error(transaction.type, error.message);
} else {
console.error(transaction.type, error);
}
return null;
}
const { encryptedData: encD, hmac: sign, iv } = cypheredContent;

try {
const content = argonDecrypt(encD, derivate, iv, sign);
const xmlContent = zlib.inflateRawSync(content.slice(6)).toString();
return JSON.parse(xml2json.toJson(xmlContent));
} catch (error: any) {
if (error instanceof Error) {
console.error(transaction.type, error.message);
} else {
console.error(transaction.type, error);
}
return null;
}
};

export const getDerivate = async (
masterPassword: string,
settingsTransaction: BackupEditTransaction
): Promise<Buffer> => {
const { keyDerivation, cypheredContent } = getCipheringMethod(settingsTransaction.content);

const { salt } = cypheredContent;

return argon2.hash(masterPassword, {
type: argon2.argon2d,
saltLength: keyDerivation.saltLength,
timeCost: keyDerivation.tCost,
memoryCost: keyDerivation.mCost,
parallelism: keyDerivation.parallelism,
salt,
version: 19,
hashLength: 32,
raw: true,
});
};
16 changes: 16 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { program } from 'commander';
import winston from 'winston';
import { sync } from './middleware/sync.js';
import { getNote } from './middleware/getSecureNotes.js';
import { getOtp, getPassword } from './middleware/getPasswords.js';
import { connectAndPrepare } from './database/index.js';

Expand Down Expand Up @@ -60,4 +61,19 @@ program
db.close();
});

program
.command('note')
.alias('n')
.description('Retrieve secure notes from local vault and open it.')
.argument('[filter]', 'Filter notes based on their title')
.action(async (filter: string | null) => {
const { db, deviceKeys } = await connectAndPrepare();
await getNote({
titleFilter: filter,
login: deviceKeys.login,
db,
});
db.close();
});

program.parseAsync().catch((err) => console.error(err));
84 changes: 14 additions & 70 deletions src/middleware/getPasswords.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import * as clipboard from 'clipboardy';
import * as argon2 from 'argon2';
import * as zlib from 'zlib';
import * as xml2json from 'xml2json';
import Database from 'better-sqlite3';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { authenticator } from 'otplib';
import winston from 'winston';

import { getCipheringMethod, argonDecrypt } from '../crypto/decrypt.js';
import { AuthentifiantTransactionContent, BackupEditTransaction, VaultCredential } from '../types';
import { decryptTransaction, getDerivate } from '../crypto/decrypt.js';
import { BackupEditTransaction, VaultCredential, AuthentifiantTransactionContent } from '../types.js';
import { askReplaceMasterPassword, getMasterPassword, setMasterPassword } from '../steps/keychainManager.js';
import { notEmpty } from '../utils';

interface GetCredential {
titleFilter: string | null;
Expand All @@ -19,66 +17,7 @@ interface GetCredential {
db: Database.Database;
}

const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => {
return value !== null && value !== undefined;
};

const getDerivate = async (masterPassword: string, settingsTransaction: BackupEditTransaction): Promise<Buffer> => {
const { keyDerivation, cypheredContent } = getCipheringMethod(settingsTransaction.content);

const { salt } = cypheredContent;

return argon2.hash(masterPassword, {
type: argon2.argon2d,
saltLength: keyDerivation.saltLength,
timeCost: keyDerivation.tCost,
memoryCost: keyDerivation.mCost,
parallelism: keyDerivation.parallelism,
salt,
version: 19,
hashLength: 32,
raw: true,
});
};

const decryptTransaction = (
transaction: BackupEditTransaction,
derivate: Buffer
): AuthentifiantTransactionContent | null => {
let cypheredContent;

try {
cypheredContent = getCipheringMethod(transaction.content).cypheredContent;
} catch (error) {
if (error instanceof Error) {
console.error(transaction.type, error.message);
} else {
console.error(transaction.type, error);
}
return null;
}
const { encryptedData: encD, hmac: sign, iv } = cypheredContent;

try {
const content = argonDecrypt(encD, derivate, iv, sign);
const xmlContent = zlib.inflateRawSync(content.slice(6)).toString();
return JSON.parse(xml2json.toJson(xmlContent)) as AuthentifiantTransactionContent;
} catch (error: any) {
if (error instanceof Error) {
if (error.message === 'mismatching signatures') {
// TODO: support pbkdf2 entries
winston.debug(transaction.type + ' ' + error.message);
} else {
console.error(transaction.type, error.message);
}
} else {
console.error(transaction.type, error);
}
return null;
}
};

const decryptTransactions = async (
const decryptPasswordTransactions = async (
transactions: BackupEditTransaction[],
masterPassword: string,
login: string
Expand All @@ -94,13 +33,16 @@ const decryptTransactions = async (
throw new Error('The master password is incorrect.');
}
const masterPassword = await setMasterPassword(login);
return decryptTransactions(transactions, masterPassword, login);
return decryptPasswordTransactions(transactions, masterPassword, login);
}

const authentifiantTransactions = transactions.filter((transaction) => transaction.type === 'AUTHENTIFIANT');

const passwordsDecrypted = authentifiantTransactions
.map((transaction: BackupEditTransaction) => decryptTransaction(transaction, derivate))
.map(
(transaction: BackupEditTransaction) =>
decryptTransaction(transaction, derivate) as AuthentifiantTransactionContent | null
)
.filter(notEmpty);

if (authentifiantTransactions.length !== passwordsDecrypted.length) {
Expand All @@ -118,15 +60,15 @@ export const selectCredential = async (params: GetCredential, onlyOtpCredentials

const masterPassword = await getMasterPassword(login);
if (!masterPassword) {
throw new Error("Couldn't retrieve master pasword in OS keychain.");
throw new Error("Couldn't retrieve master password in OS keychain.");
}

winston.debug('Retrieving:', titleFilter || '');
const transactions = db
.prepare(`SELECT * FROM transactions WHERE action = 'BACKUP_EDIT'`)
.all() as BackupEditTransaction[];

const credentialsDecrypted = await decryptTransactions(transactions, masterPassword, login);
const credentialsDecrypted = await decryptPasswordTransactions(transactions, masterPassword, login);

// transform entries [{key: xx, $t: ww}] into an easier-to-use object
const beautifiedCredentials = credentialsDecrypted.map(
Expand All @@ -143,7 +85,9 @@ export const selectCredential = async (params: GetCredential, onlyOtpCredentials
if (titleFilter) {
const canonicalTitleFilter = titleFilter.toLowerCase();
matchedCredentials = beautifiedCredentials?.filter(
(item) => item.url?.includes(canonicalTitleFilter) || item.title?.includes(canonicalTitleFilter)
(item) =>
item.url?.toLowerCase().includes(canonicalTitleFilter) ||
item.title?.toLowerCase().includes(canonicalTitleFilter)
);
}

Expand Down
128 changes: 128 additions & 0 deletions src/middleware/getSecureNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import winston from 'winston';
import Database from 'better-sqlite3';

import { BackupEditTransaction, SecureNoteTransactionContent, VaultNote } from '../types.js';
import { decryptTransaction, getDerivate } from '../crypto/decrypt.js';
import { askReplaceMasterPassword, getMasterPassword, setMasterPassword } from '../steps/index.js';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { notEmpty } from '../utils';

interface GetSecureNote {
titleFilter: string | null;
login: string;
db: Database.Database;
}

const decryptSecureNotesTransactions = async (
transactions: BackupEditTransaction[],
masterPassword: string,
login: string
): Promise<SecureNoteTransactionContent[] | null> => {
const settingsTransaction = transactions.find((item) => item.identifier === 'SETTINGS_userId');
if (!settingsTransaction) {
throw new Error('Unable to locate the settings of the vault');
} else {
const derivate = await getDerivate(masterPassword, settingsTransaction);

if (!decryptTransaction(settingsTransaction, derivate)) {
if (!(await askReplaceMasterPassword())) {
return null;
}
const masterPassword = await setMasterPassword(login);
return decryptSecureNotesTransactions(transactions, masterPassword, login);
}

const secureNotesTransactions = transactions.filter((transaction) => transaction.type === 'SECURENOTE');

const secureNotesDecrypted = secureNotesTransactions
.map(
(transaction: BackupEditTransaction) =>
decryptTransaction(transaction, derivate) as SecureNoteTransactionContent | null
)
.filter(notEmpty);

if (secureNotesTransactions.length !== secureNotesDecrypted.length) {
console.error(
'Encountered decryption errors:',
secureNotesTransactions.length - secureNotesDecrypted.length
);
}
return secureNotesDecrypted;
}
};

export const getNote = async (params: GetSecureNote): Promise<void> => {
const { login, titleFilter, db } = params;

const masterPassword = await getMasterPassword(login);
if (!masterPassword) {
throw new Error("Couldn't retrieve master password in OS keychain.");
}

winston.debug('Retrieving:', titleFilter || '');
const transactions = db
.prepare(
`SELECT *
FROM transactions
WHERE action = 'BACKUP_EDIT'`
)
.all() as BackupEditTransaction[];

const notesDecrypted = await decryptSecureNotesTransactions(transactions, masterPassword, login);

// transform entries [{key: xx, $t: ww}] into an easier-to-use object
const beautifiedNotes = notesDecrypted?.map(
(item) =>
Object.fromEntries(
item.root.KWSecureNote.KWDataItem.map((entry) => [
entry.key[0].toLowerCase() + entry.key.slice(1), // lowercase the first letter: OtpSecret => otpSecret
entry.$t,
])
) as unknown as VaultNote
);

let matchedNotes = beautifiedNotes;
if (titleFilter) {
const canonicalTitleFilter = titleFilter.toLowerCase();
matchedNotes = beautifiedNotes?.filter((item) => item.title.toLowerCase().includes(canonicalTitleFilter));
}
matchedNotes = matchedNotes?.sort();

let selectedNote: VaultNote | null = null;

if (!matchedNotes || matchedNotes.length === 0) {
throw new Error('No note found');
} else if (matchedNotes.length === 1) {
selectedNote = matchedNotes[0];
} else {
const message = titleFilter
? 'There are multiple results for your query, pick one:'
: 'What note would you like to get?';

inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
const noteQueried = (
await inquirer.prompt<{ note: string }>([
{
type: 'autocomplete',
name: 'note',
message,
source: (_answersSoFar: string[], input: string) =>
matchedNotes
?.map((item, index) => item.title + ' - ' + index.toString(10))
.filter((title) => title && title.toLowerCase().includes(input?.toLowerCase() || '')),
},
])
).note;
const noteQueriedSplit = noteQueried.split(' - ');

const selectedIndex = parseInt(noteQueriedSplit[noteQueriedSplit.length - 1], 10);
if (selectedIndex < 0 || selectedIndex >= matchedNotes.length) {
throw new Error('Unable to retrieve the corresponding note entry');
}

selectedNote = matchedNotes[selectedIndex];
}

console.log(selectedNote.content);
};
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ export interface AuthentifiantTransactionContent {
};
}

export interface SecureNoteTransactionContent {
root: {
KWSecureNote: {
KWDataItem: {
key: string;
$t?: string;
}[];
};
};
}

export interface VaultCredential {
title: string;
email?: string;
Expand All @@ -94,3 +105,21 @@ export interface VaultCredential {
anonId: string;
localeFormat: string; // either UNIVERSAL or a country code
}

export interface VaultNote {
anonId: string;
category?: string;
content: string;
creationDate?: string;
creationDateTime?: string;
id: string;
lastBackupTime: string;
secured: string; // either true or false
spaceId?: string;
title: string;
updateDate?: string;
localeFormat: string; // either UNIVERSAL or a country code
type: string;
sharedObject?: string;
userModificationDatetime?: string;
}
Loading

0 comments on commit cefb67c

Please sign in to comment.