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/sops.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto')
|
|
4
|
+
const age = require('./age')
|
|
5
|
+
const { keysOf } = require('./yaml')
|
|
6
|
+
const { goRegExp } = require('./regexp')
|
|
7
|
+
const { OnesecretError } = require('./error')
|
|
8
|
+
|
|
9
|
+
const SOPS_VERSION = '3.13.3'
|
|
10
|
+
const METADATA_KEY = 'sops'
|
|
11
|
+
const DEFAULT_RULES = { unencrypted_suffix: '_unencrypted' }
|
|
12
|
+
const RULE_KEYS = [
|
|
13
|
+
'unencrypted_suffix',
|
|
14
|
+
'encrypted_suffix',
|
|
15
|
+
'unencrypted_regex',
|
|
16
|
+
'encrypted_regex',
|
|
17
|
+
'mac_only_encrypted'
|
|
18
|
+
]
|
|
19
|
+
const DATA_KEY_SIZE = 32
|
|
20
|
+
const IV_SIZE = 32
|
|
21
|
+
const TAG_SIZE = 16
|
|
22
|
+
const ENCRYPTED_VALUE =
|
|
23
|
+
/^ENC\[AES256_GCM,data:(.+),iv:(.+),tag:(.+),type:(.+)\]$/
|
|
24
|
+
const CANONICAL_BASE64 =
|
|
25
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
26
|
+
const MAC_ONLY_ENCRYPTED_PREFIX = crypto
|
|
27
|
+
.createHash('sha256')
|
|
28
|
+
.update('sops')
|
|
29
|
+
.digest()
|
|
30
|
+
const COMMENT_RULE_KEYS = [
|
|
31
|
+
'unencrypted_comment_regex',
|
|
32
|
+
'encrypted_comment_regex'
|
|
33
|
+
]
|
|
34
|
+
const OTHER_KEY_SOURCES = ['kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'pgp']
|
|
35
|
+
|
|
36
|
+
const isMapping = value =>
|
|
37
|
+
value !== null &&
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
[Object.prototype, null].includes(Object.getPrototypeOf(value))
|
|
40
|
+
|
|
41
|
+
const mapLeaves = (node, path, transform, orderOf) => {
|
|
42
|
+
if (Array.isArray(node)) {
|
|
43
|
+
return node.map(item => mapLeaves(item, path, transform, orderOf))
|
|
44
|
+
}
|
|
45
|
+
if (isMapping(node)) {
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
orderOf(node).map(key => [
|
|
48
|
+
key,
|
|
49
|
+
mapLeaves(node[key], [...path, key], transform, orderOf)
|
|
50
|
+
])
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
return transform(node, path)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const rulesOf = metadata =>
|
|
57
|
+
Object.fromEntries(
|
|
58
|
+
RULE_KEYS.filter(key => metadata[key] !== undefined).map(key => [
|
|
59
|
+
key,
|
|
60
|
+
metadata[key]
|
|
61
|
+
])
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const isEncryptedPath = (path, rules) => {
|
|
65
|
+
let encrypted = true
|
|
66
|
+
if (
|
|
67
|
+
rules.unencrypted_suffix &&
|
|
68
|
+
path.some(key => key.endsWith(rules.unencrypted_suffix))
|
|
69
|
+
) {
|
|
70
|
+
encrypted = false
|
|
71
|
+
}
|
|
72
|
+
if (rules.encrypted_suffix) {
|
|
73
|
+
encrypted = path.some(key => key.endsWith(rules.encrypted_suffix))
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
rules.unencrypted_regex &&
|
|
77
|
+
path.some(key => goRegExp(rules.unencrypted_regex).test(key))
|
|
78
|
+
) {
|
|
79
|
+
encrypted = false
|
|
80
|
+
}
|
|
81
|
+
if (rules.encrypted_regex) {
|
|
82
|
+
encrypted = path.some(key => goRegExp(rules.encrypted_regex).test(key))
|
|
83
|
+
}
|
|
84
|
+
return encrypted
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const assertSupportedRules = metadata => {
|
|
88
|
+
const commentRule = COMMENT_RULE_KEYS.find(key => metadata[key])
|
|
89
|
+
if (commentRule) {
|
|
90
|
+
throw new OnesecretError(
|
|
91
|
+
`${commentRule} depends on YAML comments and is not supported, use sops`,
|
|
92
|
+
'EFORMAT'
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const otherKeySources = metadata =>
|
|
98
|
+
OTHER_KEY_SOURCES.filter(
|
|
99
|
+
key => Array.isArray(metadata[key]) && metadata[key].length > 0
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const macHash = rules => {
|
|
103
|
+
const hash = crypto.createHash('sha512')
|
|
104
|
+
if (rules.mac_only_encrypted) hash.update(MAC_ONLY_ENCRYPTED_PREFIX)
|
|
105
|
+
return hash
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isBlank = value => value === null || value === ''
|
|
109
|
+
|
|
110
|
+
const formatFloat = number => {
|
|
111
|
+
if (Number.isNaN(number)) return 'NaN'
|
|
112
|
+
if (!Number.isFinite(number)) return number > 0 ? '+Inf' : '-Inf'
|
|
113
|
+
if (Object.is(number, -0)) return '-0'
|
|
114
|
+
const [mantissa, exponentText] = String(number).split('e')
|
|
115
|
+
if (exponentText === undefined) return mantissa
|
|
116
|
+
const sign = mantissa.startsWith('-') ? '-' : ''
|
|
117
|
+
const [whole, fraction = ''] = mantissa.replace('-', '').split('.')
|
|
118
|
+
const digits = whole + fraction
|
|
119
|
+
const point = whole.length + Number(exponentText)
|
|
120
|
+
if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`
|
|
121
|
+
if (point >= digits.length) {
|
|
122
|
+
return sign + digits + '0'.repeat(point - digits.length)
|
|
123
|
+
}
|
|
124
|
+
return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const typeOf = value => {
|
|
128
|
+
if (typeof value === 'string') return 'str'
|
|
129
|
+
if (typeof value === 'boolean') return 'bool'
|
|
130
|
+
if (typeof value === 'bigint') return 'int'
|
|
131
|
+
if (typeof value === 'number') {
|
|
132
|
+
return Number.isSafeInteger(value) && !Object.is(value, -0)
|
|
133
|
+
? 'int'
|
|
134
|
+
: 'float'
|
|
135
|
+
}
|
|
136
|
+
if (Buffer.isBuffer(value)) return 'bytes'
|
|
137
|
+
throw new OnesecretError(
|
|
138
|
+
`cannot encrypt a value of type ${typeof value}`,
|
|
139
|
+
'EVALUE'
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const SERIALIZERS = {
|
|
144
|
+
str: value => Buffer.from(value),
|
|
145
|
+
bool: value => Buffer.from(value ? 'True' : 'False'),
|
|
146
|
+
int: value => Buffer.from(String(value)),
|
|
147
|
+
float: value => Buffer.from(formatFloat(value)),
|
|
148
|
+
bytes: value => value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const parseInteger = text => {
|
|
152
|
+
const value = BigInt(text)
|
|
153
|
+
return value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER
|
|
154
|
+
? Number(value)
|
|
155
|
+
: value
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const COMMENT = Symbol('onesecret.comment')
|
|
159
|
+
const INT64_MIN = -(2n ** 63n)
|
|
160
|
+
const INT64_MAX = 2n ** 63n - 1n
|
|
161
|
+
|
|
162
|
+
const PARSERS = {
|
|
163
|
+
comment: () => COMMENT,
|
|
164
|
+
str: bytes => bytes.toString(),
|
|
165
|
+
time: bytes => bytes.toString(),
|
|
166
|
+
bool: bytes => ['true', '1', 't'].includes(bytes.toString().toLowerCase()),
|
|
167
|
+
int: bytes => parseInteger(bytes.toString()),
|
|
168
|
+
float: bytes => {
|
|
169
|
+
const text = bytes.toString()
|
|
170
|
+
if (/^[-+]?inf(inity)?$/i.test(text)) {
|
|
171
|
+
return text.startsWith('-') ? -Infinity : Infinity
|
|
172
|
+
}
|
|
173
|
+
return /^nan$/i.test(text) ? NaN : Number.parseFloat(text)
|
|
174
|
+
},
|
|
175
|
+
bytes: bytes => bytes
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const serialize = value =>
|
|
179
|
+
isBlank(value) ? Buffer.alloc(0) : SERIALIZERS[typeOf(value)](value)
|
|
180
|
+
|
|
181
|
+
const additionalData = path => `${path.join(':')}:`
|
|
182
|
+
|
|
183
|
+
const isOutOfInt64 = value =>
|
|
184
|
+
typeof value === 'bigint' && (value < INT64_MIN || value > INT64_MAX)
|
|
185
|
+
|
|
186
|
+
const encryptValue = (value, dataKey, aad) => {
|
|
187
|
+
const type = typeOf(value)
|
|
188
|
+
const iv = crypto.randomBytes(IV_SIZE)
|
|
189
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', dataKey, iv)
|
|
190
|
+
cipher.setAAD(Buffer.from(aad))
|
|
191
|
+
const data = Buffer.concat([
|
|
192
|
+
cipher.update(SERIALIZERS[type](value)),
|
|
193
|
+
cipher.final()
|
|
194
|
+
])
|
|
195
|
+
const tag = cipher.getAuthTag()
|
|
196
|
+
return `ENC[AES256_GCM,data:${data.toString('base64')},iv:${iv.toString(
|
|
197
|
+
'base64'
|
|
198
|
+
)},tag:${tag.toString('base64')},type:${type}]`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const openValue = (value, dataKey, aad) => {
|
|
202
|
+
if (isBlank(value)) return { value, plaintext: Buffer.alloc(0) }
|
|
203
|
+
const match = typeof value === 'string' && ENCRYPTED_VALUE.exec(value)
|
|
204
|
+
if (!match) {
|
|
205
|
+
throw new OnesecretError(`value at "${aad}" is not encrypted`, 'EFORMAT')
|
|
206
|
+
}
|
|
207
|
+
const [, data, ivText, tagText, type] = match
|
|
208
|
+
if (!PARSERS[type]) {
|
|
209
|
+
throw new OnesecretError(
|
|
210
|
+
`value at "${aad}" has unknown type "${type}"`,
|
|
211
|
+
'EFORMAT'
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (![data, ivText, tagText].every(text => CANONICAL_BASE64.test(text))) {
|
|
215
|
+
throw new OnesecretError(
|
|
216
|
+
`value at "${aad}" has malformed base64`,
|
|
217
|
+
'EFORMAT'
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
const iv = Buffer.from(ivText, 'base64')
|
|
221
|
+
const tag = Buffer.from(tagText, 'base64')
|
|
222
|
+
if (iv.length !== IV_SIZE || tag.length !== TAG_SIZE) {
|
|
223
|
+
throw new OnesecretError(
|
|
224
|
+
`value at "${aad}" has a malformed iv or tag`,
|
|
225
|
+
'EFORMAT'
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
let plaintext
|
|
229
|
+
try {
|
|
230
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', dataKey, iv, {
|
|
231
|
+
authTagLength: TAG_SIZE
|
|
232
|
+
})
|
|
233
|
+
decipher.setAAD(Buffer.from(aad))
|
|
234
|
+
decipher.setAuthTag(tag)
|
|
235
|
+
plaintext = Buffer.concat([
|
|
236
|
+
decipher.update(Buffer.from(data, 'base64')),
|
|
237
|
+
decipher.final()
|
|
238
|
+
])
|
|
239
|
+
} catch {
|
|
240
|
+
throw new OnesecretError(`value at "${aad}" failed to authenticate`, 'EMAC')
|
|
241
|
+
}
|
|
242
|
+
return { value: PARSERS[type](plaintext), plaintext }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const openDataKey = (enc, identities) => {
|
|
246
|
+
try {
|
|
247
|
+
return age.decrypt(enc, identities)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
throw new OnesecretError(
|
|
250
|
+
`age data key is malformed: ${error.message}`,
|
|
251
|
+
'EFORMAT'
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const sealDataKey = (dataKey, recipient) => {
|
|
257
|
+
try {
|
|
258
|
+
return age.encrypt(dataKey, [recipient])
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw new OnesecretError(error.message, 'EKEY')
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const unwrapDataKey = (metadata, identities) => {
|
|
265
|
+
if (metadata.key_groups || metadata.shamir_threshold) {
|
|
266
|
+
throw new OnesecretError(
|
|
267
|
+
'key_groups are not supported, use a flat list of age recipients',
|
|
268
|
+
'EFORMAT'
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
const entries = metadata.age || []
|
|
272
|
+
if (entries.length === 0) {
|
|
273
|
+
throw new OnesecretError('file has no age recipients', 'EFORMAT')
|
|
274
|
+
}
|
|
275
|
+
for (const { enc } of entries) {
|
|
276
|
+
const dataKey = openDataKey(enc, identities)
|
|
277
|
+
if (dataKey) {
|
|
278
|
+
if (dataKey.length !== DATA_KEY_SIZE) {
|
|
279
|
+
throw new OnesecretError('data key has the wrong size', 'EFORMAT')
|
|
280
|
+
}
|
|
281
|
+
return dataKey
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const recipients = entries.map(({ recipient }) => recipient).join(', ')
|
|
285
|
+
throw new OnesecretError(
|
|
286
|
+
`no age identity matches the file recipients (${recipients})`,
|
|
287
|
+
'EKEY'
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const metadataOf = document => {
|
|
292
|
+
const metadata = isMapping(document) ? document[METADATA_KEY] : undefined
|
|
293
|
+
if (!isMapping(metadata)) {
|
|
294
|
+
throw new OnesecretError('file is not encrypted with sops', 'EFORMAT')
|
|
295
|
+
}
|
|
296
|
+
return metadata
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const names = document => {
|
|
300
|
+
metadataOf(document)
|
|
301
|
+
return keysOf(document).filter(key => key !== METADATA_KEY)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const valueTypeOf = value => {
|
|
305
|
+
const encrypted =
|
|
306
|
+
typeof value === 'string' ? ENCRYPTED_VALUE.exec(value) : null
|
|
307
|
+
if (encrypted) return encrypted[4]
|
|
308
|
+
if (value === null) return 'null'
|
|
309
|
+
if (isMapping(value)) return 'mapping'
|
|
310
|
+
if (Array.isArray(value)) return 'list'
|
|
311
|
+
return typeOf(value)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const valueTypes = document =>
|
|
315
|
+
Object.fromEntries(
|
|
316
|
+
names(document).map(name => [name, valueTypeOf(document[name])])
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const recipientsOf = document =>
|
|
320
|
+
(metadataOf(document).age || []).map(({ recipient }) => recipient)
|
|
321
|
+
|
|
322
|
+
const withoutComments = node => {
|
|
323
|
+
if (Array.isArray(node)) {
|
|
324
|
+
return node.filter(item => item !== COMMENT).map(withoutComments)
|
|
325
|
+
}
|
|
326
|
+
if (isMapping(node)) {
|
|
327
|
+
return Object.fromEntries(
|
|
328
|
+
Object.entries(node).map(([key, value]) => [key, withoutComments(value)])
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
return node
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const decrypt = (document, identities) => {
|
|
335
|
+
const metadata = metadataOf(document)
|
|
336
|
+
assertSupportedRules(metadata)
|
|
337
|
+
const dataKey = unwrapDataKey(metadata, identities)
|
|
338
|
+
const rules = rulesOf(metadata)
|
|
339
|
+
const hash = macHash(rules)
|
|
340
|
+
const tree = mapLeaves(
|
|
341
|
+
document,
|
|
342
|
+
[],
|
|
343
|
+
(value, path) => {
|
|
344
|
+
if (!isEncryptedPath(path, rules)) {
|
|
345
|
+
if (!rules.mac_only_encrypted) hash.update(serialize(value))
|
|
346
|
+
return value
|
|
347
|
+
}
|
|
348
|
+
const opened = openValue(value, dataKey, additionalData(path))
|
|
349
|
+
if (opened.value === COMMENT) return COMMENT
|
|
350
|
+
hash.update(opened.plaintext)
|
|
351
|
+
return opened.value
|
|
352
|
+
},
|
|
353
|
+
node =>
|
|
354
|
+
keysOf(node).filter(key => node !== document || key !== METADATA_KEY)
|
|
355
|
+
)
|
|
356
|
+
const { value: expectedMac } = openValue(
|
|
357
|
+
metadata.mac,
|
|
358
|
+
dataKey,
|
|
359
|
+
String(metadata.lastmodified)
|
|
360
|
+
)
|
|
361
|
+
const actualMac = hash.digest('hex').toUpperCase()
|
|
362
|
+
if (typeof expectedMac !== 'string' || expectedMac !== actualMac) {
|
|
363
|
+
throw new OnesecretError(
|
|
364
|
+
'file MAC does not match its content, it was modified without the key',
|
|
365
|
+
'EMAC'
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
return withoutComments(tree)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const encrypt = (tree, { recipients, rules = DEFAULT_RULES }) => {
|
|
372
|
+
if (METADATA_KEY in tree) {
|
|
373
|
+
throw new OnesecretError(
|
|
374
|
+
`"${METADATA_KEY}" is reserved for the file metadata`,
|
|
375
|
+
'EVALUE'
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
if (recipients.length === 0) {
|
|
379
|
+
throw new OnesecretError('at least one age recipient is required', 'EKEY')
|
|
380
|
+
}
|
|
381
|
+
const dataKey = crypto.randomBytes(DATA_KEY_SIZE)
|
|
382
|
+
const hash = macHash(rules)
|
|
383
|
+
const encrypted = mapLeaves(
|
|
384
|
+
tree,
|
|
385
|
+
[],
|
|
386
|
+
(value, path) => {
|
|
387
|
+
const shouldEncrypt = isEncryptedPath(path, rules)
|
|
388
|
+
if (isOutOfInt64(value)) {
|
|
389
|
+
throw new OnesecretError(
|
|
390
|
+
`value at "${additionalData(
|
|
391
|
+
path
|
|
392
|
+
)}" is outside the 64-bit integer range sops can read, quote it`,
|
|
393
|
+
'EVALUE'
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
if (shouldEncrypt || !rules.mac_only_encrypted) {
|
|
397
|
+
hash.update(serialize(value))
|
|
398
|
+
}
|
|
399
|
+
if (!shouldEncrypt || isBlank(value)) return value
|
|
400
|
+
return encryptValue(value, dataKey, additionalData(path))
|
|
401
|
+
},
|
|
402
|
+
Object.keys
|
|
403
|
+
)
|
|
404
|
+
const lastmodified = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
405
|
+
return {
|
|
406
|
+
...encrypted,
|
|
407
|
+
[METADATA_KEY]: {
|
|
408
|
+
age: recipients.map(recipient => ({
|
|
409
|
+
enc: sealDataKey(dataKey, recipient),
|
|
410
|
+
recipient
|
|
411
|
+
})),
|
|
412
|
+
lastmodified,
|
|
413
|
+
mac: encryptValue(
|
|
414
|
+
hash.digest('hex').toUpperCase(),
|
|
415
|
+
dataKey,
|
|
416
|
+
lastmodified
|
|
417
|
+
),
|
|
418
|
+
...rules,
|
|
419
|
+
version: SOPS_VERSION
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
DEFAULT_RULES,
|
|
426
|
+
decrypt,
|
|
427
|
+
encrypt,
|
|
428
|
+
isEncryptedPath,
|
|
429
|
+
names,
|
|
430
|
+
otherKeySources,
|
|
431
|
+
recipientsOf,
|
|
432
|
+
rulesOf,
|
|
433
|
+
valueTypes
|
|
434
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const yaml = require('./yaml')
|
|
6
|
+
const jsYaml = require('js-yaml')
|
|
7
|
+
const { rulesOf } = require('./sops')
|
|
8
|
+
const { goRegExp } = require('./regexp')
|
|
9
|
+
const { OnesecretError } = require('./error')
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LAYER = 'default'
|
|
12
|
+
const EXTENSIONS = ['.enc.yml', '.enc.yaml', '.enc.json']
|
|
13
|
+
const PLAIN_EXTENSIONS = ['.yml', '.yaml', '.json']
|
|
14
|
+
const ENCRYPTED_FILE = /^(.+)\.enc\.(ya?ml|json)$/
|
|
15
|
+
const PLAIN_FILE = /^(.+)\.(ya?ml|json)$/
|
|
16
|
+
const SOPS_CONFIG = '.sops.yaml'
|
|
17
|
+
|
|
18
|
+
const isJson = file => file.endsWith('.json')
|
|
19
|
+
|
|
20
|
+
const stringify = (file, document) =>
|
|
21
|
+
isJson(file) ? yaml.dumpJson(document) : yaml.dump(document)
|
|
22
|
+
|
|
23
|
+
const layersFor = env =>
|
|
24
|
+
env && env !== DEFAULT_LAYER ? [DEFAULT_LAYER, env] : [DEFAULT_LAYER]
|
|
25
|
+
|
|
26
|
+
const namesIn = dir => {
|
|
27
|
+
try {
|
|
28
|
+
return new Set(fs.readdirSync(dir))
|
|
29
|
+
} catch {
|
|
30
|
+
return new Set()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const assertExactCase = (names, wanted, dir) => {
|
|
35
|
+
const byLowercase = new Map(wanted.map(name => [name.toLowerCase(), name]))
|
|
36
|
+
for (const actual of names) {
|
|
37
|
+
const name = byLowercase.get(actual.toLowerCase())
|
|
38
|
+
if (name && actual !== name) {
|
|
39
|
+
throw new OnesecretError(
|
|
40
|
+
`${actual} in ${dir} differs from ${name} only in letter case, rename it to ${name}`,
|
|
41
|
+
'EFORMAT'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const findFile = (dir, layer, extensions) => {
|
|
48
|
+
const names = namesIn(dir)
|
|
49
|
+
assertExactCase(
|
|
50
|
+
names,
|
|
51
|
+
extensions.map(extension => layer + extension),
|
|
52
|
+
dir
|
|
53
|
+
)
|
|
54
|
+
const found = extensions
|
|
55
|
+
.map(extension => layer + extension)
|
|
56
|
+
.filter(name => names.has(name))
|
|
57
|
+
.map(name => path.join(dir, name))
|
|
58
|
+
if (found.length > 1) {
|
|
59
|
+
throw new OnesecretError(
|
|
60
|
+
`${found
|
|
61
|
+
.map(file => path.basename(file))
|
|
62
|
+
.join(' and ')} both hold the ${layer} layer in ${dir}, keep one`,
|
|
63
|
+
'EFORMAT'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
return found[0]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const findLayerFile = (dir, layer) => findFile(dir, layer, EXTENSIONS)
|
|
70
|
+
|
|
71
|
+
const findPlainFile = (dir, layer) => findFile(dir, layer, PLAIN_EXTENSIONS)
|
|
72
|
+
|
|
73
|
+
const layerOfFile = file => {
|
|
74
|
+
const name = path.basename(file)
|
|
75
|
+
return (ENCRYPTED_FILE.exec(name) || PLAIN_FILE.exec(name))[1]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const encryptedFilesIn = dir =>
|
|
79
|
+
[...namesIn(dir)]
|
|
80
|
+
.filter(name => ENCRYPTED_FILE.test(name))
|
|
81
|
+
.sort()
|
|
82
|
+
.map(name => path.join(dir, name))
|
|
83
|
+
|
|
84
|
+
const fileKindOf = file => {
|
|
85
|
+
const name = path.basename(file)
|
|
86
|
+
if (ENCRYPTED_FILE.test(name)) return 'encrypted'
|
|
87
|
+
if (PLAIN_FILE.test(name)) return 'plain'
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const layerFileOrNew = (dir, layer) =>
|
|
92
|
+
findLayerFile(dir, layer) || path.join(dir, layer + EXTENSIONS[0])
|
|
93
|
+
|
|
94
|
+
const parseErrorOf = error => {
|
|
95
|
+
if (error.mark) {
|
|
96
|
+
return `invalid YAML at line ${error.mark.line + 1}, column ${
|
|
97
|
+
error.mark.column + 1
|
|
98
|
+
}`
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof SyntaxError) return 'not valid JSON'
|
|
101
|
+
return error.code || error.message
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const readDocument = file => {
|
|
105
|
+
try {
|
|
106
|
+
return yaml.parse(fs.readFileSync(file, 'utf8'))
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new OnesecretError(
|
|
109
|
+
`cannot read ${file}: ${parseErrorOf(error)}`,
|
|
110
|
+
'EFORMAT'
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isMapping = value =>
|
|
116
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
117
|
+
|
|
118
|
+
const onlyDocument = (file, text) => {
|
|
119
|
+
const documents = jsYaml.loadAll(text)
|
|
120
|
+
if (documents.length > 1) {
|
|
121
|
+
throw new OnesecretError(
|
|
122
|
+
`${file} must hold a single YAML document`,
|
|
123
|
+
'EFORMAT'
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return documents[0]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parsePlain = (file, text) => {
|
|
130
|
+
if (!isJson(file)) return onlyDocument(file, text)
|
|
131
|
+
return text.trim() === '' ? null : JSON.parse(text)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const readPlain = file => {
|
|
135
|
+
let values
|
|
136
|
+
try {
|
|
137
|
+
values = parsePlain(file, fs.readFileSync(file, 'utf8'))
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof OnesecretError) throw error
|
|
140
|
+
throw new OnesecretError(
|
|
141
|
+
`cannot read ${file}: ${parseErrorOf(error)}`,
|
|
142
|
+
'EFORMAT'
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (values === undefined || values === null) return {}
|
|
146
|
+
if (!isMapping(values)) {
|
|
147
|
+
throw new OnesecretError(
|
|
148
|
+
`${file} must be a mapping of NAME: value`,
|
|
149
|
+
'EFORMAT'
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
if (isMapping(values.sops) && values.sops.mac) {
|
|
153
|
+
throw new OnesecretError(
|
|
154
|
+
`${file} is encrypted with sops, rename it to ${path
|
|
155
|
+
.basename(file)
|
|
156
|
+
.replace(PLAIN_FILE, '$1.enc.$2')}`,
|
|
157
|
+
'EFORMAT'
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return values
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const modeOf = file => {
|
|
164
|
+
try {
|
|
165
|
+
return fs.statSync(file).mode & 0o777
|
|
166
|
+
} catch {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const realPathOf = file => {
|
|
172
|
+
try {
|
|
173
|
+
return fs.realpathSync(file)
|
|
174
|
+
} catch {
|
|
175
|
+
return file
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const writeText = (link, text) => {
|
|
180
|
+
const file = realPathOf(link)
|
|
181
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
182
|
+
const mode = modeOf(file)
|
|
183
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
184
|
+
fs.writeFileSync(temporary, text)
|
|
185
|
+
if (mode !== null) fs.chmodSync(temporary, mode)
|
|
186
|
+
fs.renameSync(temporary, file)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const writeDocument = (file, document) =>
|
|
190
|
+
writeText(file, stringify(file, document))
|
|
191
|
+
|
|
192
|
+
const readSopsConfig = file => {
|
|
193
|
+
try {
|
|
194
|
+
return (
|
|
195
|
+
jsYaml.load(fs.readFileSync(file, 'utf8'), {
|
|
196
|
+
schema: jsYaml.CORE_SCHEMA
|
|
197
|
+
}) || {}
|
|
198
|
+
)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw new OnesecretError(
|
|
201
|
+
`cannot read ${file}: ${error.reason || error.message}`,
|
|
202
|
+
'EFORMAT'
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const findSopsConfig = from => {
|
|
208
|
+
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
|
209
|
+
const candidate = path.join(dir, SOPS_CONFIG)
|
|
210
|
+
if (fs.existsSync(candidate)) return candidate
|
|
211
|
+
if (dir === path.dirname(dir)) return null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const splitRecipients = value =>
|
|
216
|
+
(Array.isArray(value) ? value : String(value).split(','))
|
|
217
|
+
.map(recipient => recipient.trim())
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
|
|
220
|
+
const creationRule = file => {
|
|
221
|
+
const configFile = findSopsConfig(path.dirname(file))
|
|
222
|
+
if (!configFile) return null
|
|
223
|
+
const config = readSopsConfig(configFile)
|
|
224
|
+
const relative = path
|
|
225
|
+
.relative(path.dirname(configFile), file)
|
|
226
|
+
.split(path.sep)
|
|
227
|
+
.join('/')
|
|
228
|
+
const rule = (config.creation_rules || []).find(
|
|
229
|
+
({ path_regex: pattern }) => !pattern || goRegExp(pattern).test(relative)
|
|
230
|
+
)
|
|
231
|
+
if (!rule || !rule.age) return null
|
|
232
|
+
const rules = rulesOf(rule)
|
|
233
|
+
return {
|
|
234
|
+
recipients: splitRecipients(rule.age),
|
|
235
|
+
rules: Object.keys(rules).length > 0 ? rules : undefined
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
DEFAULT_LAYER,
|
|
241
|
+
SOPS_CONFIG,
|
|
242
|
+
creationRule,
|
|
243
|
+
encryptedFilesIn,
|
|
244
|
+
fileKindOf,
|
|
245
|
+
findLayerFile,
|
|
246
|
+
findPlainFile,
|
|
247
|
+
isMapping,
|
|
248
|
+
layerOfFile,
|
|
249
|
+
parseErrorOf,
|
|
250
|
+
layerFileOrNew,
|
|
251
|
+
layersFor,
|
|
252
|
+
readDocument,
|
|
253
|
+
readPlain,
|
|
254
|
+
stringify,
|
|
255
|
+
writeDocument,
|
|
256
|
+
writeText
|
|
257
|
+
}
|