Create new pwa package to separate webpack builds from ui package

This commit is contained in:
Martin Kleinschrodt 2019-10-12 09:44:12 +02:00
parent e61fc47f99
commit b5b8b6603f
31 changed files with 13209 additions and 10913 deletions

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ packages/electron/build
packages/electron/app
packages/electron/dist
*.env
packages/pwa/dist

View File

@ -2,5 +2,5 @@
"packages": [
"packages/*"
],
"version": "0.0.0"
"version": "3.0.5"
}

View File

@ -1,5 +1,5 @@
{
"name": "padlock",
"name": "padloc",
"private": true,
"version": "3.0.4",
"description": "A minimalist password manager",

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
{
"name": "@padloc/app",
"version": "3.0.5",
"main": "src/elements/app.js",
"author": "Martin Kleinschrodt <martin@maklesoft.com>",
"license": "GPL-3.0",
"dependencies": {
@ -30,29 +29,8 @@
"@types/workbox-window": "^4.3.1",
"@types/zxcvbn": "^4.4.0",
"chai": "^4.2.0",
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.0.0",
"favicons-webpack-plugin": "^1.0.1",
"file-loader": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"mocha": "^5.2.0",
"reflect-metadata": "^0.1.12",
"style-loader": "^1.0.0",
"ts-loader": "^6.0.4",
"ts-node": "^7.0.1",
"typescript": "^3.5.2",
"wct-mocha": "^1.0.0",
"webpack": "^4.35.3",
"webpack-cli": "^3.3.5",
"webpack-dev-server": "^3.7.2",
"webpack-pwa-manifest": "^4.0.0",
"workbox-cli": "^4.3.1",
"workbox-webpack-plugin": "^5.0.0-alpha.0"
"reflect-metadata": "^0.1.12"
},
"description": "Padloc UI",
"scripts": {
"build": "webpack",
"start": "http-server dist -s -p $PL_CLIENT_PORT --proxy $PL_CLIENT_URL?",
"dev": "webpack-dev-server"
}
"description": "Padloc Web-Based UI package"
}

View File

@ -0,0 +1,22 @@
import { App } from "@padloc/core/src/app";
import { Router } from "./lib/route";
import { AjaxSender } from "./lib/ajax";
import { LocalStorage } from "./lib/storage";
const sender = new AjaxSender(process.env.PL_SERVER_URL!);
const billingEnabled = process.env.PL_BILLING_ENABLED === "true";
const disablePayment = process.env.PL_BILLING_DISABLE_PAYMENT === "true";
const stripePublicKey = process.env.PL_BILLING_STRIPE_PUBLIC_KEY || "";
if (billingEnabled && !disablePayment && !stripePublicKey) {
throw "Billing enabled but no stripe public key provided!";
}
const billingConfig = billingEnabled
? {
disablePayment,
stripePublicKey
}
: undefined;
export const app = (window.app = new App(new LocalStorage(), sender, billingConfig));
export const router = (window.router = new Router());

View File

@ -1,5 +0,0 @@
import { setPlatform } from "@padloc/core/src/platform";
import { WebPlatform } from "./lib/platform";
import "./elements/app";
setPlatform(new WebPlatform());

View File

@ -0,0 +1,61 @@
import { Err, ErrorCode } from "@padloc/core/src/error";
import { marshal, unmarshal } from "@padloc/core/src/encoding";
import { Request, Response, Sender, RequestProgress } from "@padloc/core/src/transport";
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export async function request(
method: Method,
url: string,
body?: string,
headers?: Map<string, string>,
progress?: RequestProgress
): Promise<XMLHttpRequest> {
let req = new XMLHttpRequest();
return new Promise<XMLHttpRequest>((resolve, reject) => {
req.onreadystatechange = () => {
if (req.readyState === 4) {
if (!req.status) {
reject(new Err(ErrorCode.FAILED_CONNECTION));
} else {
resolve(req);
}
}
};
try {
req.open(method, url, true);
if (headers) {
headers.forEach((value, key) => req.setRequestHeader(key, value));
}
if (progress) {
req.onprogress = (pg: { loaded: number; total: number }) => (progress.downloadProgress = pg);
req.upload.onprogress = (pg: { loaded: number; total: number }) => (progress.uploadProgress = pg);
}
req.send(body);
} catch (e) {
reject(new Err(ErrorCode.FAILED_CONNECTION));
}
});
}
export class AjaxSender implements Sender {
constructor(public url: string) {}
async send(req: Request, progress?: RequestProgress): Promise<Response> {
const body = marshal(req.toRaw());
const res = await request(
"POST",
this.url,
body,
new Map<string, string>([["Content-Type", "application/json"], ["Accept", "application/json"]]),
progress
);
try {
return new Response().fromRaw(unmarshal(res.responseText));
} catch (e) {
throw new Err(ErrorCode.SERVER_ERROR);
}
}
}

View File

@ -0,0 +1,40 @@
const defaults = {
animation: "slideIn",
duration: 500,
easing: "ease",
delay: 0,
fill: "backwards",
initialDelay: 0,
fullDuration: 1000,
clear: false,
direction: "normal"
};
const clearAnimation = new Map<HTMLElement, number>();
export function animateElement(el: HTMLElement, opts = {}) {
const { animation, duration, direction, easing, delay, fill, clear } = Object.assign({}, defaults, opts);
clearTimeout(clearAnimation.get(el));
el.style.animation = "";
el.offsetLeft;
el.style.animation = `${animation} ${direction} ${duration}ms ${easing} ${delay}ms ${fill}`;
if (clear) {
const clearDelay = typeof clear === "number" ? clear : 0;
clearAnimation.set(el, window.setTimeout(() => (el.style.animation = ""), delay + duration + clearDelay));
}
return new Promise(resolve => setTimeout(resolve, delay + duration));
}
export function animateCascade(nodes: Iterable<Node | Element>, opts = {}) {
const els = Array.from(nodes);
const { fullDuration, duration, initialDelay } = Object.assign({}, defaults, opts);
const dt = Math.max(30, Math.floor((fullDuration - duration) / els.length));
const promises = [];
for (const [i, e] of els.entries()) {
promises.push(animateElement(e as HTMLElement, Object.assign(opts, { delay: initialDelay + i * dt })));
}
return Promise.all(promises);
}

View File

@ -0,0 +1,14 @@
import { VaultItem, Field } from "@padloc/core/src/item";
import "../elements/clipboard";
import { Clipboard } from "../elements/clipboard";
import { getSingleton } from "./singleton"
export async function setClipboard(item: VaultItem, field: Field, duration?: number) {
const singleton = getSingleton("pl-clipboard") as Clipboard;
return singleton.set(item, field, duration);
}
export function clearClipboard() {
const singleton = getSingleton("pl-clipboard") as Clipboard;
singleton.clear();
}

View File

@ -0,0 +1,272 @@
import {
CryptoProvider,
PBKDF2Params,
AESKey,
RSAPublicKey,
RSAPrivateKey,
HMACKey,
SymmetricKey,
AESKeyParams,
RSAKeyParams,
HMACParams,
HMACKeyParams,
AESEncryptionParams,
RSAEncryptionParams,
HashParams,
RSASigningParams
} from "@padloc/core/src/crypto";
import { Err, ErrorCode } from "@padloc/core/src/error";
import SJCLProvider from "@padloc/core/src/sjcl";
const webCrypto = window.crypto && window.crypto.subtle;
export class WebCryptoProvider implements CryptoProvider {
async randomBytes(n: number): Promise<Uint8Array> {
const bytes = window.crypto.getRandomValues(new Uint8Array(n));
return bytes;
}
async hash(input: Uint8Array, params: HashParams): Promise<Uint8Array> {
const bytes = await webCrypto.digest({ name: params.algorithm }, input);
return new Uint8Array(bytes);
}
generateKey(params: AESKeyParams): Promise<AESKey>;
generateKey(params: RSAKeyParams): Promise<{ privateKey: RSAPrivateKey; publicKey: RSAPublicKey }>;
generateKey(params: HMACKeyParams): Promise<HMACKey>;
async generateKey(params: AESKeyParams | RSAKeyParams | HMACKeyParams) {
switch (params.algorithm) {
case "AES":
case "HMAC":
return this.randomBytes(params.keySize / 8);
case "RSA":
const keyPair = await webCrypto.generateKey(Object.assign(params, { name: "RSA-OAEP" }), true, [
"encrypt",
"decrypt"
]);
const privateKey = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
const publicKey = await crypto.subtle.exportKey("spki", keyPair.publicKey);
return {
privateKey: new Uint8Array(privateKey),
publicKey: new Uint8Array(publicKey)
};
// case "HMAC":
// const key = await webCrypto.generateKey(Object.assign({}, params, { name: params.algorithm }), true, [
// "sign",
// "verify"
// ]);
// const raw = await webCrypto.exportKey("raw", key);
// return new Uint8Array(raw));
}
}
async deriveKey(password: Uint8Array, params: PBKDF2Params): Promise<SymmetricKey> {
const baseKey = await webCrypto.importKey("raw", password, params.algorithm, false, ["deriveBits"]);
const key = await webCrypto.deriveBits(
{
name: params.algorithm,
salt: params.salt!,
iterations: params.iterations,
hash: params.hash
},
baseKey,
params.keySize
);
return new Uint8Array(key);
}
encrypt(key: AESKey, data: Uint8Array, params: AESEncryptionParams): Promise<Uint8Array>;
encrypt(publicKey: RSAPublicKey, data: Uint8Array, params: RSAEncryptionParams): Promise<Uint8Array>;
async encrypt(
key: AESKey | RSAPublicKey,
data: Uint8Array,
params: AESEncryptionParams | RSAEncryptionParams
): Promise<Uint8Array> {
switch (params.algorithm) {
case "AES-GCM":
case "AES-CCM":
return this._encryptAES(key, data, params);
case "RSA-OAEP":
return this._encryptRSA(key, data, params);
default:
throw new Err(ErrorCode.INVALID_ENCRYPTION_PARAMS);
}
}
decrypt(key: AESKey, data: Uint8Array, params: AESEncryptionParams): Promise<Uint8Array>;
decrypt(publicKey: RSAPublicKey, data: Uint8Array, params: RSAEncryptionParams): Promise<Uint8Array>;
async decrypt(
key: AESKey | RSAPublicKey,
data: Uint8Array,
params: AESEncryptionParams | RSAEncryptionParams
): Promise<Uint8Array> {
switch (params.algorithm) {
case "AES-GCM":
case "AES-CCM":
return this._decryptAES(key, data, params);
case "RSA-OAEP":
return this._decryptRSA(key, data, params);
default:
throw new Err(ErrorCode.INVALID_ENCRYPTION_PARAMS);
}
}
private async _encryptAES(key: AESKey, data: Uint8Array, params: AESEncryptionParams): Promise<Uint8Array> {
if (params.algorithm === "AES-CCM") {
return SJCLProvider.encrypt(key, data, params);
}
const k = await webCrypto.importKey("raw", key, params.algorithm, false, ["encrypt"]);
try {
const buf = await webCrypto.encrypt(
{
name: params.algorithm,
iv: params.iv,
additionalData: params.additionalData,
tagLength: params.tagSize
},
k,
data
);
return new Uint8Array(buf);
} catch (e) {
throw new Err(ErrorCode.ENCRYPTION_FAILED);
}
}
private async _decryptAES(key: AESKey, data: Uint8Array, params: AESEncryptionParams): Promise<Uint8Array> {
if (params.algorithm === "AES-CCM") {
return SJCLProvider.decrypt(key, data, params);
}
const k = await webCrypto.importKey("raw", key, params.algorithm, false, ["decrypt"]);
try {
const buf = await webCrypto.decrypt(
{
name: params.algorithm,
iv: params.iv!,
additionalData: params.additionalData!,
tagLength: params.tagSize
},
k,
data
);
return new Uint8Array(buf);
} catch (e) {
throw new Err(ErrorCode.DECRYPTION_FAILED);
}
}
async _encryptRSA(publicKey: RSAPublicKey, key: AESKey, params: RSAEncryptionParams) {
const p = Object.assign({}, params, { name: params.algorithm });
const k = await webCrypto.importKey("spki", publicKey, p, false, ["encrypt"]);
try {
const buf = await webCrypto.encrypt(p, k, key);
return new Uint8Array(buf);
} catch (e) {
throw new Err(ErrorCode.DECRYPTION_FAILED);
}
}
async _decryptRSA(privateKey: RSAPrivateKey, key: AESKey, params: RSAEncryptionParams) {
const p = Object.assign({}, params, { name: params.algorithm });
const k = await webCrypto.importKey("pkcs8", privateKey, p, false, ["decrypt"]);
try {
const buf = await webCrypto.decrypt(p, k, key);
return new Uint8Array(buf);
} catch (e) {
throw new Err(ErrorCode.DECRYPTION_FAILED);
}
}
async fingerprint(key: RSAPublicKey): Promise<Uint8Array> {
const bytes = await webCrypto.digest("SHA-256", key);
return new Uint8Array(bytes);
}
async sign(key: HMACKey, data: Uint8Array, params: HMACParams): Promise<Uint8Array>;
async sign(key: RSAPrivateKey, data: Uint8Array, params: RSASigningParams): Promise<Uint8Array>;
async sign(
key: HMACKey | RSAPrivateKey,
data: Uint8Array,
params: HMACParams | RSASigningParams
): Promise<Uint8Array> {
switch (params.algorithm) {
case "HMAC":
return this._signHMAC(key, data, params);
case "RSA-PSS":
return this._signRSA(key, data, params);
default:
throw new Err(ErrorCode.NOT_SUPPORTED);
}
}
async verify(key: HMACKey, signature: Uint8Array, data: Uint8Array, params: HMACParams): Promise<boolean>;
async verify(
key: RSAPrivateKey,
signature: Uint8Array,
data: Uint8Array,
params: RSASigningParams
): Promise<boolean>;
async verify(
key: HMACKey | RSAPrivateKey,
signature: Uint8Array,
data: Uint8Array,
params: HMACParams | RSASigningParams
): Promise<boolean> {
switch (params.algorithm) {
case "HMAC":
return this._verifyHMAC(key, signature, data, params);
case "RSA-PSS":
return this._verifyRSA(key, signature, data, params);
default:
throw new Err(ErrorCode.NOT_SUPPORTED);
}
}
private async _signHMAC(key: HMACKey, data: Uint8Array, params: HMACParams): Promise<Uint8Array> {
const p = Object.assign({}, params, { name: params.algorithm, length: params.keySize });
const k = await webCrypto.importKey("raw", key, p, false, ["sign"]);
const signature = await webCrypto.sign(p, k, data);
return new Uint8Array(signature);
}
private async _verifyHMAC(
key: HMACKey,
signature: Uint8Array,
data: Uint8Array,
params: HMACParams
): Promise<boolean> {
const p = Object.assign({}, params, { name: params.algorithm, length: params.keySize });
const k = await webCrypto.importKey("raw", key, p, false, ["verify"]);
return await webCrypto.verify(p, k, signature, data);
}
private async _signRSA(key: RSAPrivateKey, data: Uint8Array, params: RSASigningParams): Promise<Uint8Array> {
const p = Object.assign({}, params, { name: params.algorithm });
const k = await webCrypto.importKey("pkcs8", key, p, false, ["sign"]);
const signature = await webCrypto.sign(p, k, data);
return new Uint8Array(signature);
}
private async _verifyRSA(
key: RSAPublicKey,
signature: Uint8Array,
data: Uint8Array,
params: RSASigningParams
): Promise<boolean> {
const p = Object.assign({}, params, { name: params.algorithm });
const k = await webCrypto.importKey("spki", key, p, false, ["verify"]);
return await webCrypto.verify(p, k, signature, data);
}
}
export default WebCryptoProvider;

View File

@ -0,0 +1,87 @@
import { translate as $l } from "@padloc/locale/src/translate";
import { BaseElement } from "../elements/base";
import "../elements/generator";
import "../elements/alert-dialog";
import "../elements/prompt-dialog";
import "../elements/export-dialog";
import { AlertDialog, AlertOptions } from "../elements/alert-dialog";
import { PromptDialog, PromptOptions } from "../elements/prompt-dialog";
import { getSingleton } from "./singleton";
let lastDialogPromise = Promise.resolve();
let currentDialog: any;
export const getDialog = getSingleton;
export function lineUpDialog(d: string | any, fn: (d: any) => Promise<any>): Promise<any> {
const dialog = typeof d === "string" ? getSingleton(d) : d;
const promise = lastDialogPromise.then(() => {
currentDialog = dialog;
return fn(dialog);
});
lastDialogPromise = promise;
return promise;
}
export function alert(message: string, options?: AlertOptions, instant = false): Promise<number> {
options = options || {};
options.message = message;
return instant
? getDialog("pl-alert-dialog").show(options)
: lineUpDialog("pl-alert-dialog", (dialog: AlertDialog) => dialog.show(options));
}
export function confirm(
message: string,
confirmLabel = $l("Confirm"),
cancelLabel = $l("Cancel"),
options: any = {},
instant?: boolean
) {
options.options = [confirmLabel, cancelLabel];
options.type = options.type || "question";
options.horizontal = typeof options.horizontal !== "undefined" ? options.horizontal : true;
return alert(message, options, instant).then(choice => choice === 0);
}
export function prompt(message: string, opts: PromptOptions = {}, instant = false) {
opts.message = message;
return instant
? getDialog("pl-prompt-dialog").show(opts)
: lineUpDialog("pl-prompt-dialog", (dialog: PromptDialog) => dialog.show(opts));
}
export function choose(message: string, options: string[], opts: AlertOptions = {}): Promise<number> {
opts.options = options;
return alert(message, {
...opts,
options,
type: "choice",
vertical: true
});
}
export function generate() {
return lineUpDialog("pl-generator", dialog => dialog.show());
}
export function clearDialogs() {
if (currentDialog) {
currentDialog.open = false;
}
lastDialogPromise = Promise.resolve();
}
export function dialog(name: string) {
return (prototype: BaseElement, propertyName: string) => {
Object.defineProperty(prototype, propertyName, {
get() {
return getDialog(name);
},
enumerable: true,
configurable: true
});
};
}

View File

@ -0,0 +1,68 @@
import { VaultItem } from "@padloc/core/src/item";
import { PBES2Container } from "@padloc/core/src/container";
import { marshal, stringToBytes } from "@padloc/core/src/encoding";
import { loadPapa, ImportFormat, CSV, PBES2 } from "./import";
export const supportedFormats: ImportFormat[] = [CSV, PBES2];
export { CSV, PBES2 } from "./import";
function itemsToTable(items: VaultItem[]) {
// Array of column names
let cols = ["name", "tags"];
// Column indizes associated with field/column names
let colInds = {};
// Two dimensional array, starting with column names
let table = [cols];
// Fill up columns array with distinct field names
for (let item of items) {
for (let field of item.fields) {
if (!colInds[field.name]) {
colInds[field.name] = cols.length;
cols.push(field.name);
}
}
}
// Creates an array of empty strings with the length of the `cols` array
function emptyRow() {
var l = cols.length;
var row: string[] = [];
while (l--) {
row.push("");
}
return row;
}
// Add a row for each item
items.forEach(function(item) {
// Create an empty row to be filled with item name, category and field values
var row = emptyRow();
// VaultItem name and category are always the first and second column respectively
row[0] = item.name;
row[1] = item.tags.join(",");
// Fill up columns with corrensponding field values if the fields exist on the item. All
// other columns remain empty
item.fields.forEach(function(item) {
row[colInds[item.name]] = item.value;
});
// Add row to table
table.push(row);
});
return table;
}
export async function asCSV(items: VaultItem[]): Promise<string> {
const papa = await loadPapa();
return papa.unparse(itemsToTable(items));
}
export async function asPBES2Container(items: VaultItem[], password: string): Promise<string> {
const container = new PBES2Container();
await container.unlock(password);
await container.setData(stringToBytes(marshal({ items })));
return container.toJSON();
}

View File

@ -0,0 +1,262 @@
import { unmarshal, bytesToString } from "@padloc/core/src/encoding";
import { PBES2Container } from "@padloc/core/src/container";
import { validateLegacyContainer, parseLegacyContainer } from "@padloc/core/src/legacy";
import { VaultItem, Field, createVaultItem, guessFieldType } from "@padloc/core/src/item";
import { Err, ErrorCode } from "@padloc/core/src/error";
import { uuid } from "@padloc/core/src/util";
import { translate as $l } from "@padloc/locale/src/translate";
export interface ImportFormat {
format: "csv" | "padlock-legacy" | "lastpass" | "padloc";
toString(): string;
}
export const CSV: ImportFormat = {
format: "csv",
toString() {
return "CSV";
}
};
export const PADLOCK_LEGACY: ImportFormat = {
format: "padlock-legacy",
toString() {
return "Padlock (v2)";
}
};
export const LASTPASS: ImportFormat = {
format: "lastpass",
toString() {
return "LastPass";
}
};
export const PBES2: ImportFormat = {
format: "padloc",
toString() {
return "Encrypted Container";
}
};
export const supportedFormats: ImportFormat[] = [CSV, PADLOCK_LEGACY, LASTPASS, PBES2];
export function loadPapa(): Promise<any> {
return import(/* webpackChunkName: "papaparse" */ "papaparse");
}
/**
* Takes a data table (represented by a two-dimensional array) and converts it
* into an array of items
* @param Array data Two-dimensional array containing tabular item data; The first 'row'
* should contain field names. All other rows represent items, containing
* the item name, field values and optionally a list of tags.
* @param Integer nameColIndex Index of the column containing the item names. Defaults to 0
* @param Integer tagsColIndex Index of the column containing the item categories. If left empty
* no categories will be used
*/
export async function fromTable(data: string[][], nameColIndex?: number, tagsColIndex?: number): Promise<VaultItem[]> {
// Use first row for column names
const colNames = data[0];
if (nameColIndex === undefined) {
const i = colNames.indexOf("name");
nameColIndex = i !== -1 ? i : 0;
}
if (tagsColIndex === undefined) {
tagsColIndex = colNames.indexOf("tags");
if (tagsColIndex === -1) {
tagsColIndex = colNames.indexOf("category");
}
}
// All subsequent rows should contain values
let items = data.slice(1).map(function(row) {
// Construct an array of field object from column names and values
let fields: Field[] = [];
for (let i = 0; i < row.length; i++) {
// Skip name column, category column (if any) and empty fields
if (i != nameColIndex && i != tagsColIndex && row[i]) {
const name = colNames[i];
const value = row[i];
fields.push({
name,
value,
type: guessFieldType({ name, value })
});
}
}
const name = row[nameColIndex!];
const tags = row[tagsColIndex!];
return createVaultItem(name, fields, (tags && tags.split(",")) || []);
});
return Promise.all(items);
}
export async function isCSV(data: string): Promise<Boolean> {
const papa = await loadPapa();
return papa.parse(data).errors.length === 0;
}
export async function asCSV(data: string, nameColIndex?: number, tagsColIndex?: number): Promise<VaultItem[]> {
const papa = await loadPapa();
const parsed = papa.parse(data);
if (parsed.errors.length) {
throw new Err(ErrorCode.INVALID_CSV);
}
return fromTable(parsed.data, nameColIndex, tagsColIndex);
}
/**
* Checks if a given string represents a Padlock enrypted backup
*/
export function isPadlockV1(data: string): boolean {
try {
return validateLegacyContainer(unmarshal(data));
} catch (e) {
return false;
}
}
export async function asPadlockLegacy(data: string, password: string): Promise<VaultItem[]> {
const container = parseLegacyContainer(unmarshal(data));
await container.unlock(password);
const records = unmarshal(bytesToString(await container.getData())) as any[];
const items = records
.filter(({ removed }) => !removed)
.map(async record => {
return {
id: await uuid(),
name: record.name,
fields: record.fields,
tags: record.tags || [record.category],
updated: new Date(record.updated),
lastUsed: new Date(record.lastUsed),
updatedBy: "",
attachments: [],
favorited: []
};
});
return Promise.all(items);
}
export function isPBES2Container(data: string) {
try {
new PBES2Container().fromRaw(unmarshal(data));
return true;
} catch (e) {
return false;
}
}
export async function asPBES2Container(data: string, password: string): Promise<VaultItem[]> {
const container = new PBES2Container().fromRaw(unmarshal(data));
await container.unlock(password);
const raw = unmarshal(bytesToString(await container.getData())) as any;
const items = raw.items.map((item: any) => {
return {
...item,
updated: new Date(item.updated),
lastUsed: new Date(item.lastUsed)
};
});
return items;
}
/*
* Lastpass secure notes are exported by putting non-standard fields into the 'extra' column. Every line
* represents a field in the following format:
*
* field_name:data
*
* We're parsing that information to retrieve the individual fields
*/
function lpParseNotes(str: string): Field[] {
let lines = str.split("\n");
let fields = lines
.filter(line => !!line)
.map(line => {
let split = line.indexOf(":");
return {
name: line.substring(0, split),
value: line.substring(split + 1),
type: "text"
} as Field;
});
return fields;
}
/*
* Parses a single row in a LastPass CSV file. Apart from extracting the default fields, we also parse
* the 'extra' column for 'special notes' and remove any special fields that are not needed outside of
* LastPass
*/
async function lpParseRow(row: string[]): Promise<VaultItem> {
const nameIndex = 4;
const categoryIndex = 5;
const urlIndex = 0;
const usernameIndex = 1;
const passwordIndex = 2;
const notesIndex = 3;
let fields: Field[] = [
{ name: $l("Username"), value: row[usernameIndex], type: "username" },
{ name: $l("Password"), value: row[passwordIndex], type: "password" },
{ name: $l("URL"), value: row[urlIndex], type: "url" }
];
let notes = row[notesIndex];
if (row[urlIndex] === "http://sn") {
// The 'http://sn' url indicates that this line represents a 'secure note', which means
// we'll have to parse the 'extra' column to retrieve the individual fields
fields.push(...lpParseNotes(notes));
// In case of 'secure notes' we don't want the url and NoteType field
fields = fields.filter(f => f.name != "url" && f.name != "NoteType");
} else {
// We've got a regular 'site' item, so the 'extra' column simply contains notes
fields.push({ name: $l("Notes"), value: notes, type: "note" });
}
const dir = row[categoryIndex];
// Create a basic item using the standard fields
return createVaultItem(row[nameIndex], fields, dir ? [dir] : []);
}
export async function asLastPass(data: string): Promise<VaultItem[]> {
const papa = await loadPapa();
let items = papa
.parse(data)
.data // Remove first row as it only contains field names
.slice(1)
// Filter out empty rows
.filter((row: string[]) => row.length > 1)
.map(lpParseRow);
return Promise.all(items);
}
/**
* Checks if a given string represents a LastPass CSV file
*/
export function isLastPass(data: string): boolean {
return data.split("\n")[0] === "url,username,password,extra,name,grouping,fav";
}
export function guessFormat(data: string): ImportFormat | null {
return isPBES2Container(data)
? PBES2
: isPadlockV1(data)
? PADLOCK_LEGACY
: isLastPass(data)
? LASTPASS
: isCSV(data)
? CSV
: null;
}

View File

@ -0,0 +1,136 @@
import { Platform, StubPlatform, DeviceInfo } from "@padloc/core/src/platform";
import { WebCryptoProvider } from "./crypto";
const browserInfo = (async () => {
const { UAParser } = await import(/* webpackChunkName: "ua-parser" */ "ua-parser-js");
return new UAParser(navigator.userAgent).getResult();
})();
export class WebPlatform extends StubPlatform implements Platform {
private _clipboardTextArea: HTMLTextAreaElement;
private _qrVideo: HTMLVideoElement;
private _qrCanvas: HTMLCanvasElement;
crypto = new WebCryptoProvider();
// Set clipboard text using `document.execCommand("cut")`.
// NOTE: This only works in certain environments like Google Chrome apps with the appropriate permissions set
async setClipboard(text: string): Promise<void> {
this._clipboardTextArea = this._clipboardTextArea || document.createElement("textarea");
this._clipboardTextArea.contentEditable = "true";
this._clipboardTextArea.readOnly = false;
this._clipboardTextArea.value = text;
document.body.appendChild(this._clipboardTextArea);
const range = document.createRange();
range.selectNodeContents(this._clipboardTextArea);
const s = window.getSelection();
s!.removeAllRanges();
s!.addRange(range);
this._clipboardTextArea.select();
this._clipboardTextArea.setSelectionRange(0, this._clipboardTextArea.value.length); // A big number, to cover anything that could be inside the element.
document.execCommand("cut");
document.body.removeChild(this._clipboardTextArea);
}
// Get clipboard text using `document.execCommand("paste")`
// NOTE: This only works in certain environments like Google Chrome apps with the appropriate permissions set
async getClipboard(): Promise<string> {
this._clipboardTextArea = this._clipboardTextArea || document.createElement("textarea");
document.body.appendChild(this._clipboardTextArea);
this._clipboardTextArea.value = "";
this._clipboardTextArea.select();
document.execCommand("paste");
document.body.removeChild(this._clipboardTextArea);
return this._clipboardTextArea.value;
}
async getDeviceInfo() {
const { os, browser } = await browserInfo;
return new DeviceInfo({
platform: (os.name && os.name.replace(" ", "")) || "",
osVersion: (os.version && os.version.replace(" ", "")) || "",
id: "",
appVersion: process.env.PL_VERSION || "",
manufacturer: "",
model: "",
browser: browser.name || "",
userAgent: navigator.userAgent,
locale: navigator.language || "en"
});
}
async scanQR() {
return new Promise<string>((resolve, reject) => {
const tick = async () => {
if (this._qrVideo.readyState !== this._qrVideo.HAVE_ENOUGH_DATA) {
requestAnimationFrame(() => tick());
return;
}
const { default: jsQR } = await import(/* webpackChunkName: "jsqr" */ "jsqr");
const canvas = this._qrCanvas.getContext("2d")!;
this._qrCanvas.height = this._qrVideo.videoHeight;
this._qrCanvas.width = this._qrVideo.videoWidth;
canvas.drawImage(this._qrVideo, 0, 0, this._qrCanvas.width, this._qrCanvas.height);
const imageData = canvas.getImageData(0, 0, this._qrCanvas.width, this._qrCanvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert"
});
if (code) {
resolve(code.data);
}
requestAnimationFrame(() => tick());
};
if (!this._qrVideo) {
this._qrVideo = document.createElement("video");
this._qrVideo.setAttribute("playsinline", "");
this._qrVideo.setAttribute("muted", "");
this._qrVideo.setAttribute("autoplay", "");
}
if (!this._qrCanvas) {
this._qrCanvas = document.createElement("canvas");
Object.assign(this._qrCanvas.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: "-1"
});
document.body.appendChild(this._qrCanvas);
}
this._qrCanvas.style.display = "block";
navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: "environment" } }).then(stream => {
// Use facingMode: environment to attemt to get the front camera on phones
this._qrVideo.srcObject = stream;
this._qrVideo.play();
requestAnimationFrame(() => tick());
}, reject);
});
}
async stopScanQR() {
const stream: MediaStream | null = this._qrVideo && (this._qrVideo.srcObject as MediaStream);
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
this._qrVideo && (this._qrVideo.srcObject = null);
this._qrCanvas.style.display = "none";
}
async composeEmail(addr: string, subj: string, msg: string) {
window.open(`mailto:${addr}?subject=${encodeURIComponent(subj)}&body=${encodeURIComponent(msg)}`, "_system");
}
}

