@ultimat3/core 1.1.0 → 2.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/CLAUDE.md +252 -0
- package/README.md +217 -10
- package/package.json +2 -1
- package/src/actor.ts +118 -4
- package/src/app-version.ts +32 -0
- package/src/assert.ts +5 -1
- package/src/config.ts +47 -12
- package/src/context.ts +30 -3
- package/src/cursor.ts +25 -4
- package/src/env-example.ts +2 -1
- package/src/env.ts +14 -3
- package/src/environment.ts +39 -13
- package/src/error-codes.ts +13 -0
- package/src/error-render.ts +249 -0
- package/src/error-reporter-sentry.ts +175 -0
- package/src/error-reporter.ts +212 -0
- package/src/error-retry.ts +100 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +61 -0
- package/src/exports/observability.ts +161 -0
- package/src/exports/secrets.ts +71 -0
- package/src/ids.ts +49 -7
- package/src/impersonate.ts +62 -0
- package/src/index.ts +277 -113
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +178 -16
- package/src/logger.ts +99 -9
- package/src/mcp-exposure.ts +32 -0
- package/src/metrics.ts +0 -0
- package/src/otlp-metric-exporter.ts +136 -0
- package/src/otlp-span-exporter.ts +170 -0
- package/src/otlp.ts +217 -0
- package/src/read-capped.ts +47 -0
- package/src/runtime-metrics.ts +15 -0
- package/src/safe-url.ts +50 -0
- package/src/sampler.ts +126 -0
- package/src/schema-error-codes.ts +28 -0
- package/src/secrets-errors.ts +143 -0
- package/src/secrets-store.ts +173 -0
- package/src/secrets.ts +292 -0
- package/src/telemetry.ts +43 -11
- package/src/timing-safe-equal.ts +18 -0
- package/src/type-pins.ts +93 -0
- package/src/version.ts +53 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Single responsibility: the seven X_SECRETS_* conditions and the errors that carry them. Split
|
|
2
|
+
// from `secrets.ts` so the crypto module reads as crypto — and there are seven codes rather than
|
|
3
|
+
// one "secrets are broken" because each names a different thing an operator did and a different
|
|
4
|
+
// command that undoes it. No error here ever carries a key, a ciphertext or a decrypted value.
|
|
5
|
+
|
|
6
|
+
import { registerErrorCodes } from './error-codes';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** Codes `@ultimat3/core` owns for the encrypted-secrets file. */
|
|
10
|
+
export const SECRETS_ERROR_CODES = [
|
|
11
|
+
'X_SECRETS_KEY_MISSING',
|
|
12
|
+
'X_SECRETS_KEY_INVALID',
|
|
13
|
+
'X_SECRETS_KEY_MISMATCH',
|
|
14
|
+
'X_SECRETS_FILE_MISSING',
|
|
15
|
+
'X_SECRETS_FILE_INVALID',
|
|
16
|
+
'X_SECRETS_TAMPERED',
|
|
17
|
+
'X_SECRETS_PLAINTEXT_INVALID',
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export type SecretsErrorCode = (typeof SECRETS_ERROR_CODES)[number];
|
|
21
|
+
|
|
22
|
+
const SECRETS_ERROR_TITLES: Readonly<Record<SecretsErrorCode, string>> = {
|
|
23
|
+
X_SECRETS_KEY_MISSING: 'no master key for the encrypted secrets file',
|
|
24
|
+
X_SECRETS_KEY_INVALID: 'the master key is not 32 bytes of hex',
|
|
25
|
+
X_SECRETS_KEY_MISMATCH: 'the secrets file was sealed with a different master key',
|
|
26
|
+
X_SECRETS_FILE_MISSING: 'the encrypted secrets file does not exist',
|
|
27
|
+
X_SECRETS_FILE_INVALID: 'the encrypted secrets file is not a readable envelope',
|
|
28
|
+
X_SECRETS_TAMPERED: 'the secrets file failed its authentication tag',
|
|
29
|
+
X_SECRETS_PLAINTEXT_INVALID: 'the decrypted secrets are not a flat map of env values',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because these codes and the
|
|
33
|
+
// module that throws them ship together: `registerErrorCodes` is the documented way a set of codes
|
|
34
|
+
// joins the registry, and it raises `X_ERROR_CODE_DUPLICATE` if anything else ever claims one.
|
|
35
|
+
registerErrorCodes(
|
|
36
|
+
Object.fromEntries(
|
|
37
|
+
Object.entries(SECRETS_ERROR_TITLES).map(([code, title]) => [code, { title }]),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* No key in the environment and none on disk, while an encrypted file exists. Deliberately fatal
|
|
43
|
+
* rather than a warning: booting without the secrets a deploy was configured with produces an app
|
|
44
|
+
* that authenticates against nothing and reports itself healthy.
|
|
45
|
+
*/
|
|
46
|
+
export class SecretsKeyMissingError extends UltimateError {
|
|
47
|
+
constructor(input: { envVar: string; keyPath: string }) {
|
|
48
|
+
super({
|
|
49
|
+
code: 'X_SECRETS_KEY_MISSING',
|
|
50
|
+
cause: `${input.envVar} is unset and ${input.keyPath} does not exist, so nothing can open the encrypted secrets`,
|
|
51
|
+
fix: `export ${input.envVar}="$(cat ${input.keyPath})" # in a repo that has no key yet: x secrets init`,
|
|
52
|
+
meta: { keyPath: input.keyPath },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Key material that is not 64 lowercase hex characters. A truncated paste is the usual cause. */
|
|
58
|
+
export class SecretsKeyInvalidError extends UltimateError {
|
|
59
|
+
constructor(input: { at: string; found: number; expected: number }) {
|
|
60
|
+
super({
|
|
61
|
+
code: 'X_SECRETS_KEY_INVALID',
|
|
62
|
+
cause: `the master key in ${input.at} is ${input.found} character(s); an AES-256 key is ${input.expected} lowercase hex characters`,
|
|
63
|
+
fix: `export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)" # the key file holds the ${input.expected} characters verbatim, no newline of its own`,
|
|
64
|
+
meta: { at: input.at },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A well-formed key that is not the one this file was sealed with. Distinguishable from tampering
|
|
71
|
+
* only because the envelope carries a key id — a domain-separated SHA-256 of the key, which is
|
|
72
|
+
* safe to commit and is what turns "it will not decrypt" into two different instructions.
|
|
73
|
+
*/
|
|
74
|
+
export class SecretsKeyMismatchError extends UltimateError {
|
|
75
|
+
constructor(input: { at: string; keyAt: string; sealedWith: string; found: string }) {
|
|
76
|
+
super({
|
|
77
|
+
code: 'X_SECRETS_KEY_MISMATCH',
|
|
78
|
+
cause: `${input.at} was sealed with master key ${input.sealedWith} and ${input.keyAt} holds ${input.found}`,
|
|
79
|
+
fix: `git checkout -- ${input.at} # or point ULTIMATE_SECRETS_KEY at the key whose id is ${input.sealedWith}`,
|
|
80
|
+
meta: { at: input.at, sealedWith: input.sealedWith, found: input.found },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** No encrypted file at all — this repo has never run `x secrets init`. */
|
|
86
|
+
export class SecretsFileMissingError extends UltimateError {
|
|
87
|
+
constructor(input: { at: string }) {
|
|
88
|
+
super({
|
|
89
|
+
code: 'X_SECRETS_FILE_MISSING',
|
|
90
|
+
cause: `${input.at} does not exist, so this app declares no encrypted secrets`,
|
|
91
|
+
fix: 'x secrets init',
|
|
92
|
+
meta: { at: input.at },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The envelope itself will not parse: not JSON, an unknown version or algorithm, a header field
|
|
99
|
+
* that is not base64, or a body too short to hold a 16-byte tag. Separate from `X_SECRETS_TAMPERED`
|
|
100
|
+
* because nothing here got as far as authentication — this is a file a merge or an editor mangled.
|
|
101
|
+
*/
|
|
102
|
+
export class SecretsFileInvalidError extends UltimateError {
|
|
103
|
+
constructor(input: { at: string; reason: string }) {
|
|
104
|
+
super({
|
|
105
|
+
code: 'X_SECRETS_FILE_INVALID',
|
|
106
|
+
cause: `${input.at} ${input.reason}`,
|
|
107
|
+
fix: `git checkout -- ${input.at} # this file is written only by x secrets, never by hand`,
|
|
108
|
+
meta: { at: input.at },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The AES-256-GCM tag rejected the ciphertext or the header bound into it as AAD. Under a key whose
|
|
115
|
+
* id already matched, that means the committed bytes changed after they were sealed — a bad merge,
|
|
116
|
+
* a partial write, or an edit. Silent garbage is the alternative this code exists to prevent.
|
|
117
|
+
*/
|
|
118
|
+
export class SecretsTamperedError extends UltimateError {
|
|
119
|
+
constructor(input: { at: string }) {
|
|
120
|
+
super({
|
|
121
|
+
code: 'X_SECRETS_TAMPERED',
|
|
122
|
+
cause: `the AES-256-GCM authentication tag rejected ${input.at}: the ciphertext or its header changed after it was sealed`,
|
|
123
|
+
fix: `git checkout -- ${input.at} # then confirm the restored file opens: x secrets show --json`,
|
|
124
|
+
meta: { at: input.at },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decryption succeeded and the payload is not what a secrets file holds. Reachable two ways: an
|
|
131
|
+
* `x secrets edit` buffer saved as something other than a flat object, and a value that is not a
|
|
132
|
+
* non-empty string. Both are refused BEFORE sealing, so a file that opens always installs.
|
|
133
|
+
*/
|
|
134
|
+
export class SecretsPlaintextInvalidError extends UltimateError {
|
|
135
|
+
constructor(input: { at: string; reason: string }) {
|
|
136
|
+
super({
|
|
137
|
+
code: 'X_SECRETS_PLAINTEXT_INVALID',
|
|
138
|
+
cause: `the secrets for ${input.at} ${input.reason}`,
|
|
139
|
+
fix: 'x secrets edit # the buffer is one flat JSON object: {"SESSION_SECRET": "s3cr3t"} — env var names to non-empty strings',
|
|
140
|
+
meta: { at: input.at },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Single responsibility: where the two secrets files live, how the master key is found, and the
|
|
2
|
+
// ONE path from a decrypted value to a `defineEnv`-declared variable — `installSecrets()` writes
|
|
3
|
+
// each value into the process environment, under its own name, only where the real environment has
|
|
4
|
+
// nothing. `secrets.ts` owns the envelope; this owns the filesystem and `process.env`.
|
|
5
|
+
|
|
6
|
+
// `node:fs` sync, by necessity twice over: Bun.write takes no mode, and a world-readable master key
|
|
7
|
+
// is the whole failure this file exists to prevent — and `installSecrets()` runs once, at boot,
|
|
8
|
+
// before the process is serving anything, so there is nothing for an async read to overlap with.
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
// Bun exposes no path-join primitive.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { SecretValues } from './secrets';
|
|
13
|
+
import { masterKeyId, openSecrets, parseMasterKey, sealSecrets } from './secrets';
|
|
14
|
+
import { SecretsFileMissingError, SecretsKeyMissingError } from './secrets-errors';
|
|
15
|
+
|
|
16
|
+
/** Committed. Encrypted at rest, diffable, and the only file `x secrets` writes into the repo. */
|
|
17
|
+
export const SECRETS_FILE = 'secrets.enc.json';
|
|
18
|
+
/** Never committed — `x secrets init` writes the ignore rule before it writes this. */
|
|
19
|
+
export const SECRETS_KEY_FILE = '.secrets.key';
|
|
20
|
+
/** Read first, so a container gets its key from the platform's secret store and never from a file. */
|
|
21
|
+
export const SECRETS_KEY_ENV = 'ULTIMATE_SECRETS_KEY';
|
|
22
|
+
/** Owner read/write. A key file the rest of the box can read is a key file that has leaked. */
|
|
23
|
+
export const SECRETS_KEY_MODE = 0o600;
|
|
24
|
+
|
|
25
|
+
export type MasterKeySource = 'env' | 'file';
|
|
26
|
+
|
|
27
|
+
export interface MasterKeyRef {
|
|
28
|
+
readonly hex: string;
|
|
29
|
+
readonly source: MasterKeySource;
|
|
30
|
+
/** The env var name or the file path, for an error's `cause` — never the key itself. */
|
|
31
|
+
readonly at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type EnvRecord = Record<string, string | undefined>;
|
|
35
|
+
|
|
36
|
+
export const secretsPath = (root: string): string => join(root, SECRETS_FILE);
|
|
37
|
+
export const masterKeyPath = (root: string): string => join(root, SECRETS_KEY_FILE);
|
|
38
|
+
export const secretsFileExists = (root: string): boolean => existsSync(secretsPath(root));
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Env var first, key file second. That order is what makes one image run everywhere: a container
|
|
42
|
+
* is handed `ULTIMATE_SECRETS_KEY` and never ships a key file, while a checkout has the file and
|
|
43
|
+
* needs no exported variable.
|
|
44
|
+
*/
|
|
45
|
+
export function findMasterKey(
|
|
46
|
+
root: string,
|
|
47
|
+
env: EnvRecord = process.env,
|
|
48
|
+
): MasterKeyRef | undefined {
|
|
49
|
+
const fromEnv = env[SECRETS_KEY_ENV];
|
|
50
|
+
if (fromEnv !== undefined && fromEnv.trim().length > 0) {
|
|
51
|
+
return { hex: fromEnv.trim(), source: 'env', at: SECRETS_KEY_ENV };
|
|
52
|
+
}
|
|
53
|
+
const path = masterKeyPath(root);
|
|
54
|
+
if (!existsSync(path)) return undefined;
|
|
55
|
+
return { hex: readFileSync(path, 'utf-8').trim(), source: 'file', at: path };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function requireMasterKey(root: string, env: EnvRecord = process.env): MasterKeyRef {
|
|
59
|
+
const found = findMasterKey(root, env);
|
|
60
|
+
if (found !== undefined) return found;
|
|
61
|
+
throw new SecretsKeyMissingError({ envVar: SECRETS_KEY_ENV, keyPath: masterKeyPath(root) });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The key's public id. Safe to print, safe to commit — it is what names a rotation in a diff. */
|
|
65
|
+
export const masterKeyIdOf = (key: MasterKeyRef): Promise<string> =>
|
|
66
|
+
masterKeyId(parseMasterKey(key.hex, key.at));
|
|
67
|
+
|
|
68
|
+
/** Decrypt the committed file. `X_SECRETS_FILE_MISSING` when there is none — never an empty map. */
|
|
69
|
+
export async function readSecretsFile(root: string, key: MasterKeyRef): Promise<SecretValues> {
|
|
70
|
+
const path = secretsPath(root);
|
|
71
|
+
if (!existsSync(path)) throw new SecretsFileMissingError({ at: path });
|
|
72
|
+
return openSecrets(readFileSync(path, 'utf-8'), key.hex, { file: path, key: key.at });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Seal and commit. The only writer of `secrets.enc.json`; plaintext never reaches this path. */
|
|
76
|
+
export async function writeSecretsFile(
|
|
77
|
+
root: string,
|
|
78
|
+
values: SecretValues,
|
|
79
|
+
key: MasterKeyRef,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
const path = secretsPath(root);
|
|
82
|
+
await Bun.write(path, await sealSecrets(values, key.hex, { file: path, key: key.at }));
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write the master key at 0600. Callers must have made the ignore rule true first. */
|
|
87
|
+
export function writeMasterKeyFile(root: string, keyHex: string): string {
|
|
88
|
+
const path = masterKeyPath(root);
|
|
89
|
+
writeFileSync(path, `${keyHex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE });
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SecretsInstallOptions {
|
|
94
|
+
/** The app root holding `secrets.enc.json`. Defaults to the process's working directory. */
|
|
95
|
+
readonly root?: string | undefined;
|
|
96
|
+
/** Read for the key and written with the values. Defaults to `process.env`. */
|
|
97
|
+
readonly env?: EnvRecord | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Names only. A report that carried a value would be a secret in every log that printed it. */
|
|
101
|
+
export interface SecretsInstallReport {
|
|
102
|
+
readonly present: boolean;
|
|
103
|
+
readonly path: string;
|
|
104
|
+
readonly keySource: MasterKeySource | undefined;
|
|
105
|
+
readonly keyId: string | undefined;
|
|
106
|
+
/** Variables this call set. */
|
|
107
|
+
readonly installed: readonly string[];
|
|
108
|
+
/** Variables the real environment already supplied, so the file's value was not used. */
|
|
109
|
+
readonly skipped: readonly string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decrypt the committed secrets into the environment, then let `defineEnv` do what it already does.
|
|
114
|
+
*
|
|
115
|
+
* ```ts
|
|
116
|
+
* await installSecrets(); // app.config.ts, first line
|
|
117
|
+
* export const envSchema = { SESSION_SECRET: { type: 'string', secret: true } } satisfies EnvSchema;
|
|
118
|
+
* export const env = defineEnv(envSchema);
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* This is the whole integration, and it is one path rather than two on purpose. A secret has one
|
|
122
|
+
* name — the env var it becomes — so it keeps one declaration (`envSchema`), one row in
|
|
123
|
+
* `.env.example`, one masker (`maskedEnvValues`), one redaction entry (`redactKeys`) and one
|
|
124
|
+
* reader (`env.SESSION_SECRET`). A `secrets.get('…')` accessor would mint values with no
|
|
125
|
+
* declaration, no type and no mask, and every one of those five would need a second implementation.
|
|
126
|
+
*
|
|
127
|
+
* The real environment always wins: a platform-injected `DATABASE_URL` beats the committed file, so
|
|
128
|
+
* the same image runs in Compose and K8s without a second secrets file per deploy. A file that does
|
|
129
|
+
* not exist is not an error — an app may declare no secrets — but a file that exists with no key to
|
|
130
|
+
* open it is `X_SECRETS_KEY_MISSING`, because booting past it produces an app that authenticates
|
|
131
|
+
* against nothing and reports itself healthy.
|
|
132
|
+
*
|
|
133
|
+
* Forgetting the `await` fails loudly rather than silently: `defineEnv` runs first and throws
|
|
134
|
+
* `X_ENV_MISSING` naming every variable the file would have supplied.
|
|
135
|
+
*/
|
|
136
|
+
export async function installSecrets(
|
|
137
|
+
options: SecretsInstallOptions = {},
|
|
138
|
+
): Promise<SecretsInstallReport> {
|
|
139
|
+
const root = options.root ?? process.cwd();
|
|
140
|
+
const env = options.env ?? (process.env as EnvRecord);
|
|
141
|
+
const path = secretsPath(root);
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
return {
|
|
144
|
+
present: false,
|
|
145
|
+
path,
|
|
146
|
+
keySource: undefined,
|
|
147
|
+
keyId: undefined,
|
|
148
|
+
installed: [],
|
|
149
|
+
skipped: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const key = requireMasterKey(root, env);
|
|
153
|
+
const values = await readSecretsFile(root, key);
|
|
154
|
+
const installed: string[] = [];
|
|
155
|
+
const skipped: string[] = [];
|
|
156
|
+
for (const [name, value] of Object.entries(values)) {
|
|
157
|
+
const current = env[name];
|
|
158
|
+
if (current === undefined || current === '') {
|
|
159
|
+
env[name] = value;
|
|
160
|
+
installed.push(name);
|
|
161
|
+
} else {
|
|
162
|
+
skipped.push(name);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
present: true,
|
|
167
|
+
path,
|
|
168
|
+
keySource: key.source,
|
|
169
|
+
keyId: await masterKeyIdOf(key),
|
|
170
|
+
installed: installed.sort(),
|
|
171
|
+
skipped: skipped.sort(),
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// Single responsibility: the encrypted-secrets envelope. Seal a flat map of env values under a
|
|
2
|
+
// 32-byte master key and open one back, AES-256-GCM through WebCrypto only — a fresh 12-byte IV per
|
|
3
|
+
// seal, the 128-bit tag verified on open, and the envelope's own header bound in as additional
|
|
4
|
+
// authenticated data so a downgraded `alg` or a swapped key id fails the tag instead of decrypting.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SecretsFileInvalidError,
|
|
8
|
+
SecretsKeyInvalidError,
|
|
9
|
+
SecretsKeyMismatchError,
|
|
10
|
+
SecretsPlaintextInvalidError,
|
|
11
|
+
SecretsTamperedError,
|
|
12
|
+
} from './secrets-errors';
|
|
13
|
+
|
|
14
|
+
export const SECRETS_VERSION = 1;
|
|
15
|
+
export const SECRETS_ALG = 'AES-256-GCM';
|
|
16
|
+
export const SECRETS_KEY_BYTES = 32;
|
|
17
|
+
export const SECRETS_KEY_HEX_LENGTH = SECRETS_KEY_BYTES * 2;
|
|
18
|
+
/** GCM's standard nonce. 96 bits is the size the construction is defined for; never reused. */
|
|
19
|
+
export const SECRETS_IV_BYTES = 12;
|
|
20
|
+
export const SECRETS_TAG_BYTES = 16;
|
|
21
|
+
/** Truncated to 64 bits: enough to name a key, far too little to attack the 256-bit key behind it. */
|
|
22
|
+
export const SECRETS_KEY_ID_LENGTH = 16;
|
|
23
|
+
|
|
24
|
+
/** A secret's name is the env var it becomes — there is no second namespace. */
|
|
25
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
26
|
+
|
|
27
|
+
const HEX_KEY = /^[0-9a-f]+$/;
|
|
28
|
+
const KEY_ID = /^[0-9a-f]{16}$/;
|
|
29
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
30
|
+
|
|
31
|
+
/** Decrypted values: env var name to value. Flat on purpose — see `installSecrets`. */
|
|
32
|
+
export type SecretValues = Readonly<Record<string, string>>;
|
|
33
|
+
|
|
34
|
+
/** The committed file, header first so `git diff` shows a rotation as a one-line `kid` change. */
|
|
35
|
+
export interface SecretsEnvelope {
|
|
36
|
+
readonly v: number;
|
|
37
|
+
readonly alg: string;
|
|
38
|
+
/** Non-secret fingerprint of the master key this was sealed with. */
|
|
39
|
+
readonly kid: string;
|
|
40
|
+
readonly iv: string;
|
|
41
|
+
readonly ct: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where each half came from, so an error names the file the reader has to act on. */
|
|
45
|
+
export interface SecretsLocation {
|
|
46
|
+
/** The envelope's path, or the name of whatever produced it. */
|
|
47
|
+
readonly file: string;
|
|
48
|
+
/** The master key's origin — an env var name, or the key file's path. */
|
|
49
|
+
readonly key: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
|
|
54
|
+
function encodeHex(bytes: Uint8Array<ArrayBuffer>): string {
|
|
55
|
+
let out = '';
|
|
56
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decodeHex(hex: string): Uint8Array<ArrayBuffer> {
|
|
61
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
62
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
63
|
+
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
64
|
+
return bytes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `btoa`/`atob` rather than node:buffer — both are standard globals, and a chunk loop avoids the
|
|
68
|
+
// stack blow-up `String.fromCharCode(...bytes)` hits on a spread of any size.
|
|
69
|
+
function encodeBase64(bytes: Uint8Array<ArrayBuffer>): string {
|
|
70
|
+
let binary = '';
|
|
71
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
72
|
+
return btoa(binary);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeBase64(text: string): Uint8Array<ArrayBuffer> {
|
|
76
|
+
const binary = atob(text);
|
|
77
|
+
const bytes = new Uint8Array(binary.length);
|
|
78
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
79
|
+
return bytes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A fresh master key: 32 CSPRNG bytes, hex. The only thing that must never reach the repo. */
|
|
83
|
+
export function generateMasterKey(): string {
|
|
84
|
+
return encodeHex(crypto.getRandomValues(new Uint8Array(SECRETS_KEY_BYTES)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 64 lowercase hex characters, or `X_SECRETS_KEY_INVALID`. Whitespace is trimmed, never repaired. */
|
|
88
|
+
export function parseMasterKey(raw: string, at: string): Uint8Array<ArrayBuffer> {
|
|
89
|
+
const hex = raw.trim();
|
|
90
|
+
if (hex.length !== SECRETS_KEY_HEX_LENGTH || !HEX_KEY.test(hex)) {
|
|
91
|
+
throw new SecretsKeyInvalidError({
|
|
92
|
+
at,
|
|
93
|
+
found: hex.length,
|
|
94
|
+
expected: SECRETS_KEY_HEX_LENGTH,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return decodeHex(hex);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The key's public name. Domain-separated so this digest can never be replayed as a digest of the
|
|
102
|
+
* key computed for any other purpose, and truncated to 16 hex characters because its only job is
|
|
103
|
+
* telling two keys apart in a committed file and in an error message.
|
|
104
|
+
*/
|
|
105
|
+
export async function masterKeyId(key: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
106
|
+
const domain = encoder.encode('ultimate.secrets.kid.v1');
|
|
107
|
+
const material = new Uint8Array(domain.length + key.length);
|
|
108
|
+
material.set(domain, 0);
|
|
109
|
+
material.set(key, domain.length);
|
|
110
|
+
const digest = await crypto.subtle.digest('SHA-256', material);
|
|
111
|
+
return encodeHex(new Uint8Array(digest)).slice(0, SECRETS_KEY_ID_LENGTH);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The bytes the tag covers besides the ciphertext. Binding the header means an attacker who can
|
|
116
|
+
* write the file cannot downgrade `alg`, renumber `v` or claim a different `kid` without the tag
|
|
117
|
+
* catching it — the fields that decide how the body is read are authenticated with the body.
|
|
118
|
+
*/
|
|
119
|
+
const additionalData = (header: Omit<SecretsEnvelope, 'iv' | 'ct'>): Uint8Array<ArrayBuffer> =>
|
|
120
|
+
encoder.encode(`ultimate.secrets|v=${header.v}|alg=${header.alg}|kid=${header.kid}`);
|
|
121
|
+
|
|
122
|
+
const importKey = (key: Uint8Array<ArrayBuffer>): Promise<CryptoKey> =>
|
|
123
|
+
crypto.subtle.importKey('raw', key, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The plaintext form: one flat JSON object, keys sorted, two-space indent. Deterministic so
|
|
127
|
+
* `x secrets edit` can compare the buffer it handed the editor against the one it got back and
|
|
128
|
+
* skip the write when nothing changed — the ciphertext differs on every seal (the IV is fresh), so
|
|
129
|
+
* without this every edit session would produce a diff whether or not a value moved.
|
|
130
|
+
*/
|
|
131
|
+
export function serializeSecretValues(values: SecretValues): string {
|
|
132
|
+
const sorted = Object.fromEntries(Object.entries(values).sort(([a], [b]) => a.localeCompare(b)));
|
|
133
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Flat, env-shaped and non-empty, or `X_SECRETS_PLAINTEXT_INVALID`. Runs before every seal. */
|
|
137
|
+
export function assertSecretValues(value: unknown, at: string): SecretValues {
|
|
138
|
+
const refuse = (reason: string): never => {
|
|
139
|
+
throw new SecretsPlaintextInvalidError({ at, reason });
|
|
140
|
+
};
|
|
141
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
142
|
+
return refuse('are not a JSON object');
|
|
143
|
+
}
|
|
144
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
145
|
+
if (!SECRET_NAME.test(key)) {
|
|
146
|
+
return refuse(`name "${key}", which is not an environment variable name (A-Z, 0-9, _)`);
|
|
147
|
+
}
|
|
148
|
+
if (typeof entry !== 'string') return refuse(`give "${key}" a value that is not a string`);
|
|
149
|
+
if (entry.length === 0) return refuse(`give "${key}" an empty value`);
|
|
150
|
+
}
|
|
151
|
+
return Object.freeze({ ...(value as Record<string, string>) });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The masked projection — names and lengths only. What `x secrets show` and any log may print. */
|
|
155
|
+
export interface SecretSummary {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly length: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function describeSecrets(values: SecretValues): readonly SecretSummary[] {
|
|
161
|
+
return Object.entries(values)
|
|
162
|
+
.map(([name, value]) => ({ name, length: value.length }))
|
|
163
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Encrypt `values` under `keyHex` and return the exact bytes the committed file holds. */
|
|
167
|
+
export async function sealSecrets(
|
|
168
|
+
values: SecretValues,
|
|
169
|
+
keyHex: string,
|
|
170
|
+
at: SecretsLocation,
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
const checked = assertSecretValues(values, at.file);
|
|
173
|
+
const key = parseMasterKey(keyHex, at.key);
|
|
174
|
+
const header = { v: SECRETS_VERSION, alg: SECRETS_ALG, kid: await masterKeyId(key) };
|
|
175
|
+
const iv = crypto.getRandomValues(new Uint8Array(SECRETS_IV_BYTES));
|
|
176
|
+
const sealed = await crypto.subtle.encrypt(
|
|
177
|
+
{ name: 'AES-GCM', iv, additionalData: additionalData(header), tagLength: 128 },
|
|
178
|
+
await importKey(key),
|
|
179
|
+
encoder.encode(serializeSecretValues(checked)),
|
|
180
|
+
);
|
|
181
|
+
const envelope: SecretsEnvelope = {
|
|
182
|
+
...header,
|
|
183
|
+
iv: encodeBase64(iv),
|
|
184
|
+
ct: encodeBase64(new Uint8Array(sealed)),
|
|
185
|
+
};
|
|
186
|
+
return `${JSON.stringify(envelope, null, 2)}\n`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function field(record: Record<string, unknown>, name: string, at: string, bytes?: number): string {
|
|
190
|
+
const value = record[name];
|
|
191
|
+
if (typeof value !== 'string' || !BASE64.test(value) || value.length === 0) {
|
|
192
|
+
throw new SecretsFileInvalidError({ at, reason: `has no base64 "${name}" field` });
|
|
193
|
+
}
|
|
194
|
+
const decoded = decodeBase64(value);
|
|
195
|
+
if (bytes !== undefined && decoded.length !== bytes) {
|
|
196
|
+
throw new SecretsFileInvalidError({
|
|
197
|
+
at,
|
|
198
|
+
reason: `has a ${decoded.length}-byte "${name}"; AES-256-GCM uses ${bytes}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the envelope without needing a key. Every rejection here is `X_SECRETS_FILE_INVALID`: none
|
|
206
|
+
* of it has reached authentication yet, so calling it tampering would send the reader hunting an
|
|
207
|
+
* attacker for what is a truncated write or a merge conflict marker.
|
|
208
|
+
*/
|
|
209
|
+
export function parseSecretsEnvelope(text: string, at: string): SecretsEnvelope {
|
|
210
|
+
let parsed: unknown;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(text);
|
|
213
|
+
} catch {
|
|
214
|
+
throw new SecretsFileInvalidError({ at, reason: 'is not JSON' });
|
|
215
|
+
}
|
|
216
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
217
|
+
throw new SecretsFileInvalidError({ at, reason: 'is not a JSON object' });
|
|
218
|
+
}
|
|
219
|
+
const record = parsed as Record<string, unknown>;
|
|
220
|
+
if (record['v'] !== SECRETS_VERSION) {
|
|
221
|
+
throw new SecretsFileInvalidError({
|
|
222
|
+
at,
|
|
223
|
+
reason: `declares envelope version ${JSON.stringify(record['v'])}; this build seals version ${SECRETS_VERSION}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (record['alg'] !== SECRETS_ALG) {
|
|
227
|
+
throw new SecretsFileInvalidError({
|
|
228
|
+
at,
|
|
229
|
+
reason: `declares algorithm ${JSON.stringify(record['alg'])}; this build seals ${SECRETS_ALG}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const kid = record['kid'];
|
|
233
|
+
if (typeof kid !== 'string' || !KEY_ID.test(kid)) {
|
|
234
|
+
throw new SecretsFileInvalidError({ at, reason: 'has no 16-character hex "kid" field' });
|
|
235
|
+
}
|
|
236
|
+
const iv = field(record, 'iv', at, SECRETS_IV_BYTES);
|
|
237
|
+
const ct = field(record, 'ct', at);
|
|
238
|
+
if (decodeBase64(ct).length <= SECRETS_TAG_BYTES) {
|
|
239
|
+
throw new SecretsFileInvalidError({
|
|
240
|
+
at,
|
|
241
|
+
reason: `has a "ct" too short to hold a ${SECRETS_TAG_BYTES}-byte authentication tag and any content`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { v: SECRETS_VERSION, alg: SECRETS_ALG, kid, iv, ct };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Open a committed envelope. Four distinct refusals, in the order the facts become knowable: the
|
|
249
|
+
* file is unreadable, the key is malformed, the key is the wrong one (the `kid` says so before any
|
|
250
|
+
* decryption is attempted), or the tag rejected the body.
|
|
251
|
+
*/
|
|
252
|
+
export async function openSecrets(
|
|
253
|
+
text: string,
|
|
254
|
+
keyHex: string,
|
|
255
|
+
at: SecretsLocation,
|
|
256
|
+
): Promise<SecretValues> {
|
|
257
|
+
const envelope = parseSecretsEnvelope(text, at.file);
|
|
258
|
+
const key = parseMasterKey(keyHex, at.key);
|
|
259
|
+
const kid = await masterKeyId(key);
|
|
260
|
+
if (kid !== envelope.kid) {
|
|
261
|
+
throw new SecretsKeyMismatchError({
|
|
262
|
+
at: at.file,
|
|
263
|
+
keyAt: at.key,
|
|
264
|
+
sealedWith: envelope.kid,
|
|
265
|
+
found: kid,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
let plaintext: ArrayBuffer;
|
|
269
|
+
try {
|
|
270
|
+
plaintext = await crypto.subtle.decrypt(
|
|
271
|
+
{
|
|
272
|
+
name: 'AES-GCM',
|
|
273
|
+
iv: decodeBase64(envelope.iv),
|
|
274
|
+
additionalData: additionalData(envelope),
|
|
275
|
+
tagLength: 128,
|
|
276
|
+
},
|
|
277
|
+
await importKey(key),
|
|
278
|
+
decodeBase64(envelope.ct),
|
|
279
|
+
);
|
|
280
|
+
} catch {
|
|
281
|
+
// Deliberately no `sourceError`: WebCrypto's OperationError says nothing this code does not,
|
|
282
|
+
// and an error that wraps a crypto exception is one more object a log could try to serialize.
|
|
283
|
+
throw new SecretsTamperedError({ at: at.file });
|
|
284
|
+
}
|
|
285
|
+
let decoded: unknown;
|
|
286
|
+
try {
|
|
287
|
+
decoded = JSON.parse(new TextDecoder().decode(plaintext));
|
|
288
|
+
} catch {
|
|
289
|
+
throw new SecretsPlaintextInvalidError({ at: at.file, reason: 'are not JSON' });
|
|
290
|
+
}
|
|
291
|
+
return assertSecretValues(decoded, at.file);
|
|
292
|
+
}
|