@papervault/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/package.json +51 -0
- package/src/compress.js +170 -0
- package/src/constants.js +19 -0
- package/src/crypto.js +184 -0
- package/src/index.js +22 -0
- package/src/kit.js +206 -0
- package/src/templates/index.js +4 -0
- package/src/templates/key.js +95 -0
- package/src/templates/printall.js +67 -0
- package/src/templates/selector.js +193 -0
- package/src/templates/styles.js +282 -0
- package/src/templates/vault.js +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 PaperVault.xyz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# PaperVault Core — Crypto + Shamir + page generation library
|
|
2
|
+
|
|
3
|
+
**PaperVault 📄🔐** is a free open source tool for creating offline paper-based data vaults for your foundational secrets, such as passwords, 2FA recovery codes, digital asset keys, hard drive encryption keys, and other critical data.
|
|
4
|
+
|
|
5
|
+
This is the **core library** — the AES-256-GCM crypto, Shamir Secret Sharing, and printable HTML page generation that powers [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli) and [`@papervault/mcp`](https://www.npmjs.com/package/@papervault/mcp). The browser app lives at [papervault.xyz](https://papervault.xyz).
|
|
6
|
+
|
|
7
|
+
If you just want to back up secrets to paper, install [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli) instead. This package is the building block — useful if you want to embed kit generation in your own tool.
|
|
8
|
+
|
|
9
|
+
## 🔐 Overview
|
|
10
|
+
|
|
11
|
+
`@papervault/core` is a pure-JavaScript library (no native deps) that:
|
|
12
|
+
|
|
13
|
+
- Encrypts plaintext with a fresh AES-256-GCM key (via Web Crypto API)
|
|
14
|
+
- Splits the key into N shares using [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing) over GF(2^8), recoverable with any M of N
|
|
15
|
+
- Generates standalone printable HTML pages: one vault page with the ciphertext QR and N key pages with one share QR each
|
|
16
|
+
- Produces the **v2 vault format** — byte-identical to vaults produced at [papervault.xyz](https://papervault.xyz), so kits unlock at [papervault.xyz/unlock](https://papervault.xyz/unlock)
|
|
17
|
+
|
|
18
|
+
## 🚀 Quick Start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @papervault/core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { createKit } from '@papervault/core';
|
|
26
|
+
|
|
27
|
+
const kit = await createKit({
|
|
28
|
+
secrets: [
|
|
29
|
+
{ kind: 'password', service: 'GitHub', username: 'alice', password: 's3cret' },
|
|
30
|
+
{ kind: 'wallet', name: 'BTC cold', seed: 'abandon abandon ... about' },
|
|
31
|
+
],
|
|
32
|
+
vaultName: 'My DR Kit',
|
|
33
|
+
threshold: 2,
|
|
34
|
+
shares: 3,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// kit.pages = [
|
|
38
|
+
// { kind: 'vault', html: '...', filename: 'vault', ... },
|
|
39
|
+
// { kind: 'key', html: '...', filename: 'key-1-atom-river', seq: 'Key 1', alias: 'atom-river', ... },
|
|
40
|
+
// ...
|
|
41
|
+
// ]
|
|
42
|
+
// kit.keyShares, kit.cipherKeyHex, kit.cipherTextHex, kit.cipherIvHex available for round-trip / verify.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Node.js ≥ 24 (needs global `crypto` and `crypto.subtle`).
|
|
46
|
+
|
|
47
|
+
## 🔑 Public API
|
|
48
|
+
|
|
49
|
+
| Export | Purpose |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `createKit(opts)` | High-level: compress → encrypt → split → generate HTML. Returns the full `Kit`. |
|
|
52
|
+
| `compress(secrets, freeText?)` | Convert structured entries into the web-app's ultra-compact JSON. Byte-identical to what papervault.xyz produces. |
|
|
53
|
+
| `countUserContent(secrets, freeText?)` | Character-count for the 300-char limit (matches the web app's `getUserContentLength`). |
|
|
54
|
+
| `encrypt(plaintext)` | Fresh-key AES-256-GCM. Returns `{cipherText, cipherKey, cipherIV}` (all hex). |
|
|
55
|
+
| `decrypt(cipherTextHex, keyHex, ivHex)` | Inverse. |
|
|
56
|
+
| `splitKey(secretKeyHex, n, t)` | Shamir split. 32-byte hex key in, array of hex shares out. |
|
|
57
|
+
| `combineShares(shares)` | Inverse — give it ≥ threshold shares, get the original key back. |
|
|
58
|
+
| `generateKeyAliases(n)` | Two-word BIP-39 aliases (e.g. `atom-river`). |
|
|
59
|
+
| `generateVaultId()` | 6-char public hex ID. |
|
|
60
|
+
| `generateVaultPage(opts)` / `buildVaultBody(opts)` | Standalone vault HTML, or just its inner body fragment. |
|
|
61
|
+
| `generateKeyPage(opts)` / `buildKeyBody(opts)` | Same for key pages. |
|
|
62
|
+
| `generatePrintAllPage(opts)` | Single doc concatenating every kit page with CSS page breaks — one print dialog for the whole packet. |
|
|
63
|
+
| `generateSelectorPage(opts)` | Orchestration page used by the CLI's ephemeral print server. |
|
|
64
|
+
| `LIMITS` | `{MAX_KEYS: 20, MAX_STORAGE: 300, MAX_QR_PAYLOAD_BYTES: 102400}` |
|
|
65
|
+
| `VAULT_VERSION` | `'2'` |
|
|
66
|
+
| `VAULT_IDENT` | `'papervault.xyz'` — sentinel in vault QR payloads. |
|
|
67
|
+
|
|
68
|
+
## 📄 Vault format
|
|
69
|
+
|
|
70
|
+
v2 vaults use:
|
|
71
|
+
|
|
72
|
+
- **AES-256-GCM** for encryption (Web Crypto API)
|
|
73
|
+
- **Shamir Secret Sharing over GF(2^8)** for key splitting ([shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing))
|
|
74
|
+
- **Random 12-byte nonce** per encryption, generated by `crypto.getRandomValues()`
|
|
75
|
+
- **Level-M QR codes** for ~15% damage tolerance
|
|
76
|
+
|
|
77
|
+
The QR payload shapes match the web app exactly:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
// Vault QR (single-QR form; splits to metadata + data QRs if > 420 bytes)
|
|
81
|
+
{ id: 1, vault: 'papervault.xyz', version: '2', name, shares, threshold,
|
|
82
|
+
cipherIV, keys: [...], data: cipherText }
|
|
83
|
+
|
|
84
|
+
// Key QR
|
|
85
|
+
{ ident: keyAlias, key: keyShare }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
V1 vault decryption (legacy CryptoJS / AES-CTR / `secrets.js`) lives in the web app, not here — this package is v2-only by design.
|
|
89
|
+
|
|
90
|
+
## 🛡️ Security Notes
|
|
91
|
+
|
|
92
|
+
- All randomness via `crypto.getRandomValues()`. Never `Math.random()`.
|
|
93
|
+
- `crypto.subtle` for AES-GCM — no JS reimplementation of the cipher.
|
|
94
|
+
- No persistent state. The library never writes anywhere.
|
|
95
|
+
- The returned `Kit` object holds the raw AES key + Shamir shares so the caller can audit / round-trip. Treat it like a secret and drop the reference when done.
|
|
96
|
+
|
|
97
|
+
For the full PaperVault threat model, see the main app's [README](https://github.com/boazeb/papervault) and [SECURITY.md](https://github.com/boazeb/papervault/blob/main/SECURITY.md).
|
|
98
|
+
|
|
99
|
+
## 📦 Limits
|
|
100
|
+
|
|
101
|
+
- **Maximum Keys**: 20 (Shamir library constraint)
|
|
102
|
+
- **Storage Limit**: 300 characters of user content per vault (QR optimization — matches the web app)
|
|
103
|
+
|
|
104
|
+
`createKit` enforces both and throws clear errors.
|
|
105
|
+
|
|
106
|
+
## 🔗 Related Packages
|
|
107
|
+
|
|
108
|
+
- [`@papervault/cli`](https://www.npmjs.com/package/@papervault/cli) — Command-line front-end (most users want this)
|
|
109
|
+
- [`@papervault/mcp`](https://www.npmjs.com/package/@papervault/mcp) — MCP server for AI agents
|
|
110
|
+
- [`@papervault/init`](https://www.npmjs.com/package/@papervault/init) — `npx` setup wizard
|
|
111
|
+
- [PaperVault web app](https://papervault.xyz) — same crypto, browser version
|
|
112
|
+
- [Main repo](https://github.com/boazeb/papervault) — issues, docs, SECURITY.md, full Vault-vs-key-shares explanation, AI-audit links
|
|
113
|
+
|
|
114
|
+
## 🤝 Contributing
|
|
115
|
+
|
|
116
|
+
Contributions are welcome! See the main repo at [github.com/boazeb/papervault](https://github.com/boazeb/papervault).
|
|
117
|
+
|
|
118
|
+
## 📄 License
|
|
119
|
+
|
|
120
|
+
MIT — see [LICENSE](LICENSE).
|
|
121
|
+
|
|
122
|
+
## 🙏 Acknowledgments
|
|
123
|
+
|
|
124
|
+
- [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing) algorithm by Adi Shamir
|
|
125
|
+
- [shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing) — v2 Shamir over GF(2^8)
|
|
126
|
+
- [bip39](https://github.com/bitcoinjs/bip39) — mnemonic word lists for key aliases
|
|
127
|
+
- [qrcode](https://github.com/soldair/node-qrcode) — QR generation
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@papervault/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Crypto + Shamir Secret Sharing + printable HTML page generation for PaperVault disaster-recovery kits. Produces vaults that unlock at papervault.xyz.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./crypto": "./src/crypto.js",
|
|
10
|
+
"./templates": "./src/templates/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24.x"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://papervault.xyz",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/boazeb/papervault.git",
|
|
24
|
+
"directory": "papervault-core"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/boazeb/papervault/issues"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"papervault",
|
|
31
|
+
"cryptography",
|
|
32
|
+
"shamir-secret-sharing",
|
|
33
|
+
"aes-256-gcm",
|
|
34
|
+
"secret-sharing",
|
|
35
|
+
"paper-wallet",
|
|
36
|
+
"cold-storage",
|
|
37
|
+
"threshold-encryption",
|
|
38
|
+
"qr-code",
|
|
39
|
+
"open-source"
|
|
40
|
+
],
|
|
41
|
+
"author": "PaperVault.xyz",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"bip39": "^3.1.0",
|
|
48
|
+
"qrcode": "^1.5.3",
|
|
49
|
+
"shamir-secret-sharing": "^0.0.4"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/compress.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Convert structured CLI-input secrets into the web-app's ultra-compact format,
|
|
2
|
+
// matching papervault/src/components/SecretDataEntry.jsx convertToCompressedText().
|
|
3
|
+
// This is the plaintext that gets AES-GCM encrypted, so byte-identical output
|
|
4
|
+
// is what makes kits unlockable on papervault.xyz.
|
|
5
|
+
//
|
|
6
|
+
// Also exposes countUserContent() — character-counting that mirrors the web
|
|
7
|
+
// app's getUserContentLength(): sum of non-empty field values across all
|
|
8
|
+
// structured entries + freeText, EXCLUDING JSON overhead. This is what gets
|
|
9
|
+
// compared against LIMITS.MAX_STORAGE (300 chars) so the QR stays scannable.
|
|
10
|
+
|
|
11
|
+
import { LIMITS } from './constants.js';
|
|
12
|
+
|
|
13
|
+
// Field orders MUST match SecretDataEntry.jsx exactly.
|
|
14
|
+
const FIELD_ORDER = {
|
|
15
|
+
passwords: ['service', 'username', 'password', 'url', 'notes'],
|
|
16
|
+
wallets: ['name', 'seed', 'privateKey', 'address', 'notes'],
|
|
17
|
+
notes: ['title', 'content'],
|
|
18
|
+
apiKeys: ['service', 'key', 'secret', 'notes'],
|
|
19
|
+
custom: ['label', 'value', 'notes'],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ALLOWED_KINDS = new Set([
|
|
23
|
+
'password', 'wallet', 'note', 'apikey', 'custom',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Map singular kind -> plural section name used in the compressed format.
|
|
27
|
+
const KIND_TO_SECTION = {
|
|
28
|
+
password: 'passwords',
|
|
29
|
+
wallet: 'wallets',
|
|
30
|
+
note: 'notes',
|
|
31
|
+
apikey: 'apiKeys',
|
|
32
|
+
custom: 'custom',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Map well-known CLI field names -> structured entry. Lets users write
|
|
36
|
+
// {"name": "github", "value": "ghp_..."} as a generic shape and we put it
|
|
37
|
+
// in the right field slot per kind.
|
|
38
|
+
function normalizeEntry(kind, raw) {
|
|
39
|
+
if (kind === 'password') {
|
|
40
|
+
return {
|
|
41
|
+
service: raw.service ?? raw.name ?? '',
|
|
42
|
+
username: raw.username ?? raw.user ?? '',
|
|
43
|
+
password: raw.password ?? raw.value ?? '',
|
|
44
|
+
url: raw.url ?? '',
|
|
45
|
+
notes: raw.notes ?? '',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (kind === 'wallet') {
|
|
49
|
+
return {
|
|
50
|
+
name: raw.name ?? raw.service ?? '',
|
|
51
|
+
seed: raw.seed ?? raw.mnemonic ?? raw.value ?? '',
|
|
52
|
+
privateKey: raw.privateKey ?? raw.private_key ?? '',
|
|
53
|
+
address: raw.address ?? '',
|
|
54
|
+
notes: raw.notes ?? '',
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (kind === 'note') {
|
|
58
|
+
return {
|
|
59
|
+
title: raw.title ?? raw.name ?? '',
|
|
60
|
+
content: raw.content ?? raw.value ?? raw.body ?? '',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (kind === 'apikey') {
|
|
64
|
+
return {
|
|
65
|
+
service: raw.service ?? raw.name ?? '',
|
|
66
|
+
key: raw.key ?? raw.value ?? '',
|
|
67
|
+
secret: raw.secret ?? '',
|
|
68
|
+
notes: raw.notes ?? '',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// custom
|
|
72
|
+
return {
|
|
73
|
+
label: raw.label ?? raw.name ?? '',
|
|
74
|
+
value: raw.value ?? '',
|
|
75
|
+
notes: raw.notes ?? '',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createUltraCompactArray(values) {
|
|
80
|
+
const result = [];
|
|
81
|
+
for (const v of values) {
|
|
82
|
+
if (v && String(v).trim() !== '') {
|
|
83
|
+
result.push(String(v));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result.length > 0 ? result : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {Array<{kind: string, [k: string]: any}>} secrets - User-facing structured input
|
|
91
|
+
* @param {string} [freeText] - Optional freeform text block
|
|
92
|
+
* @returns {string} - Compressed JSON exactly matching the web-app format
|
|
93
|
+
*/
|
|
94
|
+
export function compress(secrets, freeText) {
|
|
95
|
+
if (!Array.isArray(secrets)) {
|
|
96
|
+
throw new Error('compress: secrets must be an array.');
|
|
97
|
+
}
|
|
98
|
+
if (secrets.length > LIMITS.MAX_KEYS * 5) {
|
|
99
|
+
// Loose upper bound; the real cap comes from QR payload size.
|
|
100
|
+
throw new Error('compress: too many secrets.');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Group entries by section in the order the web app expects.
|
|
104
|
+
const sections = { passwords: [], wallets: [], notes: [], apiKeys: [], custom: [] };
|
|
105
|
+
|
|
106
|
+
for (const raw of secrets) {
|
|
107
|
+
if (!raw || typeof raw !== 'object') {
|
|
108
|
+
throw new Error('compress: each secret must be an object.');
|
|
109
|
+
}
|
|
110
|
+
const kind = String(raw.kind || '').toLowerCase();
|
|
111
|
+
if (!ALLOWED_KINDS.has(kind)) {
|
|
112
|
+
throw new Error(`compress: unknown kind "${kind}". Allowed: ${[...ALLOWED_KINDS].join(', ')}.`);
|
|
113
|
+
}
|
|
114
|
+
const section = KIND_TO_SECTION[kind];
|
|
115
|
+
const normalized = normalizeEntry(kind, raw);
|
|
116
|
+
const ordered = FIELD_ORDER[section].map(f => normalized[f] ?? '');
|
|
117
|
+
const compact = createUltraCompactArray(ordered);
|
|
118
|
+
if (compact) sections[section].push(compact);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const cleanData = {};
|
|
122
|
+
const sectionsWithData = [];
|
|
123
|
+
for (const section of Object.keys(sections)) {
|
|
124
|
+
if (sections[section].length > 0) {
|
|
125
|
+
cleanData[section] = sections[section];
|
|
126
|
+
sectionsWithData.push(section);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (freeText && String(freeText).trim() !== '') {
|
|
130
|
+
cleanData.freeText = String(freeText);
|
|
131
|
+
sectionsWithData.push('freeText');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Same optimizations as SecretDataEntry.jsx — important for byte-identical output.
|
|
135
|
+
const nonFreeTextSections = sectionsWithData.filter(s => s !== 'freeText');
|
|
136
|
+
if (nonFreeTextSections.length === 1 && !cleanData.freeText) {
|
|
137
|
+
const onlySection = nonFreeTextSections[0];
|
|
138
|
+
const data = cleanData[onlySection];
|
|
139
|
+
if (data.length === 1) return JSON.stringify(data[0]);
|
|
140
|
+
return JSON.stringify(data);
|
|
141
|
+
}
|
|
142
|
+
return Object.keys(cleanData).length > 0 ? JSON.stringify(cleanData) : '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sum the character lengths of all non-empty string field values across the
|
|
147
|
+
* input secrets, plus freeText. Matches the web app's getUserContentLength()
|
|
148
|
+
* exactly so the same content hits the same limit in both places.
|
|
149
|
+
*
|
|
150
|
+
* @param {Array<object>} secrets
|
|
151
|
+
* @param {string} [freeText]
|
|
152
|
+
* @returns {number}
|
|
153
|
+
*/
|
|
154
|
+
export function countUserContent(secrets, freeText) {
|
|
155
|
+
if (!Array.isArray(secrets)) return 0;
|
|
156
|
+
let total = 0;
|
|
157
|
+
for (const entry of secrets) {
|
|
158
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
159
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
160
|
+
if (key === 'kind') continue; // metadata, not user content
|
|
161
|
+
if (typeof value === 'string' && value) {
|
|
162
|
+
total += value.length;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (freeText && typeof freeText === 'string') {
|
|
167
|
+
total += freeText.length;
|
|
168
|
+
}
|
|
169
|
+
return total;
|
|
170
|
+
}
|
package/src/constants.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Mirrors papervault/src/config/limits.js and vaultConfig.js
|
|
2
|
+
// Kept in sync manually for now; future: extract a single source of truth.
|
|
3
|
+
|
|
4
|
+
export const VAULT_VERSION = '2';
|
|
5
|
+
|
|
6
|
+
export const LIMITS = {
|
|
7
|
+
MAX_KEYS: 20,
|
|
8
|
+
MAX_STORAGE: 300,
|
|
9
|
+
MAX_QR_PAYLOAD_BYTES: 100 * 1024,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const GCM_NONCE_BYTES = 12;
|
|
13
|
+
export const KEY_BYTES = 32;
|
|
14
|
+
export const MAX_CIPHERTEXT_HEX_LENGTH = 2 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export const HEX_REGEX = /^[0-9a-fA-F]+$/;
|
|
17
|
+
|
|
18
|
+
// Sentinel used in the vault QR `vault` field — matches what papervault.xyz expects.
|
|
19
|
+
export const VAULT_IDENT = 'papervault.xyz';
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// V2 crypto only: AES-256-GCM via WebCrypto + Shamir Secret Sharing.
|
|
2
|
+
// Extracted from papervault/src/services/EncryptionService.js.
|
|
3
|
+
// Node 24.x exposes globalThis.crypto with WebCrypto subtle support.
|
|
4
|
+
|
|
5
|
+
import { split as shamirSplit, combine as shamirCombine } from 'shamir-secret-sharing';
|
|
6
|
+
import bip39 from 'bip39';
|
|
7
|
+
import {
|
|
8
|
+
GCM_NONCE_BYTES,
|
|
9
|
+
KEY_BYTES,
|
|
10
|
+
MAX_CIPHERTEXT_HEX_LENGTH,
|
|
11
|
+
HEX_REGEX,
|
|
12
|
+
LIMITS,
|
|
13
|
+
} from './constants.js';
|
|
14
|
+
|
|
15
|
+
const subtle = globalThis.crypto?.subtle;
|
|
16
|
+
if (!subtle) {
|
|
17
|
+
throw new Error('@papervault/core requires Node 24+ with WebCrypto (globalThis.crypto.subtle).');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function secureRandomBytes(length) {
|
|
21
|
+
if (!globalThis.crypto?.getRandomValues) {
|
|
22
|
+
throw new Error('secureRandomBytes: crypto.getRandomValues is not available.');
|
|
23
|
+
}
|
|
24
|
+
return globalThis.crypto.getRandomValues(new Uint8Array(length));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function hexToUint8Array(hex) {
|
|
28
|
+
if (typeof hex !== 'string' || hex.length % 2 !== 0) {
|
|
29
|
+
throw new Error('hexToUint8Array: hex string must have even length.');
|
|
30
|
+
}
|
|
31
|
+
const len = hex.length / 2;
|
|
32
|
+
const out = new Uint8Array(len);
|
|
33
|
+
for (let i = 0; i < len; i++) {
|
|
34
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function uint8ArrayToHex(arr) {
|
|
40
|
+
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateHexInput(value, label, exactBytes, maxHexLength) {
|
|
44
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
45
|
+
throw new Error(`Invalid ${label}: must be a non-empty hex string.`);
|
|
46
|
+
}
|
|
47
|
+
if (!HEX_REGEX.test(value)) {
|
|
48
|
+
throw new Error(`Invalid ${label}: not a valid hex string.`);
|
|
49
|
+
}
|
|
50
|
+
if (value.length % 2 !== 0) {
|
|
51
|
+
throw new Error(`Invalid ${label}: hex string must have even length.`);
|
|
52
|
+
}
|
|
53
|
+
if (exactBytes != null) {
|
|
54
|
+
const expectedLen = exactBytes * 2;
|
|
55
|
+
if (value.length !== expectedLen) {
|
|
56
|
+
throw new Error(`Invalid ${label}: expected ${exactBytes} bytes, got ${value.length / 2}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (maxHexLength != null && value.length > maxHexLength) {
|
|
60
|
+
throw new Error(`Invalid ${label}: exceeds maximum allowed size.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encrypt a UTF-8 string with a freshly-generated AES-256-GCM key + nonce.
|
|
66
|
+
* Returns { cipherText, cipherKey, cipherIV, version } — all hex strings.
|
|
67
|
+
*/
|
|
68
|
+
export async function encrypt(plaintext) {
|
|
69
|
+
if (typeof plaintext !== 'string') {
|
|
70
|
+
throw new Error('encrypt: plaintext must be a string.');
|
|
71
|
+
}
|
|
72
|
+
const keyBytes = secureRandomBytes(KEY_BYTES);
|
|
73
|
+
const nonce = secureRandomBytes(GCM_NONCE_BYTES);
|
|
74
|
+
const key = await subtle.importKey('raw', keyBytes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
|
|
75
|
+
const encoded = new TextEncoder().encode(plaintext);
|
|
76
|
+
const ciphertextWithTag = await subtle.encrypt(
|
|
77
|
+
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
|
|
78
|
+
key,
|
|
79
|
+
encoded,
|
|
80
|
+
);
|
|
81
|
+
return {
|
|
82
|
+
cipherText: uint8ArrayToHex(new Uint8Array(ciphertextWithTag)),
|
|
83
|
+
cipherKey: uint8ArrayToHex(keyBytes),
|
|
84
|
+
cipherIV: uint8ArrayToHex(nonce),
|
|
85
|
+
version: '2',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decrypt v2 ciphertext given the AES key + nonce in hex. Returns the UTF-8 string.
|
|
91
|
+
* Throws if validation fails or tag doesn't match.
|
|
92
|
+
*/
|
|
93
|
+
export async function decrypt(cipherTextHex, keyHex, ivHex) {
|
|
94
|
+
validateHexInput(ivHex, 'IV', GCM_NONCE_BYTES, null);
|
|
95
|
+
validateHexInput(keyHex, 'key', KEY_BYTES, null);
|
|
96
|
+
validateHexInput(cipherTextHex, 'ciphertext', null, MAX_CIPHERTEXT_HEX_LENGTH);
|
|
97
|
+
const key = await subtle.importKey('raw', hexToUint8Array(keyHex), { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
|
|
98
|
+
const plaintext = await subtle.decrypt(
|
|
99
|
+
{ name: 'AES-GCM', iv: hexToUint8Array(ivHex), tagLength: 128 },
|
|
100
|
+
key,
|
|
101
|
+
hexToUint8Array(cipherTextHex),
|
|
102
|
+
);
|
|
103
|
+
return new TextDecoder().decode(plaintext);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Split a hex-encoded 32-byte key into N shares with threshold T using Shamir over GF(2^8).
|
|
108
|
+
* Matches the v2 web-app split exactly so kits are unlockable by papervault.xyz.
|
|
109
|
+
*/
|
|
110
|
+
export async function splitKey(secretKeyHex, numberOfShares, threshold) {
|
|
111
|
+
if (typeof secretKeyHex !== 'string' || secretKeyHex.length === 0) {
|
|
112
|
+
throw new Error('splitKey: secretKey must be a non-empty string.');
|
|
113
|
+
}
|
|
114
|
+
if (secretKeyHex.length !== KEY_BYTES * 2 || !HEX_REGEX.test(secretKeyHex)) {
|
|
115
|
+
throw new Error('splitKey: secretKey must be 32-byte hex (64 chars).');
|
|
116
|
+
}
|
|
117
|
+
const n = Number(numberOfShares);
|
|
118
|
+
const t = Number(threshold);
|
|
119
|
+
if (!Number.isInteger(n) || n < 1 || n > LIMITS.MAX_KEYS) {
|
|
120
|
+
throw new Error(`splitKey: numberOfShares must be 1..${LIMITS.MAX_KEYS}.`);
|
|
121
|
+
}
|
|
122
|
+
if (!Number.isInteger(t) || t < 1 || t > n) {
|
|
123
|
+
throw new Error('splitKey: threshold must be 1..numberOfShares.');
|
|
124
|
+
}
|
|
125
|
+
if (n > 1 && t < 2) {
|
|
126
|
+
throw new Error('Shamir requires threshold >= 2 when shares > 1.');
|
|
127
|
+
}
|
|
128
|
+
const secret = hexToUint8Array(secretKeyHex);
|
|
129
|
+
const shareArrays = await shamirSplit(secret, n, t);
|
|
130
|
+
return shareArrays.map(arr => uint8ArrayToHex(arr));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Recombine threshold-or-more hex shares back into the 32-byte key (hex).
|
|
135
|
+
*/
|
|
136
|
+
export async function combineShares(shares) {
|
|
137
|
+
if (!Array.isArray(shares) || shares.length === 0) {
|
|
138
|
+
throw new Error('combineShares: shares must be a non-empty array.');
|
|
139
|
+
}
|
|
140
|
+
if (shares.length > LIMITS.MAX_KEYS) {
|
|
141
|
+
throw new Error(`combineShares: too many shares (max ${LIMITS.MAX_KEYS}).`);
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < shares.length; i++) {
|
|
144
|
+
const s = shares[i];
|
|
145
|
+
if (typeof s !== 'string' || s.length === 0 || s.length % 2 !== 0 || !HEX_REGEX.test(s)) {
|
|
146
|
+
throw new Error(`combineShares: share ${i} must be a valid hex string with even length.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const shareArrays = shares.map(hex => hexToUint8Array(hex));
|
|
150
|
+
const secret = await shamirCombine(shareArrays);
|
|
151
|
+
return uint8ArrayToHex(secret);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Generate human-friendly two-word key aliases using BIP39 wordlist.
|
|
156
|
+
* Returns N aliases like "atom-river", "moon-glass".
|
|
157
|
+
*/
|
|
158
|
+
export function generateKeyAliases(amount) {
|
|
159
|
+
if (!Number.isInteger(amount) || amount < 1 || amount > LIMITS.MAX_KEYS) {
|
|
160
|
+
throw new Error(`generateKeyAliases: amount must be 1..${LIMITS.MAX_KEYS}.`);
|
|
161
|
+
}
|
|
162
|
+
const cleanWords = [];
|
|
163
|
+
for (let i = 0; i < 4; i++) {
|
|
164
|
+
const entropyBytes = secureRandomBytes(16);
|
|
165
|
+
const entropyHex = uint8ArrayToHex(entropyBytes);
|
|
166
|
+
const words = bip39.entropyToMnemonic(entropyHex).split(' ');
|
|
167
|
+
cleanWords.push(...words);
|
|
168
|
+
}
|
|
169
|
+
const result = [];
|
|
170
|
+
let c = 0;
|
|
171
|
+
for (let i = 0; i < cleanWords.length && c < amount; i += 2) {
|
|
172
|
+
result.push(`${cleanWords[i]}-${cleanWords[i + 1]}`);
|
|
173
|
+
c++;
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Short public ID for a vault (6 hex chars). Used for folder naming and display.
|
|
180
|
+
* NOT a secret — derived from fresh random bytes, no relation to the key.
|
|
181
|
+
*/
|
|
182
|
+
export function generateVaultId() {
|
|
183
|
+
return uint8ArrayToHex(secureRandomBytes(3));
|
|
184
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Public API for @papervault/core.
|
|
2
|
+
// Consumed by @papervault/cli and (in the future) @papervault/mcp.
|
|
3
|
+
|
|
4
|
+
export { createKit } from './kit.js';
|
|
5
|
+
export { compress, countUserContent } from './compress.js';
|
|
6
|
+
export {
|
|
7
|
+
encrypt,
|
|
8
|
+
decrypt,
|
|
9
|
+
splitKey,
|
|
10
|
+
combineShares,
|
|
11
|
+
generateKeyAliases,
|
|
12
|
+
generateVaultId,
|
|
13
|
+
secureRandomBytes,
|
|
14
|
+
uint8ArrayToHex,
|
|
15
|
+
hexToUint8Array,
|
|
16
|
+
} from './crypto.js';
|
|
17
|
+
export {
|
|
18
|
+
generateVaultPage,
|
|
19
|
+
generateKeyPage,
|
|
20
|
+
generateSelectorPage,
|
|
21
|
+
} from './templates/index.js';
|
|
22
|
+
export { LIMITS, VAULT_VERSION, VAULT_IDENT } from './constants.js';
|