Initial commit
This commit is contained in:
commit
97d938a635
2
.eslintignore
Normal file
2
.eslintignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
onepassword_data
|
||||||
|
lib
|
52
.eslintrc
Normal file
52
.eslintrc
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
"@typescript-eslint/ban-types": "off",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-namespace": "off",
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "off",
|
||||||
|
"@typescript-eslint/no-use-before-define": "off",
|
||||||
|
"@typescript-eslint/no-var-requires": "off",
|
||||||
|
"@typescript-eslint/triple-slash-reference": "off",
|
||||||
|
"arrow-body-style": ["error", "as-needed"],
|
||||||
|
"class-methods-use-this": ["warn", { "exceptMethods": ["toString"] }],
|
||||||
|
"complexity": ["warn", { "max": 100 }],
|
||||||
|
"curly": ["error", "multi-line", "consistent"],
|
||||||
|
"eqeqeq": ["error", "smart"],
|
||||||
|
"no-async-promise-executor": "off",
|
||||||
|
"no-case-declarations": "off",
|
||||||
|
"no-constant-condition": ["error", { "checkLoops": false }],
|
||||||
|
"no-empty": ["error", { "allowEmptyCatch": true }],
|
||||||
|
"no-inner-declarations": "off",
|
||||||
|
"no-lonely-if": "error",
|
||||||
|
"no-var": "error",
|
||||||
|
"object-shorthand": "error",
|
||||||
|
"one-var": ["error", { "var": "never", "let": "never" }],
|
||||||
|
"prefer-arrow-callback": "error",
|
||||||
|
"prefer-const": ["error", { "destructuring": "all" }],
|
||||||
|
"prefer-object-spread": "error",
|
||||||
|
"prefer-rest-params": "warn",
|
||||||
|
"prefer-spread": "warn",
|
||||||
|
"quote-props": ["error", "as-needed"],
|
||||||
|
"react/display-name": "off",
|
||||||
|
"react/no-children-prop": "off",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"spaced-comment": "error",
|
||||||
|
"yoda": ["error", "never", { "exceptRange": true }]
|
||||||
|
}
|
||||||
|
}
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
lib
|
||||||
|
docs
|
||||||
|
ref
|
||||||
|
*.opvault
|
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
9
README.md
Normal file
9
README.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# opvault.js
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Reporting Security Issues
|
||||||
|
|
||||||
|
We appreciate your efforts to responsibly disclose your findings of security issues, and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security issue, email [security@aet.ac](mailto:security@aet.ac) and include the word "SECURITY" in the subject line.
|
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "opvault",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"repository": "https://git.aet.ac/aet/opvault.js.git",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c; prettier --write lib >/dev/null",
|
||||||
|
"build:docs": "typedoc --out docs src/index.ts --excludePrivate",
|
||||||
|
"repl": "node -r ts-node/register/transpile-only src/repl.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chai": "^4.2.19",
|
||||||
|
"@types/fs-extra": "^9.0.11",
|
||||||
|
"@types/mocha": "github:whitecolor/mocha-types#da22474cf43f48a56c86f8c23a5a0ea36e295768",
|
||||||
|
"@types/node": "^16.0.0",
|
||||||
|
"@types/prompts": "^2.0.13",
|
||||||
|
"@typescript-eslint/eslint-plugin": "4.28.1",
|
||||||
|
"@typescript-eslint/parser": "4.28.1",
|
||||||
|
"chai": "^4.3.4",
|
||||||
|
"chalk": "^4.1.1",
|
||||||
|
"eslint": "7.30.0",
|
||||||
|
"eslint-plugin-react": "7.24.0",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"mocha": "^9.0.2",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"prompts": "^2.4.1",
|
||||||
|
"rollup": "^2.52.7",
|
||||||
|
"rollup-plugin-ts": "^1.4.0",
|
||||||
|
"ts-node": "^10.0.0",
|
||||||
|
"typedoc": "^0.21.2",
|
||||||
|
"typescript": "^4.3.5"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 90,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tiny-invariant": "1.1.0",
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
}
|
3690
pnpm-lock.yaml
generated
Normal file
3690
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
repl.ts
Executable file
29
repl.ts
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env ts-node-transpile-only
|
||||||
|
import chalk from "chalk"
|
||||||
|
import prompts from "prompts"
|
||||||
|
import { OnePassword } from "./src/index"
|
||||||
|
|
||||||
|
async function main(args: string[]) {
|
||||||
|
const instance = new OnePassword({ path: args[0] })
|
||||||
|
const profiles = await instance.getProfileNames()
|
||||||
|
|
||||||
|
const { profile } = await prompts({
|
||||||
|
type: "select",
|
||||||
|
name: "profile",
|
||||||
|
choices: profiles.map(t => ({ title: t, value: t })),
|
||||||
|
message: "Which vault?",
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(chalk`You have chosen {green ${profile}}.`)
|
||||||
|
|
||||||
|
const vault = await instance.getProfile(profile)
|
||||||
|
const { password } = await prompts({
|
||||||
|
type: "invisible",
|
||||||
|
name: "password",
|
||||||
|
message: "Master Password?",
|
||||||
|
})
|
||||||
|
|
||||||
|
vault.unlock(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
main(process.argv.slice(2))
|
14
rollup.config.js
Normal file
14
rollup.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import ts from "rollup-plugin-ts"
|
||||||
|
import { builtinModules } from "module"
|
||||||
|
import { dependencies } from "./package.json"
|
||||||
|
|
||||||
|
/** @returns {import("rollup").RollupOptions} */
|
||||||
|
export default () => ({
|
||||||
|
input: "./src/index.ts",
|
||||||
|
external: builtinModules.concat(Object.keys(dependencies)),
|
||||||
|
output: {
|
||||||
|
file: "lib/index.js",
|
||||||
|
format: "cjs",
|
||||||
|
},
|
||||||
|
plugins: [ts()],
|
||||||
|
})
|
89
src/fs.ts
Normal file
89
src/fs.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { resolve } from "path"
|
||||||
|
import invariant from "tiny-invariant"
|
||||||
|
import type { Stats } from "fs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that implements basic file system functionalities.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export interface IFileSystem {
|
||||||
|
/**
|
||||||
|
* Synchronously tests whether or not the given path exists by checking with the file system.
|
||||||
|
* @param path A path to a file or directory.
|
||||||
|
*/
|
||||||
|
existsSync(path: string): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously reads the entire contents of a file.
|
||||||
|
* @param path A path to a file.
|
||||||
|
*/
|
||||||
|
readFile(path: string, encoding: BufferEncoding): Promise<string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously writes data to a file, replacing the file if it already exists.
|
||||||
|
* @param path A path to a file.
|
||||||
|
* @param data The data to write.
|
||||||
|
*/
|
||||||
|
writeFile(path: string, data: string): Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous readdir(3) - read a directory.
|
||||||
|
* @param path A path to a directory.
|
||||||
|
*/
|
||||||
|
readdir(path: string): Promise<string[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous stat(2) - Get file status.
|
||||||
|
* @param path A path to a file.
|
||||||
|
*/
|
||||||
|
stat(path: string): Promise<Stats>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultFileSystem(): IFileSystem {
|
||||||
|
const fs: typeof import("fs") = require("fs")
|
||||||
|
return { ...fs.promises, existsSync: fs.existsSync }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnePasswordFileManager {
|
||||||
|
private root: string
|
||||||
|
|
||||||
|
constructor(private fs: IFileSystem, path: string, profileName: string) {
|
||||||
|
this.root = resolve(path, profileName)
|
||||||
|
invariant(fs.existsSync(path), `Path ${path} does not exist.`)
|
||||||
|
invariant(fs.existsSync(this.root), `Profile ${profileName} does not exist.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async #readFile(path: string) {
|
||||||
|
return await this.fs.readFile(resolve(this.root, path), "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
async #writeFile(path: string, value: string) {
|
||||||
|
return await this.fs.writeFile(resolve(this.root, path), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getProfile() {
|
||||||
|
return this.#readFile("profile.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
getFolders() {
|
||||||
|
return this.#readFile("folders.js")
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBand(name: string) {
|
||||||
|
try {
|
||||||
|
return await this.#readFile(`band_${name.toUpperCase()}.js`)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfile(profile: string) {
|
||||||
|
this.#writeFile("profile.js", profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFolders(folders: string) {
|
||||||
|
this.#writeFile("folders.js", folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBand(name: string, band: string) {
|
||||||
|
this.#writeFile(`band_${name}.js`, band)
|
||||||
|
}
|
||||||
|
}
|
58
src/index.ts
Normal file
58
src/index.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { resolve } from "path"
|
||||||
|
import { getDefaultFileSystem, IFileSystem } from "./fs"
|
||||||
|
import { Vault } from "./vault"
|
||||||
|
|
||||||
|
export type { Vault } from "./vault"
|
||||||
|
export { Category, FieldType } from "./models"
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/**
|
||||||
|
* Path to `.opvault` directory
|
||||||
|
*/
|
||||||
|
path: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Underlying `fs` module. You can replace it with a wrapper of
|
||||||
|
* `memfs` or any object that implements `IFileSystem`.
|
||||||
|
*/
|
||||||
|
fs?: IFileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OnePassword instance
|
||||||
|
*/
|
||||||
|
export class OnePassword {
|
||||||
|
readonly #path: string
|
||||||
|
readonly #fs: IFileSystem
|
||||||
|
|
||||||
|
constructor({ path, fs = getDefaultFileSystem() }: Options) {
|
||||||
|
this.#fs = fs
|
||||||
|
this.#path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns A list of names of profiles of the current vault.
|
||||||
|
*/
|
||||||
|
async getProfileNames() {
|
||||||
|
const [fs, path] = [this.#fs, this.#path]
|
||||||
|
const children = await fs.readdir(path)
|
||||||
|
const profiles: string[] = []
|
||||||
|
await Promise.all(
|
||||||
|
children.map(async child => {
|
||||||
|
const fullPath = resolve(path, child)
|
||||||
|
const stats = await fs.stat(fullPath)
|
||||||
|
if (stats.isDirectory() && fs.existsSync(resolve(fullPath, "profile.js"))) {
|
||||||
|
profiles.push(child)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns A OnePassword Vault instance.
|
||||||
|
*/
|
||||||
|
async getProfile(profileName: string) {
|
||||||
|
return await Vault.of(this.#path, profileName, this.#fs)
|
||||||
|
}
|
||||||
|
}
|
143
src/types.ts
Normal file
143
src/types.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import type { FieldType } from "./models"
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
lastUpdatedBy: "Dropbox"
|
||||||
|
/** Unix seconds */
|
||||||
|
updatedAt: number
|
||||||
|
profileName: string
|
||||||
|
salt: string // base64
|
||||||
|
masterKey: string // base64
|
||||||
|
iterations: number // 50000
|
||||||
|
uuid: string // 32 chars
|
||||||
|
overviewKey: string // "b3B...IMO52D"
|
||||||
|
createdAt: number // Unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptedItem {
|
||||||
|
category: string // "001"
|
||||||
|
/** Unix seconds */
|
||||||
|
created: number
|
||||||
|
d: string // "b3BkYXRhMbt"
|
||||||
|
folder: string // 32 chars
|
||||||
|
hmac: string // base64
|
||||||
|
k: string // base64
|
||||||
|
o: string // base64
|
||||||
|
tx: number // Unix seconds
|
||||||
|
updated: number // Unix seconds
|
||||||
|
uuid: string // 32 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextField = {
|
||||||
|
type: FieldType.Text
|
||||||
|
value: string
|
||||||
|
designation: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
export type BooleanField = {
|
||||||
|
type: FieldType.Checkbox
|
||||||
|
name: string
|
||||||
|
value?: "✓" | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemField =
|
||||||
|
| TextField
|
||||||
|
| BooleanField
|
||||||
|
| {
|
||||||
|
// @TODO: This currently catches all item fields.
|
||||||
|
type: FieldType
|
||||||
|
value: string
|
||||||
|
designation?: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace ItemSection {
|
||||||
|
type A = {
|
||||||
|
guarded: "yes"
|
||||||
|
clipboardFilter?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type String = {
|
||||||
|
k: "string"
|
||||||
|
v: string
|
||||||
|
/** Unique name */
|
||||||
|
n: string // "firstname" | "initial" | "address" | "class" | "conditions" | "expiry_date"
|
||||||
|
a?: A
|
||||||
|
/** User-readable title */
|
||||||
|
t: string // "first name" | "initial" | "address" | "license class" | "conditions / restrictions" | "expiry date"
|
||||||
|
}
|
||||||
|
type Menu = {
|
||||||
|
k: "menu"
|
||||||
|
v: string // "female"
|
||||||
|
a: A
|
||||||
|
t: string // "sex"
|
||||||
|
}
|
||||||
|
type Date = {
|
||||||
|
k: "date"
|
||||||
|
v: number // 359100000
|
||||||
|
n: string // "birthdate"
|
||||||
|
a: A
|
||||||
|
t: string // "birth date" | "date of birth"
|
||||||
|
}
|
||||||
|
type Gender = {
|
||||||
|
k: "gender"
|
||||||
|
n: "sex"
|
||||||
|
v: string // "female"
|
||||||
|
t: "sex"
|
||||||
|
}
|
||||||
|
type MonthYear = {
|
||||||
|
k: "monthYear"
|
||||||
|
n: string // "expiry_date"
|
||||||
|
v: number // 2515
|
||||||
|
t: string // "expiry date"
|
||||||
|
}
|
||||||
|
type Concealed = {
|
||||||
|
k: "concealed"
|
||||||
|
n: "password"
|
||||||
|
v: string
|
||||||
|
a?: {
|
||||||
|
generate: "off"
|
||||||
|
}
|
||||||
|
t: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Any = String | Menu | Date | Gender | MonthYear | Concealed
|
||||||
|
}
|
||||||
|
|
||||||
|
// One of them is empty?, 0C4F27910A64488BB339AED63565D148
|
||||||
|
export interface Item {
|
||||||
|
htmlForm?: {
|
||||||
|
htmlAction: string // "/login/"
|
||||||
|
htmlMethod: "post" | "get"
|
||||||
|
}
|
||||||
|
notesPlain?: string
|
||||||
|
sections: {
|
||||||
|
name: string // "name" | "title" | "internet"
|
||||||
|
title: string // "Identification" | "Address", "Internet Details"
|
||||||
|
fields?: ItemSection.Any[]
|
||||||
|
}[]
|
||||||
|
/** Web form fields */
|
||||||
|
fields?: ItemField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Band {
|
||||||
|
[uuid: string]: EncryptedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Folder {
|
||||||
|
created: number // 1373754128
|
||||||
|
overview: string // "b3BkYXRhT/../KBM="
|
||||||
|
smart?: true
|
||||||
|
tx: number // 1373754523
|
||||||
|
updated: number // 1373754134
|
||||||
|
uuid: string // "AC78552EB06A4F65BEBF58B4D9E32080"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Overview {
|
||||||
|
ps: number
|
||||||
|
title?: string
|
||||||
|
ainfo?: string
|
||||||
|
url?: string
|
||||||
|
tags?: string[]
|
||||||
|
URLs?: { u: string }[]
|
||||||
|
uuid?: string // Added manually
|
||||||
|
}
|
214
src/vault.ts
Normal file
214
src/vault.ts
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import * as crypto from "crypto"
|
||||||
|
import { IFileSystem, OnePasswordFileManager } from "./fs"
|
||||||
|
|
||||||
|
import type { Profile, Band, Overview, EncryptedItem, Item } from "./types"
|
||||||
|
|
||||||
|
type FoldersMap = { [uuid: string]: Band }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main OnePassword Vault class
|
||||||
|
*/
|
||||||
|
export class Vault {
|
||||||
|
// Ciphers
|
||||||
|
#master: Cipher
|
||||||
|
#overview: Cipher
|
||||||
|
|
||||||
|
// File system interface
|
||||||
|
#files: OnePasswordFileManager
|
||||||
|
|
||||||
|
// Encrypted contents
|
||||||
|
#profile: Profile
|
||||||
|
#folders: FoldersMap
|
||||||
|
#bands = new Map<string, Band>()
|
||||||
|
|
||||||
|
// Decrypted contents with plain-texts
|
||||||
|
#overviews = new Map<string, Overview>()
|
||||||
|
#items = new Map<string, EncryptedItem>()
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
files: OnePasswordFileManager,
|
||||||
|
profile: Profile,
|
||||||
|
folders: FoldersMap,
|
||||||
|
bands: Map<string, Band>
|
||||||
|
) {
|
||||||
|
this.#files = files
|
||||||
|
this.#profile = profile
|
||||||
|
this.#folders = folders
|
||||||
|
this.#bands = bands
|
||||||
|
|
||||||
|
this.#files
|
||||||
|
this.#folders
|
||||||
|
this.#items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new OnePassword Vault instance and read all bands.
|
||||||
|
*/
|
||||||
|
static async of(path: string, profileName = "default", fs: IFileSystem) {
|
||||||
|
const files = new OnePasswordFileManager(fs, path, profileName)
|
||||||
|
const profile = JSON.parse(stripText(await files.getProfile(), "var profile=", ";"))
|
||||||
|
const folders = JSON.parse(stripText(await files.getFolders(), "loadFolders(", ");"))
|
||||||
|
const bands = new Map<string, Band>()
|
||||||
|
|
||||||
|
for (let i = 0; i < 36; i++) {
|
||||||
|
const letter = i.toString(36)
|
||||||
|
const source = await files.getBand(letter)
|
||||||
|
bands.set(letter, source ? JSON.parse(stripText(source, "ld(", ");")) : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Vault(files, profile, folders, bands)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock this OnePassword vault.
|
||||||
|
* @param masterPassword User provided master password. Only the derived
|
||||||
|
* master and overview key will be stored within the class.
|
||||||
|
*/
|
||||||
|
unlock(masterPassword: string) {
|
||||||
|
const profile = this.#profile
|
||||||
|
const derivedKey = crypto.pbkdf2Sync(
|
||||||
|
masterPassword,
|
||||||
|
toBuffer(profile.salt),
|
||||||
|
profile.iterations,
|
||||||
|
/* keylen */ 64,
|
||||||
|
"sha512"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cipher = splitPlainText(derivedKey)
|
||||||
|
|
||||||
|
// Derive master key and overview keys
|
||||||
|
try {
|
||||||
|
this.#master = decryptKeys(profile.masterKey, cipher)
|
||||||
|
this.#overview = decryptKeys(profile.overviewKey, cipher)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof HMACAssertionError) {
|
||||||
|
throw new Error("Invalid password.")
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove derived keys within the class instance.
|
||||||
|
*/
|
||||||
|
lock() {
|
||||||
|
this.#master = null!
|
||||||
|
this.#overview = null!
|
||||||
|
this.#overviews.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLocked() {
|
||||||
|
return Boolean(this.#master.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
getOverview(title: string): Overview
|
||||||
|
getOverview(predicate: (overview: Overview) => boolean): Overview
|
||||||
|
|
||||||
|
getOverview(condition: string | ((overview: Overview) => boolean)) {
|
||||||
|
if (typeof condition === "string") {
|
||||||
|
const title = condition
|
||||||
|
condition = overview => overview.title === title
|
||||||
|
}
|
||||||
|
for (const value of this.#overviews.values()) {
|
||||||
|
if (condition(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(uuid: string) {
|
||||||
|
const encrypted = uuid ? this.#bands.get(uuid[0])![uuid] : undefined
|
||||||
|
return encrypted && this.decryptItem(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
readItems() {
|
||||||
|
this.#bands.forEach(value => {
|
||||||
|
for (const [uuid, item] of Object.entries(value)) {
|
||||||
|
const overview = this.decryptOverview(item)
|
||||||
|
overview.uuid = uuid
|
||||||
|
this.#overviews.set(uuid, overview)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptOverview(item: EncryptedItem) {
|
||||||
|
try {
|
||||||
|
const overview = decryptOPData(toBuffer(item.o), this.#overview)
|
||||||
|
return JSON.parse(overview.toString("utf8")) as Overview
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to decrypt overview item.")
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptItem(item: EncryptedItem): Item {
|
||||||
|
const k = toBuffer(item.k)
|
||||||
|
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))
|
||||||
|
|
||||||
|
const detail = decryptOPData(
|
||||||
|
/* cipherText */ toBuffer(item.d),
|
||||||
|
/* cipher */ splitPlainText(derivedKey)
|
||||||
|
)
|
||||||
|
return JSON.parse(detail.toString("utf-8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encryption and MAC */
|
||||||
|
interface Cipher {
|
||||||
|
/** Encryption key */
|
||||||
|
key: Buffer
|
||||||
|
/** HMAC key */
|
||||||
|
hmac: Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripText(text: string, prefix: string, suffix: string) {
|
||||||
|
if (text.startsWith(prefix)) text = text.slice(prefix.length)
|
||||||
|
if (text.endsWith(suffix)) text = text.slice(0, -suffix.length)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPlainText = (derivedKey: Buffer): Cipher => ({
|
||||||
|
key: derivedKey.slice(0, 32),
|
||||||
|
hmac: derivedKey.slice(32, 64),
|
||||||
|
})
|
||||||
|
|
||||||
|
class HMACAssertionError extends Error {}
|
||||||
|
|
||||||
|
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 assertHMac(data: Buffer, key: Buffer, expected: Buffer) {
|
||||||
|
const actual = crypto.createHmac("sha256", key).update(data).digest()
|
||||||
|
if (!actual.equals(expected)) {
|
||||||
|
throw new HMACAssertionError("HMAC assertion failed.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptKeys(encryptedKey: string, derived: Cipher) {
|
||||||
|
const buffer = toBuffer(encryptedKey)
|
||||||
|
const base = decryptOPData(buffer, derived)
|
||||||
|
const digest = crypto.createHash("sha512").update(base).digest()
|
||||||
|
return splitPlainText(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptData(key: Buffer, iv: Buffer, data: Buffer) {
|
||||||
|
const cipher = crypto.createDecipheriv("aes-256-cbc", key, iv).setAutoPadding(false)
|
||||||
|
return Buffer.concat([cipher.update(data), cipher.final()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint16({ buffer, byteOffset, length }: Buffer) {
|
||||||
|
return new DataView(buffer, byteOffset, length).getUint16(0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBuffer(data: string) {
|
||||||
|
return Buffer.from(data, "base64")
|
||||||
|
}
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noEmit": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"stripInternal": true,
|
||||||
|
"target": "es2020",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"transpileOnly": true,
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user