@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -3
  3. package/dist/access.d.ts +100 -0
  4. package/dist/access.js +109 -0
  5. package/dist/audit.d.ts +119 -0
  6. package/dist/audit.js +160 -0
  7. package/dist/dotenv.d.ts +45 -0
  8. package/dist/dotenv.js +194 -0
  9. package/dist/envelope.d.ts +50 -0
  10. package/dist/envelope.js +83 -0
  11. package/dist/identity.d.ts +433 -0
  12. package/dist/identity.js +691 -0
  13. package/dist/kek.d.ts +72 -0
  14. package/dist/kek.js +486 -0
  15. package/dist/registry-C0NGpxcc.d.ts +126 -0
  16. package/dist/schemas.d.ts +18 -0
  17. package/dist/schemas.js +13 -0
  18. package/dist/types-Mi7-Tzj9.js +57 -0
  19. package/dist/vault.d.ts +375 -0
  20. package/dist/vault.js +45 -0
  21. package/package.json +75 -5
  22. package/src/access-fault.ts +61 -0
  23. package/src/access.ts +152 -0
  24. package/src/audit/chain.ts +207 -0
  25. package/src/checkpoint.ts +13 -0
  26. package/src/context.ts +54 -0
  27. package/src/dotenv.ts +242 -0
  28. package/src/envelope.ts +127 -0
  29. package/src/identity/auth.ts +124 -0
  30. package/src/identity/index.ts +6 -0
  31. package/src/identity/signin/config.ts +301 -0
  32. package/src/identity/signin/github.ts +191 -0
  33. package/src/identity/signin/index.ts +5 -0
  34. package/src/identity/signin/oidc.ts +199 -0
  35. package/src/identity/signin/sealed.ts +55 -0
  36. package/src/identity/signin/types.ts +94 -0
  37. package/src/identity/tokens.ts +45 -0
  38. package/src/identity/types.ts +51 -0
  39. package/src/identity/verifier.ts +86 -0
  40. package/src/kek/aws-kms.ts +279 -0
  41. package/src/kek/cancellation.ts +56 -0
  42. package/src/kek/index.ts +6 -0
  43. package/src/kek/local.ts +122 -0
  44. package/src/kek/registry.ts +63 -0
  45. package/src/kek/sigv4.ts +75 -0
  46. package/src/kek/types.ts +79 -0
  47. package/src/schemas.ts +11 -0
  48. 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 };
@@ -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 };