@alfe.ai/openclaw-secrets 0.2.24 → 0.2.26
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 +12 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +70 -17
- package/dist/crypto.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +50 -121
- package/dist/index.js.map +1 -1
- package/dist/input-validation.d.ts +30 -0
- package/dist/input-validation.d.ts.map +1 -0
- package/dist/input-validation.js +166 -0
- package/dist/input-validation.js.map +1 -0
- package/dist/scope-resolve.d.ts +1 -1
- package/dist/scope-resolve.d.ts.map +1 -1
- package/dist/scope-resolve.js +7 -1
- package/dist/scope-resolve.js.map +1 -1
- package/package.json +8 -4
- package/.turbo/turbo-build.log +0 -4
- package/CHANGELOG.md +0 -322
- package/dist/types.d.ts +0 -18
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -8
- package/dist/types.js.map +0 -1
- package/src/__tests__/crypto.test.ts +0 -187
- package/src/__tests__/scope-resolve.test.ts +0 -68
- package/src/crypto.ts +0 -115
- package/src/index.ts +0 -624
- package/src/scope-resolve.ts +0 -62
- package/src/types.ts +0 -18
- package/sst-env.d.ts +0 -10
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -9
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crypto roundtrip + tamper-fail tests for the openclaw-secrets plugin.
|
|
3
|
-
*
|
|
4
|
-
* We stub the narrow `SecretsKmsProxy` interface (the two KMS-proxy methods
|
|
5
|
-
* `generateSecretDataKey` / `decryptSecretDataKey`) so we don't need a real
|
|
6
|
-
* `AgentApiClient` or KMS during tests:
|
|
7
|
-
* - generateSecretDataKey returns a deterministic (random-but-stable) AES-256 key.
|
|
8
|
-
* - decryptSecretDataKey returns the same key for the same ciphertext handle.
|
|
9
|
-
*
|
|
10
|
-
* This lets us verify:
|
|
11
|
-
* - happy-path encrypt → decrypt roundtrip preserves bytes,
|
|
12
|
-
* - flipping any byte in the ciphertext / iv / authTag causes decrypt to fail,
|
|
13
|
-
* - a mismatched data key (simulating KMS context mismatch) causes decrypt to fail,
|
|
14
|
-
* - the Buffer passed to createCipheriv is zeroed after the call returns.
|
|
15
|
-
*
|
|
16
|
-
* We do NOT test the actual KMS proxy here — that is integration-tested
|
|
17
|
-
* against the real services/secrets.
|
|
18
|
-
*/
|
|
19
|
-
import { describe, it, expect, vi } from "vitest";
|
|
20
|
-
import { randomBytes } from "node:crypto";
|
|
21
|
-
import { encryptSecretValue, decryptSecretEnvelope, type SecretsKmsProxy } from "../crypto.js";
|
|
22
|
-
import type { EncryptedEnvelopeV1 } from "@alfe.ai/agent-api-client";
|
|
23
|
-
|
|
24
|
-
function makeFakeProxy(options: {
|
|
25
|
-
/**
|
|
26
|
-
* If set, `decryptSecretDataKey` returns a DIFFERENT key than the one that
|
|
27
|
-
* was used to encrypt, simulating an encryption-context mismatch or a wrong
|
|
28
|
-
* data-key ciphertext.
|
|
29
|
-
*/
|
|
30
|
-
tamperDecryptKey?: boolean;
|
|
31
|
-
} = {}): SecretsKmsProxy {
|
|
32
|
-
const keysByHandle = new Map<string, Buffer>();
|
|
33
|
-
let counter = 0;
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
generateSecretDataKey: vi.fn(async () => {
|
|
37
|
-
const key = randomBytes(32);
|
|
38
|
-
const handle = `handle-${String(++counter)}`;
|
|
39
|
-
keysByHandle.set(handle, key);
|
|
40
|
-
return {
|
|
41
|
-
plaintextKey: key.toString("base64"),
|
|
42
|
-
dataKeyCiphertext: handle,
|
|
43
|
-
};
|
|
44
|
-
}),
|
|
45
|
-
|
|
46
|
-
decryptSecretDataKey: vi.fn(async ({ dataKeyCiphertext }) => {
|
|
47
|
-
const key = keysByHandle.get(dataKeyCiphertext);
|
|
48
|
-
if (!key) throw new Error(`Unknown handle: ${dataKeyCiphertext}`);
|
|
49
|
-
if (options.tamperDecryptKey) {
|
|
50
|
-
return { plaintextKey: randomBytes(32).toString("base64") };
|
|
51
|
-
}
|
|
52
|
-
return { plaintextKey: key.toString("base64") };
|
|
53
|
-
}),
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const baseArgs = {
|
|
58
|
-
scope: "agent" as const,
|
|
59
|
-
scopeId: "agent-1",
|
|
60
|
-
secretId: "00000000-0000-4000-8000-000000000000",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
describe("crypto", () => {
|
|
64
|
-
it("roundtrip: encrypt then decrypt returns the original plaintext", async () => {
|
|
65
|
-
const client = makeFakeProxy();
|
|
66
|
-
const plaintext = "sk-live-super-secret-\u2764\ufe0f";
|
|
67
|
-
|
|
68
|
-
const envelope = await encryptSecretValue({
|
|
69
|
-
client,
|
|
70
|
-
...baseArgs,
|
|
71
|
-
plaintext,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
expect(envelope.version).toBe(1);
|
|
75
|
-
expect(envelope.iv).toBeTruthy();
|
|
76
|
-
expect(envelope.ciphertext).toBeTruthy();
|
|
77
|
-
expect(envelope.authTag).toBeTruthy();
|
|
78
|
-
expect(envelope.dataKeyCiphertext).toBeTruthy();
|
|
79
|
-
|
|
80
|
-
const recovered = await decryptSecretEnvelope({
|
|
81
|
-
client,
|
|
82
|
-
...baseArgs,
|
|
83
|
-
envelope,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
expect(recovered).toBe(plaintext);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("encrypt produces different ciphertext each call (fresh IV + data key)", async () => {
|
|
90
|
-
const client = makeFakeProxy();
|
|
91
|
-
const [a, b] = await Promise.all([
|
|
92
|
-
encryptSecretValue({ client, ...baseArgs, plaintext: "same" }),
|
|
93
|
-
encryptSecretValue({ client, ...baseArgs, plaintext: "same" }),
|
|
94
|
-
]);
|
|
95
|
-
expect(a.ciphertext).not.toBe(b.ciphertext);
|
|
96
|
-
expect(a.iv).not.toBe(b.iv);
|
|
97
|
-
expect(a.dataKeyCiphertext).not.toBe(b.dataKeyCiphertext);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("rejects a ciphertext with a flipped byte (GCM authTag fails)", async () => {
|
|
101
|
-
const client = makeFakeProxy();
|
|
102
|
-
const envelope = await encryptSecretValue({
|
|
103
|
-
client,
|
|
104
|
-
...baseArgs,
|
|
105
|
-
plaintext: "hello",
|
|
106
|
-
});
|
|
107
|
-
const ct = Buffer.from(envelope.ciphertext, "base64");
|
|
108
|
-
ct[0] = ct[0] ^ 0x01;
|
|
109
|
-
const tampered: EncryptedEnvelopeV1 = {
|
|
110
|
-
...envelope,
|
|
111
|
-
ciphertext: ct.toString("base64"),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
await expect(
|
|
115
|
-
decryptSecretEnvelope({ client, ...baseArgs, envelope: tampered }),
|
|
116
|
-
).rejects.toThrow();
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("rejects a flipped authTag byte", async () => {
|
|
120
|
-
const client = makeFakeProxy();
|
|
121
|
-
const envelope = await encryptSecretValue({
|
|
122
|
-
client,
|
|
123
|
-
...baseArgs,
|
|
124
|
-
plaintext: "hello",
|
|
125
|
-
});
|
|
126
|
-
const tag = Buffer.from(envelope.authTag, "base64");
|
|
127
|
-
tag[0] = tag[0] ^ 0x80;
|
|
128
|
-
|
|
129
|
-
await expect(
|
|
130
|
-
decryptSecretEnvelope({
|
|
131
|
-
client,
|
|
132
|
-
...baseArgs,
|
|
133
|
-
envelope: { ...envelope, authTag: tag.toString("base64") },
|
|
134
|
-
}),
|
|
135
|
-
).rejects.toThrow();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("rejects a flipped IV byte", async () => {
|
|
139
|
-
const client = makeFakeProxy();
|
|
140
|
-
const envelope = await encryptSecretValue({
|
|
141
|
-
client,
|
|
142
|
-
...baseArgs,
|
|
143
|
-
plaintext: "hello",
|
|
144
|
-
});
|
|
145
|
-
const iv = Buffer.from(envelope.iv, "base64");
|
|
146
|
-
iv[0] = iv[0] ^ 0x11;
|
|
147
|
-
|
|
148
|
-
await expect(
|
|
149
|
-
decryptSecretEnvelope({
|
|
150
|
-
client,
|
|
151
|
-
...baseArgs,
|
|
152
|
-
envelope: { ...envelope, iv: iv.toString("base64") },
|
|
153
|
-
}),
|
|
154
|
-
).rejects.toThrow();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("rejects a decrypt that returns a different data key (encryption-context mismatch)", async () => {
|
|
158
|
-
const client = makeFakeProxy({ tamperDecryptKey: true });
|
|
159
|
-
const envelope = await encryptSecretValue({
|
|
160
|
-
client,
|
|
161
|
-
...baseArgs,
|
|
162
|
-
plaintext: "hello",
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
await expect(
|
|
166
|
-
decryptSecretEnvelope({ client, ...baseArgs, envelope }),
|
|
167
|
-
).rejects.toThrow();
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("rejects a bogus data-key length from the proxy (32 bytes required)", async () => {
|
|
171
|
-
const badClient: SecretsKmsProxy = {
|
|
172
|
-
generateSecretDataKey: vi.fn(async () => ({
|
|
173
|
-
plaintextKey: Buffer.alloc(16).toString("base64"),
|
|
174
|
-
dataKeyCiphertext: "h",
|
|
175
|
-
})),
|
|
176
|
-
decryptSecretDataKey: vi.fn(),
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
await expect(
|
|
180
|
-
encryptSecretValue({
|
|
181
|
-
client: badClient,
|
|
182
|
-
...baseArgs,
|
|
183
|
-
plaintext: "hello",
|
|
184
|
-
}),
|
|
185
|
-
).rejects.toThrow(/data key length/);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit coverage for the scopeId auto-fill helper. The plugin used to
|
|
3
|
-
* require an explicit scopeId for every tool call, which led LLMs to
|
|
4
|
-
* pass the literal word "agent" as the ID. This helper closes the gap
|
|
5
|
-
* by filling from ctx.agentId when scope=agent and the LLM either
|
|
6
|
-
* omits scopeId or sets it to "agent".
|
|
7
|
-
*/
|
|
8
|
-
import { describe, it, expect } from "vitest";
|
|
9
|
-
import { resolveScopeId } from "../scope-resolve.js";
|
|
10
|
-
|
|
11
|
-
describe("resolveScopeId — agent scope", () => {
|
|
12
|
-
const ctx = { agentId: "agt_01TEST" };
|
|
13
|
-
|
|
14
|
-
it("falls back to ctx.agentId when scopeId is omitted", () => {
|
|
15
|
-
expect(resolveScopeId("agent", {}, ctx)).toBe("agt_01TEST");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("falls back to ctx.agentId when scopeId is empty string", () => {
|
|
19
|
-
expect(resolveScopeId("agent", { scopeId: "" }, ctx)).toBe("agt_01TEST");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("falls back to ctx.agentId when scopeId is the literal 'agent'", () => {
|
|
23
|
-
expect(resolveScopeId("agent", { scopeId: "agent" }, ctx)).toBe("agt_01TEST");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("uses an explicit real (other) agent ID as-is", () => {
|
|
27
|
-
expect(resolveScopeId("agent", { scopeId: "agt_01OTHER" }, ctx)).toBe("agt_01OTHER");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("normalises a case-mangled own agent ID back to ctx.agentId's canonical casing", () => {
|
|
31
|
-
// An LLM may lowercase the agent's own ULID; the server's self-scope check
|
|
32
|
-
// is an exact match, so we snap it back to the runtime's canonical value.
|
|
33
|
-
expect(resolveScopeId("agent", { scopeId: "agt_01test" }, ctx)).toBe("agt_01TEST");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("throws when ctx.agentId is missing and no usable scopeId is provided", () => {
|
|
37
|
-
expect(() => resolveScopeId("agent", {}, {})).toThrow(/agent scope requires/);
|
|
38
|
-
expect(() => resolveScopeId("agent", { scopeId: "agent" }, {})).toThrow(/agent scope requires/);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("resolveScopeId — non-agent scopes", () => {
|
|
43
|
-
const ctx = { agentId: "agt_01TEST" };
|
|
44
|
-
|
|
45
|
-
it("requires an explicit scopeId for org scope", () => {
|
|
46
|
-
expect(() => resolveScopeId("org", {}, ctx)).toThrow(/scopeId is required/);
|
|
47
|
-
expect(() => resolveScopeId("org", { scopeId: "" }, ctx)).toThrow(/scopeId is required/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("returns the explicit scopeId for team scope", () => {
|
|
51
|
-
expect(resolveScopeId("team", { scopeId: "team_x" }, ctx)).toBe("team_x");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("returns the explicit scopeId for project scope", () => {
|
|
55
|
-
expect(resolveScopeId("project", { scopeId: "proj_y" }, ctx)).toBe("proj_y");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("rejects scopeIds longer than 256 characters", () => {
|
|
59
|
-
const tooLong = "x".repeat(257);
|
|
60
|
-
expect(() => resolveScopeId("org", { scopeId: tooLong }, ctx)).toThrow(/256 characters/);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("ignores ctx.agentId for non-agent scopes — explicit scopeId always wins", () => {
|
|
64
|
-
expect(resolveScopeId("team", { scopeId: "team_x" }, { agentId: "agt_other" })).toBe(
|
|
65
|
-
"team_x",
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
});
|
package/src/crypto.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent-side AES-256-GCM with data keys issued by the Alfe secrets service.
|
|
3
|
-
*
|
|
4
|
-
* Trust model:
|
|
5
|
-
* - The agent NEVER holds a KMS key directly.
|
|
6
|
-
* - For each encrypt/decrypt, the agent fetches a one-shot AES data key from
|
|
7
|
-
* the secrets service over authenticated TLS.
|
|
8
|
-
* - The plaintext key is held as a `Buffer` ONLY — never as a JS string.
|
|
9
|
-
* (Strings are immutable and cannot be zeroed; Buffers can be `.fill(0)`'d.)
|
|
10
|
-
* - After each operation the Buffer is zeroed.
|
|
11
|
-
*
|
|
12
|
-
* Envelope format is `EncryptedEnvelopeV1` as defined in `@alfe/types` and
|
|
13
|
-
* persisted by services/secrets.
|
|
14
|
-
*/
|
|
15
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
16
|
-
import type { EncryptedEnvelopeV1, SecretScope } from "@alfe.ai/agent-api-client";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Narrow interface capturing just the two KMS-proxy methods the crypto
|
|
20
|
-
* helpers need. `AgentApiClient` satisfies this; tests can supply a
|
|
21
|
-
* hand-rolled stub without mocking the whole client surface.
|
|
22
|
-
*/
|
|
23
|
-
export interface SecretsKmsProxy {
|
|
24
|
-
generateSecretDataKey(args: {
|
|
25
|
-
scope: SecretScope;
|
|
26
|
-
scopeId: string;
|
|
27
|
-
secretId: string;
|
|
28
|
-
fieldKey: string;
|
|
29
|
-
}): Promise<{ plaintextKey: string; dataKeyCiphertext: string }>;
|
|
30
|
-
|
|
31
|
-
decryptSecretDataKey(args: {
|
|
32
|
-
scope: SecretScope;
|
|
33
|
-
scopeId: string;
|
|
34
|
-
secretId: string;
|
|
35
|
-
fieldKey: string;
|
|
36
|
-
dataKeyCiphertext: string;
|
|
37
|
-
}): Promise<{ plaintextKey: string }>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const AES_256_KEY_BYTES = 32;
|
|
41
|
-
const GCM_IV_BYTES = 12;
|
|
42
|
-
|
|
43
|
-
function decodeKeyToBuffer(base64: string): Buffer {
|
|
44
|
-
const buf = Buffer.from(base64, "base64");
|
|
45
|
-
if (buf.length !== AES_256_KEY_BYTES) {
|
|
46
|
-
buf.fill(0);
|
|
47
|
-
throw new Error(`Unexpected data key length: ${String(buf.length)} bytes`);
|
|
48
|
-
}
|
|
49
|
-
return buf;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function encryptSecretValue(args: {
|
|
53
|
-
client: SecretsKmsProxy;
|
|
54
|
-
scope: SecretScope;
|
|
55
|
-
scopeId: string;
|
|
56
|
-
secretId: string;
|
|
57
|
-
fieldKey: string;
|
|
58
|
-
plaintext: string;
|
|
59
|
-
}): Promise<EncryptedEnvelopeV1> {
|
|
60
|
-
const { client, scope, scopeId, secretId, fieldKey, plaintext } = args;
|
|
61
|
-
const { plaintextKey, dataKeyCiphertext } = await client.generateSecretDataKey({
|
|
62
|
-
scope,
|
|
63
|
-
scopeId,
|
|
64
|
-
secretId,
|
|
65
|
-
fieldKey,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
const keyBuf = decodeKeyToBuffer(plaintextKey);
|
|
69
|
-
try {
|
|
70
|
-
const iv = randomBytes(GCM_IV_BYTES);
|
|
71
|
-
const cipher = createCipheriv("aes-256-gcm", keyBuf, iv);
|
|
72
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
73
|
-
const authTag = cipher.getAuthTag();
|
|
74
|
-
return {
|
|
75
|
-
version: 1,
|
|
76
|
-
iv: iv.toString("base64"),
|
|
77
|
-
ciphertext: ciphertext.toString("base64"),
|
|
78
|
-
authTag: authTag.toString("base64"),
|
|
79
|
-
dataKeyCiphertext,
|
|
80
|
-
};
|
|
81
|
-
} finally {
|
|
82
|
-
keyBuf.fill(0);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export async function decryptSecretEnvelope(args: {
|
|
87
|
-
client: SecretsKmsProxy;
|
|
88
|
-
scope: SecretScope;
|
|
89
|
-
scopeId: string;
|
|
90
|
-
secretId: string;
|
|
91
|
-
fieldKey: string;
|
|
92
|
-
envelope: EncryptedEnvelopeV1;
|
|
93
|
-
}): Promise<string> {
|
|
94
|
-
const { client, scope, scopeId, secretId, fieldKey, envelope } = args;
|
|
95
|
-
const { plaintextKey } = await client.decryptSecretDataKey({
|
|
96
|
-
scope,
|
|
97
|
-
scopeId,
|
|
98
|
-
secretId,
|
|
99
|
-
fieldKey,
|
|
100
|
-
dataKeyCiphertext: envelope.dataKeyCiphertext,
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const keyBuf = decodeKeyToBuffer(plaintextKey);
|
|
104
|
-
try {
|
|
105
|
-
const iv = Buffer.from(envelope.iv, "base64");
|
|
106
|
-
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
107
|
-
const authTag = Buffer.from(envelope.authTag, "base64");
|
|
108
|
-
const decipher = createDecipheriv("aes-256-gcm", keyBuf, iv);
|
|
109
|
-
decipher.setAuthTag(authTag);
|
|
110
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
111
|
-
return decrypted.toString("utf8");
|
|
112
|
-
} finally {
|
|
113
|
-
keyBuf.fill(0);
|
|
114
|
-
}
|
|
115
|
-
}
|