Add web interface and tests
This commit is contained in:
parent
7f41a50fb1
commit
fe926be0a6
@ -1,2 +1,3 @@
|
|||||||
*.opvault
|
*.opvault
|
||||||
lib
|
lib
|
||||||
|
dist
|
@ -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",
|
||||||
{
|
{
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@ ref
|
|||||||
freddy
|
freddy
|
||||||
electron/bundled
|
electron/bundled
|
||||||
design.html
|
design.html
|
||||||
|
repl.ts
|
@ -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.
|
||||||
|
|
||||||
|
@ -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])
|
|
||||||
}
|
|
@ -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>
|
|
44
esbuild.js
44
esbuild.js
@ -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"),
|
|
||||||
})
|
|
25
package.json
25
package.json
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
3
packages/opvault.js/README.md
Normal file
3
packages/opvault.js/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# opvault.js
|
||||||
|
|
||||||
|
OnePassword local vaults parser library.
|
22
packages/opvault.js/package.json
Normal file
22
packages/opvault.js/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
@ -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(),
|
|
||||||
})
|
})
|
||||||
|
@ -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)
|
|
||||||
}
|
|
5
packages/opvault.js/src/adapters/index.d.ts
vendored
5
packages/opvault.js/src/adapters/index.d.ts
vendored
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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(),
|
|
||||||
}
|
}
|
||||||
|
301
packages/opvault.js/src/decipher.ts
Normal file
301
packages/opvault.js/src/decipher.ts
Normal 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)
|
||||||
|
}
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
6
packages/opvault.js/src/global.d.ts
vendored
6
packages/opvault.js/src/global.d.ts
vendored
@ -1,5 +1,7 @@
|
|||||||
type integer = number
|
type integer = number
|
||||||
|
|
||||||
interface IDisposable {
|
declare namespace NodeJS {
|
||||||
dispose(): void
|
interface Process {
|
||||||
|
browser?: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>,
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 */
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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" */
|
||||||
|
@ -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)!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
3
packages/opvault.js/tsconfig.json
Normal file
3
packages/opvault.js/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
5
packages/web/.gitignore
vendored
Normal file
5
packages/web/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
bundle
|
||||||
|
*.local
|
33
packages/web/esbuild.js
Executable file
33
packages/web/esbuild.js
Executable 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
17
packages/web/index.html
Normal 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
51
packages/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
15
packages/web/scripts/build-package-json.js
Executable file
15
packages/web/scripts/build-package-json.js
Executable 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
5
packages/web/scripts/build.sh
Executable 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
45
packages/web/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
90
packages/web/src/components/CategoryIcon.tsx
Normal file
90
packages/web/src/components/CategoryIcon.tsx
Normal 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
|
||||||
|
}
|
51
packages/web/src/components/ErrorBoundary.tsx
Normal file
51
packages/web/src/components/ErrorBoundary.tsx
Normal 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}</>
|
||||||
|
}
|
||||||
|
}
|
142
packages/web/src/components/Item.tsx
Normal file
142
packages/web/src/components/Item.tsx
Normal 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>
|
||||||
|
}
|
16
packages/web/src/components/ItemDates.tsx
Normal file
16
packages/web/src/components/ItemDates.tsx
Normal 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>
|
||||||
|
)
|
48
packages/web/src/components/ItemField.tsx
Normal file
48
packages/web/src/components/ItemField.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
100
packages/web/src/components/ItemFieldContextMenu.tsx
Normal file
100
packages/web/src/components/ItemFieldContextMenu.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
103
packages/web/src/components/ItemFieldValue.tsx
Normal file
103
packages/web/src/components/ItemFieldValue.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
71
packages/web/src/components/ItemList.tsx
Normal file
71
packages/web/src/components/ItemList.tsx
Normal 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>
|
||||||
|
)
|
41
packages/web/src/components/ItemWarning.tsx
Normal file
41
packages/web/src/components/ItemWarning.tsx
Normal 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
|
||||||
|
}
|
23
packages/web/src/components/TitleBar.tsx
Normal file
23
packages/web/src/components/TitleBar.tsx
Normal 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>
|
||||||
|
)
|
19
packages/web/src/components/VaultPicker.tsx
Normal file
19
packages/web/src/components/VaultPicker.tsx
Normal 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>
|
||||||
|
}
|
@ -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
|
4
packages/web/src/electron/preload.ts
Normal file
4
packages/web/src/electron/preload.ts
Normal 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
113
packages/web/src/index.scss
Normal 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
12
packages/web/src/main.tsx
Normal 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")
|
||||||
|
)
|
20
packages/web/src/pages/PickOPVault.tsx
Normal file
20
packages/web/src/pages/PickOPVault.tsx
Normal 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>
|
||||||
|
)
|
54
packages/web/src/pages/Unlock.tsx
Normal file
54
packages/web/src/pages/Unlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
142
packages/web/src/pages/Vault.tsx
Normal file
142
packages/web/src/pages/Vault.tsx
Normal 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())
|
||||||
|
}
|
5
packages/web/src/utils/index.ts
Normal file
5
packages/web/src/utils/index.ts
Normal 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
1
packages/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
3
packages/web/tsconfig.json
Normal file
3
packages/web/tsconfig.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
21
packages/web/vite.config.ts
Normal file
21
packages/web/vite.config.ts
Normal 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
2176
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
45
repl.ts
45
repl.ts
@ -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
1
scripts/buffer-shim.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("buffer")
|
1
test/decrypted.json
Normal file
1
test/decrypted.json
Normal file
File diff suppressed because one or more lines are too long
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user