Migrate to monorepo
This commit is contained in:
parent
4f41c45c9c
commit
b504df1a2e
124
packages/opvault.js/adapters/browser.ts
Normal file
124
packages/opvault.js/adapters/browser.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { Buffer } from "buffer"
|
||||||
|
import createHmac from "create-hmac"
|
||||||
|
import type { IAdapter, IFileSystem } from "./index"
|
||||||
|
|
||||||
|
function normalize(path: string) {
|
||||||
|
return path.replace(/^\//, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPath(path: string) {
|
||||||
|
const segments = normalize(path).split("/")
|
||||||
|
const filename = segments.pop()!
|
||||||
|
return [segments, filename] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileSystem implements IFileSystem {
|
||||||
|
constructor(private handle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
|
static async create() {
|
||||||
|
const handle = await showDirectoryPicker()
|
||||||
|
return new FileSystem(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDirectoryHandle(segments: string[]) {
|
||||||
|
if (!segments.length || (segments.length === 1 && !segments[0])) {
|
||||||
|
return this.handle
|
||||||
|
}
|
||||||
|
const [first, ...pathSegments] = segments
|
||||||
|
const handle = await pathSegments.reduce(
|
||||||
|
async (accum, next) => (await accum).getDirectoryHandle(next),
|
||||||
|
this.handle.getDirectoryHandle(first)
|
||||||
|
)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFileHandle(path: string) {
|
||||||
|
const [segments, filename] = splitPath(path)
|
||||||
|
const dirHandle = await this.getDirectoryHandle(segments)
|
||||||
|
const fileHandle = await dirHandle.getFileHandle(filename)
|
||||||
|
return fileHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
async readFile(path: string) {
|
||||||
|
const handle = await this.getFileHandle(path)
|
||||||
|
const file = await handle.getFile()
|
||||||
|
return file.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(path: string): Promise<boolean> {
|
||||||
|
if (path === "/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const [segments, filename] = splitPath(path)
|
||||||
|
let { handle } = this
|
||||||
|
for (const segment of segments) {
|
||||||
|
try {
|
||||||
|
handle = await handle.getDirectoryHandle(segment)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await success(() => handle.getFileHandle(filename))) ||
|
||||||
|
(await success(() => handle.getDirectoryHandle(filename)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async readBuffer(path: string): Promise<Buffer> {
|
||||||
|
const handle = await this.getFileHandle(path)
|
||||||
|
const file = await handle.getFile()
|
||||||
|
return Buffer.from(await file.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, data: string): Promise<void> {
|
||||||
|
const handle = await this.getFileHandle(path)
|
||||||
|
const writable = await handle.createWritable()
|
||||||
|
await writable.write(data)
|
||||||
|
await writable.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async readdir(path: string): Promise<string[]> {
|
||||||
|
const segments = normalize(path).split("/")
|
||||||
|
const dirHandle = await this.getDirectoryHandle(segments)
|
||||||
|
const keys: string[] = []
|
||||||
|
for await (const key of dirHandle.keys()) {
|
||||||
|
keys.push(key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
async isDirectory(path: string) {
|
||||||
|
const [segments, filename] = splitPath(path)
|
||||||
|
const dirHandle = await this.getDirectoryHandle(segments)
|
||||||
|
for await (const [key, handle] of dirHandle.entries()) {
|
||||||
|
if (key !== filename) continue
|
||||||
|
|
||||||
|
if (handle instanceof FileSystemDirectoryHandle) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function success(fn: () => Promise<any>) {
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default Browser adapter.
|
||||||
|
*/
|
||||||
|
export const getBrowserAdapter = (handle: FileSystemDirectoryHandle): IAdapter => ({
|
||||||
|
fs: new FileSystem(handle),
|
||||||
|
subtle: crypto.subtle,
|
||||||
|
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
||||||
|
})
|
324
packages/opvault.js/adapters/decipher.ts
Normal file
324
packages/opvault.js/adapters/decipher.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
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)
|
||||||
|
}
|
@ -3,10 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
export interface IFileSystem {
|
export interface IFileSystem {
|
||||||
/**
|
/**
|
||||||
* Synchronously tests whether or not the given path exists by checking with the file system.
|
* Asynchronously tests whether or not the given path exists by checking with the file system.
|
||||||
* @param path A path to a file or directory.
|
* @param path A path to a file or directory.
|
||||||
*/
|
*/
|
||||||
existsSync(path: string): boolean
|
exists(path: string): Promise<boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously reads the entire contents of a file.
|
* Asynchronously reads the entire contents of a file.
|
||||||
@ -34,10 +34,9 @@ export interface IFileSystem {
|
|||||||
readdir(path: string): Promise<string[]>
|
readdir(path: string): Promise<string[]>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronous stat(2) - Get file status.
|
* Returns true if the path points to a directory.
|
||||||
* @param path A path to a file.
|
|
||||||
*/
|
*/
|
||||||
stat(path: string): Promise<{ isDirectory(): boolean }>
|
isDirectory(path: string): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAdapter {
|
export interface IAdapter {
|
||||||
@ -46,4 +45,16 @@ export interface IAdapter {
|
|||||||
* `memfs` or any object that implements `IFileSystem`.
|
* `memfs` or any object that implements `IFileSystem`.
|
||||||
*/
|
*/
|
||||||
fs: IFileSystem
|
fs: IFileSystem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `SubtleCrypto` implementation. On Node.js this is
|
||||||
|
* `require("crypto").webcrypto.subtle` and in the browser this is
|
||||||
|
* `window.crypto.subtle`.
|
||||||
|
*/
|
||||||
|
subtle: SubtleCrypto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equivalent to `createHmac("sha256", key).update(data).digest()`
|
||||||
|
*/
|
||||||
|
hmacSHA256(key: Buffer, data: Buffer): Buffer
|
||||||
}
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { promises as fs, existsSync } from "fs"
|
import { promises as fs, existsSync } from "fs"
|
||||||
|
import { webcrypto, createHmac } from "crypto"
|
||||||
|
|
||||||
import type { IAdapter } from "./index"
|
import type { IAdapter } from "./index"
|
||||||
|
|
||||||
@ -6,15 +7,15 @@ 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.
|
||||||
*/
|
*/
|
||||||
const nodeAdapter: IAdapter = {
|
export const adapter: 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),
|
||||||
writeFile: fs.writeFile,
|
writeFile: fs.writeFile,
|
||||||
readdir: fs.readdir,
|
readdir: fs.readdir,
|
||||||
stat: fs.stat,
|
isDirectory: async path => fs.stat(path).then(x => x.isDirectory()),
|
||||||
existsSync,
|
exists: async path => existsSync(path),
|
||||||
},
|
},
|
||||||
|
subtle: (webcrypto as any).subtle,
|
||||||
|
hmacSHA256: (key, data) => createHmac("sha256", key).update(data).digest(),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nodeAdapter
|
|
22
packages/opvault.js/ee.ts
Normal file
22
packages/opvault.js/ee.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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 }
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listeners.forEach(fn => fn(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emitter as Emitter
|
||||||
|
}
|
@ -5,14 +5,14 @@ import { once } from "./util"
|
|||||||
|
|
||||||
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
export type OnePasswordFileManager = ReturnType<typeof OnePasswordFileManager>
|
||||||
|
|
||||||
export function OnePasswordFileManager(
|
export async function OnePasswordFileManager(
|
||||||
fs: IFileSystem,
|
fs: IFileSystem,
|
||||||
path: string,
|
path: string,
|
||||||
profileName: string
|
profileName: string
|
||||||
) {
|
) {
|
||||||
const root = resolve(path, profileName)
|
const root = resolve(path, profileName)
|
||||||
invariant(fs.existsSync(path), `Path ${path} does not exist.`)
|
invariant(await fs.exists(path), `Path ${path} does not exist.`)
|
||||||
invariant(fs.existsSync(root), `Profile ${profileName} does not exist.`)
|
invariant(await fs.exists(root), `Profile ${profileName} does not exist.`)
|
||||||
|
|
||||||
const abs = (path: string) => resolve(root, path)
|
const abs = (path: string) => resolve(root, path)
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ export function OnePasswordFileManager(
|
|||||||
|
|
||||||
async getBand(name: string) {
|
async getBand(name: string) {
|
||||||
const path = abs(`band_${name}.js`)
|
const path = abs(`band_${name}.js`)
|
||||||
if (fs.existsSync(path)) {
|
if (await fs.exists(path)) {
|
||||||
return await fs.readFile(path)
|
return await fs.readFile(path)
|
||||||
}
|
}
|
||||||
},
|
},
|
@ -1,6 +1,5 @@
|
|||||||
import { resolve } from "path"
|
import { resolve } from "path"
|
||||||
import { Vault } from "./models/vault"
|
import { Vault } from "./models/vault"
|
||||||
import { invariant } from "./errors"
|
|
||||||
import type { IAdapter } from "./adapters"
|
import type { IAdapter } from "./adapters"
|
||||||
import { asyncMap } from "./util"
|
import { asyncMap } from "./util"
|
||||||
|
|
||||||
@ -26,10 +25,9 @@ export class OnePassword {
|
|||||||
readonly #path: string
|
readonly #path: string
|
||||||
readonly #adapter: IAdapter
|
readonly #adapter: IAdapter
|
||||||
|
|
||||||
constructor({ path, adapter = require("./adapters/node").default }: IOptions) {
|
constructor({ path, adapter = require("./adapters/node").adapter }: IOptions) {
|
||||||
this.#adapter = adapter
|
this.#adapter = adapter
|
||||||
this.#path = path
|
this.#path = path
|
||||||
invariant(path, "Path must not be empty")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,8 +39,10 @@ export class OnePassword {
|
|||||||
const profiles: string[] = []
|
const profiles: string[] = []
|
||||||
await asyncMap(children, async child => {
|
await asyncMap(children, async child => {
|
||||||
const fullPath = resolve(path, child)
|
const fullPath = resolve(path, child)
|
||||||
const stats = await fs.stat(fullPath)
|
if (
|
||||||
if (stats.isDirectory() && fs.existsSync(resolve(fullPath, "profile.js"))) {
|
(await fs.isDirectory(fullPath)) &&
|
||||||
|
(await fs.exists(resolve(fullPath, "profile.js")))
|
||||||
|
) {
|
||||||
profiles.push(child)
|
profiles.push(child)
|
||||||
}
|
}
|
||||||
})
|
})
|
@ -1,5 +1,5 @@
|
|||||||
|
import { Buffer } from "buffer"
|
||||||
import type { Crypto } from "./crypto"
|
import type { Crypto } from "./crypto"
|
||||||
import { decryptOPData } from "./crypto"
|
|
||||||
import { invariant } from "../errors"
|
import { invariant } from "../errors"
|
||||||
|
|
||||||
type integer = number
|
type integer = number
|
||||||
@ -38,7 +38,7 @@ export class Attachment {
|
|||||||
this.metadataSize = buffer.readIntLE(8, 2)
|
this.metadataSize = buffer.readIntLE(8, 2)
|
||||||
this.iconSize = buffer.readIntLE(12, 3)
|
this.iconSize = buffer.readIntLE(12, 3)
|
||||||
|
|
||||||
crypto.on("lock", () => {
|
crypto.onLock(() => {
|
||||||
this.#lock()
|
this.#lock()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -80,18 +80,21 @@ export class Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#decrypt() {
|
#decrypt() {
|
||||||
const cipher = this.#crypto.deriveConcreteKey({ k: this.#k })
|
const crypto = this.#crypto
|
||||||
|
const cipher = crypto.deriveConcreteKey({ k: this.#k })
|
||||||
const { metadataSize, iconSize } = this
|
const { metadataSize, iconSize } = this
|
||||||
const buffer = this.#buffer
|
const buffer = this.#buffer
|
||||||
this.#icon = decryptOPData(
|
this.#icon = crypto.decryptOPData(
|
||||||
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
|
buffer.slice(16 + metadataSize, 16 + metadataSize + iconSize),
|
||||||
cipher
|
cipher
|
||||||
)
|
)
|
||||||
this.#file = decryptOPData(buffer.slice(16 + metadataSize + iconSize), cipher)
|
this.#file = 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(
|
||||||
decryptOPData(Buffer.from(metadata.overview, "base64"), this.#crypto.ov).toString()
|
crypto
|
||||||
|
.decryptOPData(Buffer.from(metadata.overview, "base64"), crypto.overview)
|
||||||
|
.toString()
|
||||||
)
|
)
|
||||||
this.#metadata = metadata
|
this.#metadata = metadata
|
||||||
}
|
}
|
173
packages/opvault.js/models/crypto.ts
Normal file
173
packages/opvault.js/models/crypto.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { Buffer } from "buffer"
|
||||||
|
import { createDecipheriv } from "../adapters/decipher"
|
||||||
|
import type { IAdapter } from "../adapters"
|
||||||
|
import { createEventEmitter } from "../ee"
|
||||||
|
import { HMACAssertionError } from "../errors"
|
||||||
|
import type { i18n } from "../i18n"
|
||||||
|
import type { ItemDetails, Overview, Profile } from "../types"
|
||||||
|
import { setIfAbsent } from "../util"
|
||||||
|
import type { EncryptedItem } from "./item"
|
||||||
|
|
||||||
|
/** Encryption and MAC */
|
||||||
|
export interface Cipher {
|
||||||
|
/** Encryption key */
|
||||||
|
key: Buffer
|
||||||
|
/** HMAC key */
|
||||||
|
hmac: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Crypto implements IDisposable {
|
||||||
|
#disposables: IDisposable[] = []
|
||||||
|
#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) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const key = await this.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(masterPassword),
|
||||||
|
{ name: "PBKDF2" },
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
)
|
||||||
|
const derivedKey = await this.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: Buffer.from(profile.salt, "base64"),
|
||||||
|
iterations: profile.iterations,
|
||||||
|
hash: {
|
||||||
|
name: "SHA-512",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
64 << 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const cipher = splitPlainText(Buffer.from(derivedKey))
|
||||||
|
|
||||||
|
// Derive master key and overview keys
|
||||||
|
this.#master = await this.decryptKeys(profile.masterKey, cipher)
|
||||||
|
this.#overview = await this.decryptKeys(profile.overviewKey, cipher)
|
||||||
|
this.#locked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
get locked() {
|
||||||
|
return this.#locked
|
||||||
|
}
|
||||||
|
|
||||||
|
lock() {
|
||||||
|
this.#locked = true
|
||||||
|
this.#master = null!
|
||||||
|
this.#overview = null!
|
||||||
|
this.#disposables.forEach(fn => fn.dispose())
|
||||||
|
this.onLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertUnlocked() {
|
||||||
|
if (this.#locked) {
|
||||||
|
throw new Error(this.i18n.error.vaultIsLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#createCache = <K, V, K2 = K>(
|
||||||
|
deriveArg: (value: K) => K2,
|
||||||
|
implementation: (value: K2) => V
|
||||||
|
) => {
|
||||||
|
const map = new Map<K2, V>()
|
||||||
|
this.#disposables.push({
|
||||||
|
dispose: () => 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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return JSON.parse(overview.toString("utf8")) as Overview
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
deriveConcreteKey = this.#createCache(
|
||||||
|
(data: { k: string }) => data.k,
|
||||||
|
($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))
|
||||||
|
return splitPlainText(derivedKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
||||||
|
const actual = this.hmacSHA256(key, data)
|
||||||
|
if (!actual.equals(expected)) {
|
||||||
|
throw new HMACAssertionError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptOPData(cipherText: Buffer, cipher: Cipher) {
|
||||||
|
const key = cipherText.slice(0, -32)
|
||||||
|
this.assertHMac(key, cipher.hmac, cipherText.slice(-32))
|
||||||
|
|
||||||
|
const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32))
|
||||||
|
const size = readUint16(key.slice(8, 16))
|
||||||
|
return plaintext.slice(-size)
|
||||||
|
}
|
||||||
|
|
||||||
|
async decryptKeys(encryptedKey: string, derived: Cipher) {
|
||||||
|
const buffer = Buffer.from(encryptedKey, "base64")
|
||||||
|
const base = this.decryptOPData(buffer, derived)
|
||||||
|
const digest = await this.subtle.digest("SHA-512", base)
|
||||||
|
return splitPlainText(Buffer.from(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
get overview() {
|
||||||
|
return this.#overview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
||||||
|
key: derivedKey.slice(0, 32),
|
||||||
|
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)
|
||||||
|
}
|
@ -5,41 +5,33 @@ import { i18n } from "../i18n"
|
|||||||
import type { EncryptedItem } from "./item"
|
import type { EncryptedItem } from "./item"
|
||||||
import { Crypto } from "./crypto"
|
import { Crypto } from "./crypto"
|
||||||
import { Item } from "./item"
|
import { Item } from "./item"
|
||||||
import type { Profile, Overview } from "../types"
|
import type { Profile } from "../types"
|
||||||
import { WeakValueMap } from "../weakMap"
|
import { WeakValueMap } from "../weakMap"
|
||||||
import { EventEmitter } from "../ee"
|
import { createEventEmitter } from "../ee"
|
||||||
import { asyncMap } from "../util"
|
|
||||||
|
|
||||||
type Band = { [uuid: string]: Item }
|
// type FoldersMap = { [uuid: string]: { [uuid: string]: Item } }
|
||||||
type FoldersMap = { [uuid: string]: Band }
|
|
||||||
|
|
||||||
interface VaultEvents {
|
|
||||||
lock: void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main OnePassword Vault class
|
* Main OnePassword Vault class
|
||||||
*/
|
*/
|
||||||
export class Vault extends EventEmitter<VaultEvents> {
|
export class Vault {
|
||||||
// File system interface
|
|
||||||
#profile: Profile
|
#profile: Profile
|
||||||
#folders: FoldersMap
|
// #folders: FoldersMap
|
||||||
|
|
||||||
#items: Item[] = []
|
#items: Item[] = []
|
||||||
#itemsMap = new WeakValueMap<string, Item>()
|
#itemsMap = new WeakValueMap<string, Item>()
|
||||||
|
|
||||||
#crypto: Crypto
|
#crypto: Crypto
|
||||||
|
|
||||||
|
readonly onLock = createEventEmitter<void>()
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
folders: FoldersMap,
|
// folders: FoldersMap,
|
||||||
items: Item[],
|
items: Item[],
|
||||||
crypto: Crypto,
|
crypto: Crypto,
|
||||||
itemsMap: WeakValueMap<string, Item>
|
itemsMap: WeakValueMap<string, Item>
|
||||||
) {
|
) {
|
||||||
super()
|
|
||||||
this.#profile = profile
|
this.#profile = profile
|
||||||
this.#folders = folders
|
// this.#folders = folders
|
||||||
this.#items = items
|
this.#items = items
|
||||||
this.#crypto = crypto
|
this.#crypto = crypto
|
||||||
this.#itemsMap = itemsMap
|
this.#itemsMap = itemsMap
|
||||||
@ -50,12 +42,12 @@ export class Vault extends EventEmitter<VaultEvents> {
|
|||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
static async of(path: string, profileName = "default", adapter: IAdapter) {
|
static async of(path: string, profileName = "default", adapter: IAdapter) {
|
||||||
const crypto = new Crypto(i18n)
|
const crypto = new Crypto(i18n, adapter)
|
||||||
const files = OnePasswordFileManager(adapter.fs, path, profileName)
|
const files = await OnePasswordFileManager(adapter.fs, path, profileName)
|
||||||
const profile = JSON.parse(
|
const profile = JSON.parse(
|
||||||
stripText(await files.getProfile(), /^var profile\s*=/, ";")
|
stripText(await files.getProfile(), /^var profile\s*=/, ";")
|
||||||
)
|
)
|
||||||
const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");"))
|
// const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");"))
|
||||||
|
|
||||||
const itemsMap = new WeakValueMap<string, Item>()
|
const itemsMap = new WeakValueMap<string, Item>()
|
||||||
const bands: Item[] = []
|
const bands: Item[] = []
|
||||||
@ -75,34 +67,23 @@ export class Vault extends EventEmitter<VaultEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachments = await files.getAttachments()
|
const attachments = await files.getAttachments()
|
||||||
await asyncMap(attachments, async att => {
|
for (const att of attachments) {
|
||||||
const file = itemsMap.get(att.itemUUID)
|
const file = itemsMap.get(att.itemUUID)
|
||||||
invariant(file, `Item ${att.itemUUID} of attachment does not exist`)
|
invariant(file, `Item ${att.itemUUID} of attachment does not exist`)
|
||||||
file.addAttachment(await att.getFile())
|
file.addAttachment(await att.getFile())
|
||||||
})
|
}
|
||||||
|
|
||||||
return new Vault(profile, folders, bands, crypto, itemsMap)
|
return new Vault(profile, bands, crypto, itemsMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly overviews = Object.freeze({
|
getOverview(uuid: string) {
|
||||||
get: (condition: string | ((overview: Overview) => boolean)) => {
|
this.#crypto.assertUnlocked()
|
||||||
this.#crypto.assertUnlocked()
|
return this.#items.find(x => x.uuid === uuid)?.overview
|
||||||
if (typeof condition === "string") {
|
}
|
||||||
const title = condition
|
|
||||||
condition = overview => overview.title === title
|
|
||||||
}
|
|
||||||
for (const value of this.#items) {
|
|
||||||
if (condition(value.overview)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
values: () => {
|
keys() {
|
||||||
this.#crypto.assertUnlocked()
|
return this.#items.map(x => x.uuid)
|
||||||
return Array.from(this.#items.map(x => x.overview))
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unlock this OnePassword vault.
|
* Unlock this OnePassword vault.
|
||||||
@ -111,7 +92,7 @@ export class Vault extends EventEmitter<VaultEvents> {
|
|||||||
*/
|
*/
|
||||||
async unlock(masterPassword: string) {
|
async unlock(masterPassword: string) {
|
||||||
try {
|
try {
|
||||||
this.#crypto.unlock(this.#profile, masterPassword)
|
await this.#crypto.unlock(this.#profile, masterPassword)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof HMACAssertionError) {
|
if (e instanceof HMACAssertionError) {
|
||||||
throw new Error(i18n.error.invalidPassword)
|
throw new Error(i18n.error.invalidPassword)
|
||||||
@ -126,7 +107,7 @@ export class Vault extends EventEmitter<VaultEvents> {
|
|||||||
*/
|
*/
|
||||||
lock() {
|
lock() {
|
||||||
this.#crypto.lock()
|
this.#crypto.lock()
|
||||||
this.emit("lock")
|
this.onLock()
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,9 +115,20 @@ export class Vault extends EventEmitter<VaultEvents> {
|
|||||||
return this.#crypto.locked
|
return this.#crypto.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(uuid: string) {
|
getItem(uuid: string): Item | undefined
|
||||||
|
getItem(filter: { title: string }): Item | undefined
|
||||||
|
|
||||||
|
getItem(filter: any) {
|
||||||
this.#crypto.assertUnlocked()
|
this.#crypto.assertUnlocked()
|
||||||
return this.#itemsMap.get(uuid)
|
if (typeof filter === "string") {
|
||||||
|
return this.#itemsMap.get(filter)
|
||||||
|
} else {
|
||||||
|
for (const value of this.#items) {
|
||||||
|
if (value.overview.title === filter.title) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,6 @@ export interface Overview {
|
|||||||
url?: string
|
url?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
URLs?: { u: string }[]
|
URLs?: { u: string }[]
|
||||||
uuid?: string // Added manually
|
|
||||||
appIds: {
|
appIds: {
|
||||||
/** @example 'Firefox' | 'Email' */
|
/** @example 'Firefox' | 'Email' */
|
||||||
name: string
|
name: string
|
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/**/"
|
@ -1,89 +0,0 @@
|
|||||||
import { promises as fs, existsSync } from "fs"
|
|
||||||
import { Buffer } from "buffer"
|
|
||||||
|
|
||||||
import type { IAdapter, IFileSystem } from "./index"
|
|
||||||
|
|
||||||
function splitPath(path: string) {
|
|
||||||
const segments = path.split("/")
|
|
||||||
const filename = segments.pop()!
|
|
||||||
return [segments, filename] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BrowserAdapter implements IFileSystem {
|
|
||||||
constructor(private handle: FileSystemDirectoryHandle) {}
|
|
||||||
|
|
||||||
static async create() {
|
|
||||||
const handle = await showDirectoryPicker()
|
|
||||||
return new BrowserAdapter(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDirectoryHandle([first, ...pathSegments]: string[]) {
|
|
||||||
const handle = await pathSegments.reduce(
|
|
||||||
async (accum, next) => (await accum).getDirectoryHandle(next),
|
|
||||||
this.handle.getDirectoryHandle(first)
|
|
||||||
)
|
|
||||||
return handle
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getFileHandle(path: string) {
|
|
||||||
const [segments, filename] = splitPath(path)
|
|
||||||
const dirHandle = await this.getDirectoryHandle(segments)
|
|
||||||
const fileHandle = await dirHandle.getFileHandle(filename)
|
|
||||||
return fileHandle
|
|
||||||
}
|
|
||||||
|
|
||||||
async readFile(path: string) {
|
|
||||||
const handle = await this.getFileHandle(path)
|
|
||||||
const file = await handle.getFile()
|
|
||||||
return file.text()
|
|
||||||
}
|
|
||||||
|
|
||||||
async existsSync(path: string): Promise<boolean> {
|
|
||||||
const [segments, filename] = splitPath(path)
|
|
||||||
let handle = this.handle
|
|
||||||
for (const segment of segments) {
|
|
||||||
handle.values()
|
|
||||||
const next = await handle.getDirectoryHandle()
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Method not implemented.")
|
|
||||||
}
|
|
||||||
|
|
||||||
async readBuffer(path: string): Promise<Buffer> {
|
|
||||||
const handle = await this.getFileHandle(path)
|
|
||||||
const file = await handle.getFile()
|
|
||||||
return Buffer.from(await file.arrayBuffer())
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeFile(path: string, data: string): Promise<void> {
|
|
||||||
const handle = await this.getFileHandle(path)
|
|
||||||
const writable = await handle.createWritable()
|
|
||||||
await writable.write(data)
|
|
||||||
await writable.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
readdir(path: string): Promise<string[]> {
|
|
||||||
throw new Error("Method not implemented.")
|
|
||||||
}
|
|
||||||
|
|
||||||
stat(path: string): Promise<{ isDirectory(): boolean }> {
|
|
||||||
throw new Error("Method not implemented.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default Node.js adapter. This can be used while using `opvault.js`
|
|
||||||
* in a Node.js environment.
|
|
||||||
*/
|
|
||||||
const nodeAdapter: IAdapter = {
|
|
||||||
fs: {
|
|
||||||
readFile: path => fs.readFile(path, "utf-8"),
|
|
||||||
readBuffer: path => fs.readFile(path),
|
|
||||||
writeFile: fs.writeFile,
|
|
||||||
readdir: fs.readdir,
|
|
||||||
stat: fs.stat,
|
|
||||||
existsSync,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nodeAdapter
|
|
49
src/ee.ts
49
src/ee.ts
@ -1,49 +0,0 @@
|
|||||||
type EventKeyWithNoArg<T> = {
|
|
||||||
[K in keyof T]: T[K] extends void ? K : never
|
|
||||||
}[keyof T]
|
|
||||||
|
|
||||||
type CallbackSignature<T, K extends keyof T> = K extends EventKeyWithNoArg<T>
|
|
||||||
? () => void
|
|
||||||
: (value: T[K]) => void
|
|
||||||
|
|
||||||
export class EventEmitter<T extends Record</* eventName */ string, /* eventArg */ any>> {
|
|
||||||
#listeners = new Map<keyof T, Set<(...args: any[]) => any>>()
|
|
||||||
|
|
||||||
#getList(key: keyof T) {
|
|
||||||
if (!this.#listeners.has(key)) {
|
|
||||||
this.#listeners.set(key, new Set())
|
|
||||||
}
|
|
||||||
return this.#listeners.get(key)!
|
|
||||||
}
|
|
||||||
|
|
||||||
on<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
|
|
||||||
this.#getList(key).add(fn)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
off<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
|
|
||||||
this.#getList(key).delete(fn)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
once<K extends keyof T>(key: K, fn: CallbackSignature<T, K>) {
|
|
||||||
const wrapped = (...arg: any[]) => {
|
|
||||||
;(fn as any)(...arg)
|
|
||||||
this.off(key, wrapped)
|
|
||||||
}
|
|
||||||
return this.on(key, wrapped)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit<K extends EventKeyWithNoArg<T>>(key: K): this
|
|
||||||
emit<K extends keyof T>(key: K, value: T[K]): this
|
|
||||||
|
|
||||||
emit<K extends keyof T>(key: K, value?: T[K]) {
|
|
||||||
const listeners = this.#getList(key)
|
|
||||||
if (arguments.length === 1) {
|
|
||||||
listeners.forEach(fn => fn())
|
|
||||||
} else {
|
|
||||||
listeners.forEach(fn => fn(value!))
|
|
||||||
}
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,179 +0,0 @@
|
|||||||
import * as crypto from "crypto"
|
|
||||||
import { createHmac, createDecipheriv, createHash } from "crypto"
|
|
||||||
import { EventEmitter } from "../ee"
|
|
||||||
import { HMACAssertionError } from "../errors"
|
|
||||||
import type { i18n } from "../i18n"
|
|
||||||
import type { ItemDetails, Overview, Profile } from "../types"
|
|
||||||
import { setIfAbsent } from "../util"
|
|
||||||
import type { EncryptedItem } from "./item"
|
|
||||||
|
|
||||||
/** Encryption and MAC */
|
|
||||||
export interface Cipher {
|
|
||||||
/** Encryption key */
|
|
||||||
key: Buffer
|
|
||||||
/** HMAC key */
|
|
||||||
hmac: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Crypto extends EventEmitter<{ lock: void }> implements IDisposable {
|
|
||||||
#disposables: IDisposable[] = []
|
|
||||||
#locked = true
|
|
||||||
|
|
||||||
#master!: Cipher
|
|
||||||
#overview!: Cipher
|
|
||||||
|
|
||||||
constructor(readonly i18n: i18n) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
unlock(profile: Profile, masterPassword: string) {
|
|
||||||
const derivedKey = crypto.pbkdf2Sync(
|
|
||||||
masterPassword,
|
|
||||||
Buffer.from(profile.salt, "base64"),
|
|
||||||
profile.iterations,
|
|
||||||
64,
|
|
||||||
"sha512"
|
|
||||||
)
|
|
||||||
|
|
||||||
const cipher = splitPlainText(derivedKey)
|
|
||||||
|
|
||||||
// Derive master key and overview keys
|
|
||||||
this.#master = decryptKeys(profile.masterKey, cipher)
|
|
||||||
this.#overview = decryptKeys(profile.overviewKey, cipher)
|
|
||||||
this.#locked = false
|
|
||||||
}
|
|
||||||
|
|
||||||
get locked() {
|
|
||||||
return this.#locked
|
|
||||||
}
|
|
||||||
|
|
||||||
lock() {
|
|
||||||
this.#locked = true
|
|
||||||
this.#master = null!
|
|
||||||
this.#overview = null!
|
|
||||||
this.#disposables.forEach(fn => fn.dispose())
|
|
||||||
this.emit("lock")
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
assertUnlocked() {
|
|
||||||
if (this.#locked) {
|
|
||||||
throw new Error(this.i18n.error.vaultIsLocked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#createCache = <K, V, K2 = K>(
|
|
||||||
deriveArg: (value: K) => K2,
|
|
||||||
implementation: (value: K2) => V
|
|
||||||
) => {
|
|
||||||
const map = new Map<K2, V>()
|
|
||||||
this.#disposables.push({
|
|
||||||
dispose: () => 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()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return (data: K) => setIfAbsent(map, data, implementation)
|
|
||||||
}
|
|
||||||
|
|
||||||
decryptItemDetails = this.#createWeakCache((item: EncryptedItem) => {
|
|
||||||
const cipher = this.deriveConcreteKey(item)
|
|
||||||
const detail = 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 = 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) => {
|
|
||||||
const k = Buffer.from($k, "base64")
|
|
||||||
const data = k.slice(0, -32)
|
|
||||||
assertHMac(data, this.#master.hmac, k.slice(-32))
|
|
||||||
const derivedKey = decryptData(this.#master.key, data.slice(0, 16), data.slice(16))
|
|
||||||
return splitPlainText(derivedKey)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
deriveGeneralKey = (input: Buffer) => {}
|
|
||||||
|
|
||||||
get ov() {
|
|
||||||
return this.#overview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// async function pbkdf2(password: string, salt: string, iterations = 1000, length = 256) {
|
|
||||||
// const encoder = new TextEncoder()
|
|
||||||
// const key = await webcrypto.subtle.importKey(
|
|
||||||
// "raw",
|
|
||||||
// encoder.encode(password),
|
|
||||||
// "PBKDF2",
|
|
||||||
// false,
|
|
||||||
// ["deriveBits"]
|
|
||||||
// )
|
|
||||||
// const bits = await webcrypto.subtle.deriveBits(
|
|
||||||
// {
|
|
||||||
// name: "PBKDF2",
|
|
||||||
// hash: "SHA-512",
|
|
||||||
// salt: encoder.encode(salt),
|
|
||||||
// iterations,
|
|
||||||
// },
|
|
||||||
// key,
|
|
||||||
// length
|
|
||||||
// )
|
|
||||||
// return bits
|
|
||||||
// }
|
|
||||||
|
|
||||||
export function assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
|
||||||
const actual = createHmac("sha256", key).update(data).digest()
|
|
||||||
if (!actual.equals(expected)) {
|
|
||||||
console.error({ actual, expected })
|
|
||||||
throw new HMACAssertionError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
|
||||||
key: derivedKey.slice(0, 32),
|
|
||||||
hmac: derivedKey.slice(32, 64),
|
|
||||||
})
|
|
||||||
|
|
||||||
export function decryptKeys(encryptedKey: string, derived: Cipher) {
|
|
||||||
const buffer = Buffer.from(encryptedKey, "base64")
|
|
||||||
const base = decryptOPData(buffer, derived)
|
|
||||||
const digest = createHash("sha512").update(base).digest()
|
|
||||||
return splitPlainText(digest)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
|
|
||||||
const cipher = createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false)
|
|
||||||
return Buffer.concat([cipher.update(data), cipher.final()])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptOPData(cipherText: Buffer, cipher: Cipher) {
|
|
||||||
const key = cipherText.slice(0, -32)
|
|
||||||
assertHMac(key, cipher.hmac, cipherText.slice(-32))
|
|
||||||
|
|
||||||
const plaintext = decryptData(cipher.key, key.slice(16, 32), key.slice(32))
|
|
||||||
const size = readUint16(key.slice(8, 16))
|
|
||||||
return plaintext.slice(-size)
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUint16({ buffer, byteOffset, length }: Buffer) {
|
|
||||||
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user