@omnituum/pqc-shared 0.2.6

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.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +543 -0
  3. package/dist/crypto/index.cjs +807 -0
  4. package/dist/crypto/index.d.cts +641 -0
  5. package/dist/crypto/index.d.ts +641 -0
  6. package/dist/crypto/index.js +716 -0
  7. package/dist/decrypt-eSHlbh1j.d.cts +321 -0
  8. package/dist/decrypt-eSHlbh1j.d.ts +321 -0
  9. package/dist/fs/index.cjs +1168 -0
  10. package/dist/fs/index.d.cts +400 -0
  11. package/dist/fs/index.d.ts +400 -0
  12. package/dist/fs/index.js +1091 -0
  13. package/dist/index.cjs +2160 -0
  14. package/dist/index.d.cts +282 -0
  15. package/dist/index.d.ts +282 -0
  16. package/dist/index.js +2031 -0
  17. package/dist/integrity-CCYjrap3.d.ts +31 -0
  18. package/dist/integrity-Dx9jukMH.d.cts +31 -0
  19. package/dist/types-61c7Q9ri.d.ts +134 -0
  20. package/dist/types-Ch0y-n7K.d.cts +134 -0
  21. package/dist/utils/index.cjs +129 -0
  22. package/dist/utils/index.d.cts +49 -0
  23. package/dist/utils/index.d.ts +49 -0
  24. package/dist/utils/index.js +114 -0
  25. package/dist/vault/index.cjs +713 -0
  26. package/dist/vault/index.d.cts +237 -0
  27. package/dist/vault/index.d.ts +237 -0
  28. package/dist/vault/index.js +677 -0
  29. package/dist/version-BygzPVGs.d.cts +55 -0
  30. package/dist/version-BygzPVGs.d.ts +55 -0
  31. package/package.json +86 -0
  32. package/src/crypto/dilithium.ts +233 -0
  33. package/src/crypto/hybrid.ts +358 -0
  34. package/src/crypto/index.ts +181 -0
  35. package/src/crypto/kyber.ts +199 -0
  36. package/src/crypto/nacl.ts +204 -0
  37. package/src/crypto/primitives/blake3.ts +141 -0
  38. package/src/crypto/primitives/chacha.ts +211 -0
  39. package/src/crypto/primitives/hkdf.ts +192 -0
  40. package/src/crypto/primitives/index.ts +54 -0
  41. package/src/crypto/primitives.ts +144 -0
  42. package/src/crypto/x25519.ts +134 -0
  43. package/src/fs/aes.ts +343 -0
  44. package/src/fs/argon2.ts +184 -0
  45. package/src/fs/browser.ts +408 -0
  46. package/src/fs/decrypt.ts +320 -0
  47. package/src/fs/encrypt.ts +324 -0
  48. package/src/fs/format.ts +425 -0
  49. package/src/fs/index.ts +144 -0
  50. package/src/fs/types.ts +304 -0
  51. package/src/index.ts +414 -0
  52. package/src/kdf/index.ts +311 -0
  53. package/src/runtime/crypto.ts +16 -0
  54. package/src/security/index.ts +345 -0
  55. package/src/tunnel/index.ts +39 -0
  56. package/src/tunnel/session.ts +229 -0
  57. package/src/tunnel/types.ts +115 -0
  58. package/src/utils/entropy.ts +128 -0
  59. package/src/utils/index.ts +25 -0
  60. package/src/utils/integrity.ts +95 -0
  61. package/src/vault/decrypt.ts +167 -0
  62. package/src/vault/encrypt.ts +207 -0
  63. package/src/vault/index.ts +71 -0
  64. package/src/vault/manager.ts +327 -0
  65. package/src/vault/migrate.ts +190 -0
  66. package/src/vault/types.ts +177 -0
  67. package/src/version.ts +304 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Omnituum Tunnel v1 - Session Implementation
3
+ *
4
+ * XChaCha20-Poly1305 encrypted tunnel with counter-based nonces.
5
+ * Handshake-agnostic: accepts any TunnelKeyMaterial producer.
6
+ *
7
+ * @see pqc-docs/specs/tunnel.v1.md
8
+ */
9
+
10
+ import { xChaCha20Poly1305Encrypt, xChaCha20Poly1305Decrypt } from '../crypto/primitives/chacha';
11
+ import { zeroMemory } from '../security';
12
+ import type { TunnelKeyMaterial, PQCTunnelSession } from './types';
13
+ import { TUNNEL_KEY_SIZE, TUNNEL_NONCE_SIZE } from './types';
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+ // NONCE DERIVATION
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+
19
+ /**
20
+ * Derive a unique nonce from base nonce and counter.
21
+ *
22
+ * Construction:
23
+ * - nonce[0..16] = base[0..16]
24
+ * - nonce[16..24] = base[16..24] XOR BE64(counter)
25
+ *
26
+ * This ensures:
27
+ * - Unique nonces for each message (counter monotonicity)
28
+ * - Different nonces for send/recv (different base nonces)
29
+ * - No nonce reuse until counter overflow (~18 quintillion messages)
30
+ */
31
+ function deriveNonce(base: Uint8Array, counter: bigint): Uint8Array {
32
+ const nonce = new Uint8Array(24);
33
+
34
+ // Copy first 16 bytes unchanged
35
+ nonce.set(base.subarray(0, 16), 0);
36
+
37
+ // XOR counter (big-endian) into last 8 bytes
38
+ const view = new DataView(nonce.buffer, 16, 8);
39
+ const baseView = new DataView(base.buffer, base.byteOffset + 16, 8);
40
+
41
+ // Read base's last 8 bytes as bigint and XOR with counter
42
+ const baseValue = baseView.getBigUint64(0, false);
43
+ view.setBigUint64(0, baseValue ^ counter, false);
44
+
45
+ return nonce;
46
+ }
47
+
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // VALIDATION
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ /**
53
+ * Validate tunnel key material.
54
+ */
55
+ function validateKeyMaterial(keys: TunnelKeyMaterial): void {
56
+ if (!keys.sendKey || keys.sendKey.length !== TUNNEL_KEY_SIZE) {
57
+ throw new Error(`sendKey must be ${TUNNEL_KEY_SIZE} bytes`);
58
+ }
59
+ if (!keys.recvKey || keys.recvKey.length !== TUNNEL_KEY_SIZE) {
60
+ throw new Error(`recvKey must be ${TUNNEL_KEY_SIZE} bytes`);
61
+ }
62
+ if (!keys.sendBaseNonce || keys.sendBaseNonce.length !== TUNNEL_NONCE_SIZE) {
63
+ throw new Error(`sendBaseNonce must be ${TUNNEL_NONCE_SIZE} bytes`);
64
+ }
65
+ if (!keys.recvBaseNonce || keys.recvBaseNonce.length !== TUNNEL_NONCE_SIZE) {
66
+ throw new Error(`recvBaseNonce must be ${TUNNEL_NONCE_SIZE} bytes`);
67
+ }
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+ // SESSION FACTORY
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+
74
+ /**
75
+ * Create a secure tunnel session from key material.
76
+ *
77
+ * @param keys - Key material from handshake (Noise, TLS, etc.)
78
+ * @returns Tunnel session for encrypted communication
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * // From Noise handshake
83
+ * const keys = toTunnelKeyMaterial(noiseState);
84
+ * const tunnel = createTunnelSession(keys);
85
+ *
86
+ * // Send encrypted message
87
+ * const ciphertext = tunnel.encrypt(plaintext);
88
+ * channel.send(ciphertext);
89
+ *
90
+ * // Receive encrypted message
91
+ * const plaintext = tunnel.decrypt(received);
92
+ *
93
+ * // Clean up when done
94
+ * tunnel.close();
95
+ * ```
96
+ */
97
+ export function createTunnelSession(keys: TunnelKeyMaterial): PQCTunnelSession {
98
+ validateKeyMaterial(keys);
99
+
100
+ // Copy key material to prevent external mutation
101
+ const sendKey = new Uint8Array(keys.sendKey);
102
+ const recvKey = new Uint8Array(keys.recvKey);
103
+ const sendBaseNonce = new Uint8Array(keys.sendBaseNonce);
104
+ const recvBaseNonce = new Uint8Array(keys.recvBaseNonce);
105
+
106
+ // Per-direction counters
107
+ let sendCounter = 0n;
108
+ let recvCounter = 0n;
109
+
110
+ // Session state
111
+ let closed = false;
112
+
113
+ /**
114
+ * Assert tunnel is still open.
115
+ */
116
+ function assertOpen(): void {
117
+ if (closed) {
118
+ throw new Error('Tunnel is closed');
119
+ }
120
+ }
121
+
122
+ return {
123
+ encrypt(plaintext: Uint8Array, aad?: Uint8Array): Uint8Array {
124
+ assertOpen();
125
+
126
+ // Derive nonce for this message
127
+ const nonce = deriveNonce(sendBaseNonce, sendCounter);
128
+
129
+ // Encrypt
130
+ const ciphertext = xChaCha20Poly1305Encrypt(sendKey, nonce, plaintext, aad);
131
+
132
+ // Increment counter after successful encryption
133
+ sendCounter++;
134
+
135
+ // Zero the nonce (it contained counter information)
136
+ zeroMemory(nonce);
137
+
138
+ return ciphertext;
139
+ },
140
+
141
+ decrypt(ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array | null {
142
+ assertOpen();
143
+
144
+ // Derive nonce for this message
145
+ const nonce = deriveNonce(recvBaseNonce, recvCounter);
146
+
147
+ // Decrypt
148
+ const plaintext = xChaCha20Poly1305Decrypt(recvKey, nonce, ciphertext, aad);
149
+
150
+ // Increment counter after successful decryption
151
+ // Note: We increment even on failure to stay in sync with sender
152
+ recvCounter++;
153
+
154
+ // Zero the nonce
155
+ zeroMemory(nonce);
156
+
157
+ return plaintext;
158
+ },
159
+
160
+ close(): void {
161
+ if (closed) return;
162
+
163
+ closed = true;
164
+
165
+ // Securely zero all key material
166
+ zeroMemory(sendKey);
167
+ zeroMemory(recvKey);
168
+ zeroMemory(sendBaseNonce);
169
+ zeroMemory(recvBaseNonce);
170
+ },
171
+
172
+ get isOpen(): boolean {
173
+ return !closed;
174
+ },
175
+
176
+ get sendCounter(): bigint {
177
+ return sendCounter;
178
+ },
179
+
180
+ get recvCounter(): bigint {
181
+ return recvCounter;
182
+ },
183
+ };
184
+ }
185
+
186
+ // ═══════════════════════════════════════════════════════════════════════════
187
+ // UTILITIES
188
+ // ═══════════════════════════════════════════════════════════════════════════
189
+
190
+ /**
191
+ * Create key material for testing (deterministic).
192
+ * DO NOT use in production - keys are derived from a simple seed.
193
+ *
194
+ * @internal
195
+ */
196
+ export function createTestKeyMaterial(seed: string, isInitiator: boolean): TunnelKeyMaterial {
197
+ // Simple deterministic derivation for testing
198
+ const encoder = new TextEncoder();
199
+ const seedBytes = encoder.encode(seed);
200
+
201
+ // Generate 128 bytes of "key material" (very insecure, testing only!)
202
+ const material = new Uint8Array(128);
203
+ for (let i = 0; i < 128; i++) {
204
+ material[i] = (seedBytes[i % seedBytes.length] + i) % 256;
205
+ }
206
+
207
+ // Split into key components
208
+ const key1 = material.slice(0, 32);
209
+ const key2 = material.slice(32, 64);
210
+ const nonce1 = material.slice(64, 88);
211
+ const nonce2 = material.slice(88, 112);
212
+
213
+ // Assign based on role (initiator/responder swap to ensure symmetry)
214
+ if (isInitiator) {
215
+ return {
216
+ sendKey: key1,
217
+ recvKey: key2,
218
+ sendBaseNonce: nonce1,
219
+ recvBaseNonce: nonce2,
220
+ };
221
+ } else {
222
+ return {
223
+ sendKey: key2,
224
+ recvKey: key1,
225
+ sendBaseNonce: nonce2,
226
+ recvBaseNonce: nonce1,
227
+ };
228
+ }
229
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Omnituum Tunnel v1 - Type Definitions
3
+ *
4
+ * Post-handshake encrypted tunnel interface.
5
+ * Handshake-agnostic: any key agreement can feed into this.
6
+ *
7
+ * @see pqc-docs/specs/tunnel.v1.md
8
+ */
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // KEY MATERIAL
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Key material required to establish a tunnel session.
16
+ * Produced by any key agreement protocol (Noise, TLS, custom).
17
+ */
18
+ export interface TunnelKeyMaterial {
19
+ /** 32-byte key for outgoing messages */
20
+ sendKey: Uint8Array;
21
+
22
+ /** 32-byte key for incoming messages */
23
+ recvKey: Uint8Array;
24
+
25
+ /** 24-byte base nonce for outgoing messages */
26
+ sendBaseNonce: Uint8Array;
27
+
28
+ /** 24-byte base nonce for incoming messages */
29
+ recvBaseNonce: Uint8Array;
30
+ }
31
+
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+ // SESSION INTERFACE
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+
36
+ /**
37
+ * A secure tunnel session for post-handshake communication.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * import { createTunnelSession } from '@omnituum/pqc-shared';
42
+ *
43
+ * const tunnel = createTunnelSession(keys);
44
+ *
45
+ * // Send a message
46
+ * const ciphertext = tunnel.encrypt(plaintext);
47
+ *
48
+ * // Receive a message
49
+ * const plaintext = tunnel.decrypt(ciphertext);
50
+ * if (!plaintext) throw new Error('Authentication failed');
51
+ *
52
+ * // Clean up
53
+ * tunnel.close();
54
+ * ```
55
+ */
56
+ export interface PQCTunnelSession {
57
+ /**
58
+ * Encrypt plaintext for transmission.
59
+ * Automatically increments the send counter.
60
+ *
61
+ * @param plaintext - Data to encrypt
62
+ * @param aad - Optional additional authenticated data
63
+ * @returns Ciphertext with authentication tag
64
+ * @throws Error if tunnel is closed
65
+ */
66
+ encrypt(plaintext: Uint8Array, aad?: Uint8Array): Uint8Array;
67
+
68
+ /**
69
+ * Decrypt received ciphertext.
70
+ * Automatically increments the receive counter.
71
+ *
72
+ * @param ciphertext - Data to decrypt (includes auth tag)
73
+ * @param aad - Optional additional authenticated data (must match encryption)
74
+ * @returns Plaintext, or null if authentication fails
75
+ * @throws Error if tunnel is closed
76
+ */
77
+ decrypt(ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array | null;
78
+
79
+ /**
80
+ * Securely close the tunnel.
81
+ * Zeros all key material and rejects further operations.
82
+ */
83
+ close(): void;
84
+
85
+ /**
86
+ * Check if the tunnel is still open.
87
+ */
88
+ readonly isOpen: boolean;
89
+
90
+ /**
91
+ * Get current send counter (for debugging/monitoring).
92
+ */
93
+ readonly sendCounter: bigint;
94
+
95
+ /**
96
+ * Get current receive counter (for debugging/monitoring).
97
+ */
98
+ readonly recvCounter: bigint;
99
+ }
100
+
101
+ // ═══════════════════════════════════════════════════════════════════════════
102
+ // CONSTANTS
103
+ // ═══════════════════════════════════════════════════════════════════════════
104
+
105
+ /** Tunnel version string */
106
+ export const TUNNEL_VERSION = 'omnituum.tunnel.v1' as const;
107
+
108
+ /** Key size in bytes (32 = 256 bits) */
109
+ export const TUNNEL_KEY_SIZE = 32;
110
+
111
+ /** Base nonce size in bytes (24 for XChaCha20) */
112
+ export const TUNNEL_NONCE_SIZE = 24;
113
+
114
+ /** Authentication tag size in bytes (16 for Poly1305) */
115
+ export const TUNNEL_TAG_SIZE = 16;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Omnituum PQC Shared - Entropy & Randomness Utilities
3
+ *
4
+ * Functions for generating secure random values and measuring entropy.
5
+ */
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // ID GENERATION
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ /**
12
+ * Generate a cryptographically secure random ID.
13
+ */
14
+ export function generateId(): string {
15
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
16
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
17
+ }
18
+
19
+ /**
20
+ * Generate a short random ID (8 characters).
21
+ */
22
+ export function generateShortId(): string {
23
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(4));
24
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
25
+ }
26
+
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+ // ENTROPY MEASUREMENT
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+
31
+ /**
32
+ * Calculate Shannon entropy of a byte array.
33
+ * Returns bits per byte (max 8.0 for perfect randomness).
34
+ */
35
+ export function calculateShannonEntropy(bytes: Uint8Array): number {
36
+ if (bytes.length === 0) return 0;
37
+
38
+ // Count byte frequencies
39
+ const freq = new Map<number, number>();
40
+ for (const byte of bytes) {
41
+ freq.set(byte, (freq.get(byte) || 0) + 1);
42
+ }
43
+
44
+ // Calculate entropy
45
+ let entropy = 0;
46
+ const len = bytes.length;
47
+ for (const count of freq.values()) {
48
+ const p = count / len;
49
+ entropy -= p * Math.log2(p);
50
+ }
51
+
52
+ return entropy;
53
+ }
54
+
55
+ /**
56
+ * Calculate entropy score (0-100) for key material.
57
+ * 100 = perfect entropy, 0 = no entropy.
58
+ */
59
+ export function calculateEntropyScore(hexKey: string): number {
60
+ // Convert hex to bytes
61
+ const clean = hexKey.startsWith('0x') ? hexKey.slice(2) : hexKey;
62
+ const bytes = new Uint8Array(clean.length / 2);
63
+ for (let i = 0; i < bytes.length; i++) {
64
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
65
+ }
66
+
67
+ // Calculate Shannon entropy
68
+ const entropy = calculateShannonEntropy(bytes);
69
+
70
+ // Convert to 0-100 score (8.0 bits/byte = 100%)
71
+ return Math.min(100, Math.round((entropy / 8.0) * 100));
72
+ }
73
+
74
+ /**
75
+ * Check if a key has sufficient entropy.
76
+ */
77
+ export function hasGoodEntropy(hexKey: string, threshold: number = 70): boolean {
78
+ return calculateEntropyScore(hexKey) >= threshold;
79
+ }
80
+
81
+ // ═══════════════════════════════════════════════════════════════════════════
82
+ // KEY VALIDATION
83
+ // ═══════════════════════════════════════════════════════════════════════════
84
+
85
+ /**
86
+ * Validate X25519 public key format.
87
+ */
88
+ export function isValidX25519Key(hexKey: string): boolean {
89
+ const clean = hexKey.startsWith('0x') ? hexKey.slice(2) : hexKey;
90
+ return /^[0-9a-fA-F]{64}$/.test(clean);
91
+ }
92
+
93
+ /**
94
+ * Validate Kyber public key format (base64, ~1568 bytes for ML-KEM-768).
95
+ */
96
+ export function isValidKyberKey(b64Key: string): boolean {
97
+ try {
98
+ const decoded = atob(b64Key);
99
+ // ML-KEM-768 public key is 1184 bytes
100
+ return decoded.length >= 1000 && decoded.length <= 1500;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ // ═══════════════════════════════════════════════════════════════════════════
107
+ // ROTATION AGE
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+
110
+ /**
111
+ * Calculate days since last rotation.
112
+ */
113
+ export function daysSinceRotation(lastRotatedAt?: string, createdAt?: string): number {
114
+ const dateStr = lastRotatedAt || createdAt;
115
+ if (!dateStr) return 0;
116
+
117
+ const date = new Date(dateStr);
118
+ const now = new Date();
119
+ const diffMs = now.getTime() - date.getTime();
120
+ return Math.floor(diffMs / (1000 * 60 * 60 * 24));
121
+ }
122
+
123
+ /**
124
+ * Check if keys should be rotated (default: 90 days).
125
+ */
126
+ export function shouldRotate(lastRotatedAt?: string, createdAt?: string, maxDays: number = 90): boolean {
127
+ return daysSinceRotation(lastRotatedAt, createdAt) >= maxDays;
128
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Omnituum PQC Shared - Utils Exports
3
+ */
4
+
5
+ // Entropy
6
+ export {
7
+ generateId,
8
+ generateShortId,
9
+ calculateShannonEntropy,
10
+ calculateEntropyScore,
11
+ hasGoodEntropy,
12
+ isValidX25519Key,
13
+ isValidKyberKey,
14
+ daysSinceRotation,
15
+ shouldRotate,
16
+ } from './entropy';
17
+
18
+ // Integrity
19
+ export {
20
+ computeIntegrityHash,
21
+ computeHashAsync,
22
+ verifyIntegrity,
23
+ computeKeyFingerprint,
24
+ formatFingerprint,
25
+ } from './integrity';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Omnituum PQC Shared - Integrity Verification
3
+ *
4
+ * SHA-256 based integrity checking for vault contents.
5
+ */
6
+
7
+ import type { HybridIdentityRecord } from '../vault/types';
8
+ import { toHex } from '../crypto/primitives';
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // INTEGRITY HASH
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Compute SHA-256 integrity hash for a list of identities.
16
+ * Uses only the public keys and metadata to create a deterministic hash.
17
+ */
18
+ export function computeIntegrityHash(identities: HybridIdentityRecord[]): string {
19
+ // Create a deterministic representation (exclude secret keys from hash input)
20
+ const canonical = identities.map(i => ({
21
+ id: i.id,
22
+ name: i.name,
23
+ x25519PubHex: i.x25519PubHex,
24
+ kyberPubB64: i.kyberPubB64,
25
+ createdAt: i.createdAt,
26
+ rotationCount: i.rotationCount,
27
+ }));
28
+
29
+ const serialized = JSON.stringify(canonical, Object.keys(canonical[0] || {}).sort());
30
+ return computeStringHash(serialized);
31
+ }
32
+
33
+ /**
34
+ * Compute SHA-256 hash of a string (synchronous fallback).
35
+ */
36
+ function computeStringHash(str: string): string {
37
+ // Simple hash for sync operation - actual implementation uses Web Crypto
38
+ let hash = 0;
39
+ for (let i = 0; i < str.length; i++) {
40
+ const char = str.charCodeAt(i);
41
+ hash = ((hash << 5) - hash) + char;
42
+ hash = hash & hash;
43
+ }
44
+ return Math.abs(hash).toString(16).padStart(16, '0');
45
+ }
46
+
47
+ /**
48
+ * Compute SHA-256 hash asynchronously using Web Crypto or Node fallback.
49
+ */
50
+ export async function computeHashAsync(data: string): Promise<string> {
51
+ const encoder = new TextEncoder();
52
+ const bytes = encoder.encode(data);
53
+
54
+ // Browser/WebCrypto path
55
+ const subtle = globalThis.crypto?.subtle;
56
+ if (subtle) {
57
+ const hashBuffer = await subtle.digest('SHA-256', bytes);
58
+ return toHex(new Uint8Array(hashBuffer));
59
+ }
60
+
61
+ // Node fallback (always available, no async shim dependency)
62
+ const { createHash } = await import('node:crypto');
63
+ return toHex(new Uint8Array(createHash('sha256').update(bytes).digest()));
64
+ }
65
+
66
+ /**
67
+ * Verify vault integrity.
68
+ */
69
+ export async function verifyIntegrity(
70
+ identities: HybridIdentityRecord[],
71
+ expectedHash: string
72
+ ): Promise<boolean> {
73
+ const computed = computeIntegrityHash(identities);
74
+ return computed === expectedHash;
75
+ }
76
+
77
+ // ═══════════════════════════════════════════════════════════════════════════
78
+ // KEY FINGERPRINTS
79
+ // ═══════════════════════════════════════════════════════════════════════════
80
+
81
+ /**
82
+ * Compute a short fingerprint for an identity's public keys.
83
+ */
84
+ export async function computeKeyFingerprint(identity: HybridIdentityRecord): Promise<string> {
85
+ const combined = identity.x25519PubHex + identity.kyberPubB64;
86
+ const hash = await computeHashAsync(combined);
87
+ return hash.slice(0, 16).toUpperCase();
88
+ }
89
+
90
+ /**
91
+ * Format a fingerprint for display (groups of 4).
92
+ */
93
+ export function formatFingerprint(fingerprint: string): string {
94
+ return fingerprint.match(/.{1,4}/g)?.join(' ') || fingerprint;
95
+ }