facult 1.0.1
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/LICENSE +21 -0
- package/README.md +383 -0
- package/bin/facult.cjs +302 -0
- package/package.json +78 -0
- package/src/adapters/claude-cli.ts +18 -0
- package/src/adapters/claude-desktop.ts +15 -0
- package/src/adapters/clawdbot.ts +18 -0
- package/src/adapters/codex.ts +19 -0
- package/src/adapters/cursor.ts +18 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.ts +270 -0
- package/src/adapters/reference.ts +9 -0
- package/src/adapters/skills.ts +47 -0
- package/src/adapters/types.ts +42 -0
- package/src/adapters/version.ts +18 -0
- package/src/audit/agent.ts +1071 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/static.ts +1130 -0
- package/src/audit/tui.ts +704 -0
- package/src/audit/types.ts +68 -0
- package/src/audit/update-index.ts +115 -0
- package/src/conflicts.ts +135 -0
- package/src/consolidate-conflict-action.ts +57 -0
- package/src/consolidate.ts +1637 -0
- package/src/enable-disable.ts +349 -0
- package/src/index-builder.ts +562 -0
- package/src/index.ts +589 -0
- package/src/manage.ts +894 -0
- package/src/migrate.ts +272 -0
- package/src/paths.ts +238 -0
- package/src/quarantine.ts +217 -0
- package/src/query.ts +186 -0
- package/src/remote-manifest-integrity.ts +367 -0
- package/src/remote-providers.ts +905 -0
- package/src/remote-source-policy.ts +237 -0
- package/src/remote-sources.ts +162 -0
- package/src/remote-types.ts +136 -0
- package/src/remote.ts +1970 -0
- package/src/scan.ts +2427 -0
- package/src/schema.ts +39 -0
- package/src/self-update.ts +408 -0
- package/src/snippets-cli.ts +293 -0
- package/src/snippets.ts +706 -0
- package/src/source-trust.ts +203 -0
- package/src/trust-list.ts +232 -0
- package/src/trust.ts +170 -0
- package/src/tui.ts +118 -0
- package/src/util/codex-toml.ts +126 -0
- package/src/util/json.ts +32 -0
- package/src/util/skills.ts +55 -0
package/src/query.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
AgentEntry,
|
|
5
|
+
FacultIndex,
|
|
6
|
+
McpEntry,
|
|
7
|
+
SkillEntry,
|
|
8
|
+
SnippetEntry,
|
|
9
|
+
} from "./index-builder";
|
|
10
|
+
import { facultRootDir } from "./paths";
|
|
11
|
+
import { applyOrgTrustList } from "./trust-list";
|
|
12
|
+
|
|
13
|
+
export interface QueryFilters {
|
|
14
|
+
/** Only include entries enabled for this tool name. */
|
|
15
|
+
enabledFor?: string;
|
|
16
|
+
/** Only include entries that are not trusted. */
|
|
17
|
+
untrusted?: boolean;
|
|
18
|
+
/** Only include entries flagged by audit. */
|
|
19
|
+
flagged?: boolean;
|
|
20
|
+
/** Only include entries pending audit. */
|
|
21
|
+
pending?: boolean;
|
|
22
|
+
/** Only include entries with all of these tags. */
|
|
23
|
+
tags?: string[];
|
|
24
|
+
/** Full-text search query (case-insensitive). */
|
|
25
|
+
text?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface IndexEntry {
|
|
29
|
+
name: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
enabledFor?: string[];
|
|
33
|
+
trusted?: boolean;
|
|
34
|
+
auditStatus?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const WHITESPACE_RE = /\s+/;
|
|
38
|
+
|
|
39
|
+
function normalizeText(v: string): string {
|
|
40
|
+
return v.trim().toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesEnabledFor(entry: IndexEntry, tool?: string): boolean {
|
|
44
|
+
if (!tool) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const enabledFor = entry.enabledFor;
|
|
48
|
+
if (!Array.isArray(enabledFor)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const target = normalizeText(tool);
|
|
52
|
+
return enabledFor.some((t) => normalizeText(t) === target);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesUntrusted(entry: IndexEntry, untrusted?: boolean): boolean {
|
|
56
|
+
if (!untrusted) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return entry.trusted !== true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchesFlagged(entry: IndexEntry, flagged?: boolean): boolean {
|
|
63
|
+
if (!flagged) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return normalizeText(entry.auditStatus ?? "") === "flagged";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesPending(entry: IndexEntry, pending?: boolean): boolean {
|
|
70
|
+
if (!pending) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const status = normalizeText(entry.auditStatus ?? "");
|
|
74
|
+
// Treat missing auditStatus as pending for backward compatibility.
|
|
75
|
+
return !status || status === "pending";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function matchesTags(entry: IndexEntry, tags?: string[]): boolean {
|
|
79
|
+
if (!tags || tags.length === 0) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
const entryTags = entry.tags ?? [];
|
|
83
|
+
return tags.every((tag) =>
|
|
84
|
+
entryTags.some((t) => normalizeText(t) === normalizeText(tag))
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function matchesText(entry: IndexEntry, text?: string): boolean {
|
|
89
|
+
if (!text) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const haystack = `${entry.name} ${entry.description ?? ""} ${
|
|
93
|
+
entry.tags?.join(" ") ?? ""
|
|
94
|
+
}`.toLowerCase();
|
|
95
|
+
const terms = text
|
|
96
|
+
.split(WHITESPACE_RE)
|
|
97
|
+
.map((t) => t.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
return terms.every((term) => haystack.includes(term.toLowerCase()));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Return the canonical facult root directory. */
|
|
103
|
+
export function facultRootDirPath(home: string = homedir()): string {
|
|
104
|
+
return facultRootDir(home);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Return the path to the facult index.json file.
|
|
109
|
+
*/
|
|
110
|
+
export function facultIndexPath(home: string = homedir()): string {
|
|
111
|
+
return join(facultRootDir(home), "index.json");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load the facult index.json into memory.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadIndex(opts?: {
|
|
118
|
+
/** Override the default canonical root dir (useful for tests). */
|
|
119
|
+
rootDir?: string;
|
|
120
|
+
/** Override home directory for org trust-list loading (useful for tests). */
|
|
121
|
+
homeDir?: string;
|
|
122
|
+
}): Promise<FacultIndex> {
|
|
123
|
+
const root = opts?.rootDir ?? facultRootDir();
|
|
124
|
+
const indexPath = join(root, "index.json");
|
|
125
|
+
const file = Bun.file(indexPath);
|
|
126
|
+
if (!(await file.exists())) {
|
|
127
|
+
throw new Error(`Index not found at ${indexPath}. Run "facult index".`);
|
|
128
|
+
}
|
|
129
|
+
const raw = await file.text();
|
|
130
|
+
const parsed = JSON.parse(raw) as FacultIndex;
|
|
131
|
+
return await applyOrgTrustList(parsed, { homeDir: opts?.homeDir });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Filter skill entries using query filters.
|
|
136
|
+
*/
|
|
137
|
+
export function filterSkills(
|
|
138
|
+
entries: Record<string, SkillEntry>,
|
|
139
|
+
filters?: QueryFilters
|
|
140
|
+
): SkillEntry[] {
|
|
141
|
+
return filterEntries(entries, filters);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Filter MCP server entries using query filters.
|
|
146
|
+
*/
|
|
147
|
+
export function filterMcp(
|
|
148
|
+
entries: Record<string, McpEntry>,
|
|
149
|
+
filters?: QueryFilters
|
|
150
|
+
): McpEntry[] {
|
|
151
|
+
return filterEntries(entries, filters);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Filter agent entries using query filters.
|
|
156
|
+
*/
|
|
157
|
+
export function filterAgents(
|
|
158
|
+
entries: Record<string, AgentEntry>,
|
|
159
|
+
filters?: QueryFilters
|
|
160
|
+
): AgentEntry[] {
|
|
161
|
+
return filterEntries(entries, filters);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Filter snippet entries using query filters.
|
|
166
|
+
*/
|
|
167
|
+
export function filterSnippets(
|
|
168
|
+
entries: Record<string, SnippetEntry>,
|
|
169
|
+
filters?: QueryFilters
|
|
170
|
+
): SnippetEntry[] {
|
|
171
|
+
return filterEntries(entries, filters);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function filterEntries<T extends IndexEntry>(
|
|
175
|
+
entries: Record<string, T>,
|
|
176
|
+
filters?: QueryFilters
|
|
177
|
+
): T[] {
|
|
178
|
+
return Object.values(entries)
|
|
179
|
+
.filter((entry) => matchesEnabledFor(entry, filters?.enabledFor))
|
|
180
|
+
.filter((entry) => matchesUntrusted(entry, filters?.untrusted))
|
|
181
|
+
.filter((entry) => matchesFlagged(entry, filters?.flagged))
|
|
182
|
+
.filter((entry) => matchesPending(entry, filters?.pending))
|
|
183
|
+
.filter((entry) => matchesTags(entry, filters?.tags))
|
|
184
|
+
.filter((entry) => matchesText(entry, filters?.text))
|
|
185
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
186
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
6
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
7
|
+
const SHA256_TAGGED_HEX_RE = /^sha256:([0-9a-f]{64})$/i;
|
|
8
|
+
const SHA256_TAGGED_BASE64_RE = /^sha256-([A-Za-z0-9+/]+={0,2})$/;
|
|
9
|
+
|
|
10
|
+
export interface ManifestIntegrity {
|
|
11
|
+
algorithm: "sha256";
|
|
12
|
+
encoding: "hex" | "base64";
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ManifestSignature {
|
|
17
|
+
algorithm: "ed25519";
|
|
18
|
+
value: string;
|
|
19
|
+
keyId?: string;
|
|
20
|
+
publicKey?: string;
|
|
21
|
+
publicKeyPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ManifestSignatureKeyStatus = "active" | "retired" | "revoked";
|
|
25
|
+
|
|
26
|
+
export interface ManifestSignatureKey {
|
|
27
|
+
id: string;
|
|
28
|
+
status: ManifestSignatureKeyStatus;
|
|
29
|
+
publicKey?: string;
|
|
30
|
+
publicKeyPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseManifestIntegrity(
|
|
34
|
+
raw: unknown
|
|
35
|
+
): ManifestIntegrity | undefined {
|
|
36
|
+
if (typeof raw !== "string") {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const trimmed = raw.trim();
|
|
40
|
+
if (!trimmed) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const taggedHex = trimmed.match(SHA256_TAGGED_HEX_RE)?.[1];
|
|
45
|
+
if (taggedHex) {
|
|
46
|
+
return {
|
|
47
|
+
algorithm: "sha256",
|
|
48
|
+
encoding: "hex",
|
|
49
|
+
value: taggedHex.toLowerCase(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const taggedBase64 = trimmed.match(SHA256_TAGGED_BASE64_RE)?.[1];
|
|
54
|
+
if (taggedBase64) {
|
|
55
|
+
return {
|
|
56
|
+
algorithm: "sha256",
|
|
57
|
+
encoding: "base64",
|
|
58
|
+
value: taggedBase64,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (SHA256_HEX_RE.test(trimmed)) {
|
|
63
|
+
return {
|
|
64
|
+
algorithm: "sha256",
|
|
65
|
+
encoding: "hex",
|
|
66
|
+
value: trimmed.toLowerCase(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
74
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseManifestSignature(
|
|
78
|
+
raw: unknown
|
|
79
|
+
): ManifestSignature | undefined {
|
|
80
|
+
if (typeof raw === "string") {
|
|
81
|
+
const value = raw.trim();
|
|
82
|
+
if (!value) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
algorithm: "ed25519",
|
|
87
|
+
value,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!isPlainObject(raw)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const obj = raw as Record<string, unknown>;
|
|
95
|
+
const algorithm =
|
|
96
|
+
typeof obj.algorithm === "string" ? obj.algorithm.trim() : "ed25519";
|
|
97
|
+
if (algorithm !== "ed25519") {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const valueRaw =
|
|
101
|
+
typeof obj.value === "string"
|
|
102
|
+
? obj.value
|
|
103
|
+
: typeof obj.signature === "string"
|
|
104
|
+
? obj.signature
|
|
105
|
+
: typeof obj.sig === "string"
|
|
106
|
+
? obj.sig
|
|
107
|
+
: "";
|
|
108
|
+
const value = valueRaw.trim();
|
|
109
|
+
if (!value) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const publicKey =
|
|
114
|
+
typeof obj.publicKey === "string"
|
|
115
|
+
? obj.publicKey
|
|
116
|
+
: typeof obj.publicKeyPem === "string"
|
|
117
|
+
? obj.publicKeyPem
|
|
118
|
+
: undefined;
|
|
119
|
+
const keyId =
|
|
120
|
+
typeof obj.keyId === "string"
|
|
121
|
+
? obj.keyId
|
|
122
|
+
: typeof obj.kid === "string"
|
|
123
|
+
? obj.kid
|
|
124
|
+
: undefined;
|
|
125
|
+
const publicKeyPath =
|
|
126
|
+
typeof obj.publicKeyPath === "string"
|
|
127
|
+
? obj.publicKeyPath
|
|
128
|
+
: typeof obj.keyPath === "string"
|
|
129
|
+
? obj.keyPath
|
|
130
|
+
: undefined;
|
|
131
|
+
return {
|
|
132
|
+
algorithm: "ed25519",
|
|
133
|
+
value,
|
|
134
|
+
keyId: keyId?.trim() || undefined,
|
|
135
|
+
publicKey: publicKey?.trim() || undefined,
|
|
136
|
+
publicKeyPath: publicKeyPath?.trim() || undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function parseKeyStatus(raw: unknown): ManifestSignatureKeyStatus {
|
|
141
|
+
if (raw === "revoked") {
|
|
142
|
+
return "revoked";
|
|
143
|
+
}
|
|
144
|
+
if (raw === "retired") {
|
|
145
|
+
return "retired";
|
|
146
|
+
}
|
|
147
|
+
return "active";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parseManifestSignatureKeys(
|
|
151
|
+
raw: unknown
|
|
152
|
+
): ManifestSignatureKey[] {
|
|
153
|
+
if (!Array.isArray(raw)) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const out = new Map<string, ManifestSignatureKey>();
|
|
158
|
+
for (const entry of raw) {
|
|
159
|
+
if (!isPlainObject(entry)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const obj = entry as Record<string, unknown>;
|
|
163
|
+
const id =
|
|
164
|
+
typeof obj.id === "string"
|
|
165
|
+
? obj.id.trim()
|
|
166
|
+
: typeof obj.keyId === "string"
|
|
167
|
+
? obj.keyId.trim()
|
|
168
|
+
: typeof obj.kid === "string"
|
|
169
|
+
? obj.kid.trim()
|
|
170
|
+
: "";
|
|
171
|
+
if (!id) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const publicKey =
|
|
176
|
+
typeof obj.publicKey === "string"
|
|
177
|
+
? obj.publicKey
|
|
178
|
+
: typeof obj.publicKeyPem === "string"
|
|
179
|
+
? obj.publicKeyPem
|
|
180
|
+
: undefined;
|
|
181
|
+
const publicKeyPath =
|
|
182
|
+
typeof obj.publicKeyPath === "string"
|
|
183
|
+
? obj.publicKeyPath
|
|
184
|
+
: typeof obj.keyPath === "string"
|
|
185
|
+
? obj.keyPath
|
|
186
|
+
: undefined;
|
|
187
|
+
out.set(id, {
|
|
188
|
+
id,
|
|
189
|
+
status: parseKeyStatus(obj.status),
|
|
190
|
+
publicKey: publicKey?.trim() || undefined,
|
|
191
|
+
publicKeyPath: publicKeyPath?.trim() || undefined,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return Array.from(out.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sha256Hex(input: string): string {
|
|
198
|
+
if (typeof Bun !== "undefined" && "CryptoHasher" in Bun) {
|
|
199
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
200
|
+
hasher.update(input);
|
|
201
|
+
return hasher.digest("hex");
|
|
202
|
+
}
|
|
203
|
+
return createHash("sha256").update(input).digest("hex");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toBase64(hex: string): string {
|
|
207
|
+
return Buffer.from(hex, "hex").toString("base64");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function decodeBase64(raw: string): Buffer {
|
|
211
|
+
const trimmed = raw.trim();
|
|
212
|
+
if (!(trimmed && BASE64_RE.test(trimmed))) {
|
|
213
|
+
throw new Error("Invalid base64 payload.");
|
|
214
|
+
}
|
|
215
|
+
return Buffer.from(trimmed, "base64");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function resolvePath(rawPath: string, cwd: string, homeDir: string): string {
|
|
219
|
+
if (rawPath.startsWith("~/")) {
|
|
220
|
+
return join(homeDir, rawPath.slice(2));
|
|
221
|
+
}
|
|
222
|
+
if (isAbsolute(rawPath)) {
|
|
223
|
+
return rawPath;
|
|
224
|
+
}
|
|
225
|
+
return resolve(cwd, rawPath);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function loadSignaturePublicKeyPem(args: {
|
|
229
|
+
signature: ManifestSignature;
|
|
230
|
+
signatureKeys?: ManifestSignatureKey[];
|
|
231
|
+
cwd: string;
|
|
232
|
+
homeDir: string;
|
|
233
|
+
}): Promise<string> {
|
|
234
|
+
const keyId = args.signature.keyId?.trim();
|
|
235
|
+
const keySet = args.signatureKeys ?? [];
|
|
236
|
+
if (keyId) {
|
|
237
|
+
const key = keySet.find((candidate) => candidate.id === keyId);
|
|
238
|
+
if (!key) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Manifest signature keyId "${keyId}" was not found in configured keys.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (key.status === "revoked") {
|
|
244
|
+
throw new Error(`Manifest signature keyId "${keyId}" is revoked.`);
|
|
245
|
+
}
|
|
246
|
+
if (key.publicKey?.trim()) {
|
|
247
|
+
return key.publicKey;
|
|
248
|
+
}
|
|
249
|
+
if (key.publicKeyPath?.trim()) {
|
|
250
|
+
const resolved = resolvePath(key.publicKeyPath, args.cwd, args.homeDir);
|
|
251
|
+
return (await readFile(resolved, "utf8")).trim();
|
|
252
|
+
}
|
|
253
|
+
if (args.signature.publicKey?.trim()) {
|
|
254
|
+
return args.signature.publicKey;
|
|
255
|
+
}
|
|
256
|
+
const path = args.signature.publicKeyPath?.trim();
|
|
257
|
+
if (path) {
|
|
258
|
+
const resolved = resolvePath(path, args.cwd, args.homeDir);
|
|
259
|
+
return (await readFile(resolved, "utf8")).trim();
|
|
260
|
+
}
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Manifest signature keyId "${keyId}" does not provide key material.`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (args.signature.publicKey?.trim()) {
|
|
267
|
+
return args.signature.publicKey;
|
|
268
|
+
}
|
|
269
|
+
const path = args.signature.publicKeyPath?.trim();
|
|
270
|
+
if (!path) {
|
|
271
|
+
const candidates = keySet.filter(
|
|
272
|
+
(key) =>
|
|
273
|
+
key.status !== "revoked" &&
|
|
274
|
+
Boolean(key.publicKey?.trim() || key.publicKeyPath?.trim())
|
|
275
|
+
);
|
|
276
|
+
if (candidates.length === 1 && candidates[0]) {
|
|
277
|
+
const selected = candidates[0];
|
|
278
|
+
if (selected.publicKey?.trim()) {
|
|
279
|
+
return selected.publicKey;
|
|
280
|
+
}
|
|
281
|
+
const selectedPath = selected.publicKeyPath?.trim();
|
|
282
|
+
if (!selectedPath) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Manifest signature key "${selected.id}" does not provide key material.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const resolved = resolvePath(selectedPath, args.cwd, args.homeDir);
|
|
288
|
+
return (await readFile(resolved, "utf8")).trim();
|
|
289
|
+
}
|
|
290
|
+
if (candidates.length > 1) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"Manifest signature matches multiple configured keys; set signature.keyId."
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
throw new Error(
|
|
296
|
+
"Manifest signature requires publicKey/publicKeyPath or configured signature keys."
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const resolved = resolvePath(path, args.cwd, args.homeDir);
|
|
300
|
+
return (await readFile(resolved, "utf8")).trim();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function publicKeyObjectFromInput(publicKeyPem: string) {
|
|
304
|
+
const trimmed = publicKeyPem.trim();
|
|
305
|
+
if (trimmed.includes("BEGIN PUBLIC KEY")) {
|
|
306
|
+
return createPublicKey(trimmed);
|
|
307
|
+
}
|
|
308
|
+
const der = decodeBase64(trimmed);
|
|
309
|
+
return createPublicKey({
|
|
310
|
+
key: der,
|
|
311
|
+
format: "der",
|
|
312
|
+
type: "spki",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function assertManifestIntegrity(args: {
|
|
317
|
+
sourceName: string;
|
|
318
|
+
sourceUrl: string;
|
|
319
|
+
integrity: ManifestIntegrity;
|
|
320
|
+
manifestText: string;
|
|
321
|
+
}): void {
|
|
322
|
+
const digestHex = sha256Hex(args.manifestText);
|
|
323
|
+
const digestBase64 = toBase64(digestHex);
|
|
324
|
+
const matched =
|
|
325
|
+
args.integrity.encoding === "hex"
|
|
326
|
+
? digestHex.toLowerCase() === args.integrity.value.toLowerCase()
|
|
327
|
+
: digestBase64 === args.integrity.value;
|
|
328
|
+
|
|
329
|
+
if (matched) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Manifest integrity check failed for source "${args.sourceName}" (${args.sourceUrl}).`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function assertManifestSignature(args: {
|
|
339
|
+
sourceName: string;
|
|
340
|
+
sourceUrl: string;
|
|
341
|
+
signature: ManifestSignature;
|
|
342
|
+
signatureKeys?: ManifestSignatureKey[];
|
|
343
|
+
manifestText: string;
|
|
344
|
+
cwd: string;
|
|
345
|
+
homeDir: string;
|
|
346
|
+
}): Promise<void> {
|
|
347
|
+
const publicKeyPem = await loadSignaturePublicKeyPem({
|
|
348
|
+
signature: args.signature,
|
|
349
|
+
signatureKeys: args.signatureKeys,
|
|
350
|
+
cwd: args.cwd,
|
|
351
|
+
homeDir: args.homeDir,
|
|
352
|
+
});
|
|
353
|
+
const publicKey = publicKeyObjectFromInput(publicKeyPem);
|
|
354
|
+
const signatureBytes = decodeBase64(args.signature.value);
|
|
355
|
+
const verified = verify(
|
|
356
|
+
null,
|
|
357
|
+
Buffer.from(args.manifestText),
|
|
358
|
+
publicKey,
|
|
359
|
+
signatureBytes
|
|
360
|
+
);
|
|
361
|
+
if (verified) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Manifest signature check failed for source "${args.sourceName}" (${args.sourceUrl}).`
|
|
366
|
+
);
|
|
367
|
+
}
|