@swal/vault 0.0.0-stage → 0.2.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 +128 -0
- package/README.md +232 -2
- package/dist/bytes.d.ts +20 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +135 -0
- package/dist/bytes.js.map +1 -0
- package/dist/cloudShare.d.ts +20 -0
- package/dist/cloudShare.d.ts.map +1 -0
- package/dist/cloudShare.js +21 -0
- package/dist/cloudShare.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/r2Client.d.ts +37 -0
- package/dist/r2Client.d.ts.map +1 -0
- package/dist/r2Client.js +109 -0
- package/dist/r2Client.js.map +1 -0
- package/dist/sealedPack.d.ts +90 -0
- package/dist/sealedPack.d.ts.map +1 -0
- package/dist/sealedPack.js +233 -0
- package/dist/sealedPack.js.map +1 -0
- package/dist/web/vault-client.d.ts +64 -0
- package/dist/web/vault-client.d.ts.map +1 -0
- package/dist/web/vault-client.js +249 -0
- package/dist/web/vault-client.js.map +1 -0
- package/package.json +79 -4
- package/src/bytes.ts +126 -0
- package/src/cloudShare.ts +27 -0
- package/src/index.ts +4 -0
- package/src/r2Client.ts +123 -0
- package/src/sealedPack.ts +306 -0
- package/src/web/vault-client.ts +348 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// sealedPack.ts — core reusable vault (antes en gara-g app-pwa)
|
|
2
|
+
// Extraído a cores/swal-vault para reuso en gara-g, hosteler-ia, flota, cualquier app SWAL.
|
|
3
|
+
// 100% cifrado: AES-256-GCM + Shamir 2-of-3 sobre DEK + SHA256 content_hash + Ed25519.
|
|
4
|
+
// Cloudflare nunca ve plaintext, solo ciphertext+shares+hash.
|
|
5
|
+
// Pay-per-use: costo solo si se sube a R2; mesh-only gratis (replica P2P en otros nodos gara-g).
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
bs, concatBytes, fromBase64, fromHex, randomBytes, randomInt, sha256, sha256Sync, toBase64, toHex, utf8,
|
|
9
|
+
} from './bytes.js';
|
|
10
|
+
|
|
11
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
12
|
+
|
|
13
|
+
// Cadena de anclaje por defecto (sin dependencia de @swal/econ-sdk; el vault es puro).
|
|
14
|
+
const _econChain = 'polygon';
|
|
15
|
+
const _econChainId = 137;
|
|
16
|
+
|
|
17
|
+
/** Version de formato del pack. Ausente = legado (sin AAD). 2 = metadatos ligados como AAD de AES-GCM. */
|
|
18
|
+
export const PACK_VERSION = 2;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lista blanca de metadatos que pueden viajar EN CLARO junto al pack. Todo lo que no este aqui
|
|
22
|
+
* se rechaza al sellar (salvo `extraClearKeys` explicito): los metadatos no van cifrados.
|
|
23
|
+
*/
|
|
24
|
+
export const CLEAR_METADATA_KEYS: readonly string[] = [
|
|
25
|
+
'vehicleId', 'entityId', 'entity', 'appId', 'instanceId', 'timestamp', 'recordCount', 'deviceId',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export interface DEKShare {
|
|
29
|
+
index: number;
|
|
30
|
+
value: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SealedPackMetadata {
|
|
34
|
+
vehicleId?: string;
|
|
35
|
+
entityId?: string;
|
|
36
|
+
entity?: string;
|
|
37
|
+
appId?: string;
|
|
38
|
+
instanceId?: string;
|
|
39
|
+
timestamp: number;
|
|
40
|
+
recordCount?: number;
|
|
41
|
+
deviceId?: string;
|
|
42
|
+
// extensible para cualquier modelo negocio: gara-g vehicle, hosteler order, etc.
|
|
43
|
+
[k: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SealedPack {
|
|
47
|
+
/** Version de formato. Ausente = legado sin AAD. */
|
|
48
|
+
v?: number;
|
|
49
|
+
id: string;
|
|
50
|
+
content_hash: string;
|
|
51
|
+
ciphertext: string;
|
|
52
|
+
iv: string;
|
|
53
|
+
tag: string;
|
|
54
|
+
shares: DEKShare[];
|
|
55
|
+
signature: string;
|
|
56
|
+
publicKey: string;
|
|
57
|
+
metadata: SealedPackMetadata;
|
|
58
|
+
chain: string;
|
|
59
|
+
chainId: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mod257(n: number): number {
|
|
63
|
+
const r = n % 257;
|
|
64
|
+
return r < 0 ? r + 257 : r;
|
|
65
|
+
}
|
|
66
|
+
function modInverse257(n: number): number {
|
|
67
|
+
const a = mod257(n);
|
|
68
|
+
if (a === 0) throw new Error('Division by zero GF257');
|
|
69
|
+
let res = 1;
|
|
70
|
+
let base = a;
|
|
71
|
+
let exp = 255;
|
|
72
|
+
while (exp > 0) {
|
|
73
|
+
if (exp % 2 === 1) res = (res * base) % 257;
|
|
74
|
+
base = (base * base) % 257;
|
|
75
|
+
exp = Math.floor(exp / 2);
|
|
76
|
+
}
|
|
77
|
+
return res;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function splitDEK(dek: Uint8Array): DEKShare[] {
|
|
81
|
+
if (dek.length !== 32) throw new Error('DEK 32 bytes');
|
|
82
|
+
const s1 = new Uint8Array(32);
|
|
83
|
+
const s2 = new Uint8Array(32);
|
|
84
|
+
const s3 = new Uint8Array(32);
|
|
85
|
+
for (let i = 0; i < 32; i++) {
|
|
86
|
+
const secret = dek[i];
|
|
87
|
+
let a1: number, y1: number, y2: number, y3: number;
|
|
88
|
+
do {
|
|
89
|
+
a1 = randomInt(1, 257);
|
|
90
|
+
y1 = mod257(secret + a1 * 1);
|
|
91
|
+
y2 = mod257(secret + a1 * 2);
|
|
92
|
+
y3 = mod257(secret + a1 * 3);
|
|
93
|
+
} while (y1 === 256 || y2 === 256 || y3 === 256);
|
|
94
|
+
s1[i] = y1;
|
|
95
|
+
s2[i] = y2;
|
|
96
|
+
s3[i] = y3;
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
{ index: 1, value: toBase64(s1) },
|
|
100
|
+
{ index: 2, value: toBase64(s2) },
|
|
101
|
+
{ index: 3, value: toBase64(s3) },
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function reconstructDEK(shares: DEKShare[]): Uint8Array {
|
|
106
|
+
if (shares.length < 2) throw new Error('At least 2 shares are required to reconstruct DEK');
|
|
107
|
+
const s1 = shares[0], s2 = shares[1];
|
|
108
|
+
const x1 = s1.index, x2 = s2.index;
|
|
109
|
+
if (x1 === x2) throw new Error('shares duplicate x');
|
|
110
|
+
const y1 = fromBase64(s1.value);
|
|
111
|
+
const y2 = fromBase64(s2.value);
|
|
112
|
+
if (y1.length !== 32 || y2.length !== 32) throw new Error('share length 32');
|
|
113
|
+
const num1 = mod257(-x2), den1 = mod257(x1 - x2), L1 = mod257(num1 * modInverse257(den1));
|
|
114
|
+
const num2 = mod257(-x1), den2 = mod257(x2 - x1), L2 = mod257(num2 * modInverse257(den2));
|
|
115
|
+
const dek = new Uint8Array(32);
|
|
116
|
+
for (let i = 0; i < 32; i++) dek[i] = mod257(y1[i] * L1 + y2[i] * L2);
|
|
117
|
+
return dek;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function canonicalJson(v: unknown): string {
|
|
121
|
+
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(',')}]`;
|
|
122
|
+
if (v && typeof v === 'object') {
|
|
123
|
+
const o = v as Record<string, unknown>;
|
|
124
|
+
return `{${Object.keys(o).sort().filter((k) => o[k] !== undefined)
|
|
125
|
+
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`).join(',')}}`;
|
|
126
|
+
}
|
|
127
|
+
return JSON.stringify(v) ?? 'null';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** AAD de AES-GCM (v2): los metadatos quedan autenticados; alterarlos rompe el descifrado. */
|
|
131
|
+
function metadataAad(metadata: SealedPackMetadata): Uint8Array {
|
|
132
|
+
return utf8(`swal-vault/v${PACK_VERSION}|${canonicalJson(metadata)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertMetadataWhitelisted(metadata: SealedPackMetadata, extra: readonly string[]): void {
|
|
136
|
+
const allowed = new Set([...CLEAR_METADATA_KEYS, ...extra]);
|
|
137
|
+
for (const k of Object.keys(metadata)) {
|
|
138
|
+
if (!allowed.has(k)) {
|
|
139
|
+
throw new Error(`Metadato "${k}" no esta en la lista blanca de metadatos en claro (usa extraClearKeys si es intencional)`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function contentHashInput(ciphertext: Uint8Array, metadata: SealedPackMetadata): Uint8Array {
|
|
145
|
+
return concatBytes(ciphertext, utf8(JSON.stringify(metadata, Object.keys(metadata).sort())));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @deprecated Usa `computeContentHashAsync` (WebCrypto). Se mantiene sincrono (SHA-256 JS puro)
|
|
150
|
+
* solo por compatibilidad; produce el mismo resultado.
|
|
151
|
+
*/
|
|
152
|
+
export function computeContentHash(ciphertext: Uint8Array, metadata: SealedPackMetadata): string {
|
|
153
|
+
return '0x' + toHex(sha256Sync(contentHashInput(ciphertext, metadata)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function computeContentHashAsync(ciphertext: Uint8Array, metadata: SealedPackMetadata): Promise<string> {
|
|
157
|
+
return '0x' + toHex(await sha256(contentHashInput(ciphertext, metadata)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Frontera de confianza: qué NO puede vivir en la nube ─────────────────────────
|
|
161
|
+
//
|
|
162
|
+
// El reparto es Shamir 2-de-3: con DOS shares cualquiera se reconstruye la DEK. Por tanto la nube
|
|
163
|
+
// (R2 + D1 + KV) **nunca** puede acumular 2 shares, o el operador podria leer los datos y la
|
|
164
|
+
// promesa "el operador no puede leer" seria falsa.
|
|
165
|
+
//
|
|
166
|
+
// Reparto correcto:
|
|
167
|
+
// * share x=1 -> DISPOSITIVO (nunca se sube)
|
|
168
|
+
// * share x=2 -> RECUPERACION (nunca se sube; se entrega al usuario en el alta)
|
|
169
|
+
// * share x=3 -> KV (unico share que la nube puede tener)
|
|
170
|
+
//
|
|
171
|
+
// Asi R2+D1+KV juntos suman 1 share y no alcanzan el umbral.
|
|
172
|
+
|
|
173
|
+
/** Umbral de Shamir: shares necesarias para reconstruir. */
|
|
174
|
+
export const SHAMIR_THRESHOLD = 2;
|
|
175
|
+
|
|
176
|
+
/** Indice del unico share que puede residir en la nube. */
|
|
177
|
+
export const CLOUD_SHARE_INDEX = 3;
|
|
178
|
+
|
|
179
|
+
/** Devuelve una copia del pack SIN shares, apta para almacenar en la nube. */
|
|
180
|
+
export function packWithoutShares(pack: SealedPack): SealedPack {
|
|
181
|
+
return { ...pack, shares: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** El share que puede subirse a KV (x=3). `null` si no existe. */
|
|
185
|
+
export function cloudBoundShare(shares: DEKShare[]): DEKShare | null {
|
|
186
|
+
return shares.find((s) => s.index === CLOUD_SHARE_INDEX) ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Guarda de la frontera: lanza si la nube acumularia el umbral o mas shares.
|
|
191
|
+
* Se usa antes de subir nada, para fallar cerrado en vez de filtrar la clave.
|
|
192
|
+
*/
|
|
193
|
+
export function assertCloudSharesSafe(cloudShareCount: number): void {
|
|
194
|
+
if (cloudShareCount >= SHAMIR_THRESHOLD) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`E2EE: la nube no puede acumular ${cloudShareCount} shares (umbral ${SHAMIR_THRESHOLD}). ` +
|
|
197
|
+
'Deja en el dispositivo los shares 1 y 2 y sube solo el 3.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Par de claves Ed25519. Acepta CryptoKeyPair (WebCrypto) o, por compat, el par de
|
|
204
|
+
* `KeyObject` de node (se detecta por duck-typing, sin depender de modulos de node).
|
|
205
|
+
*/
|
|
206
|
+
export type SealedPackKeyPair =
|
|
207
|
+
| CryptoKeyPair
|
|
208
|
+
| { privateKey: { export(o: { type: 'pkcs8'; format: 'der' }): Uint8Array }; publicKey: { export(o: { type: 'spki'; format: 'der' }): Uint8Array } };
|
|
209
|
+
|
|
210
|
+
async function resolveKeyPair(kp?: SealedPackKeyPair): Promise<{ priv: CryptoKey; pubSpki: Uint8Array }> {
|
|
211
|
+
if (!kp) {
|
|
212
|
+
const pair = (await subtle().generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as CryptoKeyPair;
|
|
213
|
+
return { priv: pair.privateKey, pubSpki: new Uint8Array(await subtle().exportKey('spki', pair.publicKey)) };
|
|
214
|
+
}
|
|
215
|
+
const priv = kp.privateKey as any;
|
|
216
|
+
if (typeof priv.export === 'function') {
|
|
217
|
+
const pkcs8 = new Uint8Array(priv.export({ type: 'pkcs8', format: 'der' }));
|
|
218
|
+
const pubSpki = new Uint8Array((kp.publicKey as any).export({ type: 'spki', format: 'der' }));
|
|
219
|
+
return { priv: await subtle().importKey('pkcs8', bs(pkcs8), { name: 'Ed25519' }, false, ['sign']), pubSpki };
|
|
220
|
+
}
|
|
221
|
+
return { priv: priv as CryptoKey, pubSpki: new Uint8Array(await subtle().exportKey('spki', kp.publicKey as CryptoKey)) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function aesKey(dek: Uint8Array, usage: 'encrypt' | 'decrypt'): Promise<CryptoKey> {
|
|
225
|
+
return subtle().importKey('raw', bs(dek), { name: 'AES-GCM' }, false, [usage]);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function createSealedPack(
|
|
229
|
+
payload: Uint8Array,
|
|
230
|
+
metadata: SealedPackMetadata,
|
|
231
|
+
keyPair?: SealedPackKeyPair,
|
|
232
|
+
options: { extraClearKeys?: string[] } = {},
|
|
233
|
+
): Promise<SealedPack> {
|
|
234
|
+
assertMetadataWhitelisted(metadata, options.extraClearKeys ?? []);
|
|
235
|
+
const dek = randomBytes(32);
|
|
236
|
+
const iv = randomBytes(12);
|
|
237
|
+
// WebCrypto devuelve ciphertext||tag(16); se separan para mantener el formato del pack.
|
|
238
|
+
const sealed = new Uint8Array(
|
|
239
|
+
await subtle().encrypt({ name: 'AES-GCM', iv: bs(iv), tagLength: 128, additionalData: bs(metadataAad(metadata)) }, await aesKey(dek, 'encrypt'), bs(payload)),
|
|
240
|
+
);
|
|
241
|
+
const ct = sealed.subarray(0, sealed.length - 16);
|
|
242
|
+
const tag = sealed.subarray(sealed.length - 16);
|
|
243
|
+
const shares = splitDEK(dek);
|
|
244
|
+
const contentHash = await computeContentHashAsync(ct, metadata);
|
|
245
|
+
const { priv, pubSpki } = await resolveKeyPair(keyPair);
|
|
246
|
+
const sig = new Uint8Array(await subtle().sign({ name: 'Ed25519' }, priv, bs(utf8(contentHash))));
|
|
247
|
+
return {
|
|
248
|
+
v: PACK_VERSION,
|
|
249
|
+
id: `pack-${globalThis.crypto.randomUUID()}`,
|
|
250
|
+
content_hash: contentHash,
|
|
251
|
+
ciphertext: toBase64(ct),
|
|
252
|
+
iv: toBase64(iv),
|
|
253
|
+
tag: toBase64(tag),
|
|
254
|
+
shares,
|
|
255
|
+
signature: toHex(sig),
|
|
256
|
+
publicKey: toHex(pubSpki),
|
|
257
|
+
metadata,
|
|
258
|
+
chain: _econChain,
|
|
259
|
+
chainId: _econChainId,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function unpackSealedPack(pack: SealedPack, shares: DEKShare[]): Promise<Uint8Array> {
|
|
264
|
+
const dek = reconstructDEK(shares);
|
|
265
|
+
const ct = fromBase64(pack.ciphertext);
|
|
266
|
+
const iv = fromBase64(pack.iv);
|
|
267
|
+
const tag = fromBase64(pack.tag);
|
|
268
|
+
if (pack.v !== undefined && pack.v !== PACK_VERSION) throw new Error(`Version de pack no soportada: ${pack.v}`);
|
|
269
|
+
// Legado (sin `v`): sin AAD. Un pack v2 al que se le quita `v` falla por tag al no coincidir el AAD.
|
|
270
|
+
const params: AesGcmParams = { name: 'AES-GCM', iv: bs(iv), tagLength: 128 };
|
|
271
|
+
if (pack.v === PACK_VERSION) params.additionalData = bs(metadataAad(pack.metadata));
|
|
272
|
+
const dec = await subtle().decrypt(
|
|
273
|
+
params,
|
|
274
|
+
await aesKey(dek, 'decrypt'),
|
|
275
|
+
bs(concatBytes(ct, tag)),
|
|
276
|
+
);
|
|
277
|
+
return new Uint8Array(dec);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function verifySealedPackHash(pack: SealedPack): Promise<boolean> {
|
|
281
|
+
const exp = await computeContentHashAsync(fromBase64(pack.ciphertext), pack.metadata);
|
|
282
|
+
return exp.toLowerCase() === pack.content_hash.toLowerCase();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Verifica la firma Ed25519 del content_hash con la publicKey del pack. */
|
|
286
|
+
export async function verifySealedPackSignature(pack: SealedPack): Promise<boolean> {
|
|
287
|
+
try {
|
|
288
|
+
const key = await subtle().importKey('spki', bs(fromHex(pack.publicKey)), { name: 'Ed25519' }, false, ['verify']);
|
|
289
|
+
return await subtle().verify({ name: 'Ed25519' }, key, bs(fromHex(pack.signature)), bs(utf8(pack.content_hash)));
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Helper generico para cualquier entity (no solo vehicle)
|
|
296
|
+
export function packMetadataForEntity(entity: string, id: string, appId: string, instanceId: string): SealedPackMetadata {
|
|
297
|
+
return { entity, entityId: id, appId, instanceId, timestamp: Date.now() };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @deprecated Eliminado: la version anterior fabricaba un `walletAddress` falso (0x + prefijo de la
|
|
302
|
+
* publicKey) y emitia eventos con el. El anclaje real en Polygon aun no esta implementado.
|
|
303
|
+
*/
|
|
304
|
+
export function registerOnPolygon(_pack: SealedPack, _bus?: unknown): never {
|
|
305
|
+
throw new Error('registerOnPolygon: not implemented');
|
|
306
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// web/vault-client.ts — VaultClient para navegador (PWA). Solo WebCrypto + IndexedDB.
|
|
2
|
+
// Nunca realiza peticiones de red. Nada sale del dispositivo salvo lo que el llamador decida
|
|
3
|
+
// hacer con el share de recuperacion o con la meshKey.
|
|
4
|
+
//
|
|
5
|
+
// Modelo de claves:
|
|
6
|
+
// * identidad de dispositivo: ECDSA P-256, CryptoKey NO extraible guardada en IndexedDB.
|
|
7
|
+
// * KEK (AES-GCM, no extraible): derivada de passphrase (PBKDF2-SHA256) o de passkey PRF (HKDF).
|
|
8
|
+
// * meshKey (32 bytes): se guarda siempre envuelta (AES-GCM) con la KEK.
|
|
9
|
+
// * recuperacion: Shamir 2-de-3 sobre la meshKey; share 1 queda en el dispositivo,
|
|
10
|
+
// share 2 se muestra una sola vez al usuario, share 3 se devuelve para custodia opcional.
|
|
11
|
+
|
|
12
|
+
import { bs, fromBase64Url, randomBytes, sha256, toBase64Url, utf8 } from '../bytes.js';
|
|
13
|
+
import { reconstructDEK, splitDEK, type DEKShare } from '../sealedPack.js';
|
|
14
|
+
|
|
15
|
+
export const MIN_PBKDF2_ITERATIONS = 310_000;
|
|
16
|
+
export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
|
17
|
+
|
|
18
|
+
export class VaultError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
public code: 'LOCKED' | 'BAD_KEY' | 'BAD_FORMAT' | 'NO_PASSKEY' | 'SHARE_ALREADY_SHOWN' | 'NO_MESH_KEY' | 'BAD_PARAMS',
|
|
21
|
+
message: string,
|
|
22
|
+
) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'VaultError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Proveedor de salida PRF de un passkey (inyectable; por defecto usa WebAuthn). */
|
|
29
|
+
export interface PasskeyPrfProvider {
|
|
30
|
+
/** Devuelve >=32 bytes deterministas para `salt`, o lanza si el autenticador no soporta PRF. */
|
|
31
|
+
getPrfOutput(salt: Uint8Array): Promise<Uint8Array>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VaultClientOptions {
|
|
35
|
+
storage: 'idb';
|
|
36
|
+
dbName?: string;
|
|
37
|
+
pbkdf2Iterations?: number;
|
|
38
|
+
passkey?: PasskeyPrfProvider;
|
|
39
|
+
/** Para WebAuthn por defecto. */
|
|
40
|
+
rpId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DeviceIdentity {
|
|
44
|
+
deviceId: string;
|
|
45
|
+
/** SPKI en base64url. */
|
|
46
|
+
publicKey: string;
|
|
47
|
+
publicKeyJwk: JsonWebKey;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type UnlockOptions = { passphrase: string } | { passkey: true };
|
|
51
|
+
|
|
52
|
+
export interface MeshKey {
|
|
53
|
+
/** Bytes crudos (32). Evita retenerlos mas de lo necesario. */
|
|
54
|
+
raw: Uint8Array;
|
|
55
|
+
/** CryptoKey AES-GCM no extraible, derivada de los mismos bytes. */
|
|
56
|
+
key: CryptoKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VaultClient {
|
|
60
|
+
getDeviceIdentity(): Promise<DeviceIdentity>;
|
|
61
|
+
sign(data: Uint8Array): Promise<Uint8Array>;
|
|
62
|
+
verify(publicKeyJwk: JsonWebKey, data: Uint8Array, signature: Uint8Array): Promise<boolean>;
|
|
63
|
+
|
|
64
|
+
/** Disponibilidad probable de passkeys (la confirmacion de PRF solo ocurre al usarlo). */
|
|
65
|
+
isPasskeyPrfAvailable(): boolean;
|
|
66
|
+
/** Deriva la KEK (passphrase o passkey PRF) y la mantiene en memoria. */
|
|
67
|
+
unlock(opts: UnlockOptions): Promise<void>;
|
|
68
|
+
/** Como unlock, pero con passkey si hay soporte; si no, usa passphrase. */
|
|
69
|
+
unlockPreferPasskey(passphrase: string): Promise<'passkey' | 'passphrase'>;
|
|
70
|
+
lock(): void;
|
|
71
|
+
isUnlocked(): boolean;
|
|
72
|
+
|
|
73
|
+
wrapKey(raw32: Uint8Array, kek?: CryptoKey): Promise<string>;
|
|
74
|
+
unwrapKey(wrapped: string, kek?: CryptoKey): Promise<Uint8Array>;
|
|
75
|
+
|
|
76
|
+
getOrCreateMeshKey(): Promise<MeshKey>;
|
|
77
|
+
/** Devuelve el share de recuperacion (x=2) UNA sola vez; luego lanza SHARE_ALREADY_SHOWN. */
|
|
78
|
+
exportRecoveryShare(): Promise<{ recoveryShare: DEKShare; cloudShare: DEKShare }>;
|
|
79
|
+
/** Reconstruye la meshKey con el share de recuperacion + el share del dispositivo. */
|
|
80
|
+
recoverMeshKey(recoveryShare: DEKShare): Promise<Uint8Array>;
|
|
81
|
+
/** Recupera la meshKey y la re-envuelve con una nueva passphrase. */
|
|
82
|
+
resetPassphrase(recoveryShare: DEKShare, newPassphrase: string): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── IndexedDB mini wrapper ────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const STORE = 'kv';
|
|
88
|
+
|
|
89
|
+
function openDb(name: string): Promise<IDBDatabase> {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const req = indexedDB.open(name, 1);
|
|
92
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
93
|
+
req.onsuccess = () => resolve(req.result);
|
|
94
|
+
req.onerror = () => reject(req.error);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tx<T>(db: IDBDatabase, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const t = db.transaction(STORE, mode);
|
|
101
|
+
const r = fn(t.objectStore(STORE));
|
|
102
|
+
t.oncomplete = () => resolve(r.result);
|
|
103
|
+
t.onerror = () => reject(t.error);
|
|
104
|
+
t.onabort = () => reject(t.error);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Primitivas ────────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const subtle = () => globalThis.crypto.subtle;
|
|
111
|
+
|
|
112
|
+
async function kekFromPassphrase(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
|
113
|
+
const base = await subtle().importKey('raw', bs(utf8(passphrase.normalize('NFKC'))), 'PBKDF2', false, ['deriveKey']);
|
|
114
|
+
return subtle().deriveKey(
|
|
115
|
+
{ name: 'PBKDF2', hash: 'SHA-256', salt: bs(salt), iterations },
|
|
116
|
+
base,
|
|
117
|
+
{ name: 'AES-GCM', length: 256 },
|
|
118
|
+
false,
|
|
119
|
+
['encrypt', 'decrypt'],
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function kekFromPrf(prf: Uint8Array, salt: Uint8Array): Promise<CryptoKey> {
|
|
124
|
+
const base = await subtle().importKey('raw', bs(prf), 'HKDF', false, ['deriveKey']);
|
|
125
|
+
return subtle().deriveKey(
|
|
126
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: bs(salt), info: bs(utf8('swal-vault/kek/v1')) },
|
|
127
|
+
base,
|
|
128
|
+
{ name: 'AES-GCM', length: 256 },
|
|
129
|
+
false,
|
|
130
|
+
['encrypt', 'decrypt'],
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Formato: "v1.<iv b64url>.<ct+tag b64url>" */
|
|
135
|
+
async function wrapWith(raw: Uint8Array, kek: CryptoKey): Promise<string> {
|
|
136
|
+
const iv = randomBytes(12);
|
|
137
|
+
const ct = new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: bs(iv) }, kek, bs(raw)));
|
|
138
|
+
return `v1.${toBase64Url(iv)}.${toBase64Url(ct)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function unwrapWith(wrapped: string, kek: CryptoKey): Promise<Uint8Array> {
|
|
142
|
+
const parts = wrapped.split('.');
|
|
143
|
+
if (parts.length !== 3 || parts[0] !== 'v1') throw new VaultError('BAD_FORMAT', 'formato de clave envuelta invalido');
|
|
144
|
+
try {
|
|
145
|
+
const pt = await subtle().decrypt(
|
|
146
|
+
{ name: 'AES-GCM', iv: bs(fromBase64Url(parts[1])) },
|
|
147
|
+
kek,
|
|
148
|
+
bs(fromBase64Url(parts[2])),
|
|
149
|
+
);
|
|
150
|
+
return new Uint8Array(pt);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new VaultError('BAD_KEY', 'no se pudo desenvolver la clave (KEK incorrecta o datos alterados)');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type Kv = {
|
|
157
|
+
get: <T>(k: string) => Promise<T | undefined>;
|
|
158
|
+
put: (k: string, v: unknown) => Promise<void>;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function defaultPasskeyProvider(kv: Kv, rpId?: string): PasskeyPrfProvider | undefined {
|
|
162
|
+
if (typeof PublicKeyCredential === 'undefined' || !globalThis.navigator?.credentials) return undefined;
|
|
163
|
+
return {
|
|
164
|
+
async getPrfOutput(salt) {
|
|
165
|
+
const cred = globalThis.navigator.credentials;
|
|
166
|
+
const idRaw = await kv.get<Uint8Array>('passkeyCredId');
|
|
167
|
+
const ext = { prf: { eval: { first: salt } } } as AuthenticationExtensionsClientInputs;
|
|
168
|
+
let credential: PublicKeyCredential | null;
|
|
169
|
+
if (!idRaw) {
|
|
170
|
+
credential = (await cred.create({
|
|
171
|
+
publicKey: {
|
|
172
|
+
rp: { name: 'SWAL Vault', ...(rpId ? { id: rpId } : {}) },
|
|
173
|
+
user: { id: bs(randomBytes(16)), name: 'swal-vault', displayName: 'SWAL Vault' },
|
|
174
|
+
challenge: bs(randomBytes(32)),
|
|
175
|
+
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }],
|
|
176
|
+
authenticatorSelection: { residentKey: 'preferred', userVerification: 'required' },
|
|
177
|
+
extensions: { prf: {} } as AuthenticationExtensionsClientInputs,
|
|
178
|
+
},
|
|
179
|
+
})) as PublicKeyCredential | null;
|
|
180
|
+
if (!credential) throw new VaultError('NO_PASSKEY', 'creacion de passkey cancelada');
|
|
181
|
+
if (!(credential.getClientExtensionResults() as any).prf?.enabled)
|
|
182
|
+
throw new VaultError('NO_PASSKEY', 'el autenticador no soporta PRF');
|
|
183
|
+
await kv.put('passkeyCredId', new Uint8Array(credential.rawId));
|
|
184
|
+
}
|
|
185
|
+
const id = (await kv.get<Uint8Array>('passkeyCredId'))!;
|
|
186
|
+
const asr = (await cred.get({
|
|
187
|
+
publicKey: {
|
|
188
|
+
challenge: bs(randomBytes(32)),
|
|
189
|
+
allowCredentials: [{ type: 'public-key', id: bs(id) }],
|
|
190
|
+
userVerification: 'required',
|
|
191
|
+
extensions: ext,
|
|
192
|
+
...(rpId ? { rpId } : {}),
|
|
193
|
+
},
|
|
194
|
+
})) as PublicKeyCredential | null;
|
|
195
|
+
const first = (asr?.getClientExtensionResults() as any)?.prf?.results?.first as ArrayBuffer | undefined;
|
|
196
|
+
if (!first) throw new VaultError('NO_PASSKEY', 'el passkey no devolvio salida PRF');
|
|
197
|
+
return new Uint8Array(first);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Fabrica ───────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function createVaultClient(opts: VaultClientOptions): VaultClient {
|
|
205
|
+
if (opts.storage !== 'idb') throw new VaultError('BAD_PARAMS', "solo storage: 'idb' esta soportado");
|
|
206
|
+
const iterations = opts.pbkdf2Iterations ?? DEFAULT_PBKDF2_ITERATIONS;
|
|
207
|
+
if (iterations < MIN_PBKDF2_ITERATIONS)
|
|
208
|
+
throw new VaultError('BAD_PARAMS', `pbkdf2Iterations debe ser >= ${MIN_PBKDF2_ITERATIONS}`);
|
|
209
|
+
const dbName = opts.dbName ?? 'swal-vault';
|
|
210
|
+
|
|
211
|
+
let dbp: Promise<IDBDatabase> | null = null;
|
|
212
|
+
const db = () => (dbp ??= openDb(dbName));
|
|
213
|
+
const get = async <T>(k: string): Promise<T | undefined> => (await tx(await db(), 'readonly', (s) => s.get(k))) as T | undefined;
|
|
214
|
+
const put = async (k: string, v: unknown): Promise<void> => {
|
|
215
|
+
await tx(await db(), 'readwrite', (s) => s.put(v, k));
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const passkey = opts.passkey ?? defaultPasskeyProvider({ get, put }, opts.rpId);
|
|
219
|
+
|
|
220
|
+
let kek: CryptoKey | null = null;
|
|
221
|
+
const needKek = (k?: CryptoKey): CryptoKey => {
|
|
222
|
+
const v = k ?? kek;
|
|
223
|
+
if (!v) throw new VaultError('LOCKED', 'vault bloqueado: llama unlock() primero');
|
|
224
|
+
return v;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
async function salt(): Promise<Uint8Array> {
|
|
228
|
+
let s = await get<Uint8Array>('kekSalt');
|
|
229
|
+
if (!s) {
|
|
230
|
+
s = randomBytes(16);
|
|
231
|
+
await put('kekSalt', s);
|
|
232
|
+
}
|
|
233
|
+
return s;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function deriveKek(o: UnlockOptions): Promise<CryptoKey> {
|
|
237
|
+
const s = await salt();
|
|
238
|
+
if ('passkey' in o) {
|
|
239
|
+
if (!passkey) throw new VaultError('NO_PASSKEY', 'passkeys no disponibles en este entorno');
|
|
240
|
+
return kekFromPrf(await passkey.getPrfOutput(s), s);
|
|
241
|
+
}
|
|
242
|
+
return kekFromPassphrase(o.passphrase, s, iterations);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function toMeshKey(raw: Uint8Array): Promise<MeshKey> {
|
|
246
|
+
const key = await subtle().importKey('raw', bs(raw), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
247
|
+
return { raw, key };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const self: VaultClient = {
|
|
251
|
+
async getDeviceIdentity() {
|
|
252
|
+
let pair = await get<CryptoKeyPair>('deviceKeyPair');
|
|
253
|
+
if (!pair) {
|
|
254
|
+
pair = (await subtle().generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'])) as CryptoKeyPair;
|
|
255
|
+
await put('deviceKeyPair', pair);
|
|
256
|
+
}
|
|
257
|
+
const spki = new Uint8Array(await subtle().exportKey('spki', pair.publicKey));
|
|
258
|
+
const publicKeyJwk = await subtle().exportKey('jwk', pair.publicKey);
|
|
259
|
+
return {
|
|
260
|
+
deviceId: toBase64Url((await sha256(spki)).subarray(0, 16)),
|
|
261
|
+
publicKey: toBase64Url(spki),
|
|
262
|
+
publicKeyJwk,
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async sign(data) {
|
|
267
|
+
await self.getDeviceIdentity();
|
|
268
|
+
const pair = (await get<CryptoKeyPair>('deviceKeyPair'))!;
|
|
269
|
+
return new Uint8Array(await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, pair.privateKey, bs(data)));
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
async verify(jwk, data, signature) {
|
|
273
|
+
const key = await subtle().importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
274
|
+
return subtle().verify({ name: 'ECDSA', hash: 'SHA-256' }, key, bs(signature), bs(data));
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
isPasskeyPrfAvailable: () => !!passkey,
|
|
278
|
+
|
|
279
|
+
async unlock(o) {
|
|
280
|
+
const candidate = await deriveKek(o);
|
|
281
|
+
// Si ya existe meshKey, valida la KEK (falla con BAD_KEY si es incorrecta).
|
|
282
|
+
const wrapped = await get<string>('meshKeyWrapped');
|
|
283
|
+
if (wrapped) await unwrapWith(wrapped, candidate);
|
|
284
|
+
kek = candidate;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async unlockPreferPasskey(passphrase) {
|
|
288
|
+
if (passkey) {
|
|
289
|
+
try {
|
|
290
|
+
await self.unlock({ passkey: true });
|
|
291
|
+
return 'passkey';
|
|
292
|
+
} catch (e) {
|
|
293
|
+
if (!(e instanceof VaultError) || e.code !== 'NO_PASSKEY') {
|
|
294
|
+
// cancelacion del usuario u otro error de WebAuthn: cae a passphrase
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
await self.unlock({ passphrase });
|
|
299
|
+
return 'passphrase';
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
lock() {
|
|
303
|
+
kek = null;
|
|
304
|
+
},
|
|
305
|
+
isUnlocked: () => kek !== null,
|
|
306
|
+
|
|
307
|
+
wrapKey: (raw32, k) => {
|
|
308
|
+
if (raw32.length !== 32) throw new VaultError('BAD_PARAMS', 'se esperan 32 bytes');
|
|
309
|
+
return wrapWith(raw32, needKek(k));
|
|
310
|
+
},
|
|
311
|
+
unwrapKey: (wrapped, k) => unwrapWith(wrapped, needKek(k)),
|
|
312
|
+
|
|
313
|
+
async getOrCreateMeshKey() {
|
|
314
|
+
const k = needKek();
|
|
315
|
+
const wrapped = await get<string>('meshKeyWrapped');
|
|
316
|
+
if (wrapped) return toMeshKey(await unwrapWith(wrapped, k));
|
|
317
|
+
const raw = randomBytes(32);
|
|
318
|
+
await put('meshKeyWrapped', await wrapWith(raw, k));
|
|
319
|
+
return toMeshKey(raw);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async exportRecoveryShare() {
|
|
323
|
+
const { raw } = await self.getOrCreateMeshKey();
|
|
324
|
+
if (await get<boolean>('recoveryShown'))
|
|
325
|
+
throw new VaultError('SHARE_ALREADY_SHOWN', 'el share de recuperacion ya se mostro y no puede repetirse');
|
|
326
|
+
const [device, recoveryShare, cloudShare] = splitDEK(raw);
|
|
327
|
+
await put('deviceShare', device); // un solo share no revela nada (umbral 2)
|
|
328
|
+
await put('recoveryShown', true);
|
|
329
|
+
return { recoveryShare, cloudShare };
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async recoverMeshKey(recoveryShare) {
|
|
333
|
+
const device = await get<DEKShare>('deviceShare');
|
|
334
|
+
if (!device) throw new VaultError('NO_MESH_KEY', 'no hay share de dispositivo');
|
|
335
|
+
return reconstructDEK([device, recoveryShare]);
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
async resetPassphrase(recoveryShare, newPassphrase) {
|
|
339
|
+
const raw = await self.recoverMeshKey(recoveryShare);
|
|
340
|
+
const s = randomBytes(16);
|
|
341
|
+
const newKek = await kekFromPassphrase(newPassphrase, s, iterations);
|
|
342
|
+
await put('kekSalt', s);
|
|
343
|
+
await put('meshKeyWrapped', await wrapWith(raw, newKek));
|
|
344
|
+
kek = newKek;
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
return self;
|
|
348
|
+
}
|