Add web interface and tests

This commit is contained in:
aet 2021-11-05 03:17:18 -04:00
parent 7f41a50fb1
commit fe926be0a6
66 changed files with 3390 additions and 1275 deletions

View File

@ -1,2 +1,3 @@
*.opvault *.opvault
lib lib
dist

View File

@ -6,6 +6,9 @@
"node": true, "node": true,
"browser": true "browser": true
}, },
"parserOptions": {
"project": "./tsconfig.json"
},
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
@ -36,6 +39,7 @@
"disallowTypeAnnotations": false "disallowTypeAnnotations": false
} }
], ],
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-types": [ "@typescript-eslint/ban-types": [
"error", "error",
{ {

3
.gitignore vendored
View File

@ -6,4 +6,5 @@ ref
*.opvault *.opvault
freddy freddy
electron/bundled electron/bundled
design.html design.html
repl.ts

View File

@ -1,5 +1,12 @@
# OPVault design # OPVault design
<!--
(C) AgileBits Inc.
Canonical URL: https://support.1password.com/cs/opvault-design/
This document is reformatted into Markdown for implementation reference purposes.
-->
Learn about the design of the OPVault format, used by default when syncing with iCloud or Dropbox. Learn about the design of the OPVault format, used by default when syncing with iCloud or Dropbox.
> ### Tip > ### Tip
@ -11,7 +18,7 @@ Learn about the design of the OPVault format, used by default when syncing with
The OPVault format was introduced in December 2012 and shortly thereafter became the default format for syncing with iCloud and Dropbox. The OPVault format was introduced in December 2012 and shortly thereafter became the default format for syncing with iCloud and Dropbox.
The [Agile Keychain](/cs/agile-keychain-design/) format was introduced in 2008 as a successor to using macOS Keychain integration. It proved to be much more reliable for syncing and gave us flexibility in design, efficient and reliable syncing, and portability across a variety of platforms. We designed it not only to withstand threats from 2008 but future threats as well. The [Agile Keychain](https://support.1password.com/cs/agile-keychain-design/) format was introduced in 2008 as a successor to using macOS Keychain integration. It proved to be much more reliable for syncing and gave us flexibility in design, efficient and reliable syncing, and portability across a variety of platforms. We designed it not only to withstand threats from 2008 but future threats as well.
Changes in available technology allowed us to improve on that design. And we again designed against threats that may not exist today, but which may develop in the coming years. Changes in available technology allowed us to improve on that design. And we again designed against threats that may not exist today, but which may develop in the coming years.

View File

@ -1,8 +0,0 @@
const replaceText = (selector, text) => {
const element = document.getElementById(selector)
if (element) element.innerText = text
}
for (const type of ["chrome", "node", "electron"]) {
replaceText(`${type}-version`, process.versions[type])
}

View File

@ -1,22 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<title>Hello World!</title>
</head>
<body>
<h1>Hello World!</h1>
We are using Node.js <span id="node-version"></span>, Chromium
<span id="chrome-version"></span>, and Electron <span id="electron-version"></span>.
<script src="bundled/index.js"></script>
</body>
</html>

View File

@ -1,44 +0,0 @@
#!/usr/bin/env node
// @ts-check
const { build } = require("esbuild")
const sassPlugin = require("esbuild-plugin-sass")
const { nodeBuiltIns } = require("esbuild-node-builtins")
const args = process.argv.slice(2)
build({
bundle: true,
define: {
"process.browser": "true",
"process.env.BLUEPRINT_NAMESPACE": '"bp4"',
global: "globalThis",
},
entryPoints: ["electron/app/index.tsx"],
inject: ["./scripts/react-shim.js"],
outdir: "electron/bundled",
external: ["path", "glob", "fs", "util"],
jsxFactory: "esbuildCreateElement",
jsxFragment: "esbuildFragment",
plugins: [
sassPlugin(),
nodeBuiltIns({
include: ["path", "fs"],
}),
],
target: ["chrome90"],
tsconfig: "./tsconfig.json",
sourcemap: "inline",
minify: process.env.NODE_ENV === "production",
banner: {
js: "/* eslint-disable */",
},
loader: {
".png": "file",
".eot": "file",
".svg": "file",
".woff": "file",
".woff2": "file",
".ttf": "file",
},
watch: args.includes("-w") || args.includes("--watch"),
})

BIN
icon.bin

Binary file not shown.

View File

@ -4,25 +4,18 @@
"main": "lib/index.js", "main": "lib/index.js",
"repository": "https://git.aet.ac/aet/opvault.js.git", "repository": "https://git.aet.ac/aet/opvault.js.git",
"private": true, "private": true,
"license": "UNLICENSED",
"scripts": { "scripts": {
"build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null",
"build:docs": "typedoc --out docs src/index.ts --excludePrivate",
"design": "marked -o design.html < design.md", "design": "marked -o design.html < design.md",
"test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts", "test": "node --expose-gc node_modules/mocha/bin/_mocha test/**/*.test.ts",
"repl": "node -r ts-node/register/transpile-only src/repl.ts", "repl": "node -r ts-node/register/transpile-only src/repl.ts"
"start": "electron ./electron/index.js"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-replace": "^3.0.0",
"@types/chai": "^4.2.22", "@types/chai": "^4.2.22",
"@types/chai-as-promised": "^7.1.4", "@types/chai-as-promised": "^7.1.4",
"@types/fs-extra": "^9.0.13", "@types/fs-extra": "^9.0.13",
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768", "@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
"@types/node": "^16.10.3", "@types/node": "^16.10.3",
"@types/prompts": "^2.0.14",
"@types/react": "^17.0.30",
"@types/react-dom": "^17.0.9",
"@types/sinon": "^10.0.4", "@types/sinon": "^10.0.4",
"@types/sinon-chai": "^3.2.5", "@types/sinon-chai": "^3.2.5",
"@types/wicg-file-system-access": "^2020.9.4", "@types/wicg-file-system-access": "^2020.9.4",
@ -31,10 +24,6 @@
"chai": "^4.3.4", "chai": "^4.3.4",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"electron": "^15.2.0",
"esbuild": "^0.13.6",
"esbuild-node-builtins": "^0.1.0",
"esbuild-plugin-sass": "^0.6.0",
"eslint": "7.32.0", "eslint": "7.32.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-import-resolver-typescript": "2.5.0", "eslint-import-resolver-typescript": "2.5.0",
@ -43,21 +32,16 @@
"eslint-plugin-react-hooks": "4.2.0", "eslint-plugin-react-hooks": "4.2.0",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"marked": "^3.0.8", "marked": "^3.0.8",
"memfs": "^3.3.0",
"mocha": "^9.1.2", "mocha": "^9.1.2",
"mochawesome": "^6.3.0", "mochawesome": "^6.3.0",
"prettier": "^2.4.1", "prettier": "^2.4.1",
"prompts": "^2.4.1",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"rollup": "^2.58.0",
"rollup-plugin-ts": "^1.4.7",
"sass": "^1.43.2", "sass": "^1.43.2",
"sinon": "^11.1.2", "sinon": "^11.1.2",
"sinon-chai": "^3.7.0", "sinon-chai": "^3.7.0",
"ts-node": "^10.2.1", "ts-node": "^10.2.1",
"tsconfig-paths": "^3.11.0", "tsconfig-paths": "^3.11.0",
"typedoc": "^0.22.5",
"typescript": "^4.4.3" "typescript": "^4.4.3"
}, },
"prettier": { "prettier": {
@ -67,10 +51,5 @@
"semi": false, "semi": false,
"singleQuote": false, "singleQuote": false,
"trailingComma": "es5" "trailingComma": "es5"
},
"dependencies": {
"buffer": "^6.0.3",
"tiny-invariant": "1.1.0",
"tslib": "2.3.1"
} }
} }

View File

@ -0,0 +1,3 @@
# opvault.js
OnePassword local vaults parser library.

View File

@ -0,0 +1,22 @@
{
"name": "opvault.js",
"main": "src/index.ts",
"version": "0.0.1",
"license": "LGPL-3.0-or-later",
"scripts": {
"build": "rollup -c; cp src/adapters/index.d.ts lib/adapters/; prettier --write lib >/dev/null",
"build:docs": "typedoc --out docs src/index.ts --excludePrivate"
},
"dependencies": {
"tiny-invariant": "1.2.0",
"tslib": "2.3.1"
},
"devDependencies": {
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-replace": "^3.0.0",
"prettier": "^2.4.1",
"rollup": "^2.58.0",
"rollup-plugin-ts": "^1.4.7",
"typedoc": "^0.22.7"
}
}

View File

@ -1,5 +1,4 @@
import { Buffer } from "buffer" import { Buffer } from "buffer"
import createHmac from "create-hmac"
import type { IAdapter, IFileSystem } from "./index" import type { IAdapter, IFileSystem } from "./index"
function normalize(path: string) { function normalize(path: string) {
@ -120,5 +119,4 @@ async function success(fn: () => Promise<any>) {
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({ export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({
fs: new FileSystem(handle), fs: new FileSystem(handle),
subtle: crypto.subtle, subtle: crypto.subtle,
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
}) })

View File

@ -1,324 +0,0 @@
function bufferXor(a: Buffer, b: Buffer) {
const length = Math.min(a.length, b.length)
const buffer = Buffer.alloc(length)
for (let i = 0; i < length; ++i) {
buffer[i] = a[i] ^ b[i]
}
return buffer
}
function asUInt32Array(buf: Buffer) {
const len = (buf.length / 4) | 0
const out: number[] = new Array(len)
for (let i = 0; i < len; i++) {
out[i] = buf.readUInt32BE(i * 4)
}
return out
}
function cryptBlock(
M: number[],
keySchedule: number[],
SUB_MIX: number[][],
SBOX: number[],
nRounds: number
) {
const SUB_MIX0 = SUB_MIX[0]
const SUB_MIX1 = SUB_MIX[1]
const SUB_MIX2 = SUB_MIX[2]
const SUB_MIX3 = SUB_MIX[3]
let s0 = M[0] ^ keySchedule[0]
let s1 = M[1] ^ keySchedule[1]
let s2 = M[2] ^ keySchedule[2]
let s3 = M[3] ^ keySchedule[3]
let t0: number
let t1: number
let t2: number
let t3: number
let ksRow = 4
for (let round = 1; round < nRounds; round++) {
t0 =
SUB_MIX0[s0 >>> 24] ^
SUB_MIX1[(s1 >>> 16) & 0xff] ^
SUB_MIX2[(s2 >>> 8) & 0xff] ^
SUB_MIX3[s3 & 0xff] ^
keySchedule[ksRow++]
t1 =
SUB_MIX0[s1 >>> 24] ^
SUB_MIX1[(s2 >>> 16) & 0xff] ^
SUB_MIX2[(s3 >>> 8) & 0xff] ^
SUB_MIX3[s0 & 0xff] ^
keySchedule[ksRow++]
t2 =
SUB_MIX0[s2 >>> 24] ^
SUB_MIX1[(s3 >>> 16) & 0xff] ^
SUB_MIX2[(s0 >>> 8) & 0xff] ^
SUB_MIX3[s1 & 0xff] ^
keySchedule[ksRow++]
t3 =
SUB_MIX0[s3 >>> 24] ^
SUB_MIX1[(s0 >>> 16) & 0xff] ^
SUB_MIX2[(s1 >>> 8) & 0xff] ^
SUB_MIX3[s2 & 0xff] ^
keySchedule[ksRow++]
s0 = t0
s1 = t1
s2 = t2
s3 = t3
}
t0 =
((SBOX[s0 >>> 24] << 24) |
(SBOX[(s1 >>> 16) & 0xff] << 16) |
(SBOX[(s2 >>> 8) & 0xff] << 8) |
SBOX[s3 & 0xff]) ^
keySchedule[ksRow++]
t1 =
((SBOX[s1 >>> 24] << 24) |
(SBOX[(s2 >>> 16) & 0xff] << 16) |
(SBOX[(s3 >>> 8) & 0xff] << 8) |
SBOX[s0 & 0xff]) ^
keySchedule[ksRow++]
t2 =
((SBOX[s2 >>> 24] << 24) |
(SBOX[(s3 >>> 16) & 0xff] << 16) |
(SBOX[(s0 >>> 8) & 0xff] << 8) |
SBOX[s1 & 0xff]) ^
keySchedule[ksRow++]
t3 =
((SBOX[s3 >>> 24] << 24) |
(SBOX[(s0 >>> 16) & 0xff] << 16) |
(SBOX[(s1 >>> 8) & 0xff] << 8) |
SBOX[s2 & 0xff]) ^
keySchedule[ksRow++]
t0 = t0 >>> 0
t1 = t1 >>> 0
t2 = t2 >>> 0
t3 = t3 >>> 0
return [t0, t1, t2, t3]
}
// AES constants
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
const G = (function () {
// Compute double table
const d = new Array(256)
for (let j = 0; j < 256; j++) {
if (j < 128) {
d[j] = j << 1
} else {
d[j] = (j << 1) ^ 0x11b
}
}
const SBOX: number[] = []
const INV_SBOX: number[] = []
const SUB_MIX: number[][] = [[], [], [], []]
const INV_SUB_MIX: number[][] = [[], [], [], []]
// Walk GF(2^8)
let x = 0
let xi = 0
for (let i = 0; i < 256; ++i) {
// Compute sbox
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4)
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63
SBOX[x] = sx
INV_SBOX[sx] = x
// Compute multiplication
const x2 = d[x]
const x4 = d[x2]
const x8 = d[x4]
// Compute sub bytes, mix columns tables
let t = (d[sx] * 0x101) ^ (sx * 0x1010100)
SUB_MIX[0][x] = (t << 24) | (t >>> 8)
SUB_MIX[1][x] = (t << 16) | (t >>> 16)
SUB_MIX[2][x] = (t << 8) | (t >>> 24)
SUB_MIX[3][x] = t
// Compute inv sub bytes, inv mix columns tables
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100)
INV_SUB_MIX[0][sx] = (t << 24) | (t >>> 8)
INV_SUB_MIX[1][sx] = (t << 16) | (t >>> 16)
INV_SUB_MIX[2][sx] = (t << 8) | (t >>> 24)
INV_SUB_MIX[3][sx] = t
if (x === 0) {
x = xi = 1
} else {
x = x2 ^ d[d[d[x8 ^ x2]]]
xi ^= d[d[xi]]
}
}
return {
SBOX,
INV_SBOX,
SUB_MIX,
INV_SUB_MIX,
}
})()
class AES {
private _key: number[]
private _nRounds!: number
private _invKeySchedule!: number[]
constructor(key: Buffer) {
this._key = asUInt32Array(key)
this._reset()
}
_reset() {
const keyWords = this._key
const keySize = keyWords.length
const nRounds = keySize + 6
const ksRows = (nRounds + 1) * 4
const keySchedule: number[] = []
for (let k = 0; k < keySize; k++) {
keySchedule[k] = keyWords[k]
}
for (let k = keySize; k < ksRows; k++) {
let t = keySchedule[k - 1]
if (k % keySize === 0) {
t = (t << 8) | (t >>> 24)
t =
(G.SBOX[t >>> 24] << 24) |
(G.SBOX[(t >>> 16) & 0xff] << 16) |
(G.SBOX[(t >>> 8) & 0xff] << 8) |
G.SBOX[t & 0xff]
t ^= RCON[(k / keySize) | 0] << 24
} else if (keySize > 6 && k % keySize === 4) {
t =
(G.SBOX[t >>> 24] << 24) |
(G.SBOX[(t >>> 16) & 0xff] << 16) |
(G.SBOX[(t >>> 8) & 0xff] << 8) |
G.SBOX[t & 0xff]
}
keySchedule[k] = keySchedule[k - keySize] ^ t
}
const invKeySchedule = []
for (let ik = 0; ik < ksRows; ik++) {
const ksR = ksRows - ik
const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)]
if (ik < 4 || ksR <= 4) {
invKeySchedule[ik] = tt
} else {
invKeySchedule[ik] =
G.INV_SUB_MIX[0][G.SBOX[tt >>> 24]] ^
G.INV_SUB_MIX[1][G.SBOX[(tt >>> 16) & 0xff]] ^
G.INV_SUB_MIX[2][G.SBOX[(tt >>> 8) & 0xff]] ^
G.INV_SUB_MIX[3][G.SBOX[tt & 0xff]]
}
}
this._nRounds = nRounds
this._invKeySchedule = invKeySchedule
}
decryptBlock(buffer: Buffer) {
const M = asUInt32Array(buffer)
// swap
const m1 = M[1]
M[1] = M[3]
M[3] = m1
const out = cryptBlock(
M,
this._invKeySchedule,
G.INV_SUB_MIX,
G.INV_SBOX,
this._nRounds
)
const buf = Buffer.allocUnsafe(16)
buf.writeUInt32BE(out[0], 0)
buf.writeUInt32BE(out[3], 4)
buf.writeUInt32BE(out[2], 8)
buf.writeUInt32BE(out[1], 12)
return buf
}
static blockSize = 4 * 4
static keySize = 256 / 8
}
class Decipher {
private _cipher: AES
private _prev: Buffer
private _cache = new Splitter()
constructor(key: Buffer, iv: Buffer) {
this._cipher = new AES(key)
this._prev = Buffer.from(iv)
}
update(data: Buffer) {
this._cache.add(data)
let chunk: Buffer | null
let thing: Buffer
const out: Buffer[] = []
while ((chunk = this._cache.get())) {
thing = this.cbc_decrypt(chunk)
out.push(thing)
}
return Buffer.concat(out)
}
setAutoPadding(setTo: boolean) {
return this
}
cbc_decrypt(block: Buffer) {
const pad = this._prev
this._prev = block
const out = this._cipher.decryptBlock(block)
return bufferXor(out, pad)
}
}
class Splitter {
cache = Buffer.allocUnsafe(0)
add(data: Buffer) {
this.cache = Buffer.concat([this.cache, data])
}
get() {
if (this.cache.length >= 16) {
const out = this.cache.slice(0, 16)
this.cache = this.cache.slice(16)
return out
}
return null
}
}
export function createDecipheriv(_suite: any, password: Buffer, iv: Buffer) {
if (iv.length !== 16) {
throw new TypeError(`invalid iv length ${iv.length}`)
}
if (password.length !== 256 / 8) {
throw new TypeError(`invalid key length ${password.length}`)
}
return new Decipher(password, iv)
}

