@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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Shared CSS for vault + key pages.
|
|
2
|
+
// Designed for A4 print output. Single page per HTML document.
|
|
3
|
+
// @page rule sets the print size; @media print hides any browser chrome.
|
|
4
|
+
|
|
5
|
+
export const SHARED_CSS = `
|
|
6
|
+
@page {
|
|
7
|
+
size: A4;
|
|
8
|
+
margin: 15mm;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
|
|
13
|
+
html, body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
padding: 0;
|
|
16
|
+
background: #fff;
|
|
17
|
+
color: #2c3e50;
|
|
18
|
+
font-family: Helvetica, Arial, sans-serif;
|
|
19
|
+
-webkit-print-color-adjust: exact;
|
|
20
|
+
print-color-adjust: exact;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
body {
|
|
24
|
+
padding: 20px;
|
|
25
|
+
max-width: 210mm;
|
|
26
|
+
margin: 0 auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@media print {
|
|
30
|
+
body { padding: 0; max-width: none; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.layout {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: row;
|
|
36
|
+
gap: 30px;
|
|
37
|
+
align-items: flex-start;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.qr-column {
|
|
41
|
+
flex: 0 0 280px;
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-direction: column;
|
|
44
|
+
align-items: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.qr-box {
|
|
48
|
+
width: 280px;
|
|
49
|
+
height: 280px;
|
|
50
|
+
border: 1px dotted #ccc;
|
|
51
|
+
border-radius: 8px;
|
|
52
|
+
padding: 10px;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
background: #fff;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.qr-box img { width: 100%; height: 100%; object-fit: contain; }
|
|
60
|
+
|
|
61
|
+
.qr-label {
|
|
62
|
+
font-size: 11px;
|
|
63
|
+
font-weight: bold;
|
|
64
|
+
color: #495057;
|
|
65
|
+
text-align: center;
|
|
66
|
+
margin-bottom: 6px;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
letter-spacing: 0.5px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.do-not-fold {
|
|
72
|
+
font-size: 14px;
|
|
73
|
+
font-weight: bold;
|
|
74
|
+
font-style: italic;
|
|
75
|
+
color: #dc3545;
|
|
76
|
+
text-align: center;
|
|
77
|
+
margin-top: 15px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.info-column {
|
|
81
|
+
flex: 1;
|
|
82
|
+
display: flex;
|
|
83
|
+
flex-direction: column;
|
|
84
|
+
gap: 16px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.header-row {
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: row;
|
|
90
|
+
justify-content: space-between;
|
|
91
|
+
align-items: flex-start;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.page-title {
|
|
95
|
+
font-size: 20px;
|
|
96
|
+
font-weight: bold;
|
|
97
|
+
font-family: Helvetica, Arial, sans-serif;
|
|
98
|
+
margin: 0;
|
|
99
|
+
color: #2c3e50;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.color-strip {
|
|
103
|
+
display: flex;
|
|
104
|
+
flex-direction: row;
|
|
105
|
+
gap: 8px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.color-swatch {
|
|
109
|
+
width: 28px;
|
|
110
|
+
height: 28px;
|
|
111
|
+
border: 1px solid #333;
|
|
112
|
+
border-radius: 4px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ascii-art {
|
|
116
|
+
font-family: 'Courier New', monospace;
|
|
117
|
+
font-size: 11px;
|
|
118
|
+
line-height: 1.1;
|
|
119
|
+
white-space: pre;
|
|
120
|
+
text-align: center;
|
|
121
|
+
color: #2c3e50;
|
|
122
|
+
margin: 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.key-alias-display {
|
|
126
|
+
font-size: 22px;
|
|
127
|
+
font-weight: bold;
|
|
128
|
+
color: #0d6efd;
|
|
129
|
+
text-align: center;
|
|
130
|
+
margin-top: 8px;
|
|
131
|
+
letter-spacing: 0.5px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.panel {
|
|
135
|
+
background: #f8f9fa;
|
|
136
|
+
border: 1px solid #e9ecef;
|
|
137
|
+
border-radius: 8px;
|
|
138
|
+
padding: 16px;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.panel-white {
|
|
142
|
+
background: #fff;
|
|
143
|
+
border: 1px solid #e9ecef;
|
|
144
|
+
border-radius: 8px;
|
|
145
|
+
padding: 16px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.panel-warning {
|
|
149
|
+
background: #fff3cd;
|
|
150
|
+
border: 1px solid #ffeaa7;
|
|
151
|
+
border-radius: 8px;
|
|
152
|
+
padding: 14px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.panel h3 {
|
|
156
|
+
font-size: 16px;
|
|
157
|
+
margin: 0 0 8px 0;
|
|
158
|
+
color: #2c3e50;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.panel ol {
|
|
162
|
+
font-size: 13px;
|
|
163
|
+
margin: 0;
|
|
164
|
+
padding-left: 20px;
|
|
165
|
+
color: #495057;
|
|
166
|
+
line-height: 1.5;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.panel p {
|
|
170
|
+
font-size: 13px;
|
|
171
|
+
margin: 8px 0 0 0;
|
|
172
|
+
color: #495057;
|
|
173
|
+
line-height: 1.5;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.detail-row {
|
|
177
|
+
font-size: 13px;
|
|
178
|
+
color: #495057;
|
|
179
|
+
line-height: 1.7;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.detail-row strong { color: #2c3e50; }
|
|
183
|
+
|
|
184
|
+
.key-pill {
|
|
185
|
+
display: inline-block;
|
|
186
|
+
padding: 2px 8px;
|
|
187
|
+
margin: 2px 4px 2px 0;
|
|
188
|
+
background: #e9ecef;
|
|
189
|
+
border: 1px solid #ced4da;
|
|
190
|
+
border-radius: 12px;
|
|
191
|
+
font-size: 12px;
|
|
192
|
+
color: #495057;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.warning-title {
|
|
196
|
+
font-size: 14px;
|
|
197
|
+
font-weight: bold;
|
|
198
|
+
color: #856404;
|
|
199
|
+
margin-bottom: 4px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.warning-body {
|
|
203
|
+
font-size: 13px;
|
|
204
|
+
color: #856404;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.toolbar {
|
|
208
|
+
/* Hidden in print. Shown only in browser, used by selector iframe. */
|
|
209
|
+
display: none;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@media screen {
|
|
213
|
+
.auto-print-notice {
|
|
214
|
+
position: fixed;
|
|
215
|
+
top: 12px;
|
|
216
|
+
right: 12px;
|
|
217
|
+
background: #2c3e50;
|
|
218
|
+
color: #fff;
|
|
219
|
+
padding: 8px 14px;
|
|
220
|
+
border-radius: 6px;
|
|
221
|
+
font-size: 13px;
|
|
222
|
+
font-family: Helvetica, Arial, sans-serif;
|
|
223
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
224
|
+
z-index: 1000;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@media print {
|
|
229
|
+
.auto-print-notice { display: none !important; }
|
|
230
|
+
}
|
|
231
|
+
`;
|
|
232
|
+
|
|
233
|
+
// ASCII art reused from the React PDFVaultBackup + PDFKeyBackup components.
|
|
234
|
+
// Each line is right-padded to the widest line so `text-align: center` plus
|
|
235
|
+
// `white-space: pre` doesn't center each row independently — otherwise the
|
|
236
|
+
// columns drift. Editors that strip trailing whitespace would silently break
|
|
237
|
+
// the alignment, so we compute the padding at runtime instead of relying on
|
|
238
|
+
// the source-file spaces being preserved.
|
|
239
|
+
|
|
240
|
+
function rightPadBlock(lines) {
|
|
241
|
+
const width = Math.max(...lines.map(l => l.length));
|
|
242
|
+
return lines.map(l => l.padEnd(width)).join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const VAULT_ASCII = rightPadBlock([
|
|
246
|
+
' ██╗ ██╗ █████╗ ██╗ ██╗██╗ ████████╗',
|
|
247
|
+
' ██║ ██║██╔══██╗██║ ██║██║ ╚══██╔══╝',
|
|
248
|
+
' ██║ ██║███████║██║ ██║██║ ██║',
|
|
249
|
+
' ╚██╗ ██╔╝██╔══██║██║ ██║██║ ██║',
|
|
250
|
+
' ╚████╔╝ ██║ ██║╚██████╔╝███████╗██║',
|
|
251
|
+
' ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝',
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
export const KEY_ASCII = rightPadBlock([
|
|
255
|
+
' ██╗ ██╗███████╗██╗ ██╗',
|
|
256
|
+
' ██║ ██╔╝██╔════╝╚██╗ ██╔╝',
|
|
257
|
+
' █████╔╝ █████╗ ╚████╔╝',
|
|
258
|
+
' ██╔═██╗ ██╔══╝ ╚██╔╝',
|
|
259
|
+
' ██║ ██╗███████╗ ██║',
|
|
260
|
+
' ╚═╝ ╚═╝╚══════╝ ╚═╝',
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
// Default 3-color identifier strip — matches the web app's defaults.
|
|
264
|
+
export const DEFAULT_COLORS = ['#FF0000', '#0000FF', '#008000'];
|
|
265
|
+
|
|
266
|
+
/** Basic HTML escape. Used for any user-supplied string. */
|
|
267
|
+
export function esc(s) {
|
|
268
|
+
return String(s ?? '')
|
|
269
|
+
.replace(/&/g, '&')
|
|
270
|
+
.replace(/</g, '<')
|
|
271
|
+
.replace(/>/g, '>')
|
|
272
|
+
.replace(/"/g, '"')
|
|
273
|
+
.replace(/'/g, ''');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Format a unix-seconds timestamp as YYYY-MM-DD HH:mm:ss in local time. */
|
|
277
|
+
export function formatTime(unixSeconds) {
|
|
278
|
+
const d = new Date(unixSeconds * 1000);
|
|
279
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
280
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
|
281
|
+
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
282
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import QRCode from 'qrcode';
|
|
2
|
+
import { SHARED_CSS, VAULT_ASCII, DEFAULT_COLORS, esc, formatTime } from './styles.js';
|
|
3
|
+
import { VAULT_IDENT, VAULT_VERSION } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
// Match PDFVaultBackup.jsx: payloads ≤420 bytes go in one QR; larger payloads
|
|
6
|
+
// split into a metadata QR + a data-only QR. Web-app unlock supports both.
|
|
7
|
+
const SINGLE_QR_BYTE_THRESHOLD = 420;
|
|
8
|
+
|
|
9
|
+
async function qrDataUrl(payload) {
|
|
10
|
+
return QRCode.toDataURL(payload, {
|
|
11
|
+
errorCorrectionLevel: 'M',
|
|
12
|
+
width: 280,
|
|
13
|
+
margin: 2,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the inner content for a vault page (the `<div class="layout">…</div>`
|
|
19
|
+
* block, without any `<html>/<head>/<body>` wrapper). Reused by both
|
|
20
|
+
* generateVaultPage (standalone preview) and generatePrintAllPage (kit).
|
|
21
|
+
*/
|
|
22
|
+
export async function buildVaultBody(opts) {
|
|
23
|
+
const {
|
|
24
|
+
vaultName,
|
|
25
|
+
cipherText,
|
|
26
|
+
cipherIV,
|
|
27
|
+
threshold,
|
|
28
|
+
keyAliases,
|
|
29
|
+
createdAt,
|
|
30
|
+
colors = DEFAULT_COLORS,
|
|
31
|
+
} = opts;
|
|
32
|
+
|
|
33
|
+
const shares = keyAliases.length;
|
|
34
|
+
|
|
35
|
+
const combined = JSON.stringify({
|
|
36
|
+
id: 1,
|
|
37
|
+
vault: VAULT_IDENT,
|
|
38
|
+
version: VAULT_VERSION,
|
|
39
|
+
name: vaultName,
|
|
40
|
+
shares,
|
|
41
|
+
threshold,
|
|
42
|
+
cipherIV,
|
|
43
|
+
keys: keyAliases,
|
|
44
|
+
data: cipherText,
|
|
45
|
+
});
|
|
46
|
+
const combinedBytes = new TextEncoder().encode(combined).length;
|
|
47
|
+
|
|
48
|
+
let qrs;
|
|
49
|
+
if (combinedBytes <= SINGLE_QR_BYTE_THRESHOLD) {
|
|
50
|
+
qrs = [{ label: null, dataUrl: await qrDataUrl(combined) }];
|
|
51
|
+
} else {
|
|
52
|
+
const metadataPayload = JSON.stringify({
|
|
53
|
+
id: 1,
|
|
54
|
+
vault: VAULT_IDENT,
|
|
55
|
+
version: VAULT_VERSION,
|
|
56
|
+
name: vaultName,
|
|
57
|
+
shares,
|
|
58
|
+
threshold,
|
|
59
|
+
cipherIV,
|
|
60
|
+
keys: keyAliases,
|
|
61
|
+
qrcodes: 2,
|
|
62
|
+
});
|
|
63
|
+
const dataPayload = JSON.stringify({ id: 2, data: cipherText });
|
|
64
|
+
qrs = [
|
|
65
|
+
{ label: 'Vault QR 1 of 2', dataUrl: await qrDataUrl(metadataPayload) },
|
|
66
|
+
{ label: 'Vault QR 2 of 2', dataUrl: await qrDataUrl(dataPayload) },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const qrBlocks = qrs.map(qr => `
|
|
71
|
+
${qr.label ? `<div class="qr-label">${esc(qr.label)}</div>` : ''}
|
|
72
|
+
<div class="qr-box"><img src="${qr.dataUrl}" alt="Vault QR"></div>
|
|
73
|
+
`).join('');
|
|
74
|
+
|
|
75
|
+
const colorStrip = colors.map(c => `<span class="color-swatch" style="background:${esc(c)}"></span>`).join('');
|
|
76
|
+
const keyPills = keyAliases.map(k => `<span class="key-pill">${esc(k)}</span>`).join(' ');
|
|
77
|
+
|
|
78
|
+
return `<div class="layout">
|
|
79
|
+
<div class="qr-column">
|
|
80
|
+
${qrBlocks}
|
|
81
|
+
<div class="do-not-fold">DO NOT FOLD</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="info-column">
|
|
84
|
+
<div class="header-row">
|
|
85
|
+
<h1 class="page-title">PAPERVAULT.XYZ VAULT</h1>
|
|
86
|
+
<div class="color-strip">${colorStrip}</div>
|
|
87
|
+
</div>
|
|
88
|
+
<pre class="ascii-art">${VAULT_ASCII}</pre>
|
|
89
|
+
<div class="panel">
|
|
90
|
+
<h3>How to Unlock</h3>
|
|
91
|
+
<ol>
|
|
92
|
+
<li>Go to <strong>papervault.xyz/unlock</strong></li>
|
|
93
|
+
<li>Go offline</li>
|
|
94
|
+
<li>Scan your vault QR code${qrs.length > 1 ? 's' : ''} (this page)</li>
|
|
95
|
+
<li>Scan ${threshold} key${threshold > 1 ? 's' : ''}</li>
|
|
96
|
+
</ol>
|
|
97
|
+
<p>The unlock utility is open source: https://github.com/boazeb/papervault</p>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="panel-white">
|
|
100
|
+
<h3>Vault Details</h3>
|
|
101
|
+
<div class="detail-row"><strong>Name:</strong> ${esc(vaultName)}</div>
|
|
102
|
+
<div class="detail-row"><strong>Keys Required:</strong> ${threshold} of ${shares}</div>
|
|
103
|
+
<div class="detail-row"><strong>Created:</strong> ${esc(formatTime(createdAt))}</div>
|
|
104
|
+
<div class="detail-row" style="margin-top:8px;"><strong>Key Names:</strong></div>
|
|
105
|
+
<div style="margin-top:4px;">${keyPills}</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="panel-warning">
|
|
108
|
+
<div class="warning-title">🔒 KEEP SECURE</div>
|
|
109
|
+
<div class="warning-body">Store this vault in a safe private location.</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Standalone vault page (full HTML document). Used for individual previews.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} opts See buildVaultBody, plus:
|
|
119
|
+
* @param {boolean} [opts.autoPrint] Trigger window.print() on load
|
|
120
|
+
* @returns {Promise<string>} Complete HTML document
|
|
121
|
+
*/
|
|
122
|
+
export async function generateVaultPage(opts) {
|
|
123
|
+
const body = await buildVaultBody(opts);
|
|
124
|
+
const autoPrintScript = opts.autoPrint
|
|
125
|
+
? `<script>window.addEventListener('load', () => { setTimeout(() => window.print(), 200); });</script>`
|
|
126
|
+
: '';
|
|
127
|
+
const notice = opts.autoPrint
|
|
128
|
+
? '<div class="auto-print-notice">Opening print dialog…</div>'
|
|
129
|
+
: '';
|
|
130
|
+
|
|
131
|
+
return `<!doctype html>
|
|
132
|
+
<html lang="en">
|
|
133
|
+
<head>
|
|
134
|
+
<meta charset="utf-8">
|
|
135
|
+
<title>PaperVault — ${esc(opts.vaultName)}</title>
|
|
136
|
+
<style>${SHARED_CSS}</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
${notice}
|
|
140
|
+
${body}
|
|
141
|
+
${autoPrintScript}
|
|
142
|
+
</body>
|
|
143
|
+
</html>`;
|
|
144
|
+
}
|