@dorigjo/besa 0.1.0-alpha.2 → 0.1.0-beta.5

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/io.js ADDED
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ export const MAX_ARTIFACT_BYTES = 1_048_576;
5
+ export function readUtf8File(path, maximumBytes = MAX_ARTIFACT_BYTES) {
6
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) {
7
+ throw new TypeError("maximumBytes must be a positive safe integer");
8
+ }
9
+ const descriptor = openSync(path, "r");
10
+ try {
11
+ if (!fstatSync(descriptor).isFile()) {
12
+ throw new Error(`refusing to read non-regular file at ${path}`);
13
+ }
14
+ const buffer = Buffer.allocUnsafe(maximumBytes + 1);
15
+ let total = 0;
16
+ while (total <= maximumBytes) {
17
+ const count = readSync(descriptor, buffer, total, maximumBytes + 1 - total, null);
18
+ if (count === 0)
19
+ break;
20
+ total += count;
21
+ }
22
+ if (total > maximumBytes) {
23
+ throw new Error(`file at ${path} exceeds the ${String(maximumBytes)} byte limit`);
24
+ }
25
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, total));
26
+ }
27
+ finally {
28
+ closeSync(descriptor);
29
+ }
30
+ }
31
+ export function readJsonFile(path) {
32
+ try {
33
+ return JSON.parse(readUtf8File(path));
34
+ }
35
+ catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ throw new Error(`invalid JSON at ${path}: ${message}`);
38
+ }
39
+ }
40
+ export function writeJsonAtomic(path, value, mode = 0o600) {
41
+ const contents = JSON.stringify(value, null, 2) + "\n";
42
+ if (Buffer.byteLength(contents, "utf8") > MAX_ARTIFACT_BYTES) {
43
+ throw new Error(`refusing to write oversized JSON artifact at ${path}`);
44
+ }
45
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
+ const temporaryPath = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
47
+ let descriptor;
48
+ try {
49
+ descriptor = openSync(temporaryPath, "wx", mode);
50
+ writeFileSync(descriptor, contents, "utf8");
51
+ fsyncSync(descriptor);
52
+ closeSync(descriptor);
53
+ descriptor = undefined;
54
+ renameSync(temporaryPath, path);
55
+ try {
56
+ chmodSync(path, mode);
57
+ }
58
+ catch {
59
+ // Windows does not apply POSIX modes; ACL custody remains operator-owned.
60
+ }
61
+ }
62
+ finally {
63
+ if (descriptor !== undefined) {
64
+ closeSync(descriptor);
65
+ }
66
+ if (existsSync(temporaryPath)) {
67
+ unlinkSync(temporaryPath);
68
+ }
69
+ }
70
+ }
71
+ export function writeJsonExclusive(path, value, mode = 0o600) {
72
+ const contents = JSON.stringify(value, null, 2) + "\n";
73
+ if (Buffer.byteLength(contents, "utf8") > MAX_ARTIFACT_BYTES) {
74
+ throw new Error(`refusing to write oversized JSON artifact at ${path}`);
75
+ }
76
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
77
+ const descriptor = openSync(path, "wx", mode);
78
+ let complete = false;
79
+ try {
80
+ writeFileSync(descriptor, contents, "utf8");
81
+ fsyncSync(descriptor);
82
+ complete = true;
83
+ }
84
+ finally {
85
+ closeSync(descriptor);
86
+ if (!complete && existsSync(path)) {
87
+ unlinkSync(path);
88
+ }
89
+ }
90
+ try {
91
+ chmodSync(path, mode);
92
+ }
93
+ catch {
94
+ if (process.platform !== "win32")
95
+ throw new Error(`cannot protect ${path}`);
96
+ }
97
+ }
@@ -0,0 +1,16 @@
1
+ import { type KeyPair } from "./crypto.js";
2
+ export interface StoredKeyPair {
3
+ version: 1;
4
+ publicKeyDer: string;
5
+ protection: {
6
+ kdf: "scrypt";
7
+ cipher: "aes-256-gcm";
8
+ salt: string;
9
+ iv: string;
10
+ authTag: string;
11
+ ciphertext: string;
12
+ };
13
+ }
14
+ export declare function sealKeyPair(keypair: KeyPair, passphrase: string): StoredKeyPair;
15
+ export declare function isStoredKeyPair(value: unknown): value is StoredKeyPair;
16
+ export declare function openKeyPair(stored: unknown, passphrase: string): KeyPair;
@@ -0,0 +1,117 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, } from "node:crypto";
2
+ import { canonicalize, isCanonicalBase64, validateKeyPair } from "./crypto.js";
3
+ const SCRYPT_N = 32_768;
4
+ const SCRYPT_R = 8;
5
+ const SCRYPT_P = 1;
6
+ const SCRYPT_MAX_MEMORY = 64 * 1024 * 1024;
7
+ function assertPassphrase(passphrase) {
8
+ const length = Buffer.byteLength(passphrase, "utf8");
9
+ if (length < 16 || length > 1_024) {
10
+ throw new Error("key passphrase must contain 16-1024 UTF-8 bytes");
11
+ }
12
+ }
13
+ function deriveKey(passphrase, salt) {
14
+ return scryptSync(passphrase, salt, 32, {
15
+ N: SCRYPT_N,
16
+ r: SCRYPT_R,
17
+ p: SCRYPT_P,
18
+ maxmem: SCRYPT_MAX_MEMORY,
19
+ });
20
+ }
21
+ function additionalData(publicKeyDer) {
22
+ return Buffer.from(canonicalize({
23
+ version: 1,
24
+ publicKeyDer,
25
+ kdf: "scrypt",
26
+ cipher: "aes-256-gcm",
27
+ parameters: { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P },
28
+ }), "utf8");
29
+ }
30
+ export function sealKeyPair(keypair, passphrase) {
31
+ if (!validateKeyPair(keypair)) {
32
+ throw new Error("cannot seal an invalid Ed25519 key pair");
33
+ }
34
+ assertPassphrase(passphrase);
35
+ const salt = randomBytes(16);
36
+ const iv = randomBytes(12);
37
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(passphrase, salt), iv, { authTagLength: 16 });
38
+ cipher.setAAD(additionalData(keypair.publicKeyDer));
39
+ const ciphertext = Buffer.concat([
40
+ cipher.update(keypair.privateKeyDer, "utf8"),
41
+ cipher.final(),
42
+ ]);
43
+ return {
44
+ version: 1,
45
+ publicKeyDer: keypair.publicKeyDer,
46
+ protection: {
47
+ kdf: "scrypt",
48
+ cipher: "aes-256-gcm",
49
+ salt: salt.toString("base64"),
50
+ iv: iv.toString("base64"),
51
+ authTag: cipher.getAuthTag().toString("base64"),
52
+ ciphertext: ciphertext.toString("base64"),
53
+ },
54
+ };
55
+ }
56
+ export function isStoredKeyPair(value) {
57
+ if (!value || typeof value !== "object" || Array.isArray(value))
58
+ return false;
59
+ const candidate = value;
60
+ if (candidate.version !== 1 ||
61
+ typeof candidate.publicKeyDer !== "string" ||
62
+ !candidate.protection ||
63
+ typeof candidate.protection !== "object" ||
64
+ Array.isArray(candidate.protection)) {
65
+ return false;
66
+ }
67
+ if (Object.keys(candidate).some((field) => field !== "version" && field !== "publicKeyDer" && field !== "protection")) {
68
+ return false;
69
+ }
70
+ const protection = candidate.protection;
71
+ const allowed = new Set([
72
+ "kdf",
73
+ "cipher",
74
+ "salt",
75
+ "iv",
76
+ "authTag",
77
+ "ciphertext",
78
+ ]);
79
+ return (Object.keys(protection).every((field) => allowed.has(field)) &&
80
+ protection.kdf === "scrypt" &&
81
+ protection.cipher === "aes-256-gcm" &&
82
+ typeof protection.salt === "string" &&
83
+ protection.salt.length === 24 &&
84
+ isCanonicalBase64(protection.salt) &&
85
+ typeof protection.iv === "string" &&
86
+ protection.iv.length === 16 &&
87
+ isCanonicalBase64(protection.iv) &&
88
+ typeof protection.authTag === "string" &&
89
+ protection.authTag.length === 24 &&
90
+ isCanonicalBase64(protection.authTag) &&
91
+ typeof protection.ciphertext === "string" &&
92
+ protection.ciphertext.length <= 16_384 &&
93
+ isCanonicalBase64(protection.ciphertext));
94
+ }
95
+ export function openKeyPair(stored, passphrase) {
96
+ assertPassphrase(passphrase);
97
+ if (!isStoredKeyPair(stored)) {
98
+ throw new Error("encrypted key file is malformed or unsupported");
99
+ }
100
+ try {
101
+ const protection = stored.protection;
102
+ const decipher = createDecipheriv("aes-256-gcm", deriveKey(passphrase, Buffer.from(protection.salt, "base64")), Buffer.from(protection.iv, "base64"), { authTagLength: 16 });
103
+ decipher.setAAD(additionalData(stored.publicKeyDer));
104
+ decipher.setAuthTag(Buffer.from(protection.authTag, "base64"));
105
+ const privateKeyDer = Buffer.concat([
106
+ decipher.update(Buffer.from(protection.ciphertext, "base64")),
107
+ decipher.final(),
108
+ ]).toString("utf8");
109
+ const keypair = { publicKeyDer: stored.publicKeyDer, privateKeyDer };
110
+ if (!validateKeyPair(keypair))
111
+ throw new Error("key pair mismatch");
112
+ return keypair;
113
+ }
114
+ catch {
115
+ throw new Error("key file authentication failed");
116
+ }
117
+ }
package/dist/manifest.js CHANGED
@@ -1,21 +1,52 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { extname } from "node:path";
3
2
  import { parse as parseYaml } from "yaml";