View File

@ -0,0 +1,78 @@
import { EventEmitter } from "@padloc/core/src/event-target";
export class Router extends EventEmitter {
history: string[] = [];
constructor(public basePath = "/") {
super();
window.addEventListener("popstate", () => {
this._pathChanged();
});
this._pathChanged();
}
private _pathChanged() {
const index = (history.state && history.state.historyIndex) || 0;
const path = this.path;
const direction = this.history.length - 1 < index ? "forward" : "backward";
if (this.history.length === index) {
this.history.push(path);
} else
while (this.history.length - 1 > index) {
this.history.pop();
}
this.dispatch("route-changed", { path, direction });
}
get path() {
return window.location.pathname.replace(new RegExp("^" + this.basePath), "");
}
get params() {
const params = {};
for (const [key, value] of new URLSearchParams(window.location.search)) {
params[key] = value;
}
return params;
}
set params(params: { [prop: string]: string }) {
history.pushState(
{ historyIndex: this.history.length - 1 },
"",
this.basePath + this.path + "?" + new URLSearchParams(params).toString()
);
}
get canGoBack() {
return this.history.length > 1;
}
go(path: string, params?: { [prop: string]: string }) {
const queryString = new URLSearchParams(params || this.params).toString();
if (path !== this.path || queryString !== window.location.search) {
let url = this.basePath + path;
if (queryString) {
url += "?" + queryString;
}
history.pushState({ historyIndex: this.history.length }, "", url);
this._pathChanged();
}
}
forward() {
history.forward();
}
back(alternate = "") {
if (this.canGoBack) {
history.back();
} else {
this.go(alternate);
}
}
}

