onesecret 0.0.0 → 0.1.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/README.md +561 -116
- package/bin/help.txt +9 -11
- package/bin/index.js +4 -4
- package/package.json +37 -6
- package/src/accessors.js +70 -0
- package/src/cli.js +105 -43
- package/src/index.js +2 -6
- package/src/secrets.js +76 -20
- package/src/store.js +6 -2
- package/src/write.js +18 -12
- package/src/deploy.js +0 -113
- package/src/fly.js +0 -109
package/README.md
CHANGED
|
@@ -6,123 +6,210 @@
|
|
|
6
6
|
|
|
7
7
|
> Keep just one secret to decrypt the rest.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Put your app's passwords, tokens, and API keys in your repository, encrypted, right next to your code. One key unlocks all of them, and your servers don't even need that key: when you deploy, you hand the values to your hosting platform, and your app finds them already set.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
11
|
+
## Why onesecret
|
|
12
|
+
|
|
13
|
+
- **Your secrets are never exposed in your repository.** They are always encrypted there, so anyone who sees the code, a fork, or the git history sees nothing usable. In production your hosting platform holds the values as environment variables, the standard way to run secrets, and the key never reaches your servers. If a server leaks, it leaks only its own values.
|
|
14
|
+
- **One key unlocks everything.** Every secret, in every environment, opens with the same key. Onboarding a teammate or a CI job means sharing that one key, and nothing else.
|
|
15
|
+
- **Works on any platform.** Your app reads plain environment variables, which every host supports: Vercel, Fly.io, Kubernetes, AWS, Docker, or your own server. There is no secrets service to sign up for, pay for, or migrate away from.
|
|
16
|
+
- **Versioned with your code.** Secrets and plain settings live side by side in one `config/` folder, so a change ships in the same commit and pull request as the code that needs it, with history and review.
|
|
16
17
|
|
|
17
18
|
## Install
|
|
18
19
|
|
|
20
|
+
Requires Node.js 24 or newer.
|
|
21
|
+
|
|
19
22
|
```bash
|
|
20
23
|
$ npm install onesecret
|
|
21
24
|
```
|
|
22
25
|
|
|
23
26
|
## Quick start
|
|
24
27
|
|
|
28
|
+
**1. Create your key.**
|
|
29
|
+
|
|
25
30
|
```bash
|
|
26
31
|
$ npx onesecret init
|
|
32
|
+
|
|
27
33
|
public key age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
|
28
34
|
private key AGE-SECRET-KEY-1…
|
|
29
35
|
secrets config/default.enc.yml
|
|
30
36
|
|
|
31
|
-
The private key is
|
|
32
|
-
|
|
37
|
+
The private key is the only secret to keep: it decrypts all the others.
|
|
38
|
+
It is shown only now and saved nowhere, so store it safely (and as
|
|
39
|
+
SOPS_AGE_KEY in CI). To use it in this shell:
|
|
33
40
|
|
|
34
|
-
|
|
41
|
+
export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
|
|
42
|
+
```
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
The private key is the only secret you need to keep: it decrypts all the others. It is shown only once and saved nowhere, so store it somewhere safe and never commit it. You don't need to keep the public key, since it is already written into the encrypted file.
|
|
45
|
+
|
|
46
|
+
**2. Add your settings and secrets, then commit them.**
|
|
47
|
+
|
|
48
|
+
Everything lives in the `config/` folder. Plain settings go in `.yml` files, secrets go encrypted in `.enc.yml` files, and each file name is an environment. This step ends with:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
config/default.yml ← PORT, for every environment
|
|
52
|
+
config/default.enc.yml ← DATABASE_URL, encrypted, for every environment
|
|
53
|
+
config/production.enc.yml ← DATABASE_URL, encrypted, for production only
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`default` is the base. An environment such as `production` adds its own values on top and wins where both have the same name.
|
|
57
|
+
|
|
58
|
+
Run the `export` line that `init` printed, then:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
$ echo 'PORT: 3000' > config/default.yml
|
|
62
|
+
$ npx onesecret set DATABASE_URL
|
|
63
|
+
$ npx onesecret set --env production DATABASE_URL
|
|
64
|
+
$ git add config && git commit -m "Add config"
|
|
40
65
|
```
|
|
41
66
|
|
|
42
|
-
`
|
|
67
|
+
`set` opens your editor (`$VISUAL` or `$EDITOR`, otherwise `vi`) for the value, so it never ends up in your shell history. In a script, pipe it in instead: `printf %s "$VALUE" | npx onesecret set NAME`.
|
|
43
68
|
|
|
44
|
-
|
|
69
|
+
**3. Load them when your app starts.**
|
|
45
70
|
|
|
46
|
-
|
|
71
|
+
`config()` returns your settings and secrets as one object:
|
|
47
72
|
|
|
48
73
|
```js
|
|
49
|
-
const
|
|
74
|
+
const onesecret = require('onesecret')
|
|
50
75
|
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
// it reads process.env by default
|
|
77
|
+
const config = onesecret.config()
|
|
78
|
+
|
|
79
|
+
// Read any setting or secret by name
|
|
80
|
+
config.PORT // 3000
|
|
81
|
+
config.DATABASE_URL // 'postgres://…'
|
|
82
|
+
|
|
83
|
+
// Optional values: fall back to a default when unset
|
|
84
|
+
config.get('LOG_LEVEL', 'info') // 'info'
|
|
85
|
+
config.get('database.pool', 10) // a dot path reaches nested settings
|
|
86
|
+
|
|
87
|
+
// Check before using something optional
|
|
88
|
+
if (config.has('SENTRY_DSN')) {
|
|
89
|
+
// set up error tracking
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Values your app cannot run without: fail at startup, naming what is missing
|
|
93
|
+
config.require('DATABASE_URL') // 'postgres://…'
|
|
94
|
+
const { DATABASE_URL, PORT } = config.required // the same, for several names at once
|
|
95
|
+
|
|
96
|
+
// OnesecretError: onesecret: "DATABASE_URL" is required but not set in the "production" config
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If your code already reads `process.env`, as with dotenv, call `env()` instead. It puts every value into `process.env`:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
require('onesecret').env()
|
|
103
|
+
|
|
104
|
+
process.env.DATABASE_URL // 'postgres://…'
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
onesecret decrypts with the key you exported. Without a key, it stops and tells you which secret it could not read:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
onesecret: DATABASE_URL is not set and cannot be decrypted, no age key found: …
|
|
53
111
|
```
|
|
54
112
|
|
|
55
|
-
|
|
113
|
+
To see it all working, run the [example app](https://github.com/Kikobeats/onesecret/tree/master/example).
|
|
56
114
|
|
|
57
|
-
|
|
115
|
+
## Everyday tasks
|
|
116
|
+
|
|
117
|
+
### Add or change a secret
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
$ npx onesecret set API_TOKEN
|
|
121
|
+
$ npx onesecret set --env production API_TOKEN
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
To change several at once, open the whole file. You edit it decrypted, and it is encrypted again when you save:
|
|
58
125
|
|
|
59
126
|
```bash
|
|
60
127
|
$ npx onesecret edit config/production.enc.yml
|
|
61
128
|
```
|
|
62
129
|
|
|
63
|
-
|
|
130
|
+
### See what's stored
|
|
131
|
+
|
|
132
|
+
List the secret names (no key needed), or print one value:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
$ npx onesecret ls
|
|
136
|
+
$ npx onesecret get DATABASE_URL
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Run a command with your secrets
|
|
140
|
+
|
|
141
|
+
Any command, without changing its code:
|
|
64
142
|
|
|
65
143
|
```bash
|
|
66
144
|
$ npx onesecret exec -- node server.js
|
|
67
145
|
```
|
|
68
146
|
|
|
69
|
-
|
|
147
|
+
### Run your tests
|
|
70
148
|
|
|
71
|
-
|
|
149
|
+
Most tests don't need real credentials. With `ONESECRET_SKIP=1`, nothing is decrypted: your plain settings still load, and secrets stay unset.
|
|
72
150
|
|
|
151
|
+
```bash
|
|
152
|
+
$ ONESECRET_SKIP=1 npm test
|
|
73
153
|
```
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
154
|
+
|
|
155
|
+
Tests that do need credentials can run under `onesecret exec -- npm test`, which passes the values, but never the key, to the test runner.
|
|
156
|
+
|
|
157
|
+
### Join a project
|
|
158
|
+
|
|
159
|
+
Get the key from whoever set up the project and export it. The encrypted files already say which key opens them, so there is nothing else to set up.
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
$ export SOPS_AGE_KEY=AGE-SECRET-KEY-1…
|
|
163
|
+
$ npx onesecret set --env staging DATABASE_URL
|
|
78
164
|
```
|
|
79
165
|
|
|
80
|
-
|
|
166
|
+
### Move an existing project to onesecret
|
|
81
167
|
|
|
82
|
-
|
|
168
|
+
Give your coding agent this prompt:
|
|
83
169
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
170
|
+
```
|
|
171
|
+
Migrate this project to onesecret: run npx onesecret init (keep the printed key out of the repo), move plain settings to config/<env>.yml, encrypt each secret by piping its value into npx onesecret set --env <env> NAME, replace dotenv with require('onesecret').env(), and verify every value matches the old .env without printing it.
|
|
172
|
+
```
|
|
87
173
|
|
|
88
|
-
|
|
174
|
+
## Deploy
|
|
89
175
|
|
|
90
|
-
|
|
176
|
+
In production, your deploy script sends the secret values to your hosting platform (Vercel, Fly.io, Kubernetes, AWS, and so on), which sets them as environment variables. At startup, your app finds them already set and decrypts nothing.
|
|
91
177
|
|
|
92
|
-
|
|
178
|
+
Ship the `config/` folder with your app. It holds your plain settings and the list of secret names, so the app can still tell you if one is missing.
|
|
93
179
|
|
|
94
|
-
|
|
95
|
-
- `SOPS_AGE_KEY_FILE`, a path to a keys file.
|
|
96
|
-
- The sops keys file: `$XDG_CONFIG_HOME/sops/age/keys.txt`, `~/Library/Application Support/sops/age/keys.txt` on macOS, and `~/.config/sops/age/keys.txt` on any system.
|
|
180
|
+
Pick your platform below. Most recipes use [jq](https://jqlang.org) to handle the values.
|
|
97
181
|
|
|
98
|
-
|
|
182
|
+
<details>
|
|
183
|
+
<summary data-logo="recipes"><strong>How the recipes work</strong></summary>
|
|
99
184
|
|
|
100
|
-
|
|
185
|
+
Deploys push only the secrets, with their merged values. Plain settings ship inside the image with the `config/` directory.
|
|
101
186
|
|
|
102
|
-
`
|
|
187
|
+
Keep the `config/` directory in your image: `config()` reads the plain settings from it and the secret names to check that the platform injected every secret, and fails at boot if one is missing.
|
|
103
188
|
|
|
104
|
-
|
|
189
|
+
Every provider is fed with three primitives:
|
|
105
190
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
"keyCommand": "glab variable get SOPS_AGE_KEY -R my-group/my-project"
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
```
|
|
191
|
+
- `onesecret dotenv --json` prints every secret as one JSON object, for APIs and CLIs that take JSON.
|
|
192
|
+
- `onesecret ls` prints the secret names, and `onesecret get NAME` prints one value. When piped, `get` prints the exact bytes with no trailing newline.
|
|
193
|
+
- `onesecret exec -- <command>` runs a command with the secrets in its environment.
|
|
113
194
|
|
|
114
|
-
`
|
|
195
|
+
Always pass `--env` to them in a deploy script: without it they read `NODE_ENV`, then `development`. With `--env`, `dotenv` and `ls` refuse a layer that has no plain or encrypted file, so a typo such as `--env prodution` or a wrong `--dir` fails instead of pushing the default layer. To push the default layer alone, pass `--env default`.
|
|
115
196
|
|
|
116
|
-
|
|
197
|
+
Each recipe below decrypts once into a `secrets` variable and pushes only when that worked, so a missing key sends nothing instead of empty values. In a script, start with `set -euo pipefail`, so a failed decrypt also stops the deploy that follows.
|
|
117
198
|
|
|
118
|
-
|
|
199
|
+
</details>
|
|
119
200
|
|
|
120
|
-
|
|
201
|
+
<details>
|
|
202
|
+
<summary data-logo="kubernetes"><strong>Kubernetes</strong></summary>
|
|
121
203
|
|
|
122
|
-
|
|
204
|
+
Apply the secrets as a Secret before `helm upgrade`, then pass their checksum to the chart so pods roll when a value changes:
|
|
123
205
|
|
|
124
206
|
```bash
|
|
125
|
-
$
|
|
207
|
+
$ secrets=$(npx onesecret dotenv --env production --json) &&
|
|
208
|
+
printf %s "$secrets" | jq '{apiVersion: "v1", kind: "Secret", type: "Opaque",
|
|
209
|
+
metadata: {name: "api-env", labels: {"app.kubernetes.io/managed-by": "onesecret"}},
|
|
210
|
+
data: map_values(@base64)}' |
|
|
211
|
+
kubectl apply --namespace api --context prod -f -
|
|
212
|
+
$ checksum=$(printf %s "$secrets" | jq -S -c . | shasum -a 256 | cut -d' ' -f1)
|
|
126
213
|
$ helm upgrade api ./chart --set-string envSecretChecksum=$checksum
|
|
127
214
|
```
|
|
128
215
|
|
|
@@ -141,20 +228,225 @@ spec:
|
|
|
141
228
|
name: api-env
|
|
142
229
|
```
|
|
143
230
|
|
|
144
|
-
Apply the Secret before every install, including one-off installs such as a canary, because a pod that reads a Secret that does not exist yet never becomes ready.
|
|
231
|
+
Apply the Secret before every install, including one-off installs such as a canary, because a pod that reads a Secret that does not exist yet never becomes ready.
|
|
232
|
+
|
|
233
|
+
The checksum annotation matters too: when only a secret value changes, nothing else in the pod template changes, so without it Kubernetes keeps the old pods running with the old values.
|
|
234
|
+
|
|
235
|
+
- `kubectl apply` replaces the whole `data`, so a secret that moved to a plain file disappears from the Secret.
|
|
236
|
+
- Do not use `kubectl create secret --from-env-file` with `onesecret dotenv`: kubectl keeps quotes as part of the value and cannot read multi-line values.
|
|
145
237
|
|
|
146
|
-
|
|
238
|
+
</details>
|
|
239
|
+
|
|
240
|
+
<details>
|
|
241
|
+
<summary data-logo="flyio"><strong>Fly.io</strong></summary>
|
|
147
242
|
|
|
148
243
|
```bash
|
|
149
|
-
$ npx onesecret
|
|
244
|
+
$ secrets=$(npx onesecret dotenv --env production --json) &&
|
|
245
|
+
printf %s "$secrets" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"' | xargs -0 fly secrets set --stage --app my-app
|
|
150
246
|
$ fly deploy --app my-app
|
|
151
247
|
```
|
|
152
248
|
|
|
153
|
-
`
|
|
249
|
+
`fly secrets set` splits each argument at the first `=` and keeps the rest as it is, so `#`, quotes, and multi-line values such as PEM keys arrive unchanged. `--stage` applies them with the next deploy; drop it to restart the machines with them right away.
|
|
250
|
+
|
|
251
|
+
- The values are arguments of `fly` while it runs, so they are visible in the process list of that machine.
|
|
252
|
+
- flyctl reads a value of exactly `-` from stdin instead.
|
|
253
|
+
- Keep secret names out of the `[env]` section of `fly.toml`, since those values would be stored as plain configuration.
|
|
254
|
+
- `fly secrets set` adds and updates but never removes, so when a secret moves to a plain file, run `fly secrets unset NAME`, or the old secret keeps overriding the file.
|
|
255
|
+
|
|
256
|
+
</details>
|
|
257
|
+
|
|
258
|
+
<details>
|
|
259
|
+
<summary data-logo="vercel"><strong>Vercel</strong></summary>
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
$ secrets=$(npx onesecret dotenv --env production --json)
|
|
263
|
+
$ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
|
|
264
|
+
printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' |
|
|
265
|
+
vercel env add "$name" --environment production --sensitive --force --yes
|
|
266
|
+
done
|
|
267
|
+
$ vercel deploy --prod
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- New values apply only to new deployments.
|
|
271
|
+
- `vercel env add` adds and overwrites but never removes: run `vercel env rm NAME --environment production --yes` when a secret moves to a plain file. It cannot read an empty value from stdin.
|
|
272
|
+
- File tracing cannot see the `config/` directory, so include it in the function bundle, or `config()` fails at boot. In `vercel.json`: `{ "functions": { "api/**/*.js": { "includeFiles": "config/**" } } }`. With Next.js, use `outputFileTracingIncludes: { '/*': ['./config/**/*'] }` in `next.config.js`.
|
|
273
|
+
|
|
274
|
+
</details>
|
|
275
|
+
|
|
276
|
+
<details>
|
|
277
|
+
<summary data-logo="netlify"><strong>Netlify</strong></summary>
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
$ secrets=$(npx onesecret dotenv --env production --json)
|
|
281
|
+
$ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
|
|
282
|
+
netlify env:set --context production --secret --force -- "$name" "$(printf %s "$secrets" | jq -r --arg name "$name" '.[$name]')"
|
|
283
|
+
done
|
|
284
|
+
$ netlify deploy --prod
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- `env:set` takes the value as an argument, so `$(...)` drops a trailing newline that belongs to the value. Keep the `--` and put the flags before it: a PEM key starts with `-`.
|
|
288
|
+
- Do not use `netlify env:import`: it writes every variable to all contexts without the secret flag, and prints the values.
|
|
289
|
+
- `env:set` adds and updates but never removes: run `netlify env:unset NAME --context production`. New values apply to the next deploy.
|
|
290
|
+
- Include the config files in the functions bundle, in `netlify.toml`: `[functions]` with `included_files = ["config/**"]`.
|
|
291
|
+
|
|
292
|
+
</details>
|
|
293
|
+
|
|
294
|
+
<details>
|
|
295
|
+
<summary data-logo="cloudflare-workers"><strong>Cloudflare Workers</strong></summary>
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
$ secrets=$(npx onesecret dotenv --env production --json) &&
|
|
299
|
+
printf %s "$secrets" | npx wrangler secret bulk --name my-worker
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- `wrangler secret bulk` creates and deploys a new version of the Worker right away. It adds and updates but never removes: run `wrangler secret delete NAME`. It takes at most 100 secrets per call, and a Worker holds 128 variables in total on the Paid plan, 64 on Free.
|
|
303
|
+
- A Worker has no project directory at runtime. Attach the config files as modules and point `config()` at them:
|
|
304
|
+
|
|
305
|
+
```toml
|
|
306
|
+
# wrangler.toml
|
|
307
|
+
compatibility_date = "2025-10-01"
|
|
308
|
+
compatibility_flags = ["nodejs_compat"]
|
|
309
|
+
base_dir = "."
|
|
310
|
+
find_additional_modules = true
|
|
311
|
+
rules = [{ type = "Text", globs = ["config/**/*.yml"], fallthrough = true }]
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
```js
|
|
315
|
+
const config = require('onesecret').config({ env: 'production', cwd: '/bundle' })
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- `package.json` is not in the bundle, so pass `dir` and `keyCommand` in code. The Workers runtime reports Node.js 22: onesecret ran there in `wrangler dev`, but it is below the supported `engines` range.
|
|
319
|
+
|
|
320
|
+
</details>
|
|
321
|
+
|
|
322
|
+
<details>
|
|
323
|
+
<summary data-logo="railway"><strong>Railway</strong></summary>
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
$ secrets=$(npx onesecret dotenv --env production --json)
|
|
327
|
+
$ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
|
|
328
|
+
printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' |
|
|
329
|
+
railway variable set "$name" --stdin --service api --environment production --skip-deploys
|
|
330
|
+
done
|
|
331
|
+
$ railway redeploy --service api --environment production --yes
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
- `--stdin` strips trailing newlines from the value and refuses an empty one.
|
|
335
|
+
- Railway reads `${{ NAME }}` in a value as a reference to another variable.
|
|
336
|
+
- Do not paste `onesecret dotenv` into `railway variable edit`: it splits multi-line values, keeps backticks as part of the value, and deletes every variable missing from the file.
|
|
337
|
+
- `variable set` adds and updates but never removes: run `railway variable delete NAME`.
|
|
338
|
+
|
|
339
|
+
</details>
|
|
340
|
+
|
|
341
|
+
<details>
|
|
342
|
+
<summary data-logo="render"><strong>Render</strong></summary>
|
|
343
|
+
|
|
344
|
+
The Render CLI cannot set variables on an existing service, so the loop calls the API, one key at a time:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
$ secrets=$(npx onesecret dotenv --env production --json)
|
|
348
|
+
$ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
|
|
349
|
+
printf %s "$secrets" | jq --arg name "$name" '{value: .[$name]}' |
|
|
350
|
+
curl -fsS -o /dev/null -X PUT "https://api.render.com/v1/services/$RENDER_SERVICE_ID/env-vars/$name" \
|
|
351
|
+
-H "Authorization: Bearer $RENDER_API_KEY" -H 'Content-Type: application/json' --data-binary @-
|
|
352
|
+
done
|
|
353
|
+
$ render deploys create "$RENDER_SERVICE_ID" --wait --confirm
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- Each call adds or updates one variable set on the service, not in a linked environment group.
|
|
357
|
+
- Do not use the bulk `PUT /env-vars` endpoint: it replaces every variable, removing the plain ones set in the dashboard.
|
|
358
|
+
|
|
359
|
+
</details>
|
|
360
|
+
|
|
361
|
+
<details>
|
|
362
|
+
<summary data-logo="aws"><strong>AWS</strong></summary>
|
|
363
|
+
|
|
364
|
+
On ECS, keep every secret of an environment in one Secrets Manager secret as JSON, and reference each key from the task definition:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
$ secrets=$(npx onesecret dotenv --env production --json) &&
|
|
368
|
+
printf %s "$secrets" | aws secretsmanager put-secret-value --secret-id my-app/production --secret-string file:///dev/stdin
|
|
369
|
+
$ arn=$(aws secretsmanager describe-secret --secret-id my-app/production --query ARN --output text)
|
|
370
|
+
$ npx onesecret ls --env production | jq -Rn --arg arn "$arn" '[inputs | {name: ., valueFrom: "\($arn):\(.)::"}]'
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Put the printed list in `containerDefinitions[].secrets`, register the task definition, then roll the service, since tasks read secrets only when they start:
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
$ aws ecs update-service --cluster prod --service my-app --force-new-deployment
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- The first time, run `create-secret --name my-app/production` in place of `put-secret-value --secret-id my-app/production`.
|
|
380
|
+
- `put-secret-value` replaces the whole JSON, so a removed secret disappears. Generate the task definition list again whenever the names change. The task execution role needs `secretsmanager:GetSecretValue`, and the JSON is limited to 64 KB.
|
|
381
|
+
- Do not use the task definition's `environmentFiles`: ECS keeps quotes as part of the value and cannot read multi-line values.
|
|
382
|
+
- On Lambda, `secrets=$(npx onesecret dotenv --env production --json) && printf %s "$secrets" | jq '{Variables: .}' | aws lambda update-function-configuration --function-name my-fn --environment file:///dev/stdin` sets them, but it replaces every variable of the function, and all of them together are limited to 4 KB. Ship `config/` inside the deployment package.
|
|
154
383
|
|
|
155
|
-
|
|
384
|
+
</details>
|
|
156
385
|
|
|
157
|
-
|
|
386
|
+
<details>
|
|
387
|
+
<summary data-logo="google-cloud-run"><strong>Google Cloud Run</strong></summary>
|
|
388
|
+
|
|
389
|
+
Keep each secret in Secret Manager and mount them all as variables. Secret names are shared by the whole Google Cloud project, so prefix them with the app and the environment:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
$ secrets=$(npx onesecret dotenv --env production --json)
|
|
393
|
+
$ for name in $(printf %s "$secrets" | jq -r 'keys[]'); do
|
|
394
|
+
id="my-app-production-$name"
|
|
395
|
+
gcloud secrets describe "$id" >/dev/null 2>&1 || gcloud secrets create "$id" --replication-policy=automatic
|
|
396
|
+
printf %s "$secrets" | jq -j --arg name "$name" '.[$name]' | gcloud secrets versions add "$id" --data-file=-
|
|
397
|
+
done
|
|
398
|
+
$ gcloud run deploy my-app --source . --set-secrets="$(npx onesecret ls --env production | sed 's/.*/&=my-app-production-&:latest/' | paste -sd, -)"
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
- `--set-secrets` replaces the secrets of the previous revision. Values are read when an instance starts, so deploy after every push. The service account needs `roles/secretmanager.secretAccessor`.
|
|
402
|
+
- `--env-vars-file` stores values as plain configuration instead. If you use it, pass `onesecret dotenv --json > env.json`, never a `.env` file: gcloud reads those with python-dotenv, which expands `${NAME}` and breaks backtick quoting.
|
|
403
|
+
- With `--source`, keep `config/` out of `.gcloudignore`.
|
|
404
|
+
|
|
405
|
+
</details>
|
|
406
|
+
|
|
407
|
+
<details>
|
|
408
|
+
<summary data-logo="docker"><strong>Docker</strong></summary>
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
$ npx onesecret exec --env production -- docker run $(npx onesecret ls --env production | sed 's/^/-e /') my-image
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`-e NAME` without a value copies `NAME` from the environment `onesecret exec` builds. The unquoted `$(...)` splits into one `-e NAME` pair per secret, which is safe because secret names are valid variable names. With Compose, list the names without values under `environment:`, such as `- DATABASE_URL`, then run `npx onesecret exec --env production -- docker compose up -d`.
|
|
415
|
+
|
|
416
|
+
- Do not pass `onesecret dotenv` output to `docker run --env-file` or Compose `env_file`. Docker keeps quotes as part of the value and fails on multi-line values, and Compose expands `$NAME` and rejects backticks.
|
|
417
|
+
- Copy the config files into the image, with `COPY config ./config` on a `node:24` base image.
|
|
418
|
+
|
|
419
|
+
</details>
|
|
420
|
+
|
|
421
|
+
<details>
|
|
422
|
+
<summary data-logo="github-actions"><strong>GitHub Actions</strong></summary>
|
|
423
|
+
|
|
424
|
+
A job that deploys needs a single secret: the age key. onesecret decrypts the rest in the job.
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
$ printf %s "$SOPS_AGE_KEY" | gh secret set SOPS_AGE_KEY --env production
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
```yaml
|
|
431
|
+
jobs:
|
|
432
|
+
deploy:
|
|
433
|
+
runs-on: ubuntu-latest
|
|
434
|
+
environment: production
|
|
435
|
+
steps:
|
|
436
|
+
- uses: actions/checkout@v4
|
|
437
|
+
- run: ./deploy.sh
|
|
438
|
+
env:
|
|
439
|
+
SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The job must declare `environment: production`: GitHub gives an environment's secrets only to jobs that reference it.
|
|
443
|
+
|
|
444
|
+
Do not import `onesecret dotenv` with `gh secret set --env-file`: gh parses it with godotenv, which expands `$NAME` and rejects backticks.
|
|
445
|
+
|
|
446
|
+
</details>
|
|
447
|
+
|
|
448
|
+
<details>
|
|
449
|
+
<summary data-logo="anywhere"><strong>Anywhere else</strong></summary>
|
|
158
450
|
|
|
159
451
|
```bash
|
|
160
452
|
$ npx onesecret dotenv --env production > .env.production
|
|
@@ -162,19 +454,103 @@ $ npx onesecret dotenv --env production > .env.production
|
|
|
162
454
|
|
|
163
455
|
Values are quoted only when needed, and every line is checked against both Node.js's own `.env` parser and the `dotenv` package before it is printed, so `#`, spaces, quotes, backslashes, and newlines read back unchanged with `node --env-file` or `dotenv`. A value that no quoting can carry through both, such as one starting with `'` and ending with `\`, is refused instead of corrupted.
|
|
164
456
|
|
|
165
|
-
|
|
457
|
+
Other `.env` parsers differ, as the providers above show. When a tool takes JSON, prefer `onesecret dotenv --json`, which carries every value unchanged, as a string.
|
|
458
|
+
|
|
459
|
+
</details>
|
|
460
|
+
|
|
461
|
+
## How it works
|
|
462
|
+
|
|
463
|
+
### Your config is a folder of files
|
|
464
|
+
|
|
465
|
+
Each environment can have two files, one for plain settings and one for secrets. Every file is optional.
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
config/default.yml ← settings for every environment
|
|
469
|
+
config/default.enc.yml ← secrets for every environment
|
|
470
|
+
config/production.yml ← settings for production only
|
|
471
|
+
config/production.enc.yml ← secrets for production only
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
onesecret reads them in that order and merges them, so a later file wins. Nested settings are merged key by key, so `production.yml` can change `database.pool` and keep the rest of `database` from `default.yml`.
|
|
475
|
+
|
|
476
|
+
In the encrypted files, only the values are encrypted. The names stay readable, which is how onesecret knows what should exist without the key, and how `npx onesecret ls` lists them.
|
|
477
|
+
|
|
478
|
+
The encrypted files are standard [SOPS](https://github.com/getsops/sops) files locked with an [age](https://age-encryption.org) key, so the `sops` CLI and other SOPS tools can read them too. You don't need to install either: onesecret does the encryption itself.
|
|
479
|
+
|
|
480
|
+
### What happens when your app starts
|
|
481
|
+
|
|
482
|
+
`config()` and `env()` look at the secret names, then at what is already in the environment:
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
Is every secret already set in the environment?
|
|
486
|
+
├─ yes → use those values, decrypt nothing ← production
|
|
487
|
+
└─ no → is there a key?
|
|
488
|
+
├─ yes → decrypt only the missing ones ← development, CI
|
|
489
|
+
└─ no → stop, naming each missing secret
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
- A value already in the environment always wins, for plain settings too. An empty string counts as set.
|
|
493
|
+
- To fill in missing secrets, onesecret decrypts every encrypted file of that environment, so the key must open all of them, even when only one holds a missing secret.
|
|
494
|
+
- `config()` never changes `process.env`. `env()` changes nothing but `process.env`, or `options.processEnv` when given.
|
|
495
|
+
|
|
496
|
+
### Where the key comes from
|
|
497
|
+
|
|
498
|
+
onesecret looks for the key in these places and tries every key it finds:
|
|
499
|
+
|
|
500
|
+
1. `SOPS_AGE_KEY`, the key itself. This is the usual choice in CI.
|
|
501
|
+
2. `SOPS_AGE_KEY_FILE`, a path to a keys file.
|
|
502
|
+
3. The keys file the `sops` CLI uses: `$XDG_CONFIG_HOME/sops/age/keys.txt`, `~/Library/Application Support/sops/age/keys.txt` on macOS, or `~/.config/sops/age/keys.txt`.
|
|
503
|
+
|
|
504
|
+
If none of them opens the file, it runs `SOPS_AGE_KEY_CMD` or the `keyCommand` setting.
|
|
505
|
+
|
|
506
|
+
### Fetch the key with a command
|
|
507
|
+
|
|
508
|
+
With `keyCommand`, nobody keeps the key on disk. It is a command that prints the key, for example from a GitLab CI variable (`glab`) or from 1Password (`op`). Set it in `package.json`:
|
|
509
|
+
|
|
510
|
+
```json
|
|
511
|
+
{
|
|
512
|
+
"onesecret": {
|
|
513
|
+
"keyCommand": "glab variable get SOPS_AGE_KEY -R my-group/my-project"
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
It runs at most once per process, whether it works or not. `config()` and `env()` never run it in `production` or `staging`, so a missing secret there fails the boot instead of fetching a key. The CLI (`exec`, `get`, `dotenv`) runs it in any environment, since you call it on purpose.
|
|
519
|
+
|
|
520
|
+
Because it comes from `package.json`, `keyCommand` is as trusted as an npm script: only run onesecret in repositories you trust.
|
|
166
521
|
|
|
167
|
-
|
|
522
|
+
### Keeping the key away from your app
|
|
168
523
|
|
|
169
|
-
|
|
524
|
+
`exec` removes `SOPS_AGE_KEY`, `SOPS_AGE_KEY_FILE`, and `SOPS_AGE_KEY_CMD` before it starts your command, so the command gets the secrets but not the key.
|
|
525
|
+
|
|
526
|
+
Two things still see it: the keys file stays readable on disk to anything running as you, and `edit` and `keyCommand` run with your full environment.
|
|
527
|
+
|
|
528
|
+
### New environments use the same key
|
|
529
|
+
|
|
530
|
+
A new encrypted file, such as `config/staging.enc.yml`, is locked for the same key as `config/default.enc.yml` in its folder, with the same encryption rules. If the folder has no `default.enc.yml` yet, it is locked for the key you exported in `SOPS_AGE_KEY`.
|
|
531
|
+
|
|
532
|
+
### Separate keys per person
|
|
533
|
+
|
|
534
|
+
One shared key needs no setup. To give each person or CI job its own key, list their public keys in a `.sops.yaml` rule, the format the `sops` CLI uses, then encrypt each environment again:
|
|
535
|
+
|
|
536
|
+
```yaml
|
|
537
|
+
# .sops.yaml
|
|
538
|
+
creation_rules:
|
|
539
|
+
- path_regex: ^config/
|
|
540
|
+
age: age1…alice,age1…bob,age1…ci
|
|
541
|
+
```
|
|
170
542
|
|
|
171
543
|
```bash
|
|
172
|
-
$
|
|
544
|
+
$ npx onesecret rekey
|
|
545
|
+
$ npx onesecret rekey --env production
|
|
173
546
|
```
|
|
174
547
|
|
|
175
|
-
|
|
548
|
+
When a `.sops.yaml` rule matches a file, its keys replace the ones the file already lists. Removing someone stops them from reading future versions, but not the versions they already had, so change the credentials themselves too.
|
|
549
|
+
|
|
550
|
+
## Reference
|
|
176
551
|
|
|
177
|
-
|
|
552
|
+
<details>
|
|
553
|
+
<summary><strong>CLI</strong></summary>
|
|
178
554
|
|
|
179
555
|
```
|
|
180
556
|
$ onesecret --help
|
|
@@ -185,66 +561,62 @@ Usage
|
|
|
185
561
|
Commands
|
|
186
562
|
init Write config/default.enc.yml for a new age key it prints once,
|
|
187
563
|
or for SOPS_AGE_KEY when exported
|
|
188
|
-
set NAME
|
|
564
|
+
set NAME Encrypt a secret into a layer, typed in $EDITOR
|
|
565
|
+
(or read from stdin when piped)
|
|
189
566
|
unset NAME... Remove secrets from a layer
|
|
190
567
|
edit [file] Open an encrypted file decrypted in $EDITOR, encrypt it on save
|
|
191
568
|
(a plain .yml or .json file opens as it is)
|
|
192
569
|
rekey Encrypt a layer again with a new data key, for its recipients or a .sops.yaml rule
|
|
193
570
|
ls List secret names (no key needed)
|
|
194
571
|
get NAME Print one value, from a plain or an encrypted layer
|
|
572
|
+
(with no trailing newline when piped)
|
|
195
573
|
dotenv Print every decrypted secret as NAME=value
|
|
574
|
+
(--json prints them as one JSON object)
|
|
196
575
|
exec -- <command> Run a command with the config and secrets in its environment
|
|
197
576
|
(--env also sets its NODE_ENV)
|
|
198
|
-
kubernetes --name <name> Apply the secrets as a Kubernetes Secret, print its checksum
|
|
199
|
-
fly --app <app> Import the secrets into a Fly app, print their checksum
|
|
200
577
|
|
|
201
578
|
Options
|
|
202
579
|
--env, -e Layer to read on top of "default" (defaults to NODE_ENV, then "development").
|
|
203
580
|
Write commands (set, unset, edit, rekey) write this layer, "default" when omitted.
|
|
204
|
-
|
|
581
|
+
Passed to dotenv or ls, the layer file must exist.
|
|
205
582
|
--dir Directory holding the config files (defaults to "config")
|
|
206
583
|
--cwd Project root, where package.json and the config directory are (defaults to the current directory)
|
|
207
|
-
--
|
|
208
|
-
--context kubectl context
|
|
209
|
-
--no-stage fly: apply the secrets now instead of on the next deploy
|
|
584
|
+
--json dotenv: print a JSON object instead of NAME=value lines
|
|
210
585
|
|
|
211
586
|
Examples
|
|
212
587
|
$ onesecret init
|
|
213
|
-
$ onesecret set DATABASE_URL
|
|
214
|
-
$ onesecret set --env production
|
|
588
|
+
$ onesecret set DATABASE_URL
|
|
589
|
+
$ printf %s "$TOKEN" | onesecret set --env production API_TOKEN
|
|
215
590
|
$ onesecret edit config/production.enc.yml
|
|
216
591
|
$ onesecret exec -- node server.js
|
|
217
|
-
$ onesecret
|
|
218
|
-
$ onesecret fly --env production --app my-app
|
|
592
|
+
$ onesecret dotenv --env production --json
|
|
219
593
|
```
|
|
220
594
|
|
|
221
|
-
|
|
595
|
+
**set**
|
|
222
596
|
|
|
223
|
-
|
|
597
|
+
In a terminal, `set NAME` opens `$EDITOR` on the current value: the newline the editor adds at the end is dropped, and saving an empty file cancels. When stdin is piped, `set` waits until it closes and stores the bytes as they are, so pipe with `printf %s` rather than `echo`, which adds a newline. It refuses an empty value, text that is not UTF-8, and NUL bytes; to store an empty value, use `onesecret edit`. Values never go on the command line, so they stay out of shell history and the process list.
|
|
224
598
|
|
|
225
|
-
`
|
|
599
|
+
`set`, and `edit` of an encrypted file, only accept names that are valid environment variables, refuse `sops` (reserved for the file metadata), and refuse to write a value that the file's rules would leave in plaintext. A name ending in `_unencrypted` is stored in plaintext on purpose, as sops does.
|
|
226
600
|
|
|
227
|
-
|
|
601
|
+
**edit**
|
|
228
602
|
|
|
229
|
-
|
|
603
|
+
`edit` takes the path of the file to open, such as `config/production.enc.yml`, or edits the `--env` layer when no path is given.
|
|
230
604
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
age: age1…alice,age1…bob,age1…ci
|
|
236
|
-
```
|
|
605
|
+
- **Editor.** It opens `$VISUAL` or `$EDITOR` (then `vi`). GUI editors that return at once, such as VS Code, Cursor, Zed, or Sublime Text, get their wait flag added, so `EDITOR=code` works without `--wait`. Any other editor command must wait until the file is closed, as git requires too.
|
|
606
|
+
- **Invalid YAML.** When an edit is not valid YAML, the editor opens again with your changes, and the error never prints the decrypted content.
|
|
607
|
+
- **New files.** A path that does not exist yet is locked for the recipients of `default.enc.yml` in its folder, or else for your exported `SOPS_AGE_KEY`, and is refused before the editor opens when there is neither.
|
|
608
|
+
- **Plain files.** A plain `.yml` or `.json` path opens as it is; when it is saved invalid, the editor opens again.
|
|
237
609
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
```
|
|
610
|
+
**get**
|
|
611
|
+
|
|
612
|
+
`get` reads a plain value without the key, and prints a nested value as YAML. On a terminal it ends the value with a newline; piped, it prints the value alone, so `onesecret get NAME | some-cli --stdin` stores exactly the value. A `while read` loop over it needs `|| [ -n "$line" ]` to keep the last line.
|
|
242
613
|
|
|
243
|
-
|
|
614
|
+
</details>
|
|
244
615
|
|
|
245
|
-
|
|
616
|
+
<details>
|
|
617
|
+
<summary><strong>API: options</strong></summary>
|
|
246
618
|
|
|
247
|
-
`config
|
|
619
|
+
`config` and `env` take the env name as a string, or these options:
|
|
248
620
|
|
|
249
621
|
| Option | Default | Description |
|
|
250
622
|
| ------------ | ---------------------------------- | ----------------------------------------------------- |
|
|
@@ -252,60 +624,133 @@ When a `.sops.yaml` rule matches a file, its recipients win over the ones the fi
|
|
|
252
624
|
| `dir` | `"config"` | Directory with the config files, relative to `cwd`. |
|
|
253
625
|
| `env` | `NODE_ENV`, then `"development"` | Layer merged on top of `default`. |
|
|
254
626
|
| `keyCommand` | none | Command that prints the age key. |
|
|
255
|
-
| `processEnv` | `process.env` | `config()`
|
|
627
|
+
| `processEnv` | `process.env` | `config()` and `env()`: the environment to read, including `NODE_ENV` and `ONESECRET_SKIP`, and the one `env()` writes. |
|
|
256
628
|
|
|
257
629
|
`dir` and `keyCommand` can also be set in `package.json` under `"onesecret"`.
|
|
258
630
|
|
|
259
|
-
|
|
631
|
+
</details>
|
|
260
632
|
|
|
261
|
-
|
|
633
|
+
<details>
|
|
634
|
+
<summary><strong>API: config()</strong></summary>
|
|
635
|
+
|
|
636
|
+
Merges the layers and returns the merged object, with the values the environment already had taking their place. It never changes `process.env`. Throws an `OnesecretError` with code `EMISSING` when a secret is missing and there is no key to decrypt it.
|
|
262
637
|
|
|
263
638
|
```js
|
|
264
639
|
const config = require('onesecret').config() // NODE_ENV, then "development"
|
|
265
640
|
const staging = require('onesecret').config('staging')
|
|
266
641
|
```
|
|
267
642
|
|
|
268
|
-
|
|
643
|
+
The object also reads values through four accessors:
|
|
644
|
+
|
|
645
|
+
```js
|
|
646
|
+
config.DATABASE_URL // plain access
|
|
647
|
+
config.get('database.pool', 5) // a dot path, with a fallback when it is unset
|
|
648
|
+
config.has('FEATURE_FLAG') // true when set, even to 0, false, or ''
|
|
649
|
+
config.require('STRIPE_KEY') // the value, or throws EMISSING when it is unset
|
|
650
|
+
const { STRIPE_KEY, DATABASE_URL } = config.required // throws for an unset name
|
|
651
|
+
```
|
|
269
652
|
|
|
270
|
-
|
|
653
|
+
- A path is a dot string or an array of keys, such as `['database', 'pool']`. A string matches an exact name first, so `get('log.level')` reads a key named `log.level` before it looks into `log`. Only config values count: `has('constructor')`, `has('get')`, and `has('hosts.length')` are `false`.
|
|
654
|
+
- The accessors are not enumerable, so they stay out of `Object.keys`, spread, and `JSON.stringify`. A config value named `get`, `has`, `require`, or `required` is refused.
|
|
655
|
+
- `config.required` is only for destructuring: every name read from it is required, so `JSON.stringify(config.required)` or `await config.required` throws.
|
|
656
|
+
- They work like [miconfig](https://github.com/Kikobeats/miconfig)'s with two differences. `0`, `false`, `''`, and `null` count as set. Values are not parsed again: they keep their YAML type, and a value from the environment is a string unless it matches the file's value, so `PORT=8080` over `PORT: 3000` reads `'8080'`.
|
|
271
657
|
|
|
272
|
-
|
|
658
|
+
</details>
|
|
273
659
|
|
|
274
|
-
|
|
660
|
+
<details>
|
|
661
|
+
<summary><strong>API: env()</strong></summary>
|
|
275
662
|
|
|
276
|
-
|
|
663
|
+
Writes every value missing from `process.env` (or `options.processEnv`) into it, as a string: the same values `config()` would add on top of the environment. It follows the same rules and throws the same errors as `config()`, writes nothing when it throws, and returns nothing.
|
|
277
664
|
|
|
278
|
-
|
|
665
|
+
```js
|
|
666
|
+
require('onesecret').env()
|
|
667
|
+
```
|
|
279
668
|
|
|
280
|
-
|
|
669
|
+
</details>
|
|
281
670
|
|
|
282
|
-
|
|
671
|
+
<details>
|
|
672
|
+
<summary><strong>API: helpers</strong></summary>
|
|
283
673
|
|
|
284
|
-
|
|
674
|
+
Helpers: turn secrets into environment strings, render them as dotenv, hash them in an order-independent way, and drop the age key variables from an environment.
|
|
285
675
|
|
|
286
|
-
|
|
676
|
+
</details>
|
|
287
677
|
|
|
288
|
-
|
|
678
|
+
<details>
|
|
679
|
+
<summary><strong>Errors</strong></summary>
|
|
289
680
|
|
|
290
681
|
Every error is an `OnesecretError` with a `code`:
|
|
291
682
|
|
|
292
683
|
| Code | Meaning |
|
|
293
684
|
| ----------- | ------------------------------------------------------------------ |
|
|
294
|
-
| `EMISSING` | A secret is not in the environment and could not be decrypted.
|
|
685
|
+
| `EMISSING` | A secret is not in the environment and could not be decrypted, or `config().require` found no value. |
|
|
295
686
|
| `ENOKEY` | No age key was found. |
|
|
296
687
|
| `EKEY` | A key was found but does not open the file, or `keyCommand` failed. |
|
|
297
688
|
| `EMAC` | The file was modified without the key. |
|
|
298
|
-
| `EFORMAT` | A file cannot be read: not valid YAML or JSON, not a supported SOPS file, two files for one layer,
|
|
689
|
+
| `EFORMAT` | A file cannot be read: not valid YAML or JSON, not a supported SOPS file, two files for one layer, a secret `config()` could never find in the environment, or a config name reserved for an accessor. |
|
|
299
690
|
| `EVALUE` | A value cannot be stored or turned into an environment variable. |
|
|
300
|
-
| `EDEPLOY` | `kubectl` or `fly` failed. |
|
|
301
691
|
| `EUSAGE` | A CLI command was called the wrong way, or the editor failed. |
|
|
302
692
|
|
|
303
|
-
|
|
693
|
+
</details>
|
|
694
|
+
|
|
695
|
+
<details>
|
|
696
|
+
<summary><strong>Secrets and plain settings</strong></summary>
|
|
697
|
+
|
|
698
|
+
A name is a secret while an encrypted file contributes to its merged value. A plain file that replaces a secret from a lower layer makes the name plain config for that env; a plain mapping merged into an encrypted one keeps it a secret.
|
|
699
|
+
|
|
700
|
+
A secret must be a string, number, or boolean, since the platform injects it as a variable: a mapping or list secret missing from the environment fails with `EFORMAT`.
|
|
701
|
+
|
|
702
|
+
</details>
|
|
703
|
+
|
|
704
|
+
<details>
|
|
705
|
+
<summary><strong>Types and process.env</strong></summary>
|
|
706
|
+
|
|
707
|
+
- `env()` writes every top-level value that is a string, number, boolean, or `null` into `process.env`, as a string.
|
|
708
|
+
- `config()` returns the merged object with the YAML types kept, so `TIMEOUT: 60000` comes back as the number `60000`, while `env()` sets `process.env.TIMEOUT` to `"60000"`.
|
|
709
|
+
- Nested objects and lists, and names that are not valid variable names (such as `log-level`), are returned by `config()` but never written into `process.env`.
|
|
710
|
+
- A value the environment provides comes back with its file's type when the string matches it, so `PORT=3000` is the number `3000` again, even for a secret, whose type is plaintext in the encrypted file.
|
|
711
|
+
|
|
712
|
+
</details>
|
|
304
713
|
|
|
305
|
-
|
|
714
|
+
<details>
|
|
715
|
+
<summary><strong>File rules</strong></summary>
|
|
716
|
+
|
|
717
|
+
- **Formats.** Plain files are YAML (`.yml`, `.yaml`) or JSON (`.json`), read with [js-yaml](https://github.com/nodeca/js-yaml); an empty file is an empty layer.
|
|
718
|
+
- **One file of each kind per layer.** Each layer has at most one plain and one encrypted file, so `default.yml` next to `default.json` is an error, and so is a sops-encrypted file named like a plain one.
|
|
719
|
+
- **YAML tags.** Encrypted files accept only the standard tags `!!str`, `!!int`, `!!float`, `!!bool`, `!!null`, and `!!timestamp`, on values, so a value such as `!Passw0rd` must be quoted.
|
|
720
|
+
- **Errors never print secrets.** A YAML error names the line and column but never the text, so a broken line cannot print a secret.
|
|
721
|
+
- **Env names.** Env names are lowercase letters, digits, `.`, `_`, and `-`, without `..`, an `enc` part, or a file extension, so `NODE_ENV=Production` fails at boot instead of reading another file.
|
|
722
|
+
- **Exact file names.** Layer files must match that exact name: `Production.yml` next to a `production` layer is refused, so a layer reads the same on every filesystem.
|
|
723
|
+
- **No files at all.** `config()` throws when it finds no file at all, so an image that forgot to copy `config/`, or a process started from the wrong directory, fails at boot instead of running without its configuration.
|
|
724
|
+
|
|
725
|
+
</details>
|
|
726
|
+
|
|
727
|
+
<details>
|
|
728
|
+
<summary><strong>Key sources</strong></summary>
|
|
729
|
+
|
|
730
|
+
A source can hold several keys, one per line or separated by spaces. Post-quantum (`AGE-SECRET-KEY-PQ-`) and plugin identities are skipped, and SSH keys are not read. A source that cannot be read or parsed, such as a keys file holding an SSH key, is skipped when another source has a key; when no key opens the file, the error names the sources it skipped.
|
|
731
|
+
|
|
732
|
+
</details>
|
|
733
|
+
|
|
734
|
+
<details>
|
|
735
|
+
<summary><strong>Performance</strong></summary>
|
|
736
|
+
|
|
737
|
+
After about 9ms to load the module, the first `config()` call takes about 6ms when it decrypts two layers, against roughly 30ms for spawning `sops -d` twice, and about 3.5ms when the platform already injected every secret and nothing is decrypted. Each file is decrypted once per process, so later calls, including `env()` after `config()`, take about 1ms.
|
|
738
|
+
|
|
739
|
+
</details>
|
|
740
|
+
|
|
741
|
+
<details>
|
|
742
|
+
<summary><strong>Compatibility</strong></summary>
|
|
743
|
+
|
|
744
|
+
onesecret reads and writes SOPS files encrypted for age X25519 recipients, in YAML or JSON, and reads every value type sops writes in those formats (`str`, `int`, `float`, `bool`, `time`).
|
|
745
|
+
|
|
746
|
+
Values in encrypted files resolve the way go-yaml v3 does in sops, so `0777` is 511 and `2024-01-01` is a timestamp. Plain files are read with js-yaml instead, where `0777` is 777, YAML merge keys (`<<`) are not applied, and integers beyond 2^53 lose precision, so moving a value between a plain and an encrypted file can change it. Integers beyond 2^53 come back as `BigInt`, and timestamps as RFC 3339 strings, which are written back as strings.
|
|
747
|
+
|
|
748
|
+
Rules and `path_regex` accept Go regular expression syntax such as `(?i)`, `\A`, `\z`, and `[[:lower:]]`. The test suite checks all of this against `sops` 3.13 and `age` 1.3 in both directions, including `mac_only_encrypted` and the `*_suffix` and `*_regex` rules. Like sops, it rejects truncated authentication tags and low-order age recipients.
|
|
306
749
|
|
|
307
750
|
It does not support PGP, cloud KMS, key groups, Shamir secret sharing, comment-based rules, or YAML anchors and multiple documents in an encrypted file (`.sops.yaml` can use anchors). It refuses to write a file that also has KMS or PGP keys rather than drop them, and YAML comments are lost when it writes a file. Use the `sops` CLI for any of those.
|
|
308
751
|
|
|
752
|
+
</details>
|
|
753
|
+
|
|
309
754
|
## License
|
|
310
755
|
|
|
311
756
|
**onesecret** © [Jose Francisco 'Kiko' Verdú Gambín](https://kikobeats.com), released under the [MIT](https://github.com/kikobeats/onesecret/blob/master/LICENSE.md) License.<br>
|