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/index.js ADDED
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ const { config, load, names } = require('./secrets')
4
+ const { fly, kubernetes } = require('./deploy')
5
+ const { checksum, toDotenv, toEnv } = require('./format')
6
+ const { withoutAgeKey } = require('./keys')
7
+ const { OnesecretError } = require('./error')
8
+
9
+ module.exports = {
10
+ checksum,
11
+ config,
12
+ fly,
13
+ kubernetes,
14
+ load,
15
+ names,
16
+ toDotenv,
17
+ toEnv,
18
+ withoutAgeKey,
19
+ OnesecretError
20
+ }
package/src/keys.js ADDED
@@ -0,0 +1,197 @@
1
+ 'use strict'
2
+
3
+ const { execSync } = require('node:child_process')
4
+ const fs = require('node:fs')
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const age = require('./age')
8
+ const { OnesecretError } = require('./error')
9
+
10
+ const KEYS_FILE = path.join('sops', 'age', 'keys.txt')
11
+ const AGE_KEY_VARIABLES = [
12
+ 'SOPS_AGE_KEY',
13
+ 'SOPS_AGE_KEY_FILE',
14
+ 'SOPS_AGE_KEY_CMD'
15
+ ]
16
+
17
+ const sopsKeysFile = () => {
18
+ if (process.env.XDG_CONFIG_HOME) {
19
+ return path.join(process.env.XDG_CONFIG_HOME, KEYS_FILE)
20
+ }
21
+ if (process.platform === 'darwin') {
22
+ return path.join(os.homedir(), 'Library', 'Application Support', KEYS_FILE)
23
+ }
24
+ return path.join(os.homedir(), '.config', KEYS_FILE)
25
+ }
26
+
27
+ const defaultKeyFiles = () => [
28
+ ...new Set([sopsKeysFile(), path.join(os.homedir(), '.config', KEYS_FILE)])
29
+ ]
30
+
31
+ const readKeyFile = file => {
32
+ try {
33
+ return fs.readFileSync(file, 'utf8')
34
+ } catch (error) {
35
+ throw new OnesecretError(
36
+ `cannot read age key file ${file}: ${error.code || error.message}`,
37
+ 'EKEY'
38
+ )
39
+ }
40
+ }
41
+
42
+ const parseSource = (label, text) => {
43
+ try {
44
+ return age.parseIdentities(text)
45
+ } catch (error) {
46
+ throw new OnesecretError(
47
+ `${label} holds an invalid age key: ${error.message}`,
48
+ 'EKEY'
49
+ )
50
+ }
51
+ }
52
+
53
+ const localSources = () => [
54
+ ...(process.env.SOPS_AGE_KEY
55
+ ? [['SOPS_AGE_KEY', () => process.env.SOPS_AGE_KEY]]
56
+ : []),
57
+ ...(process.env.SOPS_AGE_KEY_FILE
58
+ ? [
59
+ [
60
+ process.env.SOPS_AGE_KEY_FILE,
61
+ () => readKeyFile(process.env.SOPS_AGE_KEY_FILE)
62
+ ]
63
+ ]
64
+ : []),
65
+ ...defaultKeyFiles()
66
+ .filter(file => fs.existsSync(file))
67
+ .map(file => [file, () => readKeyFile(file)])
68
+ ]
69
+
70
+ const localIdentities = () => {
71
+ const identities = []
72
+ const invalid = []
73
+ for (const [label, read] of localSources()) {
74
+ try {
75
+ identities.push(...parseSource(label, read()))
76
+ } catch (error) {
77
+ invalid.push(error)
78
+ }
79
+ }
80
+ return { identities, invalid }
81
+ }
82
+
83
+ const withSkippedSources = (error, invalid) => {
84
+ if (error.code !== 'EKEY' || invalid.length === 0) return error
85
+ const skipped = invalid.map(({ reason }) => reason).join('; ')
86
+ return new OnesecretError(`${error.reason} (skipped: ${skipped})`, 'EKEY')
87
+ }
88
+
89
+ const commandCache = new Map()
90
+
91
+ const runKeyCommand = command => {
92
+ if (!commandCache.has(command)) {
93
+ try {
94
+ commandCache.set(command, { identities: readKeyCommand(command) })
95
+ } catch (error) {
96
+ commandCache.set(command, { error })
97
+ }
98
+ }
99
+ const { identities, error } = commandCache.get(command)
100
+ if (error) throw error
101
+ return identities
102
+ }
103
+
104
+ const readKeyCommand = command => {
105
+ let output
106
+ try {
107
+ output = execSync(command, {
108
+ encoding: 'utf8',
109
+ stdio: ['ignore', 'pipe', 'pipe']
110
+ })
111
+ } catch (error) {
112
+ const detail = error.stderr
113
+ ? String(error.stderr).trim().split('\n')[0]
114
+ : ''
115
+ throw new OnesecretError(
116
+ `key command "${command}" failed${detail ? `: ${detail}` : ''}`,
117
+ 'EKEY'
118
+ )
119
+ }
120
+ const identities = parseSource(`key command "${command}"`, output)
121
+ if (identities.length === 0) {
122
+ throw new OnesecretError(
123
+ `key command "${command}" printed no age identity`,
124
+ 'EKEY'
125
+ )
126
+ }
127
+ return identities
128
+ }
129
+
130
+ const keyCommandOf = keyCommand => process.env.SOPS_AGE_KEY_CMD || keyCommand
131
+
132
+ const withIdentities = (keyCommand, use, { allowKeyCommand = true } = {}) => {
133
+ const { identities, invalid } = localIdentities()
134
+ const command = allowKeyCommand ? keyCommandOf(keyCommand) : undefined
135
+ if (identities.length === 0 && !command) {
136
+ if (invalid.length > 0) throw invalid[0]
137
+ const sources = `set SOPS_AGE_KEY or SOPS_AGE_KEY_FILE, or add it to ${sopsKeysFile()}`
138
+ const hint = allowKeyCommand
139
+ ? `${sources}, or configure a keyCommand`
140
+ : `${sources} (keyCommand is not run here)`
141
+ throw new OnesecretError(`no age key found: ${hint}`, 'ENOKEY')
142
+ }
143
+ if (identities.length > 0) {
144
+ try {
145
+ return use(identities)
146
+ } catch (error) {
147
+ if (error.code !== 'EKEY' || !command) {
148
+ throw withSkippedSources(error, invalid)
149
+ }
150
+ }
151
+ }
152
+ return use(runKeyCommand(command))
153
+ }
154
+
155
+ const withoutAgeKey = env =>
156
+ Object.fromEntries(
157
+ Object.entries(env).filter(([name]) => !AGE_KEY_VARIABLES.includes(name))
158
+ )
159
+
160
+ const EXPORTED_KEY = /AGE-SECRET-KEY-1[0-9A-Z]+/g
161
+
162
+ const exportedKeyText = () => {
163
+ const texts = [process.env.SOPS_AGE_KEY || '']
164
+ if (process.env.SOPS_AGE_KEY_FILE) {
165
+ try {
166
+ texts.push(fs.readFileSync(process.env.SOPS_AGE_KEY_FILE, 'utf8'))
167
+ } catch {}
168
+ }
169
+ return texts
170
+ .join('\n')
171
+ .split('\n')
172
+ .filter(line => !line.trim().startsWith('#'))
173
+ .join('\n')
174
+ }
175
+
176
+ const recipientOrNull = identity => {
177
+ try {
178
+ return age.recipientOf(identity)
179
+ } catch {
180
+ return null
181
+ }
182
+ }
183
+
184
+ const exportedRecipients = () => [
185
+ ...new Set(
186
+ (exportedKeyText().match(EXPORTED_KEY) || [])
187
+ .map(recipientOrNull)
188
+ .filter(Boolean)
189
+ )
190
+ ]
191
+
192
+ module.exports = {
193
+ exportedRecipients,
194
+ sopsKeysFile,
195
+ withIdentities,
196
+ withoutAgeKey
197
+ }
package/src/regexp.js ADDED
@@ -0,0 +1,91 @@
1
+ 'use strict'
2
+
3
+ const { OnesecretError } = require('./error')
4
+
5
+ const POSIX_CLASSES = {
6
+ alnum: '0-9A-Za-z',
7
+ alpha: 'A-Za-z',
8
+ ascii: '\\x00-\\x7F',
9
+ blank: '\\t ',
10
+ cntrl: '\\x00-\\x1F\\x7F',
11
+ digit: '0-9',
12
+ graph: '!-~',
13
+ lower: 'a-z',
14
+ print: ' -~',
15
+ punct: '!-\\/:-@\\[-`{-~',
16
+ space: '\\t\\n\\v\\f\\r ',
17
+ upper: 'A-Z',
18
+ word: '0-9A-Za-z_',
19
+ xdigit: '0-9A-Fa-f'
20
+ }
21
+ const LEADING_FLAGS = /^\(\?([imsU]+)\)/
22
+ const POSIX_CLASS = /^\[:(\^?)([a-z]+):\]/
23
+ const SUPPORTED_FLAGS = new Set(['i', 'm', 's'])
24
+ const ESCAPES = { A: '(?<![\\s\\S])', z: '(?![\\s\\S])' }
25
+ const UNSUPPORTED_ESCAPES = new Set(['Q', 'E', 'p', 'P', 'C', 'Z'])
26
+ const LITERAL_BRACKET_CLASS = /^\[\^?\]/
27
+
28
+ const cache = new Map()
29
+
30
+ const translateBody = pattern => {
31
+ let output = ''
32
+ let inClass = false
33
+ for (let index = 0; index < pattern.length; index++) {
34
+ const char = pattern[index]
35
+ const rest = pattern.slice(index)
36
+ if (char === '\\') {
37
+ const next = pattern[index + 1]
38
+ if (
39
+ UNSUPPORTED_ESCAPES.has(next) ||
40
+ (next === 'x' && pattern[index + 2] === '{')
41
+ ) {
42
+ throw new Error(`the Go escape \\${next} has no JavaScript equivalent`)
43
+ }
44
+ output += !inClass && ESCAPES[next] ? ESCAPES[next] : char + (next ?? '')
45
+ index++
46
+ } else if (inClass && POSIX_CLASS.test(rest)) {
47
+ const [match, negated, name] = POSIX_CLASS.exec(rest)
48
+ if (!POSIX_CLASSES[name]) { throw new Error(`unknown character class [:${name}:]`) }
49
+ if (negated) { throw new Error(`negated class [:^${name}:] is not supported`) }
50
+ output += POSIX_CLASSES[name]
51
+ index += match.length - 1
52
+ } else if (!inClass && LITERAL_BRACKET_CLASS.test(rest)) {
53
+ throw new Error('a class that starts with ] is not supported')
54
+ } else if (rest.startsWith('(?P<')) {
55
+ output += '(?<'
56
+ index += 3
57
+ } else {
58
+ if (char === '[' && !inClass) inClass = true
59
+ else if (char === ']' && inClass) inClass = false
60
+ output += char
61
+ }
62
+ }
63
+ return output
64
+ }
65
+
66
+ const translate = pattern => {
67
+ const leading = LEADING_FLAGS.exec(pattern)
68
+ const flags = leading
69
+ ? [...leading[1]].filter(flag => SUPPORTED_FLAGS.has(flag)).join('')
70
+ : ''
71
+ return new RegExp(
72
+ translateBody(leading ? pattern.slice(leading[0].length) : pattern),
73
+ flags
74
+ )
75
+ }
76
+
77
+ const goRegExp = pattern => {
78
+ if (!cache.has(pattern)) {
79
+ try {
80
+ cache.set(pattern, translate(pattern))
81
+ } catch (error) {
82
+ throw new OnesecretError(
83
+ `regular expression "${pattern}" is not supported: ${error.message}`,
84
+ 'EFORMAT'
85
+ )
86
+ }
87
+ }
88
+ return cache.get(pattern)
89
+ }
90
+
91
+ module.exports = { goRegExp }
package/src/secrets.js ADDED
@@ -0,0 +1,331 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const sops = require('./sops')
6
+ const store = require('./store')
7
+ const { withIdentities } = require('./keys')
8
+ const { isEnvName, isEnvScalar, toEnvValue } = require('./format')
9
+ const { OnesecretError } = require('./error')
10
+
11
+ const DEFAULT_DIR = 'config'
12
+ const DEFAULT_ENV = 'development'
13
+ const SKIP_VARIABLE = 'ONESECRET_SKIP'
14
+ const DEPLOYED_ENVS = ['production', 'staging']
15
+ const EXPORTABLE_TYPES = ['str', 'int', 'float', 'bool', 'time', 'null']
16
+ const FILE_LIKE_ENV = /(^|\.)enc(\.|$)|\.(ya?ml|json)$/i
17
+ const KEY_ERRORS = ['ENOKEY', 'EKEY']
18
+
19
+ const packageSettings = cwd => {
20
+ try {
21
+ return (
22
+ JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
23
+ .onesecret || {}
24
+ )
25
+ } catch {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ const ENV_NAME = /^[a-z0-9_-][a-z0-9_.-]*$/
31
+
32
+ const assertEnv = env => {
33
+ if (!ENV_NAME.test(env) || env.includes('..') || FILE_LIKE_ENV.test(env)) {
34
+ throw new OnesecretError(
35
+ `"${env}" is not a valid env name, use lowercase letters, digits, ".", "_", or "-"`,
36
+ 'EVALUE'
37
+ )
38
+ }
39
+ return env
40
+ }
41
+
42
+ const optionsOf = input =>
43
+ typeof input === 'string' ? { env: input } : input || {}
44
+
45
+ const resolveOptions = input => {
46
+ const options = optionsOf(input)
47
+ const cwd = path.resolve(options.cwd || process.cwd())
48
+ const settings = packageSettings(cwd)
49
+ return {
50
+ cwd,
51
+ dir: path.resolve(cwd, options.dir || settings.dir || DEFAULT_DIR),
52
+ env:
53
+ options.env ||
54
+ (options.processEnv || process.env).NODE_ENV ||
55
+ DEFAULT_ENV,
56
+ keyCommand: options.keyCommand || settings.keyCommand
57
+ }
58
+ }
59
+
60
+ const readLayer = (dir, name) => {
61
+ const plainFile = store.findPlainFile(dir, name)
62
+ const encryptedFile = store.findLayerFile(dir, name)
63
+ return {
64
+ name,
65
+ plainFile,
66
+ encryptedFile,
67
+ values: plainFile ? store.readPlain(plainFile) : {},
68
+ document: encryptedFile ? readEncrypted(encryptedFile) : null
69
+ }
70
+ }
71
+
72
+ const readEncrypted = file => {
73
+ const document = store.readDocument(file)
74
+ if (document === null) return null
75
+ try {
76
+ sops.names(document)
77
+ } catch (error) {
78
+ throw new OnesecretError(`${file}: ${error.reason}`, error.code)
79
+ }
80
+ return document
81
+ }
82
+
83
+ const readLayers = input => {
84
+ const { dir, env, keyCommand } = resolveOptions(input)
85
+ assertEnv(env)
86
+ const layers = store.layersFor(env).map(name => readLayer(dir, name))
87
+ return { dir, env, keyCommand, layers }
88
+ }
89
+
90
+ const documentsOf = layers =>
91
+ layers.map(({ document }) => document).filter(Boolean)
92
+
93
+ const hasFiles = layers =>
94
+ layers.some(({ plainFile, encryptedFile }) => plainFile || encryptedFile)
95
+
96
+ const isPlainObject = value =>
97
+ Object.prototype.toString.call(value) === '[object Object]'
98
+
99
+ const secretsOf = layers => {
100
+ const topmost = new Map()
101
+ for (const layer of layers) {
102
+ for (const [name, value] of Object.entries(layer.values)) {
103
+ const merges =
104
+ isPlainObject(value) && topmost.get(name)?.type === 'mapping'
105
+ if (!merges) topmost.set(name, null)
106
+ }
107
+ if (!layer.document) continue
108
+ for (const [name, type] of Object.entries(
109
+ sops.valueTypes(layer.document)
110
+ )) {
111
+ topmost.set(name, { type, file: layer.encryptedFile })
112
+ }
113
+ }
114
+ return new Map([...topmost].filter(([, secret]) => secret))
115
+ }
116
+
117
+ const secretNamesOf = layers => [...secretsOf(layers).keys()]
118
+
119
+ const decryptDocuments = (documents, keyCommand, keyOptions) => {
120
+ if (documents.length === 0) return []
121
+ return withIdentities(
122
+ keyCommand,
123
+ identities => documents.map(document => sops.decrypt(document, identities)),
124
+ keyOptions
125
+ )
126
+ }
127
+
128
+ const decryptDocument = (document, keyCommand) =>
129
+ decryptDocuments([document], keyCommand)[0]
130
+
131
+ const UNSAFE_NAME = '__proto__'
132
+
133
+ const assign = (target, name, value) =>
134
+ Object.defineProperty(target, name, {
135
+ value,
136
+ enumerable: true,
137
+ writable: true,
138
+ configurable: true
139
+ })
140
+
141
+ const deepMerge = (base, override) => {
142
+ const merged = { ...base }
143
+ for (const [name, value] of Object.entries(override)) {
144
+ if (name === UNSAFE_NAME) continue
145
+ const current = Object.hasOwn(merged, name) ? merged[name] : undefined
146
+ assign(
147
+ merged,
148
+ name,
149
+ isPlainObject(value)
150
+ ? deepMerge(isPlainObject(current) ? current : {}, value)
151
+ : value
152
+ )
153
+ }
154
+ return merged
155
+ }
156
+
157
+ const withoutMetadata = document => pick(document, sops.names(document))
158
+
159
+ const mergeLayers = (layers, decrypted) => {
160
+ const trees = decrypted
161
+ ? [...decrypted]
162
+ : documentsOf(layers).map(withoutMetadata)
163
+ const merged = layers.reduce((merged, layer) => {
164
+ const withPlain = deepMerge(merged, layer.values)
165
+ return layer.document ? deepMerge(withPlain, trees.shift()) : withPlain
166
+ }, {})
167
+ if (decrypted) return merged
168
+ for (const name of secretNamesOf(layers)) delete merged[name]
169
+ return merged
170
+ }
171
+
172
+ const pick = (values, names) => {
173
+ const picked = {}
174
+ for (const name of names) {
175
+ if (Object.hasOwn(values, name)) assign(picked, name, values[name])
176
+ }
177
+ return picked
178
+ }
179
+
180
+ const names = input => secretNamesOf(readLayers(input).layers)
181
+
182
+ const loadLayers = ({ layers, keyCommand }) =>
183
+ mergeLayers(layers, decryptDocuments(documentsOf(layers), keyCommand))
184
+
185
+ const load = input => loadLayers(readLayers(input))
186
+
187
+ const get = (input, name) => {
188
+ const layered = readLayers(input)
189
+ return secretNamesOf(layered.layers).includes(name)
190
+ ? loadLayers(layered)
191
+ : mergeLayers(layered.layers, null)
192
+ }
193
+
194
+ const loadSecrets = layered =>
195
+ pick(loadLayers(layered), secretNamesOf(layered.layers))
196
+
197
+ const isUnset = (target, name) =>
198
+ !Object.hasOwn(target, name) || target[name] === undefined
199
+
200
+ const nameOrder = layers => [
201
+ ...new Set(
202
+ layers.flatMap(layer => [
203
+ ...Object.keys(layer.values),
204
+ ...(layer.document ? sops.names(layer.document) : [])
205
+ ])
206
+ )
207
+ ]
208
+
209
+ const decryptForConfig = (
210
+ { layers, env, keyCommand },
211
+ missing,
212
+ allowKeyCommand = !DEPLOYED_ENVS.includes(env)
213
+ ) => {
214
+ try {
215
+ return decryptDocuments(documentsOf(layers), keyCommand, {
216
+ allowKeyCommand
217
+ })
218
+ } catch (error) {
219
+ if (!KEY_ERRORS.includes(error.code)) throw error
220
+ const verb = missing.length === 1 ? 'is' : 'are'
221
+ throw new OnesecretError(
222
+ `${missing.join(', ')} ${verb} not set and cannot be decrypted, ${
223
+ error.reason
224
+ }`,
225
+ 'EMISSING'
226
+ )
227
+ }
228
+ }
229
+
230
+ const isExportable = (name, value) =>
231
+ isEnvName(name) && (value === null || isEnvScalar(value))
232
+
233
+ const ENV_PARSERS = {
234
+ int: text => {
235
+ if (!/^-?\d+$/.test(text)) return undefined
236
+ const number = Number(text)
237
+ return Number.isSafeInteger(number) ? number : BigInt(text)
238
+ },
239
+ float: text => Number(text),
240
+ null: text => (text === '' ? null : undefined),
241
+ bool: text => ({ true: true, false: false }[text])
242
+ }
243
+
244
+ const envStringOf = value => (value === null ? '' : String(value))
245
+
246
+ const roundTrips = (value, text) =>
247
+ value !== undefined && envStringOf(value) === text
248
+
249
+ const typedSecret = (text, secret) => {
250
+ const parse = secret && ENV_PARSERS[secret.type]
251
+ const value = parse ? parse(text) : undefined
252
+ return roundTrips(value, text) ? value : text
253
+ }
254
+
255
+ const typedFromEnv = (name, text, merged, secret) => {
256
+ if (secret || !Object.hasOwn(merged, name)) {
257
+ return typedSecret(text, secret)
258
+ }
259
+ const value = merged[name]
260
+ return isExportable(name, value) && roundTrips(value, text) ? value : text
261
+ }
262
+
263
+ const assertScalarSecrets = (secrets, missing) => {
264
+ for (const name of missing) {
265
+ const { type, file } = secrets.get(name)
266
+ if (!isEnvName(name)) {
267
+ throw new OnesecretError(
268
+ `"${name}" in ${file} cannot be an environment variable name, so config() could never find it set`,
269
+ 'EFORMAT'
270
+ )
271
+ }
272
+ if (!EXPORTABLE_TYPES.includes(type)) {
273
+ throw new OnesecretError(
274
+ `"${name}" in ${file} has type ${type}, config() needs each secret to be a string, number, or boolean`,
275
+ 'EFORMAT'
276
+ )
277
+ }
278
+ }
279
+ }
280
+
281
+ const config = input => fill(input)
282
+
283
+ const fill = (input, { allowKeyCommand } = {}) => {
284
+ const options = optionsOf(input)
285
+ const target = options.processEnv || process.env
286
+ const skip = target[SKIP_VARIABLE] === '1'
287
+ const layered = readLayers(options)
288
+ if (!hasFiles(layered.layers)) {
289
+ if (skip) return {}
290
+ throw new OnesecretError(
291
+ `no config files for "default" or "${layered.env}" found in ${layered.dir}`,
292
+ 'EMISSING'
293
+ )
294
+ }
295
+ const secrets = secretsOf(layered.layers)
296
+ const missing = [...secrets.keys()].filter(name => isUnset(target, name))
297
+ if (!skip) assertScalarSecrets(secrets, missing)
298
+ const decrypted =
299
+ missing.length > 0 && !skip
300
+ ? decryptForConfig(layered, missing, allowKeyCommand)
301
+ : null
302
+ const merged = mergeLayers(layered.layers, decrypted)
303
+ const result = {}
304
+ for (const name of nameOrder(layered.layers)) {
305
+ if (!isUnset(target, name)) {
306
+ assign(
307
+ result,
308
+ name,
309
+ typedFromEnv(name, target[name], merged, secrets.get(name))
310
+ )
311
+ } else if (Object.hasOwn(merged, name)) {
312
+ const value = merged[name]
313
+ assign(result, name, value)
314
+ if (isExportable(name, value)) target[name] = toEnvValue(name, value)
315
+ }
316
+ }
317
+ return result
318
+ }
319
+
320
+ module.exports = {
321
+ assertEnv,
322
+ config,
323
+ decryptDocument,
324
+ fill,
325
+ get,
326
+ load,
327
+ loadSecrets,
328
+ names,
329
+ readLayers,
330
+ resolveOptions
331
+ }