View File

@ -0,0 +1,17 @@
const singletons = {};
let container: HTMLElement;
export function getSingleton(elName: string) {
if (!container) {
container = document.querySelector("pl-app") as HTMLElement;
}
let el = singletons[elName];
if (!el) {
singletons[elName] = el = document.createElement(elName);
container.appendChild(el);
}
return el;
}

View File

@ -0,0 +1,27 @@
import { Storage, Storable, StorableConstructor } from "@padloc/core/src/storage";
import { Err, ErrorCode } from "@padloc/core/src/error";
// @ts-ignore
import localStorage from "localforage/src/localforage";
export class LocalStorage implements Storage {
async save(s: Storable) {
await localStorage.setItem(`${s.kind}_${s.id}`, s.toRaw());
}
async get<T extends Storable>(cls: T | StorableConstructor<T>, id: string) {
const s = cls instanceof Storable ? cls : new cls();
const data = await localStorage.getItem(`${s.kind}_${id}`);
if (!data) {
throw new Err(ErrorCode.NOT_FOUND);
}
return s.fromRaw(data);
}
async delete(s: Storable) {
await localStorage.removeItem(`${s.kind}_${s.id}`);
}
async clear() {
await localStorage.clear();
}
}

View File

@ -0,0 +1,110 @@
const loaded: Map<string, Promise<any>> = new Map<string, Promise<any>>();
export function loadScript(src: string, global?: string): Promise<any> {
if (loaded.has(src)) {
return loaded.get(src)!;
}
const s = document.createElement("script");
s.src = src;
s.type = "text/javascript";
const p = new Promise((resolve, reject) => {
s.onload = () => resolve(global ? window[global] : undefined);
s.onerror = e => reject(e);
document.head.appendChild(s);
});
loaded.set(src, p);
return p;
}
export async function formatDateFromNow(date: Date | string | number, addSuffix = true) {
const { distanceInWordsToNow } = await import(/* webpackChunkName: "date-fns" */ "date-fns");
return distanceInWordsToNow(date, { addSuffix });
}
export async function passwordStrength(pwd: string): Promise<{ score: number }> {
// @ts-ignore
const { default: zxcvbn } = await import(/* webpackChunkName: "zxcvbn" */ "zxcvbn");
return zxcvbn(pwd);
}
export function toggleAttribute(el: Element, attr: string, on: boolean) {
if (on) {
el.setAttribute(attr, "");
} else {
el.removeAttribute(attr);
}
}
export function mediaType(mimeType: string) {
const match = mimeType.match(/(.*)\/(.*)/);
const [, type, subtype] = match || ["", "", ""];
switch (type) {
case "video":
return "video";
case "audio":
return "audio";
case "image":
return "image";
case "text":
switch (subtype) {
case "csv":
// return "csv";
case "plain":
return "text";
default:
return "code";
}
case "application":
switch (subtype) {
case "pdf":
return "pdf";
case "json":
return "code";
case "pkcs8":
case "pkcs10":
case "pkix-cert":
case "pkix-crl":
case "pkcs7-mime":
case "x-x509-ca-cert":
case "x-x509-user-cert":
case "x-pkcs12":
case "x-pkcs7-certificates":
case "x-pkcs7-mime":
case "x-pkcs7-crl":
case "x-pem-file":
case "x-pkcs12":
case "x-pkcs7-certreqresp":
return "certificate";
case "zip":
case "x-7z-compressed":
case "x-freearc":
case "x-bzip":
case "x-bzip2":
case "java-archive":
case "x-rar-compressed":
case "x-tar":
return "archive";
}
default:
return "";
}
}
export function fileIcon(mimeType: string) {
const mType = mediaType(mimeType);
return mType ? `file-${mType}` : "file";
}
export function fileSize(size: number = 0) {
return size < 1e6 ? Math.ceil(size / 10) / 100 + " KB" : Math.ceil(size / 10000) / 100 + " MB";
}
export function mask(value: string): string {
return value && value.replace(/[^\n]/g, "\u2022");
}
export function isTouch(): boolean {
return window.matchMedia("(hover: none)").matches;
}