3
+ import { canonicalize } from "./crypto.js";
4
+ import { readUtf8File } from "./io.js";
4
5
  const CAPABILITIES = ["read", "write", "destructive"];
5
6
  const RISKS = ["low", "medium", "high"];
6
- const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
7
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
8
+ const MANIFEST_FIELDS = new Set([
9
+ "serverName",
10
+ "serverVersion",
11
+ "serverUrl",
12
+ "createdAt",
13
+ "tools",
14
+ ]);
15
+ const TOOL_FIELDS = new Set([
16
+ "name",
17
+ "description",
18
+ "capability",
19
+ "risk",
20
+ "scopes",
21
+ "budgetLimit",
22
+ "inputSchema",
23
+ ]);
24
+ const MAX_TOOLS = 256;
25
+ const MAX_SCOPES = 64;
26
+ const TOOL_NAME_RE = /^[a-zA-Z0-9._-]{1,256}$/;
7
27
  function isObject(value) {
8
28
  return value !== null && typeof value === "object" && !Array.isArray(value);
9
29
  }
10
- function isNonEmptyString(value) {
11
- return typeof value === "string" && value.trim().length > 0;
30
+ function isNonEmptyString(value, maximumLength) {
31
+ return (typeof value === "string" &&
32
+ value.length <= maximumLength &&
33
+ value.trim().length > 0 &&
34
+ value.trim() === value &&
35
+ !/[\u0000-\u001f\u007f]/.test(value));
12
36
  }
13
37
  function isHttpUrl(value) {
14
38
  if (typeof value !== "string")
15
39
  return false;
16
40
  try {
17
41
  const url = new URL(value);
18
- return url.protocol === "http:" || url.protocol === "https:";
42
+ const isLoopback = url.hostname === "localhost" ||
43
+ url.hostname === "127.0.0.1" ||
44
+ url.hostname === "[::1]";
45
+ return (value.length <= 2_048 &&
46
+ (url.protocol === "https:" || (url.protocol === "http:" && isLoopback)) &&
47
+ url.username === "" &&
48
+ url.password === "" &&
49
+ url.hash === "");
19
50
  }
20
51
  catch {
21
52
  return false;
@@ -31,11 +62,16 @@ export function validateManifest(raw) {
31
62
  if (!isObject(raw)) {
32
63
  return { ok: false, errors: ["manifest must be an object"] };
33
64
  }
34
- if (!isNonEmptyString(raw.serverName)) {
35
- errors.push("serverName must be a non-empty string");
65
+ for (const field of Object.keys(raw)) {
66
+ if (!MANIFEST_FIELDS.has(field)) {
67
+ errors.push(`unexpected manifest field '${field}'`);
68
+ }
69
+ }
70
+ if (!isNonEmptyString(raw.serverName, 128)) {
71
+ errors.push("serverName must be a non-empty string of at most 128 characters");
36
72
  }
37
- if (!isNonEmptyString(raw.serverVersion)) {
38
- errors.push("serverVersion must be a non-empty string");
73
+ if (!isNonEmptyString(raw.serverVersion, 64)) {
74
+ errors.push("serverVersion must be a non-empty string of at most 64 characters");
39
75
  }
40
76
  if (!isHttpUrl(raw.serverUrl)) {
41
77
  errors.push("serverUrl must be a valid http(s) URL");
@@ -46,6 +82,9 @@ export function validateManifest(raw) {
46
82
  if (!Array.isArray(raw.tools) || raw.tools.length === 0) {
47
83
  errors.push("tools must be a non-empty array");
48
84
  }
85
+ else if (raw.tools.length > MAX_TOOLS) {
86
+ errors.push(`tools must contain at most ${String(MAX_TOOLS)} entries`);
87
+ }
49
88
  else {
50
89
  const seen = new Set();
51
90
  raw.tools.forEach((tool, index) => {
@@ -58,6 +97,13 @@ export function validateManifest(raw) {
58
97
  }
59
98
  });
60
99
  }
100
+ try {
101
+ canonicalize(raw);
102
+ }
103
+ catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ errors.push(`manifest must contain only JSON values: ${message}`);
106
+ }
61
107
  if (errors.length > 0) {
62
108
  return { ok: false, errors };
63
109
  }
@@ -69,11 +115,16 @@ function validateTool(tool, index, errors) {
69
115
  errors.push(`${path} must be an object`);
70
116
  return;
71
117
  }
72
- if (!isNonEmptyString(tool.name)) {
73
- errors.push(`${path}.name must be a non-empty string`);
118
+ for (const field of Object.keys(tool)) {
119
+ if (!TOOL_FIELDS.has(field)) {
120
+ errors.push(`unexpected ${path} field '${field}'`);
121
+ }
74
122
  }
75
- if (typeof tool.description !== "string") {
76
- errors.push(`${path}.description must be a string`);
123
+ if (typeof tool.name !== "string" || !TOOL_NAME_RE.test(tool.name)) {
124
+ errors.push(`${path}.name must contain only ASCII letters, digits, dots, underscores, and hyphens (1-256 characters)`);
125
+ }
126
+ if (typeof tool.description !== "string" || tool.description.length > 4_096) {
127
+ errors.push(`${path}.description must be a string of at most 4096 characters`);
77
128
  }
78
129
  if (!CAPABILITIES.includes(tool.capability)) {
79
130
  errors.push(`${path}.capability must be one of ${CAPABILITIES.join(", ")}`);
@@ -83,8 +134,9 @@ function validateTool(tool, index, errors) {
83
134
  }
84
135
  if (!Array.isArray(tool.scopes) ||
85
136
  tool.scopes.length === 0 ||
86
- !tool.scopes.every((scope) => isNonEmptyString(scope))) {
87
- errors.push(`${path}.scopes must be a non-empty array of non-empty strings`);
137
+ tool.scopes.length > MAX_SCOPES ||
138
+ !tool.scopes.every((scope) => isNonEmptyString(scope, 256))) {
139
+ errors.push(`${path}.scopes must contain 1-${String(MAX_SCOPES)} non-empty strings of at most 256 characters`);
88
140
  }
89
141
  if (typeof tool.budgetLimit !== "number" ||
90
142
  !Number.isSafeInteger(tool.budgetLimit) ||
@@ -96,8 +148,22 @@ function validateTool(tool, index, errors) {
96
148
  }
97
149
  }
98
150
  export function loadManifest(path) {
99
- const source = readFileSync(path, "utf8");
100
- const raw = extname(path) === ".json" ? JSON.parse(source) : parseYaml(source);
151
+ const source = readUtf8File(path);
152
+ const extension = extname(path).toLowerCase();
153
+ let raw;
154
+ if (extension === ".json") {
155
+ raw = JSON.parse(source);
156
+ }
157
+ else if (extension === ".yaml" || extension === ".yml") {
158
+ raw = parseYaml(source, {
159
+ maxAliasCount: 50,
160
+ strict: true,
161
+ uniqueKeys: true,
162
+ });
163
+ }
164
+ else {
165
+ throw new Error("manifest path must end in .json, .yaml, or .yml");
166
+ }
101
167
  const result = validateManifest(raw);
102
168
  if (!result.ok || !result.manifest) {
103
169
  throw new Error(`Invalid manifest:\n - ${result.errors.join("\n - ")}`);
package/dist/sdk.d.ts CHANGED
@@ -4,3 +4,5 @@ export * from "./manifest.js";
4
4
  export * from "./signing.js";
5
5
  export * from "./admit.js";
6
6
  export * from "./grant.js";
7
+ export * from "./trust.js";
8
+ export * from "./keystore.js";
package/dist/sdk.js CHANGED
@@ -4,3 +4,5 @@ export * from "./manifest.js";
4
4
  export * from "./signing.js";
5
5
  export * from "./admit.js";
6
6
  export * from "./grant.js";
7
+ export * from "./trust.js";
8
+ export * from "./keystore.js";
package/dist/signing.d.ts CHANGED
@@ -5,6 +5,16 @@ export interface VerifyResult {
5
5
  reasonCode: string;
6
6
  detail: string;
7
7
  }
8
+ export interface SignedManifestValidationResult {
9
+ ok: boolean;
10
+ signedManifest?: SignedManifest;
11
+ errors: string[];
12
+ }
13
+ export interface ReceiptValidationResult {
14
+ ok: boolean;
15
+ receipt?: Receipt;
16
+ errors: string[];
17
+ }
8
18
  export interface ReceiptInput {
9
19
  manifestHash: string;
10
20
  toolName: string;
@@ -14,8 +24,12 @@ export interface ReceiptInput {
14
24
  agentId?: string;
15
25
  grantReasonCode?: string;
16
26
  }
27
+ export declare function validateSignedManifest(value: unknown): SignedManifestValidationResult;
28
+ export declare function validateReceipt(value: unknown): ReceiptValidationResult;
17
29
  export declare function hashManifest(manifest: Manifest): string;
30
+ export declare function hashRequest(request: unknown): string;
18
31
  export declare function signManifest(manifest: Manifest, keypair: KeyPair): SignedManifest;
19
- export declare function verifySignedManifest(signed: SignedManifest): VerifyResult;
32
+ export declare function verifySignedManifest(value: unknown): VerifyResult;
20
33
  export declare function createReceipt(input: ReceiptInput, keypair: KeyPair): Receipt;
21
- export declare function verifyReceipt(receipt: Receipt, publicKeyDer: string): boolean;
34
+ export declare function verifyReceiptDetailed(value: unknown, publicKeyDer: string): VerifyResult;
35
+ export declare function verifyReceipt(receipt: unknown, publicKeyDer: string): boolean;