Security Audit v1 (#414)
This adds a security audit page with automatic checks for: - Reused passwords - Weak passwords - Compromised passwords Storing the audit information in the items themselves. Co-authored-by: Martin Kleinschrodt <martin@maklesoft.com>
This commit is contained in:
parent
2f8c900a3e
commit
8ef09eb7be
|
@ -21,6 +21,7 @@ import "./org-view";
|
|||
import "./settings";
|
||||
import "./generator-view";
|
||||
import "./invite-recipient";
|
||||
import "./audit";
|
||||
import "./support";
|
||||
import "./menu";
|
||||
import { registerPlatformAuthenticator, supportsPlatformAuthenticator } from "@padloc/core/src/platform";
|
||||
|
@ -29,7 +30,8 @@ import { ProvisioningStatus } from "@padloc/core/src/provisioning";
|
|||
import "./rich-content";
|
||||
import { alertDisabledFeature, displayProvisioning, getDefaultStatusLabel } from "../lib/provisioning";
|
||||
import { ItemsView } from "./items";
|
||||
import { wait } from "@padloc/core/src/util";
|
||||
import { wait, throttle } from "@padloc/core/src/util";
|
||||
import { auditVaults } from "../lib/audit";
|
||||
|
||||
@customElement("pl-app")
|
||||
export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLock(Routing(LitElement)))))) {
|
||||
|
@ -59,6 +61,7 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
|
|||
"orgs",
|
||||
"invite",
|
||||
"generator",
|
||||
"audit",
|
||||
"support",
|
||||
];
|
||||
|
||||
|
@ -334,6 +337,8 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
|
|||
|
||||
<pl-generator-view ?hidden=${this._page !== "generator"}></pl-generator-view>
|
||||
|
||||
<pl-audit ?hidden=${this._page !== "audit"}></pl-audit>
|
||||
|
||||
<pl-support ?hidden=${this._page !== "support"}></pl-support>
|
||||
|
||||
<div
|
||||
|
@ -361,6 +366,8 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
|
|||
await app.logout();
|
||||
this.go("start");
|
||||
displayProvisioning(provisioning);
|
||||
} else if (!this.state.locked) {
|
||||
this._scheduleRunAudits();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -555,4 +562,13 @@ export class App extends ServiceWorker(StateMixin(AutoSync(ErrorHandling(AutoLoc
|
|||
target.shadowRoot?.querySelector("pl-totp")) as TOTPElement | null;
|
||||
event.dataTransfer!.setData("text/plain", field.type === "totp" && totp ? totp.token : field.value);
|
||||
}
|
||||
|
||||
private _scheduleRunAudits = throttle(() => {
|
||||
this._maybeRunAudits();
|
||||
}, 30000);
|
||||
|
||||
private async _maybeRunAudits() {
|
||||
const { vaults } = this.state;
|
||||
await auditVaults(vaults, { updateOnlyIfOutdated: true });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
import { translate as $l } from "@padloc/locale/src/translate";
|
||||
import { AuditResultType, VaultItem } from "@padloc/core/src/item";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { css, html } from "lit";
|
||||
import { app } from "../globals";
|
||||
import { StateMixin } from "../mixins/state";
|
||||
import { Routing } from "../mixins/routing";
|
||||
import { View } from "./view";
|
||||
import { descriptionForAudit, iconForAudit, titleTextForAudit } from "../lib/audit";
|
||||
import { Vault } from "@padloc/core/src/vault";
|
||||
|
||||
@customElement("pl-audit")
|
||||
export class Audit extends StateMixin(Routing(View)) {
|
||||
readonly routePattern = /^audit/;
|
||||
|
||||
shouldUpdate() {
|
||||
return !!app.account;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
...View.styles,
|
||||
css`
|
||||
.counts {
|
||||
display: grid;
|
||||
grid-gap: 1em;
|
||||
margin: 1em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.counts {
|
||||
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
render() {
|
||||
let items = app.auditedItems;
|
||||
return html`
|
||||
<div class="fullbleed vertical layout">
|
||||
<header class="padded spacing center-aligning horizontal layout">
|
||||
<pl-button
|
||||
class="transparent skinny menu-button header-title"
|
||||
@click=${() =>
|
||||
this.dispatchEvent(new CustomEvent("toggle-menu", { composed: true, bubbles: true }))}
|
||||
>
|
||||
<div class="half-margined horizontal spacing center-aligning layout text-left-aligning">
|
||||
<pl-icon icon="audit-pass"></pl-icon>
|
||||
<div class="stretch ellipsis">${$l("Security Audit")}</div>
|
||||
</div>
|
||||
</pl-button>
|
||||
</header>
|
||||
<div class="layout padded">
|
||||
<div class="vertical spacing stretch">
|
||||
<pl-scroller class="stretch">
|
||||
<div class="counts">
|
||||
${Object.values(AuditResultType).map((type) =>
|
||||
this._renderSection(
|
||||
items.filter(({ item }) => item.auditResults.some((res) => res.type === type)),
|
||||
type
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</pl-scroller>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSection(listItems: { item: VaultItem; vault: Vault }[], type: AuditResultType) {
|
||||
return html`
|
||||
<section class="box count">
|
||||
<h2 class="bg-dark border-bottom center-aligning spacing horizontal layout">
|
||||
<pl-icon icon="${iconForAudit(type)}" class="left-margined"></pl-icon>
|
||||
<div class="uppercase semibold">${titleTextForAudit(type)}</div>
|
||||
<div class="small bold subtle">${listItems.length}</div>
|
||||
<div class="stretch"></div>
|
||||
<pl-button class="subtle skinny transparent half-margined">
|
||||
<pl-icon icon="info-round"></pl-icon>
|
||||
</pl-button>
|
||||
<pl-popover class="small double-padded max-width-20em"> ${descriptionForAudit(type)} </pl-popover>
|
||||
</h2>
|
||||
${listItems.length
|
||||
? html`
|
||||
<pl-list>
|
||||
${listItems.slice(0, 5).map(
|
||||
(listItem) => html`
|
||||
<div
|
||||
class="list-item hover click"
|
||||
@click=${() => this.go(`items/${listItem.item.id}`)}
|
||||
>
|
||||
<pl-vault-item-list-item
|
||||
.item=${listItem.item}
|
||||
.vault=${listItem.vault}
|
||||
class="none-interactive"
|
||||
></pl-vault-item-list-item>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</pl-list>
|
||||
`
|
||||
: html`
|
||||
<div class="small double-padded subtle text-centering">
|
||||
<pl-icon icon="audit-pass" class="inline"></pl-icon> ${$l("Nothing Found")}
|
||||
</div>
|
||||
`}
|
||||
<pl-button
|
||||
class="slim margined transparent"
|
||||
@click=${() => this.go("items", { audit: type })}
|
||||
?hidden=${listItems.length < 6}
|
||||
>
|
||||
<div>${$l("Show All")}</div>
|
||||
<pl-icon icon="arrow-right" class="small left-margined"></pl-icon>
|
||||
</pl-button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { Field, FieldType, FIELD_DEFS } from "@padloc/core/src/item";
|
||||
import { Field, FieldType, FIELD_DEFS, AuditResult, AuditResultType } from "@padloc/core/src/item";
|
||||
import { translate as $l } from "@padloc/locale/src/translate";
|
||||
import { shared } from "../styles";
|
||||
import "./icon";
|
||||
|
@ -14,6 +14,7 @@ import { css, html, LitElement } from "lit";
|
|||
import { generatePassphrase } from "@padloc/core/src/diceware";
|
||||
import { randomString, charSets } from "@padloc/core/src/util";
|
||||
import { app } from "../globals";
|
||||
import { descriptionForAudit, iconForAudit, titleTextForAudit } from "../lib/audit";
|
||||
|
||||
@customElement("pl-field")
|
||||
export class FieldElement extends LitElement {
|
||||
|
@ -29,6 +30,9 @@ export class FieldElement extends LitElement {
|
|||
@property({ type: Boolean })
|
||||
canMoveDown: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
auditResults: AuditResult[] = [];
|
||||
|
||||
@state()
|
||||
private _masked: boolean = false;
|
||||
|
||||
|
@ -162,11 +166,14 @@ export class FieldElement extends LitElement {
|
|||
css`
|
||||
:host {
|
||||
display: block;
|
||||
opacity: 0.999;
|
||||
position: relative;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
:host(.dragging) {
|
||||
opacity: 0.999;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
--input-padding: 0.3em;
|
||||
margin: 0.2em 0;
|
||||
|
@ -391,6 +398,31 @@ export class FieldElement extends LitElement {
|
|||
>
|
||||
<div class="spacer" slot="before"></div>
|
||||
<pl-icon icon="${this._fieldDef.icon}" slot="before"></pl-icon>
|
||||
${Object.values(AuditResultType).map((type) =>
|
||||
this.auditResults.some((res) => res.type == type)
|
||||
? html`
|
||||
<pl-icon
|
||||
icon="${iconForAudit(type)}"
|
||||
slot="after"
|
||||
class="negative highlighted right-margined"
|
||||
title=${titleTextForAudit(type)}
|
||||
style="cursor: help;"
|
||||
></pl-icon>
|
||||
<pl-popover
|
||||
trigger="hover"
|
||||
class="double-padded max-width-20em"
|
||||
style="text-transform: none; color: var(--color-foreground); pointer-events: none;"
|
||||
slot="after"
|
||||
>
|
||||
<div class="large bold">
|
||||
<pl-icon icon="${iconForAudit(type)}" class="inline"></pl-icon>
|
||||
${titleTextForAudit(type)}
|
||||
</div>
|
||||
<div class="top-margined">${descriptionForAudit(type)}</div>
|
||||
</pl-popover>
|
||||
`
|
||||
: ""
|
||||
)}
|
||||
</pl-input>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -557,6 +557,26 @@ export class PlIcon extends LitElement {
|
|||
:host([icon="frozen"]) > div::before {
|
||||
content: "\\f2dc";
|
||||
}
|
||||
|
||||
:host([icon="audit-pass"]) > div::before {
|
||||
content: "\\f2f7";
|
||||
}
|
||||
|
||||
:host([icon="audit-fail"]) > div::before {
|
||||
content: "\\e24c";
|
||||
}
|
||||
|
||||
:host([icon="weak"]) > div::before {
|
||||
content: "\\f4bb";
|
||||
}
|
||||
|
||||
:host([icon="reused"]) > div::before {
|
||||
content: "\\f1b8";
|
||||
}
|
||||
|
||||
:host([icon="compromised"]) > div::before {
|
||||
content: "\\f21b";
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ import "./attachment";
|
|||
import { customElement, property, query, queryAll, state } from "lit/decorators.js";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { checkFeatureDisabled } from "../lib/provisioning";
|
||||
import { auditVaults } from "../lib/audit";
|
||||
|
||||
@customElement("pl-item-view")
|
||||
export class ItemView extends Routing(StateMixin(LitElement)) {
|
||||
|
@ -395,11 +396,14 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
|
|||
(field) => `${this.itemId}_${field.name}_${field.type}`,
|
||||
(field: Field, index: number) => html`
|
||||
<pl-field
|
||||
class="animated padded list-item"
|
||||
class="padded list-item"
|
||||
.canMoveUp=${!!index}
|
||||
.canMoveDown=${index < this._fields.length - 1}
|
||||
.field=${field}
|
||||
.editing=${this._editing}
|
||||
.auditResults=${this._item?.auditResults.filter(
|
||||
(auditResult) => auditResult.fieldIndex === index
|
||||
) || []}
|
||||
@copy-clipboard=${() => this._copyToClipboard(this._item!, field)}
|
||||
@remove=${() => this._removeField(index)}
|
||||
@generate=${() => this._generateValue(index)}
|
||||
|
@ -512,7 +516,10 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
|
|||
name: this._nameInput.value,
|
||||
fields: [...this._fieldInputs].map((fieldEl: FieldElement) => fieldEl.field),
|
||||
tags: this._tagsInput.tags,
|
||||
auditResults: [],
|
||||
lastAudited: undefined,
|
||||
});
|
||||
auditVaults([this._vault!], { updateOnlyItemWithId: this._item!.id });
|
||||
this.go(`items/${this.itemId}`, undefined, undefined, true);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { VaultItem, Field, Tag } from "@padloc/core/src/item";
|
||||
import { VaultItem, Field, Tag, AuditResultType } from "@padloc/core/src/item";
|
||||
import { Vault, VaultID } from "@padloc/core/src/vault";
|
||||
import { translate as $l } from "@padloc/locale/src/translate";
|
||||
import { debounce, wait, escapeRegex, truncate } from "@padloc/core/src/util";
|
||||
|
@ -20,8 +20,10 @@ import { customElement, property, query, queryAll, state } from "lit/decorators.
|
|||
import { css, html, LitElement } from "lit";
|
||||
import { cache } from "lit/directives/cache.js";
|
||||
import { Button } from "./button";
|
||||
import "./item-icon";
|
||||
import { iconForAudit, noItemsTextForAudit, titleTextForAudit } from "../lib/audit";
|
||||
|
||||
interface ListItem {
|
||||
export interface ListItem {
|
||||
item: VaultItem;
|
||||
vault: Vault;
|
||||
section?: string;
|
||||
|
@ -37,6 +39,7 @@ export interface ItemsFilter {
|
|||
attachments?: boolean;
|
||||
recent?: boolean;
|
||||
host?: boolean;
|
||||
audit?: AuditResultType;
|
||||
}
|
||||
|
||||
function filterByString(fs: string, rec: VaultItem) {
|
||||
|
@ -48,7 +51,6 @@ function filterByString(fs: string, rec: VaultItem) {
|
|||
.toLowerCase();
|
||||
return content.search(escapeRegex(fs.toLowerCase())) !== -1;
|
||||
}
|
||||
import "./item-icon";
|
||||
|
||||
@customElement("pl-vault-item-list-item")
|
||||
export class VaultItemListItem extends LitElement {
|
||||
|
@ -266,6 +268,10 @@ export class VaultItemListItem extends LitElement {
|
|||
:host(:not(:hover)) .move-right-button {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host(.none-interactive) * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
@ -629,7 +635,7 @@ export class ItemsList extends StateMixin(LitElement) {
|
|||
];
|
||||
|
||||
render() {
|
||||
const { favorites, recent, attachments, host, vault: vaultId, tag } = this.filter || {};
|
||||
const { favorites, recent, attachments, host, vault: vaultId, tag, audit } = this.filter || {};
|
||||
const placeholder = this._listItems.length
|
||||
? {}
|
||||
: this._filterShowing
|
||||
|
@ -657,6 +663,11 @@ export class ItemsList extends StateMixin(LitElement) {
|
|||
icon: "time",
|
||||
text: $l("You don't have any recently used items!"),
|
||||
}
|
||||
: audit
|
||||
? {
|
||||
icon: "audit-pass",
|
||||
text: noItemsTextForAudit(audit),
|
||||
}
|
||||
: {
|
||||
icon: "vaults",
|
||||
text: $l("You don't have any items yet."),
|
||||
|
@ -682,6 +693,8 @@ export class ItemsList extends StateMixin(LitElement) {
|
|||
? { title: vault.name, superTitle: org ? org.name : "", icon: "vaults" }
|
||||
: tag
|
||||
? { title: tag, superTitle: "", icon: "tags" }
|
||||
: audit
|
||||
? { title: titleTextForAudit(audit), superTitle: "", icon: iconForAudit(audit) }
|
||||
: { title: $l("All Vaults"), superTitle: "", icon: "vaults" };
|
||||
|
||||
return html`
|
||||
|
@ -914,7 +927,7 @@ export class ItemsList extends StateMixin(LitElement) {
|
|||
}
|
||||
|
||||
private _getItems(): ListItem[] {
|
||||
const { vault: vaultId, tag, favorites, attachments, recent, host } = this.filter || {};
|
||||
const { vault: vaultId, tag, favorites, attachments, recent, host, audit } = this.filter || {};
|
||||
const filter = (this._filterInput && this._filterInput.value) || "";
|
||||
const recentThreshold = new Date(Date.now() - app.settings.recentLimit * 24 * 60 * 60 * 1000);
|
||||
|
||||
|
@ -939,7 +952,8 @@ export class ItemsList extends StateMixin(LitElement) {
|
|||
(!favorites || app.account!.favorites.has(item.id)) &&
|
||||
(!attachments || !!item.attachments.length) &&
|
||||
(!recent ||
|
||||
(app.state.lastUsed.has(item.id) && app.state.lastUsed.get(item.id)! > recentThreshold))
|
||||
(app.state.lastUsed.has(item.id) && app.state.lastUsed.get(item.id)! > recentThreshold)) &&
|
||||
(!audit || item.auditResults.some((auditResult) => auditResult.type === audit))
|
||||
) {
|
||||
items.push({
|
||||
vault,
|
||||
|
|
|
@ -6,6 +6,7 @@ import "./item-view";
|
|||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { html } from "lit";
|
||||
import { wait } from "@padloc/core/src/util";
|
||||
import { AuditResultType } from "@padloc/core/src/item";
|
||||
|
||||
@customElement("pl-items")
|
||||
export class ItemsView extends Routing(StateMixin(View)) {
|
||||
|
@ -22,7 +23,7 @@ export class ItemsView extends Routing(StateMixin(View)) {
|
|||
|
||||
async handleRoute(
|
||||
[id]: [string],
|
||||
{ vault, tag, favorites, attachments, recent, host, search }: { [prop: string]: string }
|
||||
{ vault, tag, favorites, attachments, recent, host, search, audit }: { [prop: string]: string }
|
||||
) {
|
||||
this.filter = {
|
||||
vault,
|
||||
|
@ -31,6 +32,7 @@ export class ItemsView extends Routing(StateMixin(View)) {
|
|||
attachments: attachments === "true",
|
||||
recent: recent === "true",
|
||||
host: host === "true",
|
||||
audit: audit as AuditResultType,
|
||||
};
|
||||
this.selected = id;
|
||||
|
||||
|
|
|
@ -558,6 +558,18 @@ export class Menu extends Routing(StateMixin(LitElement)) {
|
|||
<div class="stretch">${$l("Password Generator")}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="menu-item"
|
||||
@click=${() => this._goTo("audit")}
|
||||
aria-selected=${this.selected === "audit"}
|
||||
>
|
||||
<pl-icon icon="audit-pass"></pl-icon>
|
||||
|
||||
<div class="stretch">${$l("Security Audit")}</div>
|
||||
|
||||
${count.audit ? html` <div class="small negative highlighted">${count.audit}</div> ` : ""}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="menu-item"
|
||||
@click=${() => this._goTo("support")}
|
||||
|
|
|
@ -22,7 +22,7 @@ export async function request(
|
|||
new Err(
|
||||
ErrorCode.FAILED_CONNECTION,
|
||||
$l(
|
||||
"The app could not establish a connection with the our servers, please check your internet connection or try again later!"
|
||||
"The app could not establish a connection with our servers, please check your internet connection or try again later!"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
@ -0,0 +1,255 @@
|
|||
import { HashParams } from "@padloc/core/src/crypto";
|
||||
import { bytesToHex, stringToBytes } from "@padloc/core/src/encoding";
|
||||
import { AuditResult, AuditResultType, FieldType } from "@padloc/core/src/item";
|
||||
import { getCryptoProvider } from "@padloc/core/src/platform";
|
||||
import { Vault } from "@padloc/core/src/vault";
|
||||
import { $l } from "@padloc/locale/src/translate";
|
||||
import { sub } from "date-fns";
|
||||
import { ListItem } from "../elements/items-list";
|
||||
import { app } from "../globals";
|
||||
import { passwordStrength } from "./util";
|
||||
|
||||
async function sha1(password: string) {
|
||||
const hashedPasswordData = await getCryptoProvider().hash(
|
||||
stringToBytes(password),
|
||||
new HashParams({ algorithm: "SHA-1" })
|
||||
);
|
||||
const hashedPassword = bytesToHex(hashedPasswordData);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
async function isPasswordWeak(password: string) {
|
||||
const { score } = await passwordStrength(password);
|
||||
|
||||
return score < 2;
|
||||
}
|
||||
|
||||
async function hasPasswordBeenCompromised(passwordHash: string) {
|
||||
const hashPrefix = passwordHash.slice(0, 5);
|
||||
|
||||
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`);
|
||||
|
||||
const result = await response.text();
|
||||
|
||||
const matchingHashSuffixes = result.split("\r\n");
|
||||
|
||||
for (const matchingHashSuffix of matchingHashSuffixes) {
|
||||
const fullLowercaseHash = `${hashPrefix}${matchingHashSuffix.toLowerCase().split(":")[0]}`;
|
||||
|
||||
if (fullLowercaseHash === passwordHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function auditVaults(
|
||||
vaults: Vault[],
|
||||
{
|
||||
updateOnlyItemWithId,
|
||||
updateOnlyIfOutdated,
|
||||
}: { updateOnlyItemWithId?: string; updateOnlyIfOutdated?: boolean } = {}
|
||||
) {
|
||||
const reusedPasswords: ListItem[] = [];
|
||||
const weakPasswords: ListItem[] = [];
|
||||
const compromisedPasswords: ListItem[] = [];
|
||||
|
||||
// Don't try to run if the app has locked
|
||||
if (app.state.locked) {
|
||||
return {
|
||||
reusedPasswords,
|
||||
weakPasswords,
|
||||
compromisedPasswords,
|
||||
};
|
||||
}
|
||||
|
||||
const usedPasswordHashCounts = new Map<string, number>();
|
||||
const reusedPasswordItemIds: Set<string> = new Set();
|
||||
const weakPasswordItemIds: Set<string> = new Set();
|
||||
const compromisedPasswordItemIds: Set<string> = new Set();
|
||||
|
||||
// We need to do a run once for all the password hashes, to calculate reused afterwards, otherwise order can become a problem
|
||||
for (const vault of vaults) {
|
||||
for (const item of vault.items) {
|
||||
const passwordFields = item.fields
|
||||
.map((field, fieldIndex) => ({ field, fieldIndex }))
|
||||
.filter((field) => field.field.type === FieldType.Password)
|
||||
.filter((field) => Boolean(field.field.value));
|
||||
|
||||
for (const passwordField of passwordFields) {
|
||||
const passwordHash = await sha1(passwordField.field.value);
|
||||
|
||||
const currentPasswordHashCount = usedPasswordHashCounts.get(passwordHash) || 0;
|
||||
|
||||
usedPasswordHashCounts.set(passwordHash, currentPasswordHashCount + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oneWeekAgo = sub(new Date(), { weeks: 1 });
|
||||
|
||||
let resultsFound = false;
|
||||
|
||||
for (const vault of vaults) {
|
||||
let vaultResultsFound = false;
|
||||
|
||||
for (const item of vault.items) {
|
||||
if (updateOnlyItemWithId) {
|
||||
if (item.id !== updateOnlyItemWithId) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (updateOnlyIfOutdated && item.lastAudited && item.lastAudited >= oneWeekAgo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const passwordFields = item.fields
|
||||
.map((field, fieldIndex) => ({ field, fieldIndex }))
|
||||
.filter((field) => field.field.type === FieldType.Password)
|
||||
.filter((field) => Boolean(field.field.value));
|
||||
|
||||
// If an item had password fields that failed audits and were since removed, we need to run the audit again to clear and update it
|
||||
const itemHasFailedAudits = (item.auditResults || []).length > 0;
|
||||
|
||||
if (passwordFields.length === 0 && !itemHasFailedAudits) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auditResults: AuditResult[] = [];
|
||||
|
||||
for (const passwordField of passwordFields) {
|
||||
const passwordHash = await sha1(passwordField.field.value);
|
||||
|
||||
// Perform reused audit (can't skip as it's interdependent)
|
||||
if (usedPasswordHashCounts.get(passwordHash)! > 1) {
|
||||
// Don't add the same item twice to the list, if there are more than one reused password fields in it
|
||||
if (!reusedPasswordItemIds.has(item.id)) {
|
||||
reusedPasswords.push({ item, vault });
|
||||
reusedPasswordItemIds.add(item.id);
|
||||
}
|
||||
|
||||
auditResults.push({
|
||||
type: AuditResultType.ReusedPassword,
|
||||
fieldIndex: passwordField.fieldIndex,
|
||||
});
|
||||
|
||||
vaultResultsFound = true;
|
||||
}
|
||||
|
||||
// Perform weak audit
|
||||
const isThisPasswordWeak = await isPasswordWeak(passwordField.field.value);
|
||||
if (isThisPasswordWeak) {
|
||||
// Don't add the same item twice to the list, if there are more than one weak password fields in it
|
||||
if (!weakPasswordItemIds.has(item.id)) {
|
||||
weakPasswords.push({ item, vault });
|
||||
weakPasswordItemIds.add(item.id);
|
||||
}
|
||||
|
||||
auditResults.push({
|
||||
type: AuditResultType.WeakPassword,
|
||||
fieldIndex: passwordField.fieldIndex,
|
||||
});
|
||||
|
||||
vaultResultsFound = true;
|
||||
}
|
||||
|
||||
// Perform compromised audit
|
||||
const isPasswordCompromised = await hasPasswordBeenCompromised(passwordHash);
|
||||
if (isPasswordCompromised) {
|
||||
// Don't add the same item twice to the list, if there are more than one compromised password fields in it
|
||||
if (!compromisedPasswordItemIds.has(item.id)) {
|
||||
compromisedPasswords.push({ item, vault });
|
||||
compromisedPasswordItemIds.add(item.id);
|
||||
}
|
||||
|
||||
auditResults.push({
|
||||
type: AuditResultType.CompromisedPassword,
|
||||
fieldIndex: passwordField.fieldIndex,
|
||||
});
|
||||
|
||||
vaultResultsFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
item.auditResults = auditResults;
|
||||
item.lastAudited = new Date();
|
||||
vault.items.update(item);
|
||||
}
|
||||
|
||||
if (!vaultResultsFound) {
|
||||
await app.saveVault(vault);
|
||||
}
|
||||
|
||||
resultsFound = resultsFound || vaultResultsFound;
|
||||
}
|
||||
|
||||
if (resultsFound) {
|
||||
await app.save();
|
||||
}
|
||||
|
||||
return {
|
||||
reusedPasswords,
|
||||
weakPasswords,
|
||||
compromisedPasswords,
|
||||
};
|
||||
}
|
||||
|
||||
export function noItemsTextForAudit(type: AuditResultType) {
|
||||
switch (type) {
|
||||
case AuditResultType.WeakPassword:
|
||||
return $l("You don't have any items with weak passwords!");
|
||||
case AuditResultType.ReusedPassword:
|
||||
return $l("You don't have any items with reused passwords!");
|
||||
case AuditResultType.CompromisedPassword:
|
||||
return $l("You don't have any items with compromised passwords!");
|
||||
default:
|
||||
return $l("You don't have any insecure items!");
|
||||
}
|
||||
}
|
||||
|
||||
export function titleTextForAudit(type: AuditResultType) {
|
||||
switch (type) {
|
||||
case AuditResultType.WeakPassword:
|
||||
return $l("Weak Passwords");
|
||||
case AuditResultType.ReusedPassword:
|
||||
return $l("Reused Passwords");
|
||||
case AuditResultType.CompromisedPassword:
|
||||
return $l("Compromised Passwords");
|
||||
default:
|
||||
return $l("Insecure");
|
||||
}
|
||||
}
|
||||
|
||||
export function iconForAudit(type: AuditResultType) {
|
||||
switch (type) {
|
||||
case AuditResultType.WeakPassword:
|
||||
return "weak";
|
||||
case AuditResultType.ReusedPassword:
|
||||
return "reused";
|
||||
case AuditResultType.CompromisedPassword:
|
||||
return "compromised";
|
||||
default:
|
||||
return "audit-fail";
|
||||
}
|
||||
}
|
||||
|
||||
export function descriptionForAudit(type: AuditResultType) {
|
||||
switch (type) {
|
||||
case AuditResultType.WeakPassword:
|
||||
return $l(
|
||||
"Passwords are considered weak if they're too short, don't have a lot of variation or contain commonly used words or phrases. These passwords generally don't offer enough protection against automated guessing attempts and should be replaced with strong, randomly generated passwords."
|
||||
);
|
||||
case AuditResultType.ReusedPassword:
|
||||
return $l(
|
||||
"Using the same password in multiple places is strongly discouraged as a data leak in one of those places will automatically compromise all other accounts/logins using the same password. We recommend generating strong, random and unique passwords for every single vault item."
|
||||
);
|
||||
case AuditResultType.CompromisedPassword:
|
||||
return $l(
|
||||
"Compromised passwords are those that have been identified as having been leaked in the past by comparing them against a database of known data breaches. These passwords can no longer be considered secure and should be changed immediately."
|
||||
);
|
||||
default:
|
||||
"";
|
||||
}
|
||||
}
|
|
@ -71,7 +71,7 @@ module.exports = {
|
|||
meta: {
|
||||
"Content-Security-Policy": {
|
||||
"http-equiv": "Content-Security-Policy",
|
||||
content: `default-src 'self' ${process.env.PL_SERVER_URL} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
content: `default-src 'self' ${process.env.PL_SERVER_URL} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Serializable, Serialize, AsDate, AsSerializable, bytesToBase64, stringT
|
|||
import { Invite, InvitePurpose } from "./invite";
|
||||
import { Vault, VaultID } from "./vault";
|
||||
import { Org, OrgID, OrgMember, OrgRole, Group, UnlockedOrg, OrgInfo } from "./org";
|
||||
import { VaultItem, VaultItemID, Field, Tag, createVaultItem } from "./item";
|
||||
import { VaultItem, VaultItemID, Field, Tag, createVaultItem, AuditResult } from "./item";
|
||||
import { Account, AccountID, UnlockedAccount } from "./account";
|
||||
import { Auth } from "./auth";
|
||||
import { Session, SessionID } from "./session";
|
||||
|
@ -367,6 +367,18 @@ export class App {
|
|||
return !!this.state.rememberedMasterKey;
|
||||
}
|
||||
|
||||
get auditedItems() {
|
||||
let items: { item: VaultItem; vault: Vault }[] = [];
|
||||
for (const vault of this.vaults) {
|
||||
for (const item of vault.items) {
|
||||
if (item.auditResults?.length) {
|
||||
items.push({ item, vault });
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
get count() {
|
||||
const count = {
|
||||
favorites: 0,
|
||||
|
@ -376,6 +388,7 @@ export class App {
|
|||
currentHost: this.state.context.browser?.url
|
||||
? this.getItemsForUrl(this.state.context.browser.url).length
|
||||
: 0,
|
||||
audit: 0,
|
||||
};
|
||||
|
||||
const recentThreshold = new Date(Date.now() - this.settings.recentLimit * 24 * 60 * 60 * 1000);
|
||||
|
@ -391,6 +404,9 @@ export class App {
|
|||
if (this.state.lastUsed.has(item.id) && this.state.lastUsed.get(item.id)! > recentThreshold) {
|
||||
count.recent++;
|
||||
}
|
||||
if (item.auditResults?.length) {
|
||||
count.audit++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1440,6 +1456,8 @@ export class App {
|
|||
fields?: Field[];
|
||||
tags?: Tag[];
|
||||
attachments?: AttachmentInfo[];
|
||||
auditResults?: AuditResult[];
|
||||
lastAudited?: Date;
|
||||
}
|
||||
) {
|
||||
const { vault } = this.getItem(item.id)!;
|
||||
|
|
|
@ -253,6 +253,17 @@ export function normalizeTag(tag: string): Tag {
|
|||
return tag.replace(",", "");
|
||||
}
|
||||
|
||||
export enum AuditResultType {
|
||||
WeakPassword = "weak_password",
|
||||
ReusedPassword = "reused_password",
|
||||
CompromisedPassword = "compromised_password",
|
||||
}
|
||||
|
||||
export interface AuditResult {
|
||||
type: AuditResultType;
|
||||
fieldIndex: number;
|
||||
}
|
||||
|
||||
/** Represents an entry within a vault */
|
||||
export class VaultItem extends Serializable {
|
||||
constructor(vals: Partial<VaultItem> = {}) {
|
||||
|
@ -292,6 +303,11 @@ export class VaultItem extends Serializable {
|
|||
/** attachments associated with this item */
|
||||
@AsSerializable(AttachmentInfo)
|
||||
attachments: AttachmentInfo[] = [];
|
||||
|
||||
auditResults: AuditResult[] = [];
|
||||
|
||||
@AsDate()
|
||||
lastAudited?: Date;
|
||||
}
|
||||
|
||||
/** Creates a new vault item */
|
||||
|
|
|
@ -78,7 +78,7 @@ module.exports = {
|
|||
meta: {
|
||||
"Content-Security-Policy": {
|
||||
"http-equiv": "Content-Security-Policy",
|
||||
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: *`,
|
||||
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: *`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -72,7 +72,7 @@ module.exports = {
|
|||
meta: {
|
||||
"Content-Security-Policy": {
|
||||
"http-equiv": "Content-Security-Policy",
|
||||
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -65,7 +65,7 @@ module.exports = {
|
|||
meta: {
|
||||
"Content-Security-Policy": {
|
||||
"http-equiv": "Content-Security-Policy",
|
||||
content: `default-src 'self' ${serverUrl} blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
content: `default-src 'self' ${serverUrl} https://api.pwnedpasswords.com blob:; style-src 'self' 'unsafe-inline'; object-src 'self' blob:; frame-src 'self'; img-src 'self' blob: data: https:;`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
Loading…
Reference in New Issue