@veducio/keja 0.2.3 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veducio/keja",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "description": "Veducio Keja - RESTFUL Web Server, Based on Lightweight Hono Framework, Written in TypeScript, Powered by Bun",
6
6
  "license": "MIT",
@@ -0,0 +1,130 @@
1
+ import { decodeJwt, decodeProtectedHeader } from "jose";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { connectVault, setVault, VaultError } from "../vault/client.ts";
4
+ import { grant, verifyGrant } from "./grant.ts";
5
+
6
+ const settings = {
7
+ address: "https://vault.example",
8
+ token: "s.token",
9
+ namespace: "apps" as string | null,
10
+ mount: "secret",
11
+ transit_key: "keja-grants" as string | null,
12
+ transit_mount: "transit",
13
+ grant_max_exp: 900,
14
+ };
15
+
16
+ afterEach(() => {
17
+ setVault(null);
18
+ });
19
+
20
+ describe("grant", () => {
21
+ it("signs a subject, action, expiry, and context with the transit key", async () => {
22
+ const calls: { url: string; body: unknown }[] = [];
23
+ await connect(calls, { type: "ecdsa-p256", valid: true });
24
+ const now = 1_700_000_000_000;
25
+ const clock = Date.now;
26
+ Date.now = () => now;
27
+ try {
28
+ const token = await grant({
29
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
30
+ action: "posts.read",
31
+ exp: 60,
32
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
33
+ });
34
+ expect(decodeProtectedHeader(token)).toEqual({
35
+ alg: "ES256",
36
+ typ: "JWT",
37
+ kid: "1",
38
+ });
39
+ expect(decodeJwt(token)).toMatchObject({
40
+ sub: "tenant_01h455vb4pex5vsknk084sn02q",
41
+ action: "posts.read",
42
+ iat: 1_700_000_000,
43
+ exp: 1_700_000_060,
44
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
45
+ });
46
+ const sign = calls.find((call) => call.url.endsWith("/sign/keja-grants"));
47
+ expect(sign?.body).toMatchObject({
48
+ hash_algorithm: "sha2-256",
49
+ marshaling_algorithm: "jws",
50
+ });
51
+ await expect(
52
+ verifyGrant(token, { action: "posts.read" }),
53
+ ).resolves.toEqual({
54
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
55
+ action: "posts.read",
56
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
57
+ });
58
+ Date.now = () => now + 120_000;
59
+ await expect(
60
+ verifyGrant(token, { action: "posts.read" }),
61
+ ).rejects.toThrow("grant is expired");
62
+ Date.now = () => now;
63
+ await expect(
64
+ verifyGrant(token, { action: "posts.write" }),
65
+ ).rejects.toThrow("grant action does not match");
66
+ } finally {
67
+ Date.now = clock;
68
+ }
69
+ });
70
+
71
+ it("rejects a lifetime past the configured maximum", async () => {
72
+ await connect([], { type: "rsa-2048", valid: true });
73
+ await expect(
74
+ grant({ subject: "tenant", action: "posts.read", exp: 901 }),
75
+ ).rejects.toThrow("grant exp must be at most 900 seconds");
76
+ });
77
+
78
+ it("rejects a signature Vault does not accept", async () => {
79
+ await connect([], { type: "rsa-2048", valid: false });
80
+ const now = Date.now;
81
+ Date.now = () => 1_700_000_000_000;
82
+ try {
83
+ const token = await grant({
84
+ subject: "tenant",
85
+ action: "posts.read",
86
+ exp: 60,
87
+ });
88
+ await expect(
89
+ verifyGrant(token, { action: "posts.read" }),
90
+ ).rejects.toThrow("grant signature was rejected");
91
+ } finally {
92
+ Date.now = now;
93
+ }
94
+ });
95
+
96
+ it("requires vault and a transit key", async () => {
97
+ await expect(
98
+ grant({ subject: "tenant", action: "posts.read", exp: 60 }),
99
+ ).rejects.toThrow("grant requires vault");
100
+ await connect([], { type: "rsa-2048", valid: true });
101
+ setVault({ ...settings, transit_key: null });
102
+ await expect(
103
+ grant({ subject: "tenant", action: "posts.read", exp: 60 }),
104
+ ).rejects.toBeInstanceOf(VaultError);
105
+ });
106
+ });
107
+
108
+ async function connect(
109
+ calls: { url: string; body: unknown }[],
110
+ result: { type: string; valid: boolean },
111
+ ) {
112
+ await connectVault(settings, async (url, init) => {
113
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
114
+ calls.push({ url, body });
115
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
116
+ return Response.json({ data: { id: "s.token" } });
117
+ }
118
+ if (url.endsWith("/keys/keja-grants")) {
119
+ return Response.json({ data: { type: result.type, latest_version: 1 } });
120
+ }
121
+ if (url.endsWith("/sign/keja-grants")) {
122
+ return Response.json({
123
+ data: {
124
+ signature: `vault:v1:${Buffer.from("signed").toString("base64")}`,
125
+ },
126
+ });
127
+ }
128
+ return Response.json({ data: { valid: result.valid } });
129
+ });
130
+ }
@@ -0,0 +1,268 @@
1
+ import { decodeJwt, decodeProtectedHeader, type JWTPayload } from "jose";
2
+ import {
3
+ openVault,
4
+ requireVaultSettings,
5
+ VaultError,
6
+ } from "../vault/client.ts";
7
+
8
+ export type Grant = {
9
+ subject: string;
10
+ action: string;
11
+ context: Record<string, unknown>;
12
+ };
13
+
14
+ type GrantInput = {
15
+ subject: string;
16
+ action: string;
17
+ exp: number;
18
+ context?: Record<string, unknown>;
19
+ };
20
+
21
+ type KeySpec = {
22
+ alg: string;
23
+ hash?: string;
24
+ marshaling?: string;
25
+ signature?: string;
26
+ };
27
+
28
+ const keySpecs: Record<string, KeySpec> = {
29
+ "rsa-2048": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
30
+ "rsa-3072": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
31
+ "rsa-4096": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
32
+ "ecdsa-p256": { alg: "ES256", hash: "sha2-256", marshaling: "jws" },
33
+ "ecdsa-p384": { alg: "ES384", hash: "sha2-384", marshaling: "jws" },
34
+ "ecdsa-p521": { alg: "ES512", hash: "sha2-512", marshaling: "jws" },
35
+ ed25519: { alg: "EdDSA" },
36
+ };
37
+
38
+ export async function grant(input: GrantInput) {
39
+ const settings = grantSettings("grant");
40
+ const subject = requiredText(input.subject, "subject");
41
+ const action = requiredText(input.action, "action");
42
+ const lifetime = lifetimeSeconds(input.exp, settings.grant_max_exp);
43
+ const context = contextObject(input.context);
44
+ const vault = openVault("grant");
45
+ const key = await readKey(vault, settings);
46
+ const now = Math.floor(Date.now() / 1000);
47
+ const body = base64url(
48
+ JSON.stringify({
49
+ sub: subject,
50
+ action,
51
+ iat: now,
52
+ exp: now + lifetime,
53
+ context,
54
+ }),
55
+ );
56
+ const header = base64url(
57
+ JSON.stringify({ alg: key.alg, typ: "JWT", kid: String(key.version) }),
58
+ );
59
+ const signingInput = `${header}.${body}`;
60
+ const signed = await vault.transitSign(
61
+ settings.transit_mount,
62
+ settings.transit_key,
63
+ signBody(key, base64(signingInput)),
64
+ );
65
+ const signature = vaultSignature(signed.signature);
66
+ if (signature.version !== String(key.version)) {
67
+ throw new VaultError("Vault signed the grant with a different key version");
68
+ }
69
+ return `${signingInput}.${signature.segment}`;
70
+ }
71
+
72
+ export async function verifyGrant(token: string, options: { action: string }) {
73
+ const settings = grantSettings("verifyGrant");
74
+ const action = requiredText(options.action, "action");
75
+ const parts = token.split(".");
76
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
77
+ throw new VaultError("grant token is unreadable");
78
+ }
79
+ const header = decodeProtectedHeader(token);
80
+ const payload = decodeJwt(token);
81
+ const vault = openVault("verifyGrant");
82
+ const key = await readKey(vault, settings);
83
+ if (
84
+ header.alg !== key.alg ||
85
+ typeof header.kid !== "string" ||
86
+ !/^\d+$/.test(header.kid)
87
+ ) {
88
+ throw new VaultError("grant token is unreadable");
89
+ }
90
+ const verified = await vault.transitVerify(
91
+ settings.transit_mount,
92
+ settings.transit_key,
93
+ {
94
+ ...signBody(key, base64(`${parts[0]}.${parts[1]}`)),
95
+ signature: `vault:v${header.kid}:${Buffer.from(parts[2], "base64url").toString("base64")}`,
96
+ },
97
+ );
98
+ if (verified.valid !== true) {
99
+ throw new VaultError("grant signature was rejected");
100
+ }
101
+ return claims(payload, action);
102
+ }
103
+
104
+ function grantSettings(feature: "grant" | "verifyGrant") {
105
+ const settings = requireVaultSettings(feature);
106
+ if (!settings.transit_key) {
107
+ throw new VaultError("vault transit key is required");
108
+ }
109
+ return settings as typeof settings & { transit_key: string };
110
+ }
111
+
112
+ async function readKey(
113
+ vault: ReturnType<typeof openVault>,
114
+ settings: { transit_mount: string; transit_key: string },
115
+ ) {
116
+ const data = await vault.transitKey(
117
+ settings.transit_mount,
118
+ settings.transit_key,
119
+ );
120
+ if (typeof data.type !== "string" || !(data.type in keySpecs)) {
121
+ throw new VaultError("Vault transit key type cannot sign a grant");
122
+ }
123
+ const version = data.latest_version;
124
+ if (
125
+ typeof version !== "number" ||
126
+ !Number.isInteger(version) ||
127
+ version < 1
128
+ ) {
129
+ throw new VaultError("Vault transit key has no version");
130
+ }
131
+ return { ...keySpecs[data.type], version };
132
+ }
133
+
134
+ function signBody(
135
+ key: KeySpec,
136
+ input: string,
137
+ ): {
138
+ input: string;
139
+ hash_algorithm?: string;
140
+ marshaling_algorithm?: string;
141
+ signature_algorithm?: string;
142
+ } {
143
+ return {
144
+ input,
145
+ ...(key.hash ? { hash_algorithm: key.hash } : {}),
146
+ ...(key.marshaling ? { marshaling_algorithm: key.marshaling } : {}),
147
+ ...(key.signature ? { signature_algorithm: key.signature } : {}),
148
+ };
149
+ }
150
+
151
+ function vaultSignature(value: unknown) {
152
+ if (typeof value !== "string") {
153
+ throw new VaultError("Vault returned an unreadable signature");
154
+ }
155
+ const match = /^vault:v(\d+):([A-Za-z0-9+/=_-]+)$/.exec(value);
156
+ if (!match) {
157
+ throw new VaultError("Vault returned an unreadable signature");
158
+ }
159
+ return {
160
+ version: match[1],
161
+ segment: Buffer.from(match[2], "base64").toString("base64url"),
162
+ };
163
+ }
164
+
165
+ function claims(payload: JWTPayload, action: string): Grant {
166
+ const now = Math.floor(Date.now() / 1000);
167
+ if (
168
+ typeof payload.exp !== "number" ||
169
+ !Number.isInteger(payload.exp) ||
170
+ payload.exp <= now
171
+ ) {
172
+ throw new VaultError("grant is expired");
173
+ }
174
+ if (
175
+ typeof payload.iat !== "number" ||
176
+ !Number.isInteger(payload.iat) ||
177
+ payload.iat > now ||
178
+ payload.iat >= payload.exp
179
+ ) {
180
+ throw new VaultError("grant token is unreadable");
181
+ }
182
+ if (typeof payload.sub !== "string" || payload.sub.trim() === "") {
183
+ throw new VaultError("grant token is unreadable");
184
+ }
185
+ if (payload.action !== action) {
186
+ throw new VaultError("grant action does not match");
187
+ }
188
+ if (!isContext(payload.context)) {
189
+ throw new VaultError("grant context must be a plain object");
190
+ }
191
+ return {
192
+ subject: payload.sub,
193
+ action,
194
+ context: payload.context,
195
+ };
196
+ }
197
+
198
+ function requiredText(value: unknown, field: string) {
199
+ if (
200
+ typeof value !== "string" ||
201
+ value.trim() === "" ||
202
+ value.trim() !== value
203
+ ) {
204
+ throw new VaultError(`grant ${field} must be a non-empty string`);
205
+ }
206
+ return value;
207
+ }
208
+
209
+ function lifetimeSeconds(value: unknown, max: number) {
210
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
211
+ throw new VaultError("grant exp must be a whole number of seconds");
212
+ }
213
+ if (value > max) {
214
+ throw new VaultError(`grant exp must be at most ${max} seconds`);
215
+ }
216
+ return value;
217
+ }
218
+
219
+ function contextObject(value: unknown) {
220
+ if (value === undefined) {
221
+ return {};
222
+ }
223
+ if (!isContext(value)) {
224
+ throw new VaultError("grant context must be a plain object");
225
+ }
226
+ return value;
227
+ }
228
+
229
+ function isContext(value: unknown): value is Record<string, unknown> {
230
+ if (!isPlain(value)) {
231
+ return false;
232
+ }
233
+ return Object.values(value).every((item) => isJson(item));
234
+ }
235
+
236
+ function isJson(value: unknown): boolean {
237
+ if (
238
+ value === null ||
239
+ typeof value === "string" ||
240
+ typeof value === "boolean" ||
241
+ (typeof value === "number" && Number.isFinite(value))
242
+ ) {
243
+ return true;
244
+ }
245
+ if (Array.isArray(value)) {
246
+ return value.every((item) => isJson(item));
247
+ }
248
+ if (!isPlain(value)) {
249
+ return false;
250
+ }
251
+ return Object.values(value).every((item) => isJson(item));
252
+ }
253
+
254
+ function isPlain(value: unknown): value is Record<string, unknown> {
255
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
256
+ return false;
257
+ }
258
+ const proto = Object.getPrototypeOf(value);
259
+ return proto === Object.prototype || proto === null;
260
+ }
261
+
262
+ function base64url(value: string) {
263
+ return Buffer.from(value).toString("base64url");
264
+ }
265
+
266
+ function base64(value: string) {
267
+ return Buffer.from(value).toString("base64");
268
+ }
@@ -100,6 +100,9 @@ describe("defineConfig", () => {
100
100
  token: "s.token",
101
101
  namespace: "apps",
102
102
  mount: "kv",
103
+ transit_key: null,
104
+ transit_mount: "transit",
105
+ grant_max_exp: 900,
103
106
  });
