@coffre/core 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/LICENSE +21 -0
- package/README.md +10 -3
- package/dist/access.d.ts +100 -0
- package/dist/access.js +109 -0
- package/dist/audit.d.ts +119 -0
- package/dist/audit.js +160 -0
- package/dist/dotenv.d.ts +45 -0
- package/dist/dotenv.js +194 -0
- package/dist/envelope.d.ts +50 -0
- package/dist/envelope.js +83 -0
- package/dist/identity.d.ts +433 -0
- package/dist/identity.js +691 -0
- package/dist/kek.d.ts +72 -0
- package/dist/kek.js +486 -0
- package/dist/registry-C0NGpxcc.d.ts +126 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +13 -0
- package/dist/types-Mi7-Tzj9.js +57 -0
- package/dist/vault.d.ts +375 -0
- package/dist/vault.js +45 -0
- package/package.json +75 -5
- package/src/access-fault.ts +61 -0
- package/src/access.ts +152 -0
- package/src/audit/chain.ts +207 -0
- package/src/checkpoint.ts +13 -0
- package/src/context.ts +54 -0
- package/src/dotenv.ts +242 -0
- package/src/envelope.ts +127 -0
- package/src/identity/auth.ts +124 -0
- package/src/identity/index.ts +6 -0
- package/src/identity/signin/config.ts +301 -0
- package/src/identity/signin/github.ts +191 -0
- package/src/identity/signin/index.ts +5 -0
- package/src/identity/signin/oidc.ts +199 -0
- package/src/identity/signin/sealed.ts +55 -0
- package/src/identity/signin/types.ts +94 -0
- package/src/identity/tokens.ts +45 -0
- package/src/identity/types.ts +51 -0
- package/src/identity/verifier.ts +86 -0
- package/src/kek/aws-kms.ts +279 -0
- package/src/kek/cancellation.ts +56 -0
- package/src/kek/index.ts +6 -0
- package/src/kek/local.ts +122 -0
- package/src/kek/registry.ts +63 -0
- package/src/kek/sigv4.ts +75 -0
- package/src/kek/types.ts +79 -0
- package/src/schemas.ts +11 -0
- package/src/vault.ts +276 -0
package/dist/dotenv.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
//#region src/dotenv.ts
|
|
2
|
+
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
3
|
+
function parseDotenv(input) {
|
|
4
|
+
const entries = [];
|
|
5
|
+
const problems = [];
|
|
6
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7
|
+
const lines = input.split(/\r\n|\n|\r/);
|
|
8
|
+
for (let index = 0; index < lines.length; index++) {
|
|
9
|
+
const lineNumber = index + 1;
|
|
10
|
+
const trimmed = lines[index].trim();
|
|
11
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
12
|
+
const withoutExport = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
|
|
13
|
+
const equals = withoutExport.indexOf("=");
|
|
14
|
+
if (equals === -1) {
|
|
15
|
+
problems.push({
|
|
16
|
+
line: lineNumber,
|
|
17
|
+
text: trimmed,
|
|
18
|
+
reason: "no \"=\" on this line"
|
|
19
|
+
});
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
23
|
+
if (!KEY_RE.test(key)) {
|
|
24
|
+
problems.push({
|
|
25
|
+
line: lineNumber,
|
|
26
|
+
text: key,
|
|
27
|
+
reason: "key must match ^[A-Za-z_][A-Za-z0-9_]*$"
|
|
28
|
+
});
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let rest = withoutExport.slice(equals + 1).trim();
|
|
32
|
+
let value;
|
|
33
|
+
if (rest.startsWith("\"")) {
|
|
34
|
+
const closing = findClosingQuote(rest, "\"");
|
|
35
|
+
if (closing === -1) {
|
|
36
|
+
problems.push({
|
|
37
|
+
line: lineNumber,
|
|
38
|
+
text: key,
|
|
39
|
+
reason: "unterminated double quote (multi-line values are not supported)"
|
|
40
|
+
});
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
value = unescapeDoubleQuoted(rest.slice(1, closing));
|
|
44
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
45
|
+
problems.push({
|
|
46
|
+
line: lineNumber,
|
|
47
|
+
text: key,
|
|
48
|
+
reason: "unexpected text after the closing quote"
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
} else if (rest.startsWith("'")) {
|
|
53
|
+
const closing = rest.indexOf("'", 1);
|
|
54
|
+
if (closing === -1) {
|
|
55
|
+
problems.push({
|
|
56
|
+
line: lineNumber,
|
|
57
|
+
text: key,
|
|
58
|
+
reason: "unterminated single quote (multi-line values are not supported)"
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
value = rest.slice(1, closing);
|
|
63
|
+
if (trailingIsSignificant(rest.slice(closing + 1))) {
|
|
64
|
+
problems.push({
|
|
65
|
+
line: lineNumber,
|
|
66
|
+
text: key,
|
|
67
|
+
reason: "unexpected text after the closing quote"
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
const comment = rest.indexOf(" #");
|
|
73
|
+
if (comment !== -1) rest = rest.slice(0, comment);
|
|
74
|
+
value = rest.trim();
|
|
75
|
+
}
|
|
76
|
+
if (value.includes("\0")) {
|
|
77
|
+
problems.push({
|
|
78
|
+
line: lineNumber,
|
|
79
|
+
text: key,
|
|
80
|
+
reason: "value contains a NUL byte, which cannot be passed in an environment"
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (seen.has(key)) {
|
|
85
|
+
problems.push({
|
|
86
|
+
line: lineNumber,
|
|
87
|
+
text: key,
|
|
88
|
+
reason: "duplicate key in this file"
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
seen.add(key);
|
|
93
|
+
entries.push({
|
|
94
|
+
key,
|
|
95
|
+
value,
|
|
96
|
+
line: lineNumber
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
entries,
|
|
101
|
+
problems
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** True when what follows a closing quote is neither whitespace nor a comment. */
|
|
105
|
+
function trailingIsSignificant(rest) {
|
|
106
|
+
const trimmed = rest.trim();
|
|
107
|
+
return trimmed !== "" && !trimmed.startsWith("#");
|
|
108
|
+
}
|
|
109
|
+
function findClosingQuote(text, quote) {
|
|
110
|
+
for (let index = 1; index < text.length; index++) {
|
|
111
|
+
if (text[index] === "\\") {
|
|
112
|
+
index++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (text[index] === quote) return index;
|
|
116
|
+
}
|
|
117
|
+
return -1;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Expand escapes inside a double-quoted value.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately minimal: only the escapes that are necessary. `\n` and `\r`
|
|
123
|
+
* because multi-line values (PEM keys) cannot otherwise be expressed on one
|
|
124
|
+
* line, and `\\` `\"` `\'` because quoting needs them.
|
|
125
|
+
*
|
|
126
|
+
* EVERYTHING ELSE IS PRESERVED VERBATIM, backslash included. The obvious
|
|
127
|
+
* implementation -- returning the character and dropping the backslash for
|
|
128
|
+
* unknown escapes -- silently mangles credentials: `"C:\path\to\file"` came
|
|
129
|
+
* out as `C:path<TAB>ofile`, and `"\d+\w"` as `d+w`. A secrets store that
|
|
130
|
+
* quietly rewrites what you gave it is worse than one that refuses.
|
|
131
|
+
*
|
|
132
|
+
* A value that should contain no interpretation at all belongs in single
|
|
133
|
+
* quotes, which are literal.
|
|
134
|
+
*/
|
|
135
|
+
function unescapeDoubleQuoted(text) {
|
|
136
|
+
return text.replace(/\\(.)/g, (match, character) => {
|
|
137
|
+
switch (character) {
|
|
138
|
+
case "n": return "\n";
|
|
139
|
+
case "r": return "\r";
|
|
140
|
+
case "\\":
|
|
141
|
+
case "\"":
|
|
142
|
+
case "'": return character;
|
|
143
|
+
default: return match;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Values that read the same unquoted: no spaces, quotes, `#`, `$` or backslashes. */
|
|
148
|
+
const PLAIN_VALUE = /^[A-Za-z0-9_./:@+,=%?-]*$/;
|
|
149
|
+
/**
|
|
150
|
+
* Write entries as a .env file that `parseDotenv` reads back exactly.
|
|
151
|
+
*
|
|
152
|
+
* Plain values stay bare; anything else is single-quoted, which is literal;
|
|
153
|
+
* values holding a quote or a line break are double-quoted with the four
|
|
154
|
+
* escapes the parser knows (`\\`, `\"`, `\n`, `\r`).
|
|
155
|
+
*/
|
|
156
|
+
function formatDotenv(entries) {
|
|
157
|
+
let out = "";
|
|
158
|
+
for (const [key, value] of entries) {
|
|
159
|
+
assertWritable(key, value);
|
|
160
|
+
out += `${key}=${quoteDotenv(value)}\n`;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function assertWritable(key, value) {
|
|
165
|
+
if (!KEY_RE.test(key)) throw new Error(`${JSON.stringify(key)} is not a valid variable name`);
|
|
166
|
+
if (value.includes("\0")) throw new Error(`${key} contains a NUL byte`);
|
|
167
|
+
}
|
|
168
|
+
function quoteDotenv(value) {
|
|
169
|
+
if (PLAIN_VALUE.test(value)) return value;
|
|
170
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
171
|
+
return `"${value.replace(/[\\"\n\r]/g, (character) => {
|
|
172
|
+
switch (character) {
|
|
173
|
+
case "\n": return "\\n";
|
|
174
|
+
case "\r": return "\\r";
|
|
175
|
+
default: return `\\${character}`;
|
|
176
|
+
}
|
|
177
|
+
})}"`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Write entries as POSIX shell `export` statements, for
|
|
181
|
+
* `eval "$(coffre export … --format shell)"`. Single quotes are the only
|
|
182
|
+
* shell quoting with no expansion inside; a single quote itself is written
|
|
183
|
+
* as `'\''`.
|
|
184
|
+
*/
|
|
185
|
+
function formatShellExports(entries) {
|
|
186
|
+
let out = "";
|
|
187
|
+
for (const [key, value] of entries) {
|
|
188
|
+
assertWritable(key, value);
|
|
189
|
+
out += `export ${key}='${value.replaceAll("'", "'\\''")}'\n`;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
export { formatDotenv, formatShellExports, parseDotenv };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { d as checkContext, f as encodeAad, l as AAD_VERSION, t as KekRegistry, u as SecretContext } from "./registry-C0NGpxcc.js";
|
|
2
|
+
//#region src/envelope.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Current envelope format version. Stored on every row so the format can change
|
|
5
|
+
* without a bulk re-encryption.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ENVELOPE_VERSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* One sealed secret value, exactly as it is stored in `secret_versions`.
|
|
10
|
+
*
|
|
11
|
+
* The AAD binding is implemented here, in our own envelope layer, rather than
|
|
12
|
+
* via a provider "encryption context" feature. That keeps the stored format
|
|
13
|
+
* portable: moving to a different KMS changes only how `wrappedDek` is produced.
|
|
14
|
+
*/
|
|
15
|
+
export type Envelope = {
|
|
16
|
+
envelopeVersion: number;
|
|
17
|
+
kekProvider: string;
|
|
18
|
+
kekId: string;
|
|
19
|
+
kekVersion: string;
|
|
20
|
+
wrappedDek: Buffer;
|
|
21
|
+
iv: Buffer;
|
|
22
|
+
authTag: Buffer;
|
|
23
|
+
ciphertext: Buffer;
|
|
24
|
+
};
|
|
25
|
+
/** The part of an envelope the data key produces: everything but the wrapped key. */
|
|
26
|
+
export type Sealed = Pick<Envelope, 'envelopeVersion' | 'iv' | 'authTag' | 'ciphertext'>;
|
|
27
|
+
/** A fresh data key. One per version, so no key ever encrypts two values. */
|
|
28
|
+
export declare function freshDek(): Buffer;
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt a secret value under `dek`, bound to `ctx`. Whoever holds the key
|
|
31
|
+
* encryption key wraps `dek` separately: the vault, in coffre.
|
|
32
|
+
*/
|
|
33
|
+
export declare function encrypt(plaintext: Buffer, ctx: SecretContext, dek: Buffer): Sealed;
|
|
34
|
+
/**
|
|
35
|
+
* Decrypt with an unwrapped `dek`. Throws if the envelope was sealed for a
|
|
36
|
+
* different project, environment or secret than `ctx` describes, because
|
|
37
|
+
* the AAD will not match. This is the property that makes relocating a
|
|
38
|
+
* ciphertext across environments fail closed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function decrypt(envelope: Sealed, ctx: SecretContext, dek: Buffer): Buffer;
|
|
41
|
+
/**
|
|
42
|
+
* Encrypt a secret value under a fresh DEK, and wrap that DEK under the
|
|
43
|
+
* registry's primary KEK: `encrypt` and the wrap in one, where both halves
|
|
44
|
+
* are in one place.
|
|
45
|
+
*/
|
|
46
|
+
export declare function seal(plaintext: Buffer, ctx: SecretContext, keks: KekRegistry): Promise<Envelope>;
|
|
47
|
+
/** Unwrap the envelope's DEK under the registry and decrypt: `decrypt` and the unwrap in one. */
|
|
48
|
+
export declare function open(envelope: Envelope, ctx: SecretContext, keks: KekRegistry): Promise<Buffer>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { AAD_VERSION, SecretContext, checkContext, encodeAad };
|
package/dist/envelope.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { a as AAD_VERSION, o as checkContext, s as encodeAad } from "./types-Mi7-Tzj9.js";
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
3
|
+
//#region src/envelope.ts
|
|
4
|
+
const IV_BYTES = 12;
|
|
5
|
+
const TAG_BYTES = 16;
|
|
6
|
+
/**
|
|
7
|
+
* Current envelope format version. Stored on every row so the format can change
|
|
8
|
+
* without a bulk re-encryption.
|
|
9
|
+
*/
|
|
10
|
+
const ENVELOPE_VERSION = 1;
|
|
11
|
+
/** A fresh data key. One per version, so no key ever encrypts two values. */
|
|
12
|
+
function freshDek() {
|
|
13
|
+
return randomBytes(32);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Encrypt a secret value under `dek`, bound to `ctx`. Whoever holds the key
|
|
17
|
+
* encryption key wraps `dek` separately: the vault, in coffre.
|
|
18
|
+
*/
|
|
19
|
+
function encrypt(plaintext, ctx, dek) {
|
|
20
|
+
const iv = randomBytes(IV_BYTES);
|
|
21
|
+
const cipher = createCipheriv("aes-256-gcm", dek, iv);
|
|
22
|
+
cipher.setAAD(encodeAad(ctx));
|
|
23
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
24
|
+
return {
|
|
25
|
+
envelopeVersion: 1,
|
|
26
|
+
iv,
|
|
27
|
+
authTag: cipher.getAuthTag(),
|
|
28
|
+
ciphertext
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Decrypt with an unwrapped `dek`. Throws if the envelope was sealed for a
|
|
33
|
+
* different project, environment or secret than `ctx` describes, because
|
|
34
|
+
* the AAD will not match. This is the property that makes relocating a
|
|
35
|
+
* ciphertext across environments fail closed.
|
|
36
|
+
*/
|
|
37
|
+
function decrypt(envelope, ctx, dek) {
|
|
38
|
+
if (envelope.envelopeVersion !== 1) throw new Error(`unsupported envelope version: ${envelope.envelopeVersion}`);
|
|
39
|
+
if (envelope.iv.length !== IV_BYTES) throw new Error("envelope IV has the wrong length");
|
|
40
|
+
if (envelope.authTag.length !== TAG_BYTES) throw new Error("envelope auth tag has the wrong length");
|
|
41
|
+
const decipher = createDecipheriv("aes-256-gcm", dek, envelope.iv);
|
|
42
|
+
decipher.setAAD(encodeAad(ctx));
|
|
43
|
+
decipher.setAuthTag(envelope.authTag);
|
|
44
|
+
return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Encrypt a secret value under a fresh DEK, and wrap that DEK under the
|
|
48
|
+
* registry's primary KEK: `encrypt` and the wrap in one, where both halves
|
|
49
|
+
* are in one place.
|
|
50
|
+
*/
|
|
51
|
+
async function seal(plaintext, ctx, keks) {
|
|
52
|
+
const dek = freshDek();
|
|
53
|
+
try {
|
|
54
|
+
const sealed = encrypt(plaintext, ctx, dek);
|
|
55
|
+
const wrapped = await keks.wrap(dek, ctx);
|
|
56
|
+
return {
|
|
57
|
+
...sealed,
|
|
58
|
+
kekProvider: wrapped.kekProvider,
|
|
59
|
+
kekId: wrapped.kekId,
|
|
60
|
+
kekVersion: wrapped.kekVersion,
|
|
61
|
+
wrappedDek: wrapped.bytes
|
|
62
|
+
};
|
|
63
|
+
} finally {
|
|
64
|
+
dek.fill(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Unwrap the envelope's DEK under the registry and decrypt: `decrypt` and the unwrap in one. */
|
|
68
|
+
async function open(envelope, ctx, keks) {
|
|
69
|
+
if (envelope.envelopeVersion !== 1) throw new Error(`unsupported envelope version: ${envelope.envelopeVersion}`);
|
|
70
|
+
const dek = await keks.unwrap({
|
|
71
|
+
kekProvider: envelope.kekProvider,
|
|
72
|
+
kekId: envelope.kekId,
|
|
73
|
+
kekVersion: envelope.kekVersion,
|
|
74
|
+
bytes: envelope.wrappedDek
|
|
75
|
+
}, ctx);
|
|
76
|
+
try {
|
|
77
|
+
return decrypt(envelope, ctx, dek);
|
|
78
|
+
} finally {
|
|
79
|
+
dek.fill(0);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
export { AAD_VERSION, ENVELOPE_VERSION, checkContext, decrypt, encodeAad, encrypt, freshDek, open, seal };
|