Add web interface and tests

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import { builtinModules } from "module"
import ts from "rollup-plugin-ts"
import json from "@rollup/plugin-json"
import replace from "@rollup/plugin-replace"
import { dependencies } from "./package.json"
/** @returns {import("rollup").RollupOptions} */
export default () => ({
input: {
index: "./src/index.ts",
"adapters/node": "./src/adapters/node.ts",
},
external: builtinModules.concat(Object.keys(dependencies)),
output: {
dir: "lib",
format: "cjs",
},
plugins: [
ts({ transpileOnly: true }),
json(),
replace({
preventAssignment: true,
values: {
"process.env.NODE_ENV": '"production"',
},
}),
],
})

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { promises as fs, existsSync } from "fs"
import { webcrypto, createHmac } from "crypto"
import { webcrypto } from "crypto"
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`
* in a Node.js environment.
*/
export const adapter: IAdapter = {
export const nodeAdapter: IAdapter = {
fs: {
readFile: path => fs.readFile(path, "utf-8"),
readBuffer: path => fs.readFile(path),
@ -17,5 +17,4 @@ export const adapter: IAdapter = {
exists: async path => existsSync(path),
},
subtle: (webcrypto as any).subtle,
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,9 @@ import type { IAdapter } from "./adapters"
import { asyncMap } from "./util"
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"
interface IOptions {
@ -25,7 +28,10 @@ export class OnePassword {
readonly #path: string
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.#path = path
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import type { ItemDetails, Overview } from "../types"
import type { Crypto } from "./crypto"
import { Attachment } from "./attachment"
import { NotUnlockedError } from "../errors"
import type { Category } from "../models"
export interface EncryptedItem {
category: string // "001"
@ -14,27 +16,76 @@ export interface EncryptedItem {
tx: integer // Unix seconds
updated: integer // Unix seconds
uuid: string // 32 chars
trashed?: boolean
}
export class Item {
#crypto: Crypto
#data: EncryptedItem
#overview?: Overview
#details?: ItemDetails
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
}
get overview(): Overview {
return this.#crypto.decryptItemOverview(this.#data)
if (!this.#overview) {
throw new NotUnlockedError()
}
return this.#overview!
}
get itemDetails(): ItemDetails {
return this.#crypto.decryptItemDetails(this.#data)
get details(): ItemDetails {
if (!this.#details) {
throw new NotUnlockedError()
}
return this.#details!
}
constructor(crypto: Crypto, data: EncryptedItem) {
this.#crypto = crypto
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 */

View File

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

View File

@ -41,13 +41,29 @@ export type ItemField =
name: string
}
declare namespace ItemSection {
type A = {
export namespace ItemSection {
export type A = {
guarded: "yes"
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"
v: string
/** Unique name */
@ -56,13 +72,13 @@ declare namespace ItemSection {
/** User-readable title */
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
}
type Menu = {
export type Menu = {
k: "menu"
v: string // "female"
a: A
t: string // "sex"
}
type Date = {
export type Date = {
k: "date"
v: number // 359100000
/** @example "birthdate" */
@ -71,19 +87,19 @@ declare namespace ItemSection {
/** @example "birth date" | "date of birth" */
t: string
}
type Gender = {
export type Gender = {
k: "gender"
n: "sex"
v: string // "female"
t: "sex"
}
type MonthYear = {
export type MonthYear = {
k: "monthYear"
n: string // "expiry_date"
v: number // 2515
t: string // "expiry date"
}
type Concealed = {
export type Concealed = {
k: "concealed"
n: "password"
v: string
@ -94,7 +110,7 @@ declare namespace ItemSection {
}
// 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
@ -105,7 +121,7 @@ export interface ItemDetails {
htmlMethod: "post" | "get"
}
notesPlain?: string
sections: {
sections?: {
/** @example "name" | "title" | "internet" */
name: string
/** @example "Identification" | "Address" | "Internet Details" */

View File

@ -1,5 +1,3 @@
import { invariant } from "./errors"
export function asyncMap<T, R>(
list: T[],
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
}
const cache = (): MethodDecorator => (_, key, descriptor: any) => {
if (process.env.NODE_ENV !== "production") {
invariant(typeof key === "string")
invariant(descriptor.get != null)
}
const cacheMap = new WeakMap()
const fn = descriptor.get
descriptor.get = function () {
if (!cacheMap.has(this)) {
cacheMap.set(this, fn.call(this))
}
return cacheMap.get(this)!
}
}

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,59 @@
// @ts-check
// Modules to control application life and create native browser window
// import { join } from "path"
import { app, BrowserWindow, Menu } from "electron"
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 650,
// frame: false,
// transparent: true,
webPreferences: {
contextIsolation: true,
// preload: join(__dirname, "preload.js"),
},
})
mainWindow.webContents.session.enableNetworkEmulation({
offline: true,
})
Menu.setApplicationMenu(null)
// 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
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
createWindow()
app.on("activate", () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit()
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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