104
107
  expect(defineConfig().vault).toEqual({ enabled: false });
105
108
  });
@@ -49,6 +49,9 @@ export type VaultSettings = {
49
49
  token: string;
50
50
  namespace: string | null;
51
51
  mount: string;
52
+ transit_key: string | null;
53
+ transit_mount: string;
54
+ grant_max_exp: number;
52
55
  };
53
56
 
54
57
  export type VaultConfigInput = {
@@ -57,6 +60,9 @@ export type VaultConfigInput = {
57
60
  token?: string;
58
61
  namespace?: string;
59
62
  mount?: string;
63
+ transit_key?: string;
64
+ transit_mount?: string;
65
+ grant_max_exp?: number;
60
66
  };
61
67
 
62
68
  export type VaultConfig =
@@ -289,6 +295,15 @@ function configVault(value: VaultConfigInput | undefined): VaultConfig {
289
295
  ),
290
296
  namespace: optionalText(namespace, "vault.namespace", "namespace"),
291
297
  mount: vaultMount(value.mount ?? configEnv("VAULT_KV_MOUNT") ?? "secret"),
298
+ transit_key:
299
+ value.transit_key === undefined
300
+ ? null
301
+ : vaultName(value.transit_key, "vault.transit_key"),
302
+ transit_mount: vaultName(
303
+ value.transit_mount ?? "transit",
304
+ "vault.transit_mount",
305
+ ),
306
+ grant_max_exp: grantMaxExp(value.grant_max_exp),
292
307
  };
