Skip to content

Commit

Permalink
Add better filters for secure note
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikescops committed Aug 17, 2023
1 parent 4c2a27c commit bd6cb7d
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 136 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ dcli p url=someurl title=mytitle
dcli p url,title=mywebsite
# will return any entry for which either the url or the title matches mywebsite

dcli note [titleFilter]
# will return any secure note for which the title matches titleFilter
dcli note title=sample.md
# will return any secure note which matches the filters (similar to password filters)

dcli otp [filters]
# will return any otp for which the title matches titleFilter
# will return any otp which matches the filters (similar to password filters)
```

Note: You can select a different output for passwords among `clipboard, password, json`. The JSON option outputs all the matching credentials.
Expand Down
8 changes: 6 additions & 2 deletions documentation/pages/personal/vault.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ dcli otp [filters]

<YoutubePlayer videoId="n8OxYRfrgfk" />

Getting a secure note is very simple, just use the `note` or `n` command and filter by title:
In order to get a secure note, just use the `note` or `n` command and define some filters (similarly to the password command).
You can also select a different output for notes among `text, json`. The JSON option outputs all the matching notes.

```sh copy
dcli note [titleFilter]
dcli note [filters]

# Example with a JSON output
dcli note tilte=sample.md -o json
```

## Options
Expand Down
159 changes: 51 additions & 108 deletions src/command-handlers/passwords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,72 @@ import { authenticator } from 'otplib';
import winston from 'winston';
import { AuthentifiantTransactionContent, BackupEditTransaction, Secrets, VaultCredential } from '../types';
import { decryptTransactions } from '../modules/crypto';
import { askCredentialChoice } from '../utils';
import { askCredentialChoice, filterMatches } from '../utils';
import { connectAndPrepare } from '../modules/database';

export const runPassword = async (filters: string[] | null, options: { output: string | null }) => {
export const runPassword = async (filters: string[] | null, options: { output: 'json' | 'clipboard' | 'password' }) => {
const { output } = options;
const { db, secrets } = await connectAndPrepare({});

if (options.output === 'json') {
console.log(
JSON.stringify(
await selectCredentials({
filters,
secrets,
output: options.output,
db,
}),
null,
4
)
);
} else {
await getPassword({
filters,
secrets,
output: options.output,
db,
});
const clipboard = new Clipboard();
const selectedCredential = await selectCredential({ filters, secrets, db });

switch (output) {
case 'clipboard':
clipboard.setText(selectedCredential.password);
console.log(
`🔓 Password for "${selectedCredential.title || selectedCredential.url || 'N\\C'}" copied to clipboard!`
);

if (selectedCredential.otpSecret) {
const token = authenticator.generate(selectedCredential.otpSecret);
const timeRemaining = authenticator.timeRemaining();
console.log(`🔢 OTP code: ${token} \u001B[3m(expires in ${timeRemaining} seconds)\u001B[0m`);
}
break;
case 'password':
console.log(selectedCredential.password);
break;
case 'json':
console.log(JSON.stringify(selectedCredential, null, 4));
break;
default:
throw new Error('Unable to recognize the output mode.');
}

db.close();
};

export const runOtp = async (filters: string[] | null, options: { print: boolean }) => {
const { db, secrets } = await connectAndPrepare({});
await getOtp({
filters,
secrets,
output: options.print ? 'otp' : 'clipboard',
db,
});

const clipboard = new Clipboard();
const selectedCredential = await selectCredential({ db, filters, secrets }, true);

const output = options.print ? 'otp' : 'clipboard';

// otpSecret can't be null because onlyOtpCredentials is set to true above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const token = authenticator.generate(selectedCredential.otpSecret!);
const timeRemaining = authenticator.timeRemaining();
switch (output) {
case 'clipboard':
clipboard.setText(token);
console.log(`🔢 OTP code: ${token} \u001B[3m(expires in ${timeRemaining} seconds)\u001B[0m`);
break;
case 'otp':
console.log(token);
break;
default:
throw new Error('Unable to recognize the output mode.');
}

db.close();
};

interface GetCredential {
filters: string[] | null;
secrets: Secrets;
output: string | null;
db: Database.Database;
}

Expand All @@ -74,39 +95,7 @@ export const selectCredentials = async (params: GetCredential): Promise<VaultCre
) as unknown as VaultCredential
);

let matchedCredentials = beautifiedCredentials;
if (filters?.length) {
interface ItemFilter {
keys: string[];
value: string;
}
const parsedFilters: ItemFilter[] = [];

filters.forEach((filter) => {
const [splitFilterKey, ...splitFilterValues] = filter.split('=');

const filterValue = splitFilterValues.join('=') || splitFilterKey;
const filterKeys = splitFilterValues.length > 0 ? splitFilterKey.split(',') : ['url', 'title'];

const canonicalFilterValue = filterValue.toLowerCase();

parsedFilters.push({
keys: filterKeys,
value: canonicalFilterValue,
});
});

matchedCredentials = matchedCredentials?.filter((item) =>
parsedFilters
.map((filter) =>
filter.keys.map((key) => item[key as keyof VaultCredential]?.toLowerCase().includes(filter.value))
)
.flat()
.some((b) => b)
);
}

return matchedCredentials;
return filterMatches<VaultCredential>(beautifiedCredentials, filters);
};

export const selectCredential = async (params: GetCredential, onlyOtpCredentials = false): Promise<VaultCredential> => {
Expand All @@ -124,49 +113,3 @@ export const selectCredential = async (params: GetCredential, onlyOtpCredentials

return askCredentialChoice({ matchedCredentials, hasFilters: Boolean(params.filters?.length) });
};

export const getPassword = async (params: GetCredential): Promise<void> => {
const clipboard = new Clipboard();
const selectedCredential = await selectCredential(params);

switch (params.output || 'clipboard') {
case 'clipboard':
clipboard.setText(selectedCredential.password);
console.log(
`🔓 Password for "${selectedCredential.title || selectedCredential.url || 'N\\C'}" copied to clipboard!`
);

if (selectedCredential.otpSecret) {
const token = authenticator.generate(selectedCredential.otpSecret);
const timeRemaining = authenticator.timeRemaining();
console.log(`🔢 OTP code: ${token} \u001B[3m(expires in ${timeRemaining} seconds)\u001B[0m`);
}
break;
case 'password':
console.log(selectedCredential.password);
break;
default:
throw new Error('Unable to recognize the output mode.');
}
};

export const getOtp = async (params: GetCredential): Promise<void> => {
const clipboard = new Clipboard();
const selectedCredential = await selectCredential(params, true);

// otpSecret can't be null because onlyOtpCredentials is set to true above
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const token = authenticator.generate(selectedCredential.otpSecret!);
const timeRemaining = authenticator.timeRemaining();
switch (params.output || 'clipboard') {
case 'clipboard':
clipboard.setText(token);
console.log(`🔢 OTP code: ${token} \u001B[3m(expires in ${timeRemaining} seconds)\u001B[0m`);
break;
case 'otp':
console.log(token);
break;
default:
throw new Error('Unable to recognize the output mode.');
}
};
50 changes: 29 additions & 21 deletions src/command-handlers/secureNotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,31 @@ import Database from 'better-sqlite3';
import winston from 'winston';
import { BackupEditTransaction, Secrets, SecureNoteTransactionContent, VaultNote } from '../types';
import { decryptTransactions } from '../modules/crypto';
import { askSecureNoteChoice } from '../utils';
import { askSecureNoteChoice, filterMatches } from '../utils';
import { connectAndPrepare } from '../modules/database';

export const runSecureNote = async (filter: string | null) => {
export const runSecureNote = async (filters: string[] | null, options: { output: 'text' | 'json' }) => {
const { db, secrets } = await connectAndPrepare({});
await getNote({
titleFilter: filter,
filters,
secrets,
output: options.output,
db,
});
db.close();
};

interface GetSecureNote {
titleFilter: string | null;
filters: string[] | null;
secrets: Secrets;
output: 'text' | 'json';
db: Database.Database;
}

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

winston.debug(`Retrieving: ${titleFilter || ''}`);
winston.debug(`Retrieving: ${filters && filters.length > 0 ? filters.join(' ') : ''}`);
const transactions = db
.prepare(`SELECT * FROM transactions WHERE login = ? AND type = 'SECURENOTE' AND action = 'BACKUP_EDIT'`)
.bind(secrets.login)
Expand All @@ -43,22 +45,28 @@ export const getNote = async (params: GetSecureNote): Promise<void> => {
) 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 matchedNotes = filterMatches<VaultNote>(beautifiedNotes, filters, ['title']);

let selectedNote: VaultNote | null = null;
switch (output) {
case 'json':
console.log(JSON.stringify(matchedNotes, null, 4));
break;
case 'text': {
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 {
selectedNote = await askSecureNoteChoice({ matchedNotes, hasFilters: Boolean(titleFilter) });
}
if (!matchedNotes || matchedNotes.length === 0) {
throw new Error('No note found');
} else if (matchedNotes.length === 1) {
selectedNote = matchedNotes[0];
} else {
matchedNotes = matchedNotes?.sort();
selectedNote = await askSecureNoteChoice({ matchedNotes, hasFilters: Boolean(filters?.length) });
}

console.log(selectedNote.content);
console.log(selectedNote.content);
break;
}
default:
throw new Error('Unable to recognize the output mode.');
}
};
10 changes: 9 additions & 1 deletion src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@ export const rootCommands = (params: { program: Command }) => {
.command('note')
.alias('n')
.description('Retrieve a secure note from the local vault and open it')
.argument('[filter]', 'Filter notes based on their title')
.addOption(
new Option('-o, --output <type>', 'How to print the notes. The JSON option outputs all the matching notes')
.choices(['text', 'json'])
.default('text')
)
.argument(
'[filters...]',
'Filter notes based on any parameter using <param>=<value>; if <param> is not specified in the filter, will default to title only'
)
.action(runSecureNote);

accountsCommands({ program });
Expand Down
47 changes: 47 additions & 0 deletions src/utils/filterCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export const filterMatches = <VaultType>(
values: VaultType[] | undefined,
filters: string[] | null,
defaultMatch: string[] = ['url', 'title']
): VaultType[] => {
if (!values) {
return [] as VaultType[];
}

let matches = values;

if (filters?.length) {
interface ItemFilter {
keys: string[];
value: string;
}
const parsedFilters: ItemFilter[] = [];

filters.forEach((filter) => {
const [splitFilterKey, ...splitFilterValues] = filter.split('=');

const filterValue = splitFilterValues.join('=') || splitFilterKey;
const filterKeys = splitFilterValues.length > 0 ? splitFilterKey.split(',') : defaultMatch;

const canonicalFilterValue = filterValue.toLowerCase();

parsedFilters.push({
keys: filterKeys,
value: canonicalFilterValue,
});
});

matches = matches?.filter((item) =>
parsedFilters
.map((filter) =>
filter.keys.map((key) => {
const val = item[key as keyof VaultType];
return typeof val === 'string' && val.toLowerCase().includes(filter.value);
})
)
.flat()
.some((b) => b)
);
}

return matches;
};
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './deviceCredentials';
export * from './dialogs';
export * from './filterCredentials';
export * from './gotImplementation';
export * from './secretParser';
export * from './secretPath';
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,6 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"exclude": ["documentation/components/**/*"]
}

0 comments on commit bd6cb7d

Please sign in to comment.