View File

@ -1,9 +1,9 @@
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
declare const workbox: typeof import("workbox-sw");
declare const __WB_MANIFEST: any;
workbox.precaching.precacheAndRoute(__WB_MANIFEST);
// @ts-ignore
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("index.html"));

View File

@ -1,6 +1,6 @@
{
"name": "@padloc/core",
"version": "3.0.0",
"version": "3.0.5",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@padloc/locale",
"version": "3.0.0",
"version": "3.0.5",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

11953
packages/pwa/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
packages/pwa/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@padloc/pwa",
"version": "3.0.5",
"author": "Martin Kleinschrodt <martin@maklesoft.com>",
"license": "GPL-3.0",
"dependencies": {
"@padloc/core": "^3.0.0",
"@padloc/app": "^3.0.0"
},
"devDependencies": {
"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.0.0",
"favicons-webpack-plugin": "^1.0.1",
"file-loader": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"http-server": "^0.11.1",
"style-loader": "^1.0.0",
"ts-loader": "^6.0.4",
"ts-node": "^7.0.1",
"typescript": "^3.5.2",
"webpack": "^4.35.3",
"webpack-cli": "^3.3.5",
"webpack-dev-server": "^3.7.2",
"webpack-pwa-manifest": "^4.0.0",
"workbox-cli": "^4.3.1",
"workbox-webpack-plugin": "^5.0.0-alpha.0"
},
"description": "Padloc Progressive Web App",
"scripts": {
"build": "webpack",
"dev": "webpack-dev-server",
"start": "http-server $PL_PWA_DIR -s -p $PL_PWA_PORT --proxy $PL_PWA_URL?",
"build_and_start": "npm run build && npm start"
}
}

