All files / encryption.ts

100.00% Branches 10/10
100.00% Lines 74/74
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x2
 
x2
x2
 
x2
x2
 
x1
 
 
 
 
 
 
 
x2
x2
x13273
x26513
x26513
x13304
x13304
 
x1
 
 
 
 
 
 
 
x2
x2
x25
x25
 
x1
 
 
 
 
 
 
 
 
 
x2
x2
x21
x21
 
x1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
x2
x2
x11
x11
x11
x11
x11
x11
x44
x11
x11
x11
x13
x11
x72
x72
x72
x18
x18
 
x1
 
 
 
 
 
 
 
 
 
 
 
x2
x2
x7
x7
x11
x7
x28
x28
x35
x7
x7
x7
x8
x7
x11
 
x1
 
 
 
 
 
 
 
x2
x2
x68
x17
 
x1
 
 
 
 
 
 
 
x2
x2
x15
x30
x35
x5
x5
x5






























































































































































































/**
 * This module contains a symmetric encryption (using AES-GCM 256 with a PBKDF2 derived key) function.
 *
 * It is inspired by existing password managers, where the aim is to provide a secure way to store credentials at rest
 * (for example in a {@link https://docs.deno.com/deploy/kv/manual | Deno.Kv} store) while being able to recover
 * them later using a single master key.
 *
 * @example
 * ```ts
 * import { decrypt, encrypt, exportKey, importKey } from "./encryption.ts"
 *
 * // Generate a key. Same seed and salt combination will always yield the same key
 * const key = await exportKey({ seed: "hello", salt: "world" })
 * console.assert(key === "664d43091e7905723fc92a4c38f58e9aeff6d822488eb07d6b11bcfc2468f48a")
 *
 * // Encrypt a message
 * const message = "🍱 bento"
 * const secret = await encrypt(message, { key, length: 512 })
 * console.assert(secret !== message)
 *
 * // Encrypted messages are different each time and can also obfuscate the original message size
 * console.assert(secret !== await encrypt(message, { key, length: 512 }))
 * console.assert(secret.length === 512)
 *
 * // Decrypt a message
 * const decrypted = await decrypt(secret, { key })
 * console.assert(decrypted === message)
 * ```
 *
 * @module
 */

/** Text encoder */
const encoder = new TextEncoder()

/** Text decoder */
const decoder = new TextDecoder()

/**
 * Convert bytes to hexadecimal string representation.
 *
 * @example
 * ```ts
 * import { hex } from "./encryption.ts"
 * console.log(hex(new Uint8Array([0x0a, 0x42]))) // "0a42"
 * ```
 */
export function hex(bytes: Uint8Array | number): string {
  if (typeof bytes === "number") {
    return bytes.toString(16).padStart(2, "0")
  }
  return Array.from(bytes).map((byte) => hex(byte)).join("")
}

/**
 * Convert hexadecimal string representation to bytes.
 *
 * @example
 * ```ts
 * import { bytes } from "./encryption.ts"
 * console.log(bytes("0a42")) // Uint8Array [ 10, 66 ]
 * ```
 */
export function bytes(hex: string): Uint8Array {
  return new Uint8Array(hex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)))
}

/**
 * Hash string and return hexadecimal string representation.
 *
 * Only algorithms supported by {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest | SubtleCrypto.digest} are allowed.
 *
 * @example
 * ```ts
 * import { hash } from "./encryption.ts"
 * console.log(await hash("foo")) // "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
 * ```
 */
export async function hash(message: string, { algorithm = "SHA-256" as AlgorithmIdentifier } = {}): Promise<string> {
  return hex(new Uint8Array(await crypto.subtle.digest(algorithm, encoder.encode(message))))
}