View File

@ -52,9 +52,4 @@ export interface IAdapter {
* `window.crypto.subtle`. * `window.crypto.subtle`.
*/ */
subtle: SubtleCrypto subtle: SubtleCrypto
/**
* Equivalent to `createHmac("sha256", key).update(data).digest()`
*/
hmacSHA256(key: Buffer, data: Buffer): Buffer
} }

View File

@ -1,5 +1,5 @@
import { promises as fs, existsSync } from "fs" import { promises as fs, existsSync } from "fs"
import { webcrypto, createHmac } from "crypto" import { webcrypto } from "crypto"
import type { IAdapter } from "./index" import type { IAdapter } from "./index"
@ -7,7 +7,7 @@ import type { IAdapter } from "./index"
* Default Node.js adapter. This can be used while using `opvault.js` * Default Node.js adapter. This can be used while using `opvault.js`
* in a Node.js environment. * in a Node.js environment.
*/ */
export const adapter: IAdapter = { export const nodeAdapter: IAdapter = {
fs: { fs: {
readFile: path => fs.readFile(path, "utf-8"), readFile: path => fs.readFile(path, "utf-8"),
readBuffer: path => fs.readFile(path), readBuffer: path => fs.readFile(path),
@ -17,5 +17,4 @@ export const adapter: IAdapter = {
exists: async path => existsSync(path), exists: async path => existsSync(path),
}, },
subtle: (webcrypto as any).subtle, subtle: (webcrypto as any).subtle,
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
} }

View File

@ -0,0 +1,301 @@
/**
* Extracted from the following sources:
*
* | License | Name | Copyright |
* |---------|----------------|--------------------------------------------|
* | MIT | sha.js | (c) 2013-2018 sha.js contributors |
* | MIT | Crypto-js | (c) 2009-2013 Jeff Mott. |
* | MIT | browserify-aes | (c) 2014-2017 browserify-aes contributors |
*/
import { Buffer } from "buffer"
function bufferXor(a: Buffer, b: Buffer) {
const length = Math.min(a.length, b.length)
const buffer = Buffer.alloc(length)
for (let i = 0; i < length; ++i) {
buffer[i] = a[i] ^ b[i]
}
return buffer
}
function toUInt32Array(buf: Buffer) {
const len = (buf.length / 4) | 0
const out: number[] = new Array(len)
for (let i = 0; i < len; i++) {
out[i] = buf.readUInt32BE(i * 4)
}
return out
}
function cryptBlock(
M: number[],
keySchedule: number[],
subMix: number[][],
sbox: number[],
nRounds: number
) {
const [subMix_0, subMix_1, subMix_2, subMix_3] = subMix
let s0 = M[0] ^ keySchedule[0]
let s1 = M[1] ^ keySchedule[1]
let s2 = M[2] ^ keySchedule[2]
let s3 = M[3] ^ keySchedule[3]
let ksRow = 4
for (let round = 1; round < nRounds; round++) {
const t0 =
subMix_0[s0 >>> 24] ^
subMix_1[(s1 >>> 16) & 0xff] ^
subMix_2[(s2 >>> 8) & 0xff] ^
subMix_3[s3 & 0xff] ^
keySchedule[ksRow++]
const t1 =
subMix_0[s1 >>> 24] ^
subMix_1[(s2 >>> 16) & 0xff] ^
subMix_2[(s3 >>> 8) & 0xff] ^
subMix_3[s0 & 0xff] ^
keySchedule[ksRow++]
const t2 =
subMix_0[s2 >>> 24] ^
subMix_1[(s3 >>> 16) & 0xff] ^
subMix_2[(s0 >>> 8) & 0xff] ^
subMix_3[s1 & 0xff] ^
keySchedule[ksRow++]
const t3 =
subMix_0[s3 >>> 24] ^
subMix_1[(s0 >>> 16) & 0xff] ^
subMix_2[(s1 >>> 8) & 0xff] ^
subMix_3[s2 & 0xff] ^
keySchedule[ksRow++]
s0 = t0
s1 = t1
s2 = t2
s3 = t3
}
let t0 =
((sbox[s0 >>> 24] << 24) |
(sbox[(s1 >>> 16) & 0xff] << 16) |
(sbox[(s2 >>> 8) & 0xff] << 8) |
sbox[s3 & 0xff]) ^
keySchedule[ksRow++]
let t1 =
((sbox[s1 >>> 24] << 24) |
(sbox[(s2 >>> 16) & 0xff] << 16) |
(sbox[(s3 >>> 8) & 0xff] << 8) |
sbox[s0 & 0xff]) ^
keySchedule[ksRow++]
let t2 =
((sbox[s2 >>> 24] << 24) |
(sbox[(s3 >>> 16) & 0xff] << 16) |
(sbox[(s0 >>> 8) & 0xff] << 8) |
sbox[s1 & 0xff]) ^
keySchedule[ksRow++]
let t3 =
((sbox[s3 >>> 24] << 24) |
(sbox[(s0 >>> 16) & 0xff] << 16) |
(sbox[(s1 >>> 8) & 0xff] << 8) |
sbox[s2 & 0xff]) ^
keySchedule[ksRow++]
t0 = t0 >>> 0
t1 = t1 >>> 0
t2 = t2 >>> 0
t3 = t3 >>> 0
return [t0, t1, t2, t3]
}
// AES constants
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
const G = (() => {
// Compute double table
const d = new Array<number>(256)
for (let j = 0; j < 256; j++) {
if (j < 128) {
d[j] = j << 1
} else {
d[j] = (j << 1) ^ 0x11b
}
}
const sbox: number[] = []
const invSBox: number[] = []
const subMix: number[][] = [[], [], [], []]
const invSubMix: number[][] = [[], [], [], []]
// Walk GF(2^8)
let x = 0
let xi = 0
for (let i = 0; i < 256; ++i) {
// Compute sbox
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4)
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63
sbox[x] = sx
invSBox[sx] = x
// Compute multiplication
const x2 = d[x]
const x4 = d[x2]
const x8 = d[x4]
// Compute sub bytes, mix columns tables
let t = (d[sx] * 0x101) ^ (sx * 0x1010100)
subMix[0][x] = (t << 24) | (t >>> 8)
subMix[1][x] = (t << 16) | (t >>> 16)
subMix[2][x] = (t << 8) | (t >>> 24)
subMix[3][x] = t
// Compute inv sub bytes, inv mix columns tables
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100)
invSubMix[0][sx] = (t << 24) | (t >>> 8)
invSubMix[1][sx] = (t << 16) | (t >>> 16)
invSubMix[2][sx] = (t << 8) | (t >>> 24)
invSubMix[3][sx] = t
if (x === 0) {
x = xi = 1
} else {
x = x2 ^ d[d[d[x8 ^ x2]]]
xi ^= d[d[xi]]
}
}
return {
sbox,
invSBox,
subMix,
invSubMix,
}
})()
class AES {
private key: number[]
private nRounds!: number
private invKeySchedule!: number[]
constructor(key: Buffer) {
this.key = toUInt32Array(key)
this.reset()
}
private reset() {
const keyWords = this.key
const keySize = keyWords.length
const nRounds = keySize + 6
const ksRows = (nRounds + 1) * 4
const keySchedule: number[] = []
for (let k = 0; k < keySize; k++) {
keySchedule[k] = keyWords[k]
}
for (let k = keySize; k < ksRows; k++) {
let t = keySchedule[k - 1]
if (k % keySize === 0) {
t = (t << 8) | (t >>> 24)
t =
(G.sbox[t >>> 24] << 24) |
(G.sbox[(t >>> 16) & 0xff] << 16) |
(G.sbox[(t >>> 8) & 0xff] << 8) |
G.sbox[t & 0xff]
t ^= RCON[(k / keySize) | 0] << 24
} else if (keySize > 6 && k % keySize === 4) {
t =
(G.sbox[t >>> 24] << 24) |
(G.sbox[(t >>> 16) & 0xff] << 16) |
(G.sbox[(t >>> 8) & 0xff] << 8) |
G.sbox[t & 0xff]
}
keySchedule[k] = keySchedule[k - keySize] ^ t
}
const invKeySchedule = []
for (let ik = 0; ik < ksRows; ik++) {
const ksR = ksRows - ik
const tt = keySchedule[ksR - (ik % 4 ? 0 : 4)]
if (ik < 4 || ksR <= 4) {
invKeySchedule[ik] = tt
} else {
invKeySchedule[ik] =
G.invSubMix[0][G.sbox[tt >>> 24]] ^
G.invSubMix[1][G.sbox[(tt >>> 16) & 0xff]] ^
G.invSubMix[2][G.sbox[(tt >>> 8) & 0xff]] ^
G.invSubMix[3][G.sbox[tt & 0xff]]
}
}
this.nRounds = nRounds
this.invKeySchedule = invKeySchedule
}
decryptBlock(buffer: Buffer) {
const M = toUInt32Array(buffer)
// swap
const m1 = M[1]
M[1] = M[3]
M[3] = m1
const out = cryptBlock(M, this.invKeySchedule, G.invSubMix, G.invSBox, this.nRounds)
const buf = Buffer.allocUnsafe(16)
buf.writeUInt32BE(out[0], 0)
buf.writeUInt32BE(out[3], 4)
buf.writeUInt32BE(out[2], 8)
buf.writeUInt32BE(out[1], 12)
return buf
}
static blockSize = 4 * 4
static keySize = 256 / 8
}
function splitter() {
let cache = Buffer.allocUnsafe(0)
return {
add(data: Buffer) {
cache = Buffer.concat([cache, data])
return this
},
get() {
if (cache.length >= 16) {
const out = cache.slice(0, 16)
cache = cache.slice(16)
return out
}
return null
},
}
}
// AES-256-CBC
// == createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
export function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
if (iv.length !== 16) {
throw new TypeError(`invalid iv length ${iv.length}`)
}
if (key.length !== 32) {
throw new TypeError(`invalid key length ${key.length}`)
}
const cipher = new AES(key)
let prev = Buffer.from(iv)
const cache = splitter().add(data)
let chunk: Buffer | null
const res: Buffer[] = []
while ((chunk = cache.get())) {
const pad = prev
prev = chunk
const out = cipher.decryptBlock(chunk)
res.push(bufferXor(out, pad))
}
return Buffer.concat(res)
}

