@oscharko-dev/keiko-security 0.2.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/dist/.tsbuildinfo +1 -0
- package/dist/errors/audit.d.ts +26 -0
- package/dist/errors/audit.d.ts.map +1 -0
- package/dist/errors/audit.js +39 -0
- package/dist/errors/gateway.d.ts +84 -0
- package/dist/errors/gateway.d.ts.map +1 -0
- package/dist/errors/gateway.js +95 -0
- package/dist/errors/harness.d.ts +22 -0
- package/dist/errors/harness.d.ts.map +1 -0
- package/dist/errors/harness.js +39 -0
- package/dist/errors/index.d.ts +9 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +12 -0
- package/dist/errors/promptEnhancer.d.ts +38 -0
- package/dist/errors/promptEnhancer.d.ts.map +1 -0
- package/dist/errors/promptEnhancer.js +56 -0
- package/dist/errors/secretbox.d.ts +5 -0
- package/dist/errors/secretbox.d.ts.map +1 -0
- package/dist/errors/secretbox.js +13 -0
- package/dist/errors/tools.d.ts +60 -0
- package/dist/errors/tools.d.ts.map +1 -0
- package/dist/errors/tools.js +94 -0
- package/dist/errors/verification.d.ts +12 -0
- package/dist/errors/verification.d.ts.map +1 -0
- package/dist/errors/verification.js +21 -0
- package/dist/errors/workspace.d.ts +56 -0
- package/dist/errors/workspace.d.ts.map +1 -0
- package/dist/errors/workspace.js +95 -0
- package/dist/hashing.d.ts +4 -0
- package/dist/hashing.d.ts.map +1 -0
- package/dist/hashing.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/promptInjection.d.ts +29 -0
- package/dist/promptInjection.d.ts.map +1 -0
- package/dist/promptInjection.js +235 -0
- package/dist/redaction.d.ts +5 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +123 -0
- package/dist/runid.d.ts +2 -0
- package/dist/runid.d.ts.map +1 -0
- package/dist/runid.js +29 -0
- package/dist/secret-vault.d.ts +33 -0
- package/dist/secret-vault.d.ts.map +1 -0
- package/dist/secret-vault.js +271 -0
- package/dist/secretbox.d.ts +6 -0
- package/dist/secretbox.d.ts.map +1 -0
- package/dist/secretbox.js +80 -0
- package/dist/secrets.d.ts +4 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +34 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/package.json +78 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Local secret vault — encrypted-at-rest, multi-entry storage for local runtime credentials
|
|
2
|
+
// (Issue #1320, Epic #1319).
|
|
3
|
+
//
|
|
4
|
+
// This is the generalised sibling of the single-value Figma PAT vault (figmaTokenStore.ts) and the
|
|
5
|
+
// MemoriaViva memory vault (ADR-0035): all three seal their secrets at rest with the shared
|
|
6
|
+
// AES-256-GCM secretbox primitive and resolve a 32-byte key with the same env -> OS-keychain ->
|
|
7
|
+
// keyfile precedence. The difference here is cardinality: a model gateway can carry several provider
|
|
8
|
+
// credentials, so this store keeps a sealed map of `reference -> sealed(secret)` rather than one
|
|
9
|
+
// sealed value. References are opaque, NON-SECRET identifiers held alongside the sealed material; the
|
|
10
|
+
// plaintext secret NEVER touches disk, an error message, a log line, or a return value other than
|
|
11
|
+
// get().
|
|
12
|
+
//
|
|
13
|
+
// Key precedence (resolveLocalVaultKey), namespaced per domain so it never collides with another
|
|
14
|
+
// vault's key:
|
|
15
|
+
// 1. <envVarName> — base64 of exactly 32 bytes. Explicit operator override; key lives outside
|
|
16
|
+
// the vault directory entirely (strongest tier).
|
|
17
|
+
// 2. macOS Keychain — generic password under <keychainService>. The OS protects the key.
|
|
18
|
+
// 3. Keyfile — <vaultDir>/<keyfileName>, mode 0600. Weakest tier (key next to store).
|
|
19
|
+
//
|
|
20
|
+
// Replay across vaults is prevented by key separation, not by the AAD: every vault resolves a
|
|
21
|
+
// DISTINCT key (distinct env var, keychain service, and keyfile), so a ciphertext sealed for one
|
|
22
|
+
// vault fails GCM authentication when opened with another vault's key regardless of the shared AAD.
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { userInfo } from "node:os";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
import { isSealed, openString, sealString } from "./secretbox.js";
|
|
29
|
+
const KEY_BYTES = 32;
|
|
30
|
+
const STORE_VERSION = 1;
|
|
31
|
+
export const NO_LOCAL_VAULT_KEYCHAIN = () => undefined;
|
|
32
|
+
function decodeKeyOrThrow(raw) {
|
|
33
|
+
const decoded = Buffer.from(raw, "base64");
|
|
34
|
+
if (decoded.length !== KEY_BYTES) {
|
|
35
|
+
// Secret-free message: never echo the (possibly partial) key material.
|
|
36
|
+
throw new Error("secret vault key must be base64 of exactly 32 bytes");
|
|
37
|
+
}
|
|
38
|
+
return decoded;
|
|
39
|
+
}
|
|
40
|
+
function keyFromEnv(env, envVarName) {
|
|
41
|
+
const raw = env[envVarName];
|
|
42
|
+
if (raw === undefined || raw.length === 0)
|
|
43
|
+
return undefined;
|
|
44
|
+
return decodeKeyOrThrow(raw);
|
|
45
|
+
}
|
|
46
|
+
function ensureDirHardened(dir) {
|
|
47
|
+
if (!existsSync(dir))
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
if (process.platform === "win32")
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(dir, 0o700);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Best-effort: a parent-owned directory we cannot chmod beats a hard failure.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function chmodIfPresent(path, mode) {
|
|
59
|
+
if (process.platform === "win32")
|
|
60
|
+
return;
|
|
61
|
+
try {
|
|
62
|
+
chmodSync(path, mode);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Best-effort hardening.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function defaultKeychainCommandRunner(args) {
|
|
69
|
+
return execFileSync("security", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
70
|
+
}
|
|
71
|
+
// Builds the default keychain-backed key access for a service. Exported with an injectable runner so
|
|
72
|
+
// the reader/generate branches are deterministically testable without the real login keychain.
|
|
73
|
+
export function createKeychainVaultKeyAccess(keychainService, runCommand = defaultKeychainCommandRunner) {
|
|
74
|
+
return () => {
|
|
75
|
+
if (process.platform !== "darwin")
|
|
76
|
+
return undefined;
|
|
77
|
+
const account = userInfo().username;
|
|
78
|
+
try {
|
|
79
|
+
const found = runCommand([
|
|
80
|
+
"find-generic-password",
|
|
81
|
+
"-s",
|
|
82
|
+
keychainService,
|
|
83
|
+
"-a",
|
|
84
|
+
account,
|
|
85
|
+
"-w",
|
|
86
|
+
]).trim();
|
|
87
|
+
return decodeKeyOrThrow(found);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return generateKeychainKey(keychainService, account, runCommand);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function generateKeychainKey(keychainService, account, runCommand) {
|
|
95
|
+
const key = randomBytes(KEY_BYTES);
|
|
96
|
+
try {
|
|
97
|
+
runCommand([
|
|
98
|
+
"add-generic-password",
|
|
99
|
+
"-s",
|
|
100
|
+
keychainService,
|
|
101
|
+
"-a",
|
|
102
|
+
account,
|
|
103
|
+
"-w",
|
|
104
|
+
key.toString("base64"),
|
|
105
|
+
]);
|
|
106
|
+
return key;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function keyFromKeyfile(vaultDir, keyfileName) {
|
|
113
|
+
const keyfile = resolve(vaultDir, keyfileName);
|
|
114
|
+
// Refuse to read or write the key through a symlinked path segment so a hostile symlink cannot
|
|
115
|
+
// redirect key material outside the hardened vault directory.
|
|
116
|
+
assertNoSymlinkedPathSegments(keyfile);
|
|
117
|
+
ensureDirHardened(dirname(keyfile));
|
|
118
|
+
assertNoSymlinkedPathSegments(keyfile);
|
|
119
|
+
if (existsSync(keyfile)) {
|
|
120
|
+
return decodeKeyOrThrow(readFileSync(keyfile, "utf8").trim());
|
|
121
|
+
}
|
|
122
|
+
const key = randomBytes(KEY_BYTES);
|
|
123
|
+
writeFileSync(keyfile, key.toString("base64"), { mode: 0o600 });
|
|
124
|
+
chmodIfPresent(keyfile, 0o600);
|
|
125
|
+
return key;
|
|
126
|
+
}
|
|
127
|
+
export function resolveLocalVaultKey(options) {
|
|
128
|
+
const fromEnv = keyFromEnv(options.env, options.envVarName);
|
|
129
|
+
if (fromEnv !== undefined)
|
|
130
|
+
return { key: fromEnv, source: "env" };
|
|
131
|
+
const keychainAccess = options.keychainAccess ?? createKeychainVaultKeyAccess(options.keychainService);
|
|
132
|
+
const fromKeychain = keychainAccess();
|
|
133
|
+
if (fromKeychain !== undefined)
|
|
134
|
+
return { key: fromKeychain, source: "keychain" };
|
|
135
|
+
return { key: keyFromKeyfile(options.vaultDir, options.keyfileName), source: "keyfile" };
|
|
136
|
+
}
|
|
137
|
+
function assertNoSymlinkedPathSegments(resolvedPath) {
|
|
138
|
+
let current = resolvedPath;
|
|
139
|
+
while (current !== dirname(current)) {
|
|
140
|
+
if (isSymlink(current)) {
|
|
141
|
+
throw new Error("refusing to write secret vault through a symlinked path");
|
|
142
|
+
}
|
|
143
|
+
current = dirname(current);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isSymlink(path) {
|
|
147
|
+
try {
|
|
148
|
+
return lstatSync(path).isSymbolicLink();
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error.code === "ENOENT") {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function isStoreFile(value) {
|
|
158
|
+
if (typeof value !== "object" || value === null)
|
|
159
|
+
return false;
|
|
160
|
+
const record = value;
|
|
161
|
+
if (record.version !== STORE_VERSION)
|
|
162
|
+
return false;
|
|
163
|
+
const entries = record.entries;
|
|
164
|
+
if (typeof entries !== "object" || entries === null || Array.isArray(entries))
|
|
165
|
+
return false;
|
|
166
|
+
return Object.values(entries).every((entry) => typeof entry === "string");
|
|
167
|
+
}
|
|
168
|
+
function readStore(storePath) {
|
|
169
|
+
const resolvedPath = resolve(storePath);
|
|
170
|
+
assertNoSymlinkedPathSegments(resolvedPath);
|
|
171
|
+
if (!existsSync(resolvedPath))
|
|
172
|
+
return {};
|
|
173
|
+
let parsed;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(readFileSync(resolvedPath, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// A non-JSON store is treated as empty so a corrupt index never crashes resolution; the entries
|
|
179
|
+
// it would have held simply resolve to undefined and surface as an honest "missing credential".
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
return isStoreFile(parsed) ? { ...parsed.entries } : {};
|
|
183
|
+
}
|
|
184
|
+
// Lists the references held in a sealed store WITHOUT resolving a vault key — a pure read over the
|
|
185
|
+
// non-secret index. Used by callers that must reconcile config references against vaulted secrets
|
|
186
|
+
// (e.g. preserve-existing persistence and `keiko repair`) without triggering key generation or
|
|
187
|
+
// decryption. Returns an empty array for a missing or corrupt store.
|
|
188
|
+
export function readLocalVaultReferences(storePath) {
|
|
189
|
+
return Object.keys(readStore(storePath));
|
|
190
|
+
}
|
|
191
|
+
// Atomic, crash-safe write: a fresh temp file in the same directory is written with 0600 and renamed
|
|
192
|
+
// over the target so a reader never observes a partially written store. Mirrors savePrivateJson.
|
|
193
|
+
function writeStore(storePath, entries) {
|
|
194
|
+
const resolvedPath = resolve(storePath);
|
|
195
|
+
const dir = dirname(resolvedPath);
|
|
196
|
+
// Check both before and after directory creation (mirrors private-json.ts): the first guards an
|
|
197
|
+
// already-symlinked path, the second narrows the window where a parent could be swapped between
|
|
198
|
+
// dir creation and the atomic rename. The atomic temp-then-rename below remains the real guarantee.
|
|
199
|
+
assertNoSymlinkedPathSegments(resolvedPath);
|
|
200
|
+
ensureDirHardened(dir);
|
|
201
|
+
assertNoSymlinkedPathSegments(resolvedPath);
|
|
202
|
+
const payload = { version: STORE_VERSION, entries };
|
|
203
|
+
const tempPath = join(dir, `.secret-vault.${String(process.pid)}.${randomBytes(8).toString("hex")}.tmp`);
|
|
204
|
+
try {
|
|
205
|
+
writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
206
|
+
encoding: "utf8",
|
|
207
|
+
flag: "wx",
|
|
208
|
+
mode: 0o600,
|
|
209
|
+
});
|
|
210
|
+
chmodIfPresent(tempPath, 0o600);
|
|
211
|
+
renameSync(tempPath, resolvedPath);
|
|
212
|
+
chmodIfPresent(resolvedPath, 0o600);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
if (existsSync(tempPath)) {
|
|
216
|
+
try {
|
|
217
|
+
unlinkSync(tempPath);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Best-effort cleanup only.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export function createLocalSecretVault(deps) {
|
|
226
|
+
const { key, storePath } = deps;
|
|
227
|
+
const resolvedStorePath = resolve(storePath);
|
|
228
|
+
const get = (reference) => {
|
|
229
|
+
const envelope = readStore(resolvedStorePath)[reference];
|
|
230
|
+
if (envelope === undefined || !isSealed(envelope))
|
|
231
|
+
return undefined;
|
|
232
|
+
return openString(key, envelope);
|
|
233
|
+
};
|
|
234
|
+
const set = (reference, secret) => {
|
|
235
|
+
const entries = readStore(resolvedStorePath);
|
|
236
|
+
entries[reference] = sealString(key, secret);
|
|
237
|
+
writeStore(resolvedStorePath, entries);
|
|
238
|
+
};
|
|
239
|
+
const replaceAll = (next) => {
|
|
240
|
+
const entries = {};
|
|
241
|
+
for (const [reference, secret] of next) {
|
|
242
|
+
entries[reference] = sealString(key, secret);
|
|
243
|
+
}
|
|
244
|
+
if (Object.keys(entries).length === 0) {
|
|
245
|
+
assertNoSymlinkedPathSegments(resolvedStorePath);
|
|
246
|
+
rmSync(resolvedStorePath, { force: true });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
writeStore(resolvedStorePath, entries);
|
|
250
|
+
};
|
|
251
|
+
const remove = (reference) => {
|
|
252
|
+
const entries = readStore(resolvedStorePath);
|
|
253
|
+
if (!(reference in entries))
|
|
254
|
+
return;
|
|
255
|
+
const next = {};
|
|
256
|
+
for (const [storedRef, sealed] of Object.entries(entries)) {
|
|
257
|
+
if (storedRef !== reference) {
|
|
258
|
+
next[storedRef] = sealed;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (Object.keys(next).length === 0) {
|
|
262
|
+
assertNoSymlinkedPathSegments(resolvedStorePath);
|
|
263
|
+
rmSync(resolvedStorePath, { force: true });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
writeStore(resolvedStorePath, next);
|
|
267
|
+
};
|
|
268
|
+
const has = (reference) => reference in readStore(resolvedStorePath);
|
|
269
|
+
const list = () => Object.keys(readStore(resolvedStorePath));
|
|
270
|
+
return { get, set, replaceAll, delete: remove, has, list };
|
|
271
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function sealString(key: Buffer, plaintext: string): string;
|
|
2
|
+
export declare function openString(key: Buffer, envelope: string): string;
|
|
3
|
+
export declare function sealBytes(key: Buffer, buf: Buffer): Buffer;
|
|
4
|
+
export declare function openBytes(key: Buffer, envelope: Buffer): Buffer;
|
|
5
|
+
export declare function isSealed(value: string): boolean;
|
|
6
|
+
//# sourceMappingURL=secretbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secretbox.d.ts","sourceRoot":"","sources":["../src/secretbox.ts"],"names":[],"mappings":"AA2CA,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKjE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAgBhE;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE/C"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// AES-256-GCM authenticated encryption primitive — the leaf crypto boundary for at-rest secrecy.
|
|
2
|
+
// Two envelope formats share one key and one AAD so a value sealed by either path is bound to this
|
|
3
|
+
// domain and cannot be replayed into another:
|
|
4
|
+
//
|
|
5
|
+
// string envelope: "kv1.<base64url(nonce12)>.<base64url(ciphertext||tag16)>"
|
|
6
|
+
// binary envelope: 0x01 || nonce12 || ciphertext || tag16 (one Buffer, no separators)
|
|
7
|
+
//
|
|
8
|
+
// GCM gives confidentiality AND integrity: open() recomputes the 16-byte auth tag and throws on
|
|
9
|
+
// any mismatch, so a tampered ciphertext or a wrong key both fail loudly (never silent corruption).
|
|
10
|
+
// The nonce is a fresh 12 random bytes per call — the GCM-safe size, and random-per-call keeps us
|
|
11
|
+
// far from the birthday bound for the local single-DB write volume in scope. AAD pins every
|
|
12
|
+
// envelope to "keiko-memory-v1" so a ciphertext lifted from another keiko surface won't open here.
|
|
13
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
14
|
+
import { SecretboxError } from "./errors/secretbox.js";
|
|
15
|
+
const STRING_PREFIX = "kv1.";
|
|
16
|
+
const BINARY_VERSION = 0x01;
|
|
17
|
+
const NONCE_BYTES = 12;
|
|
18
|
+
const TAG_BYTES = 16;
|
|
19
|
+
const ALGORITHM = "aes-256-gcm";
|
|
20
|
+
const AAD = Buffer.from("keiko-memory-v1");
|
|
21
|
+
function encrypt(key, nonce, plaintext) {
|
|
22
|
+
const cipher = createCipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_BYTES });
|
|
23
|
+
cipher.setAAD(AAD);
|
|
24
|
+
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
25
|
+
return { ct, tag: cipher.getAuthTag() };
|
|
26
|
+
}
|
|
27
|
+
// All decrypt failures funnel through here as a single SecretboxError class — Node throws a generic
|
|
28
|
+
// Error on auth-tag mismatch, which we normalise so callers branch on the type, not the message.
|
|
29
|
+
function decrypt(key, nonce, ct, tag) {
|
|
30
|
+
const decipher = createDecipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_BYTES });
|
|
31
|
+
decipher.setAAD(AAD);
|
|
32
|
+
decipher.setAuthTag(tag);
|
|
33
|
+
try {
|
|
34
|
+
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new SecretboxError("secretbox: authentication failed (wrong key or tampered data)");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function sealString(key, plaintext) {
|
|
41
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
42
|
+
const { ct, tag } = encrypt(key, nonce, Buffer.from(plaintext, "utf8"));
|
|
43
|
+
const body = Buffer.concat([ct, tag]);
|
|
44
|
+
return `${STRING_PREFIX}${nonce.toString("base64url")}.${body.toString("base64url")}`;
|
|
45
|
+
}
|
|
46
|
+
export function openString(key, envelope) {
|
|
47
|
+
if (!isSealed(envelope)) {
|
|
48
|
+
throw new SecretboxError("secretbox: envelope is missing the kv1 prefix");
|
|
49
|
+
}
|
|
50
|
+
const parts = envelope.split(".");
|
|
51
|
+
if (parts.length !== 3) {
|
|
52
|
+
throw new SecretboxError("secretbox: envelope must have three parts");
|
|
53
|
+
}
|
|
54
|
+
const nonce = Buffer.from(parts[1] ?? "", "base64url");
|
|
55
|
+
const body = Buffer.from(parts[2] ?? "", "base64url");
|
|
56
|
+
if (nonce.length !== NONCE_BYTES || body.length < TAG_BYTES) {
|
|
57
|
+
throw new SecretboxError("secretbox: envelope nonce or body length is invalid");
|
|
58
|
+
}
|
|
59
|
+
const ct = body.subarray(0, body.length - TAG_BYTES);
|
|
60
|
+
const tag = body.subarray(body.length - TAG_BYTES);
|
|
61
|
+
return decrypt(key, nonce, ct, tag).toString("utf8");
|
|
62
|
+
}
|
|
63
|
+
export function sealBytes(key, buf) {
|
|
64
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
65
|
+
const { ct, tag } = encrypt(key, nonce, buf);
|
|
66
|
+
return Buffer.concat([Buffer.from([BINARY_VERSION]), nonce, ct, tag]);
|
|
67
|
+
}
|
|
68
|
+
export function openBytes(key, envelope) {
|
|
69
|
+
const minLength = 1 + NONCE_BYTES + TAG_BYTES;
|
|
70
|
+
if (envelope.length < minLength || envelope[0] !== BINARY_VERSION) {
|
|
71
|
+
throw new SecretboxError("secretbox: binary envelope is malformed or has an unknown version");
|
|
72
|
+
}
|
|
73
|
+
const nonce = envelope.subarray(1, 1 + NONCE_BYTES);
|
|
74
|
+
const ct = envelope.subarray(1 + NONCE_BYTES, envelope.length - TAG_BYTES);
|
|
75
|
+
const tag = envelope.subarray(envelope.length - TAG_BYTES);
|
|
76
|
+
return decrypt(key, nonce, ct, tag);
|
|
77
|
+
}
|
|
78
|
+
export function isSealed(value) {
|
|
79
|
+
return value.startsWith(STRING_PREFIX);
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secrets.d.ts","sourceRoot":"","sources":["../src/secrets.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AASrE,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAK1D;AAMD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAQzE"}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Centralised secret-collection helpers used by the BFF, CLI, and evaluation entrypoints to feed
|
|
2
|
+
// `createAuditRedactor` with the exact apiKey values held in the environment. Pure functions: no
|
|
3
|
+
// IO, no logging, no dependency on UI/CLI/gateway shapes. Consumers compose these with their own
|
|
4
|
+
// config secrets (provider apiKey values) before calling the redactor builder.
|
|
5
|
+
//
|
|
6
|
+
// Why these helpers exist here, not in each caller: prior to extraction the BFF and the CLI
|
|
7
|
+
// evaluator carried byte-identical copies of `isKeikoApiKeyEnvName` and the env-walk that fed
|
|
8
|
+
// `redactionSecrets`/`keikoApiKeySecrets` — a single behavioural drift would have left one
|
|
9
|
+
// surface less protected than the other (AC #1: no API token newly exposed). Centralising the
|
|
10
|
+
// detector and the collector keeps every caller scrubbing the same names.
|
|
11
|
+
// Returns true iff `name` matches the conventional KEIKO env-var shape that carries a model
|
|
12
|
+
// provider API key:
|
|
13
|
+
// - KEIKO_DEFAULT_API_KEY (the fallback used when no per-model key is set)
|
|
14
|
+
// - KEIKO_MODEL_<id>_API_KEY (per-model override; <id> is opaque to this helper)
|
|
15
|
+
//
|
|
16
|
+
// The match is exact prefix + exact suffix to avoid false positives (e.g. "KEIKO_API_KEY_NOTE"
|
|
17
|
+
// would not match because of the missing "_MODEL_" segment).
|
|
18
|
+
export function isKeikoApiKeyEnvName(name) {
|
|
19
|
+
return (name === "KEIKO_DEFAULT_API_KEY" ||
|
|
20
|
+
(name.startsWith("KEIKO_MODEL_") && name.endsWith("_API_KEY")));
|
|
21
|
+
}
|
|
22
|
+
// Collects the VALUES of every env entry whose name is a KEIKO API-key env var. Undefined and
|
|
23
|
+
// empty values are skipped — feeding "" to the redactor would over-redact ordinary text. Names
|
|
24
|
+
// are never collected, only values: redacting a name like "KEIKO_DEFAULT_API_KEY" in a log line
|
|
25
|
+
// would obscure debugging output without protecting any secret.
|
|
26
|
+
export function keikoApiKeySecretValues(env) {
|
|
27
|
+
const values = [];
|
|
28
|
+
for (const [name, value] of Object.entries(env)) {
|
|
29
|
+
if (value !== undefined && value.length > 0 && isKeikoApiKeyEnvName(name)) {
|
|
30
|
+
values.push(value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return values;
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,sBAAsB,EAAG,OAAgB,CAAC"}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Version anchor for @oscharko-dev/keiko-security. Pinned to the package's package.json version.
|
|
2
|
+
// Lets consumers assert which security primitive contract they linked against (ADR-0019 leaf-package
|
|
3
|
+
// pattern, mirroring @oscharko-dev/keiko-contracts).
|
|
4
|
+
export const KEIKO_SECURITY_VERSION = "0.1.0";
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oscharko-dev/keiko-security",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"description": "Internal workspace package: shared Keiko security primitives (redaction, safe error shaping, secret collection, content hashing, trust-boundary helpers). Not published independently.",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./redaction": {
|
|
15
|
+
"types": "./dist/redaction.d.ts",
|
|
16
|
+
"import": "./dist/redaction.js"
|
|
17
|
+
},
|
|
18
|
+
"./errors": {
|
|
19
|
+
"types": "./dist/errors/index.d.ts",
|
|
20
|
+
"import": "./dist/errors/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./errors/gateway": {
|
|
23
|
+
"types": "./dist/errors/gateway.d.ts",
|
|
24
|
+
"import": "./dist/errors/gateway.js"
|
|
25
|
+
},
|
|
26
|
+
"./errors/audit": {
|
|
27
|
+
"types": "./dist/errors/audit.d.ts",
|
|
28
|
+
"import": "./dist/errors/audit.js"
|
|
29
|
+
},
|
|
30
|
+
"./errors/workspace": {
|
|
31
|
+
"types": "./dist/errors/workspace.d.ts",
|
|
32
|
+
"import": "./dist/errors/workspace.js"
|
|
33
|
+
},
|
|
34
|
+
"./errors/tools": {
|
|
35
|
+
"types": "./dist/errors/tools.d.ts",
|
|
36
|
+
"import": "./dist/errors/tools.js"
|
|
37
|
+
},
|
|
38
|
+
"./errors/harness": {
|
|
39
|
+
"types": "./dist/errors/harness.d.ts",
|
|
40
|
+
"import": "./dist/errors/harness.js"
|
|
41
|
+
},
|
|
42
|
+
"./errors/verification": {
|
|
43
|
+
"types": "./dist/errors/verification.d.ts",
|
|
44
|
+
"import": "./dist/errors/verification.js"
|
|
45
|
+
},
|
|
46
|
+
"./runid": {
|
|
47
|
+
"types": "./dist/runid.d.ts",
|
|
48
|
+
"import": "./dist/runid.js"
|
|
49
|
+
},
|
|
50
|
+
"./secrets": {
|
|
51
|
+
"types": "./dist/secrets.d.ts",
|
|
52
|
+
"import": "./dist/secrets.js"
|
|
53
|
+
},
|
|
54
|
+
"./hashing": {
|
|
55
|
+
"types": "./dist/hashing.d.ts",
|
|
56
|
+
"import": "./dist/hashing.js"
|
|
57
|
+
},
|
|
58
|
+
"./secret-vault": {
|
|
59
|
+
"types": "./dist/secret-vault.d.ts",
|
|
60
|
+
"import": "./dist/secret-vault.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -b tsconfig.json",
|
|
65
|
+
"typecheck": "tsc -b tsconfig.json",
|
|
66
|
+
"test": "vitest run"
|
|
67
|
+
},
|
|
68
|
+
"files": [
|
|
69
|
+
"dist"
|
|
70
|
+
],
|
|
71
|
+
"sideEffects": false,
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=22"
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"@oscharko-dev/keiko-contracts": "0.2.0"
|
|
77
|
+
}
|
|
78
|
+
}
|