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/LICENSE.md +21 -0
- package/README.md +313 -2
- package/bin/help.txt +37 -0
- package/bin/index.js +14 -0
- package/package.json +106 -3
- package/src/age.js +363 -0
- package/src/bech32.js +89 -0
- package/src/cli.js +347 -0
- package/src/deploy.js +113 -0
- package/src/dotenv.js +67 -0
- package/src/error.js +12 -0
- package/src/fly.js +109 -0
- package/src/format.js +61 -0
- package/src/index.js +20 -0
- package/src/keys.js +197 -0
- package/src/regexp.js +91 -0
- package/src/secrets.js +331 -0
- package/src/sops.js +434 -0
- package/src/store.js +257 -0
- package/src/write.js +288 -0
- package/src/yaml.js +361 -0
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 }
|