View File

@ -1,18 +1,16 @@
export function createEventEmitter<T = void>() { export function createEventEmitter<T = void>() {
type EventListener = T extends void ? () => void : (value: T) => void type EventListener = T extends void ? () => void : (value: T) => void
type Emitter = T extends void type Emitter = T extends void
? { (): void; (listener: EventListener): IDisposable } ? { (): void; (listener: EventListener): () => void }
: { (value: T): void; (listener: EventListener): IDisposable } : { (value: T): void; (listener: EventListener): () => void }
const listeners = new Set<EventListener>() const listeners = new Set<EventListener>()
function emitter(value: T | EventListener) { function emitter(value: T | EventListener) {
if (typeof value === "function") { if (typeof value === "function") {
listeners.add(value as EventListener) listeners.add(value as EventListener)
return { return () => {
dispose() { listeners.delete(value as EventListener)
listeners.delete(value as EventListener)
},
} }
} else { } else {
listeners.forEach(fn => fn(value)) listeners.forEach(fn => fn(value))

View File

@ -4,6 +4,8 @@ export class AssertionError extends OPVaultError {}
export class HMACAssertionError extends AssertionError {} export class HMACAssertionError extends AssertionError {}
export class NotUnlockedError extends AssertionError {}
export function invariant(condition: any, message?: string): asserts condition { export function invariant(condition: any, message?: string): asserts condition {
if (!condition) { if (!condition) {
throw new AssertionError(message) throw new AssertionError(message)

View File

@ -1,5 +1,7 @@
type integer = number type integer = number
interface IDisposable { declare namespace NodeJS {
dispose(): void interface Process {
browser?: boolean
}
} }

View File

@ -1,7 +1,6 @@
import json from "./res.json" import json from "./res.json"
const locale = const [locale] = Intl.DateTimeFormat().resolvedOptions().locale.split("-")
process.env.LOCALE || Intl.DateTimeFormat().resolvedOptions().locale.split("-")[0]
const mapValue = <T, R>( const mapValue = <T, R>(
object: Record<string, T>, object: Record<string, T>,

View File

@ -4,6 +4,9 @@ import type { IAdapter } from "./adapters"
import { asyncMap } from "./util" import { asyncMap } from "./util"
export type { Vault } from "./models/vault" export type { Vault } from "./models/vault"
export type { Item } from "./models/item"
export type { Attachment, AttachmentMetadata } from "./models/attachment"
export type { ItemField, ItemSection } from "./types"
export { Category, FieldType } from "./models" export { Category, FieldType } from "./models"
interface IOptions { interface IOptions {
@ -25,7 +28,10 @@ export class OnePassword {
readonly #path: string readonly #path: string
readonly #adapter: IAdapter readonly #adapter: IAdapter
constructor({ path, adapter = require("./adapters/node").adapter }: IOptions) { constructor({
path,
adapter = process.browser ? null : require("./adapters/node").nodeAdapter,
}: IOptions) {
this.#adapter = adapter this.#adapter = adapter
this.#path = path this.#path = path
} }

View File

@ -1,24 +1,22 @@
import { invariant } from "./errors"
export enum Category { export enum Category {
Login = 1, Login = "001",
CreditCard = 2, CreditCard = "002",
SecureNote = 3, SecureNote = "003",
Identity = 4, Identity = "004",
Password = 5, Password = "005",
Tombstone = 99, Tombstone = "099",
SoftwareLicense = 100, SoftwareLicense = "100",
BankAccount = 101, BankAccount = "101",
Database = 102, Database = "102",
DriverLicense = 103, DriverLicense = "103",
OutdoorLicense = 104, OutdoorLicense = "104",
Membership = 105, Membership = "105",
Passport = 106, Passport = "106",
Rewards = 107, Rewards = "107",
SSN = 108, SSN = "108",
Router = 109, Router = "109",
Server = 110, Server = "110",
Email = 111, Email = "111",
} }
export enum FieldType { export enum FieldType {
@ -31,9 +29,3 @@ export enum FieldType {
Checkbox = "C", Checkbox = "C",
URL = "U", URL = "U",
} }
export function getCategory(category: string) {
const int = parseInt(category)
invariant(int in Category, `Invalid category: ${category}`)
return int as Category
}

View File

@ -52,49 +52,48 @@ export class Attachment {
file.slice(0, 6).toString("utf-8") === "OPCLDA", file.slice(0, 6).toString("utf-8") === "OPCLDA",
"Attachment must start with OPCLDA" "Attachment must start with OPCLDA"
) )
invariant( // @TODO: Re-enable this
file.readIntLE(7, 1) === 1, false &&
"The version for this attachment file format is not supported." invariant(
) file.readIntLE(7, 1) === 1,
"The version for this attachment file format is not supported."
)
} }
get icon() { get icon() {
if (this.#icon == null) {
this.#decrypt()
}
return this.#icon! return this.#icon!
} }
get file() { get file() {
if (this.#file == null) {
this.#decrypt()
}
return this.#file! return this.#file!
} }
get metadata() { get metadata() {
if (this.#metadata == null) {
this.#decrypt()
}
return this.#metadata! return this.#metadata!
} }
#decrypt() { async unlock() {
const crypto = this.#crypto const crypto = this.#crypto
const cipher = crypto.deriveConcreteKey({ k: this.#k }) const cipher = await crypto.deriveConcreteKey({ k: this.#k })
const { metadataSize, iconSize } = this const { metadataSize, iconSize } = this
const buffer = this.#buffer const buffer = this.#buffer
this.#icon = crypto.decryptOPData( this.#icon = await crypto.decryptOPData(
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize), buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
cipher cipher
) )
this.#file = crypto.decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher) this.#file = await crypto.decryptOPData(
buffer.slice(16 + metadataSize + iconSize),
cipher
)
const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8")) const metadata = JSON.parse(buffer.slice(16, 16 + metadataSize).toString("utf-8"))
metadata.overview = JSON.parse( metadata.overview = JSON.parse(
crypto (
.decryptOPData(Buffer.from(metadata.overview, "base64"), crypto.overview) await crypto.decryptOPData(
.toString() Buffer.from(metadata.overview, "base64"),
crypto.overview
)
).toString()
) )
this.#metadata = metadata this.#metadata = metadata
} }

View File

@ -1,5 +1,5 @@
import { Buffer } from "buffer" import { Buffer } from "buffer"
import { createDecipheriv } from "../adapters/decipher" import { decryptData } from "../decipher"
import type { IAdapter } from "../adapters" import type { IAdapter } from "../adapters"
import { createEventEmitter } from "../ee" import { createEventEmitter } from "../ee"
import { HMACAssertionError } from "../errors" import { HMACAssertionError } from "../errors"
@ -16,21 +16,19 @@ export interface Cipher {
hmac: Buffer hmac: Buffer
} }
export class Crypto implements IDisposable { export class Crypto {
#disposables: IDisposable[] = [] #disposables: (() => void)[] = []
#locked = true #locked = true
#master!: Cipher #master!: Cipher
#overview!: Cipher #overview!: Cipher
private subtle: SubtleCrypto private subtle: SubtleCrypto
private hmacSHA256: IAdapter["hmacSHA256"]
readonly onLock = createEventEmitter<void>() readonly onLock = createEventEmitter<void>()
constructor(private readonly i18n: i18n, adapter: IAdapter) { constructor(private readonly i18n: i18n, adapter: IAdapter) {
this.subtle = adapter.subtle this.subtle = adapter.subtle
this.hmacSHA256 = adapter.hmacSHA256
} }
async unlock(profile: Profile, masterPassword: string) { async unlock(profile: Profile, masterPassword: string) {
@ -71,7 +69,7 @@ export class Crypto implements IDisposable {
this.#locked = true this.#locked = true
this.#master = null! this.#master = null!
this.#overview = null! this.#overview = null!
this.#disposables.forEach(fn => fn.dispose()) this.#disposables.forEach(fn => fn())
this.onLock() this.onLock()
} }
@ -90,66 +88,84 @@ export class Crypto implements IDisposable {
implementation: (value: K2) => V implementation: (value: K2) => V
) => { ) => {
const map = new Map<K2, V>() const map = new Map<K2, V>()
this.#disposables.push({ this.#disposables.push(() => map.clear())
dispose: () => map.clear(),
})
return (data: K) => setIfAbsent(map, deriveArg(data), implementation) return (data: K) => setIfAbsent(map, deriveArg(data), implementation)
} }
#createWeakCache = <K extends object, V>(implementation: (value: K) => V) => { #createWeakCache = <K extends object, V>(implementation: (value: K) => V) => {
let map = new WeakMap<K, V>() let map = new WeakMap<K, V>()
this.#disposables.push({ this.#disposables.push(() => {
dispose() { map = new WeakMap()
map = new WeakMap()
},
}) })
return (data: K) => setIfAbsent(map, data, implementation) return (data: K) => setIfAbsent(map, data, implementation)
} }
decryptItemDetails = this.#createWeakCache((item: EncryptedItem) => { decryptItemDetails = this.#createWeakCache(async (item: EncryptedItem) => {
const cipher = this.deriveConcreteKey(item) const cipher = await this.deriveConcreteKey(item)
const detail = this.decryptOPData(Buffer.from(item.d, "base64"), cipher) const detail = await this.decryptOPData(Buffer.from(item.d, "base64"), cipher)
return JSON.parse(detail.toString("utf-8")) as ItemDetails return JSON.parse(detail.toString("utf-8")) as ItemDetails
}) })
decryptItemOverview = this.#createCache( decryptItemOverview = this.#createCache(
(item: EncryptedItem) => item.o, (item: EncryptedItem) => item.o,
(o: string) => { async (o: string) => {
const overview = this.decryptOPData(Buffer.from(o, "base64"), this.#overview) const overview = await this.decryptOPData(Buffer.from(o, "base64"), this.#overview)
return JSON.parse(overview.toString("utf8")) as Overview return JSON.parse(overview.toString("utf8")) as Overview
} }
) )
deriveConcreteKey = this.#createCache( deriveConcreteKey = this.#createCache(
(data: { k: string }) => data.k, (data: { k: string }) => data.k,
($k: string) => { async ($k: string) => {
const k = Buffer.from($k, "base64") const k = Buffer.from($k, "base64")
const data = k.slice(0, -32) const data = k.slice(0, -32)
this.assertHMac(data, this.#master.hmac, k.slice(-32)) await this.assertHMac(data, this.#master.hmac, k.slice(-32))
const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16)) const derivedKey = await this.decryptData(
this.#master.key,
data.slice(0, 16),
data.slice(16)
)
return splitPlainText(derivedKey) return splitPlainText(derivedKey)
} }
) )
assertHMac(data: Buffer, key: Buffer, expected: Buffer) { async assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
const actual = this.hmacSHA256(key, data) const cryptoKey = await this.subtle.importKey(
if (!actual.equals(expected)) { "raw",
key,
{
name: "HMAC",
hash: {
name: "SHA-256",
},
},
false,
["verify"]
)
const verified = await this.subtle.verify("HMAC", cryptoKey, expected, data)
if (!verified) {
throw new HMACAssertionError() throw new HMACAssertionError()
} }
} }
decryptOPData(cipherText: Buffer, cipher: Cipher) { async decryptOPData(cipherText: Buffer, cipher: Cipher) {
const key = cipherText.slice(0, -32) const key = cipherText.slice(0, -32)
this.assertHMac(key, cipher.hmac, cipherText.slice(-32)) await this.assertHMac(key, cipher.hmac, cipherText.slice(-32))
const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32)) const plaintext = await this.decryptData(cipher.key, key.slice(16, 32), key.slice(32))
const size = readUint16(key.slice(8, 16)) const size = readUint16(key.slice(8, 16))
return plaintext.slice(-size) return plaintext.slice(-size)
} }
async decryptData(key: Buffer, iv: Buffer, data: Buffer) {
this.subtle
// return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
return decryptData(key, iv, data)
}
async decryptKeys(encryptedKey: string, derived: Cipher) { async decryptKeys(encryptedKey: string, derived: Cipher) {
const buffer = Buffer.from(encryptedKey, "base64") const buffer = Buffer.from(encryptedKey, "base64")
const base = this.decryptOPData(buffer, derived) const base = await this.decryptOPData(buffer, derived)
const digest = await this.subtle.digest("SHA-512", base) const digest = await this.subtle.digest("SHA-512", base)
return splitPlainText(Buffer.from(digest)) return splitPlainText(Buffer.from(digest))
} }
@ -164,10 +180,6 @@ export const splitPlainText = (derivedKey: Buffer): Cipher => ({
hmac: derivedKey.slice(32, 64), hmac: derivedKey.slice(32, 64),
}) })
function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
return createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false).update(data)
}
function readUint16({ buffer, byteOffset, length }: Buffer) { function readUint16({ buffer, byteOffset, length }: Buffer) {
return new DataView(buffer, byteOffset, length).getUint16(0, true) return new DataView(buffer, byteOffset, length).getUint16(0, true)
} }