293
308
  }
294
309
 
@@ -325,14 +340,36 @@ function absoluteHttpUrl(value: unknown, field: string) {
325
340
  }
326
341
 
327
342
  function vaultMount(value: string) {
328
- const mount = value.trim().replace(/^\/+|\/+$/g, "");
329
- if (!/^[A-Za-z0-9_-]+$/.test(mount)) {
343
+ return vaultName(value, "vault.mount");
344
+ }
345
+
346
+ function vaultName(value: string, field: string) {
347
+ const name = value.trim().replace(/^\/+|\/+$/g, "");
348
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
349
+ throw projectError(
350
+ "invalidConfigFile",
351
+ `keja.config.ts ${field} must be a name`,
352
+ );
353
+ }
354
+ return name;
355
+ }
356
+
357
+ function grantMaxExp(value: number | undefined) {
358
+ if (value === undefined) {
359
+ return 900;
360
+ }
361
+ if (
362
+ typeof value !== "number" ||
363
+ !Number.isInteger(value) ||
364
+ value < 1 ||
365
+ value > 86400
366
+ ) {
330
367
  throw projectError(
331
368
  "invalidConfigFile",
332
- `keja.config.ts vault.mount must be a mount name`,
369
+ "keja.config.ts vault.grant_max_exp must be a whole number of seconds from 1 to 86400",
333
370
  );
334
371
  }
335
- return mount;
372
+ return value;
336
373
  }
