onesecret 0.0.0-stage → 0.0.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/src/age.js ADDED
@@ -0,0 +1,363 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('node:crypto')
4
+ const bech32 = require('./bech32')
5
+
6
+ const VERSION_LINE = 'age-encryption.org/v1'
7
+ const X25519_LABEL = 'age-encryption.org/v1/X25519'
8
+ const STANZA_PREFIX = '-> '
9
+ const MAC_PREFIX = '---'
10
+ const RECIPIENT_PREFIX = 'age'
11
+ const IDENTITY_PREFIX = 'age-secret-key-'
12
+ const ARMOR_BEGIN = '-----BEGIN AGE ENCRYPTED FILE-----'
13
+ const ARMOR_END = '-----END AGE ENCRYPTED FILE-----'
14
+ const ARMOR_COLUMNS = 64
15
+ const STANZA_COLUMNS = 64
16
+ const KEY_SIZE = 32
17
+ const FILE_KEY_SIZE = 16
18
+ const PAYLOAD_NONCE_SIZE = 16
19
+ const TAG_SIZE = 16
20
+ const CHUNK_SIZE = 64 * 1024
21
+ const PKCS8_X25519_PREFIX = Buffer.from(
22
+ '302e020100300506032b656e04220420',
23
+ 'hex'
24
+ )
25
+ const SPKI_X25519_PREFIX = Buffer.from('302a300506032b656e032100', 'hex')
26
+ const ZERO_NONCE = Buffer.alloc(12)
27
+ const UNPADDED_BASE64 = /^[A-Za-z0-9+/]*$/
28
+
29
+ const toPrivateKey = scalar =>
30
+ crypto.createPrivateKey({
31
+ key: Buffer.concat([PKCS8_X25519_PREFIX, scalar]),
32
+ format: 'der',
33
+ type: 'pkcs8'
34
+ })
35
+
36
+ const toPublicKey = point =>
37
+ crypto.createPublicKey({
38
+ key: Buffer.concat([SPKI_X25519_PREFIX, point]),
39
+ format: 'der',
40
+ type: 'spki'
41
+ })
42
+
43
+ const publicPointOf = scalar =>
44
+ crypto
45
+ .createPublicKey(toPrivateKey(scalar))
46
+ .export({ format: 'der', type: 'spki' })
47
+ .subarray(SPKI_X25519_PREFIX.length)
48
+
49
+ const sharedSecret = (scalar, point) => {
50
+ try {
51
+ const secret = crypto.diffieHellman({
52
+ privateKey: toPrivateKey(scalar),
53
+ publicKey: toPublicKey(point)
54
+ })
55
+ return secret.every(byte => byte === 0) ? null : secret
56
+ } catch {
57
+ return null
58
+ }
59
+ }
60
+
61
+ const hkdf = (ikm, salt, info) =>
62
+ Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, KEY_SIZE))
63
+
64
+ const seal = (key, nonce, plaintext) => {
65
+ const cipher = crypto.createCipheriv('chacha20-poly1305', key, nonce, {
66
+ authTagLength: TAG_SIZE
67
+ })
68
+ return Buffer.concat([
69
+ cipher.update(plaintext),
70
+ cipher.final(),
71
+ cipher.getAuthTag()
72
+ ])
73
+ }
74
+
75
+ const open = (key, nonce, sealed) => {
76
+ if (sealed.length < TAG_SIZE) throw new Error('age ciphertext is truncated')
77
+ const decipher = crypto.createDecipheriv('chacha20-poly1305', key, nonce, {
78
+ authTagLength: TAG_SIZE
79
+ })
80
+ decipher.setAuthTag(sealed.subarray(sealed.length - TAG_SIZE))
81
+ return Buffer.concat([
82
+ decipher.update(sealed.subarray(0, sealed.length - TAG_SIZE)),
83
+ decipher.final()
84
+ ])
85
+ }
86
+
87
+ const tryOpen = (key, nonce, sealed) => {
88
+ try {
89
+ return open(key, nonce, sealed)
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ const toBase64 = bytes =>
96
+ Buffer.from(bytes).toString('base64').replace(/=+$/, '')
97
+
98
+ const fromBase64 = text => {
99
+ const bytes = Buffer.from(text, 'base64')
100
+ if (!UNPADDED_BASE64.test(text) || toBase64(bytes) !== text) {
101
+ throw new Error('age header has non-canonical base64')
102
+ }
103
+ return bytes
104
+ }
105
+
106
+ const wrapColumns = (text, columns) => {
107
+ const lines = []
108
+ for (let offset = 0; offset < text.length; offset += columns) {
109
+ lines.push(text.slice(offset, offset + columns))
110
+ }
111
+ return lines
112
+ }
113
+
114
+ const parseRecipient = recipient => {
115
+ const { prefix, bytes } = bech32.decode(recipient)
116
+ if (prefix !== RECIPIENT_PREFIX || bytes.length !== KEY_SIZE) {
117
+ throw new Error(`"${recipient}" is not an age X25519 recipient`)
118
+ }
119
+ return bytes
120
+ }
121
+
122
+ const parseIdentity = identity => {
123
+ const { prefix, bytes } = bech32.decode(identity)
124
+ if (prefix !== IDENTITY_PREFIX || bytes.length !== KEY_SIZE) {
125
+ throw new Error('age identity is not an X25519 secret key')
126
+ }
127
+ return bytes
128
+ }
129
+
130
+ const UNSUPPORTED_IDENTITY_PREFIXES = ['AGE-PLUGIN-', 'AGE-SECRET-KEY-PQ-']
131
+
132
+ const parseIdentities = text =>
133
+ text
134
+ .split('\n')
135
+ .filter(line => !line.trim().startsWith('#'))
136
+ .flatMap(line => line.split(/\s+/))
137
+ .filter(
138
+ token =>
139
+ token &&
140
+ !UNSUPPORTED_IDENTITY_PREFIXES.some(prefix => token.startsWith(prefix))
141
+ )
142
+ .map(parseIdentity)
143
+
144
+ const formatIdentity = scalar =>
145
+ bech32.encode(IDENTITY_PREFIX, scalar).toUpperCase()
146
+
147
+ const formatRecipient = point => bech32.encode(RECIPIENT_PREFIX, point)
148
+
149
+ const generateIdentity = () => {
150
+ const { privateKey } = crypto.generateKeyPairSync('x25519')
151
+ const scalar = privateKey
152
+ .export({ format: 'der', type: 'pkcs8' })
153
+ .subarray(PKCS8_X25519_PREFIX.length)
154
+ return formatIdentity(scalar)
155
+ }
156
+
157
+ const recipientOf = identity =>
158
+ formatRecipient(publicPointOf(parseIdentity(identity)))
159
+
160
+ const wrapFileKey = (fileKey, recipientPoint) => {
161
+ const ephemeralScalar = crypto.randomBytes(KEY_SIZE)
162
+ const secret = sharedSecret(ephemeralScalar, recipientPoint)
163
+ if (!secret) {
164
+ throw new Error(
165
+ `${formatRecipient(
166
+ recipientPoint
167
+ )} is a low order point, not a usable recipient`
168
+ )
169
+ }
170
+ const ephemeralShare = publicPointOf(ephemeralScalar)
171
+ const wrapKey = hkdf(
172
+ secret,
173
+ Buffer.concat([ephemeralShare, recipientPoint]),
174
+ X25519_LABEL
175
+ )
176
+ return {
177
+ args: ['X25519', toBase64(ephemeralShare)],
178
+ body: seal(wrapKey, ZERO_NONCE, fileKey)
179
+ }
180
+ }
181
+
182
+ const unwrapFileKey = (stanza, identityScalars) => {
183
+ if (stanza.args[0] !== 'X25519' || stanza.args.length !== 2) return null
184
+ const ephemeralShare = fromBase64(stanza.args[1])
185
+ if (
186
+ ephemeralShare.length !== KEY_SIZE ||
187
+ stanza.body.length !== FILE_KEY_SIZE + TAG_SIZE
188
+ ) {
189
+ throw new Error('age X25519 stanza is malformed')
190
+ }
191
+ for (const scalar of identityScalars) {
192
+ const secret = sharedSecret(scalar, ephemeralShare)
193
+ if (!secret) continue
194
+ const recipientPoint = publicPointOf(scalar)
195
+ const wrapKey = hkdf(
196
+ secret,
197
+ Buffer.concat([ephemeralShare, recipientPoint]),
198
+ X25519_LABEL
199
+ )
200
+ const fileKey = tryOpen(wrapKey, ZERO_NONCE, stanza.body)
201
+ if (fileKey) return fileKey
202
+ }
203
+ return null
204
+ }
205
+
206
+ const headerMac = (fileKey, headerText) =>
207
+ crypto
208
+ .createHmac('sha256', hkdf(fileKey, Buffer.alloc(0), 'header'))
209
+ .update(headerText)
210
+ .digest()
211
+
212
+ const streamNonce = (counter, isLast) => {
213
+ const nonce = Buffer.alloc(12)
214
+ nonce.writeBigUInt64BE(BigInt(counter), 3)
215
+ nonce[11] = isLast ? 1 : 0
216
+ return nonce
217
+ }
218
+
219
+ const encryptPayload = (fileKey, plaintext) => {
220
+ const nonce = crypto.randomBytes(PAYLOAD_NONCE_SIZE)
221
+ const payloadKey = hkdf(fileKey, nonce, 'payload')
222
+ const chunkCount = Math.max(1, Math.ceil(plaintext.length / CHUNK_SIZE))
223
+ const chunks = Array.from({ length: chunkCount }, (_, index) =>
224
+ seal(
225
+ payloadKey,
226
+ streamNonce(index, index === chunkCount - 1),
227
+ plaintext.subarray(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)
228
+ )
229
+ )
230
+ return Buffer.concat([nonce, ...chunks])
231
+ }
232
+
233
+ const decryptPayload = (fileKey, payload) => {
234
+ if (payload.length < PAYLOAD_NONCE_SIZE + TAG_SIZE) { throw new Error('age payload is truncated') }
235
+ const payloadKey = hkdf(
236
+ fileKey,
237
+ payload.subarray(0, PAYLOAD_NONCE_SIZE),
238
+ 'payload'
239
+ )
240
+ const chunks = []
241
+ let offset = PAYLOAD_NONCE_SIZE
242
+ for (let counter = 0; offset < payload.length; counter++) {
243
+ const sealed = payload.subarray(offset, offset + CHUNK_SIZE + TAG_SIZE)
244
+ offset += sealed.length
245
+ const isLast = offset >= payload.length
246
+ const chunk = tryOpen(payloadKey, streamNonce(counter, isLast), sealed)
247
+ if (!chunk) throw new Error('age payload failed to authenticate')
248
+ if (isLast && chunk.length === 0 && counter > 0) { throw new Error('age payload ends with an empty chunk') }
249
+ chunks.push(chunk)
250
+ }
251
+ return Buffer.concat(chunks)
252
+ }
253
+
254
+ const armor = binary =>
255
+ [
256
+ ARMOR_BEGIN,
257
+ ...wrapColumns(binary.toString('base64'), ARMOR_COLUMNS),
258
+ ARMOR_END,
259
+ ''
260
+ ].join('\n')
261
+
262
+ const dearmor = text => {
263
+ const lines = text.trim().split(/\r?\n/)
264
+ if (lines[0] !== ARMOR_BEGIN || lines[lines.length - 1] !== ARMOR_END) {
265
+ throw new Error('age armor is malformed')
266
+ }
267
+ const bodyLines = lines.slice(1, -1)
268
+ const isWrapped = bodyLines.every((line, index) =>
269
+ index === bodyLines.length - 1
270
+ ? line.length > 0 && line.length <= ARMOR_COLUMNS
271
+ : line.length === ARMOR_COLUMNS
272
+ )
273
+ if (!isWrapped) throw new Error('age armor is not wrapped at 64 columns')
274
+ const body = bodyLines.join('')
275
+ const binary = Buffer.from(body, 'base64')
276
+ if (binary.toString('base64') !== body) { throw new Error('age armor has invalid base64') }
277
+ return binary
278
+ }
279
+
280
+ const readLine = (binary, offset) => {
281
+ const end = binary.indexOf(0x0a, offset)
282
+ if (end === -1) throw new Error('age header is truncated')
283
+ return { line: binary.toString('latin1', offset, end), next: end + 1 }
284
+ }
285
+
286
+ const parseHeader = binary => {
287
+ let { line, next } = readLine(binary, 0)
288
+ if (line !== VERSION_LINE) throw new Error('unsupported age format version')
289
+ const stanzas = []
290
+ for (;;) {
291
+ const lineStart = next
292
+ ;({ line, next } = readLine(binary, next))
293
+ if (line.startsWith(MAC_PREFIX + ' ')) {
294
+ return {
295
+ stanzas,
296
+ authenticated: binary.subarray(0, lineStart + MAC_PREFIX.length),
297
+ mac: fromBase64(line.slice(MAC_PREFIX.length + 1)),
298
+ payload: binary.subarray(next)
299
+ }
300
+ }
301
+ if (!line.startsWith(STANZA_PREFIX)) { throw new Error('age header is malformed') }
302
+ const args = line.slice(STANZA_PREFIX.length).split(' ')
303
+ const bodyLines = []
304
+ for (;;) {
305
+ ;({ line, next } = readLine(binary, next))
306
+ bodyLines.push(line)
307
+ if (line.length > STANZA_COLUMNS) { throw new Error('age stanza line is too long') }
308
+ if (line.length < STANZA_COLUMNS) break
309
+ }
310
+ stanzas.push({ args, body: fromBase64(bodyLines.join('')) })
311
+ }
312
+ }
313
+
314
+ const encrypt = (plaintext, recipients) => {
315
+ if (recipients.length === 0) { throw new Error('age needs at least one recipient') }
316
+ const fileKey = crypto.randomBytes(FILE_KEY_SIZE)
317
+ const stanzaLines = recipients
318
+ .map(parseRecipient)
319
+ .map(point => wrapFileKey(fileKey, point))
320
+ .flatMap(({ args, body }) => {
321
+ const encodedBody = toBase64(body)
322
+ const bodyLines = wrapColumns(encodedBody, STANZA_COLUMNS)
323
+ if (encodedBody.length % STANZA_COLUMNS === 0) bodyLines.push('')
324
+ return [STANZA_PREFIX + args.join(' '), ...bodyLines]
325
+ })
326
+ const authenticated = [VERSION_LINE, ...stanzaLines, MAC_PREFIX].join('\n')
327
+ const header = `${authenticated} ${toBase64(
328
+ headerMac(fileKey, authenticated)
329
+ )}\n`
330
+ return armor(
331
+ Buffer.concat([
332
+ Buffer.from(header, 'latin1'),
333
+ encryptPayload(fileKey, Buffer.from(plaintext))
334
+ ])
335
+ )
336
+ }
337
+
338
+ const decrypt = (armored, identityScalars) => {
339
+ const { stanzas, authenticated, mac, payload } = parseHeader(dearmor(armored))
340
+ let fileKey = null
341
+ for (const stanza of stanzas) {
342
+ fileKey = unwrapFileKey(stanza, identityScalars)
343
+ if (fileKey) break
344
+ }
345
+ if (!fileKey) return null
346
+ const expected = headerMac(fileKey, authenticated)
347
+ if (
348
+ mac.length !== expected.length ||
349
+ !crypto.timingSafeEqual(mac, expected)
350
+ ) {
351
+ throw new Error('age header MAC is invalid')
352
+ }
353
+ return decryptPayload(fileKey, payload)
354
+ }
355
+
356
+ module.exports = {
357
+ decrypt,
358
+ encrypt,
359
+ generateIdentity,
360
+ parseIdentities,
361
+ parseRecipient,
362
+ recipientOf
363
+ }
package/src/bech32.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+
3
+ const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
4
+ const GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
5
+ const CHECKSUM_LENGTH = 6
6
+ const SEPARATOR = '1'
7
+
8
+ const polymod = values => {
9
+ let checksum = 1
10
+ for (const value of values) {
11
+ const top = checksum >>> 25
12
+ checksum = ((checksum & 0x1ffffff) << 5) ^ value
13
+ for (let bit = 0; bit < 5; bit++) {
14
+ if ((top >>> bit) & 1) checksum ^= GENERATOR[bit]
15
+ }
16
+ }
17
+ return checksum
18
+ }
19
+
20
+ const expandPrefix = prefix => {
21
+ const codes = [...prefix].map(char => char.charCodeAt(0))
22
+ return [...codes.map(code => code >>> 5), 0, ...codes.map(code => code & 31)]
23
+ }
24
+
25
+ const regroupBits = (values, fromBits, toBits, pad) => {
26
+ let accumulator = 0
27
+ let bits = 0
28
+ const output = []
29
+ const mask = (1 << toBits) - 1
30
+ for (const value of values) {
31
+ accumulator = (accumulator << fromBits) | value
32
+ bits += fromBits
33
+ while (bits >= toBits) {
34
+ bits -= toBits
35
+ output.push((accumulator >>> bits) & mask)
36
+ }
37
+ }
38
+ if (pad) {
39
+ if (bits > 0) output.push((accumulator << (toBits - bits)) & mask)
40
+ } else if (
41
+ bits >= fromBits ||
42
+ ((accumulator << (toBits - bits)) & mask) !== 0
43
+ ) {
44
+ throw new Error('bech32 data has invalid padding')
45
+ }
46
+ return output
47
+ }
48
+
49
+ const encode = (prefix, bytes) => {
50
+ const words = regroupBits(bytes, 8, 5, true)
51
+ const residue =
52
+ polymod([...expandPrefix(prefix), ...words, 0, 0, 0, 0, 0, 0]) ^ 1
53
+ const checksum = Array.from(
54
+ { length: CHECKSUM_LENGTH },
55
+ (_, index) => (residue >>> (5 * (CHECKSUM_LENGTH - 1 - index))) & 31
56
+ )
57
+ return (
58
+ prefix +
59
+ SEPARATOR +
60
+ [...words, ...checksum].map(word => CHARSET[word]).join('')
61
+ )
62
+ }
63
+
64
+ const decode = text => {
65
+ if (text !== text.toLowerCase() && text !== text.toUpperCase()) {
66
+ throw new Error('bech32 string mixes upper and lower case')
67
+ }
68
+ const lower = text.toLowerCase()
69
+ const separatorIndex = lower.lastIndexOf(SEPARATOR)
70
+ if (
71
+ separatorIndex < 1 ||
72
+ lower.length - separatorIndex - 1 < CHECKSUM_LENGTH
73
+ ) {
74
+ throw new Error('bech32 string is malformed')
75
+ }
76
+ const prefix = lower.slice(0, separatorIndex)
77
+ const words = [...lower.slice(separatorIndex + 1)].map(char => {
78
+ const word = CHARSET.indexOf(char)
79
+ if (word === -1) { throw new Error(`bech32 string has invalid character "${char}"`) }
80
+ return word
81
+ })
82
+ if (polymod([...expandPrefix(prefix), ...words]) !== 1) {
83
+ throw new Error('bech32 checksum is invalid')
84
+ }
85
+ const bytes = regroupBits(words.slice(0, -CHECKSUM_LENGTH), 5, 8, false)
86
+ return { prefix, bytes: Buffer.from(bytes) }
87
+ }
88
+
89
+ module.exports = { encode, decode }