View File

@ -1,6 +1,8 @@
import type { ItemDetails, Overview } from "../types" import type { ItemDetails, Overview } from "../types"
import type { Crypto } from "./crypto" import type { Crypto } from "./crypto"
import { Attachment } from "./attachment" import { Attachment } from "./attachment"
import { NotUnlockedError } from "../errors"
import type { Category } from "../models"
export interface EncryptedItem { export interface EncryptedItem {
category: string // "001" category: string // "001"
@ -14,27 +16,76 @@ export interface EncryptedItem {
tx: integer // Unix seconds tx: integer // Unix seconds
updated: integer // Unix seconds updated: integer // Unix seconds
uuid: string // 32 chars uuid: string // 32 chars
trashed?: boolean
} }
export class Item { export class Item {
#crypto: Crypto #crypto: Crypto
#data: EncryptedItem #data: EncryptedItem
#overview?: Overview
#details?: ItemDetails
attachments: Attachment[] = [] attachments: Attachment[] = []
get uuid() { // Unix milliseconds
get createdAt(): number {
return this.#data.created * 1000
}
get updatedAt(): number {
return this.#data.updated * 1000
}
get lastUsed() {
return this.#data.tx
}
get isDeleted() {
return this.#data.trashed
}
get category() {
return this.#data.category as Category
}
get uuid(): string {
return this.#data.uuid return this.#data.uuid
} }
get overview(): Overview { get overview(): Overview {
return this.#crypto.decryptItemOverview(this.#data) if (!this.#overview) {
throw new NotUnlockedError()
}
return this.#overview!
} }
get itemDetails(): ItemDetails { get details(): ItemDetails {
return this.#crypto.decryptItemDetails(this.#data) if (!this.#details) {
throw new NotUnlockedError()
}
return this.#details!
} }
constructor(crypto: Crypto, data: EncryptedItem) { constructor(crypto: Crypto, data: EncryptedItem) {
this.#crypto = crypto this.#crypto = crypto
this.#data = data this.#data = data
crypto.onLock(() => {
this.#overview = undefined!
this.#details = undefined!
this.attachments.forEach(file => file.dispose())
})
}
/** @internal */
async _unlockOverview() {
this.#overview = await this.#crypto.decryptItemOverview(this.#data)
return this
}
/** @internal */
async _unlockDetails() {
this.#details = await this.#crypto.decryptItemDetails(this.#data)
return this
}
/** @internal */
async _unlock() {
await this._unlockOverview()
await this._unlockDetails()
return this
} }
/** @internal */ /** @internal */

View File

@ -85,6 +85,13 @@ export class Vault {
return this.#items.map(x => x.uuid) return this.#items.map(x => x.uuid)
} }
async *values() {
this.#crypto.assertUnlocked()
for (const item of this.#items) {
yield await item._unlock()
}
}
/** /**
* Unlock this OnePassword vault. * Unlock this OnePassword vault.
* @param masterPassword User provided master password. Only the derived * @param masterPassword User provided master password. Only the derived
@ -115,17 +122,18 @@ export class Vault {
return this.#crypto.locked return this.#crypto.locked
} }
getItem(uuid: string): Item | undefined getItem(uuid: string): Promise<Item | undefined>
getItem(filter: { title: string }): Item | undefined getItem(filter: { title: string }): Promise<Item | undefined>
getItem(filter: any) { async getItem(filter: any) {
this.#crypto.assertUnlocked() this.#crypto.assertUnlocked()
if (typeof filter === "string") { if (typeof filter === "string") {
return this.#itemsMap.get(filter) return this.#itemsMap.get(filter)?._unlock()
} else { } else {
for (const value of this.#items) { for (const _value of this.#items) {
const value = await _value._unlockOverview()
if (value.overview.title === filter.title) { if (value.overview.title === filter.title) {
return value return value._unlockDetails()
} }
} }
} }

View File

@ -41,13 +41,29 @@ export type ItemField =
name: string name: string
} }
declare namespace ItemSection { export namespace ItemSection {
type A = { export type A = {
guarded: "yes" guarded: "yes"
clipboardFilter?: string clipboardFilter?: string
} }
type String = { export type Address = {
k: "address"
v: {
city: string
zip: string
state: string
country: string
street: string
}
n: "address"
a: {
guarded: "yes"
}
t: "address"
}
export type String = {
k: "string" k: "string"
v: string v: string
/** Unique name */ /** Unique name */
@ -56,13 +72,13 @@ declare namespace ItemSection {
/** User-readable title */ /** User-readable title */
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date" t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
} }
type Menu = { export type Menu = {
k: "menu" k: "menu"
v: string // "female" v: string // "female"
a: A a: A
t: string // "sex" t: string // "sex"
} }
type Date = { export type Date = {
k: "date" k: "date"
v: number // 359100000 v: number // 359100000
/** @example "birthdate" */ /** @example "birthdate" */
@ -71,19 +87,19 @@ declare namespace ItemSection {
/** @example "birth date" | "date of birth" */ /** @example "birth date" | "date of birth" */
t: string t: string
} }
type Gender = { export type Gender = {
k: "gender" k: "gender"
n: "sex" n: "sex"
v: string // "female" v: string // "female"
t: "sex" t: "sex"
} }
type MonthYear = { export type MonthYear = {
k: "monthYear" k: "monthYear"
n: string // "expiry_date" n: string // "expiry_date"
v: number // 2515 v: number // 2515
t: string // "expiry date" t: string // "expiry date"
} }
type Concealed = { export type Concealed = {
k: "concealed" k: "concealed"
n: "password" n: "password"
v: string v: string
@ -94,7 +110,7 @@ declare namespace ItemSection {
} }
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
type Any = String | Menu | Date | Gender | MonthYear | Concealed export type Any = String | Menu | Date | Gender | MonthYear | Concealed | Address
} }
// One of them is empty?, 0C4F27910A64488BB339AED63565D148 // One of them is empty?, 0C4F27910A64488BB339AED63565D148
@ -105,7 +121,7 @@ export interface ItemDetails {
htmlMethod: "post" | "get" htmlMethod: "post" | "get"
} }
notesPlain?: string notesPlain?: string
sections: { sections?: {
/** @example "name" | "title" | "internet" */ /** @example "name" | "title" | "internet" */
name: string name: string
/** @example "Identification" | "Address" | "Internet Details" */ /** @example "Identification" | "Address" | "Internet Details" */

View File

@ -1,5 +1,3 @@
import { invariant } from "./errors"
export function asyncMap<T, R>( export function asyncMap<T, R>(
list: T[], list: T[],
fn: (value: T, index: number, list: T[]) => Promise<R> fn: (value: T, index: number, list: T[]) => Promise<R>
@ -35,19 +33,3 @@ export function once<T extends (...args: any[]) => any>(fn: T): T {
} }
return res as any return res as any
} }
const cache = (): MethodDecorator => (_, key, descriptor: any) => {
if (process.env.NODE_ENV !== "production") {
invariant(typeof key === "string")
invariant(descriptor.get != null)
}
const cacheMap = new WeakMap()
const fn = descriptor.get
descriptor.get = function () {
if (!cacheMap.has(this)) {
cacheMap.set(this, fn.call(this))
}
return cacheMap.get(this)!
}
}

View File

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

5
packages/web/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
bundle
*.local

33
packages/web/esbuild.js Executable file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env node
// @ts-check
const { builtinModules } = require("module")
const { build } = require("esbuild")
const args = process.argv.slice(2)
build({
bundle: true,
define: {},
entryPoints: [
"./src/electron/index.ts",
// "./src/electron/preload.ts"
],
outdir: "./dist/main",
external: builtinModules.concat("electron"),
target: ["chrome90"],
tsconfig: "./tsconfig.json",
sourcemap: "external",
minify: process.env.NODE_ENV === "production",
banner: {
js: "/* eslint-disable */",
},
loader: {
".png": "file",
".eot": "file",
".svg": "file",
".woff": "file",
".woff2": "file",
".ttf": "file",
},
watch: args.includes("-w") || args.includes("--watch"),
})

17
packages/web/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
/>
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPVault Viewer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

51
packages/web/package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "opvault-web",
"version": "0.0.0",
"main": "dist/main/index.js",
"author": "proteria",
"license": "GPL-3.0-only",
"description": "OnePassword local vault viewer",
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"start": "NODE_ENV=development electron --enable-transparent-visuals --disable-gpu ./dist/main/index.js",
"bundle": "./scripts/build.sh"
},
"devDependencies": {
"@emotion/css": "^11.5.0",
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@vitejs/plugin-react": "^1.0.0",
"buffer": "^6.0.3",
"electron": "^15.2.0",
"electron-builder": "^22.13.1",
"esbuild": "^0.13.6",
"opvault.js": "*",
"path-browserify": "^1.0.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-icons": "^4.3.1",
"sass": "^1.43.4",
"typescript": "^4.3.2",
"vite": "^2.6.4"
},
"build": {
"appId": "com.proteria.opvault",
"productName": "OPVault Viewer",
"files": [
"**/*"
],
"directories": {
"output": "bundle",
"app": "dist"
},
"linux": {
"executableName": "opvault",
"icon": "1p.png",
"category": "Utility"
}
}
}