337
374
 
338
375
  function configEnv(name: string) {
@@ -24,6 +24,8 @@ export {
24
24
  DependencyNotMet,
25
25
  frameworkErrors,
26
26
  } from "../errors/framework-errors.ts";
27
+ export type { Grant } from "../grant/grant.ts";
28
+ export { grant, verifyGrant } from "../grant/grant.ts";
27
29
  export { defineConfig } from "../project/config.ts";
28
30
  export { defineRepositories } from "../repository/define.ts";
29
31
  export { repository } from "../repository/runtime.ts";
@@ -85,6 +85,9 @@ describe("createVault", () => {
85
85
  token: "s.token",
86
86
  namespace: null,
87
87
  mount: "secret",
88
+ transit_key: null,
89
+ transit_mount: "transit",
90
+ grant_max_exp: 900,
88
91
  },
89
92
  async (url) => {
90
93
  urls.push(url);
@@ -108,6 +111,9 @@ describe("createVault", () => {
108
111
  token: "s.token",
109
112
  namespace: null,
110
113
  mount: "secret",
114
+ transit_key: null,
115
+ transit_mount: "transit",
116
+ grant_max_exp: 900,
111
117
  },
112
118
  async () =>
113
119
  Response.json({ errors: ["permission denied"] }, { status: 403 }),
@@ -62,6 +62,7 @@ export function createVault(options: VaultClientOptions = {}) {
62
62
  options.mount ?? envValue("VAULT_KV_MOUNT") ?? "secret",
63
63
  );
64
64
  const fetchImpl = options.fetch ?? fetch;
65
+ const headers = () => vaultHeaders(token, namespace);
65
66
  return {
66
67
  async secret<
67
68
  Data extends Record<string, unknown> = Record<string, unknown>,
@@ -70,7 +71,7 @@ export function createVault(options: VaultClientOptions = {}) {
70
71
  const response = await fetchImpl(
71
72
  `${address}/v1/${mount}/data/${secretPath}`,
72
73
  {
73
- headers: vaultHeaders(token, namespace),
74
+ headers: headers(),
74
75
  },
75
76
  );
76
77
  if (!response.ok) {
@@ -84,9 +85,76 @@ export function createVault(options: VaultClientOptions = {}) {
84
85
  }
85
86
  return body.data.data;
86
87
  },
88
+ transitKey(mountName: string, key: string) {
89
+ const transit = secretMount(mountName);
90
+ const name = secretMount(key);
91
+ return vaultSend<{ type?: unknown; latest_version?: unknown }>(
92
+ fetchImpl,
93
+ `${address}/v1/${transit}/keys/${name}`,
94
+ { headers: headers() },
95
+ `${transit}/keys/${name}`,
96
+ );
97
+ },
98
+ transitSign(mountName: string, key: string, body: TransitSignBody) {
99
+ const transit = secretMount(mountName);
100
+ const name = secretMount(key);
101
+ return vaultSend<{ signature?: unknown }>(
102
+ fetchImpl,
103
+ `${address}/v1/${transit}/sign/${name}`,
104
+ {
105
+ method: "POST",
106
+ headers: jsonHeaders(headers()),
107
+ body: JSON.stringify(body),
108
+ },
109
+ `${transit}/sign/${name}`,
110
+ );
111
+ },
112
+ transitVerify(mountName: string, key: string, body: TransitVerifyBody) {
113
+ const transit = secretMount(mountName);
114
+ const name = secretMount(key);
115
+ return vaultSend<{ valid?: unknown }>(
116
+ fetchImpl,
117
+ `${address}/v1/${transit}/verify/${name}`,
118
+ {
119
+ method: "POST",
120
+ headers: jsonHeaders(headers()),
121
+ body: JSON.stringify(body),
122
+ },
123
+ `${transit}/verify/${name}`,
124
+ );
125
+ },
87
126
  };
88
127
  }
89
128
 
129
+ export function openVault(feature: string) {
130
+ const settings = requireVaultSettings(feature);
131
+ return createVault({
132
+ address: settings.address,
133
+ token: settings.token,
134
+ namespace: settings.namespace ?? undefined,
135
+ mount: settings.mount,
136
+ fetch: state.fetch,
137
+ });
138
+ }
139
+
140
+ export function requireVaultSettings(feature: string) {
141
+ if (!state.active) {
142
+ throw new DependencyNotMet(feature, "vault");
143
+ }
144
+ return state.active;
145
+ }
146
+
147
+ type TransitSignBody = {
148
+ input: string;
149
+ hash_algorithm?: string;
150
+ marshaling_algorithm?: string;
151
+ signature_algorithm?: string;
152
+ };
153
+
154
+ type TransitVerifyBody = TransitSignBody & {
155
+ signature: string;
156
+ };
157
+
90
158
  export function vaultSecret<
91
159
  Data extends Record<string, unknown> = Record<string, unknown>,
92
160
  >(path: string, options?: VaultClientOptions) {
@@ -141,6 +209,29 @@ function normalizePath(path: string) {
141
209
  return parts.map((part) => encodeURIComponent(part)).join("/");
142
210
  }
143
211
 
212
+ async function vaultSend<Data>(
213
+ fetchImpl: VaultClientOptions["fetch"],
214
+ url: string,
215
+ init: RequestInit,
216
+ path: string,
217
+ ) {
218
+ const fetch = fetchImpl ?? globalThis.fetch;
219
+ const response = await fetch(url, init);
220
+ if (!response.ok) {
221
+ throw await vaultFailure(response, path);
222
+ }
223
+ const body = (await response.json()) as { data?: Data };
224
+ if (!body.data || typeof body.data !== "object") {
225
+ throw new VaultError(`Vault ${path} has no data`);
226
+ }
227
+ return body.data;
228
+ }
229
+
230
+ function jsonHeaders(headers: Headers) {
231
+ headers.set("Content-Type", "application/json");
232
+ return headers;
233
+ }
234
+
144
235
  function vaultHeaders(token: string, namespace: string | null) {
145
236
  const headers = new Headers({ "X-Vault-Token": token });
146
237
  if (namespace) {