/**
 * Encrypt message with key.
 *
 * It returns an hexadecimal string structured as follows:
 * ```plaintext
 * ┌──────────────────────┬─────────────────┬────────────┬───────────┬────────────┬╴╴╴╴╴╴╴╴╴╴╴╴╴╴┐
 * │ Initial Vector [16b] │ Signature [16b] │ Hash [64b] │ Size [8b] │ Value [Xb] │ Padding [Yb] ┊
 * └──────────────────────┴─────────────────┴────────────┴───────────┴────────────┴╴╴╴╴╴╴╴╴╴╴╴╴╴╴┘
 * ```
 *
 * The following are used by the native {@link https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt | SubtleCrypto.encrypt} API:
 * - The initial vector (IV) is used by AES-GCM algorithm to ensure a same input will yield different outputs each time and prevent stream cipher attacks.
 * - The signature is used by the AES-GCM algorithm internally
 *
 * The following are used by this library:
 * - The hash (SHA-256) is used to ensure the integrity of the size and value after decryption, while providing extra entropy
 * - The size is the length of the secret value in bits, which is used to discard the padding after decryption
 * - The value is the actual secret
 * - The padding is used to obfuscate the actual value length, while providing extra entropy
 *
 * The `length` parameter is used to specify the length of the output hash in bits.
 * Supported values are `256` and `512`.
 * If set to `0` instead, padding will be disabled entirely allowing to encrypt larger values but at the cost of leaking the approximate values length.
 * Additionally, if a value size exceed 255 bytes, its integrity will only be checked by the hash field.
 *
 * @example
 * ```ts
 * import { decrypt, encrypt, exportKey } from "./encryption.ts"
 * const key = await exportKey({ seed: "", salt: "" })
 * console.assert(await decrypt(await encrypt("🍱 bento", { key }), { key }) === "🍱 bento")
 * ```
 *
 * @author Simon Lecoq (lowlighter)
 * @license MIT
 */
export async function encrypt(message: string, { key, length = 512 }: { key: CryptoKey | string; length?: 0 | 256 | 512 }): Promise<string> {
  if (typeof key === "string") {
    key = await importKey(key)
  }
  const size = hex(Math.min(encoder.encode(message).length, 2 ** 8 - 1))
  message = `${size}${message}`
  message = `${await hash(message)}${message}`
  const [iv, value] = [crypto.getRandomValues(new Uint8Array(16)), encoder.encode(message)]
  const unused = (length - 2 * (iv.length + 16 + value.length)) / 2
  if (length && (unused < 0)) {
    throw new RangeError(`Message too long for length: ${length}`)
  }
  const padding = crypto.getRandomValues(new Uint8Array(Math.max(0, unused)))
  const encoded = new Uint8Array([...value, ...padding])
  const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded))
  const result = hex(new Uint8Array([...iv, ...encrypted]))
  return result
}

/**
 * Decrypt message encrypted by {@link encrypt} function with specified key.
 *
 * @example
 * ```ts
 * import { decrypt, encrypt, exportKey } from "./encryption.ts"
 * const key = await exportKey({ seed: "", salt: "" })
 * console.assert(await decrypt(await encrypt("🍱 bento", {key}), { key }) === "🍱 bento")
 * ```
 *
 * @author Simon Lecoq (lowlighter)
 * @license MIT
 */
export async function decrypt(message: string, { key }: { key: CryptoKey | string }): Promise<string> {
  if (typeof key === "string") {
    key = await importKey(key)
  }
  const content = bytes(message)
  const [iv, encoded] = [content.slice(0, 16), content.slice(16)]
  const decrypted = decoder.decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encoded))
  const [checksum, size, body] = [decrypted.slice(0, 64), decrypted.slice(64, 64 + 2), decrypted.slice(64 + 2)]
  const value = decoder.decode(encoder.encode(body).slice(0, size === "ff" ? Infinity : Number.parseInt(size, 16)))
  if (await hash(`${size}${value}`) !== checksum) {
    throw new RangeError(`Hash mismatch: expected ${checksum} but got ${await hash(`${size}${value}`)}`)
  }
  return value
}

/**
 * Convert encryption key to {@link https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey | CryptoKey}.
 *
 * @example
 * ```ts
 * import { importKey } from "./encryption.ts"
 * console.assert(await importKey("e8bf6e323c23036402989c3e89fe8e6219c18edbfde74a461b5f27d806e51f47") instanceof CryptoKey)
 * ```
 */
export async function importKey(key: string): Promise<CryptoKey> {
  return await crypto.subtle.importKey("raw", bytes(key), "AES-GCM", true, ["encrypt", "decrypt"])
}

/**
 * Generate encryption key from seed and salt.
 *
 * @example
 * ```ts
 * import { exportKey } from "./encryption.ts"
 * console.assert(typeof await exportKey({ seed: "", salt: "" }) === "string")
 * ```
 */
export async function exportKey({ seed, salt }: { seed: string; salt: string }): Promise<string> {
  const base = await crypto.subtle.importKey("raw", encoder.encode(seed), "PBKDF2", false, ["deriveKey"])
  const pbkdf2 = { name: "PBKDF2", salt: encoder.encode(salt), iterations: 1_000_000, hash: "SHA-256" }
  const key = await crypto.subtle.deriveKey(pbkdf2, base, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"])
  const result = hex(new Uint8Array(await crypto.subtle.exportKey("raw", key)))
  return result
}