View File

@ -0,0 +1,15 @@
#!/usr/bin/env node
const fs = require("fs")
const { resolve } = require("path")
const json = require("../package.json")
json.name = "OPVault"
json.main = "main/index.js"
delete json.scripts
delete json.devDependencies
delete json.build
fs.writeFileSync(
resolve(__dirname, "../dist/package.json"),
JSON.stringify(json, null, 2)
)

5
packages/web/scripts/build.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
yarn build
NODE_ENV=production ./esbuild.js
./scripts/build-package-json.js
./node_modules/.bin/electron-builder build

45
packages/web/src/App.tsx Normal file
View File

@ -0,0 +1,45 @@
import { useCallback, useState } from "react"
import type { Vault } from "opvault.js"
import { OnePassword } from "opvault.js"
import { getBrowserAdapter } from "opvault.js/src/adapters/browser"
import { VaultView } from "./pages/Vault"
import { PickOPVault } from "./pages/PickOPVault"
import { Unlock } from "./pages/Unlock"
export const App: React.FC = () => {
const [instance, setInstance] = useState<OnePassword>()
const [vault, setVault] = useState<Vault>()
const unlock = useCallback(
async (profile: string, password: string) => {
const vault = await instance!.getProfile(profile!)
await vault.unlock(password)
setVault(vault)
},
[instance]
)
const setHandle = useCallback(async (handle: FileSystemDirectoryHandle) => {
const adapter = getBrowserAdapter(handle)
const instance = new OnePassword({ path: "/", adapter })
setInstance(instance)
}, [])
const onLock = useCallback(() => {
vault?.lock()
setVault(undefined)
}, [vault])
if (!instance) {
return <PickOPVault setHandle={setHandle} />
}
if (!vault) {
return <Unlock instance={instance} onUnlock={unlock} />
}
return (
<div>
<VaultView onLock={onLock} vault={vault} />
</div>
)
}

