@pioneer-dynamics/flashview-crypto 1.0.1
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/package.json +30 -0
- package/src/index.js +145 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pioneer-dynamics/flashview-crypto",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.0.1",
|
|
7
|
+
"description": "Isomorphic encryption package for FlashView — browser and Node.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pioneer-dynamics/FlashView",
|
|
11
|
+
"directory": "tools/flashview-crypto"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test tests/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"random-words": "^2.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { generate } from 'random-words';
|
|
2
|
+
|
|
3
|
+
const PBKDF2_ITERATIONS = 64000;
|
|
4
|
+
const KEY_LENGTH_BITS = 256;
|
|
5
|
+
const IV_LENGTH = 12; // 96-bit IV for AES-GCM
|
|
6
|
+
const SALT_LENGTH = 8; // 8 bytes = 16 hex chars
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate an 8-word hyphenated passphrase.
|
|
10
|
+
*
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function generatePassphrase() {
|
|
14
|
+
return generate({ exactly: 8, join: '-' });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Derive an AES-256-GCM key from a passphrase and salt using PBKDF2-SHA-512.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} passphrase
|
|
21
|
+
* @param {Uint8Array} salt
|
|
22
|
+
* @param {string[]} keyUsages
|
|
23
|
+
* @returns {Promise<CryptoKey>}
|
|
24
|
+
*/
|
|
25
|
+
async function deriveKey(passphrase, salt, keyUsages) {
|
|
26
|
+
const passphraseKey = await globalThis.crypto.subtle.importKey(
|
|
27
|
+
'raw',
|
|
28
|
+
new TextEncoder().encode(passphrase),
|
|
29
|
+
'PBKDF2',
|
|
30
|
+
false,
|
|
31
|
+
['deriveKey']
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return globalThis.crypto.subtle.deriveKey(
|
|
35
|
+
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-512' },
|
|
36
|
+
passphraseKey,
|
|
37
|
+
{ name: 'AES-GCM', length: KEY_LENGTH_BITS },
|
|
38
|
+
false,
|
|
39
|
+
keyUsages
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert a Uint8Array to a base64 string (isomorphic — works in browser and Node.js 18+).
|
|
45
|
+
*
|
|
46
|
+
* @param {Uint8Array} bytes
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function uint8ArrayToBase64(bytes) {
|
|
50
|
+
let binary = '';
|
|
51
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
52
|
+
binary += String.fromCharCode(bytes[i]);
|
|
53
|
+
}
|
|
54
|
+
return btoa(binary);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert a base64 string to a Uint8Array (isomorphic — works in browser and Node.js 18+).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} base64
|
|
61
|
+
* @returns {Uint8Array}
|
|
62
|
+
*/
|
|
63
|
+
function base64ToUint8Array(base64) {
|
|
64
|
+
const binary = atob(base64);
|
|
65
|
+
const bytes = new Uint8Array(binary.length);
|
|
66
|
+
for (let i = 0; i < binary.length; i++) {
|
|
67
|
+
bytes[i] = binary.charCodeAt(i);
|
|
68
|
+
}
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Encrypt a plaintext message using AES-256-GCM with PBKDF2-SHA-512 key derivation.
|
|
74
|
+
*
|
|
75
|
+
* Output format: hex(8-byte salt) + base64(12-byte IV + encrypted data + 16-byte auth tag)
|
|
76
|
+
* This format is byte-identical to the existing browser (OpenCrypto) and CLI implementations.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} message
|
|
79
|
+
* @param {string|null} passphrase - Auto-generated if null
|
|
80
|
+
* @returns {Promise<{ passphrase: string, secret: string }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function encryptMessage(message, passphrase = null) {
|
|
83
|
+
if (!passphrase) {
|
|
84
|
+
passphrase = generatePassphrase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const salt = new Uint8Array(SALT_LENGTH);
|
|
88
|
+
globalThis.crypto.getRandomValues(salt);
|
|
89
|
+
|
|
90
|
+
const iv = new Uint8Array(IV_LENGTH);
|
|
91
|
+
globalThis.crypto.getRandomValues(iv);
|
|
92
|
+
|
|
93
|
+
const key = await deriveKey(passphrase, salt, ['encrypt']);
|
|
94
|
+
|
|
95
|
+
const ciphertext = await globalThis.crypto.subtle.encrypt(
|
|
96
|
+
{ name: 'AES-GCM', iv },
|
|
97
|
+
key,
|
|
98
|
+
new TextEncoder().encode(message)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Web Crypto AES-GCM appends the 16-byte auth tag at the end of ciphertext.
|
|
102
|
+
// Combine: IV (12 bytes) + ciphertext+authTag
|
|
103
|
+
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
|
104
|
+
combined.set(iv);
|
|
105
|
+
combined.set(new Uint8Array(ciphertext), IV_LENGTH);
|
|
106
|
+
|
|
107
|
+
const saltHex = Array.from(salt).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
108
|
+
const secret = saltHex + uint8ArrayToBase64(combined);
|
|
109
|
+
|
|
110
|
+
return { passphrase, secret };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decrypt a ciphertext string using AES-256-GCM with PBKDF2-SHA-512 key derivation.
|
|
115
|
+
*
|
|
116
|
+
* Accepts ciphertexts produced by this package, the browser OpenCrypto implementation,
|
|
117
|
+
* or the Node.js CLI implementation — all use the same format.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} ciphertextString - hex(salt) + base64(IV + encrypted + authTag)
|
|
120
|
+
* @param {string} passphrase
|
|
121
|
+
* @returns {Promise<string>}
|
|
122
|
+
*/
|
|
123
|
+
export async function decryptMessage(ciphertextString, passphrase) {
|
|
124
|
+
const saltHex = ciphertextString.substring(0, 16);
|
|
125
|
+
const salt = new Uint8Array(SALT_LENGTH);
|
|
126
|
+
for (let i = 0; i < SALT_LENGTH; i++) {
|
|
127
|
+
salt[i] = parseInt(saltHex.substr(i * 2, 2), 16);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const combined = base64ToUint8Array(ciphertextString.substring(16));
|
|
131
|
+
|
|
132
|
+
// combined = IV (12 bytes) + encrypted data + auth tag (16 bytes)
|
|
133
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
134
|
+
const ciphertext = combined.slice(IV_LENGTH); // encrypted + authTag
|
|
135
|
+
|
|
136
|
+
const key = await deriveKey(passphrase, salt, ['decrypt']);
|
|
137
|
+
|
|
138
|
+
const decrypted = await globalThis.crypto.subtle.decrypt(
|
|
139
|
+
{ name: 'AES-GCM', iv },
|
|
140
|
+
key,
|
|
141
|
+
ciphertext
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return new TextDecoder().decode(decrypted);
|
|
145
|
+
}
|