Skip to content

Commit c13980d

Browse files
committed
feat: Add sync versions of various operations for Node.js
Also refactor key and message parsing to use regexes.
1 parent 7857b72 commit c13980d

File tree

6 files changed

+282
-84
lines changed

6 files changed

+282
-84
lines changed

src/ciphers/aes-gcm.ts

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,11 @@ export async function encryptAesGcm(
1818
key: CryptoKey | Uint8Array,
1919
message: string
2020
): Promise<AesCipher> {
21-
const buf = utf8.encode(message)
2221
if (typeof window === 'undefined') {
23-
// Node.js - Use native crypto module
24-
const iv = nodeCrypto.randomBytes(12)
25-
const cipher = nodeCrypto.createCipheriv(
26-
'aes-256-gcm',
27-
key as Uint8Array,
28-
iv
29-
)
30-
const encrypted = cipher.update(message, 'utf8')
31-
cipher.final()
32-
const tag = cipher.getAuthTag()
33-
return {
34-
// Authentication tag is the last 16 bytes
35-
// (for compatibility with WebCrypto serialization)
36-
text: new Uint8Array(Buffer.concat([encrypted, tag])),
37-
iv
38-
}
22+
return encryptAesGcmSync(key as Uint8Array, message)
3923
} else {
4024
// Browser - use WebCrypto
25+
const buf = utf8.encode(message)
4126
const iv = window.crypto.getRandomValues(new Uint8Array(12))
4227
const cipherText = await window.crypto.subtle.encrypt(
4328
{
@@ -54,24 +39,32 @@ export async function encryptAesGcm(
5439
}
5540
}
5641

42+
/**
43+
* Available only for Node.js
44+
*/
45+
export function encryptAesGcmSync(key: Uint8Array, message: string): AesCipher {
46+
// Node.js - Use native crypto module
47+
const iv = nodeCrypto.randomBytes(12)
48+
const cipher = nodeCrypto.createCipheriv('aes-256-gcm', key, iv)
49+
const encrypted = cipher.update(message, 'utf8')
50+
cipher.final()
51+
const tag = cipher.getAuthTag()
52+
return {
53+
// Authentication tag is the last 16 bytes
54+
// (for compatibility with WebCrypto serialization)
55+
text: new Uint8Array(Buffer.concat([encrypted, tag])),
56+
iv
57+
}
58+
}
59+
60+
// --
61+
5762
export async function decryptAesGcm(
5863
key: CryptoKey | Uint8Array,
5964
cipher: AesCipher
6065
): Promise<string> {
6166
if (typeof window === 'undefined') {
62-
// Node.js - Use native crypto module
63-
const decipher = nodeCrypto.createDecipheriv(
64-
'aes-256-gcm',
65-
key as Uint8Array,
66-
cipher.iv
67-
)
68-
// Authentication tag is the last 16 bytes
69-
// (for compatibility with WebCrypto serialization)
70-
const tagStart = cipher.text.length - 16
71-
const msg = cipher.text.slice(0, tagStart)
72-
const tag = cipher.text.slice(tagStart)
73-
decipher.setAuthTag(tag)
74-
return decipher.update(msg, undefined, 'utf8') + decipher.final('utf8')
67+
return decryptAesGcmSync(key as Uint8Array, cipher)
7568
} else {
7669
// Browser - use WebCrypto
7770
const buf = await window.crypto.subtle.decrypt(
@@ -85,3 +78,22 @@ export async function decryptAesGcm(
8578
return utf8.decode(new Uint8Array(buf))
8679
}
8780
}
81+
82+
/**
83+
* Available only for Node.js.
84+
*
85+
* @param key
86+
* @param cipher
87+
* @returns
88+
*/
89+
export function decryptAesGcmSync(key: Uint8Array, cipher: AesCipher): string {
90+
// Node.js - Use native crypto module
91+
const decipher = nodeCrypto.createDecipheriv('aes-256-gcm', key, cipher.iv)
92+
// Authentication tag is the last 16 bytes
93+
// (for compatibility with WebCrypto serialization)
94+
const tagStart = cipher.text.length - 16
95+
const msg = cipher.text.slice(0, tagStart)
96+
const tag = cipher.text.slice(tagStart)
97+
decipher.setAuthTag(tag)
98+
return decipher.update(msg, undefined, 'utf8') + decipher.final('utf8')
99+
}

src/index.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import 'jest-extended'
22
import {
3-
generateKey,
4-
encryptString,
53
decryptString,
6-
makeKeychain,
4+
encryptString,
75
findKeyForMessage,
6+
generateKey,
7+
makeKeychain,
88
parseKey
99
} from './index'
1010

@@ -31,6 +31,15 @@ describe('v1 format', () => {
3131
expect(received).toEqual(expected)
3232
})
3333

34+
test('Decrypt known message (empty string)', async () => {
35+
const key = 'k1.aesgcm256.2itF7YmMYIP4b9NNtKMhIx2axGi6aI50RcwGBiFq-VA='
36+
const cipher =
37+
'v1.aesgcm256.710bb0e2.9tZkprVBt4L7ZW_U.GDrlM3U_P0UnHf38HvOCgQ=='
38+
const expected = ''
39+
const received = await decryptString(cipher, key)
40+
expect(received).toEqual(expected)
41+
})
42+
3443
test('Decrypt known message', async () => {
3544
const key = 'k1.aesgcm256.2itF7YmMYIP4b9NNtKMhIx2axGi6aI50RcwGBiFq-VA='
3645
const cipher =

src/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
export {
2-
generateKey,
2+
CloakKey,
3+
cloakKeyRegex,
34
exportCryptoKey,
5+
generateKey,
6+
ParsedCloakKey,
47
parseKey,
5-
serializeKey,
6-
CloakKey,
7-
ParsedCloakKey
8+
parseKeySync,
9+
serializeKey
810
} from './key'
911
export * from './keychain'
1012
export * from './message'

src/key.ts

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { utf8, b64, hex } from '@47ng/codec'
1+
import { b64, hex, utf8 } from '@47ng/codec'
22
import * as NodeCrypto from 'crypto'
33

44
let nodeCrypto: typeof NodeCrypto
@@ -9,6 +9,8 @@ if (typeof window === 'undefined') {
99

1010
export const FINGERPRINT_LENGTH = 8
1111

12+
export const cloakKeyRegex = /^k1\.aesgcm256\.(?<key>[a-zA-Z0-9-_]{43}=?)$/
13+
1214
/**
1315
* Serialized representation of a Cloak key.
1416
*
@@ -30,13 +32,26 @@ export function formatKey(raw: Uint8Array) {
3032
return ['k1', 'aesgcm256', b64.encode(raw)].join('.')
3133
}
3234

33-
export async function parseKey(key: CloakKey): Promise<ParsedCloakKey> {
35+
export async function parseKey(
36+
key: CloakKey,
37+
usage?: 'encrypt' | 'decrypt'
38+
): Promise<ParsedCloakKey> {
3439
return {
35-
raw: await importKey(key),
40+
raw: await importKey(key, usage),
3641
fingerprint: await getKeyFingerprint(key)
3742
}
3843
}
3944

45+
/**
46+
* Sync version of parseKey, only available for Node.js
47+
*/
48+
export function parseKeySync(key: CloakKey): ParsedCloakKey {
49+
return {
50+
raw: importKeySync(key),
51+
fingerprint: getKeyFingerprintSync(key)
52+
}
53+
}
54+
4055
export async function serializeKey(key: ParsedCloakKey): Promise<CloakKey> {
4156
return (key.raw as CryptoKey).algorithm
4257
? await exportCryptoKey(key.raw as CryptoKey)
@@ -88,14 +103,11 @@ export async function importKey(
88103
key: CloakKey,
89104
usage?: 'encrypt' | 'decrypt'
90105
): Promise<CryptoKey | Uint8Array> {
91-
if (!key.startsWith('k1.')) {
106+
const match = key.match(cloakKeyRegex)
107+
if (!match) {
92108
throw new Error('Unknown key format')
93109
}
94-
const [_, algorithm, secret] = key.split('.')
95-
if (algorithm !== 'aesgcm256') {
96-
throw new Error('Unsupported key type')
97-
}
98-
const raw = b64.decode(secret)
110+
const raw = b64.decode(match.groups!.key)
99111
if (typeof window === 'undefined') {
100112
// Node.js
101113
return raw
@@ -114,6 +126,21 @@ export async function importKey(
114126
}
115127
}
116128

129+
/**
130+
* Internal method: de-serialize a Cloak key into an Uint8Array.
131+
*
132+
* Available only on Node.js
133+
*
134+
* @param key - Serialized Cloak key
135+
*/
136+
export function importKeySync(key: CloakKey): Uint8Array {
137+
const match = key.match(cloakKeyRegex)
138+
if (!match) {
139+
throw new Error('Unknown key format')
140+
}
141+
return b64.decode(match.groups!.key)
142+
}
143+
117144
/**
118145
* Internal method: calculate a key fingerprint
119146
* Fingerprint is the first 8 bytes of the SHA-256 of the
@@ -132,3 +159,15 @@ export async function getKeyFingerprint(key: CloakKey): Promise<string> {
132159
return hex.encode(new Uint8Array(hash)).slice(0, FINGERPRINT_LENGTH)
133160
}
134161
}
162+
163+
/**
164+
* Internal method: calculate a key fingerprint
165+
* Fingerprint is the first 8 bytes of the SHA-256 of the
166+
* serialized key text, represented as an hexadecimal string.
167+
*/
168+
export function getKeyFingerprintSync(key: CloakKey): string {
169+
const data = utf8.encode(key)
170+
const hash = nodeCrypto.createHash('sha256')
171+
hash.update(data)
172+
return hash.digest('hex').slice(0, FINGERPRINT_LENGTH)
173+
}

src/keychain.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { CloakKey, ParsedCloakKey, parseKey, serializeKey } from './key'
1+
import {
2+
CloakKey,
3+
formatKey,
4+
ParsedCloakKey,
5+
parseKey,
6+
parseKeySync,
7+
serializeKey
8+
} from './key'
29
import {
310
CloakedString,
411
decryptString,
12+
decryptStringSync,
513
encryptString,
14+
encryptStringSync,
615
getMessageKeyFingerprint
716
} from './message'
817

@@ -20,6 +29,13 @@ export type CloakKeychain = {
2029
[fingerprint: string]: KeychainEntry
2130
}
2231

32+
/**
33+
* Create a keychain holding the given list of keys.
34+
*
35+
* Runs everywhere (Node.js & browser).
36+
*
37+
* @param keys a list of keys to include in the keychain
38+
*/
2339
export async function makeKeychain(keys: CloakKey[]): Promise<CloakKeychain> {
2440
const keychain: CloakKeychain = {}
2541
for (const key of keys) {
@@ -33,7 +49,28 @@ export async function makeKeychain(keys: CloakKey[]): Promise<CloakKeychain> {
3349
}
3450

3551
/**
36-
* Decrypt and hydrate the given encrypted keychain
52+
* Create a keychain holding the given list of keys.
53+
*
54+
* Available only for Node.js
55+
*
56+
* @param keys a list of keys to include in the keychain
57+
*/
58+
export function makeKeychainSync(keys: CloakKey[]): CloakKeychain {
59+
const keychain: CloakKeychain = {}
60+
for (const key of keys) {
61+
const parsedKey = parseKeySync(key)
62+
keychain[parsedKey.fingerprint] = {
63+
key: parsedKey,
64+
createdAt: Date.now()
65+
}
66+
}
67+
return keychain
68+
}
69+
70+
/**
71+
* Decrypt and hydrate the given encrypted keychain.
72+
*
73+
* Runs everywhere (Node.js & browser).
3774
*
3875
* @param encryptedKeychain - A keychain as exported by exportKeychain
3976
* @param masterKey - The key used to encrypt the keychain
@@ -56,15 +93,42 @@ export async function importKeychain(
5693
}
5794

5895
/**
59-
* Export a serialized and encrypted version of a keychain
96+
* Decrypt and hydrate the given encrypted keychain.
97+
*
98+
* Available only for Node.js
99+
*
100+
* @param encryptedKeychain - A keychain as exported by exportKeychain
101+
* @param masterKey - The key used to encrypt the keychain
102+
*/
103+
export function importKeychainSync(
104+
encryptedKeychain: CloakedString,
105+
masterKey: CloakKey
106+
): CloakKeychain {
107+
const json = decryptStringSync(encryptedKeychain, masterKey)
108+
const keys: SerializedKeychainEntry[] = JSON.parse(json)
109+
const keychain: CloakKeychain = {}
110+
for (const { key, ...rest } of keys) {
111+
const parsedKey = parseKeySync(key)
112+
keychain[parsedKey.fingerprint] = {
113+
key: parsedKey,
114+
...rest
115+
}
116+
}
117+
return keychain
118+
}
119+
120+
/**
121+
* Export a serialized and encrypted version of a keychain.
122+
*
123+
* Runs everywhere (Node.js & browser).
60124
*
61125
* @param keychain - The keychain to export
62126
* @param masterKey - The key to use to encrypt the keychain
63127
* @returns an encrypted keychain string
64128
*/
65129
export async function exportKeychain(
66130
keychain: CloakKeychain,
67-
masterKey: CloakKey
131+
masterKey: CloakKey | ParsedCloakKey
68132
): Promise<CloakedString> {
69133
const rawEntries: KeychainEntry[] = Object.values(keychain)
70134
const entries: SerializedKeychainEntry[] = []
@@ -77,6 +141,30 @@ export async function exportKeychain(
77141
return await encryptString(JSON.stringify(entries), masterKey)
78142
}
79143

144+
/**
145+
* Export a serialized and encrypted version of a keychain
146+
*
147+
* Available only for Node.js
148+
*
149+
* @param keychain - The keychain to export
150+
* @param masterKey - The key to use to encrypt the keychain
151+
* @returns an encrypted keychain string
152+
*/
153+
export function exportKeychainSync(
154+
keychain: CloakKeychain,
155+
masterKey: CloakKey | ParsedCloakKey
156+
): CloakedString {
157+
const rawEntries: KeychainEntry[] = Object.values(keychain)
158+
const entries: SerializedKeychainEntry[] = []
159+
for (const entry of rawEntries) {
160+
entries.push({
161+
key: formatKey(entry.key.raw as Uint8Array),
162+
createdAt: entry.createdAt
163+
})
164+
}
165+
return encryptStringSync(JSON.stringify(entries), masterKey)
166+
}
167+
80168
export function findKeyForMessage(
81169
message: CloakedString,
82170
keychain: CloakKeychain

0 commit comments

Comments
 (0)