View File

@ -0,0 +1,90 @@
import { Category } from "opvault.js"
import { cx, css } from "@emotion/css"
import { BsBank2, BsPeopleFill } from "react-icons/bs"
import { CgLogIn } from "react-icons/cg"
import { HiMail, HiIdentification } from "react-icons/hi"
import { RiGovernmentLine } from "react-icons/ri"
import {
FaArchive,
FaDatabase,
FaPassport,
FaServer,
FaFish,
FaGift,
FaCar,
FaWifi,
} from "react-icons/fa"
import { GrLicense, GrNotes, GrCreditCard } from "react-icons/gr"
import { MdPassword } from "react-icons/md"
function getComponent(category: Category) {
switch (category) {
case Category.BankAccount:
return BsBank2
case Category.CreditCard:
return GrCreditCard
case Category.Database:
return FaDatabase
case Category.DriverLicense:
return FaCar
case Category.Email:
return HiMail
case Category.Identity:
return HiIdentification
case Category.Login:
return CgLogIn
case Category.Membership:
return BsPeopleFill
case Category.OutdoorLicense:
return FaFish
case Category.Passport:
return FaPassport
case Category.Password:
return MdPassword
case Category.Rewards:
return FaGift
case Category.Router:
return FaWifi
case Category.SecureNote:
return GrNotes
case Category.Server:
return FaServer
case Category.SoftwareLicense:
return GrLicense
case Category.SSN:
return RiGovernmentLine
case Category.Tombstone:
return FaArchive
default:
category
}
}
export const reactIconClass = css`
fill: var(--color);
@media (prefers-color-scheme: dark) {
path:not([fill="none"]),
path[stroke] {
fill: #fff;
}
}
`
interface CategoryIconProps {
className?: string
style?: React.CSSProperties
fill?: string
category: Category
}
export const CategoryIcon: React.FC<CategoryIconProps> = ({
className,
category,
style,
fill,
}) => {
const Component = getComponent(category)
return Component ? (
<Component className={cx(reactIconClass, className)} fill={fill} style={style} />
) : null
}

View File

@ -0,0 +1,51 @@
import type { ErrorInfo } from "react"
import React from "react"
import styled from "@emotion/styled"
/**
* @module ErrorBoundary
* React HOC to restrict an Error from blowing up the entire application.
*/
type State = { error?: Error; info?: ErrorInfo }
const Div = styled.div`
border: 1px solid #ddd;
border-radius: 10px;
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 1px;
margin: 20px;
padding: 20px;
`
const Header = styled.h2`
font-weight: 600;
margin: 0;
`
const Pre = styled.pre`
font-size: 15px;
line-height: 1.3em;
`
export class ErrorBoundary extends React.Component<any, State> {
state: State = {}
componentDidCatch(error: Error, info: ErrorInfo) {
this.setState({ error, info })
}
render() {
const { error, info } = this.state
if (error) {
console.error(error)
return (
<Div>
<Header>Error: {error.message}</Header>
<Pre>{info?.componentStack?.replace(/^\n/, "")}</Pre>
<Pre>{error.stack}</Pre>
</Div>
)
}
return <>{this.props.children}</>
}
}

View File

@ -0,0 +1,142 @@
import styled from "@emotion/styled"
import type { Attachment, AttachmentMetadata, Item } from "opvault.js"
import { useEffect, useState } from "react"
import { CategoryIcon } from "./CategoryIcon"
import { ItemDates } from "./ItemDates"
import {
ItemFieldView,
FieldContainer,
FieldTitle,
ItemDetailsFieldView,
} from "./ItemField"
import { ItemWarning } from "./ItemWarning"
interface ItemViewProps {
item: Item
}
const Header = styled.div`
display: flex;
align-items: center;
`
const Icon = styled(CategoryIcon)`
font-size: 2em;
margin-right: 5px;
`
const SectionTitle = styled.div`
font-size: 85%;
font-weight: 600;
text-transform: uppercase;
margin: 20px 0 10px;
`
const Tag = styled.div`
display: inline-block;
margin-top: 2px;
margin-right: 5px;
border-radius: 4px;
padding: 3px 7px;
background-color: var(--label-background);
`
const ExtraField = styled(FieldContainer)`
margin-bottom: 20px;
`
const ItemTitle = styled.h2``
const Container = styled.div`
height: 100%;
overflow: auto;
padding: 0 10px;
`
const Inner = styled.div`
padding: 10px 0;
`
const AttachmentContainer = styled.div`
display: flex;
margin: 5px 0;
`
export const ItemView: React.FC<ItemViewProps> = ({ item }) => (
<Container>
<Inner>
<ItemWarning item={item} />
<Header>
{item.details.fields == null}
<Icon category={item.category} />
<ItemTitle>{item.overview.title}</ItemTitle>
</Header>
<details>
<summary>JSON</summary>
<pre>
{JSON.stringify({ overview: item.overview, details: item.details }, null, 2)}
</pre>
</details>
<div style={{ marginBottom: 20 }}>
{item.details.sections
?.filter(s => s.fields?.some(x => x.v != null))
.map((section, i) => (
<div key={i}>
{section.title != null && <SectionTitle>{section.title}</SectionTitle>}
{section.fields?.map((field, j) => (
<ItemFieldView key={j} field={field} />
))}
</div>
))}
</div>
{!!item.details.fields?.length && (
<div style={{ marginBottom: 20 }}>
{item.details.fields!.map((field, i) => (
<ItemDetailsFieldView key={i} field={field} />
))}
</div>
)}
{item.details.notesPlain != null && (
<ExtraField>
<FieldTitle>notes</FieldTitle>
<div>
<p>{item.details.notesPlain}</p>
</div>
</ExtraField>
)}
{!!item.overview.tags?.length && (
<ExtraField>
<FieldTitle>tags</FieldTitle>
<div>
{item.overview.tags!.map((tag, i) => (
<Tag key={i}>{tag}</Tag>
))}
</div>
</ExtraField>
)}
{item.attachments.length > 0 && (
<ExtraField>
<FieldTitle>attachments</FieldTitle>
<div>
{item.attachments.map((file, i) => (
<AttachmentView key={i} file={file} />
))}
</div>
</ExtraField>
)}
<ExtraField>
<ItemDates item={item} />
</ExtraField>
</Inner>
</Container>
)
function AttachmentView({ file }: { file: Attachment }) {
const [metadata, setMetadata] = useState<AttachmentMetadata>()
useEffect(() => {
file.unlock().then(() => setMetadata(file.metadata))
}, [file])
if (!metadata) return null
return <AttachmentContainer>{metadata.overview.filename}</AttachmentContainer>
}

View File

@ -0,0 +1,16 @@
import styled from "@emotion/styled"
import type { Item } from "opvault.js"
const Container = styled.div`
text-align: center;
font-size: 90%;
line-height: 1.5em;
opacity: 0.5;
`
export const ItemDates: React.FC<{ item: Item }> = ({ item }) => (
<Container>
<div>Last Updated: {new Date(item.updatedAt).toLocaleString()}</div>
<div>Created: {new Date(item.createdAt).toLocaleString()}</div>
</Container>
)

View File

@ -0,0 +1,48 @@
import styled from "@emotion/styled"
import type { ItemField, ItemSection } from "opvault.js"
import { ErrorBoundary } from "./ErrorBoundary"
import { ItemFieldValue, ItemDetailsFieldValue } from "./ItemFieldValue"
export { Container as FieldContainer }
const Container: React.FC = styled.div`
padding: 5px 0;
margin-bottom: 3px;
`
export const FieldTitle: React.FC = styled.div`
font-size: 85%;
margin-bottom: 3px;
`
export const ItemFieldView: React.FC<{
field: ItemSection.Any
}> = ({ field }) => {
if (field.v == null) {
return null
}
return (
<ErrorBoundary>
<Container>
<FieldTitle>{field.t}</FieldTitle>
<ItemFieldValue field={field} />
</Container>
</ErrorBoundary>
)
}
export const ItemDetailsFieldView: React.FC<{
field: ItemField
}> = ({ field }) => {
if (field.value == null) {
return null
}
return (
<ErrorBoundary>
<Container>
<FieldTitle>{field.name}</FieldTitle>
<ItemDetailsFieldValue field={field} />
</Container>
</ErrorBoundary>
)
}

View File

@ -0,0 +1,100 @@
import { useCallback, useEffect, useState } from "react"
import styled from "@emotion/styled"
const Container = styled.menu`
background-color: #fff;
border-radius: 3px;
box-shadow: #0004 0px 1px 4px;
left: 99%;
margin-block-start: 0;
min-width: 120px;
padding-inline-start: 0;
position: absolute;
top: 0;
user-select: none;
z-index: 2;
@media (prefers-color-scheme: dark) {
background-color: #3c3c3c;
box-shadow: rgb(0 0 0) 0px 2px 4px;
color: #f0f0f0;
}
& & {
display: none;
}
`
const Separator = styled.div`
border-bottom: 1px solid #777;
margin-top: 0.4em;
margin-bottom: 0.4em;
margin-left: 0.6em;
margin-right: 0.6em;
`
const Item = styled.div`
cursor: default;
font-size: 13px;
flex: 1 1 auto;
display: flex;
height: 2.5em;
align-items: center;
padding-left: 1em;
position: relative;
&:hover {
background-color: #ddd;
border-radius: 3px;
.item-field-context-menu {
display: block;
}
@media (prefers-color-scheme: dark) {
background-color: #094771;
}
}
`
function useContextMenu() {
const [show, setShow] = useState(false)
const [pos, setPos] = useState({ x: 0, y: 0 })
const onRightClick = useCallback((e: React.MouseEvent) => {
setShow(true)
e.preventDefault()
setPos({ x: e.pageX, y: e.pageY })
}, [])
useEffect(() => {
const fn = () => setShow(false)
document.addEventListener("click", fn)
return () => document.removeEventListener("click", fn)
}, [])
return {
show,
position: {
top: pos.y,
left: pos.x,
},
onRightClick,
}
}
export function useItemFieldContextMenu() {
const { onRightClick, position, show } = useContextMenu()
const ContextMenuContainer: React.FC = useCallback(
({ children }) => {
if (!show) return null
return (
<Container style={position} className="item-field-context-menu">
{children}
</Container>
)
},
[show, position]
)
return {
onRightClick,
Item,
ContextMenuContainer,
}
}

View File

@ -0,0 +1,103 @@
import styled from "@emotion/styled"
import type { ItemSection, ItemField } from "opvault.js"
import { FieldType } from "opvault.js"
import { useCallback, useMemo, useState } from "react"
import { parseMonthYear } from "../utils"
import { ErrorBoundary } from "./ErrorBoundary"
import { useItemFieldContextMenu } from "./ItemFieldContextMenu"
const Container = styled.div``
const Password: React.FC<{
field: ItemSection.Concealed
}> = ({ field }) => {
const [show, setShow] = useState(false)
const { onRightClick, ContextMenuContainer, Item } = useItemFieldContextMenu()
const onToggle = useCallback(() => setShow(x => !x), [])
const onCopy = useCallback(() => {
navigator.clipboard.writeText(field.v)
}, [field.v])
return (
<>
<Container
onContextMenu={onRightClick}
onDoubleClick={() => setShow(x => !x)}
style={{
fontFamily: "var(--monospace)",
...(!show && { userSelect: "none" }),
}}
>
{show ? field.v : "·".repeat(10)}
</Container>
<ContextMenuContainer>
<Item onClick={onCopy}>Copier</Item>
<Item onClick={onToggle}>{show ? "Cacher" : "Afficher"}</Item>
</ContextMenuContainer>
</>
)
}
const MonthYear: React.FC<{ field: ItemSection.MonthYear }> = ({ field }) => {
const { year, month } = parseMonthYear(field.v)
return (
<Container>
{month.toString().padStart(2, "0")}/{year.toString().padStart(4, "0")}
</Container>
)
}
const DateView: React.FC<{ field: ItemSection.Date }> = ({ field }) => {
const date = useMemo(() => new Date(field.v * 1000), [field.v])
return <Container>{date.toLocaleDateString()}</Container>
}
export const ItemFieldValue: React.FC<{
field: ItemSection.Any
}> = ({ field }) => {
if (field.v == null) {
return null
}
switch (field.k) {
case "concealed":
return <Password field={field} />
case "monthYear":
return <MonthYear field={field} />
case "date":
return <DateView field={field} />
case "address":
return (
<Container style={{ whiteSpace: "pre" }}>
<div>{field.v.street}</div>
<div>
{field.v.city}, {field.v.state} ({field.v.zip})
</div>
<div>{field.v.country}</div>
</Container>
)
}
return (
<ErrorBoundary>
<Container>{field.v}</Container>
</ErrorBoundary>
)
}
export const ItemDetailsFieldValue: React.FC<{
field: ItemField
}> = ({ field }) => {
if (
field.type === FieldType.Password ||
(field.type === FieldType.Text && field.designation === "password")
) {
return <Password field={{ v: field.value } as any} />
}
return (
<ErrorBoundary>
<Container>{field.value}</Container>
</ErrorBoundary>
)
}

View File

@ -0,0 +1,71 @@
import styled from "@emotion/styled"
import { cx } from "@emotion/css"
import type { Item } from "opvault.js"
import { CategoryIcon } from "./CategoryIcon"
interface ListProps {
items: Item[]
selected?: Item
onSelect(item: Item): void
}
const Container = styled.div``
const List = styled.ol`
list-style: none;
padding: 0;
`
const ItemView = styled.li`
border-radius: 5px;
display: grid;
padding: 5px 15px;
transition: background-color 0.1s;
align-items: center;
cursor: default;
grid-template-columns: 35px 1fr;
&:hover {
background-color: var(--hover-background);
}
&.selected {
background-color: var(--selected-background);
}
&.trashed {
opacity: 0.6;
}
`
const ItemTitle = styled.div`
font-weight: 600;
margin-bottom: 2px;
`
const ItemDescription = styled.div`
font-size: 95%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 230px;
`
const Icon = styled(CategoryIcon)`
font-size: 1.5em;
`
export const ItemList: React.FC<ListProps> = ({ items, onSelect, selected }) => (
<Container>
<List>
{items.map(item => (
<ItemView
key={item.uuid}
onClick={() => onSelect(item)}
className={cx({
selected: selected?.uuid === item.uuid,
trashed: item.isDeleted,
})}
>
<Icon fill="#FFF" category={item.category} />
<div>
<ItemTitle>{item.overview.title!}</ItemTitle>
<ItemDescription>{item.overview.ainfo}</ItemDescription>
</div>
</ItemView>
))}
</List>
</Container>
)

View File

@ -0,0 +1,41 @@
import styled from "@emotion/styled"
import type { Item } from "opvault.js"
import { useMemo } from "react"
import { parseMonthYear } from "../utils"
const Container = styled.div`
background: #cdc7b2;
border-radius: 5px;
padding: 15px;
@media (prefers-color-scheme: dark) {
background: #575345;
}
`
export const ItemWarning: React.FC<{ item: Item }> = ({ item }) => {
const isExpired = useMemo(() => {
const fields = item.details.sections?.flatMap(x => x.fields ?? [])
if (!fields?.length) return false
for (const field of fields) {
if (field.k === "monthYear") {
const { year, month } = parseMonthYear(field.v)
const now = new Date()
const currentYear = now.getFullYear()
return currentYear > year || (currentYear === year && now.getMonth() + 1 > month)
} else if (field.k === "date") {
const now = Date.now()
const fieldDate = new Date(field.v * 1000).valueOf()
return now > fieldDate
}
}
return false
}, [item])
if (isExpired) {
return <Container>Expired</Container>
}
return null
}

View File

@ -0,0 +1,23 @@
import styled from "@emotion/styled"
const Container = styled.div`
background: linear-gradient(to bottom, #292929, #202020);
border-bottom: 1px solid #070707;
border-radius: 5px 5px 0 0;
height: var(--titlebar-height);
-webkit-app-region: drag;
display: flex;
align-items: center;
text-align: center;
`
const Title = styled.div`
text-align: center;
font-weight: 600;
flex-grow: 1;
`
export const TitleBar = () => (
<Container>
<Title>OPVault Viewer</Title>
</Container>
)

View File

@ -0,0 +1,19 @@
import { useCallback } from "react"
export const VaultPicker: React.FC<{
setHandle(handle: FileSystemDirectoryHandle): void
}> = ({ setHandle }) => {
const onClick = useCallback(async () => {
try {
const handle = await showDirectoryPicker()
setHandle(handle)
} catch (e) {
if ((e as Error).name === "AbortError") {
return
}
alert(e)
}
}, [setHandle])
return <button onClick={onClick}>Pick a vault here.</button>
}

View File

@ -1,25 +1,38 @@
// @ts-check // @ts-check
// Modules to control application life and create native browser window // Modules to control application life and create native browser window
// const path = require("path") // import { join } from "path"
const { app, BrowserWindow } = require("electron") import { app, BrowserWindow, Menu } from "electron"
function createWindow() { function createWindow() {
// Create the browser window. // Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 800, width: 800,
height: 600, height: 650,
// frame: false,
// transparent: true,
webPreferences: { webPreferences: {
nodeIntegration: true, contextIsolation: true,
contextIsolation: false, // preload: join(__dirname, "preload.js"),
// preload: path.join(__dirname, "preload.js"),
}, },
}) })
// and load the index.html of the app. mainWindow.webContents.session.enableNetworkEmulation({
mainWindow.loadFile("index.html") offline: true,
})
// Open the DevTools. Menu.setApplicationMenu(null)
// mainWindow.webContents.openDevTools()
// and load the index.html of the app.
if (process.env.NODE_ENV === "development") {
mainWindow.loadURL("http://localhost:3000")
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile("./web/index.html")
}
if (process.env.DEBUG) {
mainWindow.webContents.openDevTools()
}
} }
// This method will be called when Electron has finished // This method will be called when Electron has finished

View File

@ -0,0 +1,4 @@
import { contextBridge } from "electron"
import { nodeAdapter } from "opvault.js/src/adapters/node"
contextBridge.exposeInMainWorld("nodeAdapter", nodeAdapter)

113
packages/web/src/index.scss Normal file
View File

@ -0,0 +1,113 @@
@mixin scheme($property, $light-value, $dark-value) {
#{$property}: $light-value;
@media (prefers-color-scheme: dark) {
#{$property}: $dark-value;
}
}
body {
background: transparent;
margin: 0;
overflow: hidden;
font-size: 15px;
font-family: -apple-system, BlinkMacSystemFont, system-ui, "Roboto", "Oxygen", "Ubuntu",
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--color: #000;
--titlebar-height: 46px;
--titlebar-height: 0px;
--label-background: #ddd;
--selected-background: #c9c9c9;
--hover-background: #ddd;
--monospace: D2Coding, source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
html,
body,
#root {
height: 100%;
}
@media (prefers-color-scheme: dark) {
body {
color: #fff;
--color: #fff;
--label-background: #353535;
--selected-background: #353535;
--selected-background: #15539e;
--hover-background: #222;
}
#root {
background-color: #292929;
}
}
pre,
code {
font-family: var(--monospace);
}
input {
font-family: inherit;
font-size: inherit;
}
input[type="search"],
input[type="input"],
input[type="password"] {
@include scheme(background-color, #fff, #2d2d2d);
border-radius: 6px;
border: 1px solid;
@include scheme(border-color, #cdc7c2, #1b1b1b);
color: inherit;
outline: none;
padding: 7px 8px;
transition: 0.1s;
&:focus {
@include scheme(border-color, #3584e480, #15539e);
}
}
button,
select {
@include scheme(background-color, #f6f5f4, #333);
border-radius: 4px;
border: 1px solid;
@include scheme(border-color, #cdc7c2, #1b1b1b);
color: inherit;
font-family: inherit;
&:hover {
@include scheme(background-color, #f9f9f8, #363636);
}
&:active {
@include scheme(background-color, #d6d1cd, #292929);
}
}
button {
font-size: 16px;
padding: 8px 15px;
box-shadow: rgb(0 0 0 / 7%) 0px 1px 2px;
transition: 0.1s;
}
button[type="submit"] {
background-color: #15539e;
color: #fff;
}
select {
padding: 5px 10px;
}
::-webkit-scrollbar {
width: 7px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@include scheme(background, #8883, #6663);
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
transition: 0.1s;
@include scheme(background, #ddd, #555);
}

12
packages/web/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react"
import { render } from "react-dom"
import { App } from "./App"
import "./index.scss"
render(
<React.StrictMode>
{/* <TitleBar /> */}
<App />
</React.StrictMode>,
document.getElementById("root")
)

View File

@ -0,0 +1,20 @@
import styled from "@emotion/styled"
import { VaultPicker } from "../components/VaultPicker"
const Container = styled.div`
width: 800px;
padding: 100px;
text-align: center;
`
const Info = styled.div`
margin: 10px;
`
export const PickOPVault: React.FC<{
setHandle(handle: FileSystemDirectoryHandle): void
}> = ({ setHandle }) => (
<Container>
<VaultPicker setHandle={setHandle} />
<Info>No vault is picked.</Info>
</Container>
)

View File

@ -0,0 +1,54 @@
import type { OnePassword } from "opvault.js"
import styled from "@emotion/styled"
import { useCallback, useEffect, useState } from "react"
const Container = styled.div`
padding: 20px;
text-align: center;
`
export const Unlock: React.FC<{
instance: OnePassword
onUnlock(profile: string, password: string): void
}> = ({ onUnlock, instance }) => {
const [profiles, setProfiles] = useState<string[]>(() => [])
const [profile, setProfile] = useState<string>()
const [password, setPassword] = useState("")
const unlock = useCallback(() => {
if (!profile) return
onUnlock(profile, password)
setPassword("")
}, [onUnlock, profile, password])
useEffect(() => {
instance.getProfileNames().then(profiles => {
setProfiles(profiles)
setProfile(profiles[0])
})
}, [instance])
return (
<Container>
<div>
<select value={profile} onChange={e => setProfile(e.currentTarget.value)}>
{profiles.map(p => (
<option key={p} value={p}>
Vault: {p}
</option>
))}
</select>
</div>
<div style={{ margin: "10px 0" }}>
<input
type="password"
value={password}
onChange={e => setPassword(e.currentTarget.value)}
/>
</div>
<button type="submit" disabled={!profile || !password} onClick={unlock}>
Unlock
</button>
</Container>
)
}

View File

@ -0,0 +1,142 @@
import styled from "@emotion/styled"
import { useCallback, useEffect, useMemo, useState } from "react"
import type { Vault, Item } from "opvault.js"
import { Category } from "opvault.js"
import { IoSearch } from "react-icons/io5"
import { ItemList } from "../components/ItemList"
import { ItemView } from "../components/Item"
import { reactIconClass } from "../components/CategoryIcon"
const Container = styled.div`
display: flex;
height: calc(100vh - var(--titlebar-height));
`
const ListContainer = styled.div`
width: 300px;
margin-right: 10px;
overflow-y: scroll;
overflow-x: hidden;
@media (prefers-color-scheme: dark) {
background: #202020;
}
`
const ItemContainer = styled.div`
width: calc(100% - 300px);
overflow: hidden;
`
const SearchContainer = styled.div`
text-align: center;
margin: 10px 0;
position: relative;
`
const SortContainer = styled.div`
margin: 10px 10px;
`
const SearchInput = styled.input`
--margin: 10px;
width: calc(100% - var(--margin) * 2);
margin: 0 var(--margin);
padding-left: 2em !important;
`
const SearchIcon = styled(IoSearch)`
position: absolute;
top: 9px;
left: 20px;
`
const enum SortBy {
Name,
CreatedAt,
UpdatedAt,
}
export const VaultView: React.FC<{ vault: Vault; onLock(): void }> = ({
vault,
onLock,
}) => {
const [items, setItems] = useState<Item[]>(() => [])
const [item, setItem] = useState<Item>()
const [sortBy, setSortBy] = useState(SortBy.Name)
const [search, setSearch] = useState("")
const compareFn = useMemo((): ((a: Item, b: Item) => number) => {
switch (sortBy) {
case SortBy.Name:
return (a, b) => (a.overview.title ?? "").localeCompare(b?.overview.title ?? "")
case SortBy.CreatedAt:
return (a, b) => a.createdAt - b.createdAt
case SortBy.UpdatedAt:
return (a, b) => a.updatedAt - b.updatedAt
}
}, [sortBy])
const sortedItem = useMemo(() => items.slice().sort(compareFn), [items, compareFn])
useEffect(() => {
setItem(undefined)
arrayFrom(vault.values()).then(setItems)
}, [vault])
const filtered = useMemo(
() =>
sortedItem
.filter(x => x.category !== Category.Tombstone)
.filter(
search
? x =>
stringCompare(search, x.overview.title) ||
stringCompare(search, x.overview.ainfo)
: () => true
),
[sortedItem, search]
)
return (
<Container>
<ListContainer>
<div
style={{
margin: "10px 10px",
}}
>
<button onClick={onLock}>Lock</button>
</div>
<SearchContainer>
<SearchInput
type="search"
value={search}
onChange={e => setSearch(e.currentTarget.value)}
/>
<SearchIcon className={reactIconClass} />
</SearchContainer>
<SortContainer>
<select
style={{ width: "100%" }}
value={sortBy}
onChange={e => setSortBy(+e.currentTarget.value)}
>
<option value={SortBy.Name}>Sort by Name</option>
<option value={SortBy.CreatedAt}>Sort by Created Time</option>
<option value={SortBy.UpdatedAt}>Sort by Updated Time</option>
</select>
</SortContainer>
<ItemList items={filtered} onSelect={setItem} selected={item} />
</ListContainer>
<ItemContainer>{item && <ItemView item={item} />}</ItemContainer>
</Container>
)
}
async function arrayFrom<T>(generator: AsyncGenerator<T, void, unknown>) {
const list: T[] = []
for await (const value of generator) {
list.push(value)
}
return list
}
function stringCompare(search: string, source?: string) {
if (!search) return true
if (!source) return false
return source.toLocaleLowerCase().includes(search.toLocaleLowerCase())
}

View File

@ -0,0 +1,5 @@
export function parseMonthYear(v: number) {
const year = Math.floor(v / 100)
const month = v % 100
return { year, month }
}

1
packages/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

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

View File

@ -0,0 +1,21 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
// https://vitejs.dev/config/
export default defineConfig({
base: "./",
plugins: [react()],
define: {
global: "globalThis",
"process.browser": "true",
"process.env.NODE_DEBUG": "false",
},
build: {
outDir: "dist/web",
},
resolve: {
alias: {
path: require.resolve("path-browserify"),
},
},
})

2176
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

45
repl.ts
View File

@ -1,45 +0,0 @@
#!/usr/bin/env -S node -r ts-node/register/transpile-only
import fs from "fs"
import chalk from "chalk"
import prompts from "prompts"
import { OnePassword } from "./src/index"
const path = "./freddy-2013-12-04.opvault"
async function main(args: string[]) {
const instance = new OnePassword({ path })
const profiles = await instance.getProfileNames()
// const { profile } = await prompts({
// type: "select",
// name: "profile",
// choices: profiles.map(t => ({ title: t, value: t })),
// message: "Which vault?",
// })
// console.log(chalk`You have chosen {green ${profile}}.`)
const profile = "default"
const vault = await instance.getProfile(profile)
// const { password } = await prompts({
// type: "invisible",
// name: "password",
// message: "Master Password?",
// })
const password = "freddy"
vault.unlock(password)
const find = vault.overviews.get("A note with some attachments")!
const item = vault.getItem(find.uuid!)!
const [attachment] = item.attachments
// console.log({ details: item.itemDetails, overview: item.overview })
// console.log(attachment.metadata)
const op = vault.getItem(vault.overviews.get("1Password")!.uuid)!
console.log(op.itemDetails.sections[0])
// console.log(vault.overviews.values())
}
main(process.argv.slice(2))

1
scripts/buffer-shim.js Normal file
View File

@ -0,0 +1 @@
module.exports = require("buffer")

1
test/decrypted.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,9 @@
import { resolve } from "path"; import { resolve } from "path";
import { describe, it, beforeEach } from "mocha"; import { describe, it, beforeEach } from "mocha";
import { expect } from "chai"; import { expect } from "chai";
// import { fs, vol } from "memfs"
import type { Vault } from "../src/index"; import type { Vault } from "../packages/opvault.js/index";
import { OnePassword } from "../src/index"; import { OnePassword } from "../packages/opvault.js/index";
// import adapter from "../src/adapters/node";
describe("OnePassword", () => { describe("OnePassword", () => {
const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault"); const freddy = resolve(__dirname, "../freddy-2013-12-04.opvault");
@ -49,9 +47,44 @@ describe("OnePassword", () => {
await vault.unlock("freddy"); await vault.unlock("freddy");
}); });
it("reads overviews", () => { it("reads notes", async () => {
const overviews = vault.overviews.values(); const item = (await vault.getItem({
expect(overviews).to.have.lengthOf(29); title: "A note with some attachments",
}))!;
expect(item).to.exist;
expect(item.uuid).to.equal("F2DB5DA3FCA64372A751E0E85C67A538");
expect(item.attachments).to.have.lengthOf(2);
expect(item.details).to.deep.equal({
notesPlain: "This note has two attachments.",
});
expect(item.overview).to.deep.equal({
title: "A note with some attachments",
ps: 0,
ainfo: "This note has two attachments.",
});
});
it("decrypts items", async () => {
const decrypted = require("./decrypted.json");
expect(vault.isLocked).to.be.false;
for (const [uuid, item] of Object.entries<any>(decrypted)) {
const actual = await vault.getItem(uuid);
expect(actual).to.exist;
expect(actual!.overview).to.deep.equal(item.overview);
expect(actual!.details).to.deep.equal(item.itemDetails);
expect(actual!.attachments).to.have.lengthOf(item.attachments.length);
for (const [i, attachment] of actual!.attachments.entries()) {
const expected = item.attachments[i];
await attachment.unlock();
expect(attachment.metadata).to.deep.equal(expected.metadata);
expect(attachment.file.toString("base64")).to.deep.equal(
expected.file
);
expect(attachment.icon.toString("base64")).to.deep.equal(
expected.icon
);
}
}
}); });
}); });
@ -64,7 +97,8 @@ describe("OnePassword", () => {
vault.lock(); vault.lock();
expect(vault.isLocked).to.be.true; expect(vault.isLocked).to.be.true;
expect(() => vault.overviews.values()).to.throw(); expect(vault.getItem("F2DB5DA3FCA64372A751E0E85C67A538")).to.eventually
.throw;
}); });
}); });
}); });

View File

@ -1,7 +1,7 @@
import { describe, it } from "mocha"; import { describe, it } from "mocha";
import { expect } from "chai"; import { expect } from "chai";
import { WeakValueMap } from "../src/weakMap"; import { WeakValueMap } from "../packages/opvault.js/weakMap";
declare const gc: () => void; declare const gc: () => void;

View File

@ -1,9 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": false,
"declaration": true, "declaration": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"importHelpers": true, "importHelpers": true,
"jsx": "react", "jsx": "react-jsx",
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node", "moduleResolution": "node",
"noEmit": true, "noEmit": true,