View File

@ -0,0 +1,5 @@
import { setPlatform } from "@padloc/core/src/platform";
import { WebPlatform } from "@padloc/app/src/lib/platform";
import "@padloc/app/src/elements/app";
setPlatform(new WebPlatform());

View File

@ -0,0 +1,3 @@
{
"extends": "../app/tsconfig.json"
}

View File

@ -7,10 +7,12 @@ const WebpackPwaManifest = require("webpack-pwa-manifest");
const FaviconsWebpackPlugin = require("favicons-webpack-plugin");
const { version } = require("./package.json");
const out = process.env.PL_PWA_DIR || path.resolve(__dirname, "dist");
module.exports = {
entry: path.resolve(__dirname, "src/index.ts"),
output: {
path: path.resolve(__dirname, "dist"),
path: out,
filename: "[name].js",
chunkFilename: "[name].chunk.js",
publicPath: "/"
@ -49,7 +51,7 @@ module.exports = {
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: "Padloc",
template: path.resolve(__dirname, "index.html"),
template: path.resolve(__dirname, "src/index.html"),
meta: {
"Content-Security-Policy": {
"http-equiv": "Content-Security-Policy",
@ -61,7 +63,7 @@ module.exports = {
}
}
}),
new FaviconsWebpackPlugin(path.resolve(__dirname, "assets/icons/512.png")),
new FaviconsWebpackPlugin(path.resolve(__dirname, "assets/icon-512.png")),
new WebpackPwaManifest({
name: "Padloc Password Manager",
short_name: "Padloc",
@ -69,13 +71,13 @@ module.exports = {
theme: "#59c6ff",
icons: [
{
src: path.resolve(__dirname, "assets/icons/512.png"),
src: path.resolve(__dirname, "assets/icon-512.png"),
sizes: [96, 128, 192, 256, 384, 512]
}
]
}),
new InjectManifest({
swSrc: path.resolve(__dirname, "src/sw.ts"),
swSrc: path.resolve(__dirname, "../app/src/sw.ts"),
swDest: "sw.js",
exclude: [/favicon\.png$/, /\.map$/]
})

View File

@ -1,6 +1,6 @@
{
"name": "@padloc/server",
"version": "3.0.0",
"version": "3.0.5",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@ -12,7 +12,7 @@ async function init() {
setPlatform(new NodePlatform());
const config = new ServerConfig({
clientUrl: process.env.PL_CLIENT_URL || "https://localhost:8081",
clientUrl: process.env.PL_PWA_PORT || "https://localhost:8081",
reportErrors: process.env.PL_REPORT_ERRORS || "",
mfa: (process.env.PL_MFA as ("email" | "none")) || "email